@conduction/nextcloud-vue 2.65.0 → 2.67.0

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 (103) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +19 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +2 -2
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +99 -2
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +438 -383
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  12. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  13. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  15. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +4 -4
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +78 -21
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +129 -96
  22. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  24. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  25. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  26. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  28. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +63 -7
  29. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  30. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +2 -1
  31. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -1
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +335 -313
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  37. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  38. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  39. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  40. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +5 -11
  41. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  42. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  43. package/dist/esm/composables/useDependencyCheck.js +44 -1
  44. package/dist/esm/composables/useDependencyCheck.js.map +1 -1
  45. package/dist/esm/composables/useListView.js +43 -1
  46. package/dist/esm/composables/useListView.js.map +1 -1
  47. package/dist/esm/composables/useSetupStatus.js +26 -3
  48. package/dist/esm/composables/useSetupStatus.js.map +1 -1
  49. package/dist/esm/l10n/en.json.js +7 -1
  50. package/dist/esm/l10n/en.json.js.map +1 -1
  51. package/dist/esm/l10n/nl.json.js +7 -1
  52. package/dist/esm/l10n/nl.json.js.map +1 -1
  53. package/dist/esm/mixins/pageViews.js +400 -0
  54. package/dist/esm/mixins/pageViews.js.map +1 -0
  55. package/dist/esm/nextcloud-vue.css +38 -2
  56. package/dist/esm/store/plugins/liveUpdates.js +19 -9
  57. package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
  58. package/dist/esm/store/useObjectStore.js +20 -9
  59. package/dist/esm/store/useObjectStore.js.map +1 -1
  60. package/dist/esm/utils/actionsDispatcher.js +11 -2
  61. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  62. package/dist/esm/utils/columnFilters.js +27 -3
  63. package/dist/esm/utils/columnFilters.js.map +1 -1
  64. package/dist/esm/utils/schema.js +40 -2
  65. package/dist/esm/utils/schema.js.map +1 -1
  66. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  67. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  68. package/dist/nextcloud-vue.cjs.js +2377 -1247
  69. package/dist/nextcloud-vue.cjs.js.map +2 -2
  70. package/dist/nextcloud-vue.css +38 -2
  71. package/l10n/en.json +7 -1
  72. package/l10n/nl.json +7 -1
  73. package/package.json +1 -1
  74. package/src/components/CnActionsBar/CnActionsBar.md +2 -1
  75. package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
  76. package/src/components/CnAppNav/CnAppNav.vue +2 -2
  77. package/src/components/CnDashboardPage/CnDashboardPage.vue +165 -8
  78. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  79. package/src/components/CnDataTable/CnDataTable.md +1 -1
  80. package/src/components/CnDataTable/CnDataTable.vue +4 -4
  81. package/src/components/CnDetailPage/CnDetailPage.vue +94 -5
  82. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  83. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  84. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +67 -8
  85. package/src/components/CnIndexPage/CnIndexPage.md +2 -1
  86. package/src/components/CnIndexPage/CnIndexPage.vue +47 -1
  87. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  88. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  89. package/src/components/CnSetupWizard/CnSetupWizard.vue +5 -11
  90. package/src/composables/useDependencyCheck.js +43 -0
  91. package/src/composables/useListView.js +43 -1
  92. package/src/composables/useSetupStatus.js +26 -3
  93. package/src/css/actions-bar.css +20 -2
  94. package/src/css/detail-page.css +5 -0
  95. package/src/mixins/pageViews.js +396 -0
  96. package/src/schemas/app-manifest-v2.schema.json +31 -3
  97. package/src/schemas/app-manifest.schema.json +1 -1
  98. package/src/store/plugins/liveUpdates.js +19 -9
  99. package/src/store/useObjectStore.js +19 -8
  100. package/src/utils/actionsDispatcher.js +11 -2
  101. package/src/utils/columnFilters.js +29 -2
  102. package/src/utils/schema.js +40 -2
  103. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -0,0 +1,396 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * Page views: a few views of one page, each its own widget grid, behind one
