@conduction/nextcloud-vue 2.148.0 → 2.149.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 (101) 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/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +1 -1
  11. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js +23 -1
  12. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js +1 -1
  14. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +34 -1
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue.js +1 -1
  19. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js +1 -1
  22. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js.map +1 -1
  24. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue.js +1 -1
  25. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue2.js +64 -8
  26. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue3.js +14 -4
  28. package/dist/esm/components/CnDuplicateWarning/CnDuplicateWarning.vue3.js.map +1 -1
  29. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  30. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +26 -3
  31. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +10 -2
  33. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  34. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  35. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +84 -1
  36. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  37. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +61 -33
  38. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue.js +1 -1
  40. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue2.js +49 -2
  41. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue2.js.map +1 -1
  42. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue3.js +90 -59
  43. package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue2.js +3 -1
  45. package/dist/esm/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue2.js.map +1 -1
  46. package/dist/esm/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue3.js.map +1 -1
  47. package/dist/esm/components/CnNotesCard/CnNotesCard.vue.js +1 -1
  48. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js +10 -0
  49. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js.map +1 -1
  50. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js.map +1 -1
  51. package/dist/esm/components/CnObjectCard/CnObjectCard.vue.js +1 -1
  52. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +11 -0
  53. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
  54. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +28 -21
  55. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
  56. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  57. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +1 -1
  58. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  60. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  61. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +3 -15
  62. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +123 -115
  64. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  65. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
  66. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +4 -0
  67. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
  68. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
  69. package/dist/esm/l10n/en.json.js +2 -1
  70. package/dist/esm/l10n/en.json.js.map +1 -1
  71. package/dist/esm/l10n/nl.json.js +2 -1
  72. package/dist/esm/l10n/nl.json.js.map +1 -1
  73. package/dist/esm/library-style.js +1 -1
  74. package/dist/esm/nextcloud-vue.css +677 -444
  75. package/dist/nextcloud-vue.cjs.js +1336 -942
  76. package/dist/nextcloud-vue.cjs.js.map +2 -2
  77. package/dist/nextcloud-vue.css +677 -444
  78. package/l10n/en.json +2 -1
  79. package/l10n/nl.json +2 -1
  80. package/package.json +1 -1
  81. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +24 -7
  82. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +13 -1
  83. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +29 -2
  84. package/src/components/CnDataTable/CnDataTable.vue +34 -1
  85. package/src/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue +6 -1
  86. package/src/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue +1 -0
  87. package/src/components/CnDuplicateWarning/CnDuplicateWarning.vue +74 -10
  88. package/src/components/CnFactsWidget/CnFactsWidget.vue +35 -3
  89. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +131 -3
  90. package/src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue +103 -5
  91. package/src/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue +3 -1
  92. package/src/components/CnNotesCard/CnNotesCard.vue +10 -0
  93. package/src/components/CnObjectCard/CnObjectCard.vue +16 -0
  94. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +1 -1
  95. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +19 -19
  96. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +4 -0
  97. package/src/css/dialog.css +17 -0
  98. package/src/css/look-board-detail.css +52 -3
  99. package/src/css/look-board-index.css +37 -1
  100. package/src/css/look-board.css +45 -5
  101. package/src/css/table.css +5 -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.149.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
  },
@@ -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;