@conduction/nextcloud-vue 2.106.0 → 2.107.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 (135) hide show
  1. package/dist/esm/components/CnActionDialog/CnActionDialog.vue.js +9 -0
  2. package/dist/esm/components/CnActionDialog/CnActionDialog.vue.js.map +1 -0
  3. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js +338 -0
  4. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -0
  5. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js +249 -0
  6. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js.map +1 -0
  7. package/dist/esm/components/CnAdvancedFormDialog/CnPropertiesTab.vue2.js +2 -2
  8. package/dist/esm/components/CnAdvancedFormDialog/CnPropertiesTab.vue2.js.map +1 -1
  9. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  10. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +145 -138
  11. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +102 -2
  13. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +83 -6
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +4 -2
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
  21. package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
  22. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +389 -20
  23. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +68 -35
  25. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  26. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +22 -2
  27. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  28. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js +69 -5
  29. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
  30. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js +38 -5
  31. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
  32. package/dist/esm/components/CnMapWidget/CnMapWidget.vue.js +1 -1
  33. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js +7 -1
  34. package/dist/esm/components/CnMapWidget/CnMapWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnMapWidget/CnMapWidget.vue3.js.map +1 -1
  36. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue.js +8 -0
  37. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue.js.map +1 -0
  38. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue2.js +297 -0
  39. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue2.js.map +1 -0
  40. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue3.js +24 -0
  41. package/dist/esm/components/CnPartyPicker/CnPartyPicker.vue3.js.map +1 -0
  42. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue.js +9 -0
  43. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue.js.map +1 -0
  44. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue2.js +95 -0
  45. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue2.js.map +1 -0
  46. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue3.js +123 -0
  47. package/dist/esm/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue3.js.map +1 -0
  48. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue.js +1 -1
  49. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue2.js +13 -5
  50. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue2.js.map +1 -1
  51. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue3.js +3 -1
  52. package/dist/esm/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue3.js.map +1 -1
  53. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue.js +1 -1
  54. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue2.js +49 -23
  55. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue2.js.map +1 -1
  56. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue3.js +74 -27
  57. package/dist/esm/components/CnSavedViewShareFields/CnSavedViewShareFields.vue3.js.map +1 -1
  58. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue.js +0 -1
  59. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue.js.map +1 -1
  60. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js +18 -413
  61. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js.map +1 -1
  62. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js +27 -214
  63. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js.map +1 -1
  64. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue.js +9 -0
  65. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue.js.map +1 -0
  66. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue2.js +190 -0
  67. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue2.js.map +1 -0
  68. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue3.js +270 -0
  69. package/dist/esm/components/CnSavedViewsPanel/CnSavedViewsPanel.vue3.js.map +1 -0
  70. package/dist/esm/index.js +4 -0
  71. package/dist/esm/index.js.map +1 -1
  72. package/dist/esm/l10n/en.json.js +56 -1
  73. package/dist/esm/l10n/en.json.js.map +1 -1
  74. package/dist/esm/l10n/nl.json.js +56 -1
  75. package/dist/esm/l10n/nl.json.js.map +1 -1
  76. package/dist/esm/library-style.js +1 -1
  77. package/dist/esm/mixins/savedViewsList.js +235 -0
  78. package/dist/esm/mixins/savedViewsList.js.map +1 -0
  79. package/dist/esm/nextcloud-vue.css +348 -108
  80. package/dist/esm/utils/actionsDispatcher.js +2 -2
  81. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  82. package/dist/esm/utils/builtInModals.js +39 -0
  83. package/dist/esm/utils/builtInModals.js.map +1 -0
  84. package/dist/esm/utils/mapMarkerColor.js +121 -0
  85. package/dist/esm/utils/mapMarkerColor.js.map +1 -0
  86. package/dist/esm/utils/mergeManifestDelta.js +4 -0
  87. package/dist/esm/utils/mergeManifestDelta.js.map +1 -1
  88. package/dist/esm/utils/savedViewHelpers.js +59 -7
  89. package/dist/esm/utils/savedViewHelpers.js.map +1 -1
  90. package/dist/esm/utils/savedViewMenu.js +154 -0
  91. package/dist/esm/utils/savedViewMenu.js.map +1 -0
  92. package/dist/esm/utils/searchGroupSharees.js +50 -15
  93. package/dist/esm/utils/searchGroupSharees.js.map +1 -1
  94. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  95. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  96. package/dist/nextcloud-vue.cjs.js +13351 -10855
  97. package/dist/nextcloud-vue.cjs.js.map +2 -2
  98. package/dist/nextcloud-vue.css +348 -108
  99. package/l10n/en.json +56 -1
  100. package/l10n/nl.json +56 -1
  101. package/package.json +1 -1
  102. package/src/components/CnActionDialog/CnActionDialog.vue +545 -0
  103. package/src/components/CnActionDialog/index.js +12 -0
  104. package/src/components/CnAppNav/CnAppNav.vue +17 -0
  105. package/src/components/CnAppRoot/CnAppRoot.vue +102 -2
  106. package/src/components/CnDashboardPage/CnDashboardPage.vue +86 -7
  107. package/src/components/CnIndexPage/CnIndexPage.vue +409 -23
  108. package/src/components/CnIndexPage/useSelfFetchList.js +21 -1
  109. package/src/components/CnIndexSidebar/CnIndexSidebar.md +2 -0
  110. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +91 -1
  111. package/src/components/CnMapWidget/CnMapWidget.vue +16 -1
  112. package/src/components/CnPartyPicker/CnPartyPicker.vue +317 -0
  113. package/src/components/CnPartyPicker/index.js +11 -0
  114. package/src/components/CnSavedViewEditDialog/CnSavedViewEditDialog.vue +192 -0
  115. package/src/components/CnSavedViewEditDialog/index.js +11 -0
  116. package/src/components/CnSavedViewShareDialog/CnSavedViewShareDialog.vue +21 -6
  117. package/src/components/CnSavedViewShareFields/CnSavedViewShareFields.vue +130 -37
  118. package/src/components/CnSavedViewsControl/CnSavedViewsControl.vue +36 -599
  119. package/src/components/CnSavedViewsPanel/CnSavedViewsPanel.vue +459 -0
  120. package/src/components/CnSavedViewsPanel/index.js +11 -0
  121. package/src/components/index.js +4 -0
  122. package/src/css/index-page.css +16 -0
  123. package/src/css/look-board-index.css +5 -3
  124. package/src/css/look-board.css +18 -3
  125. package/src/index.js +4 -0
  126. package/src/mixins/savedViewsList.js +231 -0
  127. package/src/schemas/app-manifest-v2.schema.json +98 -3
  128. package/src/utils/actionsDispatcher.js +2 -2
  129. package/src/utils/builtInModals.js +35 -0
  130. package/src/utils/mapMarkerColor.js +117 -0
  131. package/src/utils/mergeManifestDelta.js +4 -0
  132. package/src/utils/savedViewHelpers.js +58 -6
  133. package/src/utils/savedViewMenu.js +150 -0
  134. package/src/utils/searchGroupSharees.js +49 -14
  135. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -0,0 +1,231 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * savedViewsList — what the saved-views panel and the toolbar chips share:
6
+ * the views as a tree with the defaults first, the sections, the labels,
7
+ * counts and access. Presentational only; the page owns fetching and writes.
8
+ *
9
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-6
10
+ */
11
+ import { translate as t } from '@nextcloud/l10n'
12
+ import { buildViewTree, labelsInUse, VIEW_GROUPS } from '../utils/buildViewTree.js'
13
+ import { viewAccess } from '../utils/savedViewHelpers.js'
14
+
15
+ export const savedViewsList = {
16
+ props: {
17
+ /** Views to list (View API objects from `GET /apps/openregister/api/views`, defaults included). */
18
+ views: {
19
+ type: Array,
20
+ default: () => [],
21
+ },
22
+
23
+ /** True while the parent is fetching the view list. */
24
+ loading: {
25
+ type: Boolean,
26
+ default: false,
27
+ },
28
+
29
+ /** The signed-in NC user id; a view without `@self.access` is the owner's when it matches. */
30
+ currentUserId: {
31
+ type: String,
32
+ default: '',
33
+ },
34
+
35
+ /** How deep the tree indents before it flattens (`savedViewTree.maxDepth`). */
36
+ maxDepth: {
37
+ type: Number,
38
+ default: 3,
39
+ },
40
+
41
+ /**
42
+ * How many records each view matches, keyed by view id (or slug). `null`
43
+ * shows no counts.
44
+ *
45
+ * @type {{[viewId: string]: number}|null}
46
+ */
47
+ counts: {
48
+ type: Object,
49
+ default: null,
50
+ },
51
+
52
+ /** The id (or slug) of the applied view. */
53
+ selectedViewId: {
54
+ type: String,
55
+ default: '',
56
+ },
57
+ },
58
+
59
+ data() {
60
+ return {
61
+ /** The label currently filtering the list, or '' for all of them. */
62
+ labelFilter: '',
63
+ }
64
+ },
65
+
66
+ computed: {
67
+ /** @return {string} The seeded group name, for templates. */
68
+ SEEDED_GROUP() {
69
+ return VIEW_GROUPS.SEEDED
70
+ },
71
+
72
+ /**
73
+ * The views in tree order, defaults first, filtered by the active label.
74
+ *
75
+ * @return {Array<object>} Rows of `{ view, depth, group, orphaned }`.
76
+ */
77
+ rows() {
78
+ return buildViewTree({ views: this.views, labelFilter: this.labelFilter, maxDepth: this.maxDepth })
79
+ },
80
+
81
+ /**
82
+ * The rows in their sections: one uncaptioned section when nothing is
83
+ * shared and nothing is a default, else Default views, My views and
84
+ * Shared with me.
85
+ *
86
+ * @return {Array<{key: string, label: string, rows: Array<object>}>} The sections.
87
+ * @spec openspec/changes/saved-views-shared-by-role/tasks.md#task-1
88
+ */
89
+ sections() {
90
+ const shared = this.rows.filter((r) => r.group !== VIEW_GROUPS.SEEDED && this.accessOf(r) !== 'owner')
91
+ const seeded = this.rows.filter((r) => r.group === VIEW_GROUPS.SEEDED)
92
+ if (shared.length === 0 && seeded.length === 0) {
93
+ return [{ key: 'all', label: '', rows: this.rows }]
94
+ }
95
+ const mine = this.rows.filter((r) => r.group !== VIEW_GROUPS.SEEDED && this.accessOf(r) === 'owner')
96
+ return [
97
+ { key: 'seeded', label: t('nextcloud-vue', 'Default views'), rows: seeded },
98
+ { key: 'mine', label: t('nextcloud-vue', 'My views'), rows: mine },
99
+ { key: 'shared', label: t('nextcloud-vue', 'Shared with me'), rows: shared },
100
+ ].filter((section) => section.rows.length > 0)
101
+ },
102
+
103
+ /**
104
+ * The labels in use, read from the UNFILTERED list so choosing one keeps
105
+ * the others on offer.
106
+ *
107
+ * @return {Array<string>} The labels.
108
+ */
109
+ offeredLabels() {
110
+ return labelsInUse(this.views)
111
+ },
112
+ },
113
+
114
+ methods: {
115
+ t,
116
+
117
+ /**
118
+ * Whether a view is the applied one.
119
+ *
120
+ * @param {object} view The view.
121
+ * @return {boolean}
122
+ */
123
+ isSelected(view) {
124
+ return this.selectedViewId !== '' && (this.selectedViewId === String(view.id) || this.selectedViewId === String(view.slug))
125
+ },
126
+
127
+ /**
128
+ * What the current user may do with a row's view.
129
+ *
130
+ * @param {object} row The row from buildViewTree().
131
+ * @return {'owner'|'write'|'read'} The access.
132
+ */
133
+ accessOf(row) {
134
+ return viewAccess(row.view, this.currentUserId)
135
+ },
136
+
137
+ /**
138
+ * The people and groups a view is shared with, as text.
139
+ *
140
+ * @param {object} view The View API object.
141
+ * @return {string} The user and group ids, comma-separated; empty when none.
142
+ */
143
+ groupsOf(view) {
144
+ return (Array.isArray(view.sharedWith) ? view.sharedWith : [])
145
+ .map((e) => e && (e.user || e.group))
146
+ .filter((g) => typeof g === 'string' && g !== '')
147
+ .join(', ')
148
+ },
149
+
150
+ /**
151
+ * The name a row shows, indented to its depth with figure spaces so a
152
+ * screen reader gets the same shape.
153
+ *
154
+ * @param {object} row The row from buildViewTree().
155
+ * @param {boolean} [withGroups] Append who the view is shared with.
156
+ * @return {string} The name.
157
+ */
158
+ rowName(row, withGroups = false) {
159
+ const groups = withGroups ? this.groupsOf(row.view) : ''
160
+ return `${' '.repeat(row.depth * 2)}${this.nameWithCount(row.view)}${groups !== '' ? ` · ${groups}` : ''}`
161
+ },
162
+
163
+ /**
164
+ * The number of records a view matches, or null when none is known.
165
+ *
166
+ * @param {object} view The saved view.
167
+ * @return {number|null}
168
+ */
169
+ countOf(view) {
170
+ const counts = this.counts || {}
171
+ let value = counts[view.id]
172
+ if (value === undefined || value === null) {
173
+ value = counts[view.slug]
174
+ }
175
+ if (value === undefined || value === null) {
176
+ value = view.count
177
+ }
178
+ return (typeof value === 'number' && Number.isFinite(value)) ? value : null
179
+ },
180
+
181
+ /**
182
+ * A view's name, followed by its count when one is known.
183
+ *
184
+ * @param {object} view The saved view.
185
+ * @return {string}
186
+ */
187
+ nameWithCount(view) {
188
+ const count = this.countOf(view)
189
+ return count === null ? view.name : `${view.name} (${count})`
190
+ },
191
+
192
+ /**
193
+ * What a row is called to somebody who cannot see the indentation: the
194
+ * level in words, and a note when its parent is out of reach.
195
+ *
196
+ * @param {object} row The row from buildViewTree().
197
+ * @return {string} The accessible label.
198
+ */
199
+ rowLabel(row) {
200
+ const viewName = this.nameWithCount(row.view)
201
+ const name = row.depth === 0
202
+ ? viewName
203
+ : t('nextcloud-vue', '{name}, level {level}', { name: viewName, level: row.depth + 1 })
204
+ if (row.orphaned !== true) {
205
+ return name
206
+ }
207
+ return t('nextcloud-vue', '{name}. Parts of this view come from one you cannot see.', { name })
208
+ },
209
+
210
+ /**
211
+ * The accessible label of a label filter entry.
212
+ *
213
+ * @param {string} label The label.
214
+ * @return {string}
215
+ */
216
+ labelFilterLabel(label) {
217
+ return this.labelFilter === label
218
+ ? t('nextcloud-vue', 'Stop filtering on "{label}"', { label })
219
+ : t('nextcloud-vue', 'Show only views labelled "{label}"', { label })
220
+ },
221
+
222
+ /**
223
+ * Turn a label filter on, or off when it is already on.
224
+ *
225
+ * @param {string} label The label.
226
+ */
227
+ onLabelFilter(label) {
228
+ this.labelFilter = this.labelFilter === label ? '' : label
229
+ },
230
+ },
231
+ }
@@ -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.129.0",
6
+ "version": "2.131.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -1060,7 +1060,8 @@
1060
1060
  },
