@conduction/nextcloud-vue 2.101.0 → 2.103.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 (140) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +22 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +8 -6
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  6. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +29 -4
  7. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  8. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +9 -1
  9. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  10. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  11. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +73 -0
  12. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  13. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +99 -64
  14. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  16. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +46 -0
  17. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  18. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +37 -2
  19. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  20. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +49 -6
  22. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +1 -1
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  26. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +113 -20
  27. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  28. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +387 -359
  29. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +84 -4
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +8 -6
  33. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/useNamedSource.js +26 -2
  35. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  36. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue.js +1 -1
  37. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue2.js +25 -1
  38. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnMenuWidget/CnMenuWidget.vue3.js.map +1 -1
  40. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +10 -1
  42. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  43. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue.js +1 -1
  45. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +106 -4
  46. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  47. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +70 -25
  48. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  49. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  50. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +97 -3
  51. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  52. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +46 -2
  53. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  54. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js +9 -0
  55. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js.map +1 -0
  56. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js +165 -0
  57. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js.map +1 -0
  58. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js +85 -0
  59. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js.map +1 -0
  60. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  61. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +54 -6
  62. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  63. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +3 -1
  64. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  65. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  66. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +8 -1
  67. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  68. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  69. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
  70. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +2 -23
  71. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  72. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  73. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
  74. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
  75. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  76. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
  77. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  78. package/dist/esm/composables/indexSources.js +9 -0
  79. package/dist/esm/composables/indexSources.js.map +1 -1
  80. package/dist/esm/index.js +1 -0
  81. package/dist/esm/index.js.map +1 -1
  82. package/dist/esm/l10n/en.json.js +5 -1
  83. package/dist/esm/l10n/en.json.js.map +1 -1
  84. package/dist/esm/l10n/nl.json.js +5 -1
  85. package/dist/esm/l10n/nl.json.js.map +1 -1
  86. package/dist/esm/library-style.js +1 -1
  87. package/dist/esm/mixins/pageViews.js +8 -1
  88. package/dist/esm/mixins/pageViews.js.map +1 -1
  89. package/dist/esm/nextcloud-vue.css +593 -459
  90. package/dist/esm/utils/builtInFormatters.js +24 -4
  91. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  92. package/dist/esm/utils/rowClassRules.js +43 -0
  93. package/dist/esm/utils/rowClassRules.js.map +1 -0
  94. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  95. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  96. package/dist/esm/utils/visibleWhen.js +32 -4
  97. package/dist/esm/utils/visibleWhen.js.map +1 -1
  98. package/dist/nextcloud-vue.cjs.js +10093 -8858
  99. package/dist/nextcloud-vue.cjs.js.map +2 -2
  100. package/dist/nextcloud-vue.css +593 -459
  101. package/l10n/en.json +5 -1
  102. package/l10n/nl.json +5 -1
  103. package/package.json +1 -1
  104. package/src/components/CnActionsBar/CnActionsBar.md +1 -0
  105. package/src/components/CnActionsBar/CnActionsBar.vue +31 -4
  106. package/src/components/CnChartWidget/CnChartWidget.md +3 -2
  107. package/src/components/CnChartWidget/CnChartWidget.vue +42 -4
  108. package/src/components/CnConversationThread/CnConversationThread.md +17 -0
  109. package/src/components/CnConversationThread/CnConversationThread.vue +113 -8
  110. package/src/components/CnConversationThread/CnConversationWidget.vue +60 -1
  111. package/src/components/CnDashboardPage/CnDashboardPage.md +4 -1
  112. package/src/components/CnDashboardPage/CnDashboardPage.vue +50 -7
  113. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  114. package/src/components/CnFormDialog/CnFormDialog.md +1 -1
  115. package/src/components/CnFormDialog/CnFormDialog.vue +127 -0
  116. package/src/components/CnIndexPage/CnIndexPage.md +15 -1
  117. package/src/components/CnIndexPage/CnIndexPage.vue +92 -9
  118. package/src/components/CnIndexPage/useNamedSource.js +26 -2
  119. package/src/components/CnMenuWidget/CnMenuWidget.vue +25 -1
  120. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +10 -1
  121. package/src/components/CnQuickFilterBar/CnQuickFilterBar.md +1 -0
  122. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +159 -5
  123. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +134 -2
  124. package/src/components/CnSearchSelect/CnSearchSelect.vue +283 -0
  125. package/src/components/CnSearchSelect/index.js +12 -0
  126. package/src/components/CnSetupWizard/CnSetupWizard.vue +56 -6
  127. package/src/components/CnTabsWidget/CnTabsWidget.vue +8 -1
  128. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +2 -23
  129. package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
  130. package/src/components/index.js +1 -0
  131. package/src/composables/indexSources.js +9 -0
  132. package/src/css/dialog.css +5 -3
  133. package/src/css/look-board-index.css +10 -0
  134. package/src/index.js +1 -0
  135. package/src/mixins/pageViews.js +8 -1
  136. package/src/schemas/app-manifest-v2.schema.json +107 -8
  137. package/src/utils/builtInFormatters.js +24 -4
  138. package/src/utils/rowClassRules.js +39 -0
  139. package/src/utils/validateManifestV2.compiled.js +3 -3
  140. package/src/utils/visibleWhen.js +32 -4
