@conduction/nextcloud-vue 2.83.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 (136) hide show
  1. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
  2. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +1 -1
  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/CnBodySections/CnBodySections.vue.js +1 -1
  6. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js +22 -1
  7. package/dist/esm/components/CnBodySections/CnBodySections.vue2.js.map +1 -1
  8. package/dist/esm/components/CnBodySections/CnBodySections.vue3.js.map +1 -1
  9. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
  10. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +47 -4
  11. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
  12. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue.js +1 -1
  14. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +125 -3
  15. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +3 -3
  17. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +5 -0
  20. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  22. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue.js +1 -1
  23. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js +1 -1
  24. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue3.js.map +1 -1
  26. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +203 -4
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  30. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +38 -1
  31. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +45 -5
  33. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  34. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue.js +1 -1
  35. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js +1 -1
  36. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js.map +1 -1
  37. package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue3.js.map +1 -1
  38. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js +29 -6
  39. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js.map +1 -1
  40. package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue3.js.map +1 -1
  41. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
  42. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +31 -2
  43. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  45. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  46. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +37 -2
  47. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  48. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  49. package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
  50. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +135 -2
  51. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  52. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +95 -43
  53. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  54. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue.js +1 -1
  55. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +72 -5
  56. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  57. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +33 -8
  58. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  59. package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
  60. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +1 -1
  61. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  62. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  63. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue.js +1 -1
  64. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +17 -2
  65. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  66. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +1 -1
  67. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  68. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  69. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -3
  70. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  71. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  72. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue.js +1 -1
  73. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +16 -0
  74. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  75. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  76. package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
  77. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +68 -6
  78. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
  79. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +133 -105
  80. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
  81. package/dist/esm/l10n/en.json.js +16 -1
  82. package/dist/esm/l10n/en.json.js.map +1 -1
  83. package/dist/esm/l10n/nl.json.js +38 -1
  84. package/dist/esm/l10n/nl.json.js.map +1 -1
  85. package/dist/esm/library-style.js +1 -1
  86. package/dist/esm/nextcloud-vue.css +448 -296
  87. package/dist/esm/utils/buildManifest.js +37 -3
  88. package/dist/esm/utils/buildManifest.js.map +1 -1
  89. package/dist/esm/utils/detailWidgetVisibility.js +149 -0
  90. package/dist/esm/utils/detailWidgetVisibility.js.map +1 -0
  91. package/dist/esm/utils/formatMetric.js +39 -3
  92. package/dist/esm/utils/formatMetric.js.map +1 -1
  93. package/dist/esm/utils/resolveManifestTokens.js +1 -0
  94. package/dist/esm/utils/resolveManifestTokens.js.map +1 -1
  95. package/dist/esm/utils/sentinelTokens.js +9 -0
  96. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  97. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  98. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  99. package/dist/nextcloud-vue.cjs.js +1706 -454
  100. package/dist/nextcloud-vue.cjs.js.map +2 -2
  101. package/dist/nextcloud-vue.css +448 -296
  102. package/l10n/en.json +16 -1
  103. package/l10n/nl.json +38 -1
  104. package/package.json +1 -1
  105. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +1 -1
  106. package/src/components/CnBodySections/CnBodySections.vue +22 -1
  107. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +47 -4
  108. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +128 -6
  109. package/src/components/CnDashboardPage/CnDashboardPage.vue +5 -0
  110. package/src/components/CnDeltaWidget/CnDeltaWidget.vue +1 -1
  111. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  112. package/src/components/CnDetailPage/CnDetailPage.vue +203 -4
  113. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +63 -1
  114. package/src/components/CnGaugeWidget/CnGaugeWidget.vue +1 -1
  115. package/src/components/CnInteractionFormWidget/CnInteractionFormWidget.vue +29 -6
  116. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  117. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +31 -2
  118. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +37 -2
  119. package/src/components/CnReportsPage/CnReportsPage.vue +179 -1
  120. package/src/components/CnStagesWidget/CnStagesWidget.vue +103 -6
  121. package/src/components/CnStatWidget/CnStatWidget.vue +1 -1
  122. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +34 -3
  123. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -3
  124. package/src/components/CnWalkthrough/CnWalkthrough.vue +16 -0
  125. package/src/css/form-field.css +14 -0
  126. package/src/css/form-widget.css +11 -0
  127. package/src/css/look-board-reports.css +58 -0
  128. package/src/css/look-board.css +12 -0
  129. package/src/dialogs/CnTransitionInputDialog.vue +87 -7
  130. package/src/schemas/app-manifest-v2.schema.json +23 -2
  131. package/src/utils/buildManifest.js +36 -2
  132. package/src/utils/detailWidgetVisibility.js +145 -0
  133. package/src/utils/formatMetric.js +38 -2
  134. package/src/utils/resolveManifestTokens.js +1 -0
  135. package/src/utils/sentinelTokens.js +9 -0
  136. 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
  /**
@@ -183,7 +183,7 @@ export default {
183
183
  /** Channel options `{ value, label }`. */
