@conduction/nextcloud-vue 2.56.0-beta.34 → 2.56.0-beta.36

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 (155) 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 +239 -3
  15. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +5 -5
  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 +126 -2
  43. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js +24 -2
  45. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  46. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  47. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +37 -2
  48. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  49. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  50. package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
  51. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +135 -2
  52. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  53. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +95 -43
  54. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  55. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue.js +1 -1
  56. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +72 -5
  57. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  58. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +33 -8
  59. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  60. package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
  61. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +1 -1
  62. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  64. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue.js +1 -1
  65. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +17 -2
  66. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  67. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +1 -1
  68. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  69. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  70. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -3
  71. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  72. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  73. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue.js +1 -1
  74. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +16 -0
  75. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  76. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  77. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue.js +1 -1
  78. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js +2 -1
  79. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js.map +1 -1
  80. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js.map +1 -1
  81. package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
  82. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +68 -6
  83. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
  84. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +133 -105
  85. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
  86. package/dist/esm/index.js +1 -0
  87. package/dist/esm/index.js.map +1 -1
  88. package/dist/esm/l10n/en.json.js +16 -1
  89. package/dist/esm/l10n/en.json.js.map +1 -1
  90. package/dist/esm/l10n/nl.json.js +38 -1
  91. package/dist/esm/l10n/nl.json.js.map +1 -1
  92. package/dist/esm/library-style.js +1 -1
  93. package/dist/esm/nextcloud-vue.css +525 -318
  94. package/dist/esm/utils/buildManifest.js +37 -3
  95. package/dist/esm/utils/buildManifest.js.map +1 -1
  96. package/dist/esm/utils/detailWidgetVisibility.js +149 -0
  97. package/dist/esm/utils/detailWidgetVisibility.js.map +1 -0
  98. package/dist/esm/utils/formatMetric.js +39 -3
  99. package/dist/esm/utils/formatMetric.js.map +1 -1
  100. package/dist/esm/utils/gridStack.js +40 -1
  101. package/dist/esm/utils/gridStack.js.map +1 -1
  102. package/dist/esm/utils/humaniseKey.js +41 -0
  103. package/dist/esm/utils/humaniseKey.js.map +1 -0
  104. package/dist/esm/utils/notificationRuleLabel.js +6 -23
  105. package/dist/esm/utils/notificationRuleLabel.js.map +1 -1
  106. package/dist/esm/utils/resolveManifestTokens.js +1 -0
  107. package/dist/esm/utils/resolveManifestTokens.js.map +1 -1
  108. package/dist/esm/utils/sentinelTokens.js +9 -0
  109. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  110. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  111. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  112. package/dist/nextcloud-vue.cjs.js +2250 -708
  113. package/dist/nextcloud-vue.cjs.js.map +2 -2
  114. package/dist/nextcloud-vue.css +525 -318
  115. package/l10n/en.json +16 -1
  116. package/l10n/nl.json +38 -1
  117. package/package.json +1 -1
  118. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +1 -1
  119. package/src/components/CnBodySections/CnBodySections.vue +22 -1
  120. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +47 -4
  121. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +244 -8
  122. package/src/components/CnDashboardPage/CnDashboardPage.vue +5 -0
  123. package/src/components/CnDeltaWidget/CnDeltaWidget.vue +1 -1
  124. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  125. package/src/components/CnDetailPage/CnDetailPage.vue +203 -4
  126. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +63 -1
  127. package/src/components/CnGaugeWidget/CnGaugeWidget.vue +1 -1
  128. package/src/components/CnInteractionFormWidget/CnInteractionFormWidget.vue +29 -6
  129. package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
  130. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +154 -5
  131. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +37 -2
  132. package/src/components/CnReportsPage/CnReportsPage.vue +179 -1
  133. package/src/components/CnStagesWidget/CnStagesWidget.vue +103 -6
  134. package/src/components/CnStatWidget/CnStatWidget.vue +1 -1
  135. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +34 -3
  136. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -3
  137. package/src/components/CnWalkthrough/CnWalkthrough.vue +16 -0
  138. package/src/components/CnWeekStripWidget/CnWeekStripWidget.vue +2 -1
  139. package/src/css/form-field.css +14 -0
  140. package/src/css/form-widget.css +11 -0
  141. package/src/css/kpi-card.css +13 -0
  142. package/src/css/look-board-reports.css +58 -0
  143. package/src/css/look-board.css +31 -0
  144. package/src/dialogs/CnTransitionInputDialog.vue +87 -7
  145. package/src/index.js +2 -0
  146. package/src/schemas/app-manifest-v2.schema.json +23 -2
  147. package/src/utils/buildManifest.js +36 -2
  148. package/src/utils/detailWidgetVisibility.js +145 -0
  149. package/src/utils/formatMetric.js +38 -2
  150. package/src/utils/gridStack.js +39 -0
  151. package/src/utils/humaniseKey.js +38 -0
  152. package/src/utils/notificationRuleLabel.js +5 -23
  153. package/src/utils/resolveManifestTokens.js +1 -0
  154. package/src/utils/sentinelTokens.js +9 -0
  155. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -339,16 +339,30 @@
