@conduction/nextcloud-vue 2.56.0-beta.30 → 2.56.0-beta.31

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 (173) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +3 -3
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  4. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +80 -4
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +95 -91
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +85 -6
  9. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js +1 -1
  11. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  12. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +4 -1
  13. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  14. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +1 -1
  16. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +2 -2
  19. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +40 -3
  22. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  24. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +19 -11
  25. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  26. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +43 -0
  28. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +1 -1
  30. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue2.js +1 -1
  32. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDocumentReviewList/CnDocumentReviewWidget.vue3.js.map +1 -1
  34. package/dist/esm/components/CnFacetSidebar/CnFacetSidebar.vue2.js +7 -7
  35. package/dist/esm/components/CnFacetSidebar/CnFacetSidebar.vue2.js.map +1 -1
  36. package/dist/esm/components/CnFacetSidebar/CnFacetSidebar.vue3.js.map +1 -1
  37. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +125 -0
  38. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  40. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +3 -2
  41. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  42. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  43. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +302 -4
  44. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  45. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +417 -244
  46. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  47. package/dist/esm/components/CnFilesBrowser/filesBrowser.js +101 -1
  48. package/dist/esm/components/CnFilesBrowser/filesBrowser.js.map +1 -1
  49. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +141 -1
  50. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  51. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +60 -0
  52. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  53. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js +74 -49
  54. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  55. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +29 -1
  56. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  57. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +48 -4
  58. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  59. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js +7 -7
  60. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
  61. package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
  62. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue2.js +1 -1
  63. package/dist/esm/components/CnNextStepCard/CnNextStepWidget.vue2.js.map +1 -1
  64. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +1 -1
  65. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  66. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  67. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +63 -1
  68. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  69. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +8 -1
  70. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  71. package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue2.js +14 -0
  72. package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue2.js.map +1 -1
  73. package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue3.js +5 -1
  74. package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue3.js.map +1 -1
  75. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +13 -6
  76. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  77. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  78. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +84 -1
  79. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  80. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +85 -41
  81. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  82. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +7 -2
  83. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  84. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  85. package/dist/esm/components/CnWidgetObjectTable/dashboardRegistration.js +5 -0
  86. package/dist/esm/components/CnWidgetObjectTable/dashboardRegistration.js.map +1 -1
  87. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js +197 -6
  88. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js.map +1 -1
  89. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js +94 -50
  90. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js.map +1 -1
  91. package/dist/esm/composables/useListView.js +4 -4
  92. package/dist/esm/composables/useListView.js.map +1 -1
  93. package/dist/esm/index.js +10 -1
  94. package/dist/esm/index.js.map +1 -1
  95. package/dist/esm/l10n/en.json.js +8 -1
  96. package/dist/esm/l10n/en.json.js.map +1 -1
  97. package/dist/esm/l10n/nl.json.js +54 -1
  98. package/dist/esm/l10n/nl.json.js.map +1 -1
  99. package/dist/esm/nextcloud-vue.css +198 -8
  100. package/dist/esm/utils/facets.js +75 -3
  101. package/dist/esm/utils/facets.js.map +1 -1
  102. package/dist/esm/utils/fillTemplate.js +7 -1
  103. package/dist/esm/utils/fillTemplate.js.map +1 -1
  104. package/dist/esm/utils/pageControls.js +81 -0
  105. package/dist/esm/utils/pageControls.js.map +1 -0
  106. package/dist/esm/utils/personalLenses.js +40 -3
  107. package/dist/esm/utils/personalLenses.js.map +1 -1
  108. package/dist/esm/utils/resolveFilterTokens.js +1 -1
  109. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  110. package/dist/esm/utils/resolveManifestTokens.js +2 -0
  111. package/dist/esm/utils/resolveManifestTokens.js.map +1 -1
  112. package/dist/esm/utils/sentinelTokens.js +19 -1
  113. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  114. package/dist/esm/utils/userGroupsPeek.js +91 -0
  115. package/dist/esm/utils/userGroupsPeek.js.map +1 -0
  116. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  117. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  118. package/dist/esm/utils/visibleWhen.js +57 -5
  119. package/dist/esm/utils/visibleWhen.js.map +1 -1
  120. package/dist/esm/utils/widgetVisibility.js +4 -57
  121. package/dist/esm/utils/widgetVisibility.js.map +1 -1
  122. package/dist/nextcloud-vue.cjs.js +4309 -2233
  123. package/dist/nextcloud-vue.cjs.js.map +2 -2
  124. package/dist/nextcloud-vue.css +198 -8
  125. package/l10n/en.json +8 -1
  126. package/l10n/nl.json +54 -1
  127. package/package.json +2 -1
  128. package/scripts/check-docs-mdx.mjs +108 -0
  129. package/src/components/CnActionsBar/CnActionsBar.vue +6 -1
  130. package/src/components/CnAppRoot/CnAppRoot.md +4 -2
  131. package/src/components/CnAppRoot/CnAppRoot.vue +151 -59
  132. package/src/components/CnBannerWidget/CnBannerWidget.vue +112 -7
  133. package/src/components/CnCellRenderer/CnCellRenderer.vue +4 -1
  134. package/src/components/CnConversationThread/CnConversationWidget.vue +1 -1
  135. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +2 -2
  136. package/src/components/CnDashboardPage/CnDashboardPage.vue +40 -3
  137. package/src/components/CnDataTable/CnDataTable.vue +19 -11
  138. package/src/components/CnDetailPage/CnDetailPage.vue +44 -1
  139. package/src/components/CnDocumentReviewList/CnDocumentReviewWidget.vue +1 -1
  140. package/src/components/CnFacetSidebar/CnFacetSidebar.vue +7 -7
  141. package/src/components/CnFactsWidget/CnFactsWidget.vue +125 -0
  142. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +3 -2
  143. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +588 -117
  144. package/src/components/CnFilesBrowser/filesBrowser.js +100 -0
  145. package/src/components/CnFilesBrowser/filesBrowserColumns.js +140 -0
  146. package/src/components/CnFormPage/CnFormPage.vue +81 -0
  147. package/src/components/CnIndexPage/CnIndexPage.vue +56 -3
  148. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +7 -7
  149. package/src/components/CnNextStepCard/CnNextStepWidget.vue +1 -1
  150. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +1 -1
  151. package/src/components/CnObjectSidebar/CnFilesTab.vue +70 -1
  152. package/src/components/CnObjectSidebar/CnNotesTab.vue +16 -0
  153. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +13 -6
  154. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +180 -3
  155. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +7 -2
  156. package/src/components/CnWidgetObjectTable/dashboardRegistration.js +5 -0
  157. package/src/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue +281 -10
  158. package/src/components/index.js +8 -0
  159. package/src/composables/useListView.js +4 -4
  160. package/src/css/look-board-index.css +16 -8
  161. package/src/index.js +8 -0
  162. package/src/schemas/app-manifest-v2.schema.json +93 -9
  163. package/src/utils/facets.js +74 -2
  164. package/src/utils/fillTemplate.js +7 -1
  165. package/src/utils/pageControls.js +78 -0
  166. package/src/utils/personalLenses.js +39 -2
  167. package/src/utils/resolveFilterTokens.js +3 -1
  168. package/src/utils/resolveManifestTokens.js +2 -0
  169. package/src/utils/sentinelTokens.js +19 -1
  170. package/src/utils/userGroupsPeek.js +87 -0
  171. package/src/utils/validateManifestV2.compiled.js +3 -3
  172. package/src/utils/visibleWhen.js +56 -4
  173. package/src/utils/widgetVisibility.js +7 -56
