@conduction/nextcloud-vue 2.77.0 → 2.79.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 (159) hide show
  1. package/dist/bundled-packages.json +0 -2
  2. package/dist/esm/bundled-packages.json +0 -2
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +113 -104
  5. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  6. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js +23 -0
  7. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js.map +1 -1
  8. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +12 -2
  10. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  12. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js +2 -1
  13. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js.map +1 -1
  14. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js.map +1 -1
  15. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +117 -0
  16. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  17. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +122 -97
  18. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  19. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +37 -0
  20. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  21. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +2 -2
  22. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +168 -5
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +140 -92
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +84 -2
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +4 -2
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue.js +1 -1
  32. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js +45 -2
  33. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js.map +1 -1
  34. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue3.js.map +1 -1
  35. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +61 -1
  36. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  38. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +173 -5
  39. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  40. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +181 -99
  41. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  42. package/dist/esm/components/CnFilesBrowser/filesBrowser.js +29 -11
  43. package/dist/esm/components/CnFilesBrowser/filesBrowser.js.map +1 -1
  44. package/dist/esm/components/CnFilesBrowser/openRegisterSource.js +252 -0
  45. package/dist/esm/components/CnFilesBrowser/openRegisterSource.js.map +1 -0
  46. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +8 -2
  47. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  48. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  49. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +4 -1
  50. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  51. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -2
  53. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  54. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +3 -2
  55. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  56. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +100 -3
  57. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  58. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +4 -2
  59. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  60. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +3 -2
  61. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  62. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  63. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js.map +1 -1
  64. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js +1 -1
  65. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js.map +1 -1
  66. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +36 -0
  67. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  68. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +35 -23
  69. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  70. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js +6 -2
  71. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js.map +1 -1
  72. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue3.js.map +1 -1
  73. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +30 -1
  74. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  75. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  76. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -0
  77. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  78. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  79. package/dist/esm/composables/useAppManifest.js +8 -3
  80. package/dist/esm/composables/useAppManifest.js.map +1 -1
  81. package/dist/esm/composables/useFormDraft.js +11 -1
  82. package/dist/esm/composables/useFormDraft.js.map +1 -1
  83. package/dist/esm/composables/useObjectPresence.js +15 -4
  84. package/dist/esm/composables/useObjectPresence.js.map +1 -1
  85. package/dist/esm/index.js +1 -0
  86. package/dist/esm/index.js.map +1 -1
  87. package/dist/esm/l10n/en.json.js +68 -1
  88. package/dist/esm/l10n/en.json.js.map +1 -1
  89. package/dist/esm/l10n/nl.json.js +113 -2
  90. package/dist/esm/l10n/nl.json.js.map +1 -1
  91. package/dist/esm/nextcloud-vue.css +526 -16
  92. package/dist/esm/utils/badgeVariants.js +32 -0
  93. package/dist/esm/utils/badgeVariants.js.map +1 -0
  94. package/dist/esm/utils/boardDate.js +193 -0
  95. package/dist/esm/utils/boardDate.js.map +1 -0
  96. package/dist/esm/utils/dynamicProperties.js +5 -1
  97. package/dist/esm/utils/dynamicProperties.js.map +1 -1
  98. package/dist/esm/utils/validateManifestV2.compiled.js +57 -6
  99. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  100. package/dist/nextcloud-vue.cjs.js +2569 -811
  101. package/dist/nextcloud-vue.cjs.js.map +2 -2
  102. package/dist/nextcloud-vue.css +526 -16
  103. package/l10n/en.json +68 -1
  104. package/l10n/nl.json +113 -2
  105. package/package.json +1 -1
  106. package/scripts/build-validators.js +26 -0
  107. package/src/components/CnActionsBar/CnActionsBar.vue +48 -39
  108. package/src/components/CnAiCompanion/CnAiCompanion.vue +23 -0
  109. package/src/components/CnAppRoot/CnAppRoot.vue +12 -2
  110. package/src/components/CnBoardView/CnBoardView.vue +2 -1
  111. package/src/components/CnCellRenderer/CnCellRenderer.md +1 -0
  112. package/src/components/CnCellRenderer/CnCellRenderer.vue +169 -10
  113. package/src/components/CnChartWidget/CnChartWidget.vue +39 -2
  114. package/src/components/CnDashboardPage/CnDashboardPage.vue +248 -8
  115. package/src/components/CnDataTable/CnDataTable.md +2 -0
  116. package/src/components/CnDataTable/CnDataTable.vue +88 -4
  117. package/src/components/CnDateRangePicker/CnDateRangePicker.vue +44 -1
  118. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +61 -1
  119. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +232 -11
  120. package/src/components/CnFilesBrowser/filesBrowser.js +43 -2
  121. package/src/components/CnFilesBrowser/index.js +1 -0
  122. package/src/components/CnFilesBrowser/openRegisterSource.js +248 -0
  123. package/src/components/CnFormDialog/CnFormDialog.vue +8 -2
  124. package/src/components/CnFormPage/CnFormPage.vue +4 -1
  125. package/src/components/CnIndexPage/CnIndexPage.vue +33 -3
  126. package/src/components/CnObjectSidebar/CnFilesTab.vue +103 -4
  127. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +3 -2
  128. package/src/components/CnSavedViewsControl/CnSavedViewsControl.vue +1 -1
  129. package/src/components/CnStatWidget/CnStatWidget.vue +52 -2
  130. package/src/components/CnStatusBadge/CnStatusBadge.vue +6 -2
  131. package/src/components/CnTabsWidget/CnTabsWidget.vue +30 -1
  132. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -0
  133. package/src/components/index.js +1 -1
  134. package/src/composables/__tests__/useObjectPresence.spec.js +24 -0
  135. package/src/composables/useAppManifest.js +8 -3
  136. package/src/composables/useFormDraft.js +11 -1
  137. package/src/composables/useObjectPresence.js +15 -4
  138. package/src/css/badge.css +31 -0
  139. package/src/css/kpi-card.css +51 -0
  140. package/src/css/look-board-detail.css +10 -0
  141. package/src/css/look-board-index.css +128 -14
  142. package/src/css/look-board.css +85 -0
  143. package/src/css/table.css +37 -0
  144. package/src/index.js +4 -0
  145. package/src/schemas/app-manifest-v2.schema.json +50 -5
  146. package/src/utils/badgeVariants.js +29 -0
  147. package/src/utils/boardDate.js +189 -0
  148. package/src/utils/dynamicProperties.js +5 -1
  149. package/src/utils/validateManifestV2.compiled.js +57 -2
  150. package/dist/esm/_virtual/equal.js +0 -4
  151. package/dist/esm/_virtual/equal.js.map +0 -1
  152. package/dist/esm/_virtual/ucs2length.js +0 -4
  153. package/dist/esm/_virtual/ucs2length.js.map +0 -1
  154. package/dist/esm/node_modules/ajv/dist/runtime/equal.js +0 -9
  155. package/dist/esm/node_modules/ajv/dist/runtime/equal.js.map +0 -1
  156. package/dist/esm/node_modules/ajv/dist/runtime/ucs2length.js +0 -25
  157. package/dist/esm/node_modules/ajv/dist/runtime/ucs2length.js.map +0 -1
  158. package/dist/esm/node_modules/fast-deep-equal/index.js +0 -47
  159. package/dist/esm/node_modules/fast-deep-equal/index.js.map +0 -1
