@conduction/nextcloud-vue 2.56.0-beta.11 → 2.56.0-beta.13

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 (138) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +253 -10
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +139 -46
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
  15. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  17. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  18. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  19. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  20. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  21. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  22. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +154 -4
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +555 -480
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  28. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  30. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  32. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  34. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +187 -19
  36. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +215 -135
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  40. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  44. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +157 -7
  45. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  46. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +23 -5
  47. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  49. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  53. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  54. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  55. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  56. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  57. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  58. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  59. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  60. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  61. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  62. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  63. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  64. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  65. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  66. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +74 -5
  67. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  68. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +15 -8
  69. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  70. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  71. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  72. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  73. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  74. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  75. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  76. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  77. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  78. package/dist/esm/l10n/en.json.js +5 -1
  79. package/dist/esm/l10n/en.json.js.map +1 -1
  80. package/dist/esm/l10n/nl.json.js +5 -1
  81. package/dist/esm/l10n/nl.json.js.map +1 -1
  82. package/dist/esm/mixins/pageViews.js +400 -0
  83. package/dist/esm/mixins/pageViews.js.map +1 -0
  84. package/dist/esm/nextcloud-vue.css +513 -81
  85. package/dist/esm/store/useObjectStore.js +118 -33
  86. package/dist/esm/store/useObjectStore.js.map +1 -1
  87. package/dist/esm/utils/actionsDispatcher.js +25 -2
  88. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  89. package/dist/esm/utils/columnFilters.js +27 -3
  90. package/dist/esm/utils/columnFilters.js.map +1 -1
  91. package/dist/esm/utils/schema.js +40 -2
  92. package/dist/esm/utils/schema.js.map +1 -1
  93. package/dist/esm/utils/schemaRefSlug.js +19 -3
  94. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  95. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  96. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  97. package/dist/nextcloud-vue.cjs.js +8858 -6566
  98. package/dist/nextcloud-vue.cjs.js.map +2 -2
  99. package/dist/nextcloud-vue.css +513 -81
  100. package/l10n/en.json +5 -0
  101. package/l10n/nl.json +5 -0
  102. package/package.json +1 -1
  103. package/src/components/CnAppNav/CnAppNav.vue +441 -14
  104. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  105. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  106. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  107. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  108. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  109. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  110. package/src/components/CnDashboardPage/CnDashboardPage.vue +237 -12
  111. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  112. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  113. package/src/components/CnDetailPage/CnDetailPage.md +8 -0
  114. package/src/components/CnDetailPage/CnDetailPage.vue +263 -7
  115. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  116. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  117. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +206 -9
  118. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  119. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  120. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  121. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  122. package/src/components/CnStagesWidget/CnStagesWidget.vue +189 -2
  123. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  124. package/src/components/CnStatWidget/CnStatWidget.vue +89 -7
  125. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  126. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  127. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  128. package/src/css/detail-page.css +37 -4
  129. package/src/css/kpi-card.css +71 -1
  130. package/src/css/table.css +18 -6
  131. package/src/mixins/pageViews.js +396 -0
  132. package/src/schemas/app-manifest-v2.schema.json +162 -3
  133. package/src/store/useObjectStore.js +118 -33
  134. package/src/utils/actionsDispatcher.js +25 -2
  135. package/src/utils/columnFilters.js +29 -2
  136. package/src/utils/schema.js +40 -2
  137. package/src/utils/schemaRefSlug.js +19 -3
  138. 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.45.0",
6
+ "version": "2.48.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -238,6 +238,45 @@
238
238
  "type": "string",
239
239
  "description": "Override label for the settings foldout's gear button. Defaults to 'Settings'."
240
240
  },
