@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
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.92.0",
6
+ "version": "2.96.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -150,6 +150,7 @@
150
150
  "title": { "type": "string", "description": "i18n key / text for the tour name (restart menu)." },
151
151
  "trigger": { "type": "string", "enum": ["first-visit", "version-bump", "empty-index", "manual"], "default": "manual", "description": "When the tour auto-starts." },
152
152
  "minAppVersion": { "type": "string", "description": "Semver; the tour is inert below this app version." },
153
+ "startOn": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "uniqueItems": true, "description": "Route names the tour may auto-start on. A user who first lands anywhere else, such as on a case opened from a link, does not get the tour there: it waits until they reach one of these routes. Without it a tour whose first step is centered starts wherever the user lands. Has no effect on a tour started by hand." },
153
154
  "steps": {
154
155
  "type": "array",
155
156
  "description": "Ordered steps.",
@@ -808,6 +809,11 @@
808
809
  "pattern": "^@row\\.[A-Za-z][A-Za-z0-9_.@]*$",
809
810
  "description": "Row context: a field of the row a row action runs on (@row.<field>, dot path allowed), read by the field-list widget's rowActions request url and payload (payload values resolve at any depth). Mirrors SENTINEL_TOKEN_PATTERNS.row."
810
811
  },
812
+ "sentinelContextToken": {
813
+ "type": "string",
814
+ "pattern": "^@context\\.[A-Za-z][A-Za-z0-9_.]*$",
815
+ "description": "Detail context: a record a detail page reads beside the loaded one (`config.visibilityContext`), read by a grid widget's or a tab's `visibleWhen` field as @context.<name>.<field> (dot path allowed). Mirrors SENTINEL_TOKEN_PATTERNS.context."
816
+ },
811
817
  "sentinelSubjectToken": {
812
818
  "type": "string",
813
819
  "pattern": "^@subject\\.[A-Za-z][A-Za-z0-9_.]*$",
@@ -826,6 +832,7 @@
826
832
  { "$ref": "#/$defs/sentinelAnswerToken" },
827
833
  { "$ref": "#/$defs/sentinelRowToken" },
828
834
  { "$ref": "#/$defs/sentinelSubjectToken" },
835
+ { "$ref": "#/$defs/sentinelContextToken" },
829
836
  { "$ref": "#/$defs/sentinelDeprecatedToken" }
830
837
  ]
831
838
  },
@@ -4297,7 +4304,7 @@
4297
4304
  },
4298
4305
  "headerButtons": {
4299
4306
  "type": "array",
4300
- "description": "type='index': buttons beside the page title, as the board's \"Export\" and \"New case\" (zuiddrecht-pixel-gaps-3). `action` is `add` (the Add flow; the label defaults to the Add label), `export` (the export leaf in `format`, else the export dialog; under the board look it reads \"Download\" by default), `actions-menu` (the page's header actions as one labelled menu button, absent when the page has none), `import`, `refresh`, or the id of a `headerActions` entry. Under the board look the buttons render in a fixed order whatever the manifest declares: export, actions-menu, other secondary buttons, the buildiq square, then the primary button. Shown only with `showTitle`; when they show, the actions bar drops its Views and Actions menus, and the Add button and Export menu when a button takes their action. Maps to CnIndexPage `headerButtons`.",
4307
+ "description": "type='index': buttons beside the page title, as the board's \"Export\" and \"New case\" (zuiddrecht-pixel-gaps-3). `action` is `add` (the Add flow; the label defaults to the Add label), `export` (the export leaf in `format`, else the export dialog; under the board look it reads \"Download\" by default), `actions-menu` (the page's header actions as one labelled menu button, absent when the page has none), `import`, `refresh`, or the id of a `headerActions` entry. Under the board look the buttons render in a fixed order whatever the manifest declares: export, actions-menu, other secondary buttons, the buildiq square, then the primary button. Shown only with `showTitle`; when they show, the actions bar drops its Views and Actions menus, and the Add button and Export menu when a button takes their action. Maps to CnIndexPage `headerButtons`. type='reports' (screens-reports-header-buttons, 2.96.0): the same buttons beside the reports page title, as board DcRapportages draws \"Downloaden\". There `label` is required and `action` is the id of a `headerActions` entry (same shape as on an index page), which says what the click does; every click also emits `header-action`. Under the board look the buildiq square sits between the secondary buttons and the primary one. Maps to CnReportsPage `headerButtons`.",
4301
4308
  "items": {
4302
4309
  "type": "object",
4303
4310
  "additionalProperties": false,
@@ -4309,7 +4316,7 @@
4309
4316
  "variant": { "type": "string", "enum": ["primary", "secondary"], "default": "secondary" },
4310
4317
  "icon": { "type": "string", "minLength": 1, "description": "MDI icon name." },
4311
4318
  "format": { "type": "string", "enum": ["csv", "excel"], "default": "csv", "description": "For `export`." },
4312
- "items": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 }, "description": "For `actions-menu`: the menu's entries, in order. Each is `header-actions` (every header action), the id of one `headerActions` entry, or a built-in of the actions bar's own menu, which the bar drops while header buttons show: `import`, `export`, `refresh`, `copy-selected`, `delete-selected` (disabled until a row is selected) or `documentation` (a link to the page's documentationUrl, left out without one). An unknown name is left out. Absent: the header actions only (screens-index-header-actions-menu, 2.90.0)." }
4319
+ "items": { "type": "array", "minItems": 1, "items": { "anyOf": [ { "type": "string", "minLength": 1 }, { "type": "object", "additionalProperties": false, "required": ["heading"], "properties": { "heading": { "type": "string", "minLength": 1, "description": "i18n key of a group heading." } } } ] }, "description": "For `actions-menu`: the menu's entries, in order. Each is `header-actions` (every header action), the id of one `headerActions` entry, or a built-in of the actions bar's own menu, which the bar drops while header buttons show: `import`, `export`, `refresh`, `copy-selected`, `delete-selected` (disabled until a row is selected) or `documentation` (a link to the page's documentationUrl, left out without one). An unknown name is left out. Absent: the header actions only (screens-index-header-actions-menu, 2.90.0). Entries can be grouped: `{ \"heading\": \"<i18n key>\" }` draws a group heading and `\"separator\"` a rule, each only when an entry follows it, so a group whose entries are all left out leaves no empty heading (screens-index-header-menu-headings, 2.93.0)." }
4313
4320
  }