@@ -0,0 +1,248 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * A data source for CnFilesBrowser that reads and writes one OpenRegister
6
+ * object's folder through OpenRegister's own files API, not WebDAV.
7
+ *
8
+ * Why: an object's files live in the `openregister` account's home, outside
9
+ * every person's Files app, so a reader has no WebDAV path to them. Every call
10
+ * here goes through the object's access rule on the server, as the signed-in
11
+ * person: a listing needs read on the object, a change needs update. Nothing
12
+ * is shared to make the folder visible.
13
+ *
14
+ * Paths are relative to the object folder and written with a leading slash
15
+ * (`/`, `/Bijlagen`), the same shape the WebDAV browser uses for its own
16
+ * paths, so crumbs and navigation work unchanged.
17
+ *
18
+ * @spec openspec/changes/files-browser-openregister-source/specs/files-browser/spec.md
19
+ */
20
+ import axios from '@nextcloud/axios'
21
+ import { FileType } from '@nextcloud/files'
22
+ import { generateUrl } from '@nextcloud/router'
23
+
24
+ /**
25
+ * A browser path (`/a/b`) as the API's relative path (`a/b`).
26
+ *
27
+ * @param {string} path The browser path.
28
+ * @return {string} The relative path, empty for the root.
29
+ */
30
+ export function toApiPath(path) {
31
+ return String(path || '').replace(/^\/+|\/+$/g, '')
32
+ }
33
+
34
+ /**
35
+ * One API listing entry as a row the browser can render and sort.
36
+ *
37
+ * The shape follows what the browser reads off an `@nextcloud/files` node
38
+ * (basename, type, mime, size, mtime, fileid, path, source, attributes), so
39
+ * the table, the sorter and the icons need no second code path.
40
+ *
41
+ * @param {object} entry The API entry: `{ id, name, type, mimetype, size, mtime, path }`.
42
+ * @return {object} The row.
43
+ */
44
+ export function entryToNode(entry) {
45
+ const isFolder = entry.type === 'folder'
46
+ return {
47
+ source: `openregister:${entry.id}`,
48
+ fileid: entry.id,
49
+ basename: entry.name,
50
+ displayname: entry.name,
51
+ path: `/${toApiPath(entry.path)}`,
52
+ type: isFolder ? FileType.Folder : FileType.File,
53
+ mime: isFolder ? 'httpd/unix-directory' : (entry.mimetype || ''),
54
+ size: typeof entry.size === 'number' ? entry.size : undefined,
55
+ mtime: typeof entry.mtime === 'number' ? new Date(entry.mtime * 1000) : undefined,
56
+ attributes: {},
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The status of a failed request, or null.
62
+ *
63
+ * @param {Error} err The error.
64
+ * @return {number|null} The HTTP status.
65
+ */
66
+ function statusOf(err) {
67
+ return err?.response?.status ?? err?.status ?? null
68
+ }
69
+
70
+ /**
71
+ * The server's own message on a failed request, or null.
72
+ *
73
+ * @param {Error} err The error.
74
+ * @return {string|null} The message.
75
+ */
76
+ function messageOf(err) {
77
+ const data = err?.response?.data
78
+ return (data && typeof data.error === 'string' && data.error !== '') ? data.error : null
79
+ }
80
+
81
+ /**
82
+ * Build the OpenRegister source for one object.
83
+ *
84
+ * @param {object} options The object.
85
+ * @param {string} [options.apiBase] OpenRegister's API base, `/apps/openregister/api`.
86
+ * @param {string} options.register The register slug or id.
87
+ * @param {string} options.schema The schema slug or id.
88
+ * @param {string} options.objectId The object's uuid.
89
+ * @return {object} The source CnFilesBrowser takes as `source`.
90
+ */
91
+ export function createOpenRegisterSource({ apiBase = '/apps/openregister/api', register, schema, objectId }) {
92
+ const objectUrl = (suffix) => generateUrl(`${apiBase}/objects/{register}/{schema}/{objectId}${suffix}`, { register, schema, objectId })
93
+
94
+ /**
95
+ * Re-throw a failed request with its status and the server's message.
96
+ *
97
+ * @param {Error} err The error.
98
+ * @return {never}
99
+ */
100
+ const fail = (err) => {
101
+ const error = new Error(messageOf(err) || err?.message || 'Request failed')
102
+ error.status = statusOf(err)
103
+ throw error
104
+ }
105
+
106
+ return {
107
+ kind: 'openregister',
108
+
109
+ /**
110
+ * List a folder.
111
+ *
112
+ * @param {string} path The browser path.
113
+ * @return {Promise<{folder: object, nodes: Array<object>}>} The folder (with `canChange`) and its rows.
114
+ */
115
+ async list(path) {
116
+ try {
117
+ const { data } = await axios.get(objectUrl('/folder'), { params: { path: toApiPath(path) } })
118
+ if (!Array.isArray(data?.entries)) {
119
+ // Not a folder listing: an OpenRegister without these endpoints.
120
+ const error = new Error('This OpenRegister has no folder endpoint')
121
+ error.status = 501
122
+ throw error
123
+ }
124
+ const relative = toApiPath(data?.path ?? path)
125
+ return {
126
+ folder: {
127
+ fileid: data?.folderId ?? null,
128
+ path: `/${relative}`,
129
+ basename: relative.split('/').pop() || '',
130
+ type: FileType.Folder,
131
+ canChange: data?.canChange === true,
132
+ },
133
+ nodes: data.entries.map(entryToNode),
134
+ }
135
+ } catch (err) {
136
+ if (err?.status && !err.response) {
137
+ throw err
138
+ }
139
+ return fail(err)
140
+ }
141
+ },
142
+
143
+ /**
144
+ * Create a folder.
145
+ *
146
+ * @param {string} path Where, as a browser path.
147
+ * @param {string} name The new folder's name.
148
+ * @return {Promise<object>} The new folder's row.
149
+ */
150
+ async createFolder(path, name) {
151
+ try {
152
+ const { data } = await axios.post(objectUrl('/folder'), { path: toApiPath(path), name })
153
+ return entryToNode(data)
154
+ } catch (err) {
155
+ return fail(err)
156
+ }
157
+ },
158
+
159
+ /**
160
+ * Upload one file into a folder.
161
+ *
162
+ * @param {string} path The target folder, as a browser path.
163
+ * @param {File} file The file.
164
+ * @param {(percent: number) => void} [onProgress] Called with 0..100.
165
+ * @return {Promise<object>} The stored file's row.
166
+ */
167
+ async upload(path, file, onProgress) {
168
+ const form = new FormData()
169
+ form.append('path', toApiPath(path))
170
+ form.append('files[]', file, file.name)
171
+ try {
172
+ const { data } = await axios.post(objectUrl('/folder/upload'), form, {
173
+ onUploadProgress: (progress) => {
174
+ if (progress?.total && typeof onProgress === 'function') {
175
+ onProgress(Math.round((progress.loaded / progress.total) * 100))
176
+ }
177
+ },
178
+ })
179
+ const stored = Array.isArray(data?.stored) ? data.stored : []
180
+ if (stored.length === 0) {
181
+ const error = new Error(data?.rejected?.[0]?.error || 'The upload failed')
182
+ error.status = 400
183
+ throw error
184
+ }
185
+ return entryToNode(stored[0])
186
+ } catch (err) {
187
+ if (err?.status && !err.response) {
188
+ throw err
189
+ }
190
+ return fail(err)
191
+ }
192
+ },
193
+
194
+ /**
195
+ * Rename a file or folder.
196
+ *
197
+ * @param {object} node The row.
198
+ * @param {string} name The new name.
199
+ * @return {Promise<object>} The renamed row.
200
+ */
201
+ async rename(node, name) {
202
+ try {
203
+ const { data } = await axios.put(objectUrl(`/folder/${encodeURIComponent(node.fileid)}`), { name })
204
+ return entryToNode(data)
205
+ } catch (err) {
206
+ return fail(err)
207
+ }
208
+ },
209
+
210
+ /**
211
+ * Delete a file or folder.
212
+ *
213
+ * @param {object} node The row.
214
+ * @return {Promise<void>}
215
+ */
216
+ async remove(node) {
217
+ try {
218
+ await axios.delete(objectUrl(`/folder/${encodeURIComponent(node.fileid)}`))
219
+ } catch (err) {
220
+ fail(err)
221
+ }
222
+ },
223
+
224
+ /**
225
+ * Where a file downloads from: the object's own file endpoint, which
226
+ * checks read on the object.
227
+ *
228
+ * @param {object} node The row.
229
+ * @return {string|null} The url, or null for a folder.
230
+ */
231
+ downloadUrl(node) {
232
+ if (!node?.fileid || node.type === FileType.Folder) {
233
+ return null
234
+ }
235
+ return objectUrl(`/files/${encodeURIComponent(node.fileid)}`)
236
+ },
237
+
238
+ /**
239
+ * A small preview of an image, through the object's preview endpoint.
240
+ *
241
+ * @param {object} node The row.
242
+ * @return {string} The url.
243
+ */
244
+ previewUrl(node) {
245
+ return objectUrl(`/files/${encodeURIComponent(node.fileid)}/preview?width=64&height=64`)
246
+ },
247
+ }
248
+ }
@@ -1466,11 +1466,17 @@ export default {
1466
1466
 
1467
1467
  /**
1468
1468
  * What the draft indicator announces, or '' when it says nothing.
1469
+ * While the dialog shows a failed save it never says Saved: it says
1470
+ * the draft is kept on this device, so it cannot be read as the
1471
+ * server save that just failed.
1469
1472
  *
1470
1473
  * @return {string} The text.
1474
+ * @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-the-draft-indicator-never-reads-as-a-server-save-after-a-failed-save
1471
1475
  */
1472
1476
  draftIndicatorLabel() {
1473
- return draftIndicatorText(this.draftState, this.draftSavedAt)
1477
+ const saveFailed = this.result !== null && typeof this.result === 'object'
1478
+ && (Boolean(this.result.error) || this.result.success === false)
1479
+ return draftIndicatorText(this.draftState, this.draftSavedAt, Date.now(), { saveFailed })
1474
1480
  },
1475
1481
 
1476
1482
  isCreateMode() {
@@ -3885,7 +3891,7 @@ export default {
3885
3891
  const mappedProps = propertiesFromDefinitions(
3886
3892
  Array.isArray(records) ? records : [],
3887
3893
  config,
3888
- { orderFrom: 1000 + Object.keys(properties).length },
3894
+ { orderFrom: 1000 + Object.keys(properties).length, create: this.isCreateMode },
3889
3895
  )
3890
3896
  Object.assign(properties, mappedProps.properties)
3891
3897
  required.push(...mappedProps.required)
@@ -954,11 +954,14 @@ export default {
954
954
 
955
955
  /**
956
956
  * What the draft indicator announces: Saving, then Saved with a relative time.
957
+ * After a failed submit it says the draft is kept on this device instead,
958
+ * so it cannot be read as the server save that just failed.
957
959
  *
958
960
  * @return {string} The text, or '' before anything is typed.
961
+ * @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-the-draft-indicator-never-reads-as-a-server-save-after-a-failed-save
959
962
  */
960
963
  draftIndicatorLabel() {
961
- return draftIndicatorText(this.draftState, this.draftSavedAt)
964
+ return draftIndicatorText(this.draftState, this.draftSavedAt, Date.now(), { saveFailed: Boolean(this.lastError) })
962
965
  },
963
966
  },
964
967
 
@@ -111,7 +111,7 @@
111
111
  :sortValue="sortSelectValue"
112
112
  :showSearch="inlineSearch || isBoardLook"
113
113
  :searchValue="effectiveSearchValue"
114
- :searchPlaceholder="searchPlaceholder"
114
+ :searchPlaceholder="searchPlaceholder ? cnTranslate(searchPlaceholder) : searchPlaceholder"
115
115
  :showCountWithSearch="showCountWithSearch"
116
116
  :refreshing="effectiveRefreshing"
117
117
  :refreshDisabled="refreshDisabled"
@@ -565,6 +565,7 @@
565
565
  :includeColumns="includeColumns"
566
566
  :columnOverrides="columnOverrides"
567
567
  :rowClass="rowClass"
568
+ :rowTitle="rowTitle"
568
569
  :filterable="tableHeaderFilters"
569
570
  :activeFilters="effectiveActiveFilters"
570
571
  :filterRegister="typeof register === 'string' ? register : ''"
@@ -1533,6 +1534,21 @@ export default {
1533
1534
  default: () => [],
1534
1535
  },
1535
1536
 
1537
+ /**
1538
+ * How the board look draws the table's title column (manifest
1539
+ * `config.rowTitle`): `link` (the default) underlines the title,
1540
+ * `plain` draws it as bold text without an underline over a 13px muted
1541
+ * secondary line. The row stays clickable. The Nextcloud look ignores
1542
+ * it. Maps to CnDataTable `rowTitle`.
1543
+ *
1544
+ * @spec openspec/changes/screens-table-rows-parity/specs/index-list-board-look/spec.md#requirement-a-row-title-can-be-plain-text
1545
+ */
1546
+ rowTitle: {
1547
+ type: String,
1548
+ default: 'link',
1549
+ validator: (value) => ['link', 'plain'].includes(value),
1550
+ },
1551
+
1536
1552
  /** Optional MDI icon name. Defaults to schema.icon when a schema is provided. */
1537
1553
  icon: {
1538
1554
  type: String,
@@ -5156,6 +5172,7 @@ export default {
5156
5172
  * stable key and a variant.
5157
5173
  *
5158
5174
  * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-an-index-page-can-take-the-board-header
5175
+ * @spec openspec/changes/screens-index-header-buttons-parity/specs/index-list-board-look/spec.md#requirement-the-add-header-button-carries-a-plus
5159
5176
  * @return {Array<{key: string, label: string, action: string, variant: string, icon: string, format: string}>}
5160
5177
  */
5161
5178
  resolvedHeaderButtons() {
@@ -5168,6 +5185,11 @@ export default {
5168
5185
  if (label === '' && button.action === 'add') {
5169
5186
  label = this.resolvedAddLabel
5170
5187
  }
5188
+ // The board's primary button reads "+ New request": the Add
5189
+ // button takes the plus unless the manifest names an icon.
5190
+ if (this.isBoardLook && button.action === 'add' && icon === '') {
5191
+ icon = 'Plus'
5192
+ }
5171
5193
  // The board's header: Export reads "Download" with its icon and
5172
5194
  // the Actions menu reads "Actions", without the manifest saying so.
5173
5195
  if (this.isBoardLook && label === '' && button.action === 'export') {
@@ -5338,6 +5360,7 @@ export default {
5338
5360
  * filled in when one is known, else `description`.
5339
5361
  *
5340
5362
  * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-an-index-page-title-is-a-manifest-key
5363
+ * @spec openspec/changes/screens-table-footer-and-system-dates-parity/specs/index-list-board-look/spec.md#requirement-the-count-reads-in-the-users-language
5341
5364
  * @return {string}
5342
5365
  */
5343
5366
  headerDescription() {
@@ -5345,9 +5368,16 @@ export default {
5345
5368
  // Board look: the count line is a template over the rows on this page
5346
5369
  // and all rows, "{shown} of {total}" unless the page says otherwise.
5347
5370
  if (this.isBoardLook && typeof total === 'number' && total >= 0) {
5348
- const template = this.countText || this.countSubtitle || '{shown} of {total}'
5371
+ const shown = this.effectiveObjects.length
5372
+ // The default count line is the library's own string, so it
5373
+ // reads "14 van 14" in Dutch; a page's own template goes
5374
+ // through the app's label lookup.
5375
+ if (!this.countText && !this.countSubtitle) {
5376
+ return t('nextcloud-vue', '{shown} of {total}', { shown, total })
5377
+ }
5378
+ const template = this.countText || this.countSubtitle
5349
5379
  return this.cnTranslate(template)
5350
- .replace('{shown}', String(this.effectiveObjects.length))
5380
+ .replace('{shown}', String(shown))
5351
5381
  .replace('{total}', String(total))
5352
5382
  }
5353
5383
  if (this.countSubtitle && typeof total === 'number' && total >= 0) {
@@ -7,7 +7,9 @@
7
7
  <CnFilesBrowser
8
8
  v-if="browserRoot !== null"
9
9
  :rootPath="browserRoot"
10
- :rootLabel="browserRootLabel"
10
+ :rootLabel="effectiveBrowserRootLabel"
11
+ :source="browserSource"
12
+ :deleteLabel="deleteLabel"
11
13
  :uploadButton="uploadButton"
12
14
  :dropOverlay="dropOverlay"
13
15
  :dropHint="dropHint"
@@ -238,7 +240,8 @@ import CnFilePreview from '../CnFilePreview/CnFilePreview.vue'
238
240
  import CnFilesBrowser from '../CnFilesBrowser/CnFilesBrowser.vue'
239
241
  import { useFileOpener } from '../../composables/useFileOpener.js'
240
242
  import { buildHeaders, prefixUrl } from '../../utils/index.js'
241
- import { resolveObjectFolder } from '../CnFilesBrowser/filesBrowser.js'
243
+ import { objectDisplayName, resolveObjectFolderInfo } from '../CnFilesBrowser/filesBrowser.js'
244
+ import { createOpenRegisterSource } from '../CnFilesBrowser/openRegisterSource.js'
242
245
 
243
246
  /**
244
247
  * The mime types Nextcloud Office opens by default for the current person.
@@ -308,8 +311,22 @@ export default {
308
311
  openInOfficeLabel: { type: String, default: () => t('nextcloud-vue', 'Open in Office') },
309
312
  /** Label for the delete action */
310
313
  deleteLabel: { type: String, default: () => t('nextcloud-vue', 'Delete') },
311
- /** What the files browser's root crumb reads; null shows the folder's own name, as the Files app does. */
314
+ /** What the files browser's root crumb reads; null shows the object's name, else the folder's own name. */
312
315
  browserRootLabel: { type: String, default: null },
316
+ /**
317
+ * Where the files browser reads the object's folder from.
318
+ * `openregister` (the default) goes through OpenRegister's files API under
319
+ * the object's access rule, so anyone who may read the object sees its
320
+ * files, without a share. `webdav` is the earlier behaviour: the folder is
321
+ * looked up in the person's own Files over WebDAV and the browser shows
322
+ * only when it is shared with them.
323
+ */
324
+ source: {
325
+ type: String,
326
+ default: 'openregister',
327
+ validator: (value) => ['openregister', 'webdav'].includes(value),
328
+ },
329
+
313
330
  /** Forwarded to CnFilesBrowser: a primary "Add files" button beside the New menu. */
314
331
  uploadButton: { type: Boolean, default: false },
315
332
  /** Forwarded to CnFilesBrowser: the drop state drawn over the list ("Drop to add") and announced. */
@@ -385,6 +402,9 @@ export default {
385
402
  share: false,
386
403
  /** The object's folder as a user-relative path, or null while unresolved or absent. */
387
404
  browserRoot: null,
405
+ /** The browser's data source for `source: 'openregister'`, or null for WebDAV. */
406
+ browserSource: null,
407
+ browserObjectName: null,
388
408
  linkedItems: [],
389
409
  /** File ids whose preview request failed, so the row falls back to the mime icon. */
390
410
  previewFailed: {},
@@ -403,6 +423,19 @@ export default {
403
423
  }
404
424
  },
405
425
 
426
+ computed: {
427
+ /**
428
+ * The root crumb: the host's label, else the object's name (its folder
429
+ * on disk is called after its uuid), else the folder's own name.
430
+ *
431
+ * @return {string|null}
432
+ * @spec openspec/changes/dutch-library-strings-and-files-crumb/specs/dutch-library-strings-and-files-crumb/spec.md#requirement-the-files-browser-names-the-object-in-its-root-crumb
433
+ */
434
+ effectiveBrowserRootLabel() {
435
+ return this.browserRootLabel || this.browserObjectName || null
436
+ },
437
+ },
438
+
406
439
  watch: {
407
440
  // Match every other CnObjectSidebar tab — a single immediate
408
441
  // `objectId` watcher drives all initial loads. Folding the
@@ -423,6 +456,10 @@ export default {
423
456
  linkedItemsUrl() {
424
457
  this.fetchLinkedItems()
425
458
  },
459
+
460
+ source() {
461
+ this.resolveBrowserRoot()
462
+ },
426
463
  },
427
464
 
428
465
  methods: {
@@ -483,16 +520,22 @@ export default {
483
520
 
484
521
  async resolveBrowserRoot() {
485
522
  this.browserRoot = null
523
+ this.browserSource = null
524
+ this.browserObjectName = null
486
525
  if (!this.objectId || !this.register || !this.schema) {
487
526
  return
488
527
  }
528
+ if (this.source === 'openregister') {
529
+ await this.resolveOpenRegisterSource()
530
+ return
531
+ }
489
532
  let remoteUrl
490
533
  try {
491
534
  remoteUrl = generateRemoteUrl('dav')
492
535
  } catch {
493
536
  return
494
537
  }
495
- this.browserRoot = await resolveObjectFolder({
538
+ const info = await resolveObjectFolderInfo({
496
539
  apiBase: this.apiBase,
497
540
  register: this.register,
498
541
  schema: this.schema,
@@ -500,6 +543,62 @@ export default {
500
543
  uid: getCurrentUser()?.uid || '',
501
544
  remoteUrl,
502
545
  })
546
+ this.browserRoot = info === null ? null : info.path
547
+ this.browserObjectName = info === null ? null : info.name
548
+ },
549
+
550
+ /**
551
+ * Use OpenRegister's files API for the browser, when this person may read
552
+ * the object's folder through it. A listing that fails (no read on the
553
+ * object, or an OpenRegister without the folder endpoints) leaves the
554
+ * legacy list, as an unresolved WebDAV folder did.
555
+ *
556
+ * @return {Promise<void>}
557
+ * @spec openspec/changes/files-browser-openregister-source/specs/files-browser/spec.md
558
+ */
559
+ async resolveOpenRegisterSource() {
560
+ const objectId = this.objectId
561
+ const source = createOpenRegisterSource({
562
+ apiBase: this.apiBase,
563
+ register: this.register,
564
+ schema: this.schema,
565
+ objectId,
566
+ })
567
+ try {
568
+ await source.list('/')
569
+ } catch {
570
+ return
571
+ }
572
+ if (objectId !== this.objectId) {
573
+ return
574
+ }
575
+ this.browserSource = source
576
+ this.browserRoot = '/'
577
+ this.fetchObjectName()
578
+ },
579
+
580
+ /**
581
+ * Read the object's name for the root crumb, by the same rule the WebDAV
582
+ * path uses (`objectDisplayName`: never the uuid). The OpenRegister path
583
+ * has no folder lookup to piggyback on, so this is its one object read.
584
+ * A failure keeps the crumb on its default.
585
+ *
586
+ * @return {Promise<void>}
587
+ */
588
+ async fetchObjectName() {
589
+ const objectId = this.objectId
590
+ try {
591
+ const { data } = await axios.get(generateUrl(`${this.apiBase}/objects/{register}/{schema}/{objectId}`, {
592
+ register: this.register,
593
+ schema: this.schema,
594
+ objectId,
595
+ }))
596
+ if (objectId === this.objectId) {
597
+ this.browserObjectName = objectDisplayName(data, objectId)
598
+ }
599
+ } catch {
600
+ // The crumb keeps its default.
601
+ }
503
602
  },
504
603
 
505
604
  async fetchFiles(append = false) {
@@ -115,7 +115,7 @@
115
115
  </template>
116
116
 
117
117
  <script>
118
- import { translate as t } from '@nextcloud/l10n'
118
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
119
119
  import { NcPopover, NcSelect } from '@nextcloud/vue'
120
120
  import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
121
121
  import CnIcon from '../CnIcon/CnIcon.vue'
@@ -354,7 +354,8 @@ export default {
354
354
  if (this.overflowLabel) {
355
355
  return t('nextcloud-vue', '{label} — show the other filters', { label: this.overflowLabel })
356
356
  }
357
- return t('nextcloud-vue', '{count} more filters', { count: this.overflowEntries.length })
357
+ const count = this.overflowEntries.length
358
+ return n('nextcloud-vue', '{count} more filter', '{count} more filters', count, { count })
358
359
  },
359
360
 
360
361
  /** @return {string} Heading above the hidden chips in the panel. */
@@ -41,7 +41,7 @@
41
41
  data-testid="cn-saved-views-control"
42
42
  :aria-label="isBoardLook ? t('nextcloud-vue', 'Save view') : menuLabel">
43
43
  <template #icon>
44
- <ContentSaveOutline v-if="isBoardLook" :size="18" />
44
+ <ContentSaveOutline v-if="isBoardLook" :size="14" />
45
45
  <BookmarkOutline v-else :size="20" />
46
46
  </template>
47
47
 
@@ -33,11 +33,14 @@
33
33
  </div>
34
34
 
35
35
  <div class="cn-kpi-card__body cn-stat-widget__body">
36
- <div v-if="content.label || rangePresets.length" class="cn-kpi-card__title cn-stat-widget__label">
36
+ <div
37
+ v-if="content.label || rangePresets.length"
38
+ class="cn-kpi-card__title cn-stat-widget__label"
39
+ :class="{ 'cn-kpi-card__title--badge-end': hasEndBadge }">
37
40
  <!-- Board look: the icon is an 18px glyph before the label, and
38
41
  only when the tile links to a filtered list. -->
39
42
  <span
40
- v-if="isBoardLook && isLinked && resolvedIcon"
43
+ v-if="isBoardLook && isLinked && resolvedIcon && !hasEndBadge"
41
44
  class="cn-kpi-card__glyph cn-stat-widget__glyph"
42
45
  data-testid="cn-stat-widget-glyph"
43
46
  aria-hidden="true">
@@ -66,6 +69,17 @@
66
69
  {{ effectiveTranslate(preset.label || preset.id) }}
67
70
  </option>
68
71
  </select>
72
+ <!-- Board look, `content.iconPlacement: "end"`: the icon sits in a
73
+ 32px circle at the right of the label row, tinted by the
74
+ tile's state (DqDashboard). -->
75
+ <span
76
+ v-if="hasEndBadge"
77
+ class="cn-kpi-card__badge cn-stat-widget__badge-icon"
78
+ :class="'cn-kpi-card__badge--' + stateTone"
79
+ data-testid="cn-stat-widget-icon-badge"
80
+ aria-hidden="true">
81
+ <CnWidgetIcon :name="resolvedIcon" :size="18" />
82
+ </span>
69
83
  </div>
70
84
 
71
85
  <div class="cn-kpi-card__value-row cn-stat-widget__value-row">
@@ -1330,9 +1344,45 @@ export default {
1330
1344
  if (layout === 'vertical' || layout === 'stacked') {
1331
1345
  return layout
1332
1346
  }
1347
+ // Board look: every KPI tile on the screens is stacked, so a tile
1348
+ // that names no layout takes it. An explicit `horizontal` stays.
1349
+ if (this.isBoardLook && layout === undefined) {
1350
+ return 'stacked'
1351
+ }
1333
1352
  return 'horizontal'
1334
1353
  },
1335
1354
 
1355
+ /**
1356
+ * Whether the board look draws the icon in a tinted circle at the
1357
+ * right of the label row (`content.iconPlacement: "end"`), as the
1358
+ * DqDashboard tiles. Off without the board look and without an icon.
1359
+ *
1360
+ * @spec openspec/changes/screens-stat-tile-parity/specs/dashboard-page/spec.md#requirement-the-board-kpi-tile-can-carry-its-icon-at-the-end
1361
+ * @return {boolean}
1362
+ */
1363
+ hasEndBadge() {
1364
+ return this.isBoardLook
1365
+ && (this.content || {}).iconPlacement === 'end'
1366
+ && Boolean(this.resolvedIcon)
1367
+ },
1368
+
1369
+ /**
1370
+ * The tile's state as a tone name for the end badge: the variant the
1371
+ * value colour comes from (`success`, `warning`, `error`), else
1372
+ * `primary`. A custom `valueColor` has no tone and reads as primary.
1373
+ *
1374
+ * @spec openspec/changes/screens-stat-tile-parity/specs/dashboard-page/spec.md#requirement-the-board-kpi-tile-can-carry-its-icon-at-the-end
1375
+ * @return {'primary'|'success'|'warning'|'error'}
1376
+ */
1377
+ stateTone() {
1378
+ const color = this.variantColor
1379
+ if (!color) {
1380
+ return 'primary'
1381
+ }
1382
+ const tone = ['success', 'warning', 'error'].find((name) => VARIANT_COLORS[name] === color)
1383
+ return tone || 'primary'
1384
+ },
1385
+
1336
1386
  /**
1337
1387
  * Whether the tile takes the stacked board look (`content.layout:
1338
1388
  * "stacked"`): a plain muted label, the number at 34px in the text
@@ -10,6 +10,8 @@
10
10
  </template>
11
11
 
12
12
  <script>
13
+ import { isBadgeVariant } from '../../utils/badgeVariants.js'
14
+
13
15
  /**
14
16
  * CnStatusBadge — Color-coded pill badge for status, priority, or category display.
15
17
  *
@@ -39,12 +41,14 @@ export default {
39
41
  },
40
42
 
41
43
  /**
42
- * Color variant: 'default', 'primary', 'success', 'warning', 'error', 'info'
44
+ * Color variant: 'default', 'primary', 'success', 'warning', 'error',
45
+ * 'info', or one of the two board tones without a Nextcloud role,
46
+ * 'purple' (work in progress) and 'teal' (handed on).
43
47
  */
44
48
  variant: {
45
49
  type: String,
46
50
  default: 'default',
47
- validator: (v) => ['default', 'primary', 'success', 'warning', 'error', 'info'].includes(v),
51
+ validator: (v) => isBadgeVariant(v),
48
52
  },
49
53
 
50
54
  /** Size: 'small' or 'medium' */