@@ -239,6 +239,11 @@ export default {
239
239
  CnMenuItemIcon,
240
240
  },
241
241
 
242
+ inject: {
243
+ /** Host translate function provided by CnAppRoot, for the item labels. */
244
+ cnTranslate: { default: () => (key) => key },
245
+ },
246
+
242
247
  props: {
243
248
  /**
244
249
  * Persisted widget content `{items, style, orientation, showIcons,
@@ -288,7 +293,26 @@ export default {
288
293
  */
289
294
  items() {
290
295
  const arr = this.content?.items
291
- return Array.isArray(arr) ? arr : []
296
+ if (!Array.isArray(arr)) {
297
+ return []
298
+ }
299
+ // Labels are manifest strings: they go through the app's translate,
300
+ // children included, so "Case types" reads "Zaaktypen" (DqCatalogus).
301
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
302
+ const translated = (list) => list.map((item) => {
303
+ if (!item || typeof item !== 'object') {
304
+ return item
305
+ }
306
+ const out = { ...item }
307
+ if (typeof item.label === 'string' && item.label !== '') {
308
+ out.label = translate(item.label)
309
+ }
310
+ if (Array.isArray(item.children)) {
311
+ out.children = translated(item.children)
312
+ }
313
+ return out
314
+ })
315
+ return translated(arr)
292
316
  },
293
317
 
294
318
  /**
@@ -1385,7 +1385,8 @@ export default {
1385
1385
  * is the budget). Measures the cell's remaining height below the table
1386
1386
  * top, reserves room for the "View all" footer, and derives the row
1387
1387
  * budget from the first rendered row's height. No-ops (fitRows null =
1388
- * render all fetched rows) outside a fixed-height cell.
1388
+ * render all fetched rows) outside a fixed-height cell and inside a
1389
+ * tab panel, unless the content sets `fit: true`.
1389
1390
  *
1390
1391
  * @return {void}
1391
1392
  */
@@ -1407,6 +1408,14 @@ export default {
1407
1408
  this.fitRows = null
1408
1409
  return
1409
1410
  }
1411
+ // A list inside a tab panel does not own the cell, as on a
1412
+ // dashboard: it renders every fetched row unless the content asks
1413
+ // for the fit with `fit: true`.
1414
+ const panel = this.$el.closest('[role="tabpanel"]')
1415
+ if (panel && cell.contains(panel) && !(this.content && this.content.fit === true)) {
1416
+ this.fitRows = null
1417
+ return
1418
+ }
1410
1419
  const table = this.$el.querySelector('.cn-object-list-widget__table table')
1411
1420
  if (!table) {
1412
1421
  return
@@ -59,6 +59,7 @@ In `manifest.json` (the usual entry-point — CnIndexPage mounts the bar for you
59
59
  - `selectLabel` (String, default `'Filter'`) — accessible label / placeholder for the dropdown control.
60
60
  - `placeholder` (String) — placeholder text for the dropdown (falls back to `selectLabel`).
61
61
  - `maxVisible` (Number, default `0`) — chips mode only: how many pills render inline before the rest move behind one more chip, a `⋯` pill that opens a small panel of the hidden lenses (also as chips). `0` renders every tab. The visible set is the first `maxVisible` entries of `tabs`, so their declared order decides what stays in the strip; an active hidden tab moves its label and fill onto that chip. Manifest: `config.quickFilterMaxVisible`.
62
+ - `fit` (Boolean, default `false`) — chips mode only: show as many chips as the bar's own width holds and move the rest behind the overflow chip, measured again when the width, the tabs or the counts change. The bar takes the room its parent gives it; CnIndexPage's `quickFilterPlacement: "toolbar"` gives it what the Filter button and the view switch leave on their row. With `maxVisible` too, the smaller of the two wins.
62
63
  - `counts` (Object or Array, default `null`): how many records each tab matches, keyed by tab index. A tab with a number shows it after its label. CnIndexPage fills this for tabs that set `showCount`.
63
64
 
64
65
  ### Events
@@ -24,8 +24,28 @@
24
24
  </div>
25
25
  <!-- Chips mode (default): the clickable tab strip. -->
26
26
  <div v-else
27
+ ref="chipsRoot"
27
28
  class="cn-quick-filter-bar"
28
- :class="{ 'cn-quick-filter-bar--inline': inline }">
29
+ :class="{ 'cn-quick-filter-bar--inline': inline, 'cn-quick-filter-bar--fit': fit }">
30
+ <!-- With `fit`: an invisible copy of every chip and the overflow chip,
31
+ measured to work out how many chips the bar's width holds. -->
32
+ <div v-if="fit"
33
+ ref="measure"
34
+ class="cn-quick-filter-bar__measure"
35
+ aria-hidden="true">
36
+ <span v-for="entry in entries"
37
+ :key="'m:' + entry.tab.label + ':' + entry.index"
38
+ class="cn-quick-filter-bar__tab"
39
+ :class="{ 'cn-quick-filter-bar__tab--active': isChipActive(entry.index) }"
40
+ data-measure="chip">
41
+ <span class="cn-quick-filter-bar__label">{{ tl(entry.tab.label) }}</span>
42
+ <span v-if="countOf(entry) !== null" class="cn-quick-filter-bar__count">{{ ' ' + countOf(entry) }}</span>
43
+ </span>
44
+ <span class="cn-quick-filter-bar__tab cn-quick-filter-bar__more" data-measure="more">
45
+ <span v-if="overflowLabel" class="cn-quick-filter-bar__label">{{ overflowLabel }}</span>
46
+ <DotsHorizontal :size="16" class="cn-quick-filter-bar__icon" />
47
+ </span>
48
+ </div>
29
49
  <!-- The tablist holds ONLY tabs: the overflow chip is a sibling, not a
30
50
  child, because a role="tablist" may not contain a menu button. -->
31
51
  <!-- Board look: the tabs are chips, pressed buttons in a group, not a
@@ -266,6 +286,19 @@ export default {
266
286
  type: [Object, Array],
267
287
  default: null,
268
288
  },
289
+
290
+ /**
291
+ * Chips mode only: show as many chips as the bar's own width holds and
292
+ * move the rest into the overflow chip, measured again whenever the
293
+ * width, the tabs or the counts change. The bar takes the room it is
294
+ * given (CnIndexPage's `quickFilterPlacement: "toolbar"` gives it what
295
+ * the Filter button and the view switch leave on their row). With
296
+ * `maxVisible` too, the smaller of the two wins.
297
+ */
298
+ fit: {
299
+ type: Boolean,
300
+ default: false,
301
+ },
269
302
  },
270
303
 
271
304
  emits: ['update:active-index', 'update:selected-indices'],
@@ -274,6 +307,8 @@ export default {
274
307
  return {
275
308
  /** Whether the overflow chip's panel is open. */
276
309
  moreOpen: false,
310
+ /** With `fit`: how many chips the width holds; null before the first measure. */
311
+ fitCount: null,
277
312
  }
278
313
  },
279
314
 
@@ -303,10 +338,24 @@ export default {
303
338
  * @return {Array<{tab: object, index: number}>}
304
339
  */
305
340
  visibleEntries() {
306
- if (!this.maxVisible || this.tabs.length <= this.maxVisible) {
341
+ const limit = this.effectiveMax
342
+ if (!limit || this.tabs.length <= limit) {
307
343
  return this.entries
308
344
  }
309
- return this.entries.slice(0, this.maxVisible)
345
+ return this.entries.slice(0, limit)
346
+ },
347
+
348
+ /**
349
+ * How many chips render: `maxVisible`, and with `fit` no more than the
350
+ * width holds; 0 for no limit.
351
+ *
352
+ * @return {number}
353
+ */
354
+ effectiveMax() {
355
+ if (!this.fit || this.fitCount === null) {
356
+ return this.maxVisible
357
+ }
358
+ return this.maxVisible ? Math.min(this.maxVisible, this.fitCount) : this.fitCount
310
359
  },
311
360
 
312
361
  /**
@@ -316,10 +365,11 @@ export default {
316
365
  * @return {Array<{tab: object, index: number}>}
317
366
  */
318
367
  overflowEntries() {
319
- if (!this.maxVisible || this.tabs.length <= this.maxVisible) {
368
+ const limit = this.effectiveMax
369
+ if (!limit || this.tabs.length <= limit) {
320
370
  return []
321
371
  }
322
- return this.entries.slice(this.maxVisible)
372
+ return this.entries.slice(limit)
323
373
  },
324
374
 
325
375
  /**
@@ -399,7 +449,79 @@ export default {
399
449
  },
400
450
  },
401
451
 
452
+ watch: {
453
+ tabs() {
454
+ this.scheduleFit()
455
+ },
456
+
457
+ counts: {
458
+ deep: true,
459
+ handler() {
460
+ this.scheduleFit()
461
+ },
462
+ },
463
+ },
464
+
465
+ mounted() {
466
+ if (this.fit && typeof ResizeObserver === 'function' && this.$refs.chipsRoot) {
467
+ this.fitObserver = new ResizeObserver(() => this.measureFit())
468
+ this.fitObserver.observe(this.$refs.chipsRoot)
469
+ }
470
+ this.scheduleFit()
471
+ },
472
+
473
+ beforeUnmount() {
474
+ if (this.fitObserver) {
475
+ this.fitObserver.disconnect()
476
+ this.fitObserver = null
477
+ }
478
+ },
479
+
402
480
  methods: {
481
+ /** Measure the fit after the next render. */
482
+ scheduleFit() {
483
+ if (this.fit) {
484
+ this.$nextTick(() => this.measureFit())
485
+ }
486
+ },
487
+
488
+ /**
489
+ * Work out how many chips the bar's width holds, from the invisible
490
+ * copy: every chip that fits, and when not all do, room left for the
491
+ * overflow chip. At least one chip always shows.
492
+ */
493
+ measureFit() {
494
+ const box = this.$refs.measure
495
+ const root = this.$refs.chipsRoot
496
+ if (!this.fit || !box || !root) {
497
+ return
498
+ }
499
+ const available = root.clientWidth
500
+ const chips = [...box.querySelectorAll('[data-measure="chip"]')].map((el) => el.offsetWidth)
501
+ const more = box.querySelector('[data-measure="more"]')
502
+ const moreWidth = more ? more.offsetWidth : 0
503
+ const gap = parseFloat(getComputedStyle(box).columnGap) || 8
504
+ if (available <= 0 || chips.length === 0) {
505
+ this.fitCount = null
506
+ return
507
+ }
508
+ const total = chips.reduce((sum, width) => sum + width, 0) + gap * (chips.length - 1)
509
+ if (total <= available) {
510
+ this.fitCount = null
511
+ return
512
+ }
513
+ let used = moreWidth
514
+ let count = 0
515
+ for (const width of chips) {
516
+ if (used + gap + width > available) {
517
+ break
518
+ }
519
+ used += gap + width
520
+ count++
521
+ }
522
+ this.fitCount = Math.max(1, count)
523
+ },
524
+
403
525
  /**
404
526
  * A manifest string through the host's label lookup (`cnTranslate`), so a key
405
527
  * such as "Mine" reads in the user's language.
@@ -531,6 +653,38 @@ export default {
531
653
  /* Inline variant — bare strip for embedding inside another bar (e.g. the
532
654
  CnActionsBar #filters slot), so it reads as a control beside the view toggle
533
655
  rather than a separate bordered row. */
656
+ /* With `fit`: one line, as wide as it is given; the chips it cannot hold
657
+ are in the overflow chip. The measuring copy takes no room and is never
658
+ seen or read. */
659
+ .cn-quick-filter-bar--fit {
660
+ position: relative;
661
+ flex-wrap: nowrap;
662
+ min-width: 0;
663
+ }
664
+
665
+ .cn-quick-filter-bar--fit .cn-quick-filter-bar__tabs {
666
+ flex-wrap: nowrap;
667
+ min-width: 0;
668
+ }
669
+
670
+ .cn-quick-filter-bar__measure {
671
+ position: absolute;
672
+ inset-block-start: 0;
673
+ inset-inline-start: 0;
674
+ display: flex;
675
+ gap: 8px;
676
+ width: 0;
677
+ height: 0;
678
+ overflow: hidden;
679
+ visibility: hidden;
680
+ pointer-events: none;
681
+ white-space: nowrap;
682
+ }
683
+
684
+ .cn-quick-filter-bar__measure > * {
685
+ flex: none;
686
+ }
687
+
534
688
  .cn-quick-filter-bar--inline {
535
689
  padding: 0;
536
690
  border-bottom: none;
@@ -201,13 +201,38 @@
201
201
  </span>
202
202
  </li>
203
203
  </ul>
204
+ <!-- `footerAction`: one action under the list, also under the empty
205
+ text ("Beroep registreren"). With `disabledWhen` it greys out and
206
+ says why (`disabledReason`: "Kan zodra de beslissing is
207
+ verzonden"). -->
208
+ <div
209
+ v-if="footerAction && !error"
210
+ class="cn-record-list__footer"
211
+ data-testid="cn-record-list-footer">
212
+ <NcButton
213
+ variant="secondary"
214
+ :disabled="footerDisabled"
215
+ :aria-describedby="footerDisabled && footerAction.reason ? footerReasonId : null"
216
+ data-testid="cn-record-list-footer-action"
217
+ @click="runFooterAction">
218
+ <template v-if="footerAction.icon" #icon>
219
+ <CnIcon :name="footerAction.icon" :size="16" />
220
+ </template>
221
+ {{ footerAction.label }}
222
+ </NcButton>
223
+ <span
224
+ v-if="footerDisabled && footerAction.reason"
225
+ :id="footerReasonId"
226
+ class="cn-record-list__footer-reason"
227
+ data-testid="cn-record-list-footer-reason">{{ footerAction.reason }}</span>
228
+ </div>
204
229
  </div>
205
230
  </template>
206
231
 
207
232
  <script>
208
233
  import { subscribe, unsubscribe } from '@nextcloud/event-bus'
209
234
  import { translate as t } from '@nextcloud/l10n'
210
- import { NcLoadingIcon } from '@nextcloud/vue'
235
+ import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
211
236
  import { inject, ref } from 'vue'
212
237
  import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
213
238
  import CnIcon from '../CnIcon/CnIcon.vue'
@@ -224,8 +249,12 @@ import { lensUnavailableText, readLensReports } from '../../utils/lensAvailabili
224
249
  import { pageFilter, pageSort } from '../../utils/pageControls.js'
225
250
  import { readPath } from '../../utils/readPath.js'
226
251
  import { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
252
+ import { evaluateVisibleWhen } from '../../utils/visibleWhen.js'
227
253
 
228
254
  const PAGE_REFRESH_CHANNEL = 'cn:page:refresh'
255
+
256
+ /** Gives each list's footer reason its own element id. */
257
+ let footerSeq = 0
229
258
  const PRESENTATIONS = ['rows', 'feed', 'inline', 'cards', 'notice']
230
259
 
231
260
  /** The tones a `notice` list takes. */
@@ -279,7 +308,7 @@ function unwrap(value) {
279
308
  export default {
280
309
  name: 'CnRecordListWidget',
281
310
 
282
- components: { ChevronRight, CnIcon, CnStatusBadge, NcLoadingIcon },
311
+ components: { ChevronRight, CnIcon, CnStatusBadge, NcButton, NcLoadingIcon },
283
312
 
284
313
  inject: {
285
314
  /** Detail-page object context (`{ objectId, object, register, schema }`). */
@@ -314,6 +343,10 @@ export default {
314
343
  * `presentation: "notice"` draws the rows as sentences in one box toned by
315
344
  * `variant` (`info`, `warning` or `error`), after an optional `lead`
316
345
  * (`{total}` filled in).
346
+ * `footerAction` (`{ label, icon?, type, ..., disabledWhen?,
347
+ * disabledReason? }`) ends the card with one action, also under the
348
+ * empty text; while the visibleWhen condition `disabledWhen` holds the
349
+ * button is disabled and `disabledReason` shows under it.
317
350
  *
318
351
  * @type {object}
319
352
  */
@@ -367,10 +400,48 @@ export default {
367
400
  error: false,
368
401
  /** Personal-lens reports (`@self.lenses`) of the latest response. */
369
402
  lenses: {},
403
+ /** Whether `footerAction.disabledWhen` holds. */
404
+ footerDisabled: false,
405
+ footerReasonId: `cn-record-list-footer-reason-${++footerSeq}`,
370
406
  }
371
407
  },
372
408
 
373
409
  computed: {
410
+ /**
411
+ * The action under the list (`footerAction`: `{ label, icon?, type,
412
+ * ..., disabledWhen?, disabledReason? }`), labels through the host
413
+ * translate, or null without a label.
414
+ *
415
+ * @spec openspec/changes/record-list-footer-action/specs/record-list/spec.md#requirement-a-record-list-can-end-with-an-action-that-says-why-it-is-disabled
416
+ * @return {{label: string, icon: string, reason: string, action: object}|null}
417
+ */
418
+ footerAction() {
419
+ const action = this.content?.footerAction
420
+ if (!action || typeof action !== 'object' || typeof action.label !== 'string' || action.label === '') {
421
+ return null
422
+ }
423
+ return {
424
+ label: this.translate(action.label),
425
+ icon: typeof action.icon === 'string' ? action.icon : '',
426
+ reason: typeof action.disabledReason === 'string' && action.disabledReason !== '' ? this.translate(action.disabledReason) : '',
427
+ action,
428
+ }
429
+ },
430
+
431
+ /**
432
+ * What `footerAction.disabledWhen` depends on, so it is evaluated again
433
+ * when the condition, the record or the workspace changes.
434
+ *
435
+ * @return {string}
436
+ */
437
+ footerDisabledKey() {
438
+ const cond = this.content?.footerAction?.disabledWhen
439
+ if (!cond) {
440
+ return ''
441
+ }
442
+ return JSON.stringify({ cond, object: this.tokenCtx.object, workspace: this.tokenCtx.workspace, total: this.total })
443
+ },
444
+
374
445
  /** @return {string} `rows`, `feed`, `inline` or `cards`. */
375
446
  /**
376
447
  * The tone of a `notice` list (`content.variant`): info, warning or error.
@@ -595,6 +666,13 @@ export default {
595
666
  this.fetchRows()
596
667
  },
597
668
 
669
+ footerDisabledKey: {
670
+ immediate: true,
671
+ handler() {
672
+ this.evaluateFooterDisabled()
673
+ },
674
+ },
675
+
598
676
  endpointKey() {
599
677
  this.fetchRows()
600
678
  },
@@ -621,6 +699,47 @@ export default {
621
699
  return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
622
700
  },
623
701
 
702
+ /**
703
+ * Settle whether the footer action is disabled: `disabledWhen` is a
704
+ * visibleWhen condition read against the page's record (`field`), the
705
+ * workspace, an endpoint or a source; `total` is the list's total.
706
+ *
707
+ * @spec openspec/changes/record-list-footer-action/specs/record-list/spec.md#requirement-a-record-list-can-end-with-an-action-that-says-why-it-is-disabled
708
+ * @return {Promise<void>}
709
+ */
710
+ async evaluateFooterDisabled() {
711
+ const cond = this.content?.footerAction?.disabledWhen
712
+ if (!cond) {
713
+ this.footerDisabled = false
714
+ return
715
+ }
716
+ const ctx = { ...this.tokenCtx, object: { ...(this.tokenCtx.object || {}), total: this.total } }
717
+ const key = this.footerDisabledKey
718
+ const disabled = await evaluateVisibleWhen(cond, ctx)
719
+ if (key === this.footerDisabledKey) {
720
+ this.footerDisabled = disabled
721
+ }
722
+ },
723
+
724
+ /**
725
+ * Run the footer action through the page's dispatcher, with the page's
726
+ * record as the token context.
727
+ *
728
+ * @spec openspec/changes/record-list-footer-action/specs/record-list/spec.md#requirement-a-record-list-can-end-with-an-action-that-says-why-it-is-disabled
729
+ * @return {unknown} What the dispatcher returns.
730
+ */
731
+ runFooterAction() {
732
+ const declared = this.footerAction?.action
733
+ if (!declared || this.footerDisabled) {
734
+ return undefined
735
+ }
736
+ const { disabledWhen, disabledReason, ...action } = declared
737
+ if (typeof this.cnDispatchAction === 'function') {
738
+ return this.cnDispatchAction(action)
739
+ }
740
+ return dispatchAction(action, { router: this.$router || null, tokenCtx: this.tokenCtx, source: { register: this.content?.register, schema: this.content?.schema } })
741
+ },
742
+
624
743
  /**
625
744
  * A row's `bar`: its value, the maximum it is measured against (the
626
745
  * row's `maxField`, else the largest row), the filled share and
@@ -1196,6 +1315,19 @@ export default {
1196
1315
  padding: 0;
1197
1316
  }
1198
1317
 
1318
+ .cn-record-list__footer {
1319
+ display: flex;
1320
+ flex-direction: column;
1321
+ align-items: flex-start;
1322
+ gap: 6px;
1323
+ margin-top: 10px;
1324
+ }
1325
+
1326
+ .cn-record-list__footer-reason {
1327
+ font-size: 13px;
1328
+ color: var(--color-text-maxcontrast);
1329
+ }
1330
+
1199
1331
  .cn-record-list__empty,
1200
1332
  .cn-record-list__error {
1201
1333
  color: var(--color-text-maxcontrast);