diff --git a/lib/Db/SchemaMapper.php b/lib/Db/SchemaMapper.php index 6890cdd980..63104de3a3 100644 --- a/lib/Db/SchemaMapper.php +++ b/lib/Db/SchemaMapper.php @@ -1491,10 +1491,9 @@ private function determineFacetTypeFromProperty(array $property): string * Resolve schema composition by merging referenced schemas * * This method implements JSON Schema composition patterns conforming to the specification: - * 1. Handles 'extend' (deprecated) for backward compatibility - * 2. Handles 'allOf' - instance must validate against ALL schemas (multiple inheritance) - * 3. Handles 'oneOf' - instance must validate against EXACTLY ONE schema - * 4. Handles 'anyOf' - instance must validate against AT LEAST ONE schema + * 1. Handles 'allOf' - instance must validate against ALL schemas (multiple inheritance) + * 2. Handles 'oneOf' - instance must validate against EXACTLY ONE schema + * 3. Handles 'anyOf' - instance must validate against AT LEAST ONE schema * * The method enforces the Liskov Substitution Principle: * - Extended schemas can ONLY ADD constraints, never relax them diff --git a/package-lock.json b/package-lock.json index dd8e6358b5..1b5b97cf38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "EUPL-1.2", "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "0.1.0-beta.14", + "@conduction/nextcloud-vue": "0.1.0-beta.16", "@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2", "@nextcloud/axios": "^2.5.0", @@ -2073,9 +2073,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "0.1.0-beta.14", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-0.1.0-beta.14.tgz", - "integrity": "sha512-1awjxbMrwJI7xJ+CByHcENXexlYEGdNrRivfKGAhYCP5rT4mug94T5m23IPg9mDx7NcA/rucCl0pRlzQlIxjRQ==", + "version": "0.1.0-beta.16", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-0.1.0-beta.16.tgz", + "integrity": "sha512-up8FTT607hA0KlEUhXjXkBEp7QWm5HNiXIvd8V4vKzx3vwk+07/K/Htw5ClpzCmE+Hti3uzf2mNveiB68NeeZg==", "license": "EUPL-1.2", "dependencies": { "@codemirror/lang-html": "^6.4.11", @@ -2087,7 +2087,8 @@ "gridstack": "^10.3.1", "lodash": "^4.17.21", "vue-apexcharts": "^1.7.0", - "vue-codemirror6": "^1.4.3" + "vue-codemirror6": "^1.4.3", + "vue-color": "^2.8.2" }, "peerDependencies": { "@nextcloud/axios": "^2.0.0", @@ -31002,9 +31003,10 @@ } }, "node_modules/vue-color": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/vue-color/-/vue-color-2.8.1.tgz", - "integrity": "sha512-BoLCEHisXi2QgwlhZBg9UepvzZZmi4176vbr+31Shen5WWZwSLVgdScEPcB+yrAtuHAz42309C0A4+WiL9lNBw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/vue-color/-/vue-color-2.8.2.tgz", + "integrity": "sha512-1qmsxl5GiIjx/jApBbTGr2r4bN/7WRKUTl3tc53vkXb9Ua0rZmiqsdq6VdG1e7dVNTLJahdsRGWcjeU2+98+NA==", + "license": "MIT", "dependencies": { "clamp": "^1.0.1", "lodash.throttle": "^4.0.0", diff --git a/package.json b/package.json index 3b3c743f7a..aa7fac5c65 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@codemirror/lang-json": "^6.0.1", - "@conduction/nextcloud-vue": "0.1.0-beta.14", + "@conduction/nextcloud-vue": "0.1.0-beta.16", "@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2", "@nextcloud/axios": "^2.5.0", diff --git a/src/entities/schema/schema.ts b/src/entities/schema/schema.ts index 04dfff0d4a..9c2d7d3b6f 100644 --- a/src/entities/schema/schema.ts +++ b/src/entities/schema/schema.ts @@ -29,6 +29,9 @@ export class Schema implements TSchema { public hardValidation: boolean public maxDepth: number public authorization?: Record + public allOf?: string[] + public oneOf?: string[] + public anyOf?: string[] public stats?: TSchema['stats'] constructor(schema: TSchema) { @@ -58,6 +61,9 @@ export class Schema implements TSchema { this.hardValidation = schema.hardValidation || false this.maxDepth = schema.maxDepth || 0 this.authorization = schema.authorization || {} + this.allOf = schema.allOf + this.oneOf = schema.oneOf + this.anyOf = schema.anyOf this.stats = schema.stats } diff --git a/src/entities/schema/schema.types.ts b/src/entities/schema/schema.types.ts index 3f48d0f3c1..87204e8362 100644 --- a/src/entities/schema/schema.types.ts +++ b/src/entities/schema/schema.types.ts @@ -24,7 +24,9 @@ export type TSchema = { hardValidation: boolean; // Whether hard validation is enabled maxDepth: number; // Maximum depth of the schema authorization?: Record; // RBAC authorization configuration - extend?: string; // ID or UUID of the parent schema that this schema extends + allOf?: string[]; // Schema refs (id, UUID, or slug) that this schema must validate against (inheritance/extension via JSON Schema allOf) + oneOf?: string[]; // Schema refs where instance must validate against EXACTLY ONE (mutually exclusive options) + anyOf?: string[]; // Schema refs where instance must validate against AT LEAST ONE (flexible composition) stats?: { objects: { total: number diff --git a/src/modals/object/ViewObject.vue b/src/modals/object/ViewObject.vue index 941f214050..68a0600d5e 100644 --- a/src/modals/object/ViewObject.vue +++ b/src/modals/object/ViewObject.vue @@ -818,7 +818,23 @@ export default { return registerStore.registerItem }, currentSchema() { - return schemaStore.schemaItem + const schema = schemaStore.schemaItem + if (!schema) return schema + const allOf = schema.allOf || [] + if (!allOf.length) return schema + // Merge inherited properties from allOf parent schemas so extended schemas + // expose the full property set (own + inherited) in the form dialog. + const inherited = {} + for (const ref of allOf) { + const schemaId = typeof ref === 'object' ? ref.id : ref + const parentSchema = schemaStore.schemaList.find(s => + s.id === schemaId || s.uuid === schemaId || String(s.id) === String(schemaId), + ) + if (parentSchema?.properties) { + Object.assign(inherited, parentSchema.properties) + } + } + return { ...schema, properties: { ...inherited, ...(schema.properties || {}) } } }, selectedPublishedCount() { return this.selectedAttachments.filter((a) => { @@ -1027,8 +1043,29 @@ export default { }, // Watch for schema changes to re-initialize data currentSchema: { - handler(newSchema) { + async handler(newSchema) { console.info('Schema changed in ViewObject:', newSchema) + + // The schema list endpoint returns un-resolved schemas — for schemas + // using composition (allOf/oneOf/anyOf) `properties` is empty until + // the detail endpoint merges in the parent's properties. When the + // active schema looks un-resolved, refetch via the detail endpoint + // and let the watcher fire again with merged properties. + if (newSchema?.id) { + const usesComposition = (newSchema.allOf?.length || 0) > 0 + || (newSchema.oneOf?.length || 0) > 0 + || (newSchema.anyOf?.length || 0) > 0 + const propsCount = Object.keys(newSchema.properties || {}).length + if (usesComposition && propsCount === 0) { + try { + await schemaStore.getSchema(newSchema.id, { setItem: true }) + return + } catch (error) { + console.warn('Failed to fetch resolved schema:', error) + } + } + } + if (newSchema && this.isNewObject) { // Re-initialize data when schema becomes available for new objects this.initializeData() @@ -1065,7 +1102,7 @@ export default { deep: true, }, }, - mounted() { + async mounted() { // Debug: Log current state when modal opens console.info('ViewObject mounted:', { objectItem: objectStore.objectItem, @@ -1074,6 +1111,18 @@ export default { isNewObject: this.isNewObject, }) + // Refetch the active schema by id so the store holds the resolved version + // (with allOf/oneOf/anyOf composition merged in by the backend). The schema + // list endpoint returns raw schemas with empty properties for extended + // schemas — the detail endpoint resolves composition. + if (schemaStore.schemaItem?.id) { + try { + await schemaStore.getSchema(schemaStore.schemaItem.id, { setItem: true }) + } catch (error) { + console.warn('Failed to fetch resolved schema:', error) + } + } + // Initialize data when modal opens this.initializeData() this.loadTitles() diff --git a/src/modals/schema/EditSchema.vue b/src/modals/schema/EditSchema.vue index de4d0a972e..fd1c451bad 100644 --- a/src/modals/schema/EditSchema.vue +++ b/src/modals/schema/EditSchema.vue @@ -10,6 +10,7 @@ import { schemaStore, navigationStore, registerStore } from '../../store/store.j :dialog-title="schemaStore.schemaItem?.id ? t('openregister', 'Edit Schema') : t('openregister', 'Add Schema')" :available-schemas="computedAvailableSchemas" :available-registers="computedAvailableRegisters" + :inherited-properties="computedInheritedProperties" :user-groups="userGroups" :loading-groups="loadingGroups" :available-tags="availableTags" @@ -86,6 +87,22 @@ export default { label: register.title || register.name || register.id, })) }, + computedInheritedProperties() { + const allOf = schemaStore.schemaItem?.allOf || [] + if (!allOf.length) return {} + + const merged = {} + for (const ref of allOf) { + const schemaId = typeof ref === 'object' ? ref.id : ref + const parentSchema = schemaStore.schemaList.find(s => + s.id === schemaId || s.uuid === schemaId || s.slug === schemaId, + ) + if (parentSchema?.properties) { + Object.assign(merged, parentSchema.properties) + } + } + return merged + }, }, mounted() { this.loadRegistersAndSchemas() @@ -176,7 +193,7 @@ export default { const newSchema = { title: `Extended ${currentItem.title}`, description: `Schema extending ${currentItem.title}`, - extend: currentItem.id, + allOf: [currentItem.id], properties: {}, required: [], } diff --git a/src/sidebars/search/SearchSideBar.vue b/src/sidebars/search/SearchSideBar.vue index e0d1e68af9..7721af5883 100644 --- a/src/sidebars/search/SearchSideBar.vue +++ b/src/sidebars/search/SearchSideBar.vue @@ -632,7 +632,7 @@ export default { return { id: schema.id, title: schema.title || schema.name || `Schema ${schema.id}`, - properties: schema.properties, + properties: this.resolveInheritedProperties(schema), } }) .filter(Boolean) // Remove null entries @@ -996,6 +996,27 @@ export default { this.updateRouteQueryFromState() }, + /** + * Merge inherited properties from allOf parent schemas into the given schema's own properties. + * Own properties take precedence over inherited ones. + * @param {object} schema - Schema object with optional allOf and properties + * @return {object} Merged properties object + */ + resolveInheritedProperties(schema) { + const allOf = schema?.allOf || [] + const inherited = {} + for (const ref of allOf) { + const schemaId = typeof ref === 'object' ? ref.id : ref + const parentSchema = schemaStore.schemaList.find(s => + s.id === schemaId || s.uuid === schemaId || String(s.id) === String(schemaId), + ) + if (parentSchema?.properties) { + Object.assign(inherited, parentSchema.properties) + } + } + return { ...inherited, ...(schema?.properties || {}) } + }, + /** Load facet options for type 'search' via _facets=extend; facet data then comes from objectStore.searchFacets. */ async discoverFacets() { if (!objectStore.searchParams.register || !objectStore.searchParams.schema) return @@ -1004,8 +1025,9 @@ export default { this.facetableFields = null await objectStore.refetchSearchCollection({ _facets: 'extend', _limit: 0 }) this.facetData = objectStore.searchFacets || null - this.facetableFields = objectStore.searchSchema?.properties - ? { object_fields: objectStore.searchSchema.properties } + const searchSchema = objectStore.searchSchema + this.facetableFields = searchSchema?.properties + ? { object_fields: this.resolveInheritedProperties(searchSchema) } : {} } catch (error) { console.error('Error loading complete faceting data:', error) diff --git a/src/store/modules/schema.js b/src/store/modules/schema.js index 1c4961180e..d590aac3a0 100644 --- a/src/store/modules/schema.js +++ b/src/store/modules/schema.js @@ -275,6 +275,12 @@ export const useSchemaStore = defineStore('schema', { delete cleaned.archive delete cleaned.version // Backend determines version + // New schemas have id: '' — omit it entirely so the backend unambiguously + // treats the request as a create rather than an update with an empty id. + if (!cleaned.id) { + delete cleaned.id + } + // Keep configuration object intact - backend should handle it // Ensure configuration object exists with default values if not present if (!cleaned.configuration) { diff --git a/src/views/search/SearchIndex.vue b/src/views/search/SearchIndex.vue index 749ea7e045..b252c0e8e5 100644 --- a/src/views/search/SearchIndex.vue +++ b/src/views/search/SearchIndex.vue @@ -72,8 +72,23 @@ export default { normalizedSchema() { const schema = objectStore.searchSchema if (!schema || !schema.properties) return schema + // Merge inherited properties from allOf parent schemas so extended schemas + // expose the full property set (own + inherited) for columns and form fields. + const allOf = schema.allOf || [] + const inheritedProperties = {} + for (const ref of allOf) { + const schemaId = typeof ref === 'object' ? ref.id : ref + const parentSchema = schemaStore.schemaList.find(s => + s.id === schemaId || s.uuid === schemaId || String(s.id) === String(schemaId), + ) + if (parentSchema?.properties) { + Object.assign(inheritedProperties, parentSchema.properties) + } + } + // Own properties take precedence over inherited; normalize order values + const rawProperties = { ...inheritedProperties, ...schema.properties } const properties = {} - for (const [key, prop] of Object.entries(schema.properties)) { + for (const [key, prop] of Object.entries(rawProperties)) { properties[key] = prop.order !== undefined ? { ...prop, order: Number(prop.order) } : prop