@conduction/nextcloud-vue 2.148.0 → 2.150.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 (111) hide show
  1. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
  2. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +24 -7
  3. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  4. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue.js +1 -1
  6. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +12 -1
  7. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  8. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +2 -1
  9. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  10. package/dist/esm/components/CnChartWidget/CnChartWidget.vue.js +1 -1
  11. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +12 -0
  12. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  14. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +1 -1
  15. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +23 -1
  16. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +1 -1
  18. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +34 -1
  20. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue.js +1 -1
  23. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js.map +1 -1
  24. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js +1 -1
  26. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js.map +1 -1
  28. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue.js +1 -1
  29. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue2.js +64 -8
  30. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue2.js.map +1 -1
  31. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue3.js +14 -4
  32. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue3.js.map +1 -1
  33. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  34. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +26 -3
  35. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  36. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +10 -2
  37. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  38. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  39. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +84 -1
  40. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +61 -33
  42. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  43. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue.js +1 -1
  44. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue2.js +49 -2
  45. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue2.js.map +1 -1
  46. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue3.js +90 -59
  47. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue2.js +3 -1
  49. package/dist/esm/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue2.js.map +1 -1
  50. package/dist/esm/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue3.js.map +1 -1
  51. package/dist/esm/components/CnNotesCard/CnNotesCard.vue.js +1 -1
  52. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js +10 -0
  53. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js.map +1 -1
  54. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js.map +1 -1
  55. package/dist/esm/components/CnObjectCard/CnObjectCard.vue.js +1 -1
  56. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +11 -0
  57. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
  58. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +28 -21
  59. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
  60. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  61. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +1 -1
  62. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  64. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  65. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +3 -15
  66. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  67. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +123 -115
  68. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  69. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
  70. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +4 -0
  71. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
  72. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
  73. package/dist/esm/l10n/en.json.js +2 -1
  74. package/dist/esm/l10n/en.json.js.map +1 -1
  75. package/dist/esm/l10n/nl.json.js +2 -1
  76. package/dist/esm/l10n/nl.json.js.map +1 -1
  77. package/dist/esm/library-style.js +1 -1
  78. package/dist/esm/nextcloud-vue.css +761 -520
  79. package/dist/esm/utils/fillTemplate.js +19 -2
  80. package/dist/esm/utils/fillTemplate.js.map +1 -1
  81. package/dist/nextcloud-vue.cjs.js +1368 -945
  82. package/dist/nextcloud-vue.cjs.js.map +2 -2
  83. package/dist/nextcloud-vue.css +761 -520
  84. package/l10n/en.json +2 -1
  85. package/l10n/nl.json +2 -1
  86. package/package.json +1 -1
  87. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +24 -7
  88. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +13 -1
  89. package/src/components/CnChartWidget/CnChartWidget.md +1 -1
  90. package/src/components/CnChartWidget/CnChartWidget.vue +12 -0
  91. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +29 -2
  92. package/src/components/CnDataTable/CnDataTable.vue +34 -1
  93. package/src/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue +6 -1
  94. package/src/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue +1 -0
  95. package/src/components/CnDuplicateWarning/CnDuplicateWarning.vue +74 -10
  96. package/src/components/CnFactsWidget/CnFactsWidget.vue +35 -3
  97. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +131 -3
  98. package/src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue +103 -5
  99. package/src/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue +3 -1
  100. package/src/components/CnNotesCard/CnNotesCard.vue +10 -0
  101. package/src/components/CnObjectCard/CnObjectCard.vue +16 -0
  102. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +1 -1
  103. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +19 -19
  104. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +4 -0
  105. package/src/css/board.css +2 -0
  106. package/src/css/dialog.css +17 -0
  107. package/src/css/look-board-detail.css +52 -3
  108. package/src/css/look-board-index.css +42 -2
  109. package/src/css/look-board.css +45 -5
  110. package/src/css/table.css +5 -2
  111. package/src/utils/fillTemplate.js +19 -2
package/l10n/en.json CHANGED
@@ -2398,7 +2398,8 @@
2398
2398
  "Until {date}": "Until {date}",
2399
2399
  "More actions for {name}": "More actions for {name}",
2400
2400
  "Unlink contact": "Unlink contact",
2401
- "{term}, extendable by {extension}": "{term}, extendable by {extension}"
2401
+ "{term}, extendable by {extension}": "{term}, extendable by {extension}",
2402
+ "Move {item}, use the arrow keys": "Move {item}, use the arrow keys"
2402
2403
  },
