@conduction/nextcloud-vue 2.147.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 (130) 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/CnDetailPage/CnDetailPage.vue2.js +71 -8
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +2 -2
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.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 +32 -4
  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/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  61. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +8 -0
  62. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  64. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  65. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +1 -1
  66. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  67. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  68. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  69. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +3 -15
  70. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  71. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +123 -115
  72. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  73. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
  74. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +4 -0
  75. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
  76. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
  77. package/dist/esm/components/CnStepper/CnStepper.vue.js +1 -1
  78. package/dist/esm/components/CnStepper/CnStepper.vue2.js.map +1 -1
  79. package/dist/esm/components/CnStepper/CnStepper.vue3.js.map +1 -1
  80. package/dist/esm/dialogs/CnAccessPurposeDialog.vue.js +1 -1
  81. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js +3 -1
  82. package/dist/esm/dialogs/CnAccessPurposeDialog.vue2.js.map +1 -1
  83. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js +26 -15
  84. package/dist/esm/dialogs/CnAccessPurposeDialog.vue3.js.map +1 -1
  85. package/dist/esm/l10n/en.json.js +3 -1
  86. package/dist/esm/l10n/en.json.js.map +1 -1
  87. package/dist/esm/l10n/nl.json.js +3 -1
  88. package/dist/esm/l10n/nl.json.js.map +1 -1
  89. package/dist/esm/library-style.js +1 -1
  90. package/dist/esm/nextcloud-vue.css +785 -518
  91. package/dist/esm/utils/builtInFormatters.js +53 -1
  92. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  93. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  94. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  95. package/dist/nextcloud-vue.cjs.js +2688 -2154
  96. package/dist/nextcloud-vue.cjs.js.map +2 -2
  97. package/dist/nextcloud-vue.css +785 -518
  98. package/l10n/en.json +3 -1
  99. package/l10n/nl.json +3 -1
  100. package/package.json +1 -1
  101. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +24 -7
  102. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +13 -1
  103. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +29 -2
  104. package/src/components/CnDataTable/CnDataTable.vue +34 -1
  105. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  106. package/src/components/CnDetailPage/CnDetailPage.vue +73 -10
  107. package/src/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue +6 -1
  108. package/src/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue +1 -0
  109. package/src/components/CnDuplicateWarning/CnDuplicateWarning.vue +74 -10
  110. package/src/components/CnFactsWidget/CnFactsWidget.vue +41 -4
  111. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +131 -3
  112. package/src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue +103 -5
  113. package/src/components/CnFollowingWidgetHost/CnFollowingWidgetHost.vue +3 -1
  114. package/src/components/CnNotesCard/CnNotesCard.vue +10 -0
  115. package/src/components/CnObjectCard/CnObjectCard.vue +16 -0
  116. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  117. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +8 -0
  118. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +1 -1
  119. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +19 -19
  120. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +4 -0
  121. package/src/components/CnStepper/CnStepper.vue +10 -2
  122. package/src/css/dialog.css +22 -2
  123. package/src/css/look-board-detail.css +52 -3
  124. package/src/css/look-board-index.css +51 -1
  125. package/src/css/look-board.css +48 -5
  126. package/src/css/table.css +5 -2
  127. package/src/dialogs/CnAccessPurposeDialog.vue +27 -12
  128. package/src/schemas/app-manifest-v2.schema.json +4 -2
  129. package/src/utils/builtInFormatters.js +52 -0
  130. package/src/utils/validateManifestV2.compiled.js +3 -3
package/l10n/en.json CHANGED
@@ -2397,7 +2397,9 @@
2397
2397
  "From {date}": "From {date}",
2398
2398
  "Until {date}": "Until {date}",
2399
2399
  "More actions for {name}": "More actions for {name}",
2400
- "Unlink contact": "Unlink contact"
2400
+ "Unlink contact": "Unlink contact",
2401
+ "{term}, extendable by {extension}": "{term}, extendable by {extension}",
2402
+ "Move {item}, use the arrow keys": "Move {item}, use the arrow keys"
2401
2403
  },
