@conduction/nextcloud-vue 2.145.0 → 2.147.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 (79) hide show
  1. package/dist/esm/components/CnAiCompanion/CnAiChatPanel.vue2.js.map +1 -1
  2. package/dist/esm/components/CnAiCompanion/CnAiChatPanel.vue3.js.map +1 -1
  3. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +1 -1
  4. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -1
  5. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -1
  6. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +12 -2
  7. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  8. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +42 -30
  9. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +22 -0
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +2 -1
  13. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  14. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue.js +1 -1
  15. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js.map +1 -1
  17. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  18. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +112 -15
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  20. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +40 -16
  21. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  22. package/dist/esm/components/CnNotesCard/CnNotesCard.vue.js +1 -1
  23. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js.map +1 -1
  24. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js +1 -0
  25. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js.map +1 -1
  26. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue.js +1 -1
  27. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue2.js +21 -2
  28. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue2.js.map +1 -1
  29. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue3.js.map +1 -1
  30. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue.js +1 -1
  31. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js +2 -2
  32. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js.map +1 -1
  34. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  35. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  36. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue.js +1 -1
  38. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js.map +1 -1
  40. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  41. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  42. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  43. package/dist/esm/library-style.js +1 -1
  44. package/dist/esm/nextcloud-vue.css +634 -301
  45. package/dist/esm/utils/detailObjectContext.js +7 -2
  46. package/dist/esm/utils/detailObjectContext.js.map +1 -1
  47. package/dist/esm/utils/objectParties.js +30 -1
  48. package/dist/esm/utils/objectParties.js.map +1 -1
  49. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  50. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  51. package/dist/esm/utils/visibleWhen.js +6 -0
  52. package/dist/esm/utils/visibleWhen.js.map +1 -1
  53. package/dist/nextcloud-vue.cjs.js +362 -138
  54. package/dist/nextcloud-vue.cjs.js.map +2 -2
  55. package/dist/nextcloud-vue.css +634 -301
  56. package/package.json +1 -1
  57. package/src/components/CnAiCompanion/CnAiChatPanel.vue +3 -0
  58. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +4 -2
  59. package/src/components/CnDataTable/CnDataTable.vue +19 -2
  60. package/src/components/CnDetailPage/CnDetailPage.md +1 -0
  61. package/src/components/CnDetailPage/CnDetailPage.vue +23 -0
  62. package/src/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue +1 -0
  63. package/src/components/CnFactsWidget/CnFactsWidget.vue +168 -16
  64. package/src/components/CnNotesCard/CnNotesCard.vue +7 -2
  65. package/src/components/CnNotificationMatrix/CnNotificationMatrix.vue +21 -2
  66. package/src/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue +2 -1
  67. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +1 -0
  68. package/src/components/CnRetentionWidget/CnRetentionWidget.vue +2 -0
  69. package/src/components/CnTabsWidget/CnTabsWidget.vue +6 -1
  70. package/src/css/dialog.css +7 -0
  71. package/src/css/look-board-detail.css +152 -8
  72. package/src/css/look-board-index.css +78 -0
  73. package/src/css/look-board.css +33 -3
  74. package/src/css/table.css +9 -0
  75. package/src/schemas/app-manifest-v2.schema.json +2 -2
  76. package/src/utils/detailObjectContext.js +7 -2
  77. package/src/utils/objectParties.js +29 -0
  78. package/src/utils/validateManifestV2.compiled.js +2 -2
  79. package/src/utils/visibleWhen.js +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.145.0",