6
+ * segmented control ("My work | My team").
7
+ *
8
+ * A page that mixes this in accepts `views` (each `{ id, label, icon?,
9
+ * widgets, layout, emptyText? }`), `defaultView` and `viewsLabel`. It keeps
10
+ * the chosen view, writes it to the address (`?view=<id>`) and to the
11
+ * user's preferences (the server, mirrored in the browser), and provides `cnPageViews` so a greeting header widget on
12
+ * the page can draw the switch instead of the page.
13
+ *
14
+ * Which view opens: the address, then the stored view, then `defaultView`,
15
+ * then the first view. An id that names no view is ignored at every step.
16
+ *
17
+ * Used by CnDashboardPage and CnDetailPage. Each renders the chosen view's
18
+ * `layout` with its own grid, so a view looks exactly like the page's own
19
+ * grid.
20
+ *
21
+ * @mixin pageViews
22
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
23
+ */
24
+ import { translate as t } from '@nextcloud/l10n'
25
+ import { readUserPreference, USER_PREFERENCE_STORAGE_PREFIX, writeUserPreference } from '../composables/useUserPreferences.js'
26
+
27
+ /** The query parameter that carries the chosen view. */
28
+ export const PAGE_VIEW_QUERY_KEY = 'view'
29
+
30
+ /** Prefix of the user-preference key; the page id follows it. */
31
+ export const PAGE_VIEW_PREFERENCE_PREFIX = 'cn_page_view:'
32
+
33
+ let regionSeq = 0
34
+
35
+ /**
36
+ * Read the browser mirror of a user preference synchronously, so the stored
37
+ * view is on screen from the first frame. Errors read as nothing stored.
38
+ *
39
+ * @param {string} appId The app id.
40
+ * @param {string} key The preference key.
41
+ * @return {string|null} The stored id, or null.
42
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
43
+ */
44
+ function readStoredViewMirror(appId, key) {
45
+ try {
46
+ if (typeof localStorage === 'undefined') {
47
+ return null
48
+ }
49
+ const raw = localStorage.getItem(`${USER_PREFERENCE_STORAGE_PREFIX}${appId}:${key}`)
50
+ if (raw === null || raw === undefined) {
51
+ return null
52
+ }
53
+ const value = JSON.parse(raw)
54
+ return typeof value === 'string' && value !== '' ? value : null
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ export const pageViews = {
61
+ inject: {
62
+ /** The host app id, provided by CnAppRoot; keys the stored view. */
63
+ cnPageViewsAppId: { from: 'cnAppId', default: '' },
64
+ },
65
+
66
+ props: {
67
+ /**
68
+ * Views of this page, each its own widget grid behind one segmented
69
+ * control. Each is `{ id, label, icon?, widgets, layout, emptyText? }`:
70
+ * `widgets` and `layout` have the same shape as the page's own. The
71
+ * chosen view renders in a region below the page's own grid. Empty
72
+ * (the default) draws no switch and no region.
73
+ *
74
+ * @type {Array<{id: string, label: string, icon?: string, widgets?: Array<object>, layout?: Array<object>, emptyText?: string}>}
75
+ */
76
+ views: {
77
+ type: Array,
78
+ default: () => [],
79
+ },
80
+
81
+ /**
82
+ * The id of the view chosen when neither the address nor the browser
83
+ * store names one. Empty chooses the first view.
84
+ *
85
+ * @type {string}
86
+ */
87
+ defaultView: {
88
+ type: String,
89
+ default: '',
90
+ },
91
+
92
+ /**
93
+ * Accessible name of the view switch (through the host translate
94
+ * function). Empty reads "View".
95
+ *
96
+ * @type {string}
97
+ */
98
+ viewsLabel: {
99
+ type: String,
100
+ default: '',
101
+ },
102
+ },
103
+
104
+ data() {
105
+ regionSeq += 1
106
+ return {
107
+ /** The view the user chose in this session, or the stored one. */
108
+ selectedViewId: null,
109
+ /** Whether the user chose a view in this session (a late server read then leaves it alone). */
110
+ viewChosen: false,
111
+ /** Id of the view region, the target of the options' `aria-controls`. */
112
+ viewRegionId: `cn-page-view-region-${regionSeq}`,
113
+ }
114
+ },
115
+
116
+ /**
117
+ * Provide the views to descendants, so a greeting header widget in the
118
+ * page's grid can draw the switch. Getters keep it reactive.
119
+ *
120
+ * @return {object} The provided `cnPageViews`.
121
+ */
122
+ provide() {
123
+ const vm = this
124
+ return {
125
+ cnPageViews: {
126
+ get views() { return vm.normalizedViews },
127
+ get activeId() { return vm.activeViewId },
128
+ get regionId() { return vm.viewRegionId },
129
+ select: (id) => vm.selectView(id),
130
+ },
131
+ }
132
+ },
133
+
134
+ computed: {
135
+ /**
136
+ * The declared views in one shape, labels translated. Entries without
137
+ * an id or a label are dropped, as are repeated ids.
138
+ *
139
+ * @return {Array<{id: string, label: string, icon: string, widgets: Array<object>, layout: Array<object>, emptyText: string}>}
140
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
141
+ */
142
+ normalizedViews() {
143
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
144
+ const seen = new Set()
145
+ return (Array.isArray(this.views) ? this.views : [])
146
+ .filter((view) => {
147
+ if (!view || typeof view !== 'object' || view.id === undefined || view.id === null || view.id === '') {
148
+ return false
149
+ }
150
+ if (typeof view.label !== 'string' || view.label === '' || seen.has(String(view.id))) {
151
+ return false
152
+ }
153
+ seen.add(String(view.id))
154
+ return true
155
+ })
156
+ .map((view) => ({
157
+ id: String(view.id),
158
+ label: translate(view.label),
159
+ icon: typeof view.icon === 'string' ? view.icon : '',
160
+ widgets: Array.isArray(view.widgets) ? view.widgets : [],
161
+ layout: Array.isArray(view.layout) ? view.layout : [],
162
+ emptyText: typeof view.emptyText === 'string' && view.emptyText !== '' ? translate(view.emptyText) : '',
163
+ }))
164
+ },
165
+
166
+ /**
167
+ * Whether this page has views.
168
+ *
169
+ * @return {boolean}
170
+ */
171
+ hasViews() {
172
+ return this.normalizedViews.length > 0
173
+ },
174
+
175
+ /**
176
+ * The chosen view's id: the address, then this session's or the
177
+ * stored choice, then `defaultView`, then the first view.
178
+ *
179
+ * @return {string|null} The id, or null without views.
180
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
181
+ */
182
+ activeViewId() {
183
+ const views = this.normalizedViews
184
+ if (views.length === 0) {
185
+ return null
186
+ }
187
+ const known = (id) => id !== null && id !== undefined && views.some((view) => view.id === String(id))
188
+ const fromAddress = this.$route && this.$route.query ? this.$route.query[PAGE_VIEW_QUERY_KEY] : undefined
189
+ if (known(fromAddress)) {
190
+ return String(fromAddress)
191
+ }
192
+ if (known(this.selectedViewId)) {
193
+ return String(this.selectedViewId)
194
+ }
195
+ if (known(this.defaultView)) {
196
+ return this.defaultView
197
+ }
198
+ return views[0].id
199
+ },
200
+
201
+ /**
202
+ * The chosen view, or null without views.
203
+ *
204
+ * @return {object|null}
205
+ */
206
+ activeView() {
207
+ return this.normalizedViews.find((view) => view.id === this.activeViewId) || null
208
+ },
209
+
210
+ /**
211
+ * The switch's options, for CnSegmentedControl.
212
+ *
213
+ * @return {Array<{value: string, label: string}>}
214
+ */
215
+ viewSwitchOptions() {
216
+ return this.normalizedViews.map((view) => ({ value: view.id, label: view.label }))
217
+ },
218
+
219
+ /**
220
+ * The switch's accessible name: `viewsLabel` translated, else "View".
221
+ *
222
+ * @return {string}
223
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
224
+ */
225
+ viewSwitchLabel() {
226
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
227
+ return this.viewsLabel ? translate(this.viewsLabel) : t('nextcloud-vue', 'Views')
228
+ },
229
+
230
+ /**
231
+ * Whether a greeting header in the page's own widgets draws the
232
+ * switch (an option with a `view`), so the page draws none.
233
+ *
234
+ * @return {boolean}
235
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-greeting-header-can-switch-the-pages-views
236
+ */
237
+ viewSwitchInHeaderWidget() {
238
+ const widgets = Array.isArray(this.widgets) ? this.widgets : []
239
+ return widgets.some((def) => {
240
+ const options = def && def.content && def.content.views && def.content.views.options
241
+ return Array.isArray(options) && options.some((option) => option && typeof option.view === 'string' && option.view !== '')
242
+ })
243
+ },
244
+
245
+ /**
246
+ * Whether the page draws the switch itself.
247
+ *
248
+ * @return {boolean}
249
+ */
250
+ showsPageViewSwitch() {
251
+ return this.hasViews && !this.viewSwitchInHeaderWidget
252
+ },
253
+
254
+ /**
255
+ * The user-preference key for this page's chosen view.
256
+ *
257
+ * @return {string}
258
+ */
259
+ viewPreferenceKey() {
260
+ // The page id, else the route name: a detail page's fallback id is
261
+ // its record's title, which would remember a view per record.
262
+ const id = this.pageId || (this.$route && this.$route.name) || this.resolvedPageId || 'page'
263
+ return `${PAGE_VIEW_PREFERENCE_PREFIX}${id}`
264
+ },
265
+
266
+ /**
267
+ * The app id the stored view is kept under: the page's `appId` prop
268
+ * when it has one, else the app root's.
269
+ *
270
+ * @return {string}
271
+ */
272
+ viewPreferenceAppId() {
273
+ const own = typeof this.appId === 'string' ? this.appId : ''
274
+ return own || (typeof this.cnPageViewsAppId === 'string' ? this.cnPageViewsAppId : '')
275
+ },
276
+
277
+ /**
278
+ * The message of the chosen view's empty state.
279
+ *
280
+ * @return {string}
281
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
282
+ */
283
+ viewEmptyText() {
284
+ if (this.activeView && this.activeView.emptyText) {
285
+ return this.activeView.emptyText
286
+ }
287
+ return t('nextcloud-vue', 'This view has no widgets yet.')
288
+ },
289
+ },
290
+
291
+ created() {
292
+ this.loadStoredView()
293
+ },
294
+
295
+ methods: {
296
+ /**
297
+ * Read the stored view: the browser mirror at once, then the user
298
+ * preference on the server, which wins unless the user already chose.
299
+ *
300
+ * @return {Promise<void>}
301
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
302
+ */
303
+ async loadStoredView() {
304
+ if (!this.hasViews) {
305
+ return
306
+ }
307
+ const appId = this.viewPreferenceAppId
308
+ const key = this.viewPreferenceKey
309
+ this.selectedViewId = readStoredViewMirror(appId, key)
310
+ if (!appId) {
311
+ return
312
+ }
313
+ const stored = await readUserPreference(appId, key, null)
314
+ if (!this.viewChosen && typeof stored === 'string' && stored !== '') {
315
+ this.selectedViewId = stored
316
+ }
317
+ },
318
+
319
+ /**
320
+ * Choose a view: keep it, store it, and write it to the address
321
+ * (replacing the history entry, keeping the other query keys).
322
+ *
323
+ * @param {string} id The view id.
324
+ * @return {void}
325
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
326
+ */
327
+ selectView(id) {
328
+ const view = this.normalizedViews.find((candidate) => candidate.id === String(id))
329
+ if (!view) {
330
+ return
331
+ }
332
+ this.selectedViewId = view.id
333
+ this.viewChosen = true
334
+ writeUserPreference(this.viewPreferenceAppId, this.viewPreferenceKey, view.id)
335
+ if (!this.$router || !this.$route) {
336
+ return
337
+ }
338
+ const query = this.$route.query || {}
339
+ if (query[PAGE_VIEW_QUERY_KEY] === view.id) {
340
+ return
341
+ }
342
+ const result = this.$router.replace({ query: { ...query, [PAGE_VIEW_QUERY_KEY]: view.id } })
343
+ if (result && typeof result.catch === 'function') {
344
+ result.catch(() => {})
345
+ }
346
+ },
347
+
348
+ /**
349
+ * Look a widget definition up in the views, for a layout item of the
350
+ * chosen view.
351
+ *
352
+ * @param {string} widgetId The widget id.
353
+ * @return {object|null} The definition, or null.
354
+ */
355
+ findViewWidget(widgetId) {
356
+ const view = this.activeView
357
+ if (!view) {
358
+ return null
359
+ }
360
+ return view.widgets.find((def) => def && def.id === widgetId) || null
361
+ },
362
+
363
+ /**
364
+ * Write a drag or resize in the chosen view's grid back onto its
365
+ * layout items in place, as the page does for its own layout, so the
366
+ * in-app manifest editor sees it.
367
+ *
368
+ * @param {Array<object>} updated The layout from the grid.
369
+ * @return {void}
370
+ */
371
+ onViewLayoutChange(updated) {
372
+ const view = this.activeView
373
+ if (!view || !Array.isArray(updated)) {
374
+ return
375
+ }
376
+ for (const u of updated) {
377
+ const item = view.layout.find((l) => String(l.id) === String(u.id))
378
+ if (!item) {
379
+ continue
380
+ }
381
+ for (const key of ['gridX', 'gridY', 'gridWidth', 'gridHeight']) {
382
+ if (u[key] !== undefined) {
383
+ item[key] = u[key]
384
+ }
385
+ }
386
+ }
387
+ /**
388
+ * Emitted when a widget in a view's grid is dragged or resized.
389
+ *
390
+ * @event view-layout-change
391
+ * @type {{view: string, layout: Array<object>}}
392
+ */
393
+ this.$emit('view-layout-change', { view: view.id, layout: view.layout })
394
+ },
395
+ },
396
+ }
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.47.0",
6
+ "version": "2.49.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -98,7 +98,7 @@
98
98
  "suggestMap": { "type": "object", "description": "choice: map of suggestFrom value → suggested option value. Used to auto-suggest a default (e.g. RGS template suggested from organisation type).", "additionalProperties": { "type": "string" } },
99
99
  "multiple": { "type": "boolean", "default": false, "description": "choice: allow multi-select." },
100
100
  "loadAction": { "type": "string", "minLength": 1, "description": "choice with display cards: an actionId each option card loads itself with. Every card except the one whose value is `none` gets its own Load button, which POSTs { dataset: <value> } to /api/setup/action/{loadAction}, spins while it runs and shows the result on that card. A successful load also selects the card. With loadAction the separate run-action step that loaded the picked dataset is no longer needed; manifests that keep it still work." },
101
- "requires": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "App ids this step needs. When any of them is not installed and enabled, the wizard skips the step and the summary shows it as skipped, naming the missing apps. Do not combine with required: true; setup status would keep reporting the skipped step as unmet." },
101
+ "requires": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "App ids this step needs. When any of them is not installed and enabled, the wizard skips the step and the summary shows it as skipped, naming the missing apps. Setup status then counts the step as not applicable, neither met nor unmet, so it never reopens the wizard or gates the app. This holds for a required step too." },
102
102
  "action": { "type": "string", "description": "run-action: actionId POSTed to /api/setup/action/{action}." },