@@ -220,3 +220,103 @@ export async function resolveObjectFolderInfo({ apiBase, register, schema, objec
220
220
  return null
221
221
  }
222
222
  }
223
+
224
+ /**
225
+ * Read a dotted path off a value without throwing on a missing segment.
226
+ *
227
+ * @param {(Array|object|string|number|boolean|null|undefined)} value The value.
228
+ * @param {string} path A dotted path; empty answers the value itself.
229
+ * @return {(Array|object|string|number|boolean|null|undefined)} What sits at the path, or undefined.
230
+ */
231
+ function readPath(value, path) {
232
+ if (typeof path !== 'string' || path === '') {
233
+ return value
234
+ }
235
+ return path.split('.').reduce((acc, key) => (acc === null || acc === undefined ? undefined : acc[key]), value)
236
+ }
237
+
238
+ /**
239
+ * Turn a host endpoint's answer into the browser's `rowData`: the host's own
240
+ * record for each file, keyed by file id.
241
+ *
242
+ * The answer may be a list of records (directly, at `path`, or under `items`
243
+ * or `results`), each naming its file in `key` (`fileId` unless the host says
244
+ * otherwise); a record without one is skipped. An object that is not a list
245
+ * and holds neither is taken as already keyed by file id. Anything else is
246
+ * an empty map: the extra columns stay empty, the files still list.
247
+ *
248
+ * @param {(Array|object|string|number|boolean|null|undefined)} data The endpoint answer.
249
+ * @param {object} [options] Where to look.
250
+ * @param {string} [options.path] A dotted path to the list in the answer.
251
+ * @param {string} [options.key] The record field that holds the file id.
252
+ * @return {object} The records by file id.
253
+ */
254
+ export function indexRowData(data, { path = '', key = 'fileId' } = {}) {
255
+ let picked = readPath(data, path)
256
+ if (picked && typeof picked === 'object' && !Array.isArray(picked)) {
257
+ if (Array.isArray(picked.items)) {
258
+ picked = picked.items
259
+ } else if (Array.isArray(picked.results)) {
260
+ picked = picked.results
261
+ } else {
262
+ return { ...picked }
263
+ }
264
+ }
265
+ if (!Array.isArray(picked)) {
266
+ return {}
267
+ }
268
+ const byId = {}
269
+ picked.forEach((record) => {
270
+ if (!record || typeof record !== 'object') {
271
+ return
272
+ }
273
+ const id = record[key]
274
+ if (id === null || id === undefined || id === '') {
275
+ return
276
+ }
277
+ byId[String(id)] = record
278
+ })
279
+ return byId
280
+ }
281
+
282
+ /**
283
+ * Whether a host row action is offered on a file, by its `visibleIf`.
284
+ *
285
+ * `visibleIf.extension` lists file extensions (no case, a leading dot is
286
+ * allowed); `visibleIf.mime` lists mime types, where one ending in `/` or
287
+ * `/*` names the whole family. Every condition named must hold. An action
288
+ * without `visibleIf` is offered on every file, as before.
289
+ *
290
+ * @param {object} action The declared action.
291
+ * @param {object} node The file (`basename`, `mime`).
292
+ * @return {boolean} True when the action belongs on this file.
293
+ */
294
+ export function hostActionApplies(action, node) {
295
+ const condition = action && action.visibleIf
296
+ if (!condition || typeof condition !== 'object') {
297
+ return true
298
+ }
299
+ if (Array.isArray(condition.extension)) {
300
+ const name = String((node && node.basename) || '')
301
+ const dot = name.lastIndexOf('.')
302
+ const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
303
+ const wanted = condition.extension.map((entry) => String(entry).replace(/^\./, '').toLowerCase())
304
+ if (extension === '' || !wanted.includes(extension)) {
305
+ return false
306
+ }
307
+ }
308
+ if (Array.isArray(condition.mime)) {
309
+ const mime = String((node && node.mime) || '').toLowerCase()
310
+ const matches = condition.mime.some((entry) => {
311
+ const wanted = String(entry).toLowerCase()
312
+ if (wanted.endsWith('/*') || wanted.endsWith('/')) {
313
+ return mime.startsWith(wanted.replace(/\*$/, ''))
314
+ }
315
+ return mime === wanted
316
+ })
317
+ if (!matches) {
318
+ return false
319
+ }
320
+ }
321
+ return true
322
+ }
@@ -219,3 +219,143 @@ export function sortNodesByColumn(nodes, column, ascending, rowData = null, isFo
219
219
  return String(left).localeCompare(String(right)) * direction
220
220
  })