339
339
  {{ stringifyCell(v) }}
340
340
  </span>
341
341
  </div>
342
- <!-- Single plain OBJECT → key: value definition list. -->
342
+ <!-- Single plain OBJECT → key: value definition list; a
343
+ nested plain object (e.g. a per-language map) gets
344
+ its own list one level down. -->
343
345
  <dl
344
346
  v-else-if="fieldValueKind(field) === 'object'"
345
347
  class="cn-object-data-widget__deflist">
346
348
  <template v-for="(pair, pi) in objectEntries(rawOf(field))" :key="pi">
347
349
  <dt>
348
- {{ pair[0] }}
350
+ {{ nestedLabel(field, [pair[0]]) }}
349
351
  </dt>
350
352
  <dd>
351
- {{ stringifyCell(pair[1]) }}
353
+ <dl
354
+ v-if="isPlainObject(pair[1])"
355
+ class="cn-object-data-widget__deflist cn-object-data-widget__deflist--nested">
356
+ <template v-for="(sub, si) in objectEntries(pair[1])" :key="si">
357
+ <dt>
358
+ {{ nestedLabel(field, [pair[0], sub[0]]) }}
359
+ </dt>
360
+ <dd>
361
+ <span class="cn-object-data-widget__deflist-text">{{ stringifyNested(sub[1]) }}</span>
362
+ </dd>
363
+ </template>
364
+ </dl>
365
+ <span v-else class="cn-object-data-widget__deflist-text">{{ stringifyNested(pair[1]) }}</span>
352
366
  </dd>
353
367
  </template>
354
368
  </dl>
@@ -413,15 +427,20 @@ import CnFormDialog from '../CnFormDialog/CnFormDialog.vue'
413
427
  import { normalizeLook } from '../../composables/useLook.js'
414
428
  import { useObjectStore } from '../../store/index.js'
415
429
  import { groupDisplayName, loadGroupDisplayName, searchNextcloudGroups } from '../../utils/groupAutocomplete.js'
430
+ import { humaniseKey } from '../../utils/humaniseKey.js'
416
431
  import { PANEL_ACTION_SINK } from '../../utils/panelActions.js'
417
432
  import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
418
433
  import { fieldsFromSchema, formatValue } from '../../utils/schema.js'
419
434
  import { schemaRefSlug } from '../../utils/schemaRefSlug.js'
420
435
  import { resolveNextcloudUser, searchNextcloudUsers } from '../../utils/userAutocomplete.js'
