@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.
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +1 -1
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
- package/dist/esm/components/CnBodySections/CnBodySections.vue.js +1 -1
- package/dist/esm/components/CnBodySections/CnBodySections.vue2.js +22 -1
- package/dist/esm/components/CnBodySections/CnBodySections.vue2.js.map +1 -1
- package/dist/esm/components/CnBodySections/CnBodySections.vue3.js.map +1 -1
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +47 -4
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue.js +1 -1
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +239 -3
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +5 -5
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +5 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue.js +1 -1
- package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js +1 -1
- package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnDeltaWidget/CnDeltaWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +203 -4
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
- package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +38 -1
- package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +45 -5
- package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue.js +1 -1
- package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js +1 -1
- package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnGaugeWidget/CnGaugeWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js +29 -6
- package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnInteractionFormWidget/CnInteractionFormWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue.js +1 -1
- package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +126 -2
- package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js +24 -2
- package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +37 -2
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnReportsPage/CnReportsPage.vue.js +1 -1
- package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js +135 -2
- package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
- package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js +95 -43
- package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue.js +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +72 -5
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +33 -8
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue.js +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +17 -2
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -3
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue.js +1 -1
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +16 -0
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue.js +1 -1
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js +2 -1
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js.map +1 -1
- package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
- package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +68 -6
- package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
- package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +133 -105
- package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +16 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +38 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +525 -318
- package/dist/esm/utils/buildManifest.js +37 -3
- package/dist/esm/utils/buildManifest.js.map +1 -1
- package/dist/esm/utils/detailWidgetVisibility.js +149 -0
- package/dist/esm/utils/detailWidgetVisibility.js.map +1 -0
- package/dist/esm/utils/formatMetric.js +39 -3
- package/dist/esm/utils/formatMetric.js.map +1 -1
- package/dist/esm/utils/gridStack.js +40 -1
- package/dist/esm/utils/gridStack.js.map +1 -1
- package/dist/esm/utils/humaniseKey.js +41 -0
- package/dist/esm/utils/humaniseKey.js.map +1 -0
- package/dist/esm/utils/notificationRuleLabel.js +6 -23
- package/dist/esm/utils/notificationRuleLabel.js.map +1 -1
- package/dist/esm/utils/resolveManifestTokens.js +1 -0
- package/dist/esm/utils/resolveManifestTokens.js.map +1 -1
- package/dist/esm/utils/sentinelTokens.js +9 -0
- package/dist/esm/utils/sentinelTokens.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +2250 -708
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +525 -318
- package/l10n/en.json +16 -1
- package/l10n/nl.json +38 -1
- package/package.json +1 -1
- package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +1 -1
- package/src/components/CnBodySections/CnBodySections.vue +22 -1
- package/src/components/CnCountdownWidget/CnCountdownWidget.vue +47 -4
- package/src/components/CnDashboardGrid/CnDashboardGrid.vue +244 -8
- package/src/components/CnDashboardPage/CnDashboardPage.vue +5 -0
- package/src/components/CnDeltaWidget/CnDeltaWidget.vue +1 -1
- package/src/components/CnDetailPage/CnDetailPage.md +2 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +203 -4
- package/src/components/CnFieldListWidget/CnFieldListWidget.vue +63 -1
- package/src/components/CnGaugeWidget/CnGaugeWidget.vue +1 -1
- package/src/components/CnInteractionFormWidget/CnInteractionFormWidget.vue +29 -6
- package/src/components/CnObjectDataWidget/CnObjectDataWidget.md +1 -1
- package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +154 -5
- package/src/components/CnRecordListWidget/CnRecordListWidget.vue +37 -2
- package/src/components/CnReportsPage/CnReportsPage.vue +179 -1
- package/src/components/CnStagesWidget/CnStagesWidget.vue +103 -6
- package/src/components/CnStatWidget/CnStatWidget.vue +1 -1
- package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +34 -3
- package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -3
- package/src/components/CnWalkthrough/CnWalkthrough.vue +16 -0
- package/src/components/CnWeekStripWidget/CnWeekStripWidget.vue +2 -1
- package/src/css/form-field.css +14 -0
- package/src/css/form-widget.css +11 -0
- package/src/css/kpi-card.css +13 -0
- package/src/css/look-board-reports.css +58 -0
- package/src/css/look-board.css +31 -0
- package/src/dialogs/CnTransitionInputDialog.vue +87 -7
- package/src/index.js +2 -0
- package/src/schemas/app-manifest-v2.schema.json +23 -2
- package/src/utils/buildManifest.js +36 -2
- package/src/utils/detailWidgetVisibility.js +145 -0
- package/src/utils/formatMetric.js +38 -2
- package/src/utils/gridStack.js +39 -0
- package/src/utils/humaniseKey.js +38 -0
- package/src/utils/notificationRuleLabel.js +5 -23
- package/src/utils/resolveManifestTokens.js +1 -0
- package/src/utils/sentinelTokens.js +9 -0
- package/src/utils/validateManifestV2.compiled.js +3 -3
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
@closing="$emit('close')">
|
|
15
15
|
<div
|
|
16
16
|
class="cn-transition-input"
|
|
17
|
+
:aria-busy="busy ? 'true' : null"
|
|
17
18
|
data-testid="cn-modal"
|
|
18
19
|
data-testid-modal="cn-transition-input-dialog">
|
|
19
20
|
<!-- A refusal keeps this dialog open with what was typed still in
|
|
@@ -29,8 +30,18 @@
|
|
|
29
30
|
class="cn-transition-input__field"
|
|
30
31
|
:class="{ 'cn-transition-input__field--refused': isRefused(field.key) }"
|
|
31
32
|
:data-testid="`cn-transition-input-${field.key}`">
|
|
33
|
+
<!-- An input that declares its choices is picked from them. -->
|
|
34
|
+
<NcSelect v-if="field.widget === 'select'"
|
|
35
|
+
:modelValue="selectedOption(field)"
|
|
36
|
+
:options="field.options"
|
|
37
|
+
label="label"
|
|
38
|
+
:inputLabel="requiredLabel(field)"
|
|
39
|
+
:clearable="!field.required"
|
|
40
|
+
:data-testid="`cn-transition-input-select-${field.key}`"
|
|
41
|
+
@update:modelValue="setValue(field.key, $event && $event.value !== undefined ? $event.value : '')" />
|
|
42
|
+
|
|
32
43
|
<!-- An input that points at another record is picked, never typed. -->
|
|
33
|
-
<div v-if="field.widget === 'reference'" class="cn-transition-input__reference">
|
|
44
|
+
<div v-else-if="field.widget === 'reference'" class="cn-transition-input__reference">
|
|
34
45
|
<label class="cn-transition-input__label">{{ requiredLabel(field) }}</label>
|
|
35
46
|
<CnResourceSelect
|
|
36
47
|
:register="field.referenceRegister"
|
|
@@ -128,11 +139,18 @@
|
|
|
128
139
|
@click="$emit('close')">
|
|
129
140
|
{{ t('nextcloud-vue', 'Cancel') }}
|
|
130
141
|
</NcButton>
|
|
142
|
+
<!-- Busy while the parent's POST runs: a transition can take
|
|
143
|
+
seconds, and a confirm button that only greys out reads as one
|
|
144
|
+
that did nothing. -->
|
|
131
145
|
<NcButton
|
|
132
146
|
variant="primary"
|
|
133
147
|
:disabled="!canConfirm"
|
|
148
|
+
:aria-busy="busy ? 'true' : null"
|
|
134
149
|
data-testid="cn-transition-input-confirm"
|
|
135
150
|
@click="onConfirm">
|
|
151
|
+
<template v-if="busy" #icon>
|
|
152
|
+
<NcLoadingIcon :size="20" data-testid="cn-transition-input-busy" />
|
|
153
|
+
</template>
|
|
136
154
|
{{ confirmLabel }}
|
|
137
155
|
</NcButton>
|
|
138
156
|
</template>
|
|
@@ -141,7 +159,7 @@
|
|
|
141
159
|
|
|
142
160
|
<script>
|
|
143
161
|
import { translate as t } from '@nextcloud/l10n'
|
|
144
|
-
import { NcButton, NcCheckboxRadioSwitch, NcTextArea, NcTextField } from '@nextcloud/vue'
|
|
162
|
+
import { NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcTextArea, NcTextField } from '@nextcloud/vue'
|
|
145
163
|
import CnDialog from '../components/CnDialog/CnDialog.vue'
|
|
146
164
|
import CnResourceSelect from '../components/CnResourceSelect/CnResourceSelect.vue'
|
|
147
165
|
import { dialogBoardMixin } from '../mixins/dialogBoard.js'
|
|
@@ -164,7 +182,13 @@ import { fieldsFromSchema } from '../utils/schema.js'
|
|
|
164
182
|
* from the property `title`, booleans render as a switch, numbers as a number
|
|
165
183
|
* field, and long text (`maxLength > 255` or a `textarea`/`markdown` format)
|
|
166
184
|
* as a textarea. Anything else — including a field the schema does not
|
|
167
|
-
* declare — falls back to a plain labelled text input.
|
|
185
|
+
* declare — falls back to a plain labelled text input.
|
|
186
|
+
*
|
|
187
|
+
* An input may also say how it is asked for, which matters when the choices
|
|
188
|
+
* depend on the record rather than on its schema: `label` names the field,
|
|
189
|
+
* and `options: [{ value, label }]` turns it into a picker that shows each
|
|
190
|
+
* `label` and sends the chosen `value`. An input that declares neither is
|
|
191
|
+
* rendered from the schema as before. Deliberately minimal:
|
|
168
192
|
* text, textarea, number and checkbox cover transition inputs; this is not a
|
|
169
193
|
* form engine (use `CnFormDialog` for full object forms).
|
|
170
194
|
*
|
|
@@ -186,9 +210,11 @@ export default {
|
|
|
186
210
|
CnResourceSelect,
|
|
187
211
|
CnDialog,
|
|
188
212
|
NcButton,
|
|
213
|
+
NcLoadingIcon,
|
|
189
214
|
NcTextField,
|
|
190
215
|
NcTextArea,
|
|
191
216
|
NcCheckboxRadioSwitch,
|
|
217
|
+
NcSelect,
|
|
192
218
|
},
|
|
193
219
|
|
|
194
220
|
mixins: [dialogBoardMixin],
|
|
@@ -199,7 +225,11 @@ export default {
|
|
|
199
225
|
* `x-openregister-lifecycle.transitions.<action>.inputs` (server-derived
|
|
200
226
|
* or config-declared); `label` doubles as dialog title + confirm label.
|
|
201
227
|
*
|
|
202
|
-
*
|
|
228
|
+
* An input may carry a `label` for its field and `options` to pick
|
|
229
|
+
* from, each `{ value, label }`; the picker shows the label and sends
|
|
230
|
+
* the value.
|
|
231
|
+
*
|
|
232
|
+
* @type {{action?: string, label?: string, inputs?: Array<{field: string, required?: boolean, label?: string, options?: Array<{value: string, label?: string}>}>}}
|
|
203
233
|
*/
|
|
204
234
|
transition: {
|
|
205
235
|
type: Object,
|
|
@@ -296,6 +326,8 @@ export default {
|
|
|
296
326
|
required: input.required === true,
|
|
297
327
|
picker: input.picker && typeof input.picker === 'object' ? input.picker : null,
|
|
298
328
|
fields: Array.isArray(input.fields) ? input.fields.filter((f) => typeof f === 'string') : null,
|
|
329
|
+
label: typeof input.label === 'string' && input.label.trim() !== '' ? input.label.trim() : null,
|
|
330
|
+
options: this.declaredOptions(input.options),
|
|
299
331
|
}))
|
|
300
332
|
},
|
|
301
333
|
|
|
@@ -305,26 +337,40 @@ export default {
|
|
|
305
337
|
* widget from the type/format); the widget is then clamped to the minimal
|
|
306
338
|
* set this dialog renders (text / textarea / number / checkbox). Fields
|
|
307
339
|
* the schema does not declare fall back to a plain text input.
|
|
340
|
+
*
|
|
341
|
+
* An input that declares `options` is a picker whatever the schema
|
|
342
|
+
* says, because its choices are the ones this transition offers on
|
|
343
|
+
* this record. A declared `label` names the field over the schema's.
|
|
308
344
|
*/
|
|
309
345
|
fields() {
|
|
310
346
|
const keys = this.inputs.map((input) => input.field)
|
|
311
347
|
const resolved = fieldsFromSchema(this.schema, { include: keys, includeReadOnly: true })
|
|
312
348
|
return this.inputs.map((input) => {
|
|
313
349
|
const field = resolved.find((f) => f.key === input.field)
|
|
350
|
+
if (input.options) {
|
|
351
|
+
return {
|
|
352
|
+
key: input.field,
|
|
353
|
+
label: input.label || (field && field.label) || input.field,
|
|
354
|
+
description: (field && field.description) || '',
|
|
355
|
+
widget: 'select',
|
|
356
|
+
options: input.options,
|
|
357
|
+
required: input.required,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
314
360
|
const prop = (this.schema && this.schema.properties && this.schema.properties[input.field]) || null
|
|
315
361
|
if (prop && prop.type === 'object' && prop.properties && typeof prop.properties === 'object') {
|
|
316
362
|
return this.objectField(input, prop)
|
|
317
363
|
}
|
|
318
364
|
if (!field) {
|
|
319
|
-
return { key: input.field, label: input.field, description: '', widget: 'text', required: input.required }
|
|
365
|
+
return { key: input.field, label: input.label || input.field, description: '', widget: 'text', required: input.required }
|
|
320
366
|
}
|
|
321
367
|
if (field.reference && field.reference.multiple !== true) {
|
|
322
368
|
const register = field.reference.register || this.register
|
|
323
369
|
if (register !== '') {
|
|
324
|
-
return { ...field, widget: 'reference', referenceRegister: register, picker: input.picker, required: input.required }
|
|
370
|
+
return { ...field, label: input.label || field.label, widget: 'reference', referenceRegister: register, picker: input.picker, required: input.required }
|
|
325
371
|
}
|
|
326
372
|
}
|
|
327
|
-
return { ...field, widget: this.clampWidget(field.widget), required: input.required }
|
|
373
|
+
return { ...field, label: input.label || field.label, widget: this.clampWidget(field.widget), required: input.required }
|
|
328
374
|
})
|
|
329
375
|
},
|
|
330
376
|
|
|
@@ -438,6 +484,40 @@ export default {
|
|
|
438
484
|
}
|
|
439
485
|
},
|
|
440
486
|
|
|
487
|
+
/**
|
|
488
|
+
* The choices an input declares, as `[{ value, label }]`, or null when
|
|
489
|
+
* it declares none. An option without a value is dropped, because it
|
|
490
|
+
* cannot be sent; one without a label shows its value.
|
|
491
|
+
*
|
|
492
|
+
* @param {unknown} options The input's declared `options`.
|
|
493
|
+
* @return {Array<{value: string, label: string}>|null}
|
|
494
|
+
*/
|
|
495
|
+
declaredOptions(options) {
|
|
496
|
+
if (!Array.isArray(options)) {
|
|
497
|
+
return null
|
|
498
|
+
}
|
|
499
|
+
const usable = options
|
|
500
|
+
.filter((option) => option && typeof option === 'object'
|
|
501
|
+
&& (typeof option.value === 'string' || typeof option.value === 'number')
|
|
502
|
+
&& String(option.value).trim() !== '')
|
|
503
|
+
.map((option) => ({
|
|
504
|
+
value: String(option.value),
|
|
505
|
+
label: typeof option.label === 'string' && option.label.trim() !== '' ? option.label.trim() : String(option.value),
|
|
506
|
+
}))
|
|
507
|
+
return usable.length > 0 ? usable : null
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The option a picker field currently holds, or null.
|
|
512
|
+
*
|
|
513
|
+
* @param {object} field The select field descriptor.
|
|
514
|
+
* @return {{value: string, label: string}|null}
|
|
515
|
+
*/
|
|
516
|
+
selectedOption(field) {
|
|
517
|
+
const value = this.values[field.key]
|
|
518
|
+
return field.options.find((option) => option.value === String(value ?? '')) || null
|
|
519
|
+
},
|
|
520
|
+
|
|
441
521
|
/**
|
|
442
522
|
* Ids a reference picker must not offer: the current record, when the
|
|
443
523
|
* input's `picker.excludeSelf` says so.
|
package/src/index.js
CHANGED
|
@@ -542,6 +542,8 @@ export { currentTheme, useCurrentTheme } from './composables/useCurrentTheme.js'
|
|
|
542
542
|
export { ROADMAP_LABEL_BLOCKLIST } from './utils/roadmapLabelBlocklist.js'
|
|
543
543
|
// The words a person reads for a notification rule key (never the raw key).
|
|
544
544
|
export { humaniseRuleKey, notificationRuleLabel } from './utils/notificationRuleLabel.js'
|
|
545
|
+
// An identifier-shaped key made readable (`shortDescription` → "Short description").
|
|
546
|
+
export { humaniseKey } from './utils/humaniseKey.js'
|
|
545
547
|
|
|
546
548
|
// Shared field validators. These are UX affordances that run in the browser —
|
|
547
549
|
// they do NOT replace server-side validation, which stays authoritative at the
|
|
@@ -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.
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -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.
|
package/src/utils/gridStack.js
CHANGED
|
@@ -53,3 +53,42 @@ export function readGridGeometry(items) {
|
|
|
53
53
|
}
|
|
54
54
|
return map
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where the items under a growing cell go so they keep their order: every item
|
|
59
|
+
* the growth would overlap moves down by the growth, and so does every item
|
|
60
|
+
* that move would overlap in turn. Items beside the cell stay put.
|
|
61
|
+
*
|
|
62
|
+
* GridStack resolves the same collision itself, but not in order: when
|
|
63
|
+
* pipelinq's size-to-content banner grew from two rows to three, the
|
|
64
|
+
* full-width card under the stat tiles landed below the lists.
|
|
65
|
+
*
|
|
66
|
+
* @spec openspec/changes/dashboard-growth-keeps-order/specs/dashboard-grid/spec.md#requirement-a-growing-cell-keeps-the-order-of-the-cards-under-it
|
|
67
|
+
* @param {Array<{x: number, y: number, w: number, h: number}>} nodes The grid's items (the growing one among them).
|
|
68
|
+
* @param {{x: number, y: number, w: number, h: number}} node The growing item.
|
|
69
|
+
* @param {number} h Its new height, in grid rows.
|
|
70
|
+
* @return {Array<{node: object, y: number}>} The items to move and their new row, bottom-most first.
|
|
71
|
+
*/
|
|
72
|
+
export function planGrowth(nodes, node, h) {
|
|
73
|
+
const delta = h - node.h
|
|
74
|
+
if (!(delta > 0)) {
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
const moved = new Map()
|
|
78
|
+
const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
|
|
79
|
+
const queue = [{ x: node.x, y: node.y, w: node.w, h }]
|
|
80
|
+
while (queue.length > 0) {
|
|
81
|
+
const rect = queue.shift()
|
|
82
|
+
for (const other of nodes) {
|
|
83
|
+
if (other === node || moved.has(other) || !overlaps(rect, other)) {
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
const next = { x: other.x, y: other.y + delta, w: other.w, h: other.h }
|
|
87
|
+
moved.set(other, next.y)
|
|
88
|
+
queue.push(next)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...moved.entries()]
|
|
92
|
+
.map(([item, y]) => ({ node: item, y }))
|
|
93
|
+
.sort((a, b) => b.y - a.y)
|
|
94
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* Make an identifier-shaped key readable for a person. Pure: no Vue.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split camelCase, snake_case and kebab-case into words, keep acronyms, and
|
|
10
|
+
* capitalise only the first word.
|
|
11
|
+
*
|
|
12
|
+
* `caseAssigned` → "Case assigned", `object_created` → "Object created",
|
|
13
|
+
* `newBRPRecord` → "New BRP record".
|
|
14
|
+
*
|
|
15
|
+
* @param {string} key The key.
|
|
16
|
+
* @return {string} A readable sentence-case label, or the key when it has no words.
|
|
17
|
+
* @spec openspec/changes/data-widget-nested-objects/specs/data-widget-nested-objects/spec.md#requirement-a-nested-object-reads-as-a-list-per-key
|
|
18
|
+
*/
|
|
19
|
+
export function humaniseKey(key) {
|
|
20
|
+
const words = String(key || '')
|
|
21
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
22
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
23
|
+
.split(/[\s_\-.]+/)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
if (words.length === 0) {
|
|
26
|
+
return String(key || '')
|
|
27
|
+
}
|
|
28
|
+
return words
|
|
29
|
+
.map((word, index) => {
|
|
30
|
+
const isAcronym = word.length > 1 && word === word.toUpperCase() && /[A-Z]/.test(word)
|
|
31
|
+
if (isAcronym) {
|
|
32
|
+
return word
|
|
33
|
+
}
|
|
34
|
+
const lower = word.toLowerCase()
|
|
35
|
+
return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower
|
|
36
|
+
})
|
|
37
|
+
.join(' ')
|
|
38
|
+
}
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { humaniseKey } from './humaniseKey.js'
|
|
32
|
+
|
|
31
33
|
/**
|
|
32
34
|
* Pick the string for the current language out of a string or per-locale map.
|
|
33
35
|
*
|
|
@@ -54,34 +56,14 @@ export function resolveLocalised(value, language = 'en') {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
|
-
* Make a rule key readable
|
|
58
|
-
* words, keep acronyms, and capitalise only the first word.
|
|
59
|
-
*
|
|
60
|
-
* `caseAssigned` → "Case assigned", `object_created` → "Object created",
|
|
61
|
-
* `newBRPRecord` → "New BRP record".
|
|
59
|
+
* Make a rule key readable (`caseAssigned` → "Case assigned"); see `humaniseKey`.
|
|
62
60
|
*
|
|
63
61
|
* @param {string} key The rule key.
|
|
64
62
|
* @return {string} A readable sentence-case label, or the key when it has no words.
|
|
63
|
+
* @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md#requirement-a-notification-rule-reads-as-a-label
|
|
65
64
|
*/
|
|
66
65
|
export function humaniseRuleKey(key) {
|
|
67
|
-
|
|
68
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
69
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
70
|
-
.split(/[\s_\-.]+/)
|
|
71
|
-
.filter(Boolean)
|
|
72
|
-
if (words.length === 0) {
|
|
73
|
-
return String(key || '')
|
|
74
|
-
}
|
|
75
|
-
return words
|
|
76
|
-
.map((word, index) => {
|
|
77
|
-
const isAcronym = word.length > 1 && word === word.toUpperCase() && /[A-Z]/.test(word)
|
|
78
|
-
if (isAcronym) {
|
|
79
|
-
return word
|
|
80
|
-
}
|
|
81
|
-
const lower = word.toLowerCase()
|
|
82
|
-
return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower
|
|
83
|
-
})
|
|
84
|
-
.join(' ')
|
|
66
|
+
return humaniseKey(key)
|
|
85
67
|
}
|
|
86
68
|
|
|
87
69
|
/**
|
|
@@ -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
|
/**
|