4314
4321
  }
4315
4322
  },
@@ -4349,6 +4356,20 @@
4349
4356
  "minLength": 1,
4350
4357
  "description": "type='detail': the id of a widget in `widgets` to render inside the header on its own row under the title, without a card of its own, e.g. the stages bars (zuiddrecht-pixel-gaps-2). The widget leaves the body grid. Maps to CnDetailPage `headerWidget`."
4351
4358
  },
4359
+ "visibilityContext": {
4360
+ "type": "object",
4361
+ "description": "type='detail': records the page reads beside the loaded one, so a widget's or a tab's `visibleWhen` can ask about them (detail-widgets-visible-when). `{ <name>: { field, register, schema } }`: `field` is the property of the loaded record holding the other record's id, and a condition reads that record as `@context.<name>.<path>`, e.g. `{ \"field\": \"@context.caseType.category\", \"op\": \"in\", \"value\": [\"Sociaal domein\"] }`. A record that cannot be read is absent, so its condition fails safe. Maps to CnDetailPage `visibilityContext`.",
4362
+ "additionalProperties": {
4363
+ "type": "object",
4364
+ "required": ["field", "register", "schema"],
4365
+ "additionalProperties": false,
4366
+ "properties": {
4367
+ "field": { "type": "string", "minLength": 1, "description": "Dot-path on the loaded record to the id of the record to read." },
4368
+ "register": { "type": "string", "minLength": 1, "description": "Register slug or id of the record to read." },
4369
+ "schema": { "type": "string", "minLength": 1, "description": "Schema slug or id of the record to read." }
4370
+ }
4371
+ }
4372
+ },
4352
4373
  "mapConfig": {
4353
4374
  "type": "object",
4354
4375
  "additionalProperties": true,
@@ -195,6 +195,7 @@ export function applyMenuRelocations(menu, relocations) {
195
195
  if (!relocations || typeof relocations !== 'object') {
196
196
  return menu
197
197
  }
198
+ const heldEntries = captionsHoldingEntries(menu)
198
199
  for (let pass = 0; pass < 5; pass++) {
199
200
  const moves = []
200
201
  for (let i = menu.length - 1; i >= 0; i--) {
@@ -240,9 +241,42 @@ export function applyMenuRelocations(menu, relocations) {
240
241
  }
241
242
  })
242
243
  }
243
- // Drop empty group shells left behind by relocations.
244
- return menu.filter((m) => m.route || m.href || m.action
244
+ // Drop empty group shells left behind by relocations. A caption (a
245
+ // section heading) has no route and no children and is kept, unless the
246
+ // relocations emptied it: every entry under it moved away.
247
+ const kept = menu.filter((m) => m.type === 'caption' || m.route || m.href || m.action
245
248
  || (Array.isArray(m.children) && m.children.length > 0))
249
+ const holdingNow = captionsHoldingEntries(kept)
250
+ return kept.filter((m) => m.type !== 'caption' || !heldEntries.has(m) || holdingNow.has(m))
251
+ }
252
+
253
+ /**
254
+ * The captions (`type: "caption"`) that have at least one entry under them:
255
+ * a non-caption entry of the same section before the next caption.
256
+ *
257
+ * @param {Array<object>} menu The top-level menu.
258
+ * @return {Set<object>} The captions holding an entry.
259
+ */
260
+ function captionsHoldingEntries(menu) {
261
+ const holding = new Set()
262
+ menu.forEach((entry, index) => {
263
+ if (entry.type !== 'caption') {
264
+ return
265
+ }
266
+ const section = entry.section ?? 'main'
267
+ for (let i = index + 1; i < menu.length; i++) {
268
+ const next = menu[i]
269
+ if ((next.section ?? 'main') !== section) {
270
+ continue
271
+ }
272
+ if (next.type === 'caption') {
273
+ break
274
+ }
275
+ holding.add(entry)
276
+ break
277
+ }
278
+ })
279
+ return holding
246
280
  }
247
281
 
248
282
  /**
@@ -0,0 +1,145 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * detailWidgetVisibility: whether a widget on a detail page shows for the
6
+ * loaded record, through the shared `visibleWhen` grammar.
7
+ *
8
+ * A detail page can name records it reads beside the loaded one
9
+ * (`visibilityContext`, e.g. a case's case type). A condition reads such a
10
+ * record as `@context.<name>.<path>`, so a widget can be shown for some case types
11
+ * only while the configuration stays in the app manifest.
12
+ *
13
+ * @module utils/detailWidgetVisibility
14
+ */
15
+
16
+ import { cnFetchJson } from './cnFetch.js'
17
+ import { evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen, isWorkspaceCondition } from './visibleWhen.js'
18
+
19
+ /**
20
+ * Read a reference value as an id: a plain string or number, or an object
21
+ * carrying `id`, `uuid` or `@self.id` (an extended reference).
22
+ *
23
+ * @param {unknown} value The value of the reference field.
24
+ * @return {string} The id, or '' when there is none.
25
+ */
26
+ function referenceId(value) {
27
+ if (typeof value === 'string' || typeof value === 'number') {
28
+ return String(value)
29
+ }
30
+ if (value && typeof value === 'object') {
31
+ const id = value.id ?? value.uuid ?? (value['@self'] && value['@self'].id)
32
+ return id === undefined || id === null ? '' : String(id)
33
+ }
34
+ return ''
35
+ }
36
+
37
+ /**
38
+ * The records a `visibilityContext` names for one loaded record.
39
+ *
40
+ * @param {object} context `{ <name>: { field, register, schema } }`.
41
+ * @param {object|null} record The loaded record.
42
+ * @return {Array<{key: string, register: string, schema: string, id: string}>} One entry per usable name; `id` is '' when the record holds no reference.
43
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
44
+ */
45
+ export function visibilityContextRefs(context, record) {
46
+ if (!context || typeof context !== 'object' || !record || typeof record !== 'object') {
47
+ return []
48
+ }
49
+ return Object.entries(context)
50
+ .filter(([name, ref]) => name !== '' && ref && typeof ref === 'object'
51
+ && typeof ref.field === 'string' && ref.field !== ''
52
+ && typeof ref.register === 'string' && ref.register !== ''
53
+ && typeof ref.schema === 'string' && ref.schema !== '')
54
+ .map(([name, ref]) => ({
55
+ key: name,
56
+ register: ref.register,
57
+ schema: ref.schema,
58
+ id: referenceId(ref.field.split('.').reduce((obj, part) => (obj === null || obj === undefined ? obj : obj[part]), record)),
59
+ }))
60
+ }
61
+
62
+ /**
63
+ * Read the records `refs` names. A reference without an id, and a read that
64
+ * fails, resolve `null`: the record is absent, and a condition on it fails
65
+ * safe rather than throwing out of the page.
66
+ *
67
+ * @param {Array<{key: string, register: string, schema: string, id: string}>} refs From {@link visibilityContextRefs}.
68
+ * @param {Map<string, object|null>} [cache] Rows already read, by `register/schema/id`.
69
+ * @return {Promise<object>} `{ <name>: row | null }`.
70
+ * @spec openspec/changes/detail-widgets-visible-when/specs/detail-widgets-visible-when/spec.md#requirement-a-detail-page-can-load-records-a-condition-names
71
+ */
72
+ export async function fetchVisibilityRows(refs, cache) {
73
+ const rows = {}
74
+ await Promise.all((refs || []).map(async (ref) => {
75
+ if (!ref.id) {
76
+ rows[ref.key] = null
77
+ return
78
+ }
79
+ const cacheKey = `${ref.register}/${ref.schema}/${ref.id}`
80
+ if (cache && cache.has(cacheKey)) {
81
+ rows[ref.key] = cache.get(cacheKey)
82
+ return
83
+ }
84
+ let row
85
+ try {
86
+ const body = await cnFetchJson(`/apps/openregister/api/objects/${encodeURIComponent(ref.register)}/${encodeURIComponent(ref.schema)}/${encodeURIComponent(ref.id)}`)
87
+ row = body && typeof body === 'object' ? body : null
88
+ } catch {
89
+ row = null
90
+ }
91
+ if (cache) {
92
+ cache.set(cacheKey, row)
93
+ }
94
+ rows[ref.key] = row
95
+ }))
96
+ return rows
97
+ }
98
+
99
+ /**
100
+ * The data a widget condition reads: the record, with the named records
101
+ * beside it under `@context.<name>`. Null while the record has not loaded.
102
+ *
103
+ * @param {object|null} record The loaded record.
104
+ * @param {object} [rows] `{ <name>: row | null }`.
105
+ * @return {object|null} The condition data.
106
+ * @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
107
+ */
108
+ export function widgetConditionData(record, rows) {
109
+ if (!record || typeof record !== 'object') {
110
+ return null
111
+ }
112
+ return { ...record, '@context': { ...(rows || {}) } }
113
+ }
114
+
115
+ /**
116
+ * Whether a widget's own `visibleWhen` lets it show on a detail page.
117
+ *
118
+ * No condition: shown, as before. A workspace condition: shown, a detail page
119
+ * has no workspace. While the data is not there (`data` null, or `pending`):
120
+ * hidden, so the widget never shows and then vanishes. A local condition is
121
+ * decided here; an endpoint or source condition takes its answer from
122
+ * `answers` (by widget id) and is hidden until it has one.
123
+ *
124
+ * @param {object|null} widget The widget definition.
125
+ * @param {object|null} data From {@link widgetConditionData}.
126
+ * @param {{pending?: boolean, answers?: object}} [state] Whether named records are still on their way, and the remote answers.
127
+ * @return {boolean} Whether the widget shows.
128
+ * @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
129
+ */
130
+ export function detailWidgetVisible(widget, data, state = {}) {
131
+ const cond = widget && typeof widget === 'object' ? widget.visibleWhen : null
132
+ if (cond === null || cond === undefined) {
133
+ return true
134
+ }
135
+ if (typeof cond === 'object' && !Array.isArray(cond) && isWorkspaceCondition(cond)) {
136
+ return true
137
+ }
138
+ if (!data || state.pending === true) {
139
+ return false
140
+ }
141
+ if (isLocallyDecidableVisibleWhen(cond)) {
142
+ return evaluateVisibleWhenLocal(cond, data)
143
+ }
144
+ return Boolean(state.answers && state.answers[widget.id] === true)
145
+ }
@@ -37,11 +37,34 @@ export function flattenAggFilter(target, filter, ctx) {
37
37
  target[`filter[${k}][${op}]`] = ov
38
38
  }
39
39
  } else if (v !== '' && v !== null && v !== undefined) {
40
- target[`filter[${k}]`] = v
40
+ target[aggFilterParam(k)] = v
41
41
  }
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * Matches a filter key that spells its operator, `field[op]`.
47
+ *
48
+ * @type {RegExp}
49
+ */
50
+ const OPERATOR_KEY = /^(.+)\[([A-Za-z_]+)\]$/
51
+
52
+ /**
53
+ * The query parameter of one plain filter key. A key that spells its operator
54
+ * (`scheduledDate[gte]`, the flat form a list page and a lens use) goes out as
55
+ * `filter[scheduledDate][gte]`, the form the aggregation endpoint reads, and
56
+ * not as `filter[scheduledDate[gte]]`, a field it does not have, which counted
57
+ * 0. Any other key goes out as `filter[key]`.
58
+ *
59
+ * @spec openspec/changes/screens-stats-block-operator-keys/specs/stats-block/spec.md#requirement-a-stats-block-filter-can-spell-its-operator-in-the-key
60
+ * @param {string} key The filter key.
61
+ * @return {string} The query parameter name.
62
+ */
63
+ export function aggFilterParam(key) {
64
+ const match = OPERATOR_KEY.exec(key)
65
+ return match ? `filter[${match[1]}][${match[2]}]` : `filter[${key}]`
66
+ }
67
+
45
68
  /**
46
69
  * Fetch one aggregated scalar from OpenRegister.
47
70
  *
@@ -226,6 +226,35 @@ export function metricLocale() {
226
226
  }
227
227
  }
228
228
 
229
+ /**
230
+ * A format spec with its literal `prefix` / `suffix` run through the host
231
+ * translate function. A `@`-token, an empty string, or a missing translate
232
+ * leaves the affix as written.
233
+ *
234
+ * @param {object} format The `content.format` spec.
235
+ * @param {((text: string) => string)|null} translate The host translate function.
236
+ * @return {object} A copy of the spec (the same object when nothing changes).
237
+ * @spec openspec/changes/screens-metric-affix-translate/specs/dashboard-page/spec.md#requirement-a-metric-prefix-and-suffix-are-translated
238
+ */
239
+ export function translateMetricAffixes(format, translate) {
240
+ const fmt = format || {}
241
+ if (typeof translate !== 'function') {
242
+ return fmt
243
+ }
244
+ let out = fmt
245
+ for (const key of ['prefix', 'suffix']) {
246
+ const text = fmt[key]
247
+ if (typeof text === 'string' && text.trim() !== '' && text.charAt(0) !== '@') {
248
+ const translated = translate(text)
249
+ if (typeof translated === 'string' && translated !== text) {
250
+ out = out === fmt ? { ...fmt } : out
251
+ out[key] = translated
252
+ }
253
+ }
254
+ }
255
+ return out
256
+ }
257
+
229
258
  /**
230
259
  * Format a numeric value per a manifest `content.format` spec.
231
260
  *
@@ -250,11 +279,18 @@ export function metricLocale() {
250
279
  * @param {unknown} value The raw value to format.
251
280
  * @param {object} format The `content.format` spec (`{ style, currency, currencyField, decimals, prefix, suffix }`).
252
281
  * @param {object} configCtx The page-level app-config map for `@config.<key>` resolution.
282
+ * A literal `prefix` / `suffix` (one that is not a `@config.` or `@object.`
283
+ * token) is run through `translate` when one is given, as every other
284
+ * manifest-authored string is, so an app's catalogue entry for " days"
285
+ * (" dagen") applies. A token is resolved, never translated.
286
+ *
253
287
  * @param {object|null} [object] The detail page's object, for `currencyField` and `@object.<field>`.
288
+ * @param {((text: string) => string)|null} [translate] The host app's translate function (`cnTranslate`).
254
289
  * @return {string} The formatted display string.
255
290
  * @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-money-value-is-shown-in-the-currency-the-entry-names
291
+ * @spec openspec/changes/screens-metric-affix-translate/specs/dashboard-page/spec.md#requirement-a-metric-prefix-and-suffix-are-translated
256
292
  */