3
+ "version": "2.147.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": {
@@ -1584,11 +1584,14 @@ export default {
1584
1584
  border-radius: 10px;
1585
1585
  background: var(--color-primary-element-light, #eaf0f7);
1586
1586
  color: var(--color-primary-element-light-text, #234a78);
1587
+ font-size: 14px;
1587
1588
  line-height: 1.5;
1588
1589
  }
1589
1590
 
1591
+ /* The icon font's own align-self: center would centre it on a long note. */
1590
1592
  .cn-ai-chat-window__note-icon {
1591
1593
  flex: none;
1594
+ align-self: flex-start;
1592
1595
  margin-top: 1px;
1593
1596
  }
1594
1597
 
@@ -1204,6 +1204,7 @@ export default {
1204
1204
  .cn-checklist-run__help {
1205
1205
  color: var(--color-text-maxcontrast);
1206
1206
  font-size: 14px;
1207
+ line-height: 1.2;
1207
1208
  margin: 0;
1208
1209
  }
1209
1210
 
@@ -1215,13 +1216,14 @@ export default {
1215
1216
  font-size: 15px;
1216
1217
  font-weight: 400;
1217
1218
  gap: 10px;
1218
- min-height: 34px;
1219
+ min-height: 24px;
1219
1220
  }
1220
1221
 
1222
+ /* The board draws the radio with the browser's own 3px 3px 0 5px margin. */
1221
1223
  .cn-checklist-run__choice input {
1222
1224
  accent-color: var(--color-primary-element);
1223
1225
  height: 20px;
1224
- margin: 0;
1226
+ margin: 3px 3px 0 5px;
1225
1227
  width: 20px;
1226
1228
  }
1227
1229
 
@@ -307,6 +307,13 @@
307
307
  customising their own data, not opting out of being told
308
308
  the record is locked. It renders nothing when unlocked,
309
309
  so an unlocked table is byte-for-byte what it was. -->
310
+ <!-- A reorderable row shows where to take hold of it, before its name. -->
311
+ <span v-if="reorderable && colIndex === 0"
312
+ class="cn-table-row__handle"
313
+ aria-hidden="true"
314
+ data-testid="cn-row-reorder-handle">
315
+ <DragVertical :size="16" />
316
+ </span>
310
317
  <CnUnreadMarker
311
318
  v-if="colIndex === titleColumnIndex && isUnreadRow(row)" />
312
319
  <CnLockIndicator
@@ -475,6 +482,7 @@ import { generateUrl } from '@nextcloud/router'
475
482
  import { NcCheckboxRadioSwitch, NcLoadingIcon } from '@nextcloud/vue'
476
483
  import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
477
484
  import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
485
+ import DragVertical from 'vue-material-design-icons/DragVertical.vue'
478
486
  import FilterOutline from 'vue-material-design-icons/FilterOutline.vue'
479
487
  import CnPagination from '../CnPagination/CnPagination.vue'
480
488
  import CnColumnFilterPopover from './CnColumnFilterPopover.vue'
@@ -585,6 +593,7 @@ export default {
585
593
  CnPagination,
586
594
  CnUnreadMarker,
587
595
  ChevronDown,
596
+ DragVertical,
588
597
  ChevronUp,
589
598
  FilterOutline,
590
599
  },
@@ -630,7 +639,10 @@ export default {
630
639
  * `link: true` makes that column the one that names the row: it carries
631
640
  * the row link and the title look. Without it the first column does.
632
641
  *
633
- * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string, link: boolean, secondary: (string|((row: object) => string)), secondaryLabels: object}|string>}
642
+ * `emptyValue` is what a row without a value holds in that column, so a
643
+ * `badge` column draws a pill on every row.
644
+ *
645
+ * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string, link: boolean, secondary: (string|((row: object) => string)), secondaryLabels: object, emptyValue: unknown}|string>}
634
646
  */
635
647
  columns: {
636
648
  type: Array,
@@ -2225,7 +2237,12 @@ export default {
2225
2237
  }
2226
2238
  return v
2227
2239
  }
2228
- return this.getCellValue(row, this.columnValueField(col))
2240
+ const value = this.getCellValue(row, this.columnValueField(col))
2241
+ // `emptyValue`: what a row without a value holds, so a badge column draws one for every row.
2242
+ if ((value === null || value === undefined || value === '') && col && Object.hasOwn(col, 'emptyValue')) {
2243
+ return col.emptyValue
2244
+ }
2245
+ return value
2229
2246
  },
2230
2247
 
