@conduction/nextcloud-vue 0.1.0-beta.14 → 0.1.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/nextcloud-vue.cjs.js +7282 -3443
  2. package/dist/nextcloud-vue.cjs.js.map +1 -1
  3. package/dist/nextcloud-vue.css +719 -100
  4. package/dist/nextcloud-vue.esm.js +7120 -3300
  5. package/dist/nextcloud-vue.esm.js.map +1 -1
  6. package/package.json +3 -2
  7. package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +36 -3
  8. package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +34 -19
  9. package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +312 -36
  10. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +983 -64
  11. package/src/components/CnAdvancedFormDialog/index.js +3 -0
  12. package/src/components/CnAppLoading/CnAppLoading.vue +93 -0
  13. package/src/components/CnAppLoading/index.js +3 -0
  14. package/src/components/CnAppNav/CnAppNav.vue +269 -0
  15. package/src/components/CnAppNav/index.js +3 -0
  16. package/src/components/CnAppRoot/CnAppRoot.vue +201 -0
  17. package/src/components/CnAppRoot/index.js +3 -0
  18. package/src/components/CnColorPicker/CnColorPicker.vue +251 -0
  19. package/src/components/CnColorPicker/index.js +1 -0
  20. package/src/components/CnContextMenu/CnContextMenu.vue +41 -4
  21. package/src/components/CnDashboardPage/CnDashboardPage.vue +8 -0
  22. package/src/components/CnDependencyMissing/CnDependencyMissing.vue +152 -0
  23. package/src/components/CnDependencyMissing/index.js +3 -0
  24. package/src/components/CnDetailPage/CnDetailPage.vue +27 -16
  25. package/src/components/CnIndexPage/CnIndexPage.vue +36 -6
  26. package/src/components/CnPageRenderer/CnPageRenderer.vue +278 -0
  27. package/src/components/CnPageRenderer/index.js +4 -0
  28. package/src/components/CnPageRenderer/pageTypes.js +37 -0
  29. package/src/components/CnRowActions/CnRowActions.vue +44 -3
  30. package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +4 -0
  31. package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +103 -74
  32. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +30 -2
  33. package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +16 -12
  34. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +9 -4
  35. package/src/components/index.js +7 -1
  36. package/src/composables/index.js +2 -0
  37. package/src/composables/useAppManifest.js +115 -0
  38. package/src/composables/useAppStatus.js +107 -0
  39. package/src/css/CnSchemaFormDialog.css +22 -0
  40. package/src/index.js +24 -2
  41. package/src/schemas/app-manifest.schema.json +153 -0
  42. package/src/types/index.d.ts +9 -0
  43. package/src/types/manifest.d.ts +88 -0
  44. package/src/utils/index.js +1 -1
  45. package/src/utils/schema.js +157 -2
  46. package/src/utils/validateManifest.js +113 -0
