@conduction/nextcloud-vue 2.82.0 → 2.84.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 (178) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +2 -2
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  4. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +24 -0
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  6. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +9 -4
  7. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  8. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +1 -1
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnBodySections/CnBodySections.vue.js +1 -1
  13. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js +22 -1
  14. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js.map +1 -1
  15. package/dist/esm/components/CnBodySections/CnBodySections.vue3.js.map +1 -1
  16. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
  17. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +47 -4
  18. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
  19. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
  20. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue.js +1 -1
  21. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +125 -3
  22. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +3 -3
  24. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +14 -0
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue.js +1 -1
  30. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js +1 -1
  31. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js.map +1 -1
  32. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue3.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +203 -4
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  37. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +38 -1
  38. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +45 -5
  40. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  41. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue.js +1 -1
  42. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js +1 -1
  43. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue3.js.map +1 -1
  45. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +96 -12
  46. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  47. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +95 -50
  48. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  49. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js +29 -6
  50. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js.map +1 -1
  51. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue3.js.map +1 -1
  52. package/dist/esm/components/CnMassActionBar/CnMassActionBar.vue2.js +2 -2
  53. package/dist/esm/components/CnMassActionBar/CnMassActionBar.vue2.js.map +1 -1
  54. package/dist/esm/components/CnMassImportDialog/CnMassImportDialog.vue2.js +2 -2
  55. package/dist/esm/components/CnMassImportDialog/CnMassImportDialog.vue2.js.map +1 -1
  56. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  57. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +31 -2
  58. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  60. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue.js +1 -1
  61. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js +16 -2
  62. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js +3 -3
  64. package/dist/esm/components/CnRadioCardsWidget/CnRadioCardsWidget.vue3.js.map +1 -1
  65. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  66. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +37 -2
  67. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  68. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  69. package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
  70. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +176 -0
  71. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  72. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +181 -52
  73. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  74. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue.js +1 -1
  75. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js +70 -5
  76. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue2.js.map +1 -1
  77. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js +61 -35
  78. package/dist/esm/components/CnSettingsFormWidget/CnSettingsFormWidget.vue3.js.map +1 -1
  79. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue.js +1 -1
  80. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +72 -5
  81. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  82. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +33 -8
  83. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  84. package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
  85. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +4 -2
  86. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  87. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  88. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue.js +1 -1
  89. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +72 -5
  90. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  91. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +88 -71
  92. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  93. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue.js +1 -1
  94. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js +18 -4
  95. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue2.js.map +1 -1
  96. package/dist/esm/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue3.js.map +1 -1
  97. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  98. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -3
  99. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  100. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  101. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue.js +1 -1
  102. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +16 -0
  103. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  104. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  105. package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
  106. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +68 -6
  107. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
  108. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +133 -105
  109. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
  110. package/dist/esm/l10n/en.json.js +16 -1
  111. package/dist/esm/l10n/en.json.js.map +1 -1
  112. package/dist/esm/l10n/nl.json.js +41 -4
  113. package/dist/esm/l10n/nl.json.js.map +1 -1
  114. package/dist/esm/library-style.js +1 -1
  115. package/dist/esm/nextcloud-vue.css +900 -537
  116. package/dist/esm/utils/buildManifest.js +37 -3
  117. package/dist/esm/utils/buildManifest.js.map +1 -1
  118. package/dist/esm/utils/detailWidgetVisibility.js +149 -0
  119. package/dist/esm/utils/detailWidgetVisibility.js.map +1 -0
  120. package/dist/esm/utils/fetchAggregate.js +25 -2
  121. package/dist/esm/utils/fetchAggregate.js.map +1 -1
  122. package/dist/esm/utils/formatMetric.js +39 -3
  123. package/dist/esm/utils/formatMetric.js.map +1 -1
  124. package/dist/esm/utils/resolveManifestTokens.js +1 -0
  125. package/dist/esm/utils/resolveManifestTokens.js.map +1 -1
  126. package/dist/esm/utils/sentinelTokens.js +9 -0
  127. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  128. package/dist/esm/utils/switchValues.js +62 -0
  129. package/dist/esm/utils/switchValues.js.map +1 -0
  130. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  131. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  132. package/dist/nextcloud-vue.cjs.js +36711 -34909
  133. package/dist/nextcloud-vue.cjs.js.map +2 -2
  134. package/dist/nextcloud-vue.css +900 -537
  135. package/l10n/en.json +16 -1
  136. package/l10n/nl.json +41 -4
  137. package/package.json +1 -1
  138. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +31 -2
  139. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +1 -1
  140. package/src/components/CnBodySections/CnBodySections.vue +22 -1
  141. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +47 -4
  142. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +128 -6
  143. package/src/components/CnDashboardPage/CnDashboardPage.vue +14 -0
  144. package/src/components/CnDeltaWidget/CnDeltaWidget.vue +1 -1
  145. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  146. package/src/components/CnDetailPage/CnDetailPage.vue +203 -4
  147. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +63 -1
  148. package/src/components/CnGaugeWidget/CnGaugeWidget.vue +1 -1
  149. package/src/components/CnIndexPage/CnIndexPage.vue +125 -15
  150. package/src/components/CnInteractionFormWidget/CnInteractionFormWidget.vue +29 -6
  151. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  152. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +31 -2
  153. package/src/components/CnRadioCardsWidget/CnRadioCardsWidget.vue +54 -3
  154. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +37 -2
  155. package/src/components/CnReportsPage/CnReportsPage.vue +263 -5
  156. package/src/components/CnSettingsFormWidget/CnSettingsFormWidget.vue +110 -18
  157. package/src/components/CnStagesWidget/CnStagesWidget.vue +103 -6
  158. package/src/components/CnStatWidget/CnStatWidget.vue +4 -2
  159. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +125 -7
  160. package/src/components/CnSwitchRowsWidget/CnSwitchRowsWidget.vue +18 -4
  161. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -3
  162. package/src/components/CnWalkthrough/CnWalkthrough.vue +16 -0
  163. package/src/css/form-field.css +14 -0
  164. package/src/css/form-widget.css +11 -0
  165. package/src/css/index-page.css +11 -0
  166. package/src/css/index.css +1 -0
  167. package/src/css/look-board-reports.css +175 -0
  168. package/src/css/look-board.css +44 -1
  169. package/src/dialogs/CnTransitionInputDialog.vue +87 -7
  170. package/src/schemas/app-manifest-v2.schema.json +24 -3
  171. package/src/utils/buildManifest.js +36 -2
  172. package/src/utils/detailWidgetVisibility.js +145 -0
  173. package/src/utils/fetchAggregate.js +24 -1
  174. package/src/utils/formatMetric.js +38 -2
  175. package/src/utils/resolveManifestTokens.js +1 -0
  176. package/src/utils/sentinelTokens.js +9 -0
  177. package/src/utils/switchValues.js +59 -0
  178. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -1253,6 +1253,7 @@ import {
1253
1253
  stageEntry,
1254
1254
  stageOf,
1255
1255
  } from '../../utils/detailActionModel.js'
