@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.
- package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js +2 -1
- package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +6 -6
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
- package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js +2 -1
- package/dist/esm/components/CnAssignDialog/CnAssignDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +18 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +14 -8
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +11 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +9 -4
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnMailTab/CnMailTab.vue2.js +2 -1
- package/dist/esm/components/CnMailTab/CnMailTab.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +71 -0
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +25 -9
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +11 -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/CnSearchPage/CnSearchPage.vue.js +1 -1
- package/dist/esm/components/CnSearchPage/CnSearchPage.vue2.js +161 -5
- package/dist/esm/components/CnSearchPage/CnSearchPage.vue2.js.map +1 -1
- package/dist/esm/components/CnSearchPage/CnSearchPage.vue3.js +9 -9
- package/dist/esm/components/CnSearchPage/CnSearchPage.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +35 -5
- 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/CnTransitionInputFields/CnTransitionInputFields.vue.js +9 -0
- package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue.js.map +1 -0
- package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js +435 -0
- package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue2.js.map +1 -0
- package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js +300 -0
- package/dist/esm/components/CnTransitionInputFields/CnTransitionInputFields.vue3.js.map +1 -0
- package/dist/esm/composables/useEndpointSource.js +1 -36
- package/dist/esm/composables/useEndpointSource.js.map +1 -1
- package/dist/esm/dialogs/CnTransitionInputDialog.vue.js +1 -1
- package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js +57 -336
- package/dist/esm/dialogs/CnTransitionInputDialog.vue2.js.map +1 -1
- package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js +18 -155
- package/dist/esm/dialogs/CnTransitionInputDialog.vue3.js.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +2 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +2 -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 +308 -229
- package/dist/esm/utils/actionsDispatcher.js +3 -2
- package/dist/esm/utils/actionsDispatcher.js.map +1 -1
- package/dist/esm/utils/interpolateUrlTokens.js +43 -0
- package/dist/esm/utils/interpolateUrlTokens.js.map +1 -0
- package/dist/esm/utils/labelsByField.js +72 -0
- package/dist/esm/utils/labelsByField.js.map +1 -0
- package/dist/esm/utils/listCreateAction.js +91 -0
- package/dist/esm/utils/listCreateAction.js.map +1 -0
- package/dist/esm/utils/transitionInputs.js +493 -0
- package/dist/esm/utils/transitionInputs.js.map +1 -0
- package/dist/esm/utils/visibleWhen.js +9 -2
- package/dist/esm/utils/visibleWhen.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +6690 -5404
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +308 -229
- package/l10n/en.json +2 -1
- package/l10n/nl.json +2 -1
- package/package.json +1 -1
- package/src/components/CnActionsBar/CnActionsBar.vue +6 -6
- package/src/components/CnDashboardPage/CnDashboardPage.vue +27 -5
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +11 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +9 -4
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +86 -0
- package/src/components/CnRecordListWidget/CnRecordListWidget.vue +11 -2
- package/src/components/CnSearchPage/CnSearchPage.vue +170 -14
- package/src/components/CnTabsWidget/CnTabsWidget.vue +35 -5
- package/src/components/CnTransitionInputFields/CnTransitionInputFields.vue +750 -0
- package/src/components/CnTransitionInputFields/index.js +11 -0
- package/src/components/index.js +1 -0
- package/src/composables/useEndpointSource.js +3 -36
- package/src/dialogs/CnTransitionInputDialog.vue +73 -465
- package/src/index.js +2 -0
- package/src/utils/interpolateUrlTokens.js +40 -0
- package/src/utils/labelsByField.js +69 -0
- package/src/utils/listCreateAction.js +87 -0
- package/src/utils/transitionInputs.js +489 -0
- 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
|
+
}
|