2402
2404
  "plurals": {
2403
2405
  "{count} member": [
package/l10n/nl.json CHANGED
@@ -2652,7 +2652,9 @@
2652
2652
  "From {date}": "Vanaf {date}",
2653
2653
  "Until {date}": "Tot {date}",
2654
2654
  "More actions for {name}": "Meer acties voor {name}",
2655
- "Unlink contact": "Contact ontkoppelen"
2655
+ "Unlink contact": "Contact ontkoppelen",
2656
+ "{term}, extendable by {extension}": "{term}, te verdagen met {extension}",
2657
+ "Move {item}, use the arrow keys": "{item} verplaatsen, gebruik de pijltjestoetsen"
2656
2658
  },
2657
2659
  "plurals": {
2658
2660
  "{count} member": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.147.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
@@ -438,7 +438,7 @@ Four levels of action and the case surfaces around them. All opt in. See [the re
438
438
  | `sideColumnFrom` | String | `''` | A field path, read like a `visibleWhen` field (e.g. `@context.caseType.sideCards`), whose list of ids picks the `sideColumn` entries this record shows and their order. Any other value keeps `sideColumn` as declared. |
439
439
  | `tabsFrom` | String | `''` | A field path, read like a `visibleWhen` field, whose list of tab ids picks the tabs of the page's `tabs` widgets and their order for this record: `@context.caseType.tabs` reads the list a case type keeps (DqAanbesteding: Inschrijvingen, Publicaties, Bestanden, Historie). A tab's id is its `id`, else its `widgetId`, or the string entry. An empty or missing list keeps the declared tabs; edit mode shows them all. |
440
440
  | `verbEndpoints` | Object | `null` | App routes that replace a record source's own route for a verb, by verb id (manifest `config.verbEndpoints`), such as `{ "complete": "/apps/dossiq/api/case-tasks/{id}/complete" }` for an app that checks something of its own before the engine completes the task. `{id}` is the record id. Only read with `entitySource`. |
441
- | `accessPurpose` | Object | `null` | Ask for a purpose before the record opens (manifest `config.accessPurpose`): `{ when, options, url, title?, description? }`. `when` is a `visibleWhen` condition on the record and its `visibilityContext` records; `options` are the purposes offered (`{ value, label }`; "Other" with a written purpose is always added); `url` is the app endpoint that records the purpose (POST `{ purpose, note }`, `{objectId}` filled in). Nothing of the record shows until the endpoint accepts, and the same record is not asked again in the same browser session. A refusal keeps the record closed. `null` asks nothing. |
441
+ | `accessPurpose` | Object | `null` | Ask for a purpose before the record opens (manifest `config.accessPurpose`): `{ when, options, url, checkUrl?, title?, description? }`. `when` is a `visibleWhen` condition on the record and its `visibilityContext` records; `options` are the purposes offered (`{ value, label, hint? }`, a `hint` being a line under the option that says what choosing it does; "Other" with a written purpose is always added); `checkUrl` is asked first (GET), and an answer `{ required: false }` opens the record without the dialog, because the app recorded the purpose itself; `url` is the app endpoint that records the purpose (POST `{ purpose, note }`, `{objectId}` filled in). Nothing of the record shows until the endpoint accepts, and the same record is not asked again in the same browser session. A refusal keeps the record closed. `null` asks nothing. |
442
442
  | `isAdmin` | Boolean \| null | `null` | Shows `adminOnly` header actions. `null` reads it from Nextcloud. |
443
443
 
444
444
  ## A body widget's own cards in the side column
@@ -772,7 +772,7 @@
772
772
  the reader has stated why they open it and the purpose is recorded. -->
773
773
  <div v-else-if="contentWithheld" class="cn-detail-page__access-purpose" data-testid="cn-detail-page-access-purpose">
774
774
  <CnAccessPurposeDialog
775
- v-if="accessPurposeAsking"
775
+ v-if="accessPurposeAsking && !accessPurposeChecking"
776
776
  :title="accessPurposeText('title')"
777
777
  :description="accessPurposeText('description')"
778
778
  :options="accessPurposeOptions"
@@ -781,7 +781,7 @@
781
781
  @submit="recordAccessPurpose"
782
782
  @cancel="accessPurposeAsking = false" />
783
783
  <NcEmptyContent
784
- v-else-if="accessPurposeRequired"
784
+ v-else-if="accessPurposeRequired && !accessPurposeChecking"
785
785
  :name="t('nextcloud-vue', 'State a purpose to open this')"
786
786
  :description="t('nextcloud-vue', 'This record holds special categories of personal data. It opens once your purpose is recorded.')"
787
787
  data-testid="cn-detail-page-access-purpose-refused">
@@ -3095,6 +3095,12 @@ export default {
3095
3095
  * browser session. A refusal keeps the record closed. `null` (the
3096
3096
  * default) asks nothing.
3097
3097
  *
3098
+ * `checkUrl` (GET, `{objectId}` filled in) is asked first: an answer
3099
+ * `{ required: false }` opens the record without the dialog (the app
3100
+ * recorded the purpose itself, as for the case's own handler); any
3101
+ * other answer, or a failure, shows the dialog. An option's `hint`
3102
+ * is a line under it saying what choosing it does besides recording it.
3103
+ *
3098
3104
  * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
3099
3105
  * @type {object|null}
3100
3106
  */
@@ -3413,6 +3419,10 @@ export default {
3413
3419
  accessPurposeGranted: readGrantedPurposes(),
3414
3420
  /** Whether the purpose dialog is open; closing it without a purpose keeps the record closed. */
3415
3421
  accessPurposeAsking: true,
3422
+ /** Whether `accessPurpose.checkUrl` is being asked for the record in view. */
3423
+ accessPurposeChecking: false,
3424
+ /** The record the check last answered for, so it is asked once per record. */
3425
+ accessPurposeCheckedKey: '',
3416
3426
  /** Whether a purpose is being recorded. */
3417
3427
  accessPurposeBusy: false,
3418
3428
  /** Why the last purpose was refused. */
@@ -4129,7 +4139,11 @@ export default {
4129
4139
  const options = Array.isArray(this.accessPurpose?.options) ? this.accessPurpose.options : []
4130
4140
  return options
4131
4141
  .filter((option) => option && typeof option.value === 'string' && option.value !== '')
4132
- .map((option) => ({ value: option.value, label: this.cnTranslate(option.label || option.value) }))
4142
+ .map((option) => ({
4143
+ value: option.value,
4144
+ label: this.cnTranslate(option.label || option.value),
4145
+ ...(typeof option.hint === 'string' && option.hint !== '' ? { hint: this.cnTranslate(option.hint) } : {}),
4146
+ }))
4133
4147
  },
4134
4148
 
4135
4149
  /**
@@ -5832,6 +5846,17 @@ export default {
5832
5846
  this.accessPurposeError = ''
5833
5847
  },
5834
5848
 
5849
+ /** Once the page knows the record asks a purpose, the app is asked first whether it must (`checkUrl`). */
5850
+ accessPurposeRequired: {
5851
+ handler(required) {
5852
+ if (required && this.contentWithheld) {
5853
+ this.checkAccessPurpose()
5854
+ }
5855
+ },
5856
+
5857
+ immediate: true,
5858
+ },
5859
+
5835
5860
  // Deep, like `sidebarTabs` / `sidebarProps` below: the manifest editors
5836
5861
  // mutate `page.config.sidebar` IN PLACE, so the reference CnPageRenderer
5837
5862
  // forwards never changes. A shallow watcher missed every such edit —
@@ -6089,6 +6114,50 @@ export default {
6089
6114
  * @param {{purpose: string, note: string}} answer The chosen purpose.
6090
6115
  * @return {Promise<void>}
6091
6116
  */
6117
+ /**
6118
+ * Ask the app whether the reader must state a purpose for this record
6119
+ * (`accessPurpose.checkUrl`); `{ required: false }` opens it at once.
6120
+ *
6121
+ * @spec openspec/changes/detail-access-purpose/specs/detail-access-purpose/spec.md#requirement-a-detail-page-can-ask-for-a-purpose-before-it-opens
6122
+ * @return {Promise<void>}
6123
+ */
6124
+ async checkAccessPurpose() {
6125
+ const key = this.accessPurposeKey
6126
+ const url = String(this.accessPurpose?.checkUrl || '').replace(/\{objectId\}/g, encodeURIComponent(String(this.objectId || '')))
6127
+ if (url === '' || this.accessPurposeCheckedKey === key) {
6128
+ return
6129
+ }
6130
+ this.accessPurposeCheckedKey = key
6131
+ this.accessPurposeChecking = true
6132
+ try {
6133
+ const { default: axios } = await import('@nextcloud/axios')
6134
+ const { data } = await axios.get(prefixUrl(url))
6135
+ if (data && data.required === false && this.accessPurposeKey === key) {
6136
+ this.grantAccessPurpose(key)
6137
+ }
6138
+ } catch {
6139
+ // The check is a shortcut: without an answer the reader is asked as before.
6140
+ } finally {
6141
+ this.accessPurposeChecking = false
6142
+ }
6143
+ },
6144
+
6145
+ /**
6146
+ * Open the record and remember for this browser session that its
6147
+ * purpose is recorded.
6148
+ *
6149
+ * @param {string} key The record's session key.
6150
+ * @return {void}
6151
+ */
6152
+ grantAccessPurpose(key) {
6153
+ this.accessPurposeGranted = [...new Set([...this.accessPurposeGranted, key])]
6154
+ try {
6155
+ window.sessionStorage.setItem(ACCESS_PURPOSE_SESSION_KEY, JSON.stringify(this.accessPurposeGranted))
6156
+ } catch {
6157
+ // Without session storage the purpose is asked again on the next visit.
6158
+ }
6159
+ },
6160
+
6092
6161
  async recordAccessPurpose(answer) {
6093
6162
  const url = String(this.accessPurpose?.url || '').replace(/\{objectId\}/g, encodeURIComponent(String(this.objectId || '')))
6094
6163
  if (url === '') {
@@ -6100,13 +6169,7 @@ export default {
6100
6169
  try {
6101
6170
  const { default: axios } = await import('@nextcloud/axios')
6102
6171
  await axios.post(prefixUrl(url), { purpose: answer.purpose, note: answer.note })
6103
- const key = this.accessPurposeKey
6104
- this.accessPurposeGranted = [...new Set([...this.accessPurposeGranted, key])]
6105
- try {
6106
- window.sessionStorage.setItem(ACCESS_PURPOSE_SESSION_KEY, JSON.stringify(this.accessPurposeGranted))
6107
- } catch {
6108
- // Without session storage the purpose is asked again on the next visit.
6109
- }
6172
+ this.grantAccessPurpose(this.accessPurposeKey)
6110
6173
  } catch (error) {
6111
6174
  const data = error?.response?.data
6112
6175
  this.accessPurposeError = (data && (data.message || data.error)) || t('nextcloud-vue', 'Your purpose could not be recorded, so the record stays closed.')
@@ -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) {