221
221
  }
222
+
223
+ /**
224
+ * Whether a cell value counts as filled in.
225
+ *
226
+ * @param {(Array|object|string|number|boolean|null|undefined)} value The value.
227
+ * @return {boolean} False for nothing, an empty string or an empty list.
228
+ */
229
+ function isFilled(value) {
230
+ if (value === null || value === undefined || value === '') {
231
+ return false
232
+ }
233
+ return !(Array.isArray(value) && value.length === 0)
234
+ }
235
+
236
+ /**
237
+ * The single value a node is grouped under, as a string key: a list groups
238
+ * under its first entry, an object under its label, title, name or id.
239
+ *
240
+ * @param {(Array|object|string|number|boolean|null|undefined)} value The cell value.
241
+ * @return {(string|null)} The group key, or null when nothing is filled in.
242
+ */
243
+ function groupKeyOf(value) {
244
+ const first = Array.isArray(value) ? value[0] : value
245
+ if (!isFilled(first)) {
246
+ return null
247
+ }
248
+ if (typeof first === 'object') {
249
+ const label = first.label ?? first.title ?? first.name ?? first.id
250
+ return isFilled(label) ? String(label) : null
251
+ }
252
+ return String(first)
253
+ }
254
+
255
+ /**
256
+ * The values a node carries in a column, as strings, for counting and
257
+ * filtering: every entry of a list, the one value otherwise.
258
+ *
259
+ * @param {object} column A resolved column.
260
+ * @param {object} node The node.
261
+ * @param {object} [rowData] The host's per-file data.
262
+ * @return {string[]} The values, deduplicated.
263
+ */
264
+ function facetValuesOf(column, node, rowData) {
265
+ const value = fileColumnValue(column, node, rowData)
266
+ const entries = Array.isArray(value) ? value : [value]
267
+ const out = []
268
+ entries.forEach((entry) => {
269
+ const key = groupKeyOf(entry)
270
+ if (key !== null && !out.includes(key)) {
271
+ out.push(key)
272
+ }
273
+ })
274
+ return out
275
+ }
276
+
277
+ /**
278
+ * Group nodes on a column's value, in the order each value is first met.
279
+ * Nodes with no value come last, in one group whose value is null.
280
+ *
281
+ * @param {Array<object>} nodes The nodes, already in display order.
282
+ * @param {object} column The resolved column.
283
+ * @param {object} [rowData] The host's per-file data.
284
+ * @return {Array<{value: (string|null), nodes: Array<object>}>} The groups.
285
+ */
286
+ export function groupNodes(nodes, column, rowData = null) {
287
+ const groups = []
288
+ const byKey = new Map()
289
+ const empty = { value: null, nodes: [] }
290
+ ;(Array.isArray(nodes) ? nodes : []).forEach((node) => {
291
+ const key = column ? groupKeyOf(fileColumnValue(column, node, rowData)) : null
292
+ if (key === null) {
293
+ empty.nodes.push(node)
294
+ return
295
+ }
296
+ if (!byKey.has(key)) {
297
+ const group = { value: key, nodes: [] }
298
+ byKey.set(key, group)
299
+ groups.push(group)
300
+ }
301
+ byKey.get(key).nodes.push(node)
302
+ })
303
+ if (empty.nodes.length > 0) {
304
+ groups.push(empty)
305
+ }
306
+ return groups
307
+ }
308
+
309
+ /**
310
+ * The values in use in a column across the nodes, with how many nodes carry
311
+ * each; a list counts once per entry. Most used first, then in first-seen
312
+ * order.
313
+ *
314
+ * @param {Array<object>} nodes The nodes.
315
+ * @param {object} column The resolved column.
316
+ * @param {object} [rowData] The host's per-file data.
317
+ * @return {Array<{value: string, count: number}>} The values and counts.
318
+ */
319
+ export function facetCounts(nodes, column, rowData = null) {
320
+ const counts = []
321
+ const byValue = new Map()
322
+ ;(Array.isArray(nodes) ? nodes : []).forEach((node) => {
323
+ facetValuesOf(column, node, rowData).forEach((value) => {
324
+ if (!byValue.has(value)) {
325
+ const entry = { value, count: 0 }
326
+ byValue.set(value, entry)
327
+ counts.push(entry)
328
+ }
329
+ byValue.get(value).count += 1
330
+ })
331
+ })
332
+ return counts
333
+ .map((entry, index) => ({ entry, index }))
334
+ .sort((a, b) => (b.entry.count - a.entry.count) || (a.index - b.index))
335
+ .map(({ entry }) => entry)
336
+ }
337
+
338
+ /**
339
+ * Whether a node passes the chosen facet values: within one facet any chosen
340
+ * value will do, and every facet with a choice must pass. A facet with
341
+ * nothing chosen does not filter.
342
+ *
343
+ * @param {object} node The node.
344
+ * @param {object} selections Chosen values by column key.
345
+ * @param {object} columnsByKey Resolved columns by key.
346
+ * @param {object} [rowData] The host's per-file data.
347
+ * @return {boolean} True when the node stays in the list.
348
+ */
349
+ export function nodeMatchesFacets(node, selections, columnsByKey, rowData = null) {
350
+ return Object.entries(selections || {}).every(([key, chosen]) => {
351
+ if (!Array.isArray(chosen) || chosen.length === 0) {
352
+ return true
353
+ }
354
+ const column = columnsByKey && columnsByKey[key]
355
+ if (!column) {
356
+ return true
357
+ }
358
+ const values = facetValuesOf(column, node, rowData)
359
+ return chosen.some((value) => values.includes(value))
360
+ })
361
+ }
@@ -116,6 +116,19 @@
116
116
  v-if="!submitted || mode !== 'public'"