@@ -0,0 +1,278 @@
1
+ <!--
2
+ CnPageRenderer — JSON-driven page dispatcher.
3
+
4
+ Mounted inside <router-view>, CnPageRenderer reads the manifest, finds
5
+ the page definition whose `id` matches the current route name, and
6
+ renders the appropriate page component by dispatching on `type`.
7
+
8
+ Page types are resolved via the `pageTypes` registry. The library
9
+ ships a built-in registry (`defaultPageTypes` — index, detail,
10
+ dashboard) and consumers can extend it by passing a merged map to
11
+ CnAppRoot or CnPageRenderer. The `custom` type is special: it
12
+ resolves `page.component` against the customComponents registry
13
+ rather than the page-types map. Adding a new built-in page type to
14
+ the library is one line in `pageTypes.js` — no change here.
15
+
16
+ Each entry in `pageTypes` is wrapped in `defineAsyncComponent` so
17
+ apps using only a subset of types do not pay the bundle cost for
18
+ others (notably the GridStack-backed dashboard).
19
+
20
+ Manifest, customComponents, pageTypes, and translate are injected
21
+ from CnAppRoot by default; each can also be passed as props for
22
+ standalone use without CnAppRoot. Props always take precedence
23
+ over inject.
24
+
25
+ See REQ-JMR-005 of the json-manifest-renderer specification.
26
+ -->
27
+ <template>
28
+ <div
29
+ v-if="currentPage"
30
+ :data-page-id="currentPage.id"
31
+ class="cn-page-renderer">
32
+ <component
33
+ :is="resolvedComponent"
34
+ v-if="resolvedComponent"
35
+ v-bind="resolvedProps">
36
+ <template
37
+ v-for="entry in resolvedSlotEntries"
38
+ #[entry.name]="slotProps">
39
+ <component
40
+ :is="entry.component"
41
+ :key="entry.name"
42
+ v-bind="slotProps" />
43
+ </template>
44
+ </component>
45
+ </div>
46
+ </template>
47
+
48
+ <script>
49
+ import { defaultPageTypes } from './pageTypes.js'
50
+
51
+ export default {
52
+ name: 'CnPageRenderer',
53
+
54
+ inject: {
55
+ cnManifest: { default: null },
56
+ cnCustomComponents: { default: () => ({}) },
57
+ cnTranslate: { default: () => (key) => key },
58
+ cnPageTypes: { default: null },
59
+ },
60
+
61
+ props: {
62
+ /**
63
+ * Manifest object. When omitted, falls back to the injected
64
+ * `cnManifest` from a CnAppRoot ancestor. Provide explicitly when
65
+ * mounting CnPageRenderer outside of CnAppRoot.
66
+ *
67
+ * @type {object|null}
68
+ */
69
+ manifest: {
70
+ type: Object,
71
+ default: null,
72
+ },
73
+ /**
74
+ * Custom-component registry. Keys are the names referenced by
75
+ * `page.component` (for `type: "custom"` pages). When omitted,
76
+ * falls back to the injected `cnCustomComponents`.
77
+ *
78
+ * @type {object|null}
79
+ */
80
+ customComponents: {
81
+ type: Object,
82
+ default: null,
83
+ },
84
+ /**
85
+ * Translate function. When omitted, falls back to the injected
86
+ * `cnTranslate`. Currently not used directly by the renderer
87
+ * itself — exposed as a prop for symmetry and so future page
88
+ * components rendered by the renderer can `inject('cnTranslate')`
89
+ * via the consumer's setup.
90
+ *
91
+ * @type {Function|null}
92
+ */
93
+ translate: {
94
+ type: Function,
95
+ default: null,
96
+ },
97
+ /**
98
+ * Page-type registry. Map of `pages[].type` value → Vue
99
+ * component to mount. Consumers extend the library defaults by
100
+ * spreading them: `{ ...defaultPageTypes, report: MyReportPage }`.
101
+ *
102
+ * Falls back to the injected `cnPageTypes` and finally to the
103
+ * library's `defaultPageTypes`. The special `custom` type is
104
+ * NOT looked up here — it resolves through the customComponents
105
+ * registry instead.
106
+ *
107
+ * @type {object|null}
108
+ */
109
+ pageTypes: {
110
+ type: Object,
111
+ default: null,
112
+ },
113
+ },
114
+
115
+ computed: {
116
+ /** Effective manifest: explicit prop wins over injected value. */
117
+ effectiveManifest() {
118
+ return this.manifest ?? this.cnManifest
119
+ },
120
+ /** Effective custom-component registry. */
121
+ effectiveCustomComponents() {
122
+ return this.customComponents ?? this.cnCustomComponents ?? {}
123
+ },
124
+ /**
125
+ * Effective page-type registry. Prop wins over inject; both
126
+ * fall back to the library's `defaultPageTypes`. Apps that want
127
+ * the library defaults plus extras typically construct the prop
128
+ * value as `{ ...defaultPageTypes, ...myExtras }`.
129
+ */
130
+ effectivePageTypes() {
131
+ return this.pageTypes ?? this.cnPageTypes ?? defaultPageTypes
132
+ },
133
+ /** Page definition matching the current route name, or null. */
134
+ currentPage() {
135
+ const manifest = this.effectiveManifest
136
+ if (!manifest || !Array.isArray(manifest.pages)) {
137
+ return null
138
+ }
139
+ const routeName = this.$route?.name
140
+ if (!routeName) {
141
+ return null
142
+ }
143
+ return manifest.pages.find((page) => page.id === routeName) ?? null
144
+ },
145
+ /**
146
+ * Component to render for the current page. Looked up in
147
+ * `effectivePageTypes` for built-in / library / consumer-extended
148
+ * types; resolved against `effectiveCustomComponents` for
149
+ * `type: "custom"` pages.
150
+ *
151
+ * Async loading is the responsibility of whoever populated the
152
+ * `pageTypes` map (the library wraps each entry in
153
+ * `defineAsyncComponent`); the renderer treats any value in the
154
+ * map as a Vue component.
155
+ */
156
+ resolvedComponent() {
157
+ const page = this.currentPage
158
+ if (!page) {
159
+ return null
160
+ }
161
+ if (page.type === 'custom') {
162
+ const name = page.component
163
+ const resolved = this.effectiveCustomComponents[name]
164
+ if (!resolved) {
165
+ // eslint-disable-next-line no-console
166
+ console.warn(
167
+ `[CnPageRenderer] Custom component "${name}" not found in registry for page id "${page.id}".`,
168
+ )
169
+ return null
170
+ }
171
+ return resolved
172
+ }
173
+ const component = this.effectivePageTypes[page.type]
174
+ if (!component) {
175
+ // eslint-disable-next-line no-console
176
+ console.warn(
177
+ `[CnPageRenderer] Unknown page type "${page.type}" for page id "${page.id}". Add it to the pageTypes registry (e.g. via the pageTypes prop on CnAppRoot or CnPageRenderer).`,
178
+ )
179
+ return null
180
+ }
181
+ return component
182
+ },
183
+ /**
184
+ * Props forwarded to the dispatched page component. Spreads the
185
+ * page's `config` object so manifest authors can supply whatever
186
+ * shape the target page expects. Intentionally generic — per-type
187
+ * prop validation lives on the target components themselves.
188
+ */
189
+ resolvedProps() {
190
+ return this.currentPage?.config ?? {}
191
+ },
192
+ /**
193
+ * Combined slot-override map for the dispatched page component.
194
+ * Sources:
195
+ * 1. `page.slots` — generic { slotName: registryName } map.
196
+ * 2. `page.headerComponent` — sugar for `slots.header`.
197
+ * 3. `page.actionsComponent` — sugar for `slots.actions`.
198
+ *
199
+ * Sugar fields take precedence when both are set so that the
200
+ * legacy fields remain meaningful in mixed manifests. Returned
201
+ * as an array of `{ name, component }` entries to make the
202
+ * `<template v-for>` + dynamic-slot-name pattern work in Vue 2.
203
+ */
204
+ resolvedSlotEntries() {
205
+ const page = this.currentPage
206
+ if (!page) return []
207
+ const map = { ...(page.slots ?? {}) }
208
+ if (page.headerComponent) map.header = page.headerComponent
209
+ if (page.actionsComponent) map.actions = page.actionsComponent
210
+ const entries = []
211
+ for (const [name, registryName] of Object.entries(map)) {
212
+ const component = this.resolveRegistryName(registryName, name)
213
+ if (component) entries.push({ name, component })
214
+ }
215
+ return entries
216
+ },
217
+ /**
218
+ * @deprecated Use `resolvedSlotEntries` for general slot
219
+ * resolution. Retained for compatibility with code that read the
220
+ * computed directly.
221
+ */
222
+ headerOverride() {
223
+ return this.resolvedSlotEntries.find((e) => e.name === 'header')?.component ?? null
224
+ },
225
+ /**
226
+ * @deprecated See `headerOverride`.
227
+ */
228
+ actionsOverride() {
229
+ return this.resolvedSlotEntries.find((e) => e.name === 'actions')?.component ?? null
230
+ },
231
+ },
232
+
233
+ created() {
234
+ // Surface the page id in Vue devtools and stack traces. The base
235
+ // component name `CnPageRenderer` becomes `CnPageRenderer:<id>`
236
+ // for the lifetime of this instance.
237
+ if (this.currentPage) {
238
+ this.$options.name = `CnPageRenderer:${this.currentPage.id}`
239
+ } else {
240
+ // Warn once at mount time when no page matches the current route.
241
+ // eslint-disable-next-line no-console
242
+ console.warn(
243
+ `[CnPageRenderer] No page found for $route.name = "${this.$route?.name}". The renderer will mount nothing.`,
244
+ )
245
+ }
246
+ },
247
+
248
+ methods: {
249
+ /**
250
+ * Resolve a registry component name. Logs a single console.warn
251
+ * naming the slot if the name is not in the registry.
252
+ *
253
+ * @param {string} registryName Name of the component to look up
254
+ * in `effectiveCustomComponents`.
255
+ * @param {string} slotName Slot the component would have filled
256
+ * (used only for the warning message).
257
+ * @return {object|null}
258
+ */
259
+ resolveRegistryName(registryName, slotName) {
260
+ const resolved = this.effectiveCustomComponents[registryName]
261
+ if (!resolved) {
262
+ // eslint-disable-next-line no-console
263
+ console.warn(
264
+ `[CnPageRenderer] Slot-override component "${registryName}" referenced by page id "${this.currentPage.id}" (slot "${slotName}") not found in registry.`,
265
+ )
266
+ return null
267
+ }
268
+ return resolved
269
+ },
270
+ },
271
+ }
272
+ </script>
273
+
274
+ <style>
275
+ .cn-page-renderer {
276
+ display: contents;
277
+ }
278
+ </style>
@@ -0,0 +1,4 @@
1
+ import CnPageRenderer from './CnPageRenderer.vue'
2
+ export default CnPageRenderer
3
+ export { CnPageRenderer }
4
+ export { defaultPageTypes } from './pageTypes.js'
@@ -0,0 +1,37 @@
1
+ import { defineAsyncComponent } from 'vue'
2
+
3
+ /**
4
+ * Default mapping from manifest `pages[].type` value to the Vue
5
+ * component the renderer mounts.
6
+ *
7
+ * The library ships built-in types here; consumers and downstream
8
+ * library extensions can add their own by passing a merged map to
9
+ * `CnAppRoot` (or `CnPageRenderer`) via the `pageTypes` prop.
10
+ *
11
+ * Each entry is wrapped in `defineAsyncComponent` so that apps using
12
+ * only a subset of types do not pay the bundle cost for the others
13
+ * (notably `dashboard` which depends on GridStack).
14
+ *
15
+ * The special `custom` type is NOT registered here — CnPageRenderer
16
+ * handles it inline, resolving `page.component` against the
17
+ * customComponents registry rather than this map.
18
+ *
19
+ * @example Extending with an app-specific page type
20
+ *
21
+ * import { defaultPageTypes } from '@conduction/nextcloud-vue'
22
+ * import MyReportPage from './views/MyReportPage.vue'
23
+ *
24
+ * const pageTypes = { ...defaultPageTypes, report: MyReportPage }
25
+ *
26
+ * <CnAppRoot :manifest="manifest" app-id="myapp" :page-types="pageTypes" />
27
+ *
28
+ * @example Adding a built-in page type to the library
29
+ *
30
+ * Add a new entry to this map and export the component from the
31
+ * `src/components/index.js` barrel. No change to CnPageRenderer.vue.
32
+ */
33
+ export const defaultPageTypes = {
34
+ index: defineAsyncComponent(() => import('../CnIndexPage/CnIndexPage.vue')),
35
+ detail: defineAsyncComponent(() => import('../CnDetailPage/CnDetailPage.vue')),
36
+ dashboard: defineAsyncComponent(() => import('../CnDashboardPage/CnDashboardPage.vue')),
37
+ }
@@ -1,8 +1,9 @@
1
1
  <template>