436
+ import { evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from '../../utils/visibleWhen.js'
421
437
  import { CnIcon } from '../CnIcon/index.js'
422
438
  import { CnObjectMetadataModal } from '../CnObjectMetadataModal/index.js'
423
439
  import { CnWidgetWrapper } from '../CnWidgetWrapper/index.js'
424
440
 
441
+ // Widgets whose value the inline text fallback would corrupt.
442
+ const STRUCTURED_WIDGETS = ['json', 'code', 'file', 'sub-objects', 'child-records']
443
+
425
444
  /**
426
445
  * CnObjectDataWidget — Schema-driven editable data grid widget.
427
446
  *
@@ -939,13 +958,18 @@ export default {
939
958
  gridRow: (this.resolvedOverrides[field.key] && this.resolvedOverrides[field.key].gridRow) || 1,
940
959
  }))
941
960
 
961
+ // Per-field condition: `overrides[key].visibleWhen` shows a field only
962
+ // for the objects it belongs to, such as a person's preferred name on
963
+ // a client that can also be an organisation.
964
+ const shown = withSpans.filter((field) => this.isVisibleByCondition(field))
965
+
942
966
  if (!this.hideEmpty) {
943
- return withSpans
967
+ return shown
944
968
  }
945
969
 
946
970
  // Keep a field the user is currently editing or has pending changes for,
947
971
  // otherwise it would vanish mid-edit the moment its value is cleared.
948
- return withSpans.filter((field) => field.key === this.editingField
972
+ return shown.filter((field) => field.key === this.editingField
949
973
  || field.key in this.dirtyFields
950
974
  || !this.isEmptyValue((this.objectData || {})[field.key]))
951
975
  },
@@ -1357,6 +1381,59 @@ export default {
1357
1381
  return (raw && typeof raw === 'object') ? Object.entries(raw) : []
1358
1382
  },
1359
1383
 
1384
+ /**
1385
+ * Whether a value is a plain object (not an array, not null).
1386
+ *
1387
+ * @param {unknown} v The value.
1388
+ * @return {boolean} True for a plain object.
1389
+ */
1390
+ isPlainObject(v) {
1391
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
1392
+ },
1393
+
1394
+ /**
1395
+ * Label for a key inside an object value: the sub-schema's `title`
1396
+ * (translated) when the schema describes it, else the key, made
1397
+ * readable when it is identifier-shaped (`shortDescription` → "Short
1398
+ * description"; `nl` stays `nl`).
1399
+ *
1400
+ * @param {object} field Resolved field definition.
1401
+ * @param {string[]} path Keys from the field's value down to this entry.
1402
+ * @return {string} The label.
1403
+ * @spec openspec/changes/data-widget-nested-objects/specs/data-widget-nested-objects/spec.md#requirement-a-nested-object-reads-as-a-list-per-key
1404
+ */
1405
+ nestedLabel(field, path) {
1406
+ let prop = ((this.schema && this.schema.properties) || {})[field.key]
1407
+ for (const key of path) {
1408
+ if (!this.isPlainObject(prop)) {
1409
+ prop = null
1410
+ break
1411
+ }
1412
+ prop = (prop.properties && prop.properties[key])
1413
+ || (this.isPlainObject(prop.additionalProperties) ? prop.additionalProperties : null)
1414
+ }
1415
+ if (prop && typeof prop.title === 'string' && prop.title) {
1416
+ return this.cnTranslate(prop.title)
1417
+ }
1418
+ const key = String(path[path.length - 1])
1419
+ return /[A-Z_-]/.test(key) ? humaniseKey(key) : key
1420
+ },
1421
+
1422
+ /**
1423
+ * Stringify a value inside a definition list: a list of scalars reads
1424
+ * as a comma-separated line, anything else as `stringifyCell`.
1425
+ *
1426
+ * @param {unknown} v The value.
1427
+ * @return {string} The display string.
1428
+ * @spec openspec/changes/data-widget-nested-objects/specs/data-widget-nested-objects/spec.md#requirement-a-nested-object-reads-as-a-list-per-key
1429
+ */
1430
+ stringifyNested(v) {
1431
+ if (Array.isArray(v) && v.length && v.every((item) => item === null || typeof item !== 'object')) {
1432
+ return v.map((item) => this.stringifyCell(item)).join(', ')
1433
+ }
1434
+ return this.stringifyCell(v)
1435
+ },
1436
+
1360
1437
  /**
1361
1438
  * Stringify a scalar cell value; a nested object/array collapses to
1362
1439
  * compact JSON (never "[object Object]").
@@ -1684,15 +1761,53 @@ export default {
1684
1761
  }))
1685
1762
  },
1686
1763
 
1764
+ /**
1765
+ * Whether a field's value cannot be edited inline: a structured widget
1766
+ * (json, code, file, sub-objects, child-records), or a plain-object /
1767
+ * list-of-objects value that is not a relation. Scalar widgets without
1768
+ * a dedicated editor (duration, language, …) keep the text fallback.
1769
+ *
1770
+ * @param {object} field Resolved field definition.
1771
+ * @return {boolean} True when only the form dialog can edit it.
1772
+ * @spec openspec/changes/data-widget-nested-objects/specs/data-widget-nested-objects/spec.md#requirement-a-structured-value-is-never-edited-inline
1773
+ */
1774
+ hasNoInlineEditor(field) {
1775
+ if (STRUCTURED_WIDGETS.includes(field.widget)) {
1776
+ return true
1777
+ }
1778
+ const prop = ((this.schema && this.schema.properties) || {})[field.key]
1779
+ if (this.isRelationField(prop)) {
1780
+ return false
1781
+ }
1782
+ // A list of objects by schema, also while empty (it resolves to `tags`).
1783
+ if (prop && prop.type === 'array' && prop.items && prop.items.type === 'object') {
1784
+ return true
1785
+ }
1786
+ // Classify the raw value itself: fieldValueKind reports a formatter
1787
+ // field as 'scalar' whatever it holds.
1788
+ const raw = this.rawOf(field)
1789
+ if (Array.isArray(raw)) {
1790
+ return raw.some((v) => this.isPlainObject(v))
1791
+ }
1792
+ return this.isPlainObject(raw)
1793
+ },
1794
+
1687
1795
  /**
1688
1796
  * Check if a field is editable.
1689
1797
  *
1690
1798
  * @param {object} field - Resolved field definition from resolvedFields
1799
+ * @spec openspec/changes/data-widget-nested-objects/specs/data-widget-nested-objects/spec.md#requirement-a-structured-value-is-never-edited-inline
1691
1800
  */
1692
1801
  isEditable(field) {
1693
1802
  if (!this.editable) {
1694
1803
  return false
1695
1804
  }
1805
+ // No inline editor exists for structured values: the text fallback
1806
+ // would commit "[object Object]". Those edit through the form dialog,
1807
+ // unless the consumer supplies its own `field-<key>` editor slot.
1808
+ if (!this.$slots['field-' + field.key] && this.hasNoInlineEditor(field)) {
1809
+ return false
1810
+ }
1696
1811
  // Per-field override takes priority
1697
1812
  const override = this.resolvedOverrides[field.key]
1698
1813
  if (override && typeof override.editable === 'boolean') {
@@ -1709,6 +1824,29 @@ export default {
1709
1824
  return !field.readOnly
1710
1825
  },
1711
1826
 
1827
+ /**
1828
+ * Evaluate a field's `visibleWhen` override against the object.
1829
+ *
1830
+ * The rule is the shared visibility predicate in its LOCAL mode
1831
+ * (`{ field, op, value }`, `all` / `any`, `appInstalled`), evaluated by
1832
+ * `evaluateVisibleWhenLocal` against the object with its unsaved
1833
+ * changes, so a field appears or goes the moment the field it depends
1834
+ * on changes. A rule that needs a request (`endpoint`, `source`) cannot
1835
+ * be decided here and is ignored: the field shows, as it did before
1836
+ * the rule existed.
1837
+ *
1838
+ * @param {object} field - Resolved field definition.
1839
+ * @return {boolean} True when the field is shown.
1840
+ */
1841
+ isVisibleByCondition(field) {
1842
+ const override = this.resolvedOverrides[field.key]
1843
+ const rule = override ? override.visibleWhen : undefined
1844
+ if (rule === undefined || rule === null || !isLocallyDecidableVisibleWhen(rule)) {
1845
+ return true
1846
+ }
1847
+ return evaluateVisibleWhenLocal(rule, { ...(this.objectData || {}), ...this.dirtyFields })
1848
+ },
1849
+
1712
1850
  /**
1713
1851
  * Evaluate a field's conditional read-only rule against the object data.
1714
1852
  *
@@ -2410,6 +2548,17 @@ export default {
2410
2548
  overflow-wrap: anywhere;
2411
2549
  }
2412
2550
 
2551
+ /* A long description keeps its own paragraphs. Set on an inline span with no
2552
+ surrounding template whitespace, so only the value's own line breaks show. */
2553
+ .cn-object-data-widget__deflist-text {
2554
+ white-space: pre-wrap;
2555
+ }
2556
+
2557
+ .cn-object-data-widget__deflist--nested {
2558
+ padding-inline-start: 8px;
2559
+ border-inline-start: 2px solid var(--color-border);
2560
+ }
2561
+
2413
2562
  /* Responsive: collapse to single column on narrow widths */
2414
2563
  @media (max-width: 600px) {
2415
2564
  .cn-object-data-widget__grid {
@@ -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
  /**
@@ -34,7 +34,31 @@
34
34
  <template>
35
35
  <div class="cn-reports-page" :class="{ 'cn-reports-page--board': isBoard }">
36
36
  <div class="cn-reports-page__header">
37
- <h2 class="cn-reports-page__title">
37
+ <!-- The header buttons (`headerButtons`) beside the title, as the
38
+ board's "Downloaden" and the buildiq square (board DcRapportages).
39
+ Without them, and outside the board look, the title stands alone
40
+ as before. -->
41
+ <div v-if="headerButtonsRow.length > 0" class="cn-reports-page__heading-row">
42
+ <h2 class="cn-reports-page__title">
43
+ {{ resolvedTitle }}
44
+ </h2>
45
+ <div class="cn-reports-page__header-buttons" data-testid="cn-reports-header-buttons">
46
+ <template v-for="button in headerButtonsRow" :key="button.key">
47
+ <CnBuildiqEditButton v-if="button.action === '__buildiq'" />
48
+ <NcButton
49
+ v-else
50
+ :variant="button.variant"
51
+ :data-testid="`cn-reports-header-button-${button.key}`"
52
+ @click="onHeaderButton(button)">
53
+ <template v-if="button.icon" #icon>
54
+ <CnIcon :name="button.icon" :size="20" />
55
+ </template>
56
+ {{ button.label }}
57
+ </NcButton>
58
+ </template>
59
+ </div>
60
+ </div>
61
+ <h2 v-else class="cn-reports-page__title">
38
62
  {{ resolvedTitle }}
39
63
  </h2>
40
64
  <p v-if="resolvedDescription" class="cn-reports-page__description">
@@ -134,9 +158,12 @@
134
158
  * The Reports page: one surface listing an app's reports as cards.
135
159
  */
136
160
  import { translate as t } from '@nextcloud/l10n'
161
+ import { NcButton } from '@nextcloud/vue'
137
162
  import { defineAsyncComponent } from 'vue'
163
+ import CnBuildiqEditButton from '../CnBuildiqEditButton/CnBuildiqEditButton.vue'
138
164
  import CnIcon from '../CnIcon/CnIcon.vue'
139
165
  import { useLook } from '../../composables/useLook.js'
166
+ import { resolveRegisteredHandler } from '../../utils/actionsDispatcher.js'
140
167
  import { followLinkClick } from '../../utils/linkNavigation.js'
141
168
 
142
169
  export default {
@@ -146,10 +173,18 @@ export default {
146
173
  // Loaded only for a page that declares widgets: a cards-only reports
147
174
  // page does not pull the dashboard in.
148
175
  CnDashboardPage: defineAsyncComponent(() => import('../CnDashboardPage/CnDashboardPage.vue').then((m) => m.default)),
176
+ CnBuildiqEditButton,
149
177
  CnIcon,
178
+ NcButton,
150
179
  },
151
180
 
152
181
  inject: {
182
+ /**
183
+ * The v2 registry and the legacy customComponents map, provided by
184
+ * CnAppRoot: a header action's named handler resolves against them.
185
+ */
186
+ cnRegistry: { default: () => ({}) },
187
+ cnCustomComponents: { default: () => ({}) },
153
188
  /**
154
189
  * The consuming app's translate function, provided by CnAppRoot.
155
190
  * Defaults to identity so the page still renders outside one.
@@ -268,6 +303,38 @@ export default {
268
303
  default: null,
269
304
  },
270
305
 
306
+ /**
307
+ * Buttons beside the title (`config.headerButtons`), as the board's
308
+ * "Downloaden". Each is `{ label, action, variant?, icon?, id? }`:
309
+ * `action` is the id of a `headerActions` entry, which says what the
310
+ * click does. Under the board look the buildiq square sits between the
311
+ * secondary buttons and the primary one. Empty (the default) changes
312
+ * nothing.
313
+ *
314
+ * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
315
+ * @type {Array<{label: string, action: string, variant?: ('primary'|'secondary'), icon?: string, id?: string}>|null}
316
+ */
317
+ headerButtons: {
318
+ type: Array,
319
+ default: null,
320
+ },
321
+
322
+ /**
323
+ * The page's header actions (`config.headerActions`), in the shape of
324
+ * an index page's: a header button names one by id. `handler` is a
325
+ * function, `navigate` (with `route`, and `params`), `emit`, `none`,
326
+ * or a registry name; `type: "navigate"` with a `target` URL opens
327
+ * that URL in a new tab. Every click also emits `header-action`
328
+ * `{ action, id }`, except for `handler: "none"`.
329
+ *
330
+ * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
331
+ * @type {Array<object>|null}
332
+ */
333
+ headerActions: {
334
+ type: Array,
335
+ default: null,
336
+ },
337
+
271
338
  /**
272
339
  * The look to draw in: `board` or `nextcloud`. Absent: the look
273
340
  * CnAppRoot provides (`cnLook`), else `nextcloud`.
@@ -290,6 +357,8 @@ export default {
290
357
  },
291
358
  },
292
359
 
360
+ emits: ['header-action'],
361
+
293
362
  setup(props) {
294
363
  const { isBoard } = useLook(props)
295
364
  return { isBoard }
@@ -339,6 +408,8 @@ export default {
339
408
  'emptyLabel',
340
409
  'widgets',
341
410
  'layout',
411
+ 'headerButtons',
412
+ 'headerActions',
342
413
  ]) {
343
414
  if (this[key] !== null && this[key] !== undefined) {
344
415
  fromProps[key] = this[key]
@@ -378,6 +449,51 @@ export default {
378
449
  })
379
450
  },
380
451
 
452
+ /**
453
+ * The declared header buttons that have a label and an action,
454
+ * translated, each with a stable key and a variant.
455
+ *
456
+ * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
457
+ * @return {Array<{key: string, label: string, action: string, variant: string, icon: string}>} The buttons.
458
+ */
459
+ resolvedHeaderButtons() {
460
+ const declared = Array.isArray(this.config.headerButtons) ? this.config.headerButtons : []
461
+ return declared
462
+ .filter((button) => button && typeof button === 'object'
463
+ && typeof button.action === 'string' && button.action !== ''
464
+ && typeof button.label === 'string' && button.label !== '')
465
+ .map((button) => ({
466
+ key: String(button.id || button.action),
467
+ label: this.tr(button.label),
468
+ action: button.action,
469
+ variant: button.variant === 'primary' ? 'primary' : 'secondary',
470
+ icon: typeof button.icon === 'string' ? button.icon : '',
471
+ }))
472
+ },
473
+
474
+ /**
475
+ * The header row in the order it renders. Without the board look:
476
+ * the declared buttons in the declared order. Under the board look:
477
+ * the secondary buttons, the buildiq square, then the primary ones,
478
+ * as the board draws "Downloaden" and the square. No declared button
479
+ * leaves the header as it was, without the square.
480
+ *
481
+ * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
482
+ * @return {Array<object>} The buttons; the square is `{ key: '__buildiq', action: '__buildiq' }`.
483
+ */
484
+ headerButtonsRow() {
485
+ const buttons = this.resolvedHeaderButtons
486
+ if (buttons.length === 0 || !this.isBoard) {
487
+ return buttons
488
+ }
489
+ const square = { key: '__buildiq', action: '__buildiq', variant: 'secondary', label: '', icon: '' }
490
+ return [
491
+ ...buttons.filter((button) => button.variant !== 'primary'),
492
+ square,
493
+ ...buttons.filter((button) => button.variant === 'primary'),
494
+ ]
495
+ },
496
+
381
497
  /**
382
498
  * The heading.
383
499
  *
@@ -567,6 +683,47 @@ export default {
567
683
  }
568
684
  },
569
685
 
686
+ /**
687
+ * A header button was clicked: run the `headerActions` entry it names
688
+ * and emit `header-action`, as an index page's header button does.
689
+ *
690
+ * @spec openspec/changes/screens-reports-header-buttons/specs/reports-page/spec.md#requirement-a-reports-page-carries-header-buttons
691
+ * @param {{action: string}} button The resolved button.
692
+ * @return {void}
693
+ */
694
+ onHeaderButton(button) {
695
+ const id = button.action
696
+ const actions = Array.isArray(this.config.headerActions) ? this.config.headerActions : []
697
+ const entry = actions.find((action) => action && action.id === id) || null
698
+ const handler = entry ? entry.handler : undefined
699
+ if (handler === 'none') {
700
+ return
701
+ }
702
+ if (typeof handler === 'function') {
703
+ handler({ actionId: id })
704
+ } else if (handler === 'navigate' && entry.route) {
705
+ const params = (entry.params && typeof entry.params === 'object') ? entry.params : null
706
+ if (this.$router && typeof this.$router.push === 'function') {
707
+ this.$router.push(params ? { name: entry.route, params } : { name: entry.route })
708
+ }
709
+ } else if (entry && entry.type === 'navigate' && typeof entry.target === 'string' && entry.target !== '') {
710
+ window.open(entry.target, '_blank', 'noopener,noreferrer')
711
+ } else if (typeof handler === 'string' && handler !== 'emit') {
712
+ const resolved = resolveRegisteredHandler(handler, this.cnRegistry, this.cnCustomComponents)
713
+ if (typeof resolved === 'function') {
714
+ resolved({ actionId: id })
715
+ }
716
+ }
717
+ /**
718
+ * A header button was clicked (`headerButtons`), after its
719
+ * `headerActions` entry ran; not for `handler: "none"`.
720
+ *
721
+ * @event header-action
722
+ * @property {{action: string, id: string}} payload The id of the `headerActions` entry the button names, as `action` and `id`.
723
+ */
724
+ this.$emit('header-action', { action: id, id })
725
+ },
726
+
570
727
  /**
571
728
  * Navigate by route NAME on a plain click; a modified or middle click
572
729
  * is left to the browser so it can open the card's href elsewhere.
@@ -598,6 +755,27 @@ export default {
598
755
  margin: 0 0 4px;
599
756
  }
600
757
 
758
+ .cn-reports-page__heading-row {
759
+ display: flex;
760
+ flex-wrap: wrap;
761
+ align-items: flex-start;
762
+ justify-content: space-between;
763
+ gap: 12px 24px;
764
+ }
765
+
766
+ .cn-reports-page__heading-row .cn-reports-page__title {
767
+ flex: 1 1 320px;
768
+ min-width: 0;
769
+ }
770
+
771
+ .cn-reports-page__header-buttons {
772
+ display: flex;
773
+ flex: none;
774
+ flex-wrap: wrap;
775
+ align-items: center;
776
+ gap: 10px;
777
+ }
778
+
601
779
  .cn-reports-page__description {
602
780
  margin: 0 0 16px;
603
781
  color: var(--color-text-maxcontrast);