184
184
  channelOptions() {
185
185
  return Array.isArray(this.content.channels) && this.content.channels.length > 0
186
- ? this.content.channels
186
+ ? this.content.channels.map((channel) => ({ ...channel, label: this.copy(channel && channel.label) }))
187
187
  : [
188
188
  { value: 'telefoon', label: t('nextcloud-vue', 'Phone') },
189
189
  { value: 'email', label: t('nextcloud-vue', 'Email') },
@@ -204,10 +204,11 @@ export default {
204
204
  const prop = properties[this.content.outcomeField || 'outcome']
205
205
  if (prop && Array.isArray(prop.enum) && prop.enum.length > 0) {
206
206
  const labels = prop['x-enum-labels'] || prop.enumLabels || {}
207
- const tr = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
208
- return prop.enum.map((value) => ({ value, label: tr(labels[value] || String(value)) }))
207
+ return prop.enum.map((value) => ({ value, label: this.copy(labels[value] || String(value)) }))
209
208
  }
210
- return Array.isArray(this.content.outcomes) ? this.content.outcomes : []
209
+ return Array.isArray(this.content.outcomes)
210
+ ? this.content.outcomes.map((outcome) => ({ ...outcome, label: this.copy(outcome && outcome.label) }))
211
+ : []
211
212
  },
212
213
 
213
214
  /**
@@ -280,8 +281,7 @@ export default {
280
281
  // `content.submitLabel` names what the form creates, e.g. "Save contact moment".
281
282
  registerLabel() {
282
283
  if (this.content.submitLabel) {
283
- const tr = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
284
- return tr(this.content.submitLabel)
284
+ return this.copy(this.content.submitLabel)
285
285
  }
286
286
  return t('nextcloud-vue', 'Register')
287
287
  },
@@ -359,6 +359,29 @@ export default {
359
359
  },
360
360
 
361
361
  /** The default channel value (first configured channel, else `telefoon`). */
362
+ /**
363
+ * A label the manifest supplies (a channel, an outcome, the submit
364
+ * label), translated: the app's own translation first, since the words
365
+ * are the app's, then the library's catalogue, then the text as
366
+ * written. Before, a channel label was shown as written and the submit
367
+ * label only reached the app's translate, so pipelinq showed "Phone"
368
+ * and "Save contact moment" in English to a Dutch reader.
369
+ *
370
+ * @spec openspec/changes/interaction-form-fits-and-reads-dutch/specs/interaction-form-widget/spec.md#requirement-the-interaction-form-fits-its-card-and-reads-in-the-readers-language
371
+ * @param {string} key The label as the manifest writes it.
372
+ * @return {string} The translated label.
373
+ */
374
+ copy(key) {
375
+ if (typeof key !== 'string' || key === '') {
376
+ return key
377
+ }
378
+ const host = typeof this.cnTranslate === 'function' ? this.cnTranslate(key) : key
379
+ if (typeof host === 'string' && host !== '' && host !== key) {
380
+ return host
381
+ }
382
+ return t('nextcloud-vue', key)
383
+ },
384
+
362
385
  firstChannel() {
363
386
  const ch = (this.content && this.content.channels) || []
364
387
  return (Array.isArray(ch) && ch.length > 0 && ch[0].value) || 'telefoon'
@@ -121,7 +121,7 @@ export default {
121
121
  | `icon` | Object\|Function | `null` | MDI icon component shown in the card header |
122
122
  | `objectType` | String | `''` | Registered object type slug used by `objectStore.saveObject()` |
123
123
  | `store` | Object | `null` | Explicit Pinia objectStore instance; auto-detected via Pinia when omitted |
124
- | `overrides` | Object | `{}` | Per-property config: `{ key: { order, gridColumn, gridRow, hidden, editable, label, widget } }` |
124
+ | `overrides` | Object | `{}` | Per-property config: `{ key: { order, gridColumn, gridRow, hidden, editable, label, widget, visibleWhen } }`. `visibleWhen` shows the field only when the shared visibility predicate holds for this object, in its local mode: `{ field, op, value }` (ops `eq`, `neq`, `in`, `empty`, ...), or `all` / `any` of those. It reads unsaved changes too. A rule that needs a request (`endpoint`, `source`) is ignored. Read grid only: the Edit form still offers the field. |
125
125
  | `columns` | Number | `3` | Number of CSS grid columns in the widget |
126
126
  | `collapsedFields` (`collapsed-fields`) | Number | `null` | Number of fields shown while collapsed, with a "Show all N fields" button for the rest. `null` fits the cell: the whole rows that fit stay, and the button only appears when the cell overflows |
127
127
  | `hideEmpty` | Boolean | `false` | Hide fields with no value instead of rendering an em dash. For discriminated supertypes (one `ticket` schema holding request/complaint/contactmoment), so the grid is type-aware without the schema enumerating which fields belong to which variant. Read grid only — the field being edited, a field with unsaved changes, and the Edit form stay visible; `false`/`0` are values and are never hidden. |
@@ -418,6 +418,7 @@ import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
418
418
  import { fieldsFromSchema, formatValue } from '../../utils/schema.js'
419
419
  import { schemaRefSlug } from '../../utils/schemaRefSlug.js'
420
420
  import { resolveNextcloudUser, searchNextcloudUsers } from '../../utils/userAutocomplete.js'
421
+ import { evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from '../../utils/visibleWhen.js'
421
422
  import { CnIcon } from '../CnIcon/index.js'
422
423
  import { CnObjectMetadataModal } from '../CnObjectMetadataModal/index.js'
423
424
  import { CnWidgetWrapper } from '../CnWidgetWrapper/index.js'
@@ -939,13 +940,18 @@ export default {
939
940
  gridRow: (this.resolvedOverrides[field.key] && this.resolvedOverrides[field.key].gridRow) || 1,
940
941
  }))
941
942
 
943
+ // Per-field condition: `overrides[key].visibleWhen` shows a field only
944
+ // for the objects it belongs to, such as a person's preferred name on
945
+ // a client that can also be an organisation.
946
+ const shown = withSpans.filter((field) => this.isVisibleByCondition(field))
947
+
942
948
  if (!this.hideEmpty) {
943
- return withSpans
949
+ return shown
944
950
  }
945
951
 
946
952
  // Keep a field the user is currently editing or has pending changes for,
947
953
  // otherwise it would vanish mid-edit the moment its value is cleared.
948
- return withSpans.filter((field) => field.key === this.editingField
954
+ return shown.filter((field) => field.key === this.editingField
949
955
  || field.key in this.dirtyFields
950
956
  || !this.isEmptyValue((this.objectData || {})[field.key]))
951
957
  },
@@ -1709,6 +1715,29 @@ export default {
1709
1715
  return !field.readOnly
1710
1716
  },
1711
1717
 
1718
+ /**
1719
+ * Evaluate a field's `visibleWhen` override against the object.
1720
+ *
1721
+ * The rule is the shared visibility predicate in its LOCAL mode
1722
+ * (`{ field, op, value }`, `all` / `any`, `appInstalled`), evaluated by
1723
+ * `evaluateVisibleWhenLocal` against the object with its unsaved
1724
+ * changes, so a field appears or goes the moment the field it depends
1725
+ * on changes. A rule that needs a request (`endpoint`, `source`) cannot
1726
+ * be decided here and is ignored: the field shows, as it did before
1727
+ * the rule existed.
1728
+ *
1729
+ * @param {object} field - Resolved field definition.
1730
+ * @return {boolean} True when the field is shown.
1731
+ */
1732
+ isVisibleByCondition(field) {
1733
+ const override = this.resolvedOverrides[field.key]
1734
+ const rule = override ? override.visibleWhen : undefined
1735
+ if (rule === undefined || rule === null || !isLocallyDecidableVisibleWhen(rule)) {
1736
+ return true
1737
+ }
1738
+ return evaluateVisibleWhenLocal(rule, { ...(this.objectData || {}), ...this.dirtyFields })
1739
+ },
1740
+
1712
1741
  /**
1713
1742
  * Evaluate a field's conditional read-only rule against the object data.
1714
1743
  *
@@ -212,7 +212,9 @@ export default {
212
212
  /**
213
213
  * The list config: `{ register, schema, filter?, sort?, limit?, extend?,
214
214
  * presentation?, title?, meta?, text?, label?, pill?, trailing?, icon?,
215
- * rowRoute?, rowIdField?, headerLink?, headerNote?, emptyText?, errorText? }`.
215
+ * rowRoute?, rowIdField?, headerLink?, headerNote?, emptyText?, errorText?,
216
+ * valueLabels? }`. `valueLabels` maps a field's stored values to the labels
217
+ * the templates show.
216
218
  *
217
219
  * @type {object}
218
220
  */
@@ -441,7 +443,40 @@ export default {
441
443
  * @return {string} The text, '' when a token is empty.
442
444
  */
443
445
  fill(template, row) {
444
- return typeof template === 'string' && template !== '' ? fillTemplate(this.translate(template), row, new Date(), this.templateContext) : ''
446
+ return typeof template === 'string' && template !== '' ? fillTemplate(this.translate(template), this.labelledRow(row), new Date(), this.templateContext) : ''
447
+ },
448
+
449
+ /**
450
+ * The row as the templates read it: a field named in `valueLabels`
451
+ * (`{ channel: { phone: "Phone", telefoon: "Phone" } }`) shows the
452
+ * translated label of its stored value, so "{channel}" reads "Telefoon"
453
+ * for both spellings the data holds. A value without a label stays as
454
+ * stored. The pill, the badges and the sort read the stored value.
455
+ *
456
+ * @param {object} row The row.
457
+ * @return {object} The row, or a shallow copy with labelled fields.
458
+ * @spec openspec/changes/screens-record-list-value-labels/specs/record-list-value-labels/spec.md#requirement-a-record-list-template-can-show-a-label-for-a-stored-value
459
+ */
460
+ labelledRow(row) {
461
+ const map = this.content?.valueLabels
462
+ if (!row || typeof row !== 'object' || !map || typeof map !== 'object') {
463
+ return row
464
+ }
465
+ let out = row
466
+ for (const [field, labels] of Object.entries(map)) {
467
+ const raw = row[field]
468
+ if (!labels || typeof labels !== 'object' || (typeof raw !== 'string' && typeof raw !== 'number')) {
469
+ continue
470
+ }
471
+ const label = labels[String(raw)]
472
+ if (typeof label === 'string' && label !== '') {
473
+ if (out === row) {
474
+ out = { ...row }
475
+ }
476
+ out[field] = this.translate(label)
477
+ }
478
+ }
479
+ return out
445
480
  },
446
481
 
447
482
  /**