257
- export function formatMetricValue(value, format, configCtx, object = null) {
293
+ export function formatMetricValue(value, format, configCtx, object = null, translate = null) {
258
294
  if (value === null || value === undefined) {
259
295
  return '—'
260
296
  }
@@ -263,7 +299,7 @@ export function formatMetricValue(value, format, configCtx, object = null) {
263
299
  return String(value)
264
300
  }
265
301
 
266
- const fmt = resolveConfigFormat(format, configCtx, object)
302
+ const fmt = resolveConfigFormat(translateMetricAffixes(format, translate), configCtx, object)
267
303
  const locale = metricLocale()
268
304
  // `duration-hours` and `decimal` default to ONE fraction digit; the
269
305
  // other styles keep the pre-existing default of 0.
@@ -56,6 +56,7 @@ export const SENTINEL_RESOLVERS = Object.freeze({
56
56
  answer: 'resolveAssignValue (utils/formAssign.js)',
57
57
  row: 'CnFieldListWidget (rowActions request)',
58
58
  subject: 'CnBannerWidget (action route)',
59
+ context: 'detailWidgetVisible (utils/detailWidgetVisibility.js)',
59
60
  })
60
61
 
61
62
  /**
@@ -89,6 +89,9 @@ export const SENTINEL_TOKEN_PATTERNS = Object.freeze({
89
89
  // A field of the record a start-here attention card names (`@subject.<field>`, dot path),
90
90
  // read by the banner widget's action routes.
91
91
  subject: '^@subject\\.[A-Za-z][A-Za-z0-9_.]*$',
92
+ // A record a detail page reads beside the loaded one (`visibilityContext`), read by a
93
+ // grid widget's or a tab's `visibleWhen` as `@context.<name>.<field>` (dot path).
94
+ context: '^@context\\.[A-Za-z][A-Za-z0-9_.]*$',
92
95
  // Deprecated-but-not-yet-removed tokens. Kept in the schema union so
93
96
  // deployed manifests still validate during the migration window; the gate
94
97
  // downgrades them to a WARN (see SENTINEL_DEPRECATIONS).
@@ -112,6 +115,7 @@ export const SENTINEL_CONTEXTS = Object.freeze([
112
115
  'answer',
113
116
  'row',
114
117
  'subject',
118
+ 'context',
115
119
  ])
116
120
 
117
121
  /**
@@ -172,6 +176,11 @@ export const SENTINEL_VOCABULARY = Object.freeze({
172
176
  description: 'A field of the record an attention card names (the first row of its ordered `visibleWhen` source query), read when the card builds an action\'s route. Dot paths are allowed (`@subject.owner.id`).',
173
177
  members: ['@subject.<field>'],
174
178
  },
179
+ context: {
180
+ resolver: 'CnDetailPage visibilityContext (detailWidgetVisibility), read by a grid widget\'s or a tab\'s visibleWhen',
181
+ description: 'A record the detail page reads beside the loaded one, named in `config.visibilityContext` (a case\'s case type). A `visibleWhen` field reads it as `@context.<name>.<field>`, dot paths allowed (`@context.caseType.category`).',
182
+ members: ['@context.<name>.<field>'],
183
+ },
175
184
  })
176
185
 
177
186
  /**
@@ -0,0 +1,59 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * A switch's value map: what a switch reads as "on" and what it writes for on
6
+ * and off. Without a map a switch is a boolean, as before. With `values:
7
+ * { on, off }` it reads a field stored as `"1"`/`"0"` or `"true"`/`"false"`
8
+ * (an app config flag) and writes those values back.
9
+ *
10
+ * @module utils/switchValues
11
+ * @spec openspec/changes/screens-switch-value-map/specs/switch-rows/spec.md#requirement-a-switch-can-read-and-write-a-value-map
12
+ */
13
+
14
+ /**
15
+ * The value map of a switch, or null when it has none (a boolean switch).
16
+ *
17
+ * @param {unknown} values The declared `values`.
18
+ * @return {{on: unknown, off: unknown}|null} The map.
19
+ */
20
+ export function switchValueMap(values) {
21
+ if (!values || typeof values !== 'object' || Array.isArray(values) || values.on === undefined) {
22
+ return null
23
+ }
24
+ return { on: values.on, off: values.off === undefined ? null : values.off }
25
+ }
26
+
27
+ /**
28
+ * Whether a stored value reads as "on". Without a map only `true` does; with
29
+ * one, the `on` value does, compared as text so `1` and `"1"` are the same.
30
+ *
31
+ * @param {unknown} stored The stored value.
32
+ * @param {unknown} values The declared `values`.
33
+ * @return {boolean} True when the switch shows on.
34
+ */
35
+ export function switchIsOn(stored, values) {
36
+ const map = switchValueMap(values)
37
+ if (!map) {
38
+ return stored === true
39
+ }
40
+ if (stored === undefined || stored === null) {
41
+ return false
42
+ }
43
+ return stored === map.on || String(stored) === String(map.on)
44
+ }
45
+
46
+ /**
47
+ * The value a switch writes for a state.
48
+ *
49
+ * @param {boolean} on The switch state.
50
+ * @param {unknown} values The declared `values`.
51
+ * @return {unknown} `true`/`false` without a map, else the map's `on` / `off`.
52
+ */
53
+ export function switchStoredValue(on, values) {
54
+ const map = switchValueMap(values)
55
+ if (!map) {
56
+ return on === true
57
+ }
58
+ return on ? map.on : map.off
59
+ }