241
+ "card": {
242
+ "type": "object",
243
+ "additionalProperties": false,
244
+ "required": ["title"],
245
+ "description": "A card above the footer entries, rendered by CnAppNav (zuiddrecht-pixel-gaps): a title, a line of text and one link. Title, text and link label are i18n keys. Omitted, nothing renders there.",
246
+ "properties": {
247
+ "title": { "type": "string", "minLength": 1 },
248
+ "text": { "type": "string" },
249
+ "link": {
250
+ "type": "object",
251
+ "additionalProperties": false,
252
+ "required": ["label"],
253
+ "description": "The card's link: a `route` (named page, optional `params`) is a router link, an `href` a plain anchor, an `action` id a button that emits `card-action`.",
254
+ "properties": {
255
+ "label": { "type": "string", "minLength": 1 },
256
+ "route": { "type": "string" },
257
+ "params": { "type": "object", "additionalProperties": true },
258
+ "href": { "type": "string" },
259
+ "action": { "type": "string" }
260
+ }
261
+ }
262
+ }
263
+ },
264
+ "help": {
265
+ "type": "object",
266
+ "additionalProperties": false,
267
+ "required": ["label"],
268
+ "description": "A help entry with a help icon in the navigation footer, above the settings foldout (zuiddrecht-pixel-gaps): `route` (named page) or `href` (opens in a new tab).",
269
+ "properties": {
270
+ "label": { "type": "string", "minLength": 1 },
271
+ "route": { "type": "string" },
272
+ "href": { "type": "string" }
273
+ }
274
+ },
275
+ "footer": {
276
+ "type": "array",
277
+ "items": { "type": "string", "minLength": 1 },
278
+ "description": "Declare the navigation footer (zuiddrecht-pixel-gaps-2): an ordered list of `section: \"footer\"` menu entry ids, plus the reserved ids `help` (the `nav.help` entry) and `settings` (the settings foldout). Only the named footer entries render there, in that order; footer entries left out move into the settings foldout so their pages stay reachable. `settings` first puts the foldout above the list (the board's `[\"settings\", \"help\"]`). Omitted, the footer renders as before: help, every footer entry, then the foldout."
279
+ },
241
280
  "primaryAction": {
242
281
  "$ref": "#/$defs/primaryAction",
243
282
  "description": "App-wide default primary action rendered as an NcAppNavigationNew button above the main menu list. A page-scoped pages[].primaryAction for the current route wins over this default."
@@ -251,6 +290,10 @@
251
290
  "type": "string",
252
291
  "description": "URL of the logo image. Decorative when `name` is set."
253
292
  },
293
+ "emblem": {
294
+ "type": ["string", "boolean"],
295
+ "description": "An emblem drawn in place of the logo (zuiddrecht-pixel-gaps): the municipality's mark beside the app name, distinct from the wordmark in the top bar. A URL, or `true` to draw the theme's emblem from the `--nldesign-emblem-url` custom property. Decorative when `name` is set."
296
+ },
254
297
  "name": {
255
298
  "type": "string",
256
299
  "description": "i18n key / text for the app or organisation name."
@@ -1158,6 +1201,23 @@
1158
1201
  },
1159
1202
  "payload": {
1160
1203
  "description": "Free-form payload echoed back to the host inside the @primary-action-click event."
1204
+ },
1205
+ "action": {
1206
+ "$ref": "#/$defs/action",
1207
+ "description": "A page action to run on click (zuiddrecht-pixel-gaps): CnAppNav then renders the button through CnActionButtons, so `type: \"open-form\"` (with `register` + `schema`) opens the create dialog without a create page, `navigate` pushes a route, `api-call` calls an endpoint. Drawn as one solid full-width primary button. `route` / `href` on the primary action itself are ignored while `action` is set."
1208
+ },
1209
+ "solid": {
1210
+ "type": "boolean",
1211
+ "default": false,
1212
+ "description": "Draw the primary action as a solid, full-width primary button whatever its form (zuiddrecht-pixel-gaps). Default: a link action is already a primary button; a plain one is NcAppNavigationNew."
1213
+ },
1214
+ "permission": {
1215
+ "type": "string",
1216
+ "description": "Permission the reader must hold for the action to render, checked like `menuItem.permission` (zuiddrecht-pixel-gaps)."
1217
+ },
1218
+ "visibleIf": {
1219
+ "$ref": "#/$defs/visibleIfCondition",
1220
+ "description": "Display condition evaluated at render time, like `menuItem.visibleIf` (zuiddrecht-pixel-gaps). A gated-out page-scoped action falls back to `nav.primaryAction`."
1161
1221
  }
1162
1222
  }
1163
1223
  },
@@ -1221,9 +1281,20 @@
1221
1281
  "count": {
1222
1282
  "oneOf": [
1223
1283
  { "type": "integer", "minimum": 0 },
1224
- { "type": "string", "enum": ["auto"] }
1284
+ { "type": "string", "enum": ["auto"] },
1285
+ {
1286
+ "type": "object",
1287
+ "additionalProperties": false,
1288
+ "required": ["register", "schema"],
1289
+ "description": "A FILTERED count (zuiddrecht-pixel-gaps): the total of this register and schema with `filter` applied (filter-value tokens such as `@me` and `@today` resolve at fetch time), fetched by CnAppRoot under the entry's own key so an index page's whole-schema total is untouched. Renders \"My work 6\" beside \"All cases 48\".",
1290
+ "properties": {
1291
+ "register": { "type": "string", "minLength": 1 },
1292
+ "schema": { "type": "string", "minLength": 1 },
1293
+ "filter": { "type": "object", "additionalProperties": true }
1294
+ }
1295
+ }
1225
1296
  ],
1226
- "description": "Counter badge in the entry's #counter slot. Positive integer renders as-is; 'auto' resolves from cnMenuCounts (populated by CnAppRoot from useObjectStore totals) for the entry's resolved index-type page (register + schema)."
1297
+ "description": "Counter badge in the entry's #counter slot. Positive integer renders as-is; 'auto' resolves from cnMenuCounts (populated by CnAppRoot from useObjectStore totals) for the entry's resolved index-type page (register + schema); an object `{ register, schema, filter? }` is the total of that filtered list."
1227
1298
  },
