@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
@@ -52,18 +52,33 @@
52
52
  {{ field.clearLabel }}
53
53
  </NcButton>
54
54
  </div>
55
- <select
56
- v-else-if="field.type === 'reference'"
57
- :id="inputId(field)"
58
- v-model="form[field.key]"
59
- class="cn-settings-form__control"
60
- :disabled="field.readOnly"
61
- :aria-busy="referenceState[field.key] === 'loading' ? 'true' : null"
62
- :data-testid="`cn-settings-form-reference-${field.key}`">
63
- <option v-for="option in referenceChoices(field)" :key="option.value" :value="option.value">
64
- {{ option.label }}
65
- </option>
66
- </select>
55
+ <div v-else-if="field.type === 'reference'" class="cn-settings-form__reference">
56
+ <select
57
+ :id="inputId(field)"
58
+ v-model="form[field.key]"
59
+ class="cn-settings-form__control"
60
+ :disabled="field.readOnly"
61
+ :aria-busy="referenceState[field.key] === 'loading' ? 'true' : null"
62
+ :data-testid="`cn-settings-form-reference-${field.key}`">
63
+ <option v-for="option in referenceChoices(field)" :key="option.value" :value="option.value">
64
+ {{ option.label }}
65
+ </option>
66
+ </select>
67
+ <!-- `free`: a typed value (a URL) besides picking an object. -->
68
+ <template v-if="field.free && !field.readOnly">
69
+ <label class="cn-settings-form__sublabel" :for="`${inputId(field)}-free`">{{ field.freeLabel }}</label>
70
+ <input
71
+ :id="`${inputId(field)}-free`"
72
+ class="cn-settings-form__control"
73
+ type="text"
74
+ inputmode="url"
75
+ :value="freeValue(field)"
76
+ :placeholder="field.freePlaceholder"
77
+ :aria-invalid="errors[field.key] ? 'true' : null"
78
+ :data-testid="`cn-settings-form-reference-free-${field.key}`"
79
+ @input="form[field.key] = $event.target.value.trim()">
80
+ </template>
81
+ </div>
67
82
  <select
68
83
  v-else-if="field.type === 'select'"
69
84
  :id="inputId(field)"
@@ -97,7 +112,8 @@
97
112
  <p v-if="errors[field.key]" class="cn-settings-form__error" role="alert">
98
113
  {{ errors[field.key] }}
99
114
  </p>
100
- <p v-else-if="field.hint" class="cn-settings-form__hint">
115
+ <!-- A chips field prints its own hint (it gets it in chipsContent). -->
116
+ <p v-else-if="field.hint && field.type !== 'chips'" class="cn-settings-form__hint">
101
117
  {{ field.hint }}
102
118
  </p>
103
119
  </div>
@@ -129,7 +145,9 @@ import { fillTemplate } from '../../utils/fillTemplate.js'
129
145
  import { prefixUrl } from '../../utils/headers.js'
130
146
  import { changedFields, objectUrlOf, writeFields } from '../../utils/objectWrite.js'
131
147
  import { readPath } from '../../utils/readPath.js'