2
- <NcActions :force-menu="actions.length > 3" :primary="primary" :menu-name="menuName">
2
+ <NcActions :force-menu="visibleActions.length > 3" :primary="primary" :menu-name="menuName">
3
3
  <NcActionButton
4
- v-for="action in actions"
4
+ v-for="action in visibleActions"
5
5
  :key="action.label"
6
+ :title="getTitle(action)"
6
7
  :disabled="isDisabled(action)"
7
8
  :class="{ 'cn-row-action--destructive': action.destructive }"
8
9
  close-after-click
@@ -43,7 +44,17 @@ export default {
43
44
  props: {
44
45
  /**
45
46
  * Action definitions.
46
- * @type {Array<{label: string, icon?: object, handler: Function, disabled?: boolean | Function, destructive?: boolean}>}
47
+ *
48
+ * Each action supports:
49
+ * - `label` (string, required) — display text
50
+ * - `icon` (component) — MDI icon
51
+ * - `handler` (function) — called with `row` on click
52
+ * - `disabled` (boolean | (row) => boolean) — gray out the entry
53
+ * - `visible` (boolean | (row) => boolean) — when `false`, hide the entry from the menu (default: shown)
54
+ * - `title` (string | (row) => string) — native tooltip shown on hover (useful to explain why an entry is disabled)
55
+ * - `destructive` (boolean) — apply error color styling
56
+ *
57
+ * @type {Array<{label: string, icon?: object, handler: Function, disabled?: boolean | Function, visible?: boolean | Function, title?: string | Function, destructive?: boolean}>}
47
58
  */
48
59
  actions: {
49
60
  type: Array,
@@ -66,6 +77,23 @@ export default {
66
77
  },
67
78
  },
68
79
 
80
+ computed: {
81
+ /**
82
+ * Filter actions by their `visible` predicate. An action without a
83
+ * `visible` field is always shown (backwards compatible).
84
+ * @return {Array} Visible actions for the current row.
85
+ */
86
+ visibleActions() {
87
+ return this.actions.filter((action) => {
88
+ if (action.visible === undefined) return true
89
+ if (typeof action.visible === 'function') {
90
+ return !!action.visible(this.row)
91
+ }
92
+ return !!action.visible
93
+ })
94
+ },
95
+ },
96
+
69
97
  methods: {
70
98
  /**
71
99
  * Resolve disabled state for an action — supports both boolean and function.
@@ -78,6 +106,19 @@ export default {
78
106
  }
79
107
  return !!action.disabled
80
108
  },
109
+ /**
110
+ * Resolve the title (native tooltip) for an action — supports both
111
+ * string and function forms. Returns undefined when no title is
112
+ * provided so the attribute is not rendered.
113
+ * @param {object} action - The action definition
114
+ * @return {string|undefined} The resolved tooltip text, or undefined.
115
+ */
116
+ getTitle(action) {
117
+ if (typeof action.title === 'function') {
118
+ return action.title(this.row) || undefined
119
+ }
120
+ return action.title || undefined
121
+ },
81
122
  onAction(action) {
82
123
  if (action.handler && typeof action.handler === 'function') {
83
124
  action.handler(this.row)
@@ -99,24 +99,28 @@
99
99
  v-model="schema.configuration.objectNameField"
100
100
  :disabled="loading"
101
101
  :options="propertyOptions"
102
+ :clearable="true"
102
103
  :input-label="t('nextcloud-vue', 'Object name field')"
103
104
  :placeholder="t('nextcloud-vue', 'Select a property to use as object name')" />
104
105
  <NcSelect
105
106
  v-model="schema.configuration.objectDescriptionField"
106
107
  :disabled="loading"
107
108
  :options="propertyOptions"
109
+ :clearable="true"
108
110
  :input-label="t('nextcloud-vue', 'Object description field')"
109
111
  :placeholder="t('nextcloud-vue', 'Select a property to use as object description')" />
110
112
  <NcSelect
111
113
  v-model="schema.configuration.objectImageField"
112
114
  :disabled="loading"
113
115
  :options="propertyOptions"
116
+ :clearable="true"
114
117
  :input-label="t('nextcloud-vue', 'Object image field')"
115
118
  :placeholder="t('nextcloud-vue', 'Select a property to use as object image representing the object. e.g. logo (should contain base64 encoded image)')" />
116
119
  <NcSelect
117
120
  v-model="schema.configuration.objectSummaryField"
118
121
  :disabled="loading"
119
122
  :options="propertyOptions"
123
+ :clearable="true"
120
124
  :input-label="t('nextcloud-vue', 'Object summary field')"
121
125
  :placeholder="t('nextcloud-vue', 'Select a property to use as object summary. e.g. summary, abstract, or excerpt')" />
122
126
  <NcCheckboxRadioSwitch
@@ -78,6 +78,7 @@
78
78
  :selected-property="selectedProperty"
79
79
  :properties-modified="propertiesModified"
80
80
  :original-properties="originalProperties"
81
+ :inherited-properties="inheritedProperties"
81
82
  :type-options-for-select="typeOptionsForSelect"
82
83
  :available-schemas="availableSchemas"
83
84
  :available-registers="availableRegisters"
@@ -110,7 +111,8 @@
110
111
  :sorted-user-groups="sortedUserGroups"
111
112
  :loading-groups="loadingGroups"
112
113
  :has-any-permissions="hasAnyPermissions"
113
- :is-restrictive-schema="isRestrictiveSchema" />
114
+ :is-restrictive-schema="isRestrictiveSchema"
115
+ :inherited-properties="inheritedProperties" />
114
116
  </template>
115
117
 
116
118
  <!-- Optional Action Buttons (edit mode only) -->
@@ -254,6 +256,8 @@ export default {
254
256
  availableTags: { type: Array, default: () => [] },
255
257
  /** Whether user groups are still loading */
256
258
  loadingGroups: { type: Boolean, default: false },
259
+ /** Properties inherited from parent schemas (allOf) — shown as locked rows in the properties tab */
260
+ inheritedProperties: { type: Object, default: () => ({}) },
257
261
  /** Number of objects attached to this schema (used for action button disable logic) */
258
262
  objectCount: { type: Number, default: 0 },
259
263
  // Optional action button visibility
@@ -370,8 +374,9 @@ export default {
370
374
  ]
371
375
  },
372
376
  propertyOptions() {
373
- const properties = this.schemaItem.properties || {}
374
- return ['', ...Object.keys(properties)]
377
+ const ownKeys = Object.keys(this.schemaItem.properties || {}).filter(k => k !== '')
378
+ const inheritedKeys = Object.keys(this.inheritedProperties || {}).filter(k => k !== '')
379
+ return [...new Set([...inheritedKeys, ...ownKeys])]
375
380
  },
376
381
  availableTagsOptions() {
377
382
  return this.availableTags.map(tag => ({
@@ -539,76 +544,17 @@ export default {
539
544
  this.$refs.dialog.resetDialog()
540
545
  }
541
546
 
542
- if (this.item && this.item.id) {
543
- this.schemaItem = {
544
- ...this.schemaItem,
545
- ...JSON.parse(JSON.stringify(this.item)),
546
- }
547
-
548
- // Ensure configuration object exists
549
- if (!this.schemaItem.configuration) {
550
- this.schemaItem.configuration = {
551
- objectNameField: '',
552
- objectDescriptionField: '',
553
- objectImageField: '',
554
- objectSummaryField: '',
555
- allowFiles: false,
556
- allowedTags: [],
557
- }
558
- } else {
559
- if (!this.schemaItem.configuration.objectNameField) {
560
- this.schemaItem.configuration.objectNameField = ''
561
- }
562
- if (!this.schemaItem.configuration.objectDescriptionField) {
563
- this.schemaItem.configuration.objectDescriptionField = ''
564
- }
565
- if (!this.schemaItem.configuration.objectImageField) {
566
- this.schemaItem.configuration.objectImageField = ''
567
- }
568
- if (!this.schemaItem.configuration.objectSummaryField) {
569
- this.schemaItem.configuration.objectSummaryField = ''
570
- }
571
- if (this.schemaItem.configuration.allowFiles === undefined) {
572
- this.schemaItem.configuration.allowFiles = false
573
- }
574
- if (!this.schemaItem.configuration.allowedTags) {
575
- this.schemaItem.configuration.allowedTags = []
576
- }
577
- if (this.schemaItem.configuration.autoPublish === undefined) {
578
- this.schemaItem.configuration.autoPublish = false
579
- }
580
- }
581
-
582
- // Ensure authorization object exists
583
- if (!this.schemaItem.authorization) {
584
- this.schemaItem.authorization = {}
585
- }
586
-
587
- // Ensure existing properties have facetable set to false by default
588
- Object.keys(this.schemaItem.properties || {}).forEach(key => {
589
- if (this.schemaItem.properties[key].facetable === undefined) {
590
- this.$set(this.schemaItem.properties[key], 'facetable', false)
591
- }
592
-
593
- if (this.schemaItem.properties[key].enum && Array.isArray(this.schemaItem.properties[key].enum)) {
594
- this.$set(this.schemaItem.properties[key], 'enum', [...this.schemaItem.properties[key].enum])
595
- }
596
-
597
- const property = this.schemaItem.properties[key]
598
- if (property.type === 'array' && property.items && property.items.type === 'object' && !property.items.objectConfiguration) {
599
- this.$set(this.schemaItem.properties[key].items, 'objectConfiguration', { handling: 'nested-object' })
600
- }
601
- })
602
-
603
- // Ensure all $ref values are strings and migrate old structure
604
- Object.keys(this.schemaItem.properties || {}).forEach(key => {
605
- this.ensureRefIsString(this.schemaItem.properties, key)
606
- this.migratePropertyToNewStructure(key)
607
- })
608
-
609
- this.originalProperties = JSON.parse(JSON.stringify(this.schemaItem.properties || {}))
610
- } else {
611
- this.schemaItem.configuration = {
547
+ // Always rebuild schemaItem from defaults + incoming item. This handles
548
+ // create mode (item null), edit mode (item with id), and extend mode
549
+ // (item non-null with no id) without leaking stale state across opens.
550
+ const defaults = {
551
+ title: '',
552
+ version: '0.0.0',
553
+ description: '',
554
+ summary: '',
555
+ slug: '',
556
+ properties: {},
557
+ configuration: {
612
558
  objectNameField: '',
613
559
  objectDescriptionField: '',
614
560
  objectImageField: '',
@@ -616,9 +562,79 @@ export default {
616
562
  allowFiles: false,
617
563
  allowedTags: [],
618
564
  autoPublish: false,
565
+ },
566
+ authorization: {},
567
+ hardValidation: false,
568
+ immutable: false,
569
+ searchable: true,
570
+ maxDepth: 0,
571
+ }
572
+ this.schemaItem = this.item
573
+ ? { ...defaults, ...JSON.parse(JSON.stringify(this.item)) }
574
+ : { ...defaults }
575
+
576
+ // Ensure configuration object has all expected keys (the spread above may
577
+ // have replaced our defaults with a partial configuration from the item)
578
+ if (!this.schemaItem.configuration) {
579
+ this.schemaItem.configuration = { ...defaults.configuration }
580
+ } else {
581
+ if (!this.schemaItem.configuration.objectNameField) {
582
+ this.schemaItem.configuration.objectNameField = ''
583
+ }
584
+ if (!this.schemaItem.configuration.objectDescriptionField) {
585
+ this.schemaItem.configuration.objectDescriptionField = ''
586
+ }
587
+ if (!this.schemaItem.configuration.objectImageField) {
588
+ this.schemaItem.configuration.objectImageField = ''
589
+ }
590
+ if (!this.schemaItem.configuration.objectSummaryField) {
591
+ this.schemaItem.configuration.objectSummaryField = ''
619
592
  }
620
- this.originalProperties = {}
593
+ if (this.schemaItem.configuration.allowFiles === undefined) {
594
+ this.schemaItem.configuration.allowFiles = false
595
+ }
596
+ if (!this.schemaItem.configuration.allowedTags) {
597
+ this.schemaItem.configuration.allowedTags = []
598
+ }
599
+ if (this.schemaItem.configuration.autoPublish === undefined) {
600
+ this.schemaItem.configuration.autoPublish = false
601
+ }
602
+ }
603
+
604
+ // Ensure authorization object exists
605
+ if (!this.schemaItem.authorization) {
606
+ this.schemaItem.authorization = {}
621
607
  }
608
+
609
+ // Ensure existing properties have facetable set to false by default
610
+ Object.keys(this.schemaItem.properties || {}).forEach(key => {
611
+ if (this.schemaItem.properties[key].facetable === undefined) {
612
+ this.$set(this.schemaItem.properties[key], 'facetable', false)
613
+ }
614
+
615
+ if (this.schemaItem.properties[key].enum && Array.isArray(this.schemaItem.properties[key].enum)) {
616
+ this.$set(this.schemaItem.properties[key], 'enum', [...this.schemaItem.properties[key].enum])
617
+ }
618
+
619
+ const property = this.schemaItem.properties[key]
620
+ if (property.type === 'array' && property.items && property.items.type === 'object' && !property.items.objectConfiguration) {
621
+ this.$set(this.schemaItem.properties[key].items, 'objectConfiguration', { handling: 'nested-object' })
622
+ }
623
+ })
624
+
625
+ // Ensure all $ref values are strings and migrate old structure
626
+ Object.keys(this.schemaItem.properties || {}).forEach(key => {
627
+ this.ensureRefIsString(this.schemaItem.properties, key)
628
+ this.migratePropertyToNewStructure(key)
629
+ })
630
+
631
+ // Snapshot original properties for change detection. For new/extending
632
+ // items (no id), there's no "original" — start empty so any added
633
+ // properties register as modifications.
634
+ this.originalProperties = this.schemaItem.id
635
+ ? JSON.parse(JSON.stringify(this.schemaItem.properties || {}))
636
+ : {}
637
+
622
638
  this.propertiesModified = false
623
639
  },
624
640
 
@@ -723,6 +739,19 @@ export default {
723
739
  }
724
740
  })
725
741
 
742
+ // NcSelect (track-by="id") can convert plain IDs to full option objects.
743
+ // Normalise back to plain IDs before emitting so the backend always gets scalars.
744
+ for (const field of ['allOf', 'oneOf', 'anyOf']) {
745
+ if (Array.isArray(cleanedSchemaItem[field])) {
746
+ cleanedSchemaItem[field] = cleanedSchemaItem[field]
747
+ .map(ref => (typeof ref === 'object' && ref !== null ? ref.id : ref))
748
+ .filter(id => id != null && id !== '')
749
+ if (cleanedSchemaItem[field].length === 0) {
750
+ delete cleanedSchemaItem[field]
751
+ }
752
+ }
753
+ }
754
+
726
755
  this.$emit('confirm', cleanedSchemaItem)
727
756
  },
728
757