2403
2404
  "plurals": {
2404
2405
  "{count} member": [
package/l10n/nl.json CHANGED
@@ -2653,7 +2653,8 @@
2653
2653
  "Until {date}": "Tot {date}",
2654
2654
  "More actions for {name}": "Meer acties voor {name}",
2655
2655
  "Unlink contact": "Contact ontkoppelen",
2656
- "{term}, extendable by {extension}": "{term}, te verdagen met {extension}"
2656
+ "{term}, extendable by {extension}": "{term}, te verdagen met {extension}",
2657
+ "Move {item}, use the arrow keys": "{item} verplaatsen, gebruik de pijltjestoetsen"
2657
2658
  },
2658
2659
  "plurals": {
2659
2660
  "{count} member": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.148.0",
3
+ "version": "2.150.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": {
@@ -184,6 +184,14 @@ export default {
184
184
  * @spec openspec/changes/audit-trail-exclude-actions/specs/audit-trail/spec.md#requirement-a-history-card-can-leave-out-events-by-action
185
185
  */
186
186
  excludeActions: { type: Array, default: () => [] },
187
+ /**
188
+ * Actors the card leaves out by user id (case-insensitive), such as
189
+ * `['System']` for a history of what people did: the bookkeeping a
190
+ * background job writes is not shown.
191
+ *
192
+ * @spec openspec/changes/audit-card-exclude-actors/specs/audit-trail/spec.md#requirement-a-history-card-can-leave-out-entries-by-actor
193
+ */
194
+ excludeActors: { type: Array, default: () => [] },
187
195
  /** Whether the card collapses. */
188
196
  collapsible: { type: Boolean, default: false },
189
197
  /** Override the card title (defaults to the translated label). */
@@ -199,7 +207,8 @@ export default {
199
207
  /**
200
208
  * What happened, in words, by action (`claim`) or by action and the
201
209
  * state it led to (`complete:completed`, tried first). `{actor}`,
202
- * `{state}` and `{changed.<key>}` (a value the entry recorded, in the
210
+ * `{state}` and `{changed.<key>}` (a value the entry recorded, a dotted key
211
+ * reading into it such as `{changed.data.fileName}`, in the
203
212
  * words of `valueLabels`) are filled in; a label that names the actor leaves it off
204
213
  * the line under it. An action without a label shows as written.
205
214
  *
@@ -262,10 +271,17 @@ export default {
262
271
  /** @return {Array<object>} The entries without the excluded actions. */
263
272
  shownEntries() {
264
273
  const excluded = this.excludeActions.map((action) => String(action).toLowerCase())
265
- if (excluded.length === 0) {
274
+ const actors = this.excludeActors.map((actor) => String(actor).toLowerCase())
275
+ if (excluded.length === 0 && actors.length === 0) {
266
276
  return this.entries
267
277
  }
268
- return this.entries.filter((entry) => !excluded.includes(String(entry.action || entry.event || '').toLowerCase()))
278
+ return this.entries.filter((entry) => !excluded.includes(String(entry.action || entry.event || '').toLowerCase())
279
+ && !actors.includes(String(entry.user || '').toLowerCase()))
280
+ },
281
+
282
+ /** @return {boolean} Whether the card leaves entries out, and so reads more than it shows. */
283
+ filtersEntries() {
284
+ return this.excludeActions.length > 0 || this.excludeActors.length > 0
269
285
  },
270
286
 
271
287
  displayedEntries() {
@@ -301,7 +317,7 @@ export default {
301
317
  if (this.sourceUrl !== null) {
302
318
  return this.sourceUrl
303
319
  }
304
- const limit = this.excludeActions.length > 0 ? Math.min(100, this.maxDisplay * 10) : this.maxDisplay
320
+ const limit = this.filtersEntries ? Math.min(100, this.maxDisplay * 10) : this.maxDisplay
305
321
  const params = new URLSearchParams({ limit: String(limit) })
306
322
  if (this.scope === 'app') {
307
323
  if (this.register) {
@@ -381,7 +397,7 @@ export default {
381
397
  if (this.scope !== 'object' || this.sourceUrl !== null || !this.objectId || !Array.isArray(this.relatedSources)) {
382
398
  return []
383
399
  }
384
- const limit = this.excludeActions.length > 0 ? Math.min(100, this.maxDisplay * 10) : this.maxDisplay
400
+ const limit = this.filtersEntries ? Math.min(100, this.maxDisplay * 10) : this.maxDisplay
385
401
  const read = async (url) => {
386
402
  const response = await fetch(prefixUrl(url), { headers: buildHeaders() })
387
403
  if (!response.ok) {
@@ -423,7 +439,7 @@ export default {
423
439
  return label
424
440
  .replace('{actor}', this.actorName(entry))
425
441
  .replace('{state}', entry.stateAfter || '')
426
- .replace(/\{changed\.([\w-]+)\}/g, (match, key) => this.changedValue(entry, key))
442
+ .replace(/\{changed\.([\w.-]+)\}/g, (match, key) => this.changedValue(entry, key))
427
443
  .replace(/\{object\.([\w.-]+)\}/g, (match, path) => {
428
444
  const value = this.object ? readPath(this.object, path) : undefined
429
445
  return value === null || value === undefined || typeof value === 'object' ? '' : String(value)
@@ -444,7 +460,8 @@ export default {
444
460
  */
445
461
  changedValue(entry, key) {
446
462
  const changed = entry && typeof entry.changed === 'object' && entry.changed !== null ? entry.changed : {}
447
- const value = newValueOf(changed[key])
463
+ // A dotted key reads into what the entry recorded (`data.fileName` on a file action).
464
+ const value = String(key).split('.').reduce((at, part) => newValueOf(at !== null && typeof at === 'object' ? at[part] : undefined), changed)
448
465
  if (value === null || value === undefined || typeof value === 'object') {
449
466
  return ''
450
467
  }
@@ -18,6 +18,7 @@
18
18
  :maxDisplay="resolvedMaxDisplay"
19
19
  :layout="content && typeof content.layout === 'string' ? content.layout : ''"
20
20
  :excludeActions="resolvedExcludeActions"
21
+ :excludeActors="resolvedExcludeActors"
21
22
  :actionLabels="actionLabels"
22
23
  :valueLabels="valueLabels"
23
24
  :changeLabels="changeLabels"
@@ -105,7 +106,7 @@ export default {
105
106
  excludeActions: { type: Array, default: () => [] },
106
107
  /** Stored widget content blob (CnDashboardPage registry branch): `{ title, maxDisplay, register?, schema?, objectId?, source?, endpoint?, emptyText?, excludeActions?, actionLabels?, footnote? }`. */
107
108
  /** Stored widget content blob (CnDashboardPage registry branch): `{ title, maxDisplay, register?, schema?, objectId?, source?, endpoint?, emptyText?, excludeActions?, actionLabels?, valueLabels? }`. */
108
- /** Stored widget content blob (CnDashboardPage registry branch): `{ title, maxDisplay, register?, schema?, objectId?, source?, endpoint?, emptyText?, excludeActions?, actionLabels?, valueLabels?, relatedSources? }`. */
109
+ /** Stored widget content blob (CnDashboardPage registry branch): `{ title, maxDisplay, register?, schema?, objectId?, source?, endpoint?, emptyText?, excludeActions?, actionLabels?, valueLabels?, relatedSources?, excludeActors? }`. */
109
110
  content: { type: Object, default: () => ({}) },
110
111
  },
111
112
 
@@ -279,6 +280,17 @@ export default {
279
280
  return Array.isArray(fromContent) ? fromContent.filter((action) => typeof action === 'string' && action !== '') : []
280
281
  },
281
282
 
283
+ /**
284
+ * The actors to leave out, from `content.excludeActors`.
285
+ *
286
+ * @spec openspec/changes/audit-card-exclude-actors/specs/audit-trail/spec.md#requirement-a-history-card-can-leave-out-entries-by-actor
287
+ * @return {Array<string>}
288
+ */
289
+ resolvedExcludeActors() {
290
+ const fromContent = this.content.excludeActors
291
+ return Array.isArray(fromContent) ? fromContent.filter((actor) => typeof actor === 'string' && actor !== '') : []
292
+ },
293
+
282
294
  resolvedTitle() {
283
295
  return this.title || this.content.title || ''
284
296
  },
@@ -109,7 +109,7 @@ Fallback slot — shown when ApexCharts is not available:
109
109
  | `barMax` | Number | `null` | `bar-list`: the value a full track stands for (100 for percentages); null takes the largest value |
110
110
  | `matrixLabelWidth` | Number | `null` | `matrix`: the row-label column's width in px; a long label wraps (DqProcesanalyse "Dagen per fase": 240). Null sizes the column to its longest label, at least 120px |
111
111
  | `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 |
112
- | `notice` | Object | `null` | A toned note card under the chart: `{ lead?, text?, values?, labels?, tone? }`. `{name}` in `lead` (bold) and `text` is filled from `values[name]`, a literal or a record token (`@object.<field>`); `labels[name]` (`"rows"`, `"columns"`, `"values"` or a map) turns a key into words. `tone`: info, success, warning, error. Hidden while a value is missing |
112
+ | `notice` | Object | `null` | A toned note card under the chart: `{ lead?, text?, values?, labels?, tone? }`. `{name}` in `lead` (bold) and `text` is filled from `values[name]`, a literal, a record token (`@object.<field>`) or a field of the record a `cellSource` with `cellsPath` read its cells from (`@source.<field>`, such as the matrix's name); `labels[name]` (`"rows"`, `"columns"`, `"values"` or a map) turns a key into words. `tone`: info, success, warning, error. Hidden while a value is missing |
113
113
  | `referenceLines` | Array | `[]` | Dashed horizontal reference lines on the value axis of a line, area or bar chart, such as a norm: `[{ value, label?, color? }]`. The label goes through the host's translate; the colour defaults to the theme's error colour (board DqDoorlooptijd). `valuePath` reads the value from the endpoint answer instead, `{value}` in the label writes it, and a line without a value is not drawn. |
114
114
  | `categoryFormat` | String | `''` | How date categories read on the x-axis: `month` ("jan") or `month-year` ("jan 2026"), in the user's language, or `week` for ISO week keys ("2026-W40" as "w40"). Categories that are not dates are left as is; a bare year key stays a year. |
115
115
  | `emptyCellLabel` | String | `''` | `matrix`: the text of a cell without a value ("geen"), through the host's translate. Empty leaves the cell blank |
@@ -1020,6 +1020,9 @@ export default {
1020
1020
  aggregateData: null,
1021
1021
  // The `cellSource` matrix, laid out (`{ series, categories, columnKeys }`), or null.
1022
1022
  cellData: null,
1023
+ // The record a `cellSource` with `cellsPath` read its cells from, for
1024
+ // `@source.<field>` in the notice.
1025
+ cellRecord: null,
1023
1026
  // Active `views[]` switcher key (null = the first view). Local
1024
1027
  // UI state only — never persisted.
1025
1028
  activeViewKey: null,
@@ -1664,6 +1667,14 @@ export default {
1664
1667
  }
1665
1668
  const declared = def.values && typeof def.values === 'object' ? def.values : {}
1666
1669
  const resolved = resolveFilterTokens(declared, this.chartTokenCtx())
1670
+ // `@source.<field>`: a field of the record the matrix read its cells
1671
+ // from (the matrix's name and version on an enforcement advice).
1672
+ for (const [name, token] of Object.entries(declared)) {
1673
+ if (typeof token === 'string' && token.startsWith('@source.')) {
1674
+ const value = token.slice('@source.'.length).split('.').reduce((at, key) => (at && typeof at === 'object' ? at[key] : undefined), this.cellRecord)
1675
+ resolved[name] = value === undefined ? null : value
1676
+ }
1677
+ }
1667
1678
  const source = this.cellSource && typeof this.cellSource === 'object' ? this.cellSource : {}
1668
1679
  const maps = { rows: source.rowLabels, columns: source.columnLabels, values: source.valueLabels }
1669
1680
  const words = {}
@@ -2392,6 +2403,7 @@ export default {
2392
2403
  if (this._cellRequest !== key) {
2393
2404
  return
2394
2405
  }
2406
+ this.cellRecord = this.cellSource.cellsPath ? (records[0] || null) : null
2395
2407
  if (this.cellSource.cellsPath) {
2396
2408
  const { cells, config } = cellsFromRecord(records[0] || null, this.cellSource, cellFilter)
2397
2409
  this.cellData = pivotCells(cells, config)
@@ -94,7 +94,7 @@
94
94
  :required="question.required"
95
95
  data-testid="cn-checklist-run-text"
96
96
  @input="markDirty(question.id)" />
97
- <label v-if="allowNote" class="cn-checklist-run__note-label">
97
+ <label v-if="allowNote && noteShown(question)" class="cn-checklist-run__note-label">
98
98
  {{ noteLabel }}
99
99
  <textarea
100
100
  v-model="draft[question.id].note"
@@ -288,6 +288,11 @@ function idOf(object) {
288
288
  * with no bound record, `record: { register, schema, filter, sort }` picks
289
289
  * the open record: the first match, under the reader's rights.
290
290
  *
291
+ * `noteWhen: { answers: [...] }` shows a question's note field only once its
292
+ * answer is one of those stored values (the board shows a note under "Nee"),
293
+ * or when the question already holds a note; without it every question shows
294
+ * one (`allowNote: false` hides them all).
295
+ *
291
296
  * `startAt: "firstOpen"` opens a run that was started earlier at the page of
292
297
  * its first unanswered question (the last page when all are answered), once
293
298
  * per record; without it the run opens at the first page.
@@ -321,7 +326,7 @@ export default {
321
326
  * The widget config: `{ questions | questionsPath, itemsKey?,
322
327
  * groupLabelField?, orderField?, fields?, typeMap?, choiceValues?,
323
328
  * answers | answersPath, answerKey?, answerFields?, recordPhotosField?,
324
- * record?, saveAction?, finishAction?, pageSize?, allowNote?,
329
+ * record?, saveAction?, finishAction?, pageSize?, allowNote?, noteWhen?,
325
330
  * allowPhotos?, caption?, emptyText? }`, as in the component
326
331
  * description and docs/components/cn-checklist-run-widget.md.
327
332
  *
@@ -612,6 +617,23 @@ export default {
612
617
  },
613
618
 
614
619
  methods: {
620
+ /**
621
+ * Whether a question shows its note field: always, unless `noteWhen`
622
+ * names the answers that ask for one and this question has none of
623
+ * them and no note yet.
624
+ *
625
+ * @param {object} question The question.
626
+ * @return {boolean}
627
+ */
628
+ noteShown(question) {
629
+ const answers = this.content?.noteWhen?.answers
630
+ if (!Array.isArray(answers)) {
631
+ return true
632
+ }
633
+ const draft = this.draft[question.id] || {}
634
+ return (draft.note || '') !== '' || answers.map(String).includes(String(draft.value ?? ''))
635
+ },
636
+
615
637
  /**
616
638
  * The host's translate, or the text unchanged.
617
639
  *
@@ -1194,6 +1216,11 @@ export default {
1194
1216
  padding: 12px 14px;
1195
1217
  }
1196
1218
 
1219
+ .cn-checklist-run__photos-title {
1220
+ font-size: 15px;
1221
+ line-height: 1.2;
1222
+ }
1223
+
1197
1224
  .cn-checklist-run__legend {
1198
1225
  font-size: 15px;
1199
1226
  font-weight: 600;
@@ -639,6 +639,8 @@ export default {
639
639
  * `link: true` makes that column the one that names the row: it carries
640
640
  * the row link and the title look. Without it the first column does.
641
641
  *
642
+ * `emptyValueBy` (`{ field, values }`) names what an empty cell holds by
643
+ * another field of the row, translated; it goes before `emptyValue`.
642
644
  * `emptyValue` is what a row without a value holds in that column, so a
643
645
  * `badge` column draws a pill on every row.
644
646
  *
@@ -2238,13 +2240,44 @@ export default {
2238
2240
  return v
2239
2241
  }
2240
2242
  const value = this.getCellValue(row, this.columnValueField(col))
2243
+ const empty = value === null || value === undefined || value === ''
2244
+ // `emptyValueBy`: what an empty cell means depends on another field
2245
+ // of the row (a retention period left empty on a result that is
2246
+ // kept reads "Permanent", on one that is destroyed it stays empty).
2247
+ if (empty && col && col.emptyValueBy && typeof col.emptyValueBy === 'object') {
2248
+ const words = this.emptyValueFor(row, col.emptyValueBy)
2249
+ if (words !== '') {
2250
+ return words
2251
+ }
2252
+ }
2241
2253
  // `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')) {
2254
+ if (empty && col && Object.hasOwn(col, 'emptyValue')) {
2243
2255
  return col.emptyValue
2244
2256
  }
2245
2257
  return value
2246
2258
  },
2247
2259
 
2260
+ /**
2261
+ * The words an empty cell shows, picked by another field of the row:
2262
+ * `{ field, values: { <that field's value>: <words> } }`, translated.
2263
+ *
2264
+ * @param {object} row The row data.
2265
+ * @param {{field: string, values: object}} spec The column's `emptyValueBy`.
2266
+ * @return {string} The words, '' when the row's value has none.
2267
+ */
2268
+ emptyValueFor(row, spec) {
2269
+ const key = this.getCellValue(row, spec.field)
2270
+ const values = spec.values && typeof spec.values === 'object' ? spec.values : {}
2271
+ if (key === null || key === undefined || !Object.hasOwn(values, String(key))) {
2272
+ return ''
2273
+ }
2274
+ const words = values[String(key)]
2275
+ if (typeof words !== 'string' || words === '') {
2276
+ return ''
2277
+ }
2278
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(words) : words
2279
+ },
2280
+
2248
2281
  /**
2249
2282
  * Whether an aggregate cell shows its `zeroLabel` as muted text instead
2250
2283
  * of through the column's widget (`aggregate.zeroAs: "text"`): a count
@@ -86,9 +86,14 @@ export default {
86
86
  font-size: 13px;
87
87
  font-weight: 700;
88
88
  gap: 6px;
89
- margin: 0;
90
89
  min-height: 32px;
91
90
  padding: 0 14px;
92
91
  white-space: nowrap;
93
92
  }
93
+
94
+ /* server.css gives every button `margin: 3px 3px 3px 0` at (0,2,1); the
95
+ doubled class outranks it. */
96
+ .cn-detail-page__catalog-action.cn-detail-page__catalog-action {
97
+ margin: 0;
98
+ }
94
99
  </style>
@@ -339,6 +339,7 @@ export default {
339
339
  margin: 0;
340
340
  font-size: 20px;
341
341
  font-weight: 700;
342
+ line-height: 1.2;
342
343
  }
343
344
 
344
345
  .cn-device-frames-widget__body {
@@ -5,11 +5,12 @@
5
5
  <template>
6
6
  <CnDialog
7
7
  :name="text(title) || t('nextcloud-vue', 'This may already exist')"
8
+ :eyebrow="text(eyebrow)"
8
9
  width="form"
9
10
  data-testid="cn-duplicate-warning"
10
11
  @closing="close">
11
12
  <div class="cn-duplicate-warning">
12
- <NcNoteCard :type="affordances.blocked ? 'error' : 'warning'" data-testid="cn-duplicate-warning-note">
13
+ <NcNoteCard :type="affordances.blocked && !affordances.mayContinue ? 'error' : 'warning'" data-testid="cn-duplicate-warning-note">
13
14
  {{ headlineText }}
14
15
  </NcNoteCard>
15
16
 
@@ -21,13 +22,14 @@
21
22
  <a v-if="row.href" :href="row.href" class="cn-duplicate-warning__title">{{ row.title }}</a>
22
23
  <span v-else class="cn-duplicate-warning__title">{{ row.title }}</span>
23
24
  <span v-if="row.meta" class="cn-duplicate-warning__meta">{{ row.meta }}</span>
24
- <span class="cn-duplicate-warning__meta">{{ row.matchedOn }} ({{ row.percentage }}%)</span>
25
+ <span class="cn-duplicate-warning__meta">{{ row.matched }}</span>
25
26
  </li>
26
27
  </ul>
27
28
 
28
29
  <NcTextField v-if="affordances.needsReason"
29
30
  :label="text(reasonLabel) || t('nextcloud-vue', 'Why file it anyway')"
30
31
  :modelValue="reason"
32
+ :helperText="text(reasonHint)"
31
33
  data-testid="cn-duplicate-warning-reason"
32
34
  @update:modelValue="reason = $event" />
33
35
 
@@ -36,6 +38,12 @@
36
38
  data-testid="cn-duplicate-warning-blocked">
37
39
  {{ text(blockedText) || t('nextcloud-vue', 'This cannot be filed twice. Open the one it matches, or ask a coordinator.') }}
38
40
  </p>
41
+
42
+ <p v-if="footnote"
43
+ class="cn-duplicate-warning__hint"
44
+ data-testid="cn-duplicate-warning-footnote">
45
+ {{ text(footnote) }}
46
+ </p>
39
47
  </div>
40
48
 
41
49
  <template #actions>
@@ -58,7 +66,7 @@ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
58
66
  import { generateUrl } from '@nextcloud/router'
59
67
  import { NcButton, NcNoteCard, NcTextField } from '@nextcloud/vue'
60
68
  import { duplicateAffordances } from '../../utils/duplicateWarning.js'
61
- import { fillTemplate } from '../../utils/fillTemplate.js'
69
+ import { fillTemplate, firstFilled } from '../../utils/fillTemplate.js'
62
70
  import { CnDialog } from '../CnDialog/index.js'
63
71
 
64
72
  /**
@@ -134,12 +142,46 @@ export default {
134
142
  default: '{title}',
135
143
  },
136
144
 
137
- /** A record's meta line, a template filled from it. */
145
+ /**
146
+ * A record's meta line, a template filled from it, or several: the
147
+ * first that fills is shown ("{identifier} · {caseType.title}", then
148
+ * "{identifier}" for a record that lacks the type).
149
+ *
150
+ * @type {string|Array<string>}
151
+ */
138
152
  rowMeta: {
139
- type: String,
153
+ type: [String, Array],
140
154
  default: '{identifier}',
141
155
  },
142
156
 
157
+ /**
158
+ * What a match was found on, `{fields}` (joined as a list: "Aanvrager
159
+ * en Onderwerp") and `{percentage}` filled, through the host
160
+ * translate. Empty: "Aanvrager, Onderwerp (82%)".
161
+ */
162
+ matchedText: {
163
+ type: String,
164
+ default: '',
165
+ },
166
+
167
+ /** A line above the title, as the board dialogs draw one. */
168
+ eyebrow: {
169
+ type: String,
170
+ default: '',
171
+ },
172
+
173
+ /** The help line under the reason field. */
174
+ reasonHint: {
175
+ type: String,
176
+ default: '',
177
+ },
178
+
179
+ /** A muted note under the matches and the reason, for what Cancel and a block mean. */
180
+ footnote: {
181
+ type: String,
182
+ default: '',
183
+ },
184
+
143
185
  /** A record's page, a template with `{uuid}`; empty draws no link. */
144
186
  recordUrl: {
145
187
  type: String,
@@ -233,15 +275,14 @@ export default {
233
275
  const record = this.records.find((row) => String((row && (row.id || row.uuid || (row['@self'] && row['@self'].id))) || '') === uuid)
234
276
  const fields = Array.isArray(match && match.matchedOn) ? match.matchedOn : []
235
277
  const score = Number(match && match.score)
278
+ const words = fields.map((field) => (this.fieldLabels[field] ? this.cnTranslate(this.fieldLabels[field]) : field))
279
+ const percentage = Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score * 100))) : 0
236
280
  return {
237
281
  uuid,
238
282
  title: record ? (fillTemplate(this.rowTitle, record).trim() || uuid) : t('nextcloud-vue', 'A record you may not open'),
239
- meta: record ? fillTemplate(this.rowMeta, record).trim() : '',
283
+ meta: record ? firstFilled(this.rowMeta, record, (key) => key).trim() : '',
240
284
  href: record && this.recordUrl ? generateUrl(fillTemplate(this.recordUrl, { uuid: encodeURIComponent(uuid) })) : '',
241
- matchedOn: fields.length
242
- ? fields.map((field) => (this.fieldLabels[field] ? this.cnTranslate(this.fieldLabels[field]) : field)).join(', ')
243
- : t('nextcloud-vue', 'Matched on the score, not on a single field'),
244
- percentage: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score * 100))) : 0,
285
+ matched: this.matchedLine(words, percentage),
245
286
  }
246
287
  })
247
288
  },
@@ -260,6 +301,29 @@ export default {
260
301
  return value ? this.cnTranslate(value) : ''
261
302
  },
262
303
 
304
+ /**
305
+ * What a match was found on, and how strongly.
306
+ *
307
+ * @param {Array<string>} words The matched fields, in the host's words.
308
+ * @param {number} percentage The score as a percentage.
309
+ * @return {string}
310
+ */
311
+ matchedLine(words, percentage) {
312
+ if (words.length === 0) {
313
+ return `${t('nextcloud-vue', 'Matched on the score, not on a single field')} (${percentage}%)`
314
+ }
315
+ if (!this.matchedText) {
316
+ return `${words.join(', ')} (${percentage}%)`
317
+ }
318
+ let fields = words.join(', ')
319
+ try {
320
+ fields = new Intl.ListFormat(document.documentElement.lang || undefined, { type: 'conjunction' }).format(words)
321
+ } catch {
322
+ // An engine without Intl.ListFormat keeps the comma list.
323
+ }
324
+ return fillTemplate(this.cnTranslate(this.matchedText), { fields, percentage: String(percentage) })
325
+ },
326
+
263
327
  /** File it over the warning, carrying the reason and what it was filed over. */
264
328
  fileAnyway() {
265
329
  if (!this.canFile) {
@@ -227,6 +227,9 @@
227
227
  <CnIcon v-if="link.icon" :name="link.icon" :size="18" />
228
228
  {{ link.label }}
229
229
  </component>
230
+ <p v-if="link && link.note && record" class="cn-facts-widget__link-note" data-testid="cn-facts-link-note">
231
+ {{ link.note }}
232
+ </p>
230
233
  <button
231
234
  v-if="action && record"
232
235
  type="button"
@@ -867,18 +870,19 @@ export default {
867
870
  if (!link || typeof link !== 'object' || !link.label) {
868
871
  return null
869
872
  }
873
+ const linkView = this.linkWords(link)
870
874
  if (typeof link.sidebarTab === 'string' && link.sidebarTab !== '') {
871
875
  return this.objectSidebarState
872
- ? { label: this.tr(link.label), sidebarTab: link.sidebarTab, icon: typeof link.icon === 'string' ? link.icon : '' }
876
+ ? { ...linkView, sidebarTab: link.sidebarTab, icon: typeof link.icon === 'string' ? link.icon : '' }
873
877
  : null
874
878
  }
875
879
  if (link.route) {
876
880
  const to = this.resolveRoute(link.route, this.record)
877
- return to ? { label: this.tr(link.label), to } : null
881
+ return to ? { ...linkView, to } : null
878
882
  }
879
883
  if (typeof link.href === 'string' && link.href !== '') {
880
884
  const href = this.linkHref(link, this.record)
881
- return href ? { label: this.tr(link.label), href, target: link.target === '_blank' ? '_blank' : '', icon: typeof link.icon === 'string' ? link.icon : '' } : null
885
+ return href ? { ...linkView, href, target: link.target === '_blank' ? '_blank' : '', icon: typeof link.icon === 'string' ? link.icon : '' } : null
882
886
  }
883
887
  return null
884
888
  },
@@ -1017,6 +1021,23 @@ export default {
1017
1021
  },
1018
1022
 
1019
1023
  methods: {
1024
+ /**
1025
+ * A link's label and the muted note under it, their `{path}` and
1026
+ * `{path:format}` placeholders filled from the record ("{page:last-segment}").
1027
+ *
1028
+ * @param {object} link The configured link.
1029
+ * @return {{label: string, note: string}}
1030
+ * @spec openspec/changes/facts-link-label-from-record/specs/facts-widget/spec.md#requirement-a-facts-link-can-take-its-words-from-the-record
1031
+ */
1032
+ linkWords(link) {
1033
+ const fill = (text) => (text.includes('{') && this.record ? this.templateValue(this.record, text) : this.tr(text))
1034
+ const label = fill(String(link.label))
1035
+ return {
1036
+ label: label || this.tr(String(link.label)),
1037
+ note: typeof link.note === 'string' && link.note !== '' ? fill(link.note) : '',
1038
+ }
1039
+ },
1040
+
1020
1041
  /**
1021
1042
  * Open the page's object sidebar on one of its tabs.
1022
1043
  *
@@ -1899,6 +1920,11 @@ export default {
1899
1920
  return formatTermDuration(raw, typeof spec.extendBy === 'string' && record ? readPath(record, spec.extendBy) : null)
1900
1921
  }
1901
1922
  // The month written out, the year only outside this year: "3 oktober".
1923
+ // One segment of a path ("Team/Page"): its first or its last.
1924
+ if (format === 'first-segment' || format === 'last-segment') {
1925
+ const segments = String(raw).split('/').map((part) => part.trim()).filter((part) => part !== '')
1926
+ return (format === 'first-segment' ? segments[0] : segments[segments.length - 1]) || ''
1927
+ }
1902
1928
  if (format === 'longdate') {
1903
1929
  return formatLongDate(raw)
1904
1930
  }
@@ -2315,6 +2341,12 @@ export default {
2315
2341
  text-decoration: underline;
2316
2342
  }
2317
2343
 
2344
+ .cn-facts-widget__link-note {
2345
+ margin: 0;
2346
+ color: var(--color-text-maxcontrast);
2347
+ font-size: 13px;
2348
+ }
2349
+
2318
2350
  .cn-facts-widget__link--button {
2319
2351
  display: inline-flex;
2320
2352
  align-items: center;