2231
2248
  /**
@@ -181,6 +181,7 @@ export default {
181
181
  |------|------|---------|-------------|
182
182
  | `icon` | String | `''` | Optional MDI icon name rendered in the header via `CnIcon` |
183
183
  | `iconSize` | Number | `28` | Icon size in pixels |
184
+ | `gridFloat` | Boolean | `true` | GridStack's `float` for the body grid (manifest `config.gridFloat`). False packs widgets upward, so a size-to-content widget ends at its content (DqHandhaving). |
184
185
  | `loadingLabel` | String | `'Loading...'` | Message shown below the spinner during loading |
185
186
  | `band` | Array | `[]` | A row of widgets across the whole page, above the body and the side column (manifest `config.band`). Each entry is a widget id from `widgets`, a widget definition, or `{ widgetId, span, chrome }`: `span` is the entry's share of the row in twelfths (default 12), and `chrome` is `card` or `bare` (a banner defaults to bare). Each entry follows its `visibleWhen`. |
186
187
  | `recordLastChange` | Boolean | `false` | Read who changed the record last (as `headerMetaLastChange` does) for the widgets on the page: a facts card reads it as `_lastChange.userName` / `_lastChange.created` (DqContact "Historie"). |
@@ -911,6 +911,7 @@
911
911
  :editable="section.editable"
912
912
  :columns="12"
913
913
  :columnOpts="bodyColumnOpts"
914
+ :float="gridFloat"
914
915
  class="cn-detail-page__grid"
915
916
  @layoutChange="section.view ? onViewLayoutChange($event) : onBodyLayoutChange($event)">
916
917
  <template #widget="{ item }">
@@ -1842,6 +1843,18 @@ export default {
1842
1843
  default: '',
1843
1844
  },
1844
1845
 
1846
+ /**
1847
+ * GridStack's `float` for the body grid (manifest `config.gridFloat`).
1848
+ * True (the default) keeps every widget on its authored row; false
1849
+ * packs widgets upward, so a size-to-content widget ends at its content
1850
+ * and the one below closes up (DqHandhaving: the violation card sits
1851
+ * 20px under the matrix).
1852
+ */
1853
+ gridFloat: {
1854
+ type: Boolean,
1855
+ default: true,
1856
+ },
1857
+
1845
1858
  /**
1846
1859
  * How tall the labelled buttons in the page's card title rows stand in
1847
1860
  * the board look: `compact` (34px, setting and case-type pages) or
@@ -5745,6 +5758,15 @@ export default {
5745
5758
  },
5746
5759
  },
5747
5760
 
5761
+ // Header actions gate on `@context.<name>` through the object context.
5762
+ visibilityRows() {
5763
+ this.syncObjectContext()
5764
+ },
5765
+
5766
+ visibilityRowsPending() {
5767
+ this.syncObjectContext()
5768
+ },
5769
+
5748
5770
  // Endpoint and source conditions on grid widgets are asked once per record.
5749
5771
  remoteWidgetConditionsKey: {
5750
5772
  immediate: true,
@@ -7887,6 +7909,7 @@ export default {
7887
7909
  object: this.currentObject || null,
7888
7910
  register: resolved.register || this.register || this.sidebarProps?.register || '',
7889
7911
  schema: resolved.schema || this.schema || this.resolvedObjectType || this.sidebarProps?.schema || '',
7912
+ context: this.visibilityRowsPending ? null : (this.visibilityRows || null),
7890
7913
  setObject: (object) => this.setCurrentObject(object),
7891
7914
  }
7892
7915
  },
@@ -86,6 +86,7 @@ export default {
86
86
  font-size: 13px;
87
87
  font-weight: 700;
88
88
  gap: 6px;
89
+ margin: 0;
89
90
  min-height: 32px;
90
91
  padding: 0 14px;
91
92
  white-space: nowrap;
@@ -41,11 +41,12 @@
41
41
  <dl
42
42
  v-if="block.kind === 'rows'"
43
43
  class="cn-facts-widget__rows"
44
- :class="{ 'cn-facts-widget__rows--columns': twoColumns }">
44
+ :class="{ 'cn-facts-widget__rows--columns': twoColumns, 'cn-facts-widget__rows--stacked': stacked }">
45
45
  <div
46
46
  v-for="row in block.rows"
47
47
  :key="row.key"
48
48
  class="cn-facts-widget__row"
49
+ :class="{ 'cn-facts-widget__row--inline': row.inline }"
49
50
  :data-testid="`cn-fact-${row.key}`">
50
51
  <dt class="cn-facts-widget__label" :class="{ 'cn-facts-widget__label--icon': row.icon }">
51
52
  <CnIcon
@@ -174,6 +175,15 @@
174
175
  </span>
175
176
  <span class="cn-facts-widget__line">{{ block.value }}</span>
176
177
  </div>
178
+ <div v-else-if="block.kind === 'pills'" class="cn-facts-widget__pills">
179
+ <CnStatusBadge
180
+ v-for="pill in block.pills"
181
+ :key="pill.key"
182
+ :label="pill.value"
183
+ :variant="pill.variant"
184
+ size="small"
185
+ :data-testid="`cn-fact-${pill.key}`" />
186
+ </div>
177
187
  <div v-else-if="block.kind === 'pill'">
178
188
  <CnStatusBadge
179
189
  :label="block.value"
@@ -190,6 +200,12 @@
190
200
  data-testid="cn-facts-notice">
191
201
  {{ notice.text }}
192
202
  </NcNoteCard>
203
+ <div
204
+ v-if="footnote && record"
205
+ class="cn-facts-widget__footnote"
206
+ data-testid="cn-facts-footnote">
207
+ {{ footnote }}
208
+ </div>
193
209
  <button
194
210
  v-if="link && link.sidebarTab && record"
195
211
  type="button"
@@ -232,7 +248,7 @@
232
248
  import { getCurrentUser } from '@nextcloud/auth'
233
249
  import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
234
250
  import { formatFileSize } from '@nextcloud/files'
235
- import { translate as t } from '@nextcloud/l10n'
251
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
236
252
  import { generateUrl } from '@nextcloud/router'
237
253
  import { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
238
254
  import Check from 'vue-material-design-icons/Check.vue'
@@ -251,6 +267,7 @@ import { parseDateValue } from '../../utils/dateVariant.js'
251
267
  import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
252
268
  import { fillTemplate, formatLongDate } from '../../utils/fillTemplate.js'
253
269
  import { formatMoney, metricLocale, unwrapAppConfig } from '../../utils/formatMetric.js'
270
+ import { groupDisplayName, loadGroupDisplayName } from '../../utils/groupAutocomplete.js'
254
271
  import { readPath } from '../../utils/readPath.js'
255
272
  import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
256
273
  import { userDisplayName } from '../../utils/userDisplayName.js'
@@ -294,6 +311,30 @@ function resolveSourceTokens(value, record) {
294
311
  }
295
312
 
296
313
  /** Kinds of entry a `fields[]` item can be. A labelled item without `as` is a row. */
