@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.
- package/dist/nextcloud-vue.cjs.js +7282 -3443
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +719 -100
- package/dist/nextcloud-vue.esm.js +7120 -3300
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +3 -2
- package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +36 -3
- package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +34 -19
- package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +312 -36
- package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +983 -64
- package/src/components/CnAdvancedFormDialog/index.js +3 -0
- package/src/components/CnAppLoading/CnAppLoading.vue +93 -0
- package/src/components/CnAppLoading/index.js +3 -0
- package/src/components/CnAppNav/CnAppNav.vue +269 -0
- package/src/components/CnAppNav/index.js +3 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +201 -0
- package/src/components/CnAppRoot/index.js +3 -0
- package/src/components/CnColorPicker/CnColorPicker.vue +251 -0
- package/src/components/CnColorPicker/index.js +1 -0
- package/src/components/CnContextMenu/CnContextMenu.vue +41 -4
- package/src/components/CnDashboardPage/CnDashboardPage.vue +8 -0
- package/src/components/CnDependencyMissing/CnDependencyMissing.vue +152 -0
- package/src/components/CnDependencyMissing/index.js +3 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +27 -16
- package/src/components/CnIndexPage/CnIndexPage.vue +36 -6
- package/src/components/CnPageRenderer/CnPageRenderer.vue +278 -0
- package/src/components/CnPageRenderer/index.js +4 -0
- package/src/components/CnPageRenderer/pageTypes.js +37 -0
- package/src/components/CnRowActions/CnRowActions.vue +44 -3
- package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +4 -0
- package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +103 -74
- package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +30 -2
- package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +16 -12
- package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +9 -4
- package/src/components/index.js +7 -1
- package/src/composables/index.js +2 -0
- package/src/composables/useAppManifest.js +115 -0
- package/src/composables/useAppStatus.js +107 -0
- package/src/css/CnSchemaFormDialog.css +22 -0
- package/src/index.js +24 -2
- package/src/schemas/app-manifest.schema.json +153 -0
- package/src/types/index.d.ts +9 -0
- package/src/types/manifest.d.ts +88 -0
- package/src/utils/index.js +1 -1
- package/src/utils/schema.js +157 -2
- 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,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="
|
|
2
|
+
<NcActions :force-menu="visibleActions.length > 3" :primary="primary" :menu-name="menuName">
|
|
3
3
|
<NcActionButton
|
|
4
|
-
v-for="action in
|
|
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
|
-
*
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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.
|
|
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
|
|