@conduction/nextcloud-vue 2.117.0 → 2.118.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 (97) hide show
  1. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js +2 -1
  2. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +6 -6
  5. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  6. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js +2 -1
  7. package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js.map +1 -1
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +18 -0
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +14 -8
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  14. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -1
  15. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  17. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +9 -4
  18. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  19. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnMailTab/CnMailTab.vue2.js +2 -1
  21. package/dist/esm/components/CnMailTab/CnMailTab.vue2.js.map +1 -1
  22. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  23. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +71 -0
  24. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  25. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +25 -9
  26. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  27. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  28. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +11 -2
  29. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  30. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  31. package/dist/esm/components/CnSearchPage/CnSearchPage.vue.js +1 -1
  32. package/dist/esm/components/CnSearchPage/CnSearchPage.vue2.js +161 -5
  33. package/dist/esm/components/CnSearchPage/CnSearchPage.vue2.js.map +1 -1
  34. package/dist/esm/components/CnSearchPage/CnSearchPage.vue3.js +9 -9
  35. package/dist/esm/components/CnSearchPage/CnSearchPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  37. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +35 -5
  38. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  40. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js +9 -0
  41. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js.map +1 -0
  42. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js +435 -0
  43. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js.map +1 -0
  44. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js +300 -0
  45. package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js.map +1 -0
  46. package/dist/esm/composables/useEndpointSource.js +1 -36
  47. package/dist/esm/composables/useEndpointSource.js.map +1 -1
  48. package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
  49. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +57 -336
  50. package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
  51. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +18 -155
  52. package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
  53. package/dist/esm/index.js +2 -0
  54. package/dist/esm/index.js.map +1 -1
  55. package/dist/esm/l10n/en.json.js +2 -1
  56. package/dist/esm/l10n/en.json.js.map +1 -1
  57. package/dist/esm/l10n/nl.json.js +2 -1
  58. package/dist/esm/l10n/nl.json.js.map +1 -1
  59. package/dist/esm/library-style.js +1 -1
  60. package/dist/esm/nextcloud-vue.css +308 -229
  61. package/dist/esm/utils/actionsDispatcher.js +3 -2
  62. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  63. package/dist/esm/utils/interpolateUrlTokens.js +43 -0
  64. package/dist/esm/utils/interpolateUrlTokens.js.map +1 -0
  65. package/dist/esm/utils/labelsByField.js +72 -0
  66. package/dist/esm/utils/labelsByField.js.map +1 -0
  67. package/dist/esm/utils/listCreateAction.js +91 -0
  68. package/dist/esm/utils/listCreateAction.js.map +1 -0
  69. package/dist/esm/utils/transitionInputs.js +493 -0
  70. package/dist/esm/utils/transitionInputs.js.map +1 -0
  71. package/dist/esm/utils/visibleWhen.js +9 -2
  72. package/dist/esm/utils/visibleWhen.js.map +1 -1
  73. package/dist/nextcloud-vue.cjs.js +6690 -5404
  74. package/dist/nextcloud-vue.cjs.js.map +2 -2
  75. package/dist/nextcloud-vue.css +308 -229
  76. package/l10n/en.json +2 -1
  77. package/l10n/nl.json +2 -1
  78. package/package.json +1 -1
  79. package/src/components/CnActionsBar/CnActionsBar.vue +6 -6
  80. package/src/components/CnDashboardPage/CnDashboardPage.vue +27 -5
  81. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +11 -1
  82. package/src/components/CnIndexPage/CnIndexPage.vue +9 -4
  83. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +86 -0
  84. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +11 -2
  85. package/src/components/CnSearchPage/CnSearchPage.vue +170 -14
  86. package/src/components/CnTabsWidget/CnTabsWidget.vue +35 -5
  87. package/src/components/CnTransitionInputFields/CnTransitionInputFields.vue +750 -0
  88. package/src/components/CnTransitionInputFields/index.js +11 -0
  89. package/src/components/index.js +1 -0
  90. package/src/composables/useEndpointSource.js +3 -36
  91. package/src/dialogs/CnTransitionInputDialog.vue +73 -465
  92. package/src/index.js +2 -0
  93. package/src/utils/interpolateUrlTokens.js +40 -0
  94. package/src/utils/labelsByField.js +69 -0
  95. package/src/utils/listCreateAction.js +87 -0
  96. package/src/utils/transitionInputs.js +489 -0
  97. package/src/utils/visibleWhen.js +9 -2