314
+ /**
315
+ * Two or more pills in a row become one `pills` block, drawn on one line.
316
+ *
317
+ * @param {Array<object>} blocks The blocks in order.
318
+ * @return {Array<object>}
319
+ * @spec openspec/changes/facts-fields-from-context/specs/facts-widget/spec.md#requirement-a-facts-card-can-take-its-entries-from-the-page-context
320
+ */
321
+ function groupPills(blocks) {
322
+ const out = []
323
+ blocks.forEach((block) => {
324
+ const last = out[out.length - 1]
325
+ if (block.kind !== 'pill') {
326
+ out.push(block)
327
+ } else if (last && last.kind === 'pills') {
328
+ last.pills.push(block)
329
+ } else if (last && last.kind === 'pill') {
330
+ out[out.length - 1] = { kind: 'pills', key: `pills-${last.key}`, pills: [last, block] }
331
+ } else {
332
+ out.push(block)
333
+ }
334
+ })
335
+ return out
336
+ }
337
+
297
338
  const KINDS = Object.freeze(['row', 'title', 'line', 'pill', 'lead', 'text', 'meter', 'progress'])
298
339
 
299
340
  /** The schemes a facts link opens, besides a path on this server. */
@@ -424,31 +465,34 @@ export default {
424
465
  /**
425
466
  * The widget configuration.
426
467
  *
427
- * - `fields` — the entries, in order. Each is `{ path, label?, as?, from?, format?, map?, itemPath?, parts?, separator?, route?, variant?, variantMap?, colorMap?, icon?, copy?, reveal?, text?, title?, subtitle?, initialsPath?, tone? }`.
468
+ * - `fields` — the entries, in order. Each is `{ path, label?, as?, from?, format?, map?, itemPath?, parts?, separator?, route?, variant?, variantMap?, colorMap?, icon?, copy?, reveal?, inline?, text?, title?, subtitle?, initialsPath?, tone? }`.
428
469
  * `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`), `pill` or `meter`.
429
470
  * `countFrom` (`{ register, schema, filter? }`, `@objectId` / `@object.<field>` in the filter) shows how many records that list holds ("Lopende zaken 43"); the row is left out while the count cannot be read.
430
471
  * A `meter` shows the amount at `path` against the limit at `limitPath` (or a constant `limit`) as "12 of 50" with a bar; past the limit the bar turns red and the text says "over the limit"; without a positive limit it shows the amount and no bar.
472
+ * A row with `inline: true` spans both columns as one line, the label, the value and its buttons side by side (a masked BSN with Show).
431
473
  * A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show, which unmasks the record's value; `reveal: { url, path? }` makes Show ask the server for the value at `url` (tokens resolved), read at `path` (else the entry's path), so a server that answers the mask can check and record the reveal). `copy: true` adds a copy button; `icon` replaces the label.
432
474
  * A part may be `{ text }`, a constant; a `line` or `text` entry without `path` or `parts` shows its `text`.
433
475
  * `resolve: { register, schema, labelField? }` on an entry or a part turns a value that is a reference (an id, or a list of ids) into the label of the referenced object (`labelField`, else `title`, then `name`): the case type or status of a task shows its name, not its uuid. The ids are fetched once per register and schema through the object store and shared by every facts widget on the page; the entry stays empty until its label is known, and when no label can be read. A resolved value works as a pill too: `map` is applied to the resolved label, and `variantMap` / `colorMap` look the tone up by the value of `resolve.toneField` on the referenced object when that is set (a status named by its `name`, coloured by its `state`), then by the resolved label, then by the stored value.
434
476
  * `route` (`{ name, params?, query? }`, `@source.<path>` in a param) on a `title` or a `row` links its value to that route; a param that does not fill leaves it plain.
435
477
  * `format: 'user'` shows a Nextcloud user id as the user's display name, read once per id; the entry stays empty until the name is known.
436
- * `template` ("{available} {unit}") fills its `{path}` placeholders from the record instead of reading `path`; it is empty when every placeholder is. `suffix` (" kg") is added to a value that is not empty.
478
+ * `template` ("{available} {unit}") fills its `{path}` placeholders from the record instead of reading `path` (`{path:format}` formats one, "{from:year}"); it is empty when every placeholder is. `suffix` (" kg") is added to a value that is not empty, its words translated.
437
479
  * `labelPath` ("@context.caseType.handlerLabel") names a value on the record or the page's `@context` records that labels the row when it holds text; `label` is the fallback.
438
480
  * `emptyValue` ("No") is shown, translated, when the entry has no value, so the entry stays drawn whatever `hideEmpty` says (an override that was never set reads "No").
439
481
  * - `action` — an action drawn as a button under the facts; `headerAction` — one drawn in the card header (reported through the `header` event).
440
482
  * - `actions` — action buttons under the facts in the `headerActions` shape (`api-call`, `open-form`, `navigate`, `open-modal`, ...; `confirm`, `variant`). `@source.<field>` names a field of the record shown, and an action whose token names an empty field is not drawn. `visibleWhen` reads the record shown; `adminOnly: true` draws the action for an instance admin only (visibility, never authorisation). An api-call or a saved form refreshes the page, and the facts read their source again.
483
+ * - `fieldsFrom` — `@context.<name>.<path>`, a list of entries on a record the page reads beside this one (a case type's `requestFields`); when it holds entries they are drawn instead of `fields`.
441
484
  * - `rowsPath` — a path to a list of `{ label, value }` the record holds, drawn as rows after `fields` (a preview the server builds).
442
485
  * - `headerPill` — a status pill in the card header (reported through the `header` event): `{ path?, text?, map?, variant?, variantMap?, colorMap? }`, read from the record like a `pill` entry (DqBezwaar "Advies ontvangen"); nothing shows while the value is empty.
443
486
  * - `notice` — `{ text, variant? }`, a note band inside the card under the facts (`info` by default; `success`, `warning` or `error`); the text is translated.
444
- * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `longdate` (the month written out, the year only outside this year: "3 oktober"), `list` (an array joined by ", ", each item read at `itemPath`), `bytes` (a size, "1.5 MB"), `number` (in the reader's language, `decimals` fraction digits when set) or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
487
+ * - `footnote` — a plain muted line under the facts, without a band (DqZaakBesluiten "Na het ondertekenen ..."); the text is translated.
488
+ * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `longdate` (the month written out, the year only outside this year: "3 oktober"), `year` ("2026"), `list` (an array joined by ", ", each item read at `itemPath` and written in `itemFormat`), `group` (a Nextcloud group by its display name), `bytes` (a size, "1.5 MB"), `number` (in the reader's language, `decimals` fraction digits when set), `months` (a number of months in words, whole years as years: 240 is "20 jaar") or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
445
489
  * `map` turns a raw value into a label (translated). `parts` (on a `line`) lists paths, or `{ path, label?, format? }`, joined by `separator` (default " · "); empty parts are dropped.
446
490
  * - `source` — `{ register, schema, id }` or `{ url }`; omitted = the current object. `{ register, schema, filter, sort? }` without `id` reads the first object that matches `filter` in `sort` order (`{ field, dir }`): the latest count of a shift. An entry with `from: "object"` reads the current object even then (a client card that also shows the ticket's channel).
447
491
  * - `endpointSource` — `{ url, method?, params?, responsePath? }`, the block a stat or chart takes: the facts read an app endpoint, shared and cached like those.
448
492
  * - `emptyWhen` — a path; when the record's value there is truthy the card shows `emptyText` (an endpoint that answers `empty: true`).
449
493
  * - `link` — `{ label, route? | href? }` at the foot. `{ label, icon?, sidebarTab }` instead opens the page's object sidebar on that tab (no link without a sidebar).
450
494
  * - `hideEmpty` — drop an entry whose value is empty (default true).
451
- * - `columns` — `2` lays the rows out in two columns with the label above the value (board DqContact); a row with `parts` joins them like a line (an address on one row).
495
+ * - `columns` — `2` lays the rows out in two columns with the label above the value (board DqContact); a row with `parts` joins them like a line (an address on one row). `1` stacks each label above its value in one column, for a narrow card with long labels (board DqHandhaving "Afwijking en definitief").
452
496
  * - `emptyText` — shown when no entry has a value.
453
497
  *
454
498
  * @type {{fields?: Array<object>, source?: object, link?: object, hideEmpty?: boolean, emptyText?: string}}
@@ -500,6 +544,29 @@ export default {
500
544
  },
501
545
 
502
546
  computed: {
547
+ /**
548
+ * The entries to draw: the list `fieldsFrom` names on the page's
549
+ * `@context` records when it holds entries (a case type's
550
+ * `requestFields`), else `fields`.
551
+ *
552
+ * @return {Array<object>}
553
+ * @spec openspec/changes/facts-fields-from-context/specs/facts-widget/spec.md#requirement-a-facts-card-can-take-its-entries-from-the-page-context
554
+ */
555
+ entries() {
556
+ const from = this.content?.fieldsFrom
557
+ if (typeof from === 'string' && from.startsWith('@context.')) {
558
+ const rows = this.cnDetailVisibilityRows && typeof this.cnDetailVisibilityRows === 'object' && 'value' in this.cnDetailVisibilityRows
559
+ ? this.cnDetailVisibilityRows.value
560
+ : this.cnDetailVisibilityRows
561
+ const named = readPath({ '@context': rows || {} }, from)
562
+ const list = Array.isArray(named) ? named.filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry)) : []
563
+ if (list.length > 0) {
564
+ return list
565
+ }
566
+ }
567
+ return Array.isArray(this.content?.fields) ? this.content.fields : []
568
+ },
569
+
503
570
  /**
504
571
  * Whether the rows sit in two columns, label above value.
505
572
  *
@@ -510,6 +577,15 @@ export default {
510
577
  return Number(this.content?.columns) === 2
511
578
  },
512
579
 
580
+ /**
581
+ * Whether the rows stack in one column, label above value.
582
+ *
583
+ * @return {boolean}
584
+ */
585
+ stacked() {
586
+ return Number(this.content?.columns) === 1
587
+ },
588
+
513
589
  /**
514
590
  * The token context: the detail object (prop, else the injected context)
515
591
  * and the page workspace.
@@ -535,7 +611,7 @@ export default {
535
611
  * @spec openspec/changes/facts-entry-count/specs/facts-widget/spec.md#requirement-a-facts-entry-can-count-a-related-list
536
612
  */