117
117
  class="cn-form-page__form"
118
118
  @submit.prevent="submit">
119
+ <!-- Spam protection: a field no person sees or reaches. A filled one
120
+ means a bot; the submit then sends nothing (see `honeypot`). -->
121
+ <div v-if="honeypot" class="cn-form-page__honeypot" aria-hidden="true">
122
+ <label :for="`cn-form-page-hp-${honeypot}`">{{ t('nextcloud-vue', 'Leave this field empty') }}</label>
123
+ <input
124
+ :id="`cn-form-page-hp-${honeypot}`"
125
+ v-model="honeypotValue"
126
+ :name="honeypot"
127
+ type="text"
128
+ tabindex="-1"
129
+ autocomplete="off"
130
+ data-testid="cn-form-page-honeypot">
131
+ </div>
119
132
  <!-- Paste to fill: only where it may be used (see smartPasteAvailable). -->
120
133
  <div v-if="smartPasteAvailable" class="cn-form-page__smart-paste">
121
134
  <NcButton type="button" data-testid="cn-form-page-smart-paste" @click="smartPasteOpen = true">
@@ -610,6 +623,17 @@ export default {
610
623
  default: '',
611
624
  },
612
625
 
626
+ /**
627
+ * Name of a honeypot field for a form anonymous visitors fill in.
628
+ * The field is rendered out of sight and out of the tab order; when
629
+ * it is filled, submit sends nothing and shows the normal success,
630
+ * so a bot learns nothing. Empty (default): no honeypot.
631
+ */
632
+ honeypot: {
633
+ type: String,
634
+ default: '',
635
+ },
636
+
613
637
  /** HTTP method for endpoint mode. POST | PUT | PATCH. */