1061
1061
  "seeded": {
1062
1062
  "type": "array",
1063
- "description": "Views that ship with the app. They render as their own group above the user's own, and a user may copy one but not delete it: a view the product promises is not a view one person can remove from everybody's install.",
1063
+ "deprecated": true,
1064
+ "description": "DEPRECATED since 2.129.0: declare default views with the index page's `config.defaultSavedViews`, which carries the criteria this key never had and is what the page actually lists. Nothing ever read this key, so moving off it loses nothing. Views that ship with the app. They render as their own group above the user's own, and a user may copy one but not delete it: a view the product promises is not a view one person can remove from everybody's install.",
1064
1065
  "items": {
1065
1066
  "type": "object",
1066
1067
  "additionalProperties": false,
@@ -4477,6 +4478,52 @@
4477
4478
  "type": "string",
4478
4479
  "description": "type='index': which pages share this page's saved views. By default a view is shared by every page over the same register and schema and by no other, because a view is filters over one schema's fields. Set this to share views across pages over different sources, or to keep two pages over one source apart. Written into the saved view's `query.scope`; views saved before scoping stay visible everywhere. Maps to CnIndexPage `savedViewsScope`."
4479
4480
  },
4481
+ "defaultSavedViews": {
4482
+ "type": "array",
4483
+ "description": "type='index': views the app ships for this page. Listed read-only above each user's own saved views, under Default views, and never stored: a new app version updates them for everybody and no user can delete one. A user may apply a default or save a copy, which they then own. `@`-tokens in `filters` (`@me`, `@today`, `@today+7d`) resolve when the default is applied; a copy keeps them. Maps to CnIndexPage `defaultSavedViews`.",
4484
+ "items": {
4485
+ "type": "object",
4486
+ "additionalProperties": false,
4487
+ "required": ["slug", "name", "filters"],
4488
+ "properties": {
4489
+ "slug": {
4490
+ "type": "string",
4491
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
4492
+ "description": "The stable name of the default. Lowercase and dash only."
4493
+ },
4494
+ "name": {
4495
+ "type": "string",
4496
+ "minLength": 1,
4497
+ "description": "What the filter is called in the control. Translatable."
4498
+ },
4499
+ "filters": {
4500
+ "type": "object",
4501
+ "description": "The filters, keyed by property, in the shape a quick filter's `filter` takes."
4502
+ },
4503
+ "search": {
4504
+ "type": "string",
4505
+ "description": "A search term the default applies."
4506
+ },
4507
+ "sort": {
4508
+ "type": "array",
4509
+ "description": "Sort keys, in priority order.",
4510
+ "items": {
4511
+ "type": "object",
4512
+ "additionalProperties": false,
4513
+ "required": ["key"],
4514
+ "properties": {
4515
+ "key": { "type": "string" },
4516
+ "order": { "type": "string", "enum": ["asc", "desc"] }
4517
+ }
4518
+ }
4519
+ },
4520
+ "label": {
4521
+ "type": "string",
4522
+ "description": "One label for the control's label filter."
4523
+ }
4524
+ }
4525
+ }
4526
+ },
4480
4527
  "splitCloseButton": {
4481
4528
  "type": "boolean",
4482
4529
  "default": true,
@@ -4574,13 +4621,32 @@
4574
4621
  "description": "type='dashboard': page-level filter controls in the header. Each selection is written into the page's workspace context, so a widget reads it through a `@page.<key>` / `@workspace.<key>` token (an endpoint widget also in its `responsePath`). Maps to the CnDashboardPage `pageFilters` prop.",
4575
4622
  "items": {
4576
4623
  "type": "object",
4577
- "required": ["key", "options"],
4624
+ "required": ["key"],
4625
+ "anyOf": [{ "required": ["options"] }, { "required": ["optionsFrom"] }],
4578
4626
  "additionalProperties": false,
4579
4627
  "properties": {
4580
4628
  "key": { "type": "string", "minLength": 1, "description": "Workspace-context key written on change (the `@page.<key>` / `@workspace.<key>` token name)." },
4581
4629
  "label": { "type": "string", "description": "Control label (i18n key or plain text)." },
4582
4630
  "type": { "type": "string", "enum": ["select"], "default": "select", "description": "Control type, carried over from v1." },
4583
4631
  "control": { "type": "string", "enum": ["select", "segmented"], "default": "select", "description": "How the filter is drawn: a select, or a compact segmented group." },
4632
+ "clearable": { "type": "boolean", "default": false, "description": "Lets the reader clear the select back to all; `placeholder` names that state (2.130.0)." },
4633
+ "placeholder": { "type": "string", "description": "What a cleared select shows, e.g. \"All case types\", through the app's translate (2.130.0)." },
4634
+ "optionsFrom": {
4635
+ "type": "object",
4636
+ "additionalProperties": false,
4637
+ "description": "Read the options instead of listing them: from an app endpoint (`url`, `params`, `responsePath`) or from OpenRegister objects (`register`, `schema`, `filter`). `valueField` and `labelField` pick each option's value and label (default `id` and `title` or `name`); tokens resolve (2.130.0).",
4638
+ "anyOf": [{ "required": ["url"] }, { "required": ["register", "schema"] }],
4639
+ "properties": {
4640
+ "url": { "type": "string", "minLength": 1 },
4641
+ "params": { "type": "object", "additionalProperties": true },
4642
+ "responsePath": { "type": "string", "minLength": 1 },
4643
+ "register": { "type": "string", "minLength": 1 },
4644
+ "schema": { "type": "string", "minLength": 1 },
4645
+ "filter": { "type": "object", "additionalProperties": true },
4646
+ "valueField": { "type": "string", "minLength": 1 },
4647
+ "labelField": { "type": "string", "minLength": 1 }
4648
+ }
4649
+ },
4584
4650
  "placement": { "type": "string", "enum": ["row", "description"], "default": "row", "description": "Where the filter sits: in the header's filter row, or (a segmented group) on the description line after the period group (DqProcesanalyse)." },
4585
4651
  "default": { "type": ["string", "number"], "description": "Initial value seeded into the context on mount; the first option's value when omitted." },
4586
4652
  "options": {
@@ -4916,6 +4982,35 @@
4916
4982
  "type": "boolean",
4917
4983
  "default": true,
4918
4984
  "description": "Whether coincident markers are grouped into a cluster that spiderfies on click. ON by default here — the opposite of CnMapWidget's own default — because an index map plots whatever the filter returns, and several rows at one address is the normal case: unclustered they land on identical pixels, the topmost takes every click and the rest are unreachable with nothing on screen saying they exist. Non-point geometry skips the cluster group, so areas are unaffected. Set false for the old behaviour."
4985
+ },
4986
+ "markerColor": {
4987
+ "type": "object",
4988
+ "required": ["field"],
4989
+ "additionalProperties": false,
4990
+ "description": "Colour each marker by a field of its row. Without `source` the value is looked up in `colorMap`; with `source` the field is a reference and the referenced row's `colorField` is the colour, or the key `colorMap` maps to one. A value nothing maps to gets `default`, else the map's own colour.",
4991
+ "properties": {
4992
+ "field": { "type": "string", "description": "The row field (dotted path, also under `@self`)." },
4993
+ "colorMap": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Value (or source colour key) to CSS colour; `var(--x, #fallback)` is resolved for the SVG marker." },
4994
+ "source": {
4995
+ "type": "object",
4996
+ "required": ["colorField"],
4997
+ "properties": {
4998
+ "register": { "type": "string" },
4999
+ "schema": { "type": "string" },
5000
+ "colorField": { "type": "string" },
5001
+ "filter": { "type": "object" }
5002
+ }
5003
+ },
5004
+ "default": { "type": "string" }
5005
+ }
5006
+ },
5007
+ "plotAll": {
5008
+ "oneOf": [{ "type": "boolean" }, { "type": "integer", "minimum": 1 }],
5009
+ "description": "Plot every row that matches the filters instead of the current page: `true` for up to 500, or a number. When more rows match, a note under the map says the first N are shown."
5010
+ },
5011
+ "count": {
5012
+ "oneOf": [{ "type": "boolean" }, { "type": "string" }],
5013
+ "description": "A count line under the map: `true` for \"Showing {located} of {total} located\", or a template with `{located}` and `{total}` through the app's translate."
4919
5014
  }
4920
5015
  }
4921
5016
  },
@@ -51,6 +51,7 @@ import { translate as t } from '@nextcloud/l10n'
51
51
  import { parseDispositionFilename, triggerBlobDownload } from '../components/CnIndexPage/selfModeIO.js'
52
52
  import { interpolateUrlTokens } from '../composables/useEndpointSource.js'
53
53
  import { routeHref } from './actionLink.js'
54
+ import { resolveModalEntry } from './builtInModals.js'
54
55
  import {
55
56
  dropOptionalUnresolved,
56
57
  dropOptionalUnresolvedDeep,
@@ -836,8 +837,7 @@ export function dispatchAction(action, context = {}) {
836
837
 
837
838
  case 'open-modal': {
838
839
  const target = action.target
839
- const registry = context.registry || {}
840
- const entry = registry[target]
840
+ const entry = resolveModalEntry(context.registry, target)
841
841
 
842
842
  if (!entry) {
843
843
  // eslint-disable-next-line no-console
@@ -0,0 +1,35 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * Modals every app can open by name with an `open-modal` action, without
6
+ * registering them. An app's own registry entry of the same name wins.
7
+ *
8
+ * @module utils/builtInModals
9
+ */
10
+
11
+ import { defineAsyncComponent } from 'vue'
12
+
13
+ /**
14
+ * The built-in modals, as registry entries (`{ kind: 'modal', component }`):
15
+ * `CnActionDialog`, the generic summary, checklist, reason and confirm dialog,
16
+ * its `config` in the action's `props`. Loaded on first open.
17
+ *
18
+ * @type {Readonly<Record<string, {kind: string, component: object}>>}
19
+ * @spec openspec/changes/action-dialog/specs/action-dialog/spec.md#requirement-a-manifest-configures-an-action-dialog
20
+ */
21
+ export const BUILT_IN_MODALS = Object.freeze({
22
+ CnActionDialog: { kind: 'modal', component: defineAsyncComponent(() => import('../components/CnActionDialog/CnActionDialog.vue')) },
23
+ })
24
+
25
+ /**
26
+ * The registry entry for a modal name: the app's own, else a built-in one.
27
+ *
28
+ * @param {object|null} registry The app's registry.
29
+ * @param {string} name The modal's name.
30
+ * @return {object|null} The entry, or null.
31
+ */
32
+ export function resolveModalEntry(registry, name) {
33
+ const own = registry && typeof registry === 'object' ? registry[name] : undefined
34
+ return own || BUILT_IN_MODALS[name] || null
35
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * mapMarkerColor — the colour of an index map's marker, read from a field of
6
+ * its row (`mapConfig.markerColor`).
7
+ *
8
+ * ```json
9
+ * "markerColor": {
10
+ * "field": "status",
11
+ * "colorMap": { "open": "#0b71ab", "closed": "var(--nl-color-grey, #696969)" },
12
+ * "source": { "schema": "statusType", "colorField": "colour" },
13
+ * "default": "#696969"
14
+ * }
15
+ * ```
16
+ *
17
+ * Without `source` the field's value is looked up in `colorMap`. With
18
+ * `source` the field holds a reference: the referenced row's `colorField`
19
+ * is the colour, or the key `colorMap` maps to one ("blue" -> a CSS colour).
20
+ * A value nothing maps to gets `default`, else no colour (the map's own).
21
+ *
22
+ * @module utils/mapMarkerColor
23
+ */
24
+
25
+ import { refIdOf } from './boardReferenceColumns.js'
26
+
27
+ /** A colour that can go into a style or an SVG attribute: no quotes, no `;`, no `url(`. */
28
+ const SAFE_COLOR = /^(?!.*url\()[#a-zA-Z0-9(),.%\s_-]{1,120}$/
29
+
30
+ /**
31
+ * The `markerColor` block in a usable shape, or null when it names no field.
32
+ *
33
+ * @param {unknown} cfg The configured block.
34
+ * @return {{field: string, colorMap: object, source: (object|null), fallback: string}|null} The block.
35
+ */
36
+ export function normalizeMarkerColor(cfg) {
37
+ if (!cfg || typeof cfg !== 'object' || typeof cfg.field !== 'string' || cfg.field === '') {
38
+ return null
39
+ }
40
+ const source = cfg.source && typeof cfg.source === 'object' && typeof cfg.source.colorField === 'string' ? cfg.source : null
41
+ return {
42
+ field: cfg.field,
43
+ colorMap: cfg.colorMap && typeof cfg.colorMap === 'object' ? cfg.colorMap : {},
44
+ source,
45
+ fallback: typeof cfg.default === 'string' ? cfg.default : '',
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Read a dotted path off a row, also under `@self`.
51
+ *
52
+ * @param {object} row The row.
53
+ * @param {string} path The path.
54
+ * @return {unknown} The value.
55
+ */
56
+ function valueAt(row, path) {
57
+ const read = (base) => path.split('.').reduce((o, k) => (o === null || o === undefined ? undefined : o[k]), base)
58
+ const direct = read(row)
59
+ return direct === undefined && row && row['@self'] ? read(row['@self']) : direct
60
+ }
61
+
62
+ /**
63
+ * The marker colour of one row.
64
+ *
65
+ * @param {object} row The row.
66
+ * @param {object|null} cfg The normalised block (see {@link normalizeMarkerColor}).
67
+ * @param {Map<string, object>} [refsById] The source rows by id, when `cfg.source` is set.
68
+ * @return {string} A CSS colour, or '' for the map's own.
69
+ */
70
+ export function markerColorOf(row, cfg, refsById) {
71
+ if (!cfg || !row) {
72
+ return ''
73
+ }
74
+ const raw = valueAt(row, cfg.field)
75
+ let key = ''
76
+ if (cfg.source) {
77
+ const ref = refsById && refsById.get(refIdOf(raw))
78
+ key = ref ? String(ref[cfg.source.colorField] ?? '') : ''
79
+ } else if (raw !== null && raw !== undefined && typeof raw !== 'object') {
80
+ key = String(raw)
81
+ }
82
+ let color
83
+ if (key !== '' && Object.hasOwn(cfg.colorMap, key)) {
84
+ color = String(cfg.colorMap[key])
85
+ } else if (key !== '' && cfg.source) {
86
+ color = key
87
+ } else {
88
+ color = cfg.fallback
89
+ }
90
+ return SAFE_COLOR.test(color) ? color : ''
91
+ }
92
+
93
+ /**
94
+ * Resolve `var(--name, fallback)` to a concrete colour, for an SVG
95
+ * attribute, where a custom property does not resolve.
96
+ *
97
+ * @param {string} color The colour.
98
+ * @param {Element} [root] The element whose custom properties count.
99
+ * @return {string} The colour without `var()`, or the input when it has none.
100
+ */
101
+ export function resolveCssColor(color, root) {
102
+ const match = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/.exec(String(color || '').trim())
103
+ if (!match) {
104
+ return color
105
+ }
106
+ let value
107
+ try {
108
+ const el = root || (typeof document !== 'undefined' ? document.documentElement : null)
109
+ value = el && typeof getComputedStyle === 'function' ? getComputedStyle(el).getPropertyValue(match[1]).trim() : ''
110
+ } catch {
111
+ value = ''
112
+ }
113
+ if (value !== '') {
114
+ return value
115
+ }
116
+ return match[2] ? resolveCssColor(match[2].trim(), root) : ''
117
+ }
@@ -45,6 +45,10 @@ export const KEYED_ARRAYS = Object.freeze({
45
45
  // (or a backend `/api/manifest` override) can add/patch/remove individual
46
46
  // children of a group without replacing the whole `children[]` array.
47
47
  children: 'id',
48
+ // An index page's default views merge by `slug`, so the per-user delta
49
+ // (`/api/manifest`) can add a user's own defaults (dossiq's case type
50
+ // pins) without wiping the defaults the page ships.
51
+ defaultSavedViews: 'slug',
48
52
  })
49
53
 
50
54
  /** Reserved delta markers — never part of a base manifest. */
@@ -213,22 +213,30 @@ export function buildViewCreatePayload({ name, description, isPublic, isDefault,
213
213
  }
214
214
 
215
215
  /**
216
- * Clean an audience list: keep `{ group, mode }` entries with a group id, and
217
- * coerce any mode but `write` to `read`.
216
+ * Clean an audience list: keep `{ group, mode }` and `{ user, mode }` entries
217
+ * that name exactly one target, drop repeats, and coerce any mode but `write`
218
+ * to `read`.
218
219
  *
219
220
  * @param {unknown} sharedWith The audience as given.
220
- * @return {Array<{group: string, mode: 'read'|'write'}>} The cleaned list.
221
+ * @return {Array<{group?: string, user?: string, mode: 'read'|'write'}>} The cleaned list.
222
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-5
221
223
  */
222
224
  export function normalizeSharedWith(sharedWith) {
223
225
  const seen = new Set()
224
226
  const out = []
225
227
  for (const entry of Array.isArray(sharedWith) ? sharedWith : []) {
226
228
  const group = entry && typeof entry.group === 'string' ? entry.group : ''
227
- if (group === '' || seen.has(group)) {
229
+ const user = entry && typeof entry.user === 'string' ? entry.user : ''
230
+ if ((group === '') === (user === '')) {
228
231
  continue
229
232
  }
230
- seen.add(group)
231
- out.push({ group, mode: entry.mode === 'write' ? 'write' : 'read' })
233
+ const key = user !== '' ? `user:${user}` : `group:${group}`
234
+ if (seen.has(key)) {
235
+ continue
236
+ }
237
+ seen.add(key)
238
+ const mode = entry.mode === 'write' ? 'write' : 'read'
239
+ out.push(user !== '' ? { user, mode } : { group, mode })
232
240
  }
233
241
  return out
234
242
  }
@@ -376,3 +384,47 @@ export function extractViewState(view) {
376
384
  export function isOwnView(view, currentUserId) {
377
385
  return !!view && !!currentUserId && view.owner === currentUserId
378
386
  }
387
+
388
+ /**
389
+ * The default views an index page declares (`config.defaultSavedViews`), as
390
+ * read-only views the saved-views control lists. They are never stored: the
391
+ * manifest installs them, so every user has the same set and none can delete
392
+ * one. `seeded: true` puts them in the control's default group; the query
393
+ * keeps its `@`-tokens, which resolve when a default is applied.
394
+ *
395
+ * @param {Array<object>|null|undefined} defaults The declared entries `{ slug, name, filters, search?, sort?, label? }`.
396
+ * @param {{ scope?: string, register?: string, schema?: object|string|null }} [page] The page, as `savedViewScope` takes it.
397
+ * @return {Array<object>} The views, in declaration order; entries without a slug or name are left out.
398
+ * @spec openspec/changes/saved-views-manage-and-defaults/tasks.md#task-4
399
+ */
400
+ export function defaultFilterViews(defaults, page = {}) {
401
+ if (!Array.isArray(defaults)) {
402
+ return []
403
+ }
404
+ return defaults
405
+ .filter((entry) => entry && typeof entry.slug === 'string' && entry.slug !== '' && typeof entry.name === 'string' && entry.name !== '')
406
+ .map((entry) => {
407
+ const { query } = buildViewCreatePayload({
408
+ name: entry.name,
409
+ state: {
410
+ filters: (entry.filters && typeof entry.filters === 'object' && !Array.isArray(entry.filters)) ? entry.filters : {},
411
+ search: typeof entry.search === 'string' ? entry.search : '',
412
+ sortKeys: Array.isArray(entry.sort) ? entry.sort : [],
413
+ },
414
+ scope: savedViewScope(page),
415
+ register: page.register,
416
+ schema: page.schema,
417
+ })
418
+ return {
419
+ id: `default:${entry.slug}`,
420
+ slug: entry.slug,
421
+ name: entry.name,
422
+ owner: '',
423
+ seeded: true,
424
+ isPublic: false,
425
+ labels: typeof entry.label === 'string' && entry.label !== '' ? [entry.label] : [],
426
+ '@self': { access: 'read' },
427
+ query,
428
+ }
429
+ })
430
+ }