148
+ import { hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
132
149
  import { serverErrorMessage } from '../../utils/serverErrorMessage.js'
150
+ import { switchIsOn, switchStoredValue } from '../../utils/switchValues.js'
133
151
 
134
152
  // The native file-picker modal ships its chrome styles here; without this the
135
153
  // spawned picker renders unstyled. Rules are scoped to the picker/dialog classes.
@@ -163,7 +181,7 @@ export default {
163
181
  /**
164
182
  * The widget configuration.
165
183
  *
166
- * - `fields` (required) — `[{ key, label, type?: 'text'|'textarea'|'select'|'switch'|'number'|'file'|'chips'|'reference', register?, schema?, labelField?, valueTemplate?, filter?, limit?, emptyLabel?, default?, minItems?, required?, minItemsMessage?, lowercase?, addLabel?, confirmLabel?, removeLabel?, picker?: { title?, mimeTypes?, startAt?, valueTemplate? }, chooseLabel?, clearLabel?, hint?, placeholder?, options?: [{ value, label }], required?, pattern?, patternMessage?, min?, max?, rows?, wide?, readOnly? }]`; `key` is a field of the object, a top-level name or a dot-path.
184
+ * - `fields` (required) — `[{ key, label, type?: 'text'|'textarea'|'select'|'switch'|'number'|'file'|'chips'|'reference', register?, schema?, labelField?, valueTemplate?, filter?, limit?, emptyLabel?, free?, freeLabel?, freePlaceholder?, freePattern?, freePatternMessage?, default?, minItems?, required?, minItemsMessage?, values?: { on, off }, lowercase?, addLabel?, confirmLabel?, removeLabel?, picker?: { title?, mimeTypes?, startAt?, valueTemplate? }, chooseLabel?, clearLabel?, hint?, placeholder?, options?: [{ value, label }], required?, pattern?, patternMessage?, min?, max?, rows?, wide?, readOnly? }]`; `key` is a field of the object, a top-level name or a dot-path. A `reference` field's `filter` values may be `@objectId` and `@object.<field>` (the record being edited). A `switch` field's `values: { on, off }` reads and writes those values instead of true/false.
167
185
  * - `save` — `{ url, method?, payload? }`, write through an app endpoint (tokens `{value}`, `{field}`, `{changes}`, `{object.<path>}`).
168
186
  * - `columns` — 1 or 2 (default 2).
169
187
  * - `saveLabel`, `savedText`, `readOnly`.
@@ -212,6 +230,9 @@ export default {
212
230
  // `reference` fields: the objects to choose from and the read's state (`loading`, `ready`, `error`), by field key.
213
231
  referenceOptions: {},
214
232
  referenceState: {},
233
+ // The query each `reference` field was last read with, by field key, so a
234
+ // new object re-reads only the fields whose filter changes with it.
235
+ referenceQuery: {},
215
236
  errors: {},
216
237
  saving: false,
217
238
  notice: '',
@@ -236,6 +257,9 @@ export default {
236
257
  hint: this.tr(field.hint || ''),
237
258
  placeholder: this.tr(field.placeholder || ''),
238
259
  chooseLabel: this.tr(field.chooseLabel || 'Choose a file'),
260
+ free: field.type === 'reference' && field.free === true,
261
+ freeLabel: this.tr(field.freeLabel || 'Or type a value'),
262
+ freePlaceholder: this.tr(field.freePlaceholder || ''),
239
263
  clearLabel: this.tr(field.clearLabel || 'Remove'),
240
264
  rows: field.rows || 3,
241
265
  wide: field.wide === true || field.type === 'textarea',
@@ -269,6 +293,7 @@ export default {
269
293
  watch: {
270
294
  objectData() {
271
295
  this.adopt()
296
+ this.loadReferences()
272
297
  },
273
298
  },
274
299
 
@@ -284,7 +309,14 @@ export default {
284
309
  * parameters, at most `limit`, 100 by default). A failed read leaves the
285
310
  * field with its stored value only, and says so in the hint's place.
286
311
  *
312
+ * `filter` values may be `@objectId` and `@object.<field>`: the record
313
+ * the form edits, so a list can be narrowed to it (the logos of this
314
+ * portal). Until the record has that value the field is not read; it is
315
+ * read again when the record changes the resolved query, never twice
316
+ * for the same query.
317
+ *
287
318
  * @spec openspec/changes/screens-settings-form-reference/specs/settings-form/spec.md#requirement-a-settings-form-field-can-pick-an-object
319
+ * @spec openspec/changes/screens-settings-form-reference-object-filter/specs/settings-form/spec.md#requirement-a-reference-fields-filter-can-name-the-record-being-edited
288
320
  * @return {Promise<void>}
289
321
  */
290
322
  async loadReferences() {
@@ -292,13 +324,35 @@ export default {
292
324
  if (fields.length === 0) {
293
325
  return
294
326
  }
327
+ const ctx = { objectId: this.objectId || (this.objectData && this.objectData.id) || undefined, object: this.objectData || undefined }
328
+ const pending = []
329
+ for (const field of fields) {
330
+ const filter = resolveFilterTokens(field.filter && typeof field.filter === 'object' ? field.filter : {}, ctx)
331
+ if (hasUnresolvedTokens(filter)) {
332
+ // The record has no value for the token yet: nothing to read.
333
+ continue
334
+ }
335
+ const params = { ...filter, _limit: Number(field.limit) > 0 ? Number(field.limit) : 100 }
336
+ const query = JSON.stringify(params)
337
+ if (this.referenceQuery[field.key] === query) {
338
+ continue
339
+ }
340
+ this.referenceQuery = { ...this.referenceQuery, [field.key]: query }
341
+ pending.push({ field, params, query })
342
+ }
343
+ if (pending.length === 0) {
344
+ return
345
+ }
295
346
  const { default: axios } = await import('@nextcloud/axios')
296
- await Promise.all(fields.map(async (field) => {
347
+ await Promise.all(pending.map(async ({ field, params, query }) => {
297
348
  this.referenceState = { ...this.referenceState, [field.key]: 'loading' }
298
349
  try {
299
350
  const url = `/apps/openregister/api/objects/${encodeURIComponent(field.register)}/${encodeURIComponent(field.schema)}`
300
- const params = { ...(field.filter && typeof field.filter === 'object' ? field.filter : {}), _limit: Number(field.limit) > 0 ? Number(field.limit) : 100 }
301
351
  const { data } = await axios.get(prefixUrl(url), { params })
352
+ if (this.referenceQuery[field.key] !== query) {
353
+ // A newer record asked for another list meanwhile.
354
+ return
355
+ }
302
356
  const rows = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : [])
303
357
  const options = []
304
358
  for (const row of rows) {
@@ -340,6 +394,24 @@ export default {
340
394
  return [...choices, ...options]
341
395
  },
342
396
 
397
+ /**
398
+ * The typed value of a `reference` field with `free`: the value the form
399
+ * holds when it is not one of the objects to pick, else empty (picking
400
+ * an object clears the typed value).
401
+ *
402
+ * @spec openspec/changes/screens-settings-form-reference-free-value/specs/settings-form/spec.md#requirement-a-reference-field-can-also-take-a-typed-value
403
+ * @param {object} field The field.
404
+ * @return {string}
405
+ */
406
+ freeValue(field) {
407
+ const current = this.form[field.key]
408
+ if (typeof current !== 'string' || current === '') {
409
+ return ''
410
+ }
411
+ const options = this.referenceOptions[field.key] || []
412
+ return options.some((option) => option.value === current) ? '' : current
413
+ },
414
+
343
415
  tr(text) {
344
416
  if (typeof text !== 'string' || text === '') {
345
417
  return ''
@@ -410,7 +482,7 @@ export default {
410
482
  stored(field) {
411
483
  const value = readPath(this.objectData || {}, field.key)
412
484
  if (field.type === 'switch') {
413
- return value === true
485
+ return switchIsOn(value, field.values)
414
486
  }
415
487
  if (field.type === 'chips') {
416
488
  return Array.isArray(value) ? value.map((item) => String(item)) : []
@@ -534,6 +606,9 @@ export default {
534
606
  if (Number.isNaN(value) || (field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max)) {
535
607
  errors[field.key] = this.tr('This number is not valid.')
536
608
  }
609
+ } else if (field.free && field.freePattern && this.freeValue(field) !== '' && !new RegExp(field.freePattern).test(String(value))) {
610
+ // A typed value (not one of the objects) answers to `freePattern`.
611
+ errors[field.key] = this.tr(field.freePatternMessage || 'This value is not valid.')
537
612
  } else if (field.pattern && !new RegExp(field.pattern).test(String(value))) {
538
613
  errors[field.key] = this.tr(field.patternMessage || 'This value is not valid.')
539
614
  }
@@ -567,6 +642,12 @@ export default {
567
642
  }
568
643
  }
569
644
  const patch = changedFields(compare, next)
645
+ // A switch with a value map (`values: { on, off }`) writes those values.
646
+ for (const field of this.fields) {
647
+ if (field.type === 'switch' && Object.hasOwn(patch, field.key)) {
648
+ patch[field.key] = switchStoredValue(patch[field.key], field.values)
649
+ }
650
+ }
570
651
  this.saving = true
571
652
  try {
572
653
  if (Object.keys(patch).length > 0) {
@@ -578,7 +659,7 @@ export default {
578
659
  const field = this.fields.find((candidate) => candidate.key === key)
579
660
  const answered = readPath(data, key)
580
661
  const written = answered !== undefined ? answered : patch[key]
581
- baseline[key] = field.type === 'switch' ? written === true : (written === null ? (field.type === 'chips' ? [] : '') : written)
662
+ baseline[key] = field.type === 'switch' ? switchIsOn(written, field.values) : (written === null ? (field.type === 'chips' ? [] : '') : written)
582
663
  form[key] = baseline[key]
583
664
  loaded[key] = baseline[key]
584
665
  }
@@ -639,6 +720,17 @@ export default {
639
720
  color: var(--color-main-text);
640
721
  }
641
722
 
723
+ .cn-settings-form__reference {
724
+ display: flex;
725
+ flex-direction: column;
726
+ gap: 6px;
727
+ }
728
+
729
+ .cn-settings-form__sublabel {
730
+ font-size: 12px;
731
+ color: var(--color-text-maxcontrast);
732
+ }
733
+
642
734
  .cn-settings-form__control {
643
735
  width: 100%;
644
736
  box-sizing: border-box;
@@ -59,8 +59,15 @@
59
59
  class="cn-stages-widget__bar-label"
60
60
  :title="stage.hint || stage.label"
61
61
  :aria-disabled="stage.disabled && stage.clickable ? 'true' : null"
62
+ :aria-busy="movingTo === stage.id ? 'true' : null"
62
63
  :data-testid="`cn-stages-widget-stage-${stage.id}`"
63
64
  @click="onBarClick(stage)">
65
+ <NcLoadingIcon
66
+ v-if="movingTo === stage.id"
67
+ class="cn-stages-widget__moving"
68
+ :size="16"
69
+ :name="movingMessage"
70
+ data-testid="cn-stages-widget-moving" />
64
71
  {{ stage.label }}<span v-if="stage.state === 'current'" class="cn-stages-widget__sr-only"> ({{ tr('current step') }})</span>
65
72
  </component>
66
73
  <span v-if="stage.date" class="cn-stages-widget__bar-date">{{ stage.date }}</span>
@@ -83,7 +90,19 @@
83
90
  @stageClick="onStageClick"
84
91
  @stageBlocked="onStageBlocked">
85
92
  <template #label="{ stage }">
86
- <span class="cn-timeline-stages__label" :data-testid="`cn-stages-widget-stage-${stage.id}`">
93
+ <span
94
+ class="cn-timeline-stages__label"
95
+ :aria-busy="movingTo === stage.id ? 'true' : null"
96
+ :data-testid="`cn-stages-widget-stage-${stage.id}`">
97
+ <!-- The move in flight. A transition can take seconds, and
98
+ a strip that shows nothing until it returns reads as
99
+ one that never moved. -->
100
+ <NcLoadingIcon
101
+ v-if="movingTo === stage.id"
102
+ class="cn-stages-widget__moving"
103
+ :size="16"
104
+ :name="movingMessage"
105
+ data-testid="cn-stages-widget-moving" />
87
106
  {{ stage.label }}
88
107
  </span>
89
108
  <span v-if="stage.subtitle" class="cn-timeline-stages__subtitle">
@@ -156,8 +175,11 @@
156
175
  v-if="pendingAction"
157
176
  :transition="pendingAction"
158
177
  :schema="schema"
178
+ :error="inputError"
179
+ :fieldErrors="inputFieldErrors"
180
+ :busy="busy"
159
181
  @confirm="onInputConfirm"
160
- @close="pendingAction = null" />
182
+ @close="closeInputDialog" />
161
183
  </div>
162
184
  </template>
163
185
 
@@ -177,6 +199,7 @@ import {
177
199
  performTransition,
178
200
  readAvailableActions,
179
201
  transitionError,
202
+ transitionFieldErrors,
180
203
  } from '../../composables/useLifecycleTransitions.js'
181
204
  import { normalizeLook } from '../../composables/useLook.js'
182
205
  import { useObjectStore } from '../../store/useObjectStore.js'
@@ -534,6 +557,17 @@ export default {
534
557
  pendingAction: null,
535
558
  /** @type {boolean} Whether a move is running. */
536
559
  busy: false,
560
+ /**
561
+ * @type {string} The stage a running move leads to, or ''. Drives
562
+ * the loading indicator on that one stage.
563
+ */
564
+ movingTo: '',
565
+ /** @type {string} A refusal shown inside the open input dialog. */
566
+ inputError: '',
567
+ /** @type {string[]} The input keys that refusal named. */
568
+ inputFieldErrors: [],
569
+ /** @type {string[]} The input keys the last refused move named. */
570
+ lastFieldErrors: [],
537
571
  /** @type {string} Why the last move failed, or ''. */
538
572
  moveError: '',
539
573
  /**
@@ -766,6 +800,20 @@ export default {
766
800
  return this.interactive && !this.busy
767
801
  },
768
802
 
803
+ /**
804
+ * What the stage a move is running to says while it runs: the
805
+ * loading indicator's name and the live region's sentence.
806
+ *
807
+ * @return {string} The sentence, or '' when nothing is moving.
808
+ */
809
+ movingMessage() {
810
+ if (!this.movingTo) {
811
+ return ''
812
+ }
813
+ const stage = this.stages.find((s) => s.id === this.movingTo)
814
+ return this.tr('Moving to {stage}', { stage: stage ? stage.label : this.movingTo })
815
+ },
816
+
769
817
  /**
770
818
  * The allowed moves by the stage they lead to, or null while they are
771
819
  * unknown.
@@ -1225,13 +1273,44 @@ export default {
1225
1273
  * @param {object} data The collected input values.
1226
1274
  * @return {void}
1227
1275
  */
1228
- onInputConfirm(data) {
1276
+ async onInputConfirm(data) {
1229
1277
  const pending = this.pendingAction
1230
- this.pendingAction = null
1231
1278
  if (!pending || !pending.__request) {
1279
+ this.closeInputDialog()
1280
+ return
1281
+ }
1282
+ this.inputError = ''
1283
+ this.inputFieldErrors = []
1284
+ // THE DIALOG STAYS OPEN WHILE THE MOVE RUNS, with its confirm
1285
+ // button busy. Closing it first left the person looking at an
1286
+ // unchanged strip for as long as the transition took, which on a
1287
+ // live case was long enough to reload the page thinking it had
1288
+ // not been sent.
1289
+ const moved = await this.performMove(pending.__request, data)
1290
+ if (this.pendingAction !== pending) {
1291
+ // Closed while the move ran; nothing left to update.
1232
1292
  return
1233
1293
  }
1234
- this.performMove(pending.__request, data)
1294
+ if (moved) {
1295
+ this.closeInputDialog()
1296
+ return
1297
+ }
1298
+ // Refused. The sentence goes into the dialog the person is looking
1299
+ // at, with what they typed still in it.
1300
+ this.inputError = this.moveError
1301
+ this.inputFieldErrors = this.lastFieldErrors
1302
+ this.moveError = ''
1303
+ },
1304
+
1305
+ /**
1306
+ * Close the input dialog and forget the refusal it was showing.
1307
+ *
1308
+ * @return {void}
1309
+ */
1310
+ closeInputDialog() {
1311
+ this.pendingAction = null
1312
+ this.inputError = ''
1313
+ this.inputFieldErrors = []
1235
1314
  },
1236
1315
 
1237
1316
  /**
@@ -1239,11 +1318,17 @@ export default {
1239
1318
  *
1240
1319
  * @param {{stage: {id: string, label: string}, action: string}} request The move.
1241
1320
  * @param {object} [data] The collected transition inputs, when any.
1242
- * @return {Promise<void>}
1321
+ * @return {Promise<boolean>} Whether the move was made.
1243
1322
  */
1244
1323
  async performMove(request, data) {
1245
1324
  this.busy = true
1325
+ this.movingTo = request.stage.id
1246
1326
  this.moveError = ''
1327
+ this.lastFieldErrors = []
1328
+ // Said as the move starts, not only when it lands: a screen reader
1329
+ // user otherwise hears nothing for as long as the server takes.
1330
+ this.statusMessage = this.movingMessage
1331
+ let moved = false
1247
1332
  try {
1248
1333
  if (this.lifecycleMode) {
1249
1334
  await performTransition(this.recordId, request.action, data)
@@ -1270,11 +1355,16 @@ export default {
1270
1355
  if (this.lifecycleMode) {
1271
1356
  await this.loadActions()
1272
1357
  }
1358
+ moved = true
1273
1359
  } catch (error) {
1274
1360
  this.moveError = transitionError(error, this.tr('The move could not be made'))
1361
+ this.lastFieldErrors = transitionFieldErrors(error)
1362
+ this.statusMessage = this.moveError
1275
1363
  } finally {
1276
1364
  this.busy = false
1365
+ this.movingTo = ''
1277
1366
  }
1367
+ return moved
1278
1368
  },
1279
1369
 
1280
1370
  /**
@@ -1501,6 +1591,13 @@ export default {
1501
1591
  padding: 8px 0;
1502
1592
  }
1503
1593
 
1594
+ /* The stage a running move leads to: a small spinner before its label. */
1595
+ .cn-stages-widget__moving {
1596
+ display: inline-flex;
1597
+ vertical-align: middle;
1598
+ margin-inline-end: 4px;
1599
+ }
1600
+
1504
1601
  .cn-stages-widget__notice {
1505
1602
  margin: 0;
1506
1603
  color: var(--color-text-maxcontrast);
@@ -163,6 +163,7 @@ import widgetLink from '../../mixins/widgetLink.js'
163
163
  import { useObjectStore } from '../../store/useObjectStore.js'
164
164
  import { resolveObjectOpType } from '../../utils/actionsDispatcher.js'
165
165
  import { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'
166
+ import { aggFilterParam } from '../../utils/fetchAggregate.js'
166
167
  import { fetchListTotal, needsListEndpoint } from '../../utils/fetchFilterCounts.js'
167
168
  import { formatMetricValue, unwrapAppConfig } from '../../utils/formatMetric.js'
168
169
  import { dropOptionalUnresolved, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
@@ -1459,7 +1460,7 @@ export default {
1459
1460
  * @return {string}
1460
1461
  */
1461
1462
  formattedValue() {
1462
- return formatMetricValue(this.displayValue, this.content.format, this.configCtx, this.objectCtx?.object || null)
1463
+ return formatMetricValue(this.displayValue, this.content.format, this.configCtx, this.objectCtx?.object || null, this.effectiveTranslate)
1463
1464
  },
1464
1465
 
1465
1466
  /**
@@ -1783,7 +1784,8 @@ export default {
1783
1784
  target[`filter[${k}][${op}]`] = ov
1784
1785
  }
1785
1786
  } else if (v !== '' && v !== null && v !== undefined) {
1786
- target[`filter[${k}]`] = v
1787
+ // `field[gte]` goes out as `filter[field][gte]`, not `filter[field[gte]]`.
1788
+ target[aggFilterParam(k)] = v
1787
1789
  }
1788
1790
  }
1789
1791
  },
@@ -17,10 +17,29 @@
17
17
  <template>
18
18
  <div
19
19
  class="cn-stats-block-widget"
20
- :class="[iconClass, { 'cn-stats-block-widget--multi': hasEntries && !isRows, 'cn-stats-block-widget--rows': isRows }]">
20
+ :class="[iconClass, { 'cn-stats-block-widget--multi': hasEntries && !isRows && !isGrid, 'cn-stats-block-widget--rows': isRows, 'cn-stats-block-widget--grid': isGrid, 'cn-stats-block-widget--tiles-box': isGrid && tiles === 'box' }]">
21
+ <!-- Grid layout: the `stats` (each a stat tile's content, so every
22
+ source kind a stat takes: ratio, elapsed, computed...) in `columns`
23
+ columns, inside this one card (the board's "Je week tot nu toe"
24
+ card). The card's title is the widget wrapper's, as on every other
25
+ card (`showTitle: true` on the widget): a heading drawn here took
26
+ the page's h2 size under a theme. -->
27
+ <template v-if="isGrid">
28
+ <div
29
+ class="cn-stats-block-widget__grid"
30
+ :style="{ '--cn-stats-block-columns': gridColumns }"
31
+ data-testid="cn-stats-block-grid">
32
+ <CnStatWidget
33
+ v-for="(stat, index) in stats"
34
+ :key="stat.id || index"
35
+ class="cn-stats-block-widget__grid-stat"
36
+ :content="stat"
37
+ data-testid="cn-stats-block-grid-stat" />
38
+ </div>
39
+ </template>
21
40
  <!-- Rows layout: a title and one label-left, number-right row per entry
22
41
  (the board's "Mijn Zuiddrecht nu" card). -->
23
- <template v-if="isRows">
42
+ <template v-else-if="isRows">
24
43
  <h2 v-if="title" class="cn-stats-block-widget__rows-title" data-testid="cn-stats-block-rows-title">
25
44
  {{ tl(title) }}
26
45
  </h2>
@@ -101,7 +120,9 @@
101
120
  <script>
102
121
  import { subscribe, unsubscribe } from '@nextcloud/event-bus'
103
122
  import CnStatsBlock from '../CnStatsBlock/CnStatsBlock.vue'
123
+ import CnStatWidget from '../CnStatWidget/CnStatWidget.vue'
104
124
  import { useDataSource } from '../../composables/useDataSource.js'
125
+ import { aggFilterParam } from '../../utils/fetchAggregate.js'
105
126
  import { formatMetricValue, normalizeMetricFormat, unwrapAppConfig } from '../../utils/formatMetric.js'
106
127
  import { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'
107
128
  import { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
@@ -180,7 +201,7 @@ const PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh'
180
201
  export default {
181
202
  name: 'CnStatsBlockWidget',
182
203
 
183
- components: { CnStatsBlock },
204
+ components: { CnStatsBlock, CnStatWidget },
184
205
 
185
206
  inject: {
186
207
  /**
@@ -241,6 +262,47 @@ export default {
241
262
  default: () => [],
242
263
  },
243
264
 
265
+ /**
266
+ * The tiles of the grid layout (`layout: "grid"`): each is a stat
267
+ * tile's `content` (`label`, `caption`, `format`, `source` with any
268
+ * kind a `type: "stat"` widget takes: aggregate, `ratio`, `elapsed`,
269
+ * `computed`, `weighted`, `endpoint`). They share this one card; its
270
+ * title is the widget wrapper's (`showTitle: true` on the widget).
271
+ * Used instead of `dataSource` / `entries`.
272
+ *
273
+ * @type {Array<object>}
274
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
275
+ */
276
+ stats: {
277
+ type: Array,
278
+ default: () => [],
279
+ },
280
+
281
+ /**
282
+ * Columns of the grid layout (1 to 4, default 2).
283
+ *
284
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
285
+ */
286
+ columns: {
287
+ type: Number,
288
+ default: 2,
289
+ },
290
+
291
+ /**
292
+ * How the grid layout draws its tiles: `'box'` gives each tile a
293
+ * surface of its own (a grey ground, 10px radius, 14px 16px padding),
294
+ * as board DcRapportages draws its efficiency figures. Empty (the
295
+ * default) keeps the tiles as parts of the one card, without a box
296
+ * (board PqMijnWerk "Je week tot nu toe").
297
+ *
298
+ * @type {'box'|null}
299
+ * @spec openspec/changes/screens-stats-block-tile-box/specs/stats-block-grid/spec.md#requirement-a-stats-block-grid-can-box-its-tiles
300
+ */
301
+ tiles: {
302
+ type: String,
303
+ default: null,
304
+ },
305
+
244
306
  /**
245
307
  * How the single-source value is shown: a style name (`'currency'`,
246
308
  * `'number'`, `'percent'`, `'decimal'`, `'duration-hours'`) or
@@ -400,6 +462,28 @@ export default {
400
462
  return this.layout === 'rows'
401
463
  },
402
464
 
465
+ /**
466
+ * Whether the widget draws its `stats` as tiles in a grid
467
+ * (`layout: "grid"` with at least one stat).
468
+ *
469
+ * @return {boolean}
470
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
471
+ */
472
+ isGrid() {
473
+ return this.layout === 'grid' && Array.isArray(this.stats) && this.stats.length > 0
474
+ },
475
+
476
+ /**
477
+ * The grid's column count, clamped to 1-4.
478
+ *
479
+ * @return {number}
480
+ * @spec openspec/changes/screens-stats-block-grid/specs/stats-block-grid/spec.md#requirement-a-stats-block-can-hold-stat-tiles-in-a-grid
481
+ */
482
+ gridColumns() {
483
+ const n = Math.floor(Number(this.columns))
484
+ return Number.isFinite(n) ? Math.min(4, Math.max(1, n)) : 2
485
+ },
486
+
403
487
  /**
404
488
  * The rows of the rows layout: one per entry, or a single row from the
405
489
  * `dataSource` (labelled by `countLabel`). A row is `{ key, label,
@@ -497,7 +581,7 @@ export default {
497
581
  if (!fmt || this.loading) {
498
582
  return null
499
583
  }
500
- return formatMetricValue(this.resolvedCount, fmt, this.configCtx, this.pageObject)
584
+ return formatMetricValue(this.resolvedCount, fmt, this.configCtx, this.pageObject, this.tl)
501
585
  },
502
586
 
503
587
  /**
@@ -529,7 +613,7 @@ export default {
529
613
  return {
530
614
  // The value as the entry's `format` says, money in the
531
615
  // currency the entry names; null keeps the plain count.
532
- formatted: fmt && resolved ? formatMetricValue(raw, fmt, this.configCtx, this.pageObject) : null,
616
+ formatted: fmt && resolved ? formatMetricValue(raw, fmt, this.configCtx, this.pageObject, this.tl) : null,
533
617
  entry: entry || {},
534
618
  key: `${(entry && entry.register) || ''}/${(entry && entry.schema) || ''}/${i}`,
535
619
  count: resolved ? raw : 0,
@@ -617,7 +701,8 @@ export default {
617
701
  // the cross-prop rule lives here (and, for manifests, in
618
702
  // validateManifestV2's post-schema check).
619
703
  const hasDataSource = !!this.dataSource
620
- if (hasDataSource === this.hasEntries) {
704
+ // A grid of stat tiles carries its sources in `stats`.
705
+ if (!this.isGrid && hasDataSource === this.hasEntries) {
621
706
  // eslint-disable-next-line no-console
622
707
  console.error('[CnStatsBlockWidget] Exactly one of `dataSource` / `entries` must be provided.')
623
708
  }
@@ -764,7 +849,8 @@ export default {
764
849
  params[`filter[${k}][${op}]`] = ov
765
850
  }
766
851
  } else if (v !== '' && v !== null && v !== undefined) {
767
- params[`filter[${k}]`] = v
852
+ // `field[gte]` goes out as `filter[field][gte]`, not `filter[field[gte]]`.
853
+ params[aggFilterParam(k)] = v
768
854
  }
769
855
  }
770
856
  const res = await axios.get(url, { params })
@@ -890,6 +976,38 @@ export default {
890
976
  color: var(--color-text-maxcontrast);
891
977
  }
892
978
 
979
+ /* `width: 100%` plus this padding overflowed the card by 48px: the last
980
+ column ran past the card's right edge (visible once the tiles have a box). */
981
+ .cn-stats-block-widget--grid {
982
+ box-sizing: border-box;
983
+ padding: 4px 24px 20px;
984
+ }
985
+
986
+ .cn-stats-block-widget__grid {
987
+ display: grid;
988
+ grid-template-columns: repeat(var(--cn-stats-block-columns, 2), minmax(0, 1fr));
989
+ gap: 16px 20px;
990
+ }
991
+
992
+ /* A tile in the grid is a part of the card, not a card: no box, no inset. */
993
+ .cn-stats-block-widget__grid-stat {
994
+ padding: 0;
995
+ min-width: 0;
996
+ }
997
+
998
+ /* `tiles: "box"`: each tile on a grey ground of its own (board DcRapportages,
999
+ "Vergaderingen en efficiëntie"), 12px apart. */
1000
+ .cn-stats-block-widget--tiles-box .cn-stats-block-widget__grid {
1001
+ gap: 12px;
1002
+ }
1003
+
1004
+ .cn-stats-block-widget--tiles-box .cn-stats-block-widget__grid-stat {
1005
+ box-sizing: border-box;
1006
+ padding: 14px 16px;
1007
+ border-radius: 10px;
1008
+ background: var(--color-background-hover);
1009
+ }
1010
+
893
1011
  .cn-stats-block-widget__rows {
894
1012
  display: flex;
895
1013
  flex-direction: column;