@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
package/l10n/en.json CHANGED
@@ -1720,7 +1720,11 @@
1720
1720
  "Made {date}": "Made {date}",
1721
1721
  "expires {date}": "expires {date}",
1722
1722
  "Loading …": "Loading …",
1723
- "Reorder {item}: drag, or press the up and down arrow keys": "Reorder {item}: drag, or press the up and down arrow keys"
1723
+ "Reorder {item}: drag, or press the up and down arrow keys": "Reorder {item}: drag, or press the up and down arrow keys",
1724
+ "No contacts linked": "No contacts linked",
1725
+ "No relations yet": "No relations yet",
1726
+ "Period": "Period",
1727
+ "On": "On"
1724
1728
  },
1725
1729
  "plurals": {
1726
1730
  "{count} selected": [
package/l10n/nl.json CHANGED
@@ -1933,7 +1933,11 @@
1933
1933
  "Expires today": "Verloopt vandaag",
1934
1934
  "Made {date}": "Gemaakt {date}",
1935
1935
  "expires {date}": "verloopt {date}",
1936
- "Reorder {item}: drag, or press the up and down arrow keys": "{item} verplaatsen: slepen, of de pijltoetsen omhoog en omlaag"
1936
+ "Reorder {item}: drag, or press the up and down arrow keys": "{item} verplaatsen: slepen, of de pijltoetsen omhoog en omlaag",
1937
+ "No contacts linked": "Geen contacten gekoppeld",
1938
+ "No relations yet": "Nog geen relaties",
1939
+ "Period": "Periode",
1940
+ "On": "Aan"
1937
1941
  },
1938
1942
  "plurals": {
1939
1943
  "{count} selected": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.101.0",
3
+ "version": "2.103.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -217,6 +217,7 @@ Controlling the inline action button count — `inlineActionCount` sets how many
217
217
  | `showBuildiqButton` | Boolean | `true` | Whether the buildiq square renders in the bar (the index page takes it into its header under the board look) |
218
218
  | `bulkNoun` | String | `''` | The plural in the bulk band lead, "With the selected <noun>" (default "items") |
219
219
  | `bulkHint` | String | `''` | The 13px hint after the bulk band's buttons |
220
+ | `bulkBandAlways` | Boolean | `false` | Board look: draw the bulk band before anything is selected too, its buttons disabled until there is a selection (DqZaken, DqWerkbord). Only when the band has an action to offer. |
220
221
 
221
222
 
222
223
  `showBuildiqButton` is documented in the prop table of the component reference (board look, screens-chrome-parity).
@@ -507,12 +507,15 @@
507
507
 
508
508
  <!--
509
509
  Board look: the bulk band is its own row between the toolbar and the
510
- table card, outside the toolbar element, and only while rows are
511
- selected. The count stays announced by the live region above.
510
+ table card, outside the toolbar element, while rows are selected; with
511
+ `bulkBandAlways` also before, its buttons disabled until there is a
512
+ selection (DqZaken, DqWerkbord). The count stays announced by the live
513
+ region above.
512
514
  -->
513
515
  <div
514
- v-if="isBoardLayout && selectable && selectedIds.length > 0"
516
+ v-if="isBoardLayout && selectable && (selectedIds.length > 0 || (bulkBandAlways && bulkBandHasActions))"
515
517
  class="cn-actions-bar__band"
518
+ :class="{ 'cn-actions-bar__band--idle': selectedIds.length === 0 }"
516
519
  role="region"
517
520
  :aria-label="t('nextcloud-vue', 'Actions for the selection')"
518
521
  data-testid="cn-bulk-band">
@@ -520,12 +523,14 @@
520
523
  <NcButton
521
524
  v-if="showMassCopy"
522
525
  variant="secondary"
526
+ :disabled="selectedIds.length === 0"
523
527
  @click="$emit('show-copy')">
524
528
  {{ t('nextcloud-vue', 'Copy selected') }}
525
529
  </NcButton>
526
530
  <NcButton
527
531
  v-if="showMassDelete"
528
532
  variant="secondary"
533
+ :disabled="selectedIds.length === 0"
529
534
  @click="$emit('show-delete')">
530
535
  {{ t('nextcloud-vue', 'Delete selected') }}
531
536
  </NcButton>
@@ -533,7 +538,7 @@
533
538
  v-for="entry in bulkActions"
534
539
  :key="entry.id"
535
540
  variant="secondary"
536
- :disabled="entry.disabled === true"
541
+ :disabled="entry.disabled === true || selectedIds.length === 0"
537
542
  :data-testid="`cn-bulk-action-${entry.id}`"
538
543
  :lang="bulkLang(entry)"
539
544
  @click="$emit('bulk-action', { id: entry.id, action: entry.id, selectedIds, count: selectedIds.length })">
@@ -719,6 +724,16 @@ export default {
719
724
  default: '',
720
725
  },
721
726
 
727
+ /**
728
+ * Board look: draw the bulk band also before anything is selected, its
729
+ * buttons disabled until there is a selection, as DqZaken and DqWerkbord
730
+ * draw it. Only when the band has an action to offer.
731
+ */
732
+ bulkBandAlways: {
733
+ type: Boolean,
734
+ default: false,
735
+ },
736
+
722
737
  /** The 13px hint after the board bulk band's buttons (`config.bulkHint`). */
723
738
  bulkHint: {
724
739
  type: String,
@@ -1117,6 +1132,18 @@ export default {
1117
1132
  return t('nextcloud-vue', '{count} active', { count: this.filterCount })
1118
1133
  },
1119
1134
 
1135
+ /**
1136
+ * Whether the bulk band has an action to offer: copy, delete, a
1137
+ * declared bulk action or the host's own buttons.
1138
+ *
1139
+ * @return {boolean}
1140
+ */
1141
+ bulkBandHasActions() {
1142
+ return this.showMassCopy || this.showMassDelete
1143
+ || (Array.isArray(this.bulkActions) && this.bulkActions.length > 0)
1144
+ || Boolean(this.$slots['selection-actions'])
1145
+ },
1146
+
1120
1147
  /**
1121
1148
  * The bold lead of the board bulk band.
1122
1149
  *
@@ -18,7 +18,7 @@ When `CnDashboardPage` resolves a widget definition with `type: "chart"`, it mou
18
18
  }
19
19
  ```
20
20
 
21
- Supported `props` keys forwarded by the dispatcher: `chartKind` (→ `type`), `series`, `categories`, `labels`, `options`, `colors`, `toolbar`, `legend`, `height`, `width`, `unavailableLabel`, plus the display passthrough `horizontal`, `legendPosition`, `valueAxisBaseline`, `valueFormat`, `colorMap`, `emptyLabel`, `views`, `endpointSource`, `barMax`, `footnote`, `highlight`, `cellSource`. The `dataSource` field resolves declaratively: `aggregate: 'count'` / raw `graphql` via GraphQL, `bucket` / `groupBy` / the OBJECT-form `aggregate` (Wave 3: group-by + topN/Other + labelResolve) via OpenRegister's REST aggregation facets, with an optional sibling `drilldown: { route, filterParam }` that navigates on segment/bar click with the raw category key in the query.
21
+ Supported `props` keys forwarded by the dispatcher: `chartKind` (→ `type`), `series`, `categories`, `labels`, `options`, `colors`, `toolbar`, `legend`, `height`, `width`, `unavailableLabel`, plus the display passthrough `horizontal`, `legendPosition`, `valueAxisBaseline`, `valueFormat`, `colorMap`, `emptyLabel`, `views`, `endpointSource`, `barMax`, `footnote`, `highlight`, `cellSource`, `emptyCellLabel`. The `dataSource` field resolves declaratively: `aggregate: 'count'` / raw `graphql` via GraphQL, `bucket` / `groupBy` / the OBJECT-form `aggregate` (Wave 3: group-by + topN/Other + labelResolve) via OpenRegister's REST aggregation facets, with an optional sibling `drilldown: { route, filterParam }` that navigates on segment/bar click with the raw category key in the query.
22
22
 
23
23
  Area chart — time series data:
24
24
 
@@ -107,7 +107,8 @@ Fallback slot — shown when ApexCharts is not available:
107
107
  | `horizontal` | Boolean | `false` | Render `type: "bar"` charts horizontally (row bars); an explicit `options.plotOptions.bar.horizontal` still wins |
108
108
  | `legendPosition` | String | `''` | Legend placement override (`top`/`bottom`/`left`/`right`); empty keeps the automatic placement |
109
109
  | `barMax` | Number | `null` | `bar-list`: the value a full track stands for (100 for percentages); null takes the largest value |
110
- | `footnote` | String | `''` | `bar-list` / `matrix`: a line under the rows, through the host's translate |
110
+ | `footnote` | String | `''` | A line under the chart, through the host's translate: under the rows of a `bar-list` or `matrix`, under the plot of a `line`, `area` or `bar` chart |
111
+ | `emptyCellLabel` | String | `''` | `matrix`: the text of a cell without a value ("geen"), through the host's translate. Empty leaves the cell blank |
111
112
  | `highlight` | Object | `null` | `matrix`: the cell to outline, `{ row, column, columnKeys? }`, tokens such as `@object.behaviour` resolved |
112
113
  | `cellSource` | Object | `null` | `matrix`: read the cells from records that each hold one cell (`{ register, schema, filter?, rowField, columnField, valueField, … }`), or with `cellsPath` from the cells inside one record, see [Bar list and matrix](#bar-list-and-matrix) |
113
114
  | `valueAxisBaseline` | String | `'auto'` | How the value axis picks its baseline, so a small difference cannot read as a dramatic one. `'auto'` anchors magnitude marks (bar/area) at zero and stops line charts zooming past a quarter of the data's magnitude; `'zero'` always anchors at zero; `'fit'` restores plain ApexCharts autoscaling — use it for series that live far from zero (a percentage hovering 95–99), where a zero baseline flattens the signal. Pie-family charts, negative series, empty data and stacked charts (`options.chart.stacked` — where the visible height is the per-category sum, not the largest datapoint) always autoscale; an explicit `options.yaxis.min`/`max` still wins. The ceiling rounds up to a nice number (7 → 8, 62 → 80). |
@@ -126,6 +126,12 @@
126
126
  </p>
127
127
  </slot>
128
128
  </div>
129
+ <p
130
+ v-if="footnote && chartComponent && !showEmptyState && type !== 'bar-list' && type !== 'matrix'"
131
+ class="cn-chart-widget__footnote"
132
+ data-testid="cn-chart-widget-footnote">
133
+ {{ hostTranslate(footnote) }}
134
+ </p>
129
135
  </div>
130
136
  </template>
131
137
 
@@ -368,8 +374,9 @@ export default {
368
374
  },
369
375
 
370
376
  /**
371
- * A line under a `bar-list` or `matrix` ("Klik een balk om de zaken
372
- * te zien"), through the host's translate.
377
+ * A line under the chart ("Klik een balk om de zaken te zien"),
378
+ * through the host's translate: under the rows of a `bar-list` or
379
+ * `matrix`, and under the plot of an ApexCharts type.
373
380
  *
374
381
  * @spec openspec/changes/chart-bar-list-and-matrix/specs/chart-bar-list-and-matrix/spec.md#requirement-a-chart-can-draw-a-bar-list
375
382
  */
@@ -605,6 +612,17 @@ export default {
605
612
  default: '',
606
613
  },
607
614
 
615
+ /**
616
+ * `matrix`: the text of a cell without a value ("geen"), through the
617
+ * host's translate. Empty (the default) leaves the cell blank.
618
+ *
619
+ * @spec openspec/changes/chart-bar-list-and-matrix/specs/chart-bar-list-and-matrix/spec.md#requirement-a-chart-can-draw-a-matrix
620
+ */
621
+ emptyCellLabel: {
622
+ type: String,
623
+ default: '',
624
+ },
625
+
608
626
  /**
609
627
  * Manifest dataSource block. When set, `series` /
610
628
  * `categories` / `labels` are resolved from the GraphQL
@@ -1442,7 +1460,7 @@ export default {
1442
1460
  const value = Number(raw)
1443
1461
  return Number.isFinite(value) && raw !== null && raw !== undefined
1444
1462
  ? { text: this.formatPlain(value), level: levelOf(value), color: '', current: isCurrent }
1445
- : { text: '', level: 0, color: '', current: isCurrent }
1463
+ : { text: this.emptyCellLabel ? this.hostTranslate(this.emptyCellLabel) : '', level: 0, color: '', current: isCurrent }
1446
1464
  }),
1447
1465
  })),
1448
1466
  }
@@ -2388,6 +2406,8 @@ export default {
2388
2406
  * Map an endpoint payload whose `rowsPath` names an array of rows:
2389
2407
  * each row is a series (name at `rowLabelPath`, values at
2390
2408
  * `rowValuesPath`), the categories are at `labelsPath`.
2409
+ * A missing value stays null; a `matrix` keeps a text value ("none")
2410
+ * as text, other types read it as 0.
2391
2411
  *
2392
2412
  * @param {unknown} payload The plucked payload.
2393
2413
  * @param {object} es The `endpointSource` block.
@@ -2398,12 +2418,23 @@ export default {
2398
2418
  const rows = getByPath(payload, es.rowsPath)
2399
2419
  const rawLabels = es.labelsPath ? getByPath(payload, es.labelsPath) : undefined
2400
2420
  const labels = Array.isArray(rawLabels) ? rawLabels.map((v) => ((v === null || v === undefined) ? '' : String(v))) : []
2421
+ const keepText = this.type === 'matrix'
2422
+ const cellOf = (v) => {
2423
+ if (v === null || v === undefined) {
2424
+ return null
2425
+ }
2426
+ const num = typeof v === 'string' && v.trim() === '' ? NaN : Number(v)
2427
+ if (Number.isFinite(num)) {
2428
+ return num
2429
+ }
2430
+ return keepText && typeof v === 'string' ? v : 0
2431
+ }
2401
2432
  const series = (Array.isArray(rows) ? rows : []).map((row) => {
2402
2433
  const name = getByPath(row, es.rowLabelPath || 'label')
2403
2434
  const values = getByPath(row, es.rowValuesPath || 'values')
2404
2435
  return {
2405
2436
  name: (name === null || name === undefined) ? '' : String(name),
2406
- data: Array.isArray(values) ? values.map((v) => (v === null || v === undefined ? null : Number(v) || 0)) : [],
2437
+ data: Array.isArray(values) ? values.map(cellOf) : [],
2407
2438
  }
2408
2439
  })
2409
2440
  return { series, categories: labels, labels }
@@ -2690,6 +2721,13 @@ export default {
2690
2721
  margin: 0;
2691
2722
  }
2692
2723
 
2724
+ /* Under an ApexCharts plot the footnote is a sibling of the canvas, so it keeps
2725
+ its own line height while the canvas takes the rest of a fitted column. */
2726
+ .cn-chart-widget > .cn-chart-widget__footnote {
2727
+ flex: 0 0 auto;
2728
+ margin-top: 8px;
2729
+ }
2730
+
2693
2731
  .cn-chart-widget__matrix {
2694
2732
  display: grid;
2695
2733
  gap: 4px;
@@ -23,6 +23,23 @@ Read-only, for a closed record:
23
23
  ]" />
24
24
  ```
25
25
 
26
+ Channel chips and a header action. `channel-filter` draws All plus a chip per channel with its count, `channels` lists labels that always get a chip, and the `header-actions` slot puts buttons beside the title:
27
+
28
+ ```vue
29
+ <CnConversationThread
30
+ title="Contact"
31
+ channel-filter
32
+ :channels="['E-mail', 'Letter']"
33
+ :messages="[
34
+ { id: 1, author: 'Sanne de Vries', side: 'them', channel: 'E-mail', text: 'Could I see the documents?' },
35
+ { id: 2, side: 'us', channel: 'E-mail', text: 'They are on their way.' },
36
+ ]">
37
+ <template #header-actions>
38
+ <NcButton>Log contact</NcButton>
39
+ </template>
40
+ </CnConversationThread>
41
+ ```
42
+
26
43
  No messages yet:
27
44
 
28
45
  ```vue
@@ -9,13 +9,27 @@
9
9
  data-testid="cn-conversation-thread"
10
10
  :aria-labelledby="title ? titleId : null"
11
11
  :aria-label="title ? null : fallbackLabel">
12
- <component
13
- :is="titleTag"
14
- v-if="title"
15
- :id="titleId"
16
- class="cn-conversation-thread__title">
17
- {{ title }}
18
- </component>
12
+ <div v-if="title || $slots['header-actions']" class="cn-conversation-thread__header">
13
+ <component
14
+ :is="titleTag"
15
+ v-if="title"
16
+ :id="titleId"
17
+ class="cn-conversation-thread__title">
18
+ {{ title }}
19
+ </component>
20
+ <div v-if="$slots['header-actions']" class="cn-conversation-thread__header-actions">
21
+ <!-- @slot header-actions Buttons beside the title, for example "Log contact". -->
22
+ <slot name="header-actions" />
23
+ </div>
24
+ </div>
25
+
26
+ <CnQuickFilterBar
27
+ v-if="channelChips.length > 0"
28
+ inline
29
+ class="cn-conversation-thread__channels"
30
+ :tabs="channelChips"
31
+ :activeIndex="activeChannelIndex"
32
+ @update:activeIndex="onChannelChip" />
19
33
 
20
34
  <p v-if="loading && rows.length === 0"
21
35
  class="cn-conversation-thread__empty"
@@ -27,9 +41,13 @@
27
41
  {{ emptyText }}
28
42
  </p>
29
43
 
44
+ <p v-else-if="shownRows.length === 0" class="cn-conversation-thread__empty" data-testid="cn-conversation-channel-empty">
45
+ {{ emptyText }}
46
+ </p>
47
+
30
48
  <ol v-else class="cn-conversation-thread__messages">
31
49
  <li
32
- v-for="row in rows"
50
+ v-for="row in shownRows"
33
51
  :key="row.key"
34
52
  class="cn-conversation-thread__message"
35
53
  :class="`cn-conversation-thread__message--${row.side}`"
@@ -93,6 +111,7 @@
93
111
  import axios from '@nextcloud/axios'
94
112
  import { translate as t } from '@nextcloud/l10n'
95
113
  import { NcButton } from '@nextcloud/vue'
114
+ import CnQuickFilterBar from '../CnQuickFilterBar/CnQuickFilterBar.vue'
96
115
  import { prefixUrl } from '../../utils/headers.js'
97
116
  import { userLocale } from '../../utils/userLocale.js'
98
117
 
@@ -126,6 +145,7 @@ export default {
126
145
  name: 'CnConversationThread',
127
146
 
128
147
  components: {
148
+ CnQuickFilterBar,
129
149
  NcButton,
130
150
  },
131
151
 
@@ -204,6 +224,24 @@ export default {
204
224
  default: false,
205
225
  },
206
226
 
227
+ /**
228
+ * Draw channel chips above the messages: All, then one per channel
229
+ * with its count. Choosing one shows only that channel's messages.
230
+ */
231
+ channelFilter: {
232
+ type: Boolean,
233
+ default: false,
234
+ },
235
+
236
+ /**
237
+ * Channel labels that always get a chip, in this order, also when no
238
+ * message uses them yet. Channels the messages use follow.
239
+ */
240
+ channels: {
241
+ type: Array,
242
+ default: () => [],
243
+ },
244
+
207
245
  /** Shown when there are no messages yet. */
208
246
  emptyText: {
209
247
  type: String,
@@ -224,6 +262,8 @@ export default {
224
262
  conversationThreadUid += 1
225
263
  return {
226
264
  draft: '',
265
+ /** The channel the chips narrow the thread to; '' is All. */
266
+ activeChannel: '',
227
267
  sending: false,
228
268
  errorMessage: '',
229
269
  titleId: `cn-conversation-title-${conversationThreadUid}`,
@@ -262,6 +302,47 @@ export default {
262
302
  })
263
303
  },
264
304
 
305
+ /**
306
+ * The channel labels with a chip: the declared ones, then the ones
307
+ * the messages use.
308
+ *
309
+ * @return {Array<string>} The labels, each once.
310
+ */
311
+ channelLabels() {
312
+ const declared = (Array.isArray(this.channels) ? this.channels : []).filter((label) => typeof label === 'string' && label !== '')
313
+ return [...new Set([...declared, ...this.rows.map((row) => row.channel).filter(Boolean)])]
314
+ },
315
+
316
+ /**
317
+ * The chips: All, then each channel, each with its count. Empty
318
+ * without `channelFilter` or without channels.
319
+ *
320
+ * @return {Array<{label: string, count: number}>} The chips.
321
+ */
322
+ channelChips() {
323
+ if (!this.channelFilter || this.channelLabels.length === 0) {
324
+ return []
325
+ }
326
+ return [
327
+ { label: t('nextcloud-vue', 'All'), count: this.rows.length },
328
+ ...this.channelLabels.map((label) => ({ label, count: this.rows.filter((row) => row.channel === label).length })),
329
+ ]
330
+ },
331
+
332
+ /** @return {number} The pressed chip: 0 for All. */
333
+ activeChannelIndex() {
334
+ const index = this.channelLabels.indexOf(this.activeChannel)
335
+ return index < 0 ? 0 : index + 1
336
+ },
337
+
338
+ /** @return {Array<object>} The rows on the chosen channel, or all of them. */
339
+ shownRows() {
340
+ if (this.channelChips.length === 0 || this.activeChannelIndex === 0) {
341
+ return this.rows
342
+ }
343
+ return this.rows.filter((row) => row.channel === this.activeChannel)
344
+ },
345
+
265
346
  /**
266
347
  * Whether there is something to send and nothing in flight.
267
348
  *
@@ -289,6 +370,16 @@ export default {
289
370
  },
290
371
 
291
372
  methods: {
373
+ /**
374
+ * Narrow the thread to the chip's channel; the All chip (0) clears it.
375
+ *
376
+ * @param {number|null} index The chip index.
377
+ * @return {void}
378
+ */
379
+ onChannelChip(index) {
380
+ this.activeChannel = index > 0 ? (this.channelLabels[index - 1] || '') : ''
381
+ },
382
+
292
383
  /**
293
384
  * Up to two initials of a name, for the avatar.
294
385
  *
@@ -370,6 +461,20 @@ export default {
370
461
  gap: calc(3.5 * var(--default-grid-baseline));
371
462
  }
372
463
 
464
+ .cn-conversation-thread__header {
465
+ align-items: flex-start;
466
+ display: flex;
467
+ flex-wrap: wrap;
468
+ gap: calc(3 * var(--default-grid-baseline));
469
+ justify-content: space-between;
470
+ }
471
+
472
+ .cn-conversation-thread__header-actions {
473
+ display: flex;
474
+ flex-wrap: wrap;
475
+ gap: calc(2 * var(--default-grid-baseline));
476
+ }
477
+
373
478
  .cn-conversation-thread__title {
374
479
  font-size: 1.1em;
375
480
  font-weight: 700;
@@ -15,12 +15,27 @@
15
15
  :endpoint="replyAction ? '' : endpoint"
16
16
  :loading="loading"
17
17
  :emptyText="content.emptyText ? tr(content.emptyText) : undefined"
18
+ :channelFilter="content.channelChips === true"
19
+ :channels="channelLabels"
18
20
  @send="onSend"
19
- @sent="onSent" />
21
+ @sent="onSent">
22
+ <template v-if="headerAction" #header-actions>
23
+ <NcButton
24
+ data-testid="cn-conversation-header-action"
25
+ @click="onHeaderAction">
26
+ <template v-if="headerAction.icon" #icon>
27
+ <CnIcon :name="headerAction.icon" :size="20" />
28
+ </template>
29
+ {{ tr(headerAction.label) }}
30
+ </NcButton>
31
+ </template>
32
+ </CnConversationThread>
20
33
  </div>
21
34
  </template>
22
35
 
23
36
  <script>
37
+ import { NcButton } from '@nextcloud/vue'
38
+ import CnIcon from '../CnIcon/CnIcon.vue'
24
39
  import CnConversationThread from './CnConversationThread.vue'
25
40
  import { dispatchAction } from '../../utils/actionsDispatcher.js'
26
41
  import { fetchReferenceObjects } from '../../utils/boardReferenceColumns.js'
@@ -45,12 +60,20 @@ import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveF
45
60
  * `replyAction`, an action descriptor the app decides the meaning of: it is
46
61
  * dispatched with `args: [{ text, objectId }]` and `props.text`. After a send
47
62
  * the source is read again.
63
+ *
64
+ * `channelChips: true` draws a chip per channel above the thread (every
65
+ * `channelMap` label, in map order, then any other channel a message uses).
66
+ * `headerAction` is a button beside the title (`{ label, icon?, ...action }`,
67
+ * DqZaakContact's "Contact vastleggen"), dispatched like `replyAction` with
68
+ * `args: [{ objectId }]`; after it the source is read again.
48
69
  */
49
70
  export default {
50
71
  name: 'CnConversationWidget',
51
72
 
52
73
  components: {
53
74
  CnConversationThread,
75
+ CnIcon,
76
+ NcButton,
54
77
  },
55
78
 
56
79
  inject: {
@@ -102,6 +125,18 @@ export default {
102
125
  return source && typeof source === 'object' && source.register && source.schema ? source : null
103
126
  },
104
127
 
128
+ /** @return {object|null} The header action, or null without a label. */
129
+ headerAction() {
130
+ const action = this.content?.headerAction
131
+ return action && typeof action === 'object' && typeof action.label === 'string' && action.label !== '' ? action : null
132
+ },
133
+
134
+ /** @return {Array<string>} The `channelMap` labels, translated, each once. */
135
+ channelLabels() {
136
+ const map = this.content?.channelMap && typeof this.content.channelMap === 'object' ? this.content.channelMap : {}
137
+ return [...new Set(Object.values(map).filter((label) => typeof label === 'string' && label !== '').map((label) => this.tr(label)))]
138
+ },
139
+
105
140
  /** @return {object|null} The reply action, or null. */
106
141
  replyAction() {
107
142
  const action = this.content?.replyAction
@@ -254,6 +289,30 @@ export default {
254
289
  this.onSent({ text })
255
290
  },
256
291
 
292
+ /**
293
+ * Run `headerAction` with the record id, then read the source again.
294
+ *
295
+ * @return {Promise<void>}
296
+ */
297
+ async onHeaderAction() {
298
+ if (!this.headerAction) {
299
+ return
300
+ }
301
+ const { label, icon, ...rest } = resolveDeepTokens(this.headerAction, this.tokenCtx)
302
+ const action = { ...rest, args: [{ objectId: this.objectId }] }
303
+ try {
304
+ const outcome = await (typeof this.cnDispatchAction === 'function'
305
+ ? this.cnDispatchAction(action)
306
+ : dispatchAction(action, { router: this.$router || null }))
307
+ if (outcome === false) {
308
+ return
309
+ }
310
+ } catch {
311
+ return
312
+ }
313
+ await this.loadSource()
314
+ },
315
+
257
316
  /**
258
317
  * Show an accepted reply at once, without waiting for the record.
259
318
  *
@@ -126,12 +126,15 @@ predicate's field value into their text wherever it says `{value}` (e.g.
126
126
  | `gridMargin` | Number | `12` | Grid margin (gutter) in pixels between widgets |
127
127
  | `emptyLabel` | String | `'No widgets configured'` | Text shown in the empty state when `layout` is empty |
128
128
  | `unavailableLabel` | String | `'Widget not available'` | Text shown for unknown or unavailable widgets |
129
- | `userLayout` | Boolean | `false` | Keep a per-user arrangement of this page. Off by default: a page without it makes **no** layout request and renders exactly as before. |
129
+ | `userLayout` | Boolean | `false` | **Deprecated** (layout belongs to Buildiq; removed after this release). Keep a per-user arrangement of this page. Off by default: a page without it makes **no** layout request and renders exactly as before. |
130
130
  | `appId` | String | `''` | The Nextcloud app the user layout is stored under. Required for `userLayout` to do anything. |
131
131
  | `userLayoutStore` | Object | `null` | A store exposing `loadDashboardLayout` / `saveDashboardLayout` / `resetDashboardLayout`, usually one carrying [`dashboardLayoutsPlugin`](../../../docs/store/plugins/dashboard-layouts.md). Left unset, the page reads and writes user preferences directly. |
132
132
 
133
133
  ## Per-user layout
134
134
 
135
+ > **Deprecated: layout belongs to Buildiq.** Arranging a page is Buildiq's job (Ruben, 18 Oct 2026): its **Indeling aanpassen** edits the layout everybody sees. `userLayout` still works for this release and will be removed after it. While Buildiq edits the page (`cnEditingBody`), the grid shows the manifest layout, and a drag or resize goes into the manifest Buildiq saves, also on a page with `userLayout`. A user arrangement made outside an in-page edit session is saved at once, because no **Done** follows to save it.
136
+
137
+
135
138
  With `userLayout: true` and an `appId`, this page loads the current user's arrangement before the first grid render and merges it over the manifest layout. The rules are [`mergeUserLayout`](../../../docs/utilities/merge-user-layout.md)'s: **the manifest decides which widgets exist, the user decides where they sit.**
136
139
 
137
140
  ```vue