1228
1299
  "pinned": {
1229
1300
  "type": "boolean",
@@ -1485,6 +1556,19 @@
1485
1556
  "minimum": 1,
1486
1557
  "description": "Row span (>= 1)."
1487
1558
  },
1559
+ "headerLink": {
1560
+ "type": "object",
1561
+ "additionalProperties": false,
1562
+ "required": ["label"],
1563
+ "description": "One text link in the widget header's actions group, before the overflow menu (zuiddrecht-pixel-gaps): \"All deadlines\", \"To the board\". A `route` (named page, optional `params` / `query`) is a router link, an `href` a plain anchor. The label is an i18n key. Rendered by CnWidgetWrapper; CnDashboardPage reads it off the layout entry or the widget definition.",
1564
+ "properties": {
1565
+ "label": { "type": "string", "minLength": 1 },
1566
+ "route": { "type": "string" },
1567
+ "params": { "type": "object", "additionalProperties": true },
1568
+ "query": { "type": "object", "additionalProperties": true },
1569
+ "href": { "type": "string" }
1570
+ }
1571
+ },
1488
1572
  "sizeToContent": {
1489
1573
  "type": ["boolean", "integer"],
1490
1574
  "minimum": 1,
@@ -3519,6 +3603,81 @@
3519
3603
  "type": "string",
3520
3604
  "description": "type='index': accessible name and tooltip for the split pane's close button. Defaults to \"Close\". Maps to CnIndexPage `splitCloseLabel`."
3521
3605
  },
3606
+ "showHeader": {
3607
+ "type": "boolean",
3608
+ "default": true,
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
+ },
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
+ },
3639
+ "showWidgetActions": {
3640
+ "type": "boolean",
3641
+ "default": true,
3642
+ "description": "type='dashboard': `false` drops the overflow Actions menu from every widget that does not set `showActions` itself, for a dashboard whose widget headers carry a header link and nothing else (zuiddrecht-pixel-gaps-2). Maps to CnDashboardPage `showWidgetActions`."
3643
+ },
3644
+ "showTitle": {
3645
+ "type": "boolean",
3646
+ "default": false,
3647
+ "description": "type='index': show the page header (icon, title, description) inline. Default false: the title renders visually hidden and the sidebar names the page. Maps to CnIndexPage `showTitle`."
3648
+ },
3649
+ "countSubtitle": {
3650
+ "type": "string",
3651
+ "description": "type='index': an i18n key for the description line with `{total}` replaced by the collection's total, e.g. \"{total} open cases\" (zuiddrecht-pixel-gaps). Shown in place of `description` once a total is known. Maps to CnIndexPage `countSubtitle`."
3652
+ },
3653
+ "showTypeEyebrow": {
3654
+ "type": "boolean",
3655
+ "default": true,
3656
+ "description": "type='detail': `false` drops the type eyebrow (the type label above the record name) once the record resolves, for a header that says the type in a pill instead (zuiddrecht-pixel-gaps). Maps to CnDetailPage `showTypeEyebrow`."
3657
+ },
3658
+ "breadcrumb": {
3659
+ "type": "object",
3660
+ "additionalProperties": false,
3661
+ "required": ["label"],
3662
+ "description": "type='detail': a breadcrumb line above the header (zuiddrecht-pixel-gaps): this crumb names the list the record belongs to (`route` + optional `params`, or `href`), the record's display name follows as the current crumb. Maps to CnDetailPage `breadcrumb`.",
3663
+ "properties": {
3664
+ "label": { "type": "string", "minLength": 1 },
3665
+ "route": { "type": "string" },
3666
+ "params": { "type": "object", "additionalProperties": true },
3667
+ "href": { "type": "string" },
3668
+ "icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name (e.g. `Home`) drawn as the first crumb instead of the label, which then becomes its accessible name (zuiddrecht-pixel-gaps-2). Without it the label shows as text." }
3669
+ }
3670
+ },
3671
+ "headerCard": {
3672
+ "type": "boolean",
3673
+ "default": false,
3674
+ "description": "type='detail': draw the header (pills, title, actions) as a bordered card on the surface colour, as the board's case header (zuiddrecht-pixel-gaps-2). Maps to CnDetailPage `headerCard`."
3675
+ },
3676
+ "headerWidget": {
3677
+ "type": "string",
3678
+ "minLength": 1,
3679
+ "description": "type='detail': the id of a widget in `widgets` to render inside the header on its own row under the title, without a card of its own, e.g. the stages bars (zuiddrecht-pixel-gaps-2). The widget leaves the body grid. Maps to CnDetailPage `headerWidget`."
3680
+ },
3522
3681
  "mapConfig": {
3523
3682
  "type": "object",
3524
3683
  "additionalProperties": true,