103
103
  "onDemand": { "type": "boolean", "default": false, "description": "run-action: the step runs only when the user clicks Run, never automatically, and is never outstanding work (it does not open the wizard or decide where a resumed wizard starts). The summary ticks it only when its action succeeded in the current session and shows it as not run otherwise, whatever the server reports. For destructive steps an app reports as done on purpose so they never auto-run, such as removing example data." },
104
104
  "healthCheck": { "type": "boolean", "default": false, "description": "summary: render a health recap." },
@@ -236,7 +236,7 @@
236
236
  },
237
237
  "settingsLabel": {
238
238
  "type": "string",
239
- "description": "Override label for the settings foldout's gear button. Defaults to 'Settings'."
239
+ "description": "Override label for the settings foldout's gear button. Defaults to 'Advanced'."
240
240
  },
241
241
  "card": {
242
242
  "type": "object",
@@ -3608,6 +3608,34 @@
3608
3608
  "default": true,
3609
3609
  "description": "type='dashboard': `false` drops the header row (title, description, header actions, edit toggle) for a page that opens with its own heading, such as a greeting widget; the title stays as a visually hidden heading (zuiddrecht-pixel-gaps). Maps to CnDashboardPage `showHeader`."
3610
3610
  },
3611
+ "views": {
3612
+ "type": "array",
3613
+ "minItems": 1,
3614
+ "description": "type='dashboard' | type='detail': views of the page, each its own widget grid behind one segmented control (\"My work | My team\") (view-switch-containers). The chosen view renders in a region below the page's own grid, drawn with the same grid and widgets; the page's own `widgets` and `layout` stay above it. The switch sits in the page header, or in a greeting header widget whose `content.views.options[]` carry `view` instead of `route`. The chosen view is in the address (`?view=<id>`) and in the user's preferences; the address wins. A view with nothing to draw shows `emptyText`, else a default sentence. On a detail page, views replace the automatic Data and Related body. Maps to CnDashboardPage / CnDetailPage `views`.",
3615
+ "items": {
3616
+ "type": "object",
3617
+ "required": ["id", "label"],
3618
+ "additionalProperties": false,
3619
+ "properties": {
3620
+ "id": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$", "description": "The view id, written to the address as `?view=<id>`." },
3621
+ "label": { "type": "string", "minLength": 1, "description": "The option label and the region's accessible name (through the app's translations)." },
3622
+ "icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name, reserved for an icon-bearing switch." },
3623
+ "emptyText": { "type": "string", "minLength": 1, "description": "The sentence shown when the view has nothing to draw." },
3624
+ "widgets": { "type": "array", "description": "The view's widget definitions, the same shape as the page's `widgets`.", "items": { "type": "object", "additionalProperties": true } },
3625
+ "layout": { "type": "array", "description": "The view's grid layout, the same shape as the page's `layout`.", "items": { "type": "object", "additionalProperties": true } }
3626
+ }
3627
+ }
3628
+ },
3629
+ "defaultView": {
3630
+ "type": "string",
3631
+ "minLength": 1,
3632
+ "description": "type='dashboard' | type='detail': the id of the view chosen when neither the address nor the user's stored choice names one; without it the first view (view-switch-containers). Maps to `defaultView`."
3633
+ },
3634
+ "viewsLabel": {
3635
+ "type": "string",
3636
+ "minLength": 1,
3637
+ "description": "type='dashboard' | type='detail': the accessible name of the page's view switch, e.g. \"Whose work\"; defaults to \"View\" (view-switch-containers). Maps to `viewsLabel`."
3638
+ },
3611
3639
  "showWidgetActions": {
3612
3640
  "type": "boolean",
3613
3641
  "default": true,
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "settingsLabel": {
52
52
  "type": "string",
53
- "description": "Override label for the settings foldout's gear button. Defaults to 'Settings'."
53
+ "description": "Override label for the settings foldout's gear button. Defaults to 'Advanced'."
54
54
  },
55
55
  "primaryAction": {
56
56
  "$ref": "#/$defs/primaryAction",
@@ -432,19 +432,29 @@ export function liveUpdatesPlugin(opts = {}) {
432
432
  const collectionInFlight = new Map()
433
433
 
434
434
  const originalFetchCollection = store.fetchCollection.bind(store)
435
- store.fetchCollection = async function dedupedFetchCollection(type, params = {}) {
435
+ store.fetchCollection = async function dedupedFetchCollection(type, params = {}, options = {}) {
436
436
  if (!store.__liveDedupActive) {
437
- return originalFetchCollection(type, params)
437
+ return originalFetchCollection(type, params, options)
438
438
  }
439
439
  const key = collectionDedupKey(type, params)
440
- if (collectionInFlight.has(key)) {
441
- return collectionInFlight.get(key)
440
+ let entry = collectionInFlight.get(key)
441
+ if (!entry) {
442
+ // The shared request reports into its own sink, copied to
443
+ // each caller's `options.outcome` below, so a caller that
444
+ // joined it learns that request's result too.
445
+ const outcome = {}
446
+ const promise = originalFetchCollection(type, params, { ...options, outcome }).finally(() => {
447
+ collectionInFlight.delete(key)
448
+ })
449
+ entry = { promise, outcome }
450
+ collectionInFlight.set(key, entry)
442
451
  }
443
- const promise = originalFetchCollection(type, params).finally(() => {
444
- collectionInFlight.delete(key)
445
- })
446
- collectionInFlight.set(key, promise)
447
- return promise
452
+ const results = await entry.promise
453
+ const sink = options && options.outcome
454
+ if (sink && typeof sink === 'object' && 'error' in entry.outcome) {
455
+ sink.error = entry.outcome.error
456
+ }
457
+ return results
448
458
  }
449
459
  },
450
460
  }
@@ -656,9 +656,12 @@ const baseActions = {
656
656
  *
657
657
  * @param {string} type The registered type slug
658
658
  * @param {object} [params] Query parameters (_limit, _page, _search, _order, filters)
659
+ * @param {object} [options] Call options
660
+ * @param {{error?: object|null}} [options.outcome] Opt-in sink for THIS call's own result: `error` is set to the error it records on `errors[type]`, or `null` on success. Unlike `errors[type]`, which every call for the type shares, a later call cannot overwrite it.
659
661
  * @return {Promise<Array>} The fetched collection (also stored in state)
660
662
  */
661
- async fetchCollection(type, params = {}) {
663
+ async fetchCollection(type, params = {}, options = {}) {
664
+ const outcome = (options && typeof options.outcome === 'object' && options.outcome) || null
662
665
  this.loading = { ...this.loading, [type]: true }
663
666
  this.errors = { ...this.errors, [type]: null }
664
667
 
@@ -685,9 +688,13 @@ const baseActions = {
685
688
  )
686
689
 
687
690
  if (!response.ok) {
688
- this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
691
+ const failure = await parseResponseError(response, type)
692
+ this.errors = { ...this.errors, [type]: failure }
693
+ if (outcome) {
694
+ outcome.error = failure
695
+ }
689
696
  // eslint-disable-next-line no-console -- diagnostic for a failure this code already degrades from
690
- console.error(`Error fetching ${type} collection:`, this.errors[type])
697
+ console.error(`Error fetching ${type} collection:`, failure)
691
698
  return []
692
699
  }
693
700
 
@@ -711,13 +718,17 @@ const baseActions = {
711
718
  this.facets = { ...this.facets, [type]: transformed }
712
719
  }
713
720
 
721
+ if (outcome) {
722
+ outcome.error = null
723
+ }
714
724
  return results
715
725
  } catch (error) {
716
- this.errors = {
717
- ...this.errors,
718
- [type]: error.name === 'TypeError'
719
- ? networkError(error)
720
- : genericError(error),
726
+ const failure = error.name === 'TypeError'
727
+ ? networkError(error)
728
+ : genericError(error)
729
+ this.errors = { ...this.errors, [type]: failure }
730
+ if (outcome) {
731
+ outcome.error = failure
721
732
  }
722
733
  // eslint-disable-next-line no-console -- diagnostic for a failure this code already degrades from
723
734
  console.error(`Error fetching ${type} collection:`, error)
@@ -100,6 +100,14 @@ export function isExternalActionTarget(target) {
100
100
  * would otherwise register — and fetch — a 404 (defect 7, learniq round
101
101
  * 1). Idempotent for a caller that already passes a correct slug.
102
102
  *
103
+ * A caller that already holds the schema SLUG (not a `$ref` title) passes
104
+ * `{ exactSchema: true }`: the slug is then used exactly as given. Kebab-casing
105
+ * a real camelCase slug (`productCategory` -> `product-category`) asks the
106
+ * objects API for a schema that does not exist and 404s (pipelinq review,
107
+ * round two).
108
+ *
109
+ * @spec openspec/changes/review-round-two/specs/schema-utilities/spec.md
110
+ *
103
111
  * A title is not always registered under its kebab form: learniq holds
104
112
  * `LearniqSettings` and `SubjectTeacherAssignment` as written. So a type
105
113
  * registered here for a value the kebab rule CHANGED also carries the value
@@ -110,11 +118,12 @@ export function isExternalActionTarget(target) {
110
118
  * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-schema-title-falls-back-to-its-written-form
111
119
  * @param {object} store The object store instance (useObjectStore shape).
112
120
  * @param {{register: (string|number), schema: (string|number)}} source The widget source.
121
+ * @param {{exactSchema: (boolean|undefined)}} [options] `exactSchema: true` skips the title-to-slug step.
113
122
  * @return {string} The type slug to use for store CRUD calls.
114
123
  */
115
- export function resolveObjectOpType(store, source) {
124
+ export function resolveObjectOpType(store, source, options = {}) {
116
125
  const register = String(source.register)
117
- const schema = String(schemaRefSlug(source.schema))
126
+ const schema = options.exactSchema === true ? String(source.schema) : String(schemaRefSlug(source.schema))
118
127
  const written = typeof source.schema === 'string'
119
128
  ? source.schema.substring(source.schema.lastIndexOf('/') + 1)
120
129
  : ''
@@ -15,13 +15,19 @@
15
15
  * Parameter shapes, as OpenRegister reads them:
16
16
  * - equality and "any of": `key=value`, `key[]=a&key[]=b`
17
17
  * - ranges: `key[gte]=from`, `key[lte]=to`
18
+ * - contains (text): `key[like]=term`, case-insensitive (openregister#4430);
19
+ * the server escapes `%`, `_` and `\`, so the raw term goes out as typed
18
20
  *
19
21
  * @module utils/columnFilters
20
22
  * @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md
23
+ * @spec openspec/changes/header-filter-contains/specs/cn-data-table/spec.md#requirement-a-text-header-filter-matches-on-contains
21
24
  */
22
25
 
23
26
  import { schemaRefSlug } from './schemaRefSlug.js'
24
27
 
28
+ /** OpenRegister's case-insensitive contains operator, used by the text filter. */
29
+ export const CONTAINS_OPERATOR = 'like'
30
+
25
31
  /** Column keys that mark a value computed on the client, never stored. */
26
32
  const COMPUTED_MARKERS = ['aggregate', 'compute', 'computed', 'virtual']
27
33
 
@@ -160,7 +166,7 @@ function referenceTarget(column, prop) {
160
166
  *
161
167
  * Kinds: `enum` (checkbox list), `boolean` (yes, no, any), `reference`
162
168
  * (searchable list of the referenced objects), `number` and `date` (from and
163
- * to), and `string` (equals). `filterable: false` on the column turns it
169
+ * to), and `string` (contains, through OpenRegister's `[like]`). `filterable: false` on the column turns it
164
170
  * off. A column with no schema property behind it only filters when it
165
171
  * carries an enum hint (`enum`, or a badge widget's colour map), because the
166
172
  * server cannot filter on a value it does not store.
@@ -234,9 +240,25 @@ export function columnFilterParamKeys(def) {
234
240
  if (def.kind === 'number' || def.kind === 'date') {
235
241
  return [`${def.key}[gte]`, `${def.key}[lte]`]
236
242
  }
243
+ if (def.kind === 'string') {
244
+ return [containsKey(def)]
245
+ }
237
246
  return [def.key]
238
247
  }
239
248
 
249
+ /**
250
+ * The parameter a text filter writes: `{key}[like]`. An exact `key=value`
251
+ * from the facet sidebar or a fixed filter is a different key, so the header
252
+ * neither shows nor clears it.
253
+ *
254
+ * @param {object} def A filter definition of kind `string`.
255
+ * @return {string} The parameter key.
256
+ * @spec openspec/changes/header-filter-contains/specs/cn-data-table/spec.md#requirement-a-text-header-filter-matches-on-contains
257
+ */
258
+ function containsKey(def) {
259
+ return `${def.key}[${CONTAINS_OPERATOR}]`
260
+ }
261
+
240
262
  /**
241
263
  * A column filter's current state, read from the active-filter map.
242
264
  *
@@ -262,6 +284,9 @@ export function columnFilterState(def, activeFilters) {
262
284
  to: def.kind === 'date' ? to.slice(0, 10) : to,
263
285
  }
264
286
  }
287
+ if (def.kind === 'string') {
288
+ return { value: asList(filters[containsKey(def)])[0] || '' }
289
+ }
265
290
  return { value: asList(filters[def.key])[0] || '' }
266
291
  }
267
292
 
@@ -309,8 +334,10 @@ export function columnFilterParams(def, state) {
309
334
  if (def.kind === 'boolean') {
310
335
  return { [def.key]: (s.value === 'true' || s.value === 'false') ? [s.value] : [] }
311
336
  }
337
+ // Text: the raw term under `[like]`. No wildcards: OpenRegister wraps
338
+ // the term itself and escapes `%`, `_` and `\` in it.
312
339
  const value = s.value === undefined || s.value === null ? '' : String(s.value).trim()
313
- return { [def.key]: value === '' ? [] : [value] }
340
+ return { [containsKey(def)]: value === '' ? [] : [value] }
314
341
  }
315
342
 
316
343
  /**