614
638
  submitMethod: {
615
639
  type: String,
@@ -748,6 +772,8 @@ export default {
748
772
  summaryKeys: [],
749
773
  /** Per-field validation error messages, keyed by field.key. */
750
774
  fieldErrors: {},
775
+ /** What a bot typed into the honeypot field; stays empty for a person. */
776
+ honeypotValue: '',
751
777
  /**
752
778
  * Cache of resolved `endpoint`/`source` visibleWhen outcomes,
753
779
  * keyed by field.key. Resolved ONCE in `mounted()` — see the
@@ -1783,6 +1809,11 @@ export default {
1783
1809
  }
1784
1810
 
1785
1811
  this.lastError = null
1812
+ if (this.honeypot && this.honeypotValue !== '') {
1813
+ // A bot filled the field no person sees: send nothing, look done.
1814
+ this.submitted = true
1815
+ return
1816
+ }
1786
1817
  this.submitting = true
1787
1818
  try {
1788
1819
  if (this.submitEndpoint) {
@@ -1808,6 +1839,9 @@ export default {
1808
1839
  // describes the transport ("Request failed with status code 400")
1809
1840
  // and leaves the user to guess which field it meant.
1810
1841
  this.lastError = serverErrorMessage(err)
1842
+ // A refusal that names its fields (`findings[]`, as OpenRegister's
1843
+ // form destination check answers 422) is marked on each field.
1844
+ this.showServerFindings(err)
1811
1845
  /**
1812
1846
  * Submit failure event. Payload is the thrown error / rejected reason.
1813
1847
  *
@@ -1820,6 +1854,45 @@ export default {
1820
1854
  }
1821
1855
  },
1822
1856
 
1857
+ /**
1858
+ * Put each finding of a refused submit on its field.
1859
+ *
1860
+ * Reads `response.data.findings[]`, each `{ field?, property, message }`,
1861
+ * and marks the field whose key is the finding's `field`, or else its
1862
+ * `property`. Findings on no field of this form stay in the general
1863
+ * error above the form. Nothing happens for an answer without findings.
1864
+ *
1865
+ * @param {object} err The rejected request.
1866
+ * @return {void}
1867
+ */
1868
+ showServerFindings(err) {
1869
+ const findings = err?.response?.data?.findings
1870
+ if (!Array.isArray(findings) || findings.length === 0) {
1871
+ return
1872
+ }
1873
+ const keys = new Set(this.fields.map((f) => f && f.key))
1874
+ const marked = []
1875
+ for (const finding of findings) {
1876
+ const key = finding && (finding.field || finding.property)
1877
+ if (!key || !keys.has(key) || this.fieldErrors[key]) {
1878
+ continue
1879
+ }
1880
+ this.fieldErrors[key] = String(finding.message || t('nextcloud-vue', 'This answer was refused.'))
1881
+ marked.push(key)
1882
+ }
1883
+ if (marked.length === 0) {
1884
+ return
1885
+ }
1886
+ this.summaryKeys = this.fields.map((f) => f.key).filter((key) => marked.includes(key))
1887
+ if (this.hasSteps) {
1888
+ const stepIndex = this.steps.findIndex((step) => this.stepFields(step).some((f) => f.key === this.summaryKeys[0]))
1889
+ if (stepIndex >= 0) {
1890
+ this.currentStepIndex = stepIndex
1891
+ }
1892
+ }
1893
+ this.focusFirstError(this.summaryKeys[0])
1894
+ },
1895
+
1823
1896
  async submitViaEndpoint() {
1824
1897
  const method = (this.submitMethod || 'POST').toLowerCase()
1825
1898
  const url = resolveParams(this.submitEndpoint, this.$route?.params || {})
@@ -1843,6 +1916,14 @@ export default {
1843
1916
  </script>
1844
1917
 
1845
1918
  <style>
1919
+ .cn-form-page__honeypot {
1920
+ position: absolute;
1921
+ left: -10000px;
1922
+ width: 1px;
1923
+ height: 1px;
1924
+ overflow: hidden;
1925
+ }
1926
+
1846
1927
  .cn-form-page {
1847
1928
  display: flex;
1848
1929
  flex-direction: column;
@@ -263,6 +263,31 @@
263
263
  @update:activeIndex="onQuickFilterChange"
264
264
  @update:selectedIndices="onQuickFilterMultiChange" />
265
265
  </template>
266
+ <!-- Board look, `filterMenu` on a page with no filter sidebar: the
267
+ board's Filter button, a menu of each enum column's values as
268
+ toggleable filters. -->
269
+ <template v-if="boardFilterMenu" #filter-menu>
270
+ <NcActions
271
+ class="cn-actions-bar__filter-menu"
272
+ variant="secondary"
273
+ :forceMenu="true"
274
+ :menuName="t('nextcloud-vue', 'Filter')"
275
+ data-testid="cn-index-filter-menu">
276
+ <template #icon>
277
+ <Tune :size="16" />
278
+ </template>
279
+ <template v-for="field in filterableFields" :key="field.key">
280
+ <NcActionCaption :name="field.label" />
281
+ <NcActionCheckbox
282
+ v-for="val in field.values"
283
+ :key="`${field.key}-${val}`"
284
+ :modelValue="isFilterActive(field.key, val)"
285
+ @update:modelValue="toggleFilter(field.key, val)">
286
+ {{ val }}
287
+ </NcActionCheckbox>
288
+ </template>
289
+ </NcActions>
290
+ </template>
266
291
  </CnActionsBar>
267
292
 
268
293
  <!-- Quick edit: a few fields of one row, over the list, with the list
@@ -625,10 +650,10 @@
625
650
  the column menu lists every governed column as a visibility
626
651
  checkbox — compact, in-table alternatives to the sidebar. -->
627
652
  <template
628
- v-if="(filterMenu && filterableFields.length) || (columnMenu && governedColumns.length)"
653
+ v-if="(showHeaderFilterMenu) || (columnMenu && governedColumns.length)"
629
654
  #actions-header>
630
655
  <NcActions
631
- v-if="filterMenu && filterableFields.length"
656
+ v-if="showHeaderFilterMenu"
632
657
  :forceMenu="true"
633
658
  :aria-label="t('nextcloud-vue', 'Filter')">
634
659
  <template #icon>
@@ -968,6 +993,7 @@ import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
968
993
  import Export from 'vue-material-design-icons/Export.vue'
969
994
  import Eye from 'vue-material-design-icons/Eye.vue'
970
995
  import FilterOutline from 'vue-material-design-icons/FilterOutline.vue'
996
+ import Tune from 'vue-material-design-icons/Tune.vue'
971
997
  import ViewColumnOutline from 'vue-material-design-icons/ViewColumnOutline.vue'
972
998
  import CnConfirmDialog from '../../dialogs/CnConfirmDialog.vue'
973
999
  import CnQuickEditDialog from '../../dialogs/CnQuickEditDialog.vue'
@@ -1275,6 +1301,7 @@ export default {
1275
1301
  DatabaseSearch,
1276
1302
  Export,
1277
1303
  FilterOutline,
1304
+ Tune,
1278
1305
  ViewColumnOutline,
1279
1306
  NcCheckboxRadioSwitch,
1280
1307
  CnPageHeader,
@@ -1607,7 +1634,10 @@ export default {
1607
1634
  * unchanged. Under the board look a tab with `activeLabel` ("Pipeline:
1608
1635
  * Sales") adds a removable chip with that text to the Active line and
1609
1636
  * counts on the Filter badge while it is selected; removing the chip
1610
- * goes back to the default tab.
1637
+ * goes back to the default tab. A tab can say `empty: "field"` (or a
1638
+ * list of fields) for the rows where the field has no value, and
1639
+ * `notEmpty` for the rows where it has one: it reads as the filter
1640
+ * `{ field: { isnull: true } }`.
1611
1641
  */
1612
1642
  quickFilters: {
1613
1643
  type: Array,
@@ -2892,6 +2922,8 @@ export default {
2892
2922
  * Show a filter menu (funnel button) in the table header, above the
2893
2923
  * row-actions column. Its menu lists every enum/badge column's values as
2894
2924
  * toggleable facet filters — a compact alternative to the facet sidebar.
2925
+ * Under the board look, on a page with no filter sidebar, it is the
2926
+ * toolbar's labelled Filter button instead (row 1, before the view switch).
2895
2927
  * Fed from the manifest as `pages[].config.filterMenu`.
2896
2928
  */
2897
2929
  filterMenu: {
@@ -5606,6 +5638,27 @@ export default {
5606
5638
  return !!this.resolvedSidebar.enabled && this.resolvedSidebar.show !== false
5607
5639
  },
5608
5640
 
5641
+ /**
5642
+ * Whether the board toolbar draws the Filter button as a menu: the
5643
+ * board look, `filterMenu` on, something to filter by, and no filter
5644
+ * sidebar (whose toggle is that button otherwise).
5645
+ *
5646
+ * @return {boolean}
5647
+ */
5648
+ boardFilterMenu() {
5649
+ return this.isBoardLook && this.filterMenu && !this.hasSidebar && this.filterableFields.length > 0
5650
+ },
5651
+
5652
+ /**
5653
+ * Whether the funnel menu sits in the table header: `filterMenu` with
5654
+ * values to offer, unless the toolbar's Filter button is that menu.
5655
+ *
5656
+ * @return {boolean}
5657
+ */
5658
+ showHeaderFilterMenu() {
5659
+ return this.filterMenu && this.filterableFields.length > 0 && !this.boardFilterMenu
5660
+ },
5661
+
5609
5662
  /** Search props forwarded to the embedded CnIndexSidebar (defaults applied per CnIndexSidebar). */
5610
5663
  sidebarSearchProps() {
5611
5664
  return (this.sidebar && this.sidebar.search) || {}
@@ -270,7 +270,7 @@ import Pin from 'vue-material-design-icons/Pin.vue'
270
270
  import PinOutline from 'vue-material-design-icons/PinOutline.vue'
271
271
  import ViewColumnOutline from 'vue-material-design-icons/ViewColumnOutline.vue'
272
272
  import { METADATA_COLUMNS } from '../../constants/metadata.js'
273
- import { facetOptionLabel } from '../../utils/facets.js'
273
+ import { facetOptions } from '../../utils/facets.js'
274
274
  import { labelLang } from '../../utils/manifestTranslate.js'
275
275
  import { columnsFromSchema, filtersFromSchema } from '../../utils/schema.js'
276
276
  import { CnDateRangePicker, DEFAULT_DATE_RANGE_PRESETS } from '../CnDateRangePicker/index.js'
@@ -867,12 +867,12 @@ export default {
867
867
  * @param {object} filter Filter object
868
868
  */
869
869
  getFilterOptions(filter) {
870
- const facet = this.facetData[filter.key]
871
- if (facet?.values?.length > 0) {
872
- return facet.values.map((v) => ({
873
- id: v.value,
874
- label: facetOptionLabel(v),
875
- }))
870
+ const live = facetOptions(
871
+ this.facetData[filter.key],
872
+ (count) => t('nextcloud-vue', 'No value ({count})', { count }),
873
+ )
874
+ if (live !== null) {
875
+ return live
876
876
  }
877
877
  // A reference picker's rows come from another list, and neither a
878
878
  // schema enum nor a facet bucket can carry them.
@@ -21,7 +21,7 @@ import { resolveNextStep } from '../../utils/detailActionModel.js'
21
21
  * Renders CnNextStepCard for the bound record's stage from
22
22
  * `content: { field?, stages: { <stage>: { title?, checklist, after? } } }`.
23
23
  * The checklist only: the page's own primary button stays where the page
24
- * puts it. Resolved by its registry key, not exported.
24
+ * puts it. Resolved by its registry key, and exported for an app that renders it itself.
25
25
  */
26
26
  export default {
27
27
  name: 'CnNextStepWidget',
@@ -619,7 +619,7 @@ export default {
619
619
  // `secondary` (a second, muted line under the cell) rides
620
620
  // along too: the table draws it, and dropping it here took the
621
621
  // list rows' second line away (zuiddrecht-pixel-gaps-3).
622
- for (const k of ['format', 'widget', 'widgetProps', 'formatter', 'align', 'width', 'type', 'enum', 'enumLabels', 'sortable', 'secondary']) {
622
+ for (const k of ['format', 'widget', 'widgetProps', 'formatter', 'align', 'width', 'type', 'enum', 'enumLabels', 'sortable', 'secondary', 'colorMap']) {
623
623
  if (c[k] !== undefined) {
624
624
  out[k] = c[k]
625
625
  }
@@ -16,6 +16,13 @@
16
16
  :rowActions="rowActions"
17
17
  :newActions="newActions"
18
18
  :linkedItems="linkedItems"
19
+ :columns="columns"
20
+ :rowData="rowDataSource"
21
+ :preferenceApp="preferenceApp"
22
+ :preferenceKey="preferenceKey"
23
+ :bulkActions="bulkActions"
24
+ :groupBy="groupBy"
25
+ :facets="facets"
19
26
  @changed="onBrowserChanged" />
20
27
  <template v-else>
21
28
  <!-- Upload error -->
@@ -240,7 +247,7 @@ import CnFilePreview from '../CnFilePreview/CnFilePreview.vue'
240
247
  import CnFilesBrowser from '../CnFilesBrowser/CnFilesBrowser.vue'
241
248
  import { useFileOpener } from '../../composables/useFileOpener.js'
242
249
  import { buildHeaders, prefixUrl } from '../../utils/index.js'
243
- import { objectDisplayName, resolveObjectFolderInfo } from '../CnFilesBrowser/filesBrowser.js'
250
+ import { indexRowData, objectDisplayName, resolveObjectFolderInfo } from '../CnFilesBrowser/filesBrowser.js'
244
251
  import { createOpenRegisterSource } from '../CnFilesBrowser/openRegisterSource.js'
245
252
 
246
253
  /**
@@ -359,6 +366,47 @@ export default {
359
366
  * Null fetches nothing.
360
367
  */
361
368
  linkedItemsUrl: { type: String, default: null },
369
+ /**
370
+ * Forwarded to CnFilesBrowser: the columns the browser shows, built-in
371
+ * names or declared columns (`{ key, label, source, formatter }`).
372
+ * A `source: 'row'` column reads the host's record for the file from
373
+ * `rowDataUrl`. None keeps name, size and modified.
374
+ *
375
+ * @type {Array<string|object>}
376
+ */
377
+ columns: { type: Array, default: () => [] },
378
+ /** Forwarded to CnFilesBrowser: the app id the user's column choice is stored under; unset offers no Columns chooser. */
379
+ preferenceApp: { type: String, default: '' },
380
+ /** Forwarded to CnFilesBrowser: the key the column choice is stored under. */
381
+ preferenceKey: { type: String, default: 'files-browser-columns' },
382
+ /**
383
+ * An app endpoint that answers the host's own record per file, for the
384
+ * browser's `source: 'row'` columns. App-relative; `{objectId}` is
385
+ * replaced. Read once per listing. The answer is a list of records
386
+ * (or the list at `rowDataPath`, or under `items` or `results`), each
387
+ * naming its file in `rowDataKey`. Null reads nothing.
388
+ */
389
+ rowDataUrl: { type: String, default: null },
390
+ /**
391
+ * Forwarded to CnFilesBrowser: the host's actions on a selection of
392
+ * files. With at least one, files get checkboxes and a bulk bar.
393
+ *
394
+ * @type {Array<{id: string, label: string, icon?: string, type?: string, target?: string, props?: object}>}
395
+ */
396
+ bulkActions: { type: Array, default: () => [] },
397
+ /** Forwarded to CnFilesBrowser: the key of a declared column to group the files on. */
398
+ groupBy: { type: String, default: '' },
399
+ /**
400
+ * Forwarded to CnFilesBrowser: keys of declared columns to filter on,
401
+ * a chip per value in use.
402
+ *
403
+ * @type {string[]}
404
+ */
405
+ facets: { type: Array, default: () => [] },
406
+ /** A dotted path to the list of records in the `rowDataUrl` answer; empty reads the answer itself. */
407
+ rowDataPath: { type: String, default: '' },
408
+ /** The record field that holds the Nextcloud file id. */
409
+ rowDataKey: { type: String, default: 'fileId' },
362
410
  /** Label of the Download action. */
363
411
  downloadLabel: { type: String, default: () => t('nextcloud-vue', 'Download') },
364
412
  /** Label of the action that opens the Files sidebar on the file. */
@@ -424,6 +472,27 @@ export default {
424
472
  },
425
473
 
426
474
  computed: {
475
+ /**
476
+ * The browser's `rowData`: a function that reads the host's records
477
+ * from `rowDataUrl` and keys them by file id, or null without a url.
478
+ * The browser calls it once per listing.
479
+ *
480
+ * @return {(function(): Promise<object>)|null} The row data source.
481
+ * @spec openspec/changes/files-browser-hosts-a-documents-list/specs/files-browser/spec.md
482
+ */
483
+ rowDataSource() {
484
+ if (!this.rowDataUrl || !this.objectId) {
485
+ return null
486
+ }
487
+ const url = generateUrl(this.rowDataUrl.replace('{objectId}', encodeURIComponent(this.objectId)))
488
+ const path = this.rowDataPath
489
+ const key = this.rowDataKey
490
+ return async () => {
491
+ const { data } = await axios.get(url)
492
+ return indexRowData(data, { path, key })
493
+ }
494
+ },
495
+
427
496
  /**
428
497
  * The root crumb: the host's label, else the object's name (its folder
429
498
  * on disk is called after its uuid), else the folder's own name.
@@ -18,6 +18,8 @@
18
18
  {{ cancelLabel }}
19
19
  </NcButton>
20
20
  </p>
21
+ <!-- @slot composer-before An app's own control above the composer, e.g. a template picker. Scope: `{ setText, text }`; `setText(text)` fills the composer, `text` is what it holds now. -->
22
+ <slot name="composer-before" :setText="setComposerText" :text="newNoteText" />
21
23
  <CnNoteComposer
22
24
  class="cn-sidebar-tab__composer"
23
25
  :modelValue="newNoteText"
@@ -481,6 +483,20 @@ export default {
481
483
  }
482
484
  },
483
485
 
486
+ /**
487
+ * Fill the composer from outside, for the `composer-before` slot.
488
+ *
489
+ * The app decides what goes in (a template body, a quoted line); the
490
+ * composer stays the one place the text lives, so sending, editing and
491
+ * the mention picker all see it exactly as if it had been typed.
492
+ *
493
+ * @param {string} text The text the composer should hold.
494
+ * @return {void}
495
+ */
496
+ setComposerText(text) {
497
+ this.newNoteText = (text === null || text === undefined) ? '' : String(text)
498
+ },
499
+
484
500
  startEdit(note) {
485
501
  this.replyTo = null
486
502
  this.editingNoteId = note.id