537
613
  countJobsKey() {
538
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
614
+ const fields = this.entries
539
615
  const keys = fields
540
616
  .filter((field) => field && field.countFrom && typeof field.countFrom === 'object' && field.countFrom.register && field.countFrom.schema)
541
617
  .map((field) => this.countKey(field.countFrom))
@@ -696,7 +772,7 @@ export default {
696
772
  */
697
773
  blocks() {
698
774
  const sourceRecord = this.record
699
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
775
+ const fields = this.entries
700
776
  // A source that finds nothing still leaves the entries that read the
701
777
  // page's own object (`from: "object"`).
702
778
  const ownEntries = Boolean(this.objectData) && fields.some((field) => field && field.from === 'object')
@@ -725,7 +801,7 @@ export default {
725
801
  }
726
802
  if (kind === 'row') {
727
803
  const last = out[out.length - 1]
728
- const row = { key, label: this.labelOf(record, field), value, ...this.rowExtras(record, field, value) }
804
+ const row = { key, label: this.labelOf(record, field), value, inline: field.inline === true, ...this.rowExtras(record, field, value) }
729
805
  if (field.route && !row.href) {
730
806
  row.to = this.resolveRoute(field.route, record)
731
807
  }
@@ -775,7 +851,7 @@ export default {
775
851
  out.push({ kind: 'rows', key: 'rows-answered', rows: [row] })
776
852
  }
777
853
  })
778
- return out
854
+ return groupPills(out)
779
855
  },
780
856
 
781
857
  /**
@@ -867,6 +943,12 @@ export default {
867
943
  return { text, type: ['success', 'warning', 'error'].includes(notice.variant) ? notice.variant : 'info' }
868
944
  },
869
945
 
946
+ /** @return {string} The translated footnote, or '' when none is set. */
947
+ footnote() {
948
+ const footnote = this.content?.footnote
949
+ return typeof footnote === 'string' && footnote.trim() !== '' ? this.tr(footnote) : ''
950
+ },
951
+
870
952
  /** @return {string} The reveal button's text. */
871
953
  showLabel() {
872
954
  return t('nextcloud-vue', 'Show')
@@ -1104,7 +1186,7 @@ export default {
1104
1186
  * @spec openspec/changes/facts-entry-count/specs/facts-widget/spec.md#requirement-a-facts-entry-can-count-a-related-list
1105
1187
  */
1106
1188
  async loadCounts() {
1107
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
1189
+ const fields = this.entries
1108
1190
  const seen = new Set()
1109
1191
  for (const field of fields) {
1110
1192
  const countFrom = field && field.countFrom
@@ -1130,7 +1212,12 @@ export default {
1130
1212
  if (value === '') {
1131
1213
  return typeof field.emptyValue === 'string' && field.emptyValue !== '' ? this.tr(field.emptyValue) : ''
1132
1214
  }
1133
- return typeof field.suffix === 'string' ? `${value}${field.suffix}` : value
1215
+ if (typeof field.suffix !== 'string') {
1216
+ return value
1217
+ }
1218
+ // The words of a suffix are translated; its leading space stays as written.
1219
+ const lead = field.suffix.match(/^\s*/)[0]
1220
+ return `${value}${lead}${this.tr(field.suffix.slice(lead.length))}`
1134
1221
  },
1135
1222
 
1136
1223
  /**
@@ -1176,8 +1263,8 @@ export default {
1176
1263
  */
1177
1264
  templateValue(record, template) {
1178
1265
  let filled = false
1179
- const text = this.tr(template).replace(/\{([\w.@]+)\}/g, (match, path) => {
1180
- const value = this.format(readPath(record, path), {}, record)
1266
+ const text = this.tr(template).replace(/\{([\w.@]+)(?::([\w-]+))?\}/g, (match, path, format) => {
1267
+ const value = this.format(readPath(record, path), format ? { format } : {}, record)
1181
1268
  filled = filled || value !== ''
1182
1269
  return value
1183
1270
  })
@@ -1569,7 +1656,7 @@ export default {
1569
1656
  * @return {Array<{spec: object, raw: unknown}>}
1570
1657
  */
1571
1658
  refSpecs(record) {
1572
- const fields = Array.isArray(this.content?.fields) ? this.content.fields : []
1659
+ const fields = this.entries
1573
1660
  const specs = []
1574
1661
  const visit = (spec) => {
1575
1662
  if (!spec || typeof spec !== 'object') {
@@ -1798,10 +1885,14 @@ export default {
1798
1885
  if (format === 'list' || Array.isArray(raw)) {
1799
1886
  const items = (Array.isArray(raw) ? raw : [raw])
1800
1887
  .map((item) => (spec.itemPath && item && typeof item === 'object' ? readPath(item, spec.itemPath) : item))
1801
- .map((item) => this.format(item, { ...spec, format: 'text' }))
1888
+ .map((item) => this.format(item, { ...spec, format: spec.itemFormat || 'text' }))
1802
1889
  .filter((item) => item !== '')
1803
1890
  return items.join(', ')
1804
1891
  }
1892
+ if (format === 'year') {
1893
+ const date = parseDateValue(raw)
1894
+ return date === null ? '' : String(date.getFullYear())
1895
+ }
1805
1896
  // The month written out, the year only outside this year: "3 oktober".
1806
1897
  if (format === 'longdate') {
1807
1898
  return formatLongDate(raw)
@@ -1816,6 +1907,16 @@ export default {
1816
1907
  ? formatBoardDate(date, new Date(date.getFullYear() + 1, 0, 1))
1817
1908
  : formatBoardDate(date)
1818
1909
  }
1910
+ // A number of months in words: whole years as years ("20 jaar"), else months.
1911
+ if (format === 'months') {
1912
+ const months = Number(raw)
1913
+ if (typeof raw === 'object' || !Number.isInteger(months) || months < 0) {
1914
+ return typeof raw === 'object' ? '' : String(raw)
1915
+ }
1916
+ return months > 0 && months % 12 === 0
1917
+ ? n('nextcloud-vue', '%n year', '%n years', months / 12)
1918
+ : n('nextcloud-vue', '%n month', '%n months', months)
1919
+ }
1819
1920
  if (format === 'bytes') {
1820
1921
  const bytes = Number(raw)
1821
1922
  return Number.isFinite(bytes) && bytes >= 0 ? formatFileSize(bytes) : ''
@@ -1823,6 +1924,18 @@ export default {
1823
1924
  if (format === 'user') {
1824
1925
  return userDisplayName(raw)
1825
1926
  }
1927
+ // A Nextcloud group by its display name; the id until the name is known.
1928
+ if (format === 'group') {
1929
+ if (typeof raw === 'object') {
1930
+ return ''
1931
+ }
1932
+ const name = groupDisplayName(String(raw))
1933
+ if (name === null) {
1934
+ loadGroupDisplayName(String(raw))
1935
+ return String(raw)
1936
+ }
1937
+ return name
1938
+ }
1826
1939
  if (format === 'number') {
1827
1940
  const number = Number(raw)
1828
1941
  if (typeof raw === 'object' || !Number.isFinite(number)) {
@@ -1921,6 +2034,12 @@ export default {
1921
2034
  min-width: 0;
1922
2035
  }
1923
2036
 
2037
+ .cn-facts-widget__pills {
2038
+ display: flex;
2039
+ flex-wrap: wrap;
2040
+ gap: 8px;
2041
+ }
2042
+
1924
2043
  /* `padding: 0`: Nextcloud's core stylesheet pads every `dl` 12px at the top
1925
2044
  and bottom, which put a side card's facts 24px under its title, against
1926
2045
  the board's 12 (DqZaak "Behandeling", DqZaakDocumenten). */
@@ -1938,6 +2057,21 @@ export default {
1938
2057
  display: contents;
1939
2058
  }
1940
2059
 
2060
+ .cn-facts-widget__row--inline {
2061
+ display: flex;
2062
+ grid-column: 1 / -1;
2063
+ flex-wrap: wrap;
2064
+ align-items: center;
2065
+ gap: 10px;
2066
+ }
2067
+
2068
+ .cn-facts-widget__row--inline .cn-facts-widget__value {
2069
+ display: flex;
2070
+ flex-wrap: wrap;
2071
+ align-items: center;
2072
+ gap: 10px;
2073
+ }
2074
+
1941
2075
  /* Nextcloud core styles `dt` and `dd` (text-align end, 12px padding, a
1942
2076
  130px-wide `dt`), which pushed the label to the right of its column, added
1943
2077
  12px around both and widened the label column to 130px. The class is
@@ -2162,6 +2296,13 @@ export default {
2162
2296
  margin: 4px 0 0;
2163
2297
  }
2164
2298
 
2299
+ /* A div, not a p: a theme sets every paragraph's size with !important. */
2300
+ .cn-facts-widget__footnote {
2301
+ color: var(--color-text-maxcontrast);
2302
+ font-size: 13px;
2303
+ line-height: 1.2;
2304
+ }
2305
+
2165
2306
  .cn-facts-widget__link {
2166
2307
  font-size: 14px;
2167
2308
  font-weight: 600;
@@ -2203,6 +2344,17 @@ export default {
2203
2344
  font-size: 13px;
2204
2345
  }
2205
2346
 
2347
+ /* One column, label above value: 2px under its label, 10px to the next
2348
+ label, so a long label never squeezes its value (DqHandhaving). */
2349
+ .cn-facts-widget__rows--stacked {
2350
+ grid-template-columns: minmax(0, 1fr);
2351
+ gap: 2px 0;
2352
+ }
2353
+
2354
+ .cn-facts-widget__rows--stacked .cn-facts-widget__value.cn-facts-widget__value {
2355
+ margin-bottom: 8px;
2356
+ }
2357
+
2206
2358
  @media (max-width: 640px) {
2207
2359
  .cn-facts-widget__rows--columns {
2208
2360
  grid-template-columns: minmax(0, 1fr);
@@ -128,6 +128,7 @@
128
128
  </p>
129
129
  <NcButton
130
130
  v-if="canReply"
131
+ class="cn-notes-card__reply-btn"
131
132
  variant="tertiary"
132
133
  size="small"
133
134
  :aria-label="replyAria(row.note)"
@@ -915,7 +916,10 @@ export default {
915
916
  padding-right: 32px;
916
917
  }
917
918
 
918
- .cn-notes-card__delete-btn {
919
+ /* The note's own class leads so the position outranks NcButton's
920
+ `position: relative`, which loads later at the same weight and put the
921
+ button in the flow under the note. */
922
+ .cn-notes-card__note > .cn-notes-card__delete-btn {
919
923
  position: absolute;
920
924
  top: 8px;
921
925
  right: -4px;
@@ -923,7 +927,8 @@ export default {
923
927
  transition: opacity 0.15s ease;
924
928
  }
925
929
 
926
- .cn-notes-card__note:hover .cn-notes-card__delete-btn {
930
+ .cn-notes-card__note:hover > .cn-notes-card__delete-btn,
931
+ .cn-notes-card__note:focus-within > .cn-notes-card__delete-btn {
927
932
  opacity: 1;
928
933
  }
929
934
 
@@ -331,6 +331,23 @@ import {
331
331
  * @event {object} digest-change — A channel's digest was set. Payload: `{ channelId, mode, timeOfDay }`.
332
332
  * @event {object} test-send — A test was asked for. Payload: `{ channelId }`.
333
333
  */
334
+ /**
335
+ * An event's shipped default in one channel: one boolean for every channel,
336
+ * or the channel's own entry when the default differs per channel.
337
+ *
338
+ * @param {object} event The event.
339
+ * @param {object} channel The channel.
340
+ * @return {boolean} The default.
341
+ * @spec openspec/changes/notification-matrix-default-per-channel/specs/notification-matrix/spec.md
342
+ */
343
+ function appDefaultFor(event, channel) {
344
+ const value = event?.appDefault
345
+ if (value !== null && typeof value === 'object') {
346
+ return value[channel?.id] === true
347
+ }
348
+ return value === true
349
+ }
350
+
334
351
  export default {
335
352
  name: 'CnNotificationMatrix',
336
353
 
@@ -342,7 +359,9 @@ export default {
342
359
  props: {
343
360
  /**
344
361
  * The events, each `{ id, label, group, groupLabel, immediate,
345
- * appDefault }`. From the host's catalogue.
362
+ * appDefault }`. From the host's catalogue. `appDefault` is one
363
+ * boolean for every channel, or `{ [channelId]: boolean }` when the
364
+ * default differs per channel (an opt-in e-mail is off by default).
346
365
  */
347
366
  events: {
348
367
  type: Array,
@@ -607,7 +626,7 @@ export default {
607
626
  })
608
627
 
609
628
  return resolvePreference({
610
- appDefault: event?.appDefault === true,
629
+ appDefault: appDefaultFor(event, channel),
611
630
  groupValue: this.at(this.groupValues, event, channel),
612
631
  personalValue: narrow.value === null
613
632
  ? this.at(this.personalValues, event, channel)
@@ -248,6 +248,7 @@ import {
248
248
  indicatorVerdict,
249
249
  kindCode,
250
250
  newPartyRecord,
251
+ partyKindAccepted,
251
252
  representedByMap,
252
253
  roleLabel,
253
254
  rolesInOrder,
@@ -892,7 +893,7 @@ export default {
892
893
  if (!party?.partyKind || !this.kinds || (enforce && this.kinds.source !== enforce)) {
893
894
  return false
894
895
  }
895
- return !this.kinds.kinds.some((kind) => kindCode(kind) === party.partyKind)
896
+ return !partyKindAccepted(party.partyKind, this.kinds.kinds, this.listing?.kinds)
896
897
  },
897
898
 
898
899
  /**
@@ -3413,6 +3413,7 @@ export default {
3413
3413
  font-weight: 600;
3414
3414
  height: 34px;
3415
3415
  justify-content: center;
3416
+ margin: 0;
3416
3417
  padding: 0 14px;
3417
3418
  text-decoration: none;
3418
3419
  }