@@ -0,0 +1,40 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ */
5
+
6
+ /**
7
+ * Interpolate `@page.<key>` / `@workspace.<key>` / `@config.<key>` /
8
+ * `@objectId` / `@object.<field>` tokens inside a URL string against the
9
+ * token context. `@page.*` is an alias for `@workspace.*` (both read the
10
+ * page-level workspace context). Unresolved tokens collapse to an empty
11
+ * string so a half-built URL never sends a literal `@page.period`.
12
+ *
13
+ * @param {string} str The raw URL (or any string) to interpolate.
14
+ * @param {{objectId?: (string|number), object?: object, workspace?: object, config?: object, range?: object}} [ctx] The token context.
15
+ * @return {string} The interpolated string.
16
+ */
17
+ export function interpolateUrlTokens(str, ctx) {
18
+ if (typeof str !== 'string') {
19
+ return str
20
+ }
21
+ const c = ctx || {}
22
+ const workspace = c.workspace || {}
23
+ const config = c.config || {}
24
+ return str.replace(/@(page|workspace)\.([A-Za-z0-9_]+)/g, (_, _ns, key) => {
25
+ const v = workspace[key]
26
+ return (v === undefined || v === null) ? '' : String(v)
27
+ }).replace(/@config\.([A-Za-z0-9_]+)/g, (_, key) => {
28
+ const v = config[key]
29
+ return (v === undefined || v === null) ? '' : String(v)
30
+ }).replace(/@range\.([A-Za-z0-9_]+)/g, (_, key) => {
31
+ const v = c.range && c.range[key]
32
+ return (v === undefined || v === null) ? '' : String(v)
33
+ }).replace(/@objectId/g, () => {
34
+ const id = c.objectId
35
+ return (id === undefined || id === null) ? '' : String(id)
36
+ }).replace(/@object\.([A-Za-z0-9_]+)/g, (_, field) => {
37
+ const v = c.object && c.object[field]
38
+ return (v === undefined || v === null) ? '' : String(v)
39
+ })
40
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * labelsByField: the label of each record whose `field` holds one of the given
6
+ * values, in one list request. For a list whose rows name another record by a
7
+ * field other than its id (a slug, a code), so a tab or a cell can read the
8
+ * record's name instead of the raw value.
9
+ *
10
+ * @module utils/labelsByField
11
+ */
12
+
13
+ /**
14
+ * Read a dot-path off a record.
15
+ *
16
+ * @param {object} row The record.
17
+ * @param {string} path The dot-path.
18
+ * @return {unknown} The value, or undefined.
19
+ */
20
+ function readPath(row, path) {
21
+ return String(path).split('.').reduce((value, key) => (value && typeof value === 'object' ? value[key] : undefined), row)
22
+ }
23
+
24
+ /**
25
+ * Label by value for the records of `register`/`schema` whose `field` is one
26
+ * of `values`. A value no record carries is left out; a failed request
27
+ * answers `{}`, so the caller shows the raw value.
28
+ *
29
+ * @param {object} source What to look up.
30
+ * @param {string} source.register The register slug.
31
+ * @param {string} source.schema The schema slug.
32
+ * @param {string} source.field The field the values are matched on.
33
+ * @param {string[]} source.values The values to label.
34
+ * @param {string} [source.labelField] The field holding the label; else title, then name.
35
+ * @param {(url: string, config: object) => Promise<{data: object}>} [get] HTTP GET; defaults to @nextcloud/axios.
36
+ * @return {Promise<Object<string, string>>} Label by value.
37
+ * @spec openspec/changes/quick-filters-from-value-field/specs/index-page/spec.md#requirement-quick-filter-tabs-can-name-a-record-matched-on-another-field
38
+ */
39
+ export async function labelsByField(source, get) {
40
+ const s = source || {}
41
+ const values = [...new Set((s.values || []).filter((v) => v !== null && v !== undefined && v !== '').map(String))]
42
+ if (!s.register || !s.schema || !s.field || values.length === 0) {
43
+ return {}
44
+ }
45
+ const [{ generateUrl }, fetcher] = await Promise.all([
46
+ import('@nextcloud/router'),
47
+ get ? Promise.resolve(get) : import('@nextcloud/axios').then((m) => m.default.get.bind(m.default)),
48
+ ])
49
+ const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', { register: s.register, schema: s.schema })
50
+ let rows
51
+ try {
52
+ const res = await fetcher(url, { params: { [s.field]: values, _limit: values.length } })
53
+ rows = Array.isArray(res?.data?.results) ? res.data.results : []
54
+ } catch {
55
+ return {}
56
+ }
57
+ const out = {}
58
+ for (const row of rows) {
59
+ const key = readPath(row, s.field)
60
+ if (key === undefined || key === null || !values.includes(String(key))) {
61
+ continue
62
+ }
63
+ const label = (s.labelField ? readPath(row, s.labelField) : undefined) ?? row?.title ?? row?.name
64
+ if (label !== undefined && label !== null && String(label) !== '') {
65
+ out[String(key)] = String(label)
66
+ }
67
+ }
68
+ return out
69
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * listCreateAction — what a list's Add does when the manifest declares a
6
+ * `content.createAction`, and whether it shows.
7
+ *
8
+ * A list's Add opens its own create form by default. `createAction` replaces
9
+ * that with one action in the manifest action shape (`open-modal`,
10
+ * `handler`, `navigate`, …), for a create that has to go through the app
11
+ * rather than straight to the object API. Its `visibleWhen` is evaluated
12
+ * against the record of the page the list sits on; while it fails, the Add
13
+ * is hidden and `hiddenNote` may say why.
14
+ *
15
+ * `hiddenNote` is a string, or a list of `{ when, text }` where the first
16
+ * entry whose `when` holds against the record wins. The text is returned
17
+ * untranslated; the caller runs it through the host translate.
18
+ *
19
+ * Both CnObjectListWidget (its own Add buttons) and CnDetailWidgetHost (the
20
+ * catalog Add in the card or panel menu) read this, so the two never
21
+ * disagree about whether Add is there.
22
+ *
23
+ * @module utils/listCreateAction
24
+ */
25
+
26
+ import { evaluateVisibleWhenLocal } from './visibleWhen.js'
27
+
28
+ /**
29
+ * The declared create action, or null.
30
+ *
31
+ * @param {object|null} content The list's `content` block.
32
+ * @return {object|null} The action.
33
+ */
34
+ export function listCreateAction(content) {
35
+ const action = content && content.createAction
36
+ if (!action || typeof action !== 'object' || Array.isArray(action) || typeof action.type !== 'string') {
37
+ return null
38
+ }
39
+ return action
40
+ }
41
+
42
+ /**
43
+ * The note for a hidden create action, untranslated, or ''.
44
+ *
45
+ * @param {string|Array<{when: object, text: string}>|undefined} note The declared `hiddenNote`.
46
+ * @param {object} record The page record.
47
+ * @return {string} The note.
48
+ */
49
+ function hiddenNoteFor(note, record) {
50
+ if (typeof note === 'string') {
51
+ return note
52
+ }
53
+ if (!Array.isArray(note)) {
54
+ return ''
55
+ }
56
+ const hit = note.find((entry) => entry && typeof entry.text === 'string' && evaluateVisibleWhenLocal(entry.when, record))
57
+ return hit ? hit.text : ''
58
+ }
59
+
60
+ /**
61
+ * Whether the create action shows against this record, and the note to show
62
+ * instead when it does not.
63
+ *
64
+ * Without a `visibleWhen` the action always shows. With one, it stays hidden
65
+ * until the record is known, so a page still loading does not flash an Add
66
+ * it is about to take away; no note is given then either.
67
+ *
68
+ * @param {object|null} content The list's `content` block.
69
+ * @param {object|null} record The page record, or null while it loads.
70
+ * @return {{action: (object|null), visible: boolean, note: string}} The state.
71
+ */
72
+ export function createActionState(content, record) {
73
+ const action = listCreateAction(content)
74
+ if (!action) {
75
+ return { action: null, visible: true, note: '' }
76
+ }
77
+ if (!action.visibleWhen) {
78
+ return { action, visible: true, note: '' }
79
+ }
80
+ if (!record || typeof record !== 'object') {
81
+ return { action, visible: false, note: '' }
82
+ }
83
+ if (evaluateVisibleWhenLocal(action.visibleWhen, record)) {
84
+ return { action, visible: true, note: '' }
85
+ }
86
+ return { action, visible: false, note: hiddenNoteFor(action.hiddenNote, record) }
87
+ }
@@ -0,0 +1,489 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * The declared inputs of a transition or an act, resolved into the fields a
6
+ * form draws, the check that they are complete and the values they send.
7
+ *
8
+ * Shared by CnTransitionInputDialog (the inputs in their own dialog) and
9
+ * CnTransitionInputFields (the same inputs drawn wherever a host puts them,
10
+ * such as under the chosen card of a list of acts), so the two never decide
11
+ * differently whether a form may be confirmed.
12
+ *
13
+ * @module utils/transitionInputs
14
+ * @spec openspec/changes/dq-abstract-forms/specs/transition-inputs/spec.md
15
+ */
16
+
17
+ import { formatDateForDisplay } from './dateDisplay.js'
18
+ import { readPath } from './readPath.js'
19
+ import { fieldsFromSchema } from './schema.js'
20
+
21
+ /**
22
+ * The picker value of an input's "Other" entry. Never sent: see `submittedValue`.
23
+ *
24
+ * @type {string}
25
+ */
26
+ export const OTHER_OPTION_VALUE = '__other'
27
+
28
+ /** Widgets an input may name outright, whatever the schema says. */
29
+ const DECLARED_WIDGETS = ['text', 'textarea', 'number', 'date', 'select', 'template']
30
+
31
+ /**
32
+ * A non-empty trimmed string, or null.
33
+ *
34
+ * @param {unknown} value The value.
35
+ * @return {string|null}
36
+ */
37
+ function text(value) {
38
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
39
+ }
40
+
41
+ /**
42
+ * The choices an input declares, as `[{ value, label }]`, or null when it
43
+ * declares none. An option without a value is dropped, because it cannot be
44
+ * sent; one without a label shows its value.
45
+ *
46
+ * @param {unknown} options The input's declared `options`.
47
+ * @return {Array<{value: string, label: string}>|null}
48
+ */
49
+ export function declaredOptions(options) {
50
+ if (!Array.isArray(options)) {
51
+ return null
52
+ }
53
+ const usable = options
54
+ .filter((option) => option && typeof option === 'object'
55
+ && (typeof option.value === 'string' || typeof option.value === 'number')
56
+ && String(option.value).trim() !== '')
57
+ .map((option) => ({
58
+ value: String(option.value),
59
+ label: text(option.label) || String(option.value),
60
+ }))
61
+ return usable.length > 0 ? usable : null
62
+ }
63
+
64
+ /**
65
+ * The declared inputs, normalised. An input without a `field` is dropped.
66
+ *
67
+ * @param {unknown} declared The declared `inputs`.
68
+ * @return {Array<object>} The inputs.
69
+ */
70
+ export function normaliseInputs(declared) {
71
+ if (!Array.isArray(declared)) {
72
+ return []
73
+ }
74
+ return declared
75
+ .filter((input) => input && typeof input.field === 'string' && input.field !== '')
76
+ .map((input) => ({
77
+ ...input,
78
+ field: input.field,
79
+ required: input.required === true,
80
+ picker: input.picker && typeof input.picker === 'object' ? input.picker : null,
81
+ fields: Array.isArray(input.fields) ? input.fields.filter((f) => typeof f === 'string') : null,
82
+ label: text(input.label),
83
+ options: declaredOptions(input.options),
84
+ widget: DECLARED_WIDGETS.includes(input.widget) ? input.widget : null,
85
+ optionsSource: text(input.optionsSource),
86
+ other: input.other && typeof input.other === 'object' && text(input.other.label) ? input.other : null,
87
+ }))
88
+ }
89
+
90
+ /**
91
+ * Clamp a `fieldsFromSchema()` widget to the minimal set the inputs draw.
92
+ * Anything richer (select, user picker, …) degrades to a plain text input
93
+ * rather than pulling a form engine in.
94
+ *
95
+ * @param {string} widget The resolved widget name.
96
+ * @return {'text'|'textarea'|'number'|'checkbox'}
97
+ */
98
+ export function clampWidget(widget) {
99
+ if (widget === 'checkbox' || widget === 'switch') {
100
+ return 'checkbox'
101
+ }
102
+ if (widget === 'textarea' || widget === 'number') {
103
+ return widget
104
+ }
105
+ return 'text'
106
+ }
107
+
108
+ /**
109
+ * The choices an `optionsSource` input reads off the context: each row's
110
+ * `optionValue` (default `value`) and `optionLabel` (default `label`), with
111
+ * the rows kept so `helperFrom` can read another key of the picked one.
112
+ *
113
+ * @param {object} input The normalised input.
114
+ * @param {object} context What the source path is read from.
115
+ * @return {{options: Array<{value: string, label: string}>, rows: Array<object>}}
116
+ */
117
+ function sourcedOptions(input, context) {
118
+ const list = readPath(context || {}, input.optionsSource)
119
+ const valueKey = text(input.optionValue) || 'value'
120
+ const labelKey = text(input.optionLabel) || 'label'
121
+ const options = []
122
+ const rows = []
123
+ for (const row of Array.isArray(list) ? list : []) {
124
+ const value = row && typeof row === 'object' ? row[valueKey] : row
125
+ if ((typeof value !== 'string' && typeof value !== 'number') || String(value).trim() === '') {
126
+ continue
127
+ }
128
+ const label = row && typeof row === 'object' ? row[labelKey] : null
129
+ options.push({ value: String(value), label: text(label) || String(value) })
130
+ rows.push(row)
131
+ }
132
+ return { options, rows }
133
+ }
134
+
135
+ /**
136
+ * The descriptor of an object input: its sub-properties (narrowed to the
137
+ * input's `fields` when it names them), each with its own widget. A
138
+ * sub-field is required when the input names it in `fields` and the
139
+ * sub-schema lists it as required, or when the whole input is required and
140
+ * it is the only one asked.
141
+ *
142
+ * @param {object} input The normalised input.
143
+ * @param {object} prop The object property's schema.
144
+ * @return {object} The field descriptor.
145
+ */
146
+ function objectField(input, prop) {
147
+ const subs = fieldsFromSchema(prop, { includeReadOnly: true, ...(input.fields ? { include: input.fields } : {}) })
148
+ const required = Array.isArray(prop.required) ? prop.required : []
149
+ const only = subs.length === 1
150
+ return {
151
+ key: input.field,
152
+ label: prop.title || input.field,
153
+ description: prop.description || '',
154
+ widget: 'object',
155
+ required: input.required,
156
+ subFields: subs.map((sub) => ({
157
+ ...sub,
158
+ widget: clampWidget(sub.widget),
159
+ required: (input.fields ? required.includes(sub.key) : sub.required === true) || (input.required && only),
160
+ })),
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The field one input resolves to before the context is applied.
166
+ *
167
+ * @param {object} input The normalised input.
168
+ * @param {object} resolved The schema's fields for the declared keys.
169
+ * @param {object} options What the field is resolved against.
170
+ * @param {object|null} options.schema The object's JSON Schema.
171
+ * @param {string} options.register Register the reference pickers fall back to.
172
+ * @param {object} options.context What `optionsSource` reads.
173
+ * @return {object} The field descriptor.
174
+ */
175
+ function baseField(input, resolved, { schema, register, context }) {
176
+ const field = resolved.find((f) => f.key === input.field)
177
+ const label = input.label || (field && field.label) || input.field
178
+ const description = text(input.description) || (field && field.description) || ''
179
+
180
+ if (input.widget === 'template') {
181
+ return { key: input.field, label, description, widget: 'template', required: false }
182
+ }
183
+ if (input.optionsSource) {
184
+ const { options, rows } = sourcedOptions(input, context)
185
+ return { key: input.field, label, description, widget: 'select', options, rows, required: input.required }
186
+ }
187
+ if (input.options) {
188
+ return { key: input.field, label, description, widget: 'select', options: input.options, rows: [], required: input.required }
189
+ }
190
+ if (input.widget && input.widget !== 'select') {
191
+ return { key: input.field, label, description, widget: input.widget, required: input.required }
192
+ }
193
+ const prop = (schema && schema.properties && schema.properties[input.field]) || null
194
+ if (prop && prop.type === 'object' && prop.properties && typeof prop.properties === 'object') {
195
+ return objectField(input, prop)
196
+ }
197
+ if (!field) {
198
+ return { key: input.field, label, description, widget: 'text', required: input.required }
199
+ }
200
+ if (field.reference && field.reference.multiple !== true) {
201
+ const referenceRegister = field.reference.register || register
202
+ if (referenceRegister !== '') {
203
+ return { ...field, label, widget: 'reference', referenceRegister, picker: input.picker, required: input.required }
204
+ }
205
+ }
206
+ return { ...field, label, widget: clampWidget(field.widget), required: input.required }
207
+ }
208
+
209
+ /**
210
+ * A date `days` whole days after `now`, or null without a positive count.
211
+ *
212
+ * @param {unknown} days The count.
213
+ * @param {Date} now Today.
214
+ * @return {Date|null}
215
+ */
216
+ function daysAfter(days, now) {
217
+ const count = Number.parseInt(String(days ?? ''), 10)
218
+ if (!Number.isFinite(count) || count < 1) {
219
+ return null
220
+ }
221
+ const date = new Date(now.getTime())
222
+ date.setDate(date.getDate() + count)
223
+ return date
224
+ }
225
+
226
+ /**
227
+ * A date as a reader sees it: day, short month and year.
228
+ *
229
+ * @param {Date|string} value The date.
230
+ * @return {string} The text, '' when not a date.
231
+ */
232
+ function dateText(value) {
233
+ const shown = formatDateForDisplay(value, { dateOptions: { day: 'numeric', month: 'short', year: 'numeric' } })
234
+ return shown ? shown.text : ''
235
+ }
236
+
237
+ /**
238
+ * Fill a preview template from the input values.
239
+ *
240
+ * Tokens are `{field}`, `{field|date}`, `{today|date}` and
241
+ * `{today+field|date}` (today plus that many days). A token that resolves
242
+ * to nothing empties the whole preview, so a half-filled sentence is never
243
+ * shown: `{today+days|date}` says nothing until `days` is a positive whole
244
+ * number.
245
+ *
246
+ * @param {string} template The template.
247
+ * @param {object} values The input values.
248
+ * @param {Date} [now] What counts as today.
249
+ * @return {string} The filled text, or ''.
250
+ */
251
+ export function fillInputTemplate(template, values, now = new Date()) {
252
+ if (typeof template !== 'string' || template === '') {
253
+ return ''
254
+ }
255
+ let empty = false
256
+ const filled = template.replace(/\{([^{}|]+)(?:\|(date))?\}/g, (whole, expression, format) => {
257
+ const name = expression.trim()
258
+ let value
259
+ if (name === 'today') {
260
+ value = new Date(now.getTime())
261
+ } else if (name.startsWith('today+')) {
262
+ value = daysAfter((values || {})[name.slice(6).trim()], now)
263
+ } else {
264
+ value = (values || {})[name]
265
+ }
266
+ if (value === null || value === undefined || String(value).trim() === '') {
267
+ empty = true
268
+ return ''
269
+ }
270
+ const shown = format === 'date' ? dateText(value) : String(value)
271
+ if (shown === '') {
272
+ empty = true
273
+ }
274
+ return shown
275
+ })
276
+ return empty ? '' : filled
277
+ }
278
+
279
+ /**
280
+ * The descriptors of the declared inputs, with the context applied.
281
+ *
282
+ * Next to what the schema says, an input may declare:
283
+ * - `widget`: `text`, `textarea`, `number`, `date`, `select` or `template`.
284
+ * - `optionsSource`: a dot path into `context` holding the rows to pick from,
285
+ * read through `optionValue` and `optionLabel`. With no rows the input is
286
+ * hidden and asks nothing.
287
+ * - `other: { label, input }`: adds an "Other" entry; the input it names is
288
+ * only shown (and only asked) when this picker is hidden or Other is picked.
289
+ * - `helperFrom: { input, key }`: the helper text is `key` of the row picked
290
+ * in that other input.
291
+ * - `preview: { label, template, note, testid }`: a line under the input,
292
+ * filled by `fillInputTemplate` and shown only once it resolves.
293
+ * - `default`: the starting value.
294
+ * - Presentation: `labelOutside`, `id`, `note`, `wide`, `testid`, `rows`,
295
+ * `placeholder`.
296
+ *
297
+ * Every descriptor carries `hidden`; a hidden one is neither drawn nor asked.
298
+ *
299
+ * @param {unknown} declared The declared inputs.
300
+ * @param {object} [options] Options.
301
+ * @param {object|null} [options.schema] The object's JSON Schema.
302
+ * @param {string} [options.register] Register the reference pickers fall back to.
303
+ * @param {object} [options.context] What `optionsSource` reads.
304
+ * @param {object} [options.values] The values so far.
305
+ * @param {Date} [options.now] What counts as today, for previews.
306
+ * @return {Array<object>} The descriptors.
307
+ */
308
+ export function resolveInputFields(declared, { schema = null, register = '', context = {}, values = {}, now } = {}) {
309
+ const inputs = normaliseInputs(declared)
310
+ const resolved = fieldsFromSchema(schema, { include: inputs.map((input) => input.field), includeReadOnly: true })
311
+ const fields = inputs.map((input) => ({
312
+ ...baseField(input, resolved, { schema, register, context }),
313
+ input,
314
+ hidden: false,
315
+ }))
316
+
317
+ for (const field of fields) {
318
+ const other = field.input.other
319
+ if (field.widget !== 'select') {
320
+ continue
321
+ }
322
+ if (field.input.optionsSource && field.options.length === 0) {
323
+ field.hidden = true
324
+ }
325
+ if (other) {
326
+ field.options = [...field.options, { value: OTHER_OPTION_VALUE, label: other.label.trim() }]
327
+ const target = fields.find((candidate) => candidate.key === other.input)
328
+ if (target && !field.hidden && values[field.key] !== OTHER_OPTION_VALUE) {
329
+ target.hidden = true
330
+ }
331
+ }
332
+ }
333
+
334
+ for (const field of fields) {
335
+ const helperFrom = field.input.helperFrom
336
+ if (helperFrom && typeof helperFrom === 'object') {
337
+ const source = fields.find((candidate) => candidate.key === helperFrom.input)
338
+ const picked = source && Array.isArray(source.rows) && !source.hidden
339
+ ? source.rows[source.options.findIndex((option) => option.value === String(values[source.key] ?? ''))]
340
+ : null
341
+ const helper = picked && typeof picked === 'object' ? picked[helperFrom.key] : null
342
+ field.description = helper === null || helper === undefined ? field.description : String(helper)
343
+ }
344
+ const preview = field.input.preview
345
+ field.previewText = preview && typeof preview === 'object'
346
+ ? fillInputTemplate(preview.template, values, now)
347
+ : ''
348
+ }
349
+
350
+ return fields
351
+ }
352
+
353
+ /**
354
+ * Whether a field holds a value: a checked switch for booleans, a non-blank
355
+ * string otherwise.
356
+ *
357
+ * @param {object} field The descriptor.
358
+ * @param {object} values The values.
359
+ * @return {boolean}
360
+ */
361
+ export function isInputFilled(field, values) {
362
+ const value = (values || {})[field.key]
363
+ if (field.widget === 'checkbox') {
364
+ return value === true
365
+ }
366
+ return String(value ?? '').trim() !== ''
367
+ }
368
+
369
+ /**
370
+ * Whether an object input's sub-field holds a value.
371
+ *
372
+ * @param {object} field The object descriptor.
373
+ * @param {object} sub The sub-field descriptor.
374
+ * @param {object} values The values.
375
+ * @return {boolean}
376
+ */
377
+ export function isSubInputFilled(field, sub, values) {
378
+ const group = (values || {})[field.key]
379
+ const value = group && typeof group === 'object' ? group[sub.key] : undefined
380
+ return sub.widget === 'checkbox' ? value === true : String(value ?? '').trim() !== ''
381
+ }
382
+
383
+ /**
384
+ * Whether every shown, required field is filled. "Other" counts as a pick.
385
+ *
386
+ * @param {Array<object>} fields The descriptors (`resolveInputFields`).
387
+ * @param {object} values The values.
388
+ * @return {boolean}
389
+ */
390
+ export function inputFieldsComplete(fields, values) {
391
+ return fields.every((field) => {
392
+ if (field.hidden || field.widget === 'template') {
393
+ return true
394
+ }
395
+ if (field.widget === 'object') {
396
+ return field.subFields.every((sub) => !sub.required || isSubInputFilled(field, sub, values))
397
+ }
398
+ return !field.required || isInputFilled(field, values)
399
+ })
400
+ }
401
+
402
+ /**
403
+ * A picked value as it is sent: the "Other" entry becomes ''.
404
+ *
405
+ * @param {unknown} value The picked value.
406
+ * @return {unknown}
407
+ */
408
+ export function submittedValue(value) {
409
+ return value === OTHER_OPTION_VALUE ? '' : value
410
+ }
411
+
412
+ /**
413
+ * The values to start from: each input's `default`, else its schema default,
414
+ * else false for a boolean and '' otherwise. An object input starts from the
415
+ * record's present value, so the sub-fields it asks for show what is there.
416
+ *
417
+ * @param {unknown} declared The declared inputs.
418
+ * @param {object|null} [schema] The object's JSON Schema.
419
+ * @param {object|null} [currentObject] The record.
420
+ * @return {object} The values.
421
+ */
422
+ export function initialInputValues(declared, schema = null, currentObject = null) {
423
+ const values = {}
424
+ const properties = (schema && schema.properties) || {}
425
+ for (const input of normaliseInputs(declared)) {
426
+ const prop = properties[input.field] || {}
427
+ if (prop.type === 'object' && prop.properties && typeof prop.properties === 'object') {
428
+ const present = currentObject ? currentObject[input.field] : null
429
+ values[input.field] = present && typeof present === 'object' ? { ...present } : {}
430
+ continue
431
+ }
432
+ if (input.default !== undefined) {
433
+ values[input.field] = input.default
434
+ continue
435
+ }
436
+ values[input.field] = prop.default !== undefined
437
+ ? prop.default
438
+ : (prop.type === 'boolean' ? false : '')
439
+ }
440
+ return values
441
+ }
442
+
443
+ /**
444
+ * The text a chosen template puts into its `applyTo` input: the template's
445
+ * `body`, else `presets.body`. It never writes over what was already typed,
446
+ * because text lost to a template cannot be got back.
447
+ *
448
+ * @param {object} values The values.
449
+ * @param {string} target The input the template fills.
450
+ * @param {object|null} option The chosen template.
451
+ * @return {object} The new values, or the same object when nothing changes.
452
+ */
453
+ export function applyTemplateText(values, target, option) {
454
+ const body = option ? (option.body || (option.presets && option.presets.body) || '') : ''
455
+ if (!target || typeof body !== 'string' || body === '' || String((values || {})[target] ?? '').trim() !== '') {
456
+ return values
457
+ }
458
+ return { ...values, [target]: body }
459
+ }
460
+
461
+ /**
462
+ * Read a host's values against the declared inputs: whether every shown,
463
+ * required input is filled, and the values to send for the inputs that are
464
+ * asked now. A hidden input and a template are left out, a picked "Other"
465
+ * is sent as '' and a number input as a number. An object input is sent as
466
+ * it stands (CnTransitionInputDialog merges it over the record).
467
+ *
468
+ * @param {unknown} declared The declared inputs.
469
+ * @param {object} values The values, keyed by field.
470
+ * @param {object} [options] `{ schema, register, context, now }`, as for `resolveInputFields`.
471
+ * @return {{complete: boolean, data: object}}
472
+ * @spec openspec/changes/dq-abstract-forms/specs/transition-inputs/spec.md
473
+ */
474
+ export function readTransitionInputs(declared, values, options = {}) {
475
+ const current = values || {}
476
+ const fields = resolveInputFields(declared, { ...options, values: current })
477
+ const data = {}
478
+ for (const field of fields) {
479
+ if (field.hidden || field.widget === 'template') {
480
+ continue
481
+ }
482
+ let value = submittedValue(current[field.key])
483
+ if (field.widget === 'number' && String(value ?? '').trim() !== '' && !Number.isNaN(Number(value))) {
484
+ value = Number(value)
485
+ }
486
+ data[field.key] = value
487
+ }
488
+ return { complete: inputFieldsComplete(fields, current), data }
489
+ }