1256
+ import { detailWidgetVisible, fetchVisibilityRows, visibilityContextRefs, widgetConditionData } from '../../utils/detailWidgetVisibility.js'
1256
1257
  import { reportBindingProblems } from '../../utils/diagnostics.js'
1257
1258
  import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
1258
1259
  import { cnGridCellStyle, hasGridRow } from '../../utils/grid.js'
@@ -1260,6 +1261,7 @@ import { isActivityWidget, resolveHeaderMeta } from '../../utils/headerMeta.js'
1260
1261
  import { patchStoredSelf } from '../../utils/patchStoredSelf.js'
1261
1262
  import { setReadState } from '../../utils/recordInteractions.js'
1262
1263
  import { slotRenders } from '../../utils/slotContent.js'
1264
+ import { evaluateVisibleWhen, isLocallyDecidableVisibleWhen } from '../../utils/visibleWhen.js'
1263
1265
  import {
1264
1266
  isCardWidgetDef,
1265
1267
  isContentOnlyWidgetDef,
@@ -1512,8 +1514,8 @@ export default {
1512
1514
 
1513
1515
  /**
1514
1516
  * Offer the board look's tab list name to the tabs widget below this page
1515
- * (`config.tabsLabel`, default "<type> parts"). A getter object, so the
1516
- * widget reads it live.
1517
+ * (`config.tabsLabel`, default "<type> parts"), and the records
1518
+ * `visibilityContext` names. Getter objects, so the widget reads them live.
1517
1519
  *
1518
1520
  * @return {object} The provided values.
1519
1521
  * @spec openspec/changes/screens-detail-page-parity/specs/detail-page-board-look/spec.md#requirement-folder-tabs-take-the-board-strip
@@ -1521,6 +1523,7 @@ export default {
1521
1523
  provide() {
1522
1524
  const isBoard = computed(() => this.isBoard)
1523
1525
  const tabsLabel = computed(() => this.resolvedTabsLabel)
1526
+ const visibilityRows = computed(() => (this.visibilityRowsPending ? null : this.visibilityRows))
1524
1527
  return {
1525
1528
  // The page's own look reaches the tabs, the cards and the activity
1526
1529
  // below it, also when it came from the `look` prop and not the app.
@@ -1530,6 +1533,15 @@ export default {
1530
1533
  return tabsLabel.value
1531
1534
  },
1532
1535
  },
1536
+
1537
+ // The records `visibilityContext` names, keyed `@context.<name>`, so a tab's
1538
+ // `visibleWhen` reads the case type as a grid widget's does. Null
1539
+ // while they are on their way.
1540
+ cnDetailVisibilityRows: {
1541
+ get value() {
1542
+ return visibilityRows.value
1543
+ },
1544
+ },
1533
1545
  }
1534
1546
  },
1535
1547
 
@@ -2572,6 +2584,25 @@ export default {
2572
2584
  default: '',
2573
2585
  },
2574
2586
 
2587
+ /**
2588
+ * Records the page reads beside the loaded one, so a `visibleWhen`
2589
+ * can ask about them (manifest `config.visibilityContext`). Each entry
2590
+ * is `{ <name>: { field, register, schema } }`: `field` is the
2591
+ * property of the loaded record holding the other record's id, and a
2592
+ * condition reads that record as `@context.<name>.<path>`. A case page that
2593
+ * declares `{ caseType: { field: "caseType", register: "dossiq",
2594
+ * schema: "caseType" } }` can show a widget for some case types only
2595
+ * with `{ field: "@context.caseType.category", op: "in", value: [...] }`.
2596
+ * A record that cannot be read is absent, so its condition fails safe.
2597
+ * Empty (the default) reads nothing extra.
2598
+ *
2599
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
2600
+ */
2601
+ visibilityContext: {
2602
+ type: Object,
2603
+ default: () => ({}),
2604
+ },
2605
+
2575
2606
  /**
2576
2607
  * Whether the current user administers this instance. Shows the
2577
2608
  * `adminOnly` header actions. `null` (the default) reads it from
@@ -2799,6 +2830,19 @@ export default {
2799
2830
 
2800
2831
  data() {
2801
2832
  return {
2833
+ /**
2834
+ * The records `visibilityContext` names, keyed `@context.<name>`: the row,
2835
+ * `null` when it could not be read, absent while it is on its way.
2836
+ */
2837
+ visibilityRows: {},
2838
+ /** The `visibilityRefsKey` the rows in `visibilityRows` were read for. */
2839
+ visibilityRowsFor: '',
2840
+ /** Sequence of the last `visibilityContext` read, so a late answer for another record is dropped. */
2841
+ visibilityRowsSeq: 0,
2842
+ /** The answers of endpoint and source `visibleWhen` conditions on grid widgets, by widget id. */
2843
+ widgetConditionAnswers: {},
2844
+ /** Sequence of the last remote condition round. */
2845
+ widgetConditionSeq: 0,
2802
2846
  /** The counts of the header's count pills (`countFrom`), by pill key (`type`, `status`). */
2803
2847
  pillCounts: {},
2804
2848
  /** Sequence of the last pill count round, so a late answer does not overwrite a newer one. */
@@ -3058,11 +3102,17 @@ export default {
3058
3102
  * because a keyboard user who cannot see the page needs to know what
3059
3103
  * they are being offered before they take it.
3060
3104
  *
3105
+ * One library string with a placeholder, not "Skip to" from the host's
3106
+ * catalogue glued to the label: no app carried that fragment, so every
3107
+ * Dutch reader got "Skip to In behandeling nemen" (Zuiddrecht live
3108
+ * round 3), and a fragment cannot be put in Dutch word order anyway.
3109
+ *
3061
3110
  * @return {string} The link text.
3062
3111
  * @spec openspec/changes/case-page-and-list-as-a-place/specs/index-page/spec.md
3112
+ * @spec openspec/changes/detail-page-strings-dutch/specs/detail-page-strings-dutch/spec.md#requirement-the-detail-page-speaks-the-readers-language
3063
3113
  */
3064
3114
  skipLinkLabel() {
3065
- return `${this.effectiveTranslate('Skip to')} ${this.effectivePrimaryLabel}`
3115
+ return t('nextcloud-vue', 'Skip to {action}', { action: this.effectivePrimaryLabel })
3066
3116
  },
3067
3117
 
3068
3118
  // ── The action model (detail-action-model-and-case-surfaces) ────
@@ -3904,6 +3954,68 @@ export default {
3904
3954
  return store.objects?.[this.objectType]?.[this.objectId] ?? null
3905
3955
  },
3906
3956
 
3957
+ /**
3958
+ * The records `visibilityContext` names for the loaded record.
3959
+ *
3960
+ * @return {Array<{key: string, register: string, schema: string, id: string}>}
3961
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
3962
+ */
3963
+ visibilityRefs() {
3964
+ return visibilityContextRefs(this.visibilityContext, this.resolvedObject)
3965
+ },
3966
+
3967
+ /**
3968
+ * Whether a record `visibilityContext` names is still on its way.
3969
+ *
3970
+ * @return {boolean}
3971
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
3972
+ */
3973
+ visibilityRowsPending() {
3974
+ return this.visibilityRefs.length > 0 && this.visibilityRowsFor !== this.visibilityRefsKey
3975
+ },
3976
+
3977
+ /**
3978
+ * The named references as one key, so another record (or another case
3979
+ * type on the same record) reads them again.
3980
+ *
3981
+ * @return {string}
3982
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
3983
+ */
3984
+ visibilityRefsKey() {
3985
+ return JSON.stringify(this.visibilityRefs)
3986
+ },
3987
+
3988
+ /**
3989
+ * What a grid widget's `visibleWhen` reads: the record, with the
3990
+ * `visibilityContext` records under `@context.<name>`. Null until the record loads.
3991
+ *
3992
+ * @return {object|null}
3993
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-grid-widget-shows-only-while-its-visiblewhen-holds-for-the-record
3994
+ */
3995
+ widgetVisibilityData() {
3996
+ return widgetConditionData(this.resolvedObject, this.visibilityRows)
3997
+ },
3998
+
3999
+ /**
4000
+ * The grid widgets whose `visibleWhen` needs a request (endpoint or
4001
+ * source), with what they ask, as one key: a change re-asks them.
4002
+ *
4003
+ * @return {string}
4004
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-grid-widget-shows-only-while-its-visiblewhen-holds-for-the-record
4005
+ */
4006
+ remoteWidgetConditionsKey() {
4007
+ if (!this.widgetVisibilityData || this.visibilityRowsPending) {
4008
+ return ''
4009
+ }
4010
+ const defs = [...(this.bodyGridWidgets || []), ...((this.activeView && this.activeView.widgets) || [])]
4011
+ const remote = defs.filter((def) => def && def.visibleWhen && typeof def.visibleWhen === 'object'
4012
+ && !isLocallyDecidableVisibleWhen(def.visibleWhen))
4013
+ if (remote.length === 0) {
4014
+ return ''
4015
+ }
4016
+ return JSON.stringify([String(this.objectId || ''), remote.map((def) => [def.id, def.visibleWhen])])
4017
+ },
4018
+
3907
4019
  /**
3908
4020
  * The `@self` block of the resolved object (interaction markers live here).
3909
4021
  *
@@ -4436,6 +4548,22 @@ export default {
4436
4548
  },
4437
4549
 
4438
4550
  watch: {
4551
+ // The records a condition names are read again for another record.
4552
+ visibilityRefsKey: {
4553
+ immediate: true,
4554
+ handler() {
4555
+ this.refreshVisibilityRows()
4556
+ },
4557
+ },
4558
+
4559
+ // Endpoint and source conditions on grid widgets are asked once per record.
4560
+ remoteWidgetConditionsKey: {
4561
+ immediate: true,
4562
+ handler() {
4563
+ this.refreshWidgetConditions()
4564
+ },
4565
+ },
4566
+
4439
4567
  // The header's count pills count again for another record or other lists.
4440
4568
  pillCountKey: {
4441
4569
  immediate: true,
@@ -5674,6 +5802,62 @@ export default {
5674
5802
  || undefined
5675
5803
  },
5676
5804
 
5805
+ /**
5806
+ * Read the records `visibilityContext` names for the loaded record. A
5807
+ * late answer for a record the page has left is dropped.
5808
+ *
5809
+ * @return {Promise<void>}
5810
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
5811
+ */
5812
+ async refreshVisibilityRows() {
5813
+ const key = this.visibilityRefsKey
5814
+ const refs = this.visibilityRefs
5815
+ const seq = ++this.visibilityRowsSeq
5816
+ if (refs.length === 0) {
5817
+ // Write only on a change, so a page without `visibilityContext`
5818
+ // never touches the state its layout could read.
5819
+ if (Object.keys(this.visibilityRows).length > 0) {
5820
+ this.visibilityRows = {}
5821
+ }
5822
+ if (this.visibilityRowsFor !== key) {
5823
+ this.visibilityRowsFor = key
5824
+ }
5825
+ return
5826
+ }
5827
+ if (!this._visibilityRowCache) {
5828
+ this._visibilityRowCache = new Map()
5829
+ }
5830
+ const rows = await fetchVisibilityRows(refs, this._visibilityRowCache)
5831
+ if (seq !== this.visibilityRowsSeq) {
5832
+ return
5833
+ }
5834
+ this.visibilityRows = rows
5835
+ this.visibilityRowsFor = key
5836
+ },
5837
+
5838
+ /**
5839
+ * Ask the endpoint and source `visibleWhen` conditions of the grid
5840
+ * widgets, once per record. Each is fail-safe: an error hides the widget.
5841
+ *
5842
+ * @return {Promise<void>}
5843
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-grid-widget-shows-only-while-its-visiblewhen-holds-for-the-record
5844
+ */
5845
+ async refreshWidgetConditions() {
5846
+ const seq = ++this.widgetConditionSeq
5847
+ if (this.remoteWidgetConditionsKey === '') {
5848
+ return
5849
+ }
5850
+ const [, entries] = JSON.parse(this.remoteWidgetConditionsKey)
5851
+ const ctx = { objectId: this.objectId, object: this.widgetVisibilityData || undefined }
5852
+ const answers = {}
5853
+ await Promise.all(entries.map(async ([id, cond]) => {
5854
+ answers[id] = await evaluateVisibleWhen(cond, ctx)
5855
+ }))
5856
+ if (seq === this.widgetConditionSeq) {
5857
+ this.widgetConditionAnswers = answers
5858
+ }
5859
+ },
5860
+
5677
5861
  /**
5678
5862
  * Drop the items that draw nothing, then close the rows they freed —
5679
5863
  * `float: true` leaves the item below pinned under a gap nobody owns.
@@ -5696,7 +5880,10 @@ export default {
5696
5880
  * Whether a layout item has anything to draw, so a grid cell is worth
5697
5881
  * spending on it.
5698
5882
  *
5699
- * TWO widget types are judged, and no others.
5883
+ * A widget whose definition carries `visibleWhen` draws only while the
5884
+ * condition holds for the record (see `detailWidgetVisible`).
5885
+ *
5886
+ * Beyond that, TWO widget types are judged, and no others.
5700
5887
  *
5701
5888
  * `integration`, in the case where nothing can ever appear: a leaf from
5702
5889
  * another app that is not registered, with no `requiredApp` to fall back
@@ -5743,6 +5930,18 @@ export default {
5743
5930
  if (!widget) {
5744
5931
  return true
5745
5932
  }
5933
+ // The widget's own `visibleWhen`, read against the record and the
5934
+ // records `visibilityContext` names (a case's case type). Without
5935
+ // one the widget shows, as it always did, and the layout takes no
5936
+ // dependency on the record: a page without conditions re-lays out
5937
+ // exactly as often as before.
5938
+ if (widget.visibleWhen !== undefined && widget.visibleWhen !== null
5939
+ && !detailWidgetVisible(widget, this.widgetVisibilityData, {
5940
+ pending: this.visibilityRowsPending,
5941
+ answers: this.widgetConditionAnswers,
5942
+ })) {
5943
+ return false
5944
+ }
5746
5945
  // The board look shows the activity as the History tab. When a tabs
5747
5946
  // widget already holds it, it is not also a body section.
5748
5947
  if (this.isBoard && isActivityWidget(widget) && this.tabbedWidgetIds.has(widget.id)) {
@@ -111,6 +111,14 @@
111
111
  role="note"
112
112
  :data-testid="`cn-field-list-band-${row.key}`">
113
113
  <span class="cn-field-list-widget__band-text">{{ row.detail }}</span>
114
+ <NcButton
115
+ v-for="copy in row.copies"
116
+ :key="`copy-${copy.path}`"
117
+ variant="tertiary"
118
+ :data-testid="`cn-field-list-copy-${copy.path}-${row.key}`"
119
+ @click="copyValue(copy)">
120
+ {{ copy.label }}
121
+ </NcButton>
114
122
  <NcButton
115
123
  v-for="action in row.bandActions"
116
124
  :key="action.id"
@@ -162,6 +170,16 @@
162
170
  <p v-if="row.detail" class="cn-field-list-widget__detail">
163
171
  {{ row.detail }}
164
172
  </p>
173
+ <div v-if="row.detail && row.copies.length > 0" class="cn-field-list-widget__copies">
174
+ <NcButton
175
+ v-for="copy in row.copies"
176
+ :key="`copy-${copy.path}`"
177
+ variant="tertiary"
178
+ :data-testid="`cn-field-list-copy-${copy.path}-${row.key}`"
179
+ @click="copyValue(copy)">
180
+ {{ copy.label }}
181
+ </NcButton>
182
+ </div>
165
183
  </li>
166
184
  </ul>
167
185
 
@@ -211,6 +229,7 @@
211
229
  </NcButton>
212
230
  </form>
213
231
 
232
+ <span class="cn-field-list-widget__sr" aria-live="polite" data-testid="cn-field-list-copied">{{ copied }}</span>
214
233
  <NcNoteCard v-if="notice" :type="noticeType" data-testid="cn-field-list-notice">
215
234
  {{ notice }}
216
235
  </NcNoteCard>
@@ -260,7 +279,7 @@ export default {
260
279
  *
261
280
  * - `field` (required) — the object field holding the array.
262
281
  * - `key` — the item field that identifies an entry (default: the item itself for strings).
263
- * - `row` — `{ title, titleFallback?, avatar?, meta?: [paths|{ path, map?, format? }], pill?: { path, map?, variantMap? }, aside?: { path, format?, prefix?, fallback? }, detail?: { template, when? } }`.
282
+ * - `row` — `{ title, titleFallback?, avatar?, meta?: [paths|{ path, map?, format? }], pill?: { path, map?, variantMap? }, aside?: { path, format?, prefix?, fallback? }, detail?: { template, when?, copy?: [{ path, label }] } }`. `detail.copy` puts a copy button beside the detail line for each named value of the row (an empty value gets none), such as a DNS record's name and value.
264
283
  * - `add` — `{ label?, fields: [{ key, label, type?: 'text'|'select', options?, required?, pattern?, patternMessage? }], unique?, defaults? }`.
265
284
  * - `remove` — `{ label? }`; omitted = no remove.
266
285
  * - `rowActions` — `[{ id, label, when?, placement?: 'band', request: { method?, url, payload? } }]`; tokens `@object.<field>`, `@objectId`, `@row.<field>`.
@@ -316,6 +335,8 @@ export default {
316
335
  busy: '',
317
336
  notice: '',
318
337
  noticeType: 'error',
338
+ // What the last copy button put on the clipboard, said to a screen reader.
339
+ copied: '',
319
340
  }
320
341
  },
321
342
 
@@ -358,6 +379,7 @@ export default {
358
379
  pill: this.pillOf(entry, spec.pill),
359
380
  aside: spec.aside ? this.asideOf(entry, spec.aside) : '',
360
381
  detail: spec.detail && this.matches(entry, spec.detail.when) ? this.fill(spec.detail.template, entry) : '',
382
+ copies: spec.detail && this.matches(entry, spec.detail.when) ? this.copiesOf(entry, spec.detail.copy) : [],
361
383
  actions: this.actionsFor(entry),
362
384
  cells: this.isTable ? this.cellsOf(entry) : [],
363
385
  }
@@ -818,6 +840,40 @@ export default {
818
840
  * @return {Promise<void>}
819
841
  * @spec openspec/changes/abstract-field-list/specs/field-list/spec.md#requirement-a-field-list-row-can-run-a-configured-request
820
842
  */
843
+ /**
844
+ * The copy buttons of a row's detail line: one per named value that is not empty.
845
+ *
846
+ * @param {object} entry The entry.
847
+ * @param {Array<{path: string, label?: string}>|undefined} declared The `detail.copy` list.
848
+ * @return {Array<{path: string, label: string, value: string}>}
849
+ */
850
+ copiesOf(entry, declared) {
851
+ return (Array.isArray(declared) ? declared : [])
852
+ .filter((copy) => copy && typeof copy.path === 'string' && copy.path !== '')
853
+ .map((copy) => ({
854
+ path: copy.path,
855
+ label: this.tr(copy.label || 'Copy'),
856
+ value: String(readPath(entry, copy.path) ?? ''),
857
+ }))
858
+ .filter((copy) => copy.value !== '')
859
+ },
860
+
861
+ /**
862
+ * Put one value on the clipboard and say so; a browser that refuses says that instead.
863
+ *
864
+ * @param {{label: string, value: string}} copy The copy button.
865
+ * @return {Promise<void>}
866
+ */
867
+ async copyValue(copy) {
868
+ this.copied = ''
869
+ try {
870
+ await navigator.clipboard.writeText(copy.value)
871
+ this.copied = `${this.tr('Copied')}: ${copy.label}`
872
+ } catch {
873
+ this.notify(this.tr('Copying did not work. Select the text and copy it yourself.'))
874
+ }
875
+ },
876
+
821
877
  async runAction(action, row) {
822
878
  this.busy = row.key
823
879
  this.notice = ''
@@ -912,6 +968,12 @@ export default {
912
968
 
913
969
  .cn-field-list-widget__meta,
914
970
  .cn-field-list-widget__aside,
971
+ .cn-field-list-widget__copies {
972
+ display: flex;
973
+ flex-wrap: wrap;
974
+ gap: 4px;
975
+ }
976
+
915
977
  .cn-field-list-widget__detail {
916
978
  font-size: 13px;
917
979
  }
@@ -280,7 +280,7 @@ export default {
280
280
  * @return {string} The formatted string.
281
281
  */
282
282
  formatNumber(value) {
283
- return formatMetricValue(value, this.resolvedFormat, this.configCtx)
283
+ return formatMetricValue(value, this.resolvedFormat, this.configCtx, null, this.effectiveTranslate)
284
284
  },
285
285
 
286
286
  /**
@@ -47,8 +47,19 @@
47
47
  actions when it names none): a header action, a built-in by
48
48
  name, or a link (an entry with `href`, the documentation). -->
49
49
  <template v-for="entry in headerMenuEntries(button)" :key="entry.key">
50
+ <!-- A group heading and a separator (`{ heading }` and
51
+ `separator` in items), as the board's "Ga naar" and
52
+ "Selectie" groups. -->
53
+ <NcActionCaption
54
+ v-if="entry.kind === 'heading'"
55
+ class="cn-index-header-menu__heading"
56
+ :name="entry.label"
57
+ :data-testid="`cn-index-header-menu-${entry.key}`" />
58
+ <NcActionSeparator
59
+ v-else-if="entry.kind === 'separator'"
60
+ :data-testid="`cn-index-header-menu-${entry.key}`" />
50
61
  <NcActionLink
51
- v-if="entry.href"
62
+ v-else-if="entry.href"
52
63
  :href="entry.href"
53
64
  target="_blank"
54
65
  rel="noopener noreferrer"
@@ -58,6 +69,17 @@
58
69
  </template>
59
70
  {{ entry.label }}
60
71
  </NcActionLink>
72
+ <NcActionLink
73
+ v-else-if="entry.link"
74
+ :href="entry.link.href"
75
+ :closeAfterClick="true"
76
+ :data-testid="`cn-index-header-menu-${entry.key}`"
77
+ @click="onHeaderMenuLinkClick(entry, $event)">
78
+ <template v-if="entry.icon" #icon>
79
+ <CnIcon :name="entry.icon" :size="20" />
80
+ </template>
81
+ {{ entry.label }}
82
+ </NcActionLink>
61
83
  <NcActionButton
62
84
  v-else
63
85
  :disabled="entry.disabled"
@@ -65,8 +87,12 @@
65
87
  :closeAfterClick="true"
66
88
  :data-testid="`cn-index-header-menu-${entry.key}`"
67
89
  @click="entry.run()">
68
- <template v-if="entry.icon" #icon>
69
- <CnIcon :name="entry.icon" :size="20" />
90
+ <!-- A built-in ships its own icon (`iconComponent`), so it
91
+ never falls back to the "?" when the app has not
92
+ registered that icon name. -->
93
+ <template v-if="entry.iconComponent || entry.icon" #icon>
94
+ <component :is="entry.iconComponent" v-if="entry.iconComponent" :size="20" />
95
+ <CnIcon v-else :name="entry.icon" :size="20" />
70
96
  </template>
71
97
  {{ entry.label }}
72
98
  </NcActionButton>
@@ -1028,7 +1054,7 @@
1028
1054
  <script>
1029
1055
  import { getCurrentUser } from '@nextcloud/auth'
1030
1056
  import { translate as t } from '@nextcloud/l10n'
1031
- import { NcActionButton, NcActionCaption, NcActionCheckbox, NcActionLink, NcActions, NcButton, NcCheckboxRadioSwitch, NcLoadingIcon } from '@nextcloud/vue'
1057
+ import { NcActionButton, NcActionCaption, NcActionCheckbox, NcActionLink, NcActions, NcActionSeparator, NcButton, NcCheckboxRadioSwitch, NcLoadingIcon } from '@nextcloud/vue'
1032
1058
  import { getCurrentInstance, inject, markRaw, ref } from 'vue'
1033
1059
  import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
1034
1060
  import Close from 'vue-material-design-icons/Close.vue'
@@ -1038,6 +1064,8 @@ import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
1038
1064
  import Export from 'vue-material-design-icons/Export.vue'
1039
1065
  import Eye from 'vue-material-design-icons/Eye.vue'
1040
1066
  import FilterOutline from 'vue-material-design-icons/FilterOutline.vue'
1067
+ import ImportIcon from 'vue-material-design-icons/Import.vue'
1068
+ import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
1041
1069
  import Tune from 'vue-material-design-icons/Tune.vue'
1042
1070
  import ViewColumnOutline from 'vue-material-design-icons/ViewColumnOutline.vue'
1043
1071
  import CnConfirmDialog from '../../dialogs/CnConfirmDialog.vue'
@@ -1053,13 +1081,13 @@ import { createRefLabelResolver } from '../../composables/useRefLabels.js'
1053
1081
  import { useSavedViewsApi } from '../../composables/useSavedViewsApi.js'
1054
1082
  import { METADATA_COLUMNS } from '../../constants/metadata.js'
1055
1083
  import { useObjectStore } from '../../store/useObjectStore.js'
1056
- import { routeHref } from '../../utils/actionLink.js'
1084
+ import { followItemActionLink, resolveItemActionLink, routeHref } from '../../utils/actionLink.js'
1057
1085
  import { buildOnSuccessRoute, resolveRegisteredHandler } from '../../utils/actionsDispatcher.js'
1058
1086
  import { reportBindingProblems } from '../../utils/diagnostics.js'
1059
1087
  import { fetchFilterCounts } from '../../utils/fetchFilterCounts.js'
1060
1088
  import { buildExportUrl } from '../../utils/indexExportHelpers.js'
1061
1089
  import { lensUnavailableText } from '../../utils/lensAvailability.js'
1062
- import { openRowTarget } from '../../utils/linkNavigation.js'
1090
+ import { isModifiedClick, openRowTarget } from '../../utils/linkNavigation.js'
1063
1091
  import { resolveClaimedTeams, resolveClaimTokens, splitViewsIntoTabs, viewAsTab, viewIdOf } from '../../utils/listLenses.js'
1064
1092
  import { LIST_SHORTCUTS, listPaletteCommands, shortcutFor } from '../../utils/listShortcuts.js'
1065
1093
  import { multiKeySort } from '../../utils/multiKeySort.js'
@@ -1352,6 +1380,7 @@ export default {
1352
1380
  NcActionLink,
1353
1381
  NcActionButton,
1354
1382
  NcActionCaption,
1383
+ NcActionSeparator,
1355
1384
  NcActionCheckbox,
1356
1385
  NcButton,
1357
1386
  AlertCircleOutline,
@@ -1631,9 +1660,11 @@ export default {
1631
1660
  * actions, or what its `items` name (`header-actions`, a header action
1632
1661
  * id, `import`, `export`, `refresh`, `copy-selected`, `delete-selected`,
1633
1662
  * `documentation`), so the entries the bar's menu loses have a place.
1663
+ * `items` can group them: `{ heading }` draws a group heading (an i18n
1664
+ * key) and `separator` a rule, each only when an entry follows.
1634
1665
  *
1635
1666
  * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-an-index-page-can-take-the-board-header
1636
- * @type {Array<{label?: string, action: string, variant?: ('primary'|'secondary'), icon?: string, format?: ('csv'|'excel'), id?: string, items?: string[]}>}
1667
+ * @type {Array<{label?: string, action: string, variant?: ('primary'|'secondary'), icon?: string, format?: ('csv'|'excel'), id?: string, items?: Array<(string|{heading: string})>}>}
1637
1668
  */
1638
1669
  headerButtons: {
1639
1670
  type: Array,
@@ -5560,7 +5591,10 @@ export default {
5560
5591
  variant: button.variant === 'primary' ? 'primary' : 'secondary',
5561
5592
  icon,
5562
5593
  format: button.format === 'excel' ? 'excel' : 'csv',
5563
- items: Array.isArray(button.items) ? button.items.filter((item) => typeof item === 'string' && item !== '') : null,
5594
+ items: Array.isArray(button.items)
5595
+ ? button.items.filter((item) => (typeof item === 'string' && item !== '')
5596
+ || (item && typeof item === 'object' && typeof item.heading === 'string' && item.heading !== ''))
5597
+ : null,
5564
5598
  }
5565
5599
  })
5566
5600
  .filter((button) => button.label !== '')
@@ -6855,11 +6889,14 @@ export default {
6855
6889
  * `import`, `export`, `refresh`, `copy-selected`, `delete-selected`
6856
6890
  * (disabled until a row is selected) and `documentation` (a link to
6857
6891
  * `documentationUrl`, left out when the page has none). An unknown name
6858
- * is left out.
6892
+ * is left out. `{ heading }` and `separator` group the entries: an
6893
+ * entry of `kind` `heading` or `separator`, drawn only when an entry
6894
+ * follows it.
6859
6895
  *
6860
6896
  * @spec openspec/changes/screens-index-header-actions-menu/specs/index-header/spec.md#requirement-the-header-actions-menu-lists-what-its-items-name
6861
- * @param {{items?: string[]|null, format?: string}} button The resolved header button.
6862
- * @return {Array<{key: string, label: string, icon: string, href?: string, disabled?: boolean, title?: string, run?: Function}>}
6897
+ * @spec openspec/changes/screens-index-header-menu-headings/specs/index-header/spec.md#requirement-the-header-actions-menu-can-draw-group-headings-and-separators
6898
+ * @param {{items?: Array<(string|{heading: string})>|null, format?: string}} button The resolved header button.
6899
+ * @return {Array<{key: string, label: string, kind?: ('heading'|'separator'), icon?: string, href?: string, disabled?: boolean, title?: string, iconComponent?: object, run?: Function}>}
6863
6900
  */
6864
6901
  headerMenuEntries(button) {
6865
6902
  const fromAction = (entry) => ({
@@ -6867,14 +6904,18 @@ export default {
6867
6904
  label: entry.label ? this.cnTranslate(entry.label) : '',
6868
6905
  icon: entry.icon && typeof entry.icon === 'string' && !entry.icon.startsWith('icon-') ? entry.icon : '',
6869
6906
  href: typeof entry.href === 'string' && entry.href !== '' ? entry.href : '',
6907
+ // A `handler: "navigate"` entry whose route resolves carries `to`
6908
+ // (resolveHeaderHandler drops its handler): the menu draws it as
6909
+ // an in-app link, as the actions bar does, so it navigates.
6910
+ link: !entry.href && entry.to && !entry.disabled ? resolveItemActionLink({ to: entry.to }, null, this.$router) : null,
6870
6911
  disabled: Boolean(entry.disabled),
6871
6912
  run: () => this.onHeaderAction({ action: entry.id, id: entry.id }),
6872
6913
  })
6873
6914
  const names = button && Array.isArray(button.items) ? button.items : ['header-actions']
6874
6915
  const noSelection = this.internalSelectedIds.length < 1
6875
6916
  const builtIns = {
6876
- import: () => ({ key: 'import', label: t('nextcloud-vue', 'Import'), icon: 'Import', run: () => { this.showImportDialog = true } }),
6877
- export: () => ({ key: 'export', label: t('nextcloud-vue', 'Export'), icon: 'Export', run: () => this.onHeaderButton({ action: 'export', format: button.format }) }),
6917
+ import: () => ({ key: 'import', label: t('nextcloud-vue', 'Import'), icon: 'Import', iconComponent: ImportIcon, run: () => { this.showImportDialog = true } }),
6918
+ export: () => ({ key: 'export', label: t('nextcloud-vue', 'Export'), icon: 'Export', iconComponent: Export, run: () => this.onHeaderButton({ action: 'export', format: button.format }) }),
6878
6919
  refresh: () => ({ key: 'refresh', label: t('nextcloud-vue', 'Refresh'), icon: 'Refresh', run: () => this.onRefreshEvent() }),
6879
6920
  'copy-selected': () => ({
6880
6921
  key: 'copy-selected',
@@ -6889,6 +6930,7 @@ export default {
6889
6930
  key: 'delete-selected',
6890
6931
  label: t('nextcloud-vue', 'Delete selected'),
6891
6932
  icon: 'TrashCanOutline',
6933
+ iconComponent: TrashCanOutline,
6892
6934
  disabled: noSelection,
6893
6935
  title: noSelection ? t('nextcloud-vue', 'Select 1 or more items to delete') : '',
6894
6936
  run: () => { this.showMassDeleteDialog = true },
@@ -6906,8 +6948,17 @@ export default {
6906
6948
  entries.push(entry)
6907
6949
  }
6908
6950
  }
6951
+ // A group heading (`{ heading }`) or a `separator` is kept as a mark
6952
+ // and only drawn when an entry follows it (see groupMenuEntries).
6953
+ let mark = 0
6909
6954
  for (const name of names) {
6910
- if (name === 'header-actions') {
6955
+ if (name && typeof name === 'object') {
6956
+ mark += 1
6957
+ entries.push({ key: `__heading-${mark}`, kind: 'heading', label: this.cnTranslate(name.heading) })
6958
+ } else if (name === 'separator') {
6959
+ mark += 1
6960
+ entries.push({ key: `__separator-${mark}`, kind: 'separator', label: '' })
6961
+ } else if (name === 'header-actions') {
6911
6962
  this.mergedHeaderActions.forEach((entry) => push(fromAction(entry)))
6912
6963
  } else if (Object.hasOwn(builtIns, name)) {
6913
6964
  push(builtIns[name]())
@@ -6918,7 +6969,66 @@ export default {
6918
6969
  }
6919
6970
  }
6920
6971
  }
6921
- return entries
6972
+ return this.groupMenuEntries(entries)
6973
+ },
6974
+
6975
+ /**
6976
+ * Tidy the marks of the header's Actions menu: a heading or separator
6977
+ * with no entry after it (before the next mark or the end) is dropped,
6978
+ * a separator never opens the menu, two separators in a row are one,
6979
+ * and a heading followed by a separator before any entry is dropped. So a group whose entries are all left out (no documentation
6980
+ * URL, an unknown id) leaves no empty heading or double rule behind,
6981
+ * and a menu of marks only has no entries and is not drawn.
6982
+ *
6983
+ * @spec openspec/changes/screens-index-header-menu-headings/specs/index-header/spec.md#requirement-the-header-actions-menu-can-draw-group-headings-and-separators
6984
+ * @param {Array<{key: string, kind?: string}>} entries The entries with their marks, in order.
6985
+ * @return {Array<{key: string, kind?: string}>} The entries to draw.
6986
+ */
6987
+ groupMenuEntries(entries) {
6988
+ const out = []
6989
+ let separator = null
6990
+ let heading = null
6991
+ for (const entry of entries) {
6992
+ if (entry.kind === 'separator') {
6993
+ // A rule ends the group before it: a heading not yet followed
6994
+ // by an entry is dropped with it.
6995
+ separator = entry
6996
+ heading = null
6997
+ continue
6998
+ }
6999
+ if (entry.kind === 'heading') {
7000
+ heading = entry
7001
+ continue
7002
+ }
7003
+ if (separator && out.length > 0) {
7004
+ out.push(separator)
7005
+ }
7006
+ if (heading) {
7007
+ out.push(heading)
7008
+ }
7009
+ separator = null
7010
+ heading = null
7011
+ out.push(entry)
7012
+ }
7013
+ return out
7014
+ },
7015
+
7016
+ /**
7017
+ * An in-app link of the header's Actions menu was clicked: a plain click
7018
+ * goes through the router (a modified click is left to the browser),
7019
+ * and the entry is reported as a header action, as the actions bar does.
7020
+ *
7021
+ * @spec openspec/changes/screens-index-header-actions-menu/specs/index-header/spec.md#requirement-the-header-actions-menu-lists-what-its-items-name
7022
+ * @param {{key: string, link: object}} entry The menu entry.
7023
+ * @param {MouseEvent} event The click.
7024
+ * @return {void}
7025
+ */
7026
+ onHeaderMenuLinkClick(entry, event) {
7027
+ if (isModifiedClick(event)) {
7028
+ return
7029
+ }
7030
+ followItemActionLink(event, entry.link, this.$router)
7031
+ this.$emit('header-action', { action: entry.key, id: entry.key })
6922
7032
  },
6923
7033
 
6924
7034
  /**