@fieldia/core 0.11.0 → 0.13.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/README.md +5 -0
- package/package.json +1 -1
- package/schema/page.schema.json +3998 -893
- package/src/index.d.mts +1 -1
- package/src/index.d.ts +1 -1
- package/src/index.js +4 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +1 -1
- package/src/index.mjs.map +1 -1
- package/src/lib/expression/built-ins.d.mts +4 -0
- package/src/lib/expression/built-ins.d.ts +4 -0
- package/src/lib/expression/built-ins.js +8 -0
- package/src/lib/expression/built-ins.js.map +1 -0
- package/src/lib/expression/built-ins.mjs +5 -0
- package/src/lib/expression/built-ins.mjs.map +1 -0
- package/src/lib/expression/evaluator.js +17 -7
- package/src/lib/expression/evaluator.js.map +1 -1
- package/src/lib/expression/evaluator.mjs +17 -7
- package/src/lib/expression/evaluator.mjs.map +1 -1
- package/src/lib/expression/expression.d.mts +7 -0
- package/src/lib/expression/expression.d.ts +7 -0
- package/src/lib/expression/expression.js +2 -0
- package/src/lib/expression/expression.js.map +1 -1
- package/src/lib/expression/expression.mjs +3 -1
- package/src/lib/expression/expression.mjs.map +1 -1
- package/src/lib/expression/functions.d.mts +2 -0
- package/src/lib/expression/functions.d.ts +2 -0
- package/src/lib/expression/functions.js +38 -5
- package/src/lib/expression/functions.js.map +1 -1
- package/src/lib/expression/functions.mjs +38 -5
- package/src/lib/expression/functions.mjs.map +1 -1
- package/src/lib/expression/modifier.d.mts +7 -0
- package/src/lib/expression/modifier.d.ts +7 -0
- package/src/lib/expression/modifier.js +3 -1
- package/src/lib/expression/modifier.js.map +1 -1
- package/src/lib/expression/modifier.mjs +4 -2
- package/src/lib/expression/modifier.mjs.map +1 -1
- package/src/lib/expression/parser.d.mts +8 -1
- package/src/lib/expression/parser.d.ts +8 -1
- package/src/lib/expression/parser.js +30 -2
- package/src/lib/expression/parser.js.map +1 -1
- package/src/lib/expression/parser.mjs +30 -2
- package/src/lib/expression/parser.mjs.map +1 -1
- package/src/lib/expression/reads.d.mts +7 -0
- package/src/lib/expression/reads.d.ts +7 -0
- package/src/lib/expression/reads.js +23 -0
- package/src/lib/expression/reads.js.map +1 -1
- package/src/lib/expression/reads.mjs +21 -0
- package/src/lib/expression/reads.mjs.map +1 -1
- package/src/lib/format/actions.d.mts +486 -2
- package/src/lib/format/actions.d.ts +486 -2
- package/src/lib/format/actions.js +9 -0
- package/src/lib/format/actions.js.map +1 -1
- package/src/lib/format/actions.mjs +9 -0
- package/src/lib/format/actions.mjs.map +1 -1
- package/src/lib/format/check-page.d.mts +7 -2
- package/src/lib/format/check-page.d.ts +7 -2
- package/src/lib/format/check-page.js +6 -3
- package/src/lib/format/check-page.js.map +1 -1
- package/src/lib/format/check-page.mjs +6 -3
- package/src/lib/format/check-page.mjs.map +1 -1
- package/src/lib/format/field.d.mts +3285 -214
- package/src/lib/format/field.d.ts +3285 -214
- package/src/lib/format/field.js +101 -14
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +100 -13
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/hotkeys.d.mts +6 -0
- package/src/lib/format/hotkeys.d.ts +6 -0
- package/src/lib/format/hotkeys.js +11 -0
- package/src/lib/format/hotkeys.js.map +1 -0
- package/src/lib/format/hotkeys.mjs +7 -0
- package/src/lib/format/hotkeys.mjs.map +1 -0
- package/src/lib/format/layout.d.mts +4185 -352
- package/src/lib/format/layout.d.ts +4185 -352
- package/src/lib/format/layout.js +131 -20
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +130 -19
- package/src/lib/format/layout.mjs.map +1 -1
- package/src/lib/format/page.d.mts +2938 -196
- package/src/lib/format/page.d.ts +2938 -196
- package/src/lib/format/page.js +7 -1
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +6 -1
- package/src/lib/format/page.mjs.map +1 -1
- package/src/lib/format/references.d.mts +35 -2
- package/src/lib/format/references.d.ts +35 -2
- package/src/lib/format/references.js +321 -43
- package/src/lib/format/references.js.map +1 -1
- package/src/lib/format/references.mjs +321 -43
- package/src/lib/format/references.mjs.map +1 -1
- package/src/lib/format/themes.d.mts +9 -0
- package/src/lib/format/themes.d.ts +9 -0
- package/src/lib/format/themes.js +13 -0
- package/src/lib/format/themes.js.map +1 -0
- package/src/lib/format/themes.mjs +10 -0
- package/src/lib/format/themes.mjs.map +1 -0
- package/src/lib/format/translate.js +5 -3
- package/src/lib/format/translate.js.map +1 -1
- package/src/lib/format/translate.mjs +5 -3
- package/src/lib/format/translate.mjs.map +1 -1
- package/src/lib/format/words.d.mts +9 -0
- package/src/lib/format/words.d.ts +9 -0
- package/src/lib/format/words.js +35 -0
- package/src/lib/format/words.js.map +1 -0
- package/src/lib/format/words.mjs +30 -0
- package/src/lib/format/words.mjs.map +1 -0
- package/src/lib/record/cells.d.mts +37 -0
- package/src/lib/record/cells.d.ts +37 -0
- package/src/lib/record/cells.js +59 -0
- package/src/lib/record/cells.js.map +1 -0
- package/src/lib/record/cells.mjs +54 -0
- package/src/lib/record/cells.mjs.map +1 -0
- package/src/lib/record/compute.d.mts +5 -4
- package/src/lib/record/compute.d.ts +5 -4
- package/src/lib/record/compute.js +11 -10
- package/src/lib/record/compute.js.map +1 -1
- package/src/lib/record/compute.mjs +12 -11
- package/src/lib/record/compute.mjs.map +1 -1
- package/src/lib/record/data-source.d.mts +32 -2
- package/src/lib/record/data-source.d.ts +32 -2
- package/src/lib/record/data-source.js.map +1 -1
- package/src/lib/record/data-source.mjs.map +1 -1
- package/src/lib/record/env.d.mts +38 -1
- package/src/lib/record/env.d.ts +38 -1
- package/src/lib/record/env.js +41 -3
- package/src/lib/record/env.js.map +1 -1
- package/src/lib/record/env.mjs +38 -3
- package/src/lib/record/env.mjs.map +1 -1
- package/src/lib/record/filter.d.mts +10 -0
- package/src/lib/record/filter.d.ts +10 -0
- package/src/lib/record/filter.js +49 -0
- package/src/lib/record/filter.js.map +1 -1
- package/src/lib/record/filter.mjs +48 -0
- package/src/lib/record/filter.mjs.map +1 -1
- package/src/lib/record/form.d.mts +143 -8
- package/src/lib/record/form.d.ts +143 -8
- package/src/lib/record/form.js +495 -58
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +496 -60
- package/src/lib/record/form.mjs.map +1 -1
- package/src/lib/record/locales/ar.js +3 -0
- package/src/lib/record/locales/ar.js.map +1 -1
- package/src/lib/record/locales/ar.mjs +3 -0
- package/src/lib/record/locales/ar.mjs.map +1 -1
- package/src/lib/record/locales/de.js +3 -0
- package/src/lib/record/locales/de.js.map +1 -1
- package/src/lib/record/locales/de.mjs +3 -0
- package/src/lib/record/locales/de.mjs.map +1 -1
- package/src/lib/record/locales/fr.js +3 -0
- package/src/lib/record/locales/fr.js.map +1 -1
- package/src/lib/record/locales/fr.mjs +3 -0
- package/src/lib/record/locales/fr.mjs.map +1 -1
- package/src/lib/record/memory-data-source.d.mts +26 -2
- package/src/lib/record/memory-data-source.d.ts +26 -2
- package/src/lib/record/memory-data-source.js +98 -7
- package/src/lib/record/memory-data-source.js.map +1 -1
- package/src/lib/record/memory-data-source.mjs +98 -7
- package/src/lib/record/memory-data-source.mjs.map +1 -1
- package/src/lib/record/messages.d.mts +5 -0
- package/src/lib/record/messages.d.ts +5 -0
- package/src/lib/record/messages.js +3 -0
- package/src/lib/record/messages.js.map +1 -1
- package/src/lib/record/messages.mjs +3 -0
- package/src/lib/record/messages.mjs.map +1 -1
- package/src/lib/record/roles.d.mts +10 -0
- package/src/lib/record/roles.d.ts +10 -0
- package/src/lib/record/roles.js +21 -0
- package/src/lib/record/roles.js.map +1 -0
- package/src/lib/record/roles.mjs +18 -0
- package/src/lib/record/roles.mjs.map +1 -0
- package/src/lib/record/rules.js +30 -0
- package/src/lib/record/rules.js.map +1 -1
- package/src/lib/record/rules.mjs +31 -1
- package/src/lib/record/rules.mjs.map +1 -1
- package/src/lib/record/run.d.mts +79 -0
- package/src/lib/record/run.d.ts +79 -0
- package/src/lib/record/run.js +105 -0
- package/src/lib/record/run.js.map +1 -1
- package/src/lib/record/run.mjs +105 -0
- package/src/lib/record/run.mjs.map +1 -1
- package/src/lib/record/set-when.d.mts +5 -2
- package/src/lib/record/set-when.d.ts +5 -2
- package/src/lib/record/set-when.js +28 -12
- package/src/lib/record/set-when.js.map +1 -1
- package/src/lib/record/set-when.mjs +30 -14
- package/src/lib/record/set-when.mjs.map +1 -1
- package/src/lib/record/value-map.d.mts +41 -0
- package/src/lib/record/value-map.d.ts +41 -0
- package/src/lib/record/value-map.js +80 -0
- package/src/lib/record/value-map.js.map +1 -0
- package/src/lib/record/value-map.mjs +73 -0
- package/src/lib/record/value-map.mjs.map +1 -0
- package/src/lib/record/values.d.mts +10 -0
- package/src/lib/record/values.d.ts +10 -0
- package/src/lib/record/values.js.map +1 -1
- package/src/lib/record/values.mjs.map +1 -1
- package/src/runtime.d.mts +11 -6
- package/src/runtime.d.ts +11 -6
- package/src/runtime.js +13 -1
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +4 -0
- package/src/runtime.mjs.map +1 -1
package/src/lib/record/form.mjs
CHANGED
|
@@ -5,12 +5,16 @@ import { checkInput } from './inputs.mjs';
|
|
|
5
5
|
import { compileComputed } from './compute.mjs';
|
|
6
6
|
import { compileSetWhen } from './set-when.mjs';
|
|
7
7
|
import { checkRules, compileRules } from './rules.mjs';
|
|
8
|
-
import { expressionEnv } from './env.mjs';
|
|
8
|
+
import { expressionEnv, lineContext, recordContext } from './env.mjs';
|
|
9
|
+
import { firstValues, readLooseMap, readValueMap } from './value-map.mjs';
|
|
10
|
+
import { resolveFilter } from './filter.mjs';
|
|
11
|
+
import { rolesAllow } from './roles.mjs';
|
|
12
|
+
import { compileFieldTone, compileTable } from './cells.mjs';
|
|
9
13
|
import { MESSAGES } from './messages.mjs';
|
|
10
14
|
import { saveProblemOf } from './data-source.mjs';
|
|
11
15
|
import { hostScheduler } from './scheduler.mjs';
|
|
12
16
|
import { createMoments, createRunner } from './run.mjs';
|
|
13
|
-
import { emptyValue,
|
|
17
|
+
import { emptyValue, initialValues, isEmpty, lineKind, structuredCopy, } from './values.mjs';
|
|
14
18
|
/**
|
|
15
19
|
* The record state behind a page: values, what changed, what is visible and
|
|
16
20
|
* required, errors, loading and saving. No DOM and no framework — every
|
|
@@ -35,13 +39,68 @@ function innerForm(options, within) {
|
|
|
35
39
|
if (node.kind === 'field' && rules)
|
|
36
40
|
nodeRules.set(node.id, compileRules(rules));
|
|
37
41
|
}
|
|
42
|
+
/** Tables of lines with rules of their own, by their field node: each read once. */
|
|
43
|
+
const tables = new Map();
|
|
44
|
+
for (const node of index.values()) {
|
|
45
|
+
const source = node.source;
|
|
46
|
+
if (node.kind !== 'field' || page.fields[source.field]?.type !== 'one2many')
|
|
47
|
+
continue;
|
|
48
|
+
if (source.cells || source.rowTones || source.rowBold !== undefined || source.rowButtons || source.lineDelete !== undefined)
|
|
49
|
+
tables.set(node.id, { field: source.field, compiled: compileTable(source) });
|
|
50
|
+
}
|
|
51
|
+
/** Field nodes whose value takes a tone or bold, by id: each read once. */
|
|
52
|
+
const fieldTones = new Map();
|
|
53
|
+
for (const node of index.values()) {
|
|
54
|
+
const source = node.source;
|
|
55
|
+
if (node.kind === 'field' && (source.tones || source.bold !== undefined))
|
|
56
|
+
fieldTones.set(node.id, compileFieldTone(source));
|
|
57
|
+
}
|
|
58
|
+
/** The tables whose lines load in an order of their own fields, by the field: the first node to give one. */
|
|
59
|
+
const lineOrders = new Map();
|
|
60
|
+
for (const node of index.values()) {
|
|
61
|
+
const source = node.source;
|
|
62
|
+
if (node.kind === 'field' && source.order?.length && page.fields[source.field]?.type === 'one2many' && !lineOrders.has(source.field))
|
|
63
|
+
lineOrders.set(source.field, source.order);
|
|
64
|
+
}
|
|
65
|
+
/** Lines as they load, in their tables' orders — only `loaded` fields, when given: the same values when nothing moved. */
|
|
66
|
+
function ordered(values, loaded) {
|
|
67
|
+
let out = values;
|
|
68
|
+
for (const [field, by] of lineOrders) {
|
|
69
|
+
if (loaded && !loaded.includes(field))
|
|
70
|
+
continue;
|
|
71
|
+
const lines = values[field];
|
|
72
|
+
if (!Array.isArray(lines) || lines.length < 2)
|
|
73
|
+
continue;
|
|
74
|
+
const sorted = [...lines].sort((a, b) => compareLines(a.values, b.values, by));
|
|
75
|
+
if (sorted.some((line, i) => line !== lines[i]))
|
|
76
|
+
out = { ...out, [field]: sorted };
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** The words of each field's empty box chosen by conditions, by its node: read once. */
|
|
81
|
+
const placeholders = new Map();
|
|
82
|
+
const placeholderOf = (id, own) => {
|
|
83
|
+
if (own.placeholderWhen?.length)
|
|
84
|
+
placeholders.set(id, { fixed: own.placeholder, when: own.placeholderWhen.map((item) => ({ text: item.text, holds: compileModifier(item.when) })) });
|
|
85
|
+
};
|
|
86
|
+
for (const node of index.values())
|
|
87
|
+
if (node.kind === 'field' && 'type' in node.source)
|
|
88
|
+
placeholderOf(node.id, node.source);
|
|
89
|
+
if (page.layout.type === 'sheet' && page.layout.title)
|
|
90
|
+
placeholderOf('#title', page.layout.title);
|
|
38
91
|
/** Whether any rule only warns: without one, there are never warnings to look for. */
|
|
39
92
|
const warns = [...nodeRules.values()].some((rules) => rules.some((compiled) => compiled.rule.level === 'warning'));
|
|
40
93
|
const steps = page.layout.type === 'wizard' ? page.layout.children.map((step) => step.id) : [];
|
|
41
94
|
const draftKey = () => `fieldia:draft:${page.id}:${state.recordId ?? 'new'}`;
|
|
42
95
|
const today = () => localDay(options.now?.() ?? new Date());
|
|
43
|
-
|
|
44
|
-
|
|
96
|
+
// The record's id and the person, as worked-out values read them: the id is the one asked for until the form has its state.
|
|
97
|
+
let started = false;
|
|
98
|
+
let editing = options.editing !== false;
|
|
99
|
+
/** The record a line's form is of, as its conditions read it: worked out once. */
|
|
100
|
+
const parentContext = options.parent ? recordContext(options.parent.values, options.parent.fields, { recordId: options.parent.id ?? null, user: options.user, context: options.context }) : undefined;
|
|
101
|
+
const about = () => ({ recordId: started ? state.recordId : (options.recordId ?? null), user: options.user, editing, context: options.context, parent: parentContext });
|
|
102
|
+
const computed = compileComputed(page, today, about);
|
|
103
|
+
const setWhen = compileSetWhen(page, today, about);
|
|
45
104
|
/** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
|
|
46
105
|
const parts = new Map();
|
|
47
106
|
for (const node of index.values())
|
|
@@ -55,7 +114,75 @@ function innerForm(options, within) {
|
|
|
55
114
|
fields[part.name] = { type: 'json', label: part.title || part.name };
|
|
56
115
|
/** True while answers are handed to an inner form: what it says back then is no change of its own. */
|
|
57
116
|
let syncing = false;
|
|
58
|
-
|
|
117
|
+
/** A new record's values: each field's default, then those worked out from `defaultFrom`, then what the form is given. */
|
|
118
|
+
/** What the record starts with: each field's default, a new one's worked-out first values, and — as the form opens — what it was given. */
|
|
119
|
+
function startingValues(isNew = options.recordId == null, given = true) {
|
|
120
|
+
const values = initialValues(fields, today());
|
|
121
|
+
if (isNew) {
|
|
122
|
+
const about = { recordId: null, user: options.user, context: options.context, parent: parentContext };
|
|
123
|
+
Object.assign(values, firstValues(page.fields, mapScope(values, page.fields, recordContext(values, page.fields, about), expressionEnv(values, page.fields, today))));
|
|
124
|
+
}
|
|
125
|
+
return given ? ordered({ ...values, ...structuredCopy(options.values ?? {}) }) : values;
|
|
126
|
+
}
|
|
127
|
+
/** The name this form knows a record by, from a link to the same model it holds. */
|
|
128
|
+
function knownLabel(relation, id) {
|
|
129
|
+
const values = (started ? state.values : {});
|
|
130
|
+
for (const [name, def] of Object.entries(page.fields)) {
|
|
131
|
+
if ((def.type !== 'many2one' && def.type !== 'many2many') || def.relation !== relation)
|
|
132
|
+
continue;
|
|
133
|
+
const held = values[name];
|
|
134
|
+
const found = (Array.isArray(held) ? held : held ? [held] : []).find((record) => record?.id === id);
|
|
135
|
+
if (found)
|
|
136
|
+
return found.label;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
function mapScope(values, scopeFields, context, env, parent) {
|
|
141
|
+
return { values, fields: scopeFields, context, env, ...(parent ? { parent } : {}), ...(options.user ? { user: options.user } : {}), labelOf: knownLabel };
|
|
142
|
+
}
|
|
143
|
+
/** Where a value map on the record is read. */
|
|
144
|
+
function recordScope() {
|
|
145
|
+
const { context, env } = reading();
|
|
146
|
+
return mapScope(state.values, page.fields, context, env);
|
|
147
|
+
}
|
|
148
|
+
/** Where a value map on one line is read: the line, with its record as `parent`. */
|
|
149
|
+
function lineScope(field, values) {
|
|
150
|
+
const def = lineField(field);
|
|
151
|
+
const lineFields = def.fields;
|
|
152
|
+
return mapScope(values, lineFields, lineContext(values, lineFields, reading().context), { today }, { values: state.values, fields: page.fields });
|
|
153
|
+
}
|
|
154
|
+
function tableOf(nodeId) {
|
|
155
|
+
const table = tables.get(nodeId);
|
|
156
|
+
if (table)
|
|
157
|
+
return table;
|
|
158
|
+
const node = index.get(nodeId);
|
|
159
|
+
if (node?.kind !== 'field' || page.fields[node.field]?.type !== 'one2many')
|
|
160
|
+
throw new Error(`"${nodeId}" is not a table of lines`);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
/** A line of a table as its rules have it now; none, plain. */
|
|
164
|
+
function lineStateOf(nodeId, key) {
|
|
165
|
+
const table = tableOf(nodeId);
|
|
166
|
+
if (!table)
|
|
167
|
+
return { tone: null, bold: false, cells: {}, buttons: {}, deletable: true };
|
|
168
|
+
const line = (state.values[table.field] ?? []).find((l) => l.key === key);
|
|
169
|
+
if (!line)
|
|
170
|
+
throw new Error(`"${table.field}" has no line "${key}"`);
|
|
171
|
+
const def = lineField(table.field);
|
|
172
|
+
return table.compiled.line(lineContext(line.values, def.fields, reading().context, line.id ?? null), { today }, userRoles);
|
|
173
|
+
}
|
|
174
|
+
function createValuesOf(field, line) {
|
|
175
|
+
if (!line) {
|
|
176
|
+
const def = fieldDef(field);
|
|
177
|
+
return def.type === 'many2one' || def.type === 'many2many' ? (def.createValues ? readLooseMap(def.createValues, recordScope()) : {}) : {};
|
|
178
|
+
}
|
|
179
|
+
const found = (state.values[line.lines] ?? []).find((l) => l.key === line.key);
|
|
180
|
+
const sub = lineField(line.lines).fields[field];
|
|
181
|
+
if (!found || !sub || (sub.type !== 'many2one' && sub.type !== 'many2many') || !sub.createValues)
|
|
182
|
+
return {};
|
|
183
|
+
return readLooseMap(sub.createValues, lineScope(line.lines, found.values));
|
|
184
|
+
}
|
|
185
|
+
let baseline = computed.apply(startingValues());
|
|
59
186
|
/** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
|
|
60
187
|
let holding = setWhen.holding(baseline);
|
|
61
188
|
let state = {
|
|
@@ -73,7 +200,9 @@ function innerForm(options, within) {
|
|
|
73
200
|
skipped: [],
|
|
74
201
|
saveProblem: null,
|
|
75
202
|
choices: {},
|
|
203
|
+
definitions: {},
|
|
76
204
|
};
|
|
205
|
+
started = true;
|
|
77
206
|
/** Field errors a refused save brought, each kept until its field changes from the value it was refused with. */
|
|
78
207
|
let serverErrors = {};
|
|
79
208
|
let lineKeys = 0;
|
|
@@ -91,6 +220,7 @@ function innerForm(options, within) {
|
|
|
91
220
|
/** The wizard step last told as entered. */
|
|
92
221
|
let told = null;
|
|
93
222
|
let contextCache = null;
|
|
223
|
+
const userRoles = [...(options.user?.roles ?? [])];
|
|
94
224
|
/** The latest load of each field's choices: an answer to an older one is let go. */
|
|
95
225
|
const choiceLoads = {};
|
|
96
226
|
function set(patch) {
|
|
@@ -108,6 +238,9 @@ function innerForm(options, within) {
|
|
|
108
238
|
for (const name in state.choices)
|
|
109
239
|
if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
|
|
110
240
|
loadChoices(name);
|
|
241
|
+
for (const name in state.definitions)
|
|
242
|
+
if (fromRecord(name).dependsOn?.some((other) => was[other] !== state.values[other]))
|
|
243
|
+
loadDefinitions(name);
|
|
111
244
|
}
|
|
112
245
|
if (showing && state.step !== told)
|
|
113
246
|
tellStep(entering);
|
|
@@ -158,6 +291,24 @@ function innerForm(options, within) {
|
|
|
158
291
|
const source = options.dataSource;
|
|
159
292
|
void track((source?.options ? source.options({ list, values: structuredCopy(state.values) }) : Promise.reject(new Error(`No list "${list}"`))).then((loaded) => put({ options: loaded }), (error) => put({ options: shown, error: error.message ?? String(error) })));
|
|
160
293
|
}
|
|
294
|
+
const fromRecord = (name) => page.fields[name].definitionsFrom;
|
|
295
|
+
const definitionLoads = {};
|
|
296
|
+
function loadDefinitions(name) {
|
|
297
|
+
const { list } = fromRecord(name);
|
|
298
|
+
const load = (definitionLoads[name] = (definitionLoads[name] ?? 0) + 1);
|
|
299
|
+
const put = (definitions) => load === definitionLoads[name] && set({ definitions: { ...state.definitions, [name]: definitions } });
|
|
300
|
+
// What was shown stays while the next ones load.
|
|
301
|
+
const shown = state.definitions[name]?.definitions ?? null;
|
|
302
|
+
put({ definitions: shown, loading: true });
|
|
303
|
+
const source = options.dataSource;
|
|
304
|
+
void track((source?.definitions ? source.definitions({ list, values: structuredCopy(state.values) }) : Promise.reject(new Error(`No definitions "${list}"`))).then((loaded) => put({ definitions: loaded }), (error) => put({ definitions: shown, error: error.message ?? String(error) })));
|
|
305
|
+
}
|
|
306
|
+
async function addDefinition(name, definition) {
|
|
307
|
+
const { list } = fromRecord(name);
|
|
308
|
+
const definitions = [...(state.definitions[name]?.definitions ?? []), definition];
|
|
309
|
+
set({ definitions: { ...state.definitions, [name]: { definitions } } });
|
|
310
|
+
await track(options.dataSource?.saveDefinitions?.({ list, values: structuredCopy(state.values), definitions }) ?? Promise.resolve());
|
|
311
|
+
}
|
|
161
312
|
/** A field as its value is checked: a list's choices are those loaded, and until they are, any choice may be one. */
|
|
162
313
|
function checked(name) {
|
|
163
314
|
const def = page.fields[name];
|
|
@@ -170,11 +321,12 @@ function innerForm(options, within) {
|
|
|
170
321
|
promise.then(done, done);
|
|
171
322
|
return promise;
|
|
172
323
|
}
|
|
173
|
-
/** The values as expressions read them, and what else they read: the lines' rows, today. */
|
|
324
|
+
/** The values as expressions read them, and what else they read: the record's id, the person, the lines' rows, today. */
|
|
174
325
|
function reading() {
|
|
175
|
-
if (contextCache?.values !== state.values) {
|
|
326
|
+
if (contextCache?.values !== state.values || contextCache.recordId !== state.recordId || contextCache.editing !== editing) {
|
|
176
327
|
const values = state.values;
|
|
177
|
-
|
|
328
|
+
const context = recordContext(values, page.fields, about());
|
|
329
|
+
contextCache = { values, recordId: state.recordId, editing, context, env: expressionEnv(values, page.fields, today) };
|
|
178
330
|
}
|
|
179
331
|
return contextCache;
|
|
180
332
|
}
|
|
@@ -193,9 +345,10 @@ function innerForm(options, within) {
|
|
|
193
345
|
if (!node)
|
|
194
346
|
throw new Error(`The page has no element "${id}"`);
|
|
195
347
|
const { context: ctx, env } = reading();
|
|
196
|
-
|
|
348
|
+
const hidden = (at) => (at ? !rolesAllow(at.roles, userRoles) || at.invisible.evaluate(ctx, env) : false);
|
|
349
|
+
let invisible = hidden(node);
|
|
197
350
|
for (let parent = node.parent; !invisible && parent; parent = index.get(parent)?.parent ?? null) {
|
|
198
|
-
invisible = index.get(parent)
|
|
351
|
+
invisible = hidden(index.get(parent));
|
|
199
352
|
}
|
|
200
353
|
// A section read-only right now makes everything inside it read-only too.
|
|
201
354
|
let readonly = node.readonly.evaluate(ctx, env);
|
|
@@ -290,10 +443,15 @@ function innerForm(options, within) {
|
|
|
290
443
|
for (const line of state.values[name] ?? []) {
|
|
291
444
|
// A section or note answers only for its text, never for what an item needs.
|
|
292
445
|
const kind = lineKind(def, line.values);
|
|
446
|
+
// The table's own rules: a cell its line hides asks nothing, one it requires asks for a value.
|
|
447
|
+
const ruled = tables.has(node.id) ? lineStateOf(node.id, line.key).cells : {};
|
|
293
448
|
for (const [sub, subDef] of Object.entries(def.fields)) {
|
|
294
449
|
if (kind && sub !== def.lineKinds?.text)
|
|
295
450
|
continue;
|
|
296
|
-
const
|
|
451
|
+
const cell = ruled[sub];
|
|
452
|
+
if (cell?.invisible || (tables.has(node.id) && tables.get(node.id).compiled.hidden(sub, reading().context, reading().env, userRoles)))
|
|
453
|
+
continue;
|
|
454
|
+
const lineMessage = checkValue(subDef, line.values[sub], subDef.required === true || cell?.required === true, messages, today());
|
|
297
455
|
if (lineMessage)
|
|
298
456
|
errors[`${name}.${line.key}.${sub}`] = lineMessage;
|
|
299
457
|
}
|
|
@@ -355,7 +513,25 @@ function innerForm(options, within) {
|
|
|
355
513
|
/** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
|
|
356
514
|
function writeValues(written, extra = {}, fresh = false) {
|
|
357
515
|
const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
|
|
358
|
-
|
|
516
|
+
let errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
|
|
517
|
+
// Words typed where a number goes are said at once, as Flectra marks a box it cannot read; what is missing waits for sending.
|
|
518
|
+
const unread = {};
|
|
519
|
+
for (const name of Object.keys(written)) {
|
|
520
|
+
const def = page.fields[name];
|
|
521
|
+
if (written[name] === state.values[name] || !def)
|
|
522
|
+
continue;
|
|
523
|
+
if (notANumber(def, written[name]))
|
|
524
|
+
unread[name] = checkValue(def, written[name], false, messages);
|
|
525
|
+
if (def.type !== 'one2many')
|
|
526
|
+
continue;
|
|
527
|
+
for (const line of written[name] ?? []) {
|
|
528
|
+
for (const [sub, v] of Object.entries(line.values))
|
|
529
|
+
if (notANumber(def.fields[sub], v))
|
|
530
|
+
unread[`${name}.${line.key}.${sub}`] = checkValue(def.fields[sub], v, false, messages);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (Object.keys(unread).length)
|
|
534
|
+
errors = { ...errors, ...unread };
|
|
359
535
|
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
|
|
360
536
|
}
|
|
361
537
|
/** Once errors are on screen, keep them current as the person fixes things. */
|
|
@@ -426,6 +602,9 @@ function innerForm(options, within) {
|
|
|
426
602
|
const source = options.dataSource;
|
|
427
603
|
if (!source?.onchange || page.data.kind !== 'record')
|
|
428
604
|
return;
|
|
605
|
+
// Words in a number box are not a value to work anything out from: the server is asked once they read as one.
|
|
606
|
+
if (unreadIn(page.fields[changed], state.values[changed]))
|
|
607
|
+
return;
|
|
429
608
|
const seq = ++onchangeSeq;
|
|
430
609
|
const request = { model: page.data.model, id: state.recordId, changed, values: structuredCopy(state.values) };
|
|
431
610
|
void track(source.onchange(request).then((result) => {
|
|
@@ -526,7 +705,8 @@ function innerForm(options, within) {
|
|
|
526
705
|
values: structuredCopy(state.values),
|
|
527
706
|
});
|
|
528
707
|
forgetDraft();
|
|
529
|
-
|
|
708
|
+
// Lines the save brings back load again, in their tables' orders, as Flectra's reload after a save.
|
|
709
|
+
const values = syncParts(startFrom(result.values ? ordered({ ...state.values, ...result.values }, Object.keys(result.values)) : state.values), 'start');
|
|
530
710
|
baseline = structuredCopy(values);
|
|
531
711
|
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
|
|
532
712
|
emit('save', { recordId: state.recordId, values: state.values });
|
|
@@ -562,9 +742,18 @@ function innerForm(options, within) {
|
|
|
562
742
|
}
|
|
563
743
|
function addLine(field, values, by) {
|
|
564
744
|
const def = lineField(field);
|
|
565
|
-
const key = `new-${++lineKeys}`;
|
|
566
745
|
const current = state.values[field] ?? [];
|
|
567
|
-
|
|
746
|
+
// A key no line holds: lines given (a line's own lines, back from its form) may hold new-… keys already.
|
|
747
|
+
let key = `new-${++lineKeys}`;
|
|
748
|
+
while (current.some((line) => line.key === key))
|
|
749
|
+
key = `new-${++lineKeys}`;
|
|
750
|
+
// Each line field's default, then what is worked out from its defaultFrom, then the record's lineDefaults, then what the line is given.
|
|
751
|
+
const lineFields = def.fields;
|
|
752
|
+
const start = initialValues(lineFields, today());
|
|
753
|
+
Object.assign(start, firstValues(lineFields, lineScope(field, start)));
|
|
754
|
+
if (def.lineDefaults)
|
|
755
|
+
Object.assign(start, readValueMap(def.lineDefaults, lineFields, recordScope()));
|
|
756
|
+
const line = { key, values: { ...start, ...structuredCopy(values) } };
|
|
568
757
|
// A new line comes last, so it is numbered after the last.
|
|
569
758
|
if (def.sequenceField && line.values[def.sequenceField] == null) {
|
|
570
759
|
const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
|
|
@@ -607,30 +796,26 @@ function innerForm(options, within) {
|
|
|
607
796
|
return null;
|
|
608
797
|
return def.type === 'many2one' || def.type === 'many2many' ? def.relation : null;
|
|
609
798
|
}
|
|
610
|
-
async function runCreate(def, name, typed) {
|
|
799
|
+
async function runCreate(def, name, typed, values = {}) {
|
|
611
800
|
const model = creatable(def);
|
|
612
801
|
const source = options.dataSource;
|
|
613
802
|
if (!model || !source?.create)
|
|
614
803
|
throw new Error(`"${name}" cannot make records: its data source has no create, or it is not a link`);
|
|
615
|
-
return track(source.create({ model, name: typed.trim() }));
|
|
804
|
+
return track(source.create({ model, name: typed.trim(), ...(Object.keys(values).length ? { values } : {}) }));
|
|
616
805
|
}
|
|
617
|
-
async function runSearch(def, name, ctx, query, limit, model) {
|
|
618
|
-
if (def.type !== 'many2one' && def.type !== 'many2many' && def.type !== 'reference') {
|
|
619
|
-
throw new Error(`"${name}" is not a many2one, many2many or
|
|
806
|
+
async function runSearch(def, name, ctx, query, limit, model, ids) {
|
|
807
|
+
if (def.type !== 'many2one' && def.type !== 'many2many' && def.type !== 'reference' && def.type !== 'json') {
|
|
808
|
+
throw new Error(`"${name}" is not a many2one, many2many, reference or json`);
|
|
620
809
|
}
|
|
621
|
-
if (def.type === 'reference' && !model)
|
|
810
|
+
if ((def.type === 'reference' || def.type === 'json') && !model)
|
|
622
811
|
throw new Error(`Searching "${name}" needs a model`);
|
|
623
812
|
const source = options.dataSource;
|
|
624
813
|
if (!source?.search)
|
|
625
814
|
return [];
|
|
626
815
|
// Every valueFrom, in groups too, becomes the value it names before the search goes out.
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
? { all: item.all.map(resolve) }
|
|
631
|
-
: { field: item.field, op: item.op, value: (item.valueFrom !== undefined ? ctx[item.valueFrom] ?? null : item.value ?? null) };
|
|
632
|
-
const filter = def.type === 'reference' ? undefined : def.filter?.map(resolve);
|
|
633
|
-
const relation = def.type === 'reference' ? model : def.relation;
|
|
816
|
+
const own = def.type === 'reference' || def.type === 'json' || !def.filter ? undefined : resolveFilter(def.filter, ctx);
|
|
817
|
+
const filter = ids ? [...(own ?? []), { field: 'id', op: 'in', value: ids }] : own;
|
|
818
|
+
const relation = def.type === 'reference' || def.type === 'json' ? model : def.relation;
|
|
634
819
|
return track(source.search({ model: relation, query, ...(filter ? { filter } : {}), limit }));
|
|
635
820
|
}
|
|
636
821
|
function lineField(field) {
|
|
@@ -646,7 +831,8 @@ function innerForm(options, within) {
|
|
|
646
831
|
function optionalStep(id) {
|
|
647
832
|
return index.get(id)?.source?.optional === true;
|
|
648
833
|
}
|
|
649
|
-
|
|
834
|
+
/** Load the record; `again`, as a step's reload: no draft offered, no `open` steps run a second time. */
|
|
835
|
+
async function load(again = false) {
|
|
650
836
|
const source = options.dataSource;
|
|
651
837
|
if (state.recordId == null || page.data.kind !== 'record')
|
|
652
838
|
return;
|
|
@@ -654,18 +840,65 @@ function innerForm(options, within) {
|
|
|
654
840
|
set({ status: 'error', error: 'This page has no data source to load from' });
|
|
655
841
|
return;
|
|
656
842
|
}
|
|
843
|
+
// Only the latest load is taken: a pager pressed twice shows the record it ended on.
|
|
844
|
+
const seq = ++loads;
|
|
657
845
|
set({ status: 'loading', error: null });
|
|
658
846
|
try {
|
|
659
847
|
const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
|
|
660
|
-
|
|
848
|
+
if (seq !== loads)
|
|
849
|
+
return;
|
|
850
|
+
// Lines load in their tables' orders; where a person moves them after, they stay.
|
|
851
|
+
baseline = syncParts(startFrom(ordered({ ...initialValues(fields, today()), ...loaded })), 'start');
|
|
661
852
|
set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
|
|
853
|
+
if (again)
|
|
854
|
+
return;
|
|
662
855
|
offerDraft();
|
|
663
856
|
void track(opening());
|
|
664
857
|
}
|
|
665
858
|
catch (error) {
|
|
666
|
-
|
|
859
|
+
if (seq === loads)
|
|
860
|
+
set({ status: 'error', error: error.message });
|
|
667
861
|
}
|
|
668
862
|
}
|
|
863
|
+
let loads = 0;
|
|
864
|
+
/** Another record in this form's place, or a new one: what was there let go, the new one loaded and opened. */
|
|
865
|
+
async function openRecord(id) {
|
|
866
|
+
if (draftTimer !== null)
|
|
867
|
+
scheduler.clearTimeout(draftTimer);
|
|
868
|
+
if (autosaveTimer !== null)
|
|
869
|
+
scheduler.clearTimeout(autosaveTimer);
|
|
870
|
+
draftTimer = autosaveTimer = null;
|
|
871
|
+
serverErrors = {};
|
|
872
|
+
told = null;
|
|
873
|
+
showing = false;
|
|
874
|
+
const fresh = { dirty: [], errors: {}, warning: null, warningField: null, error: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null };
|
|
875
|
+
if (id != null) {
|
|
876
|
+
set({ ...fresh, recordId: id, status: 'idle' });
|
|
877
|
+
return load();
|
|
878
|
+
}
|
|
879
|
+
loads++;
|
|
880
|
+
baseline = startFrom(startingValues(true, false));
|
|
881
|
+
const values = syncParts(structuredCopy(baseline), 'start');
|
|
882
|
+
set({ ...fresh, recordId: null, status: 'ready', values, warnings: withValues(values, collectWarnings) });
|
|
883
|
+
offerDraft();
|
|
884
|
+
await opening();
|
|
885
|
+
}
|
|
886
|
+
/** The record a record step asks the data source about. */
|
|
887
|
+
const record = () => ({ model: page.data.model, id: state.recordId, fields });
|
|
888
|
+
async function post(message) {
|
|
889
|
+
if (options.host?.post) {
|
|
890
|
+
try {
|
|
891
|
+
return (await options.host.post(message)) !== false;
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (!events.get('post')?.size)
|
|
898
|
+
return false;
|
|
899
|
+
emit('post', { recordId: state.recordId, message });
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
669
902
|
const runner = createRunner({
|
|
670
903
|
fields,
|
|
671
904
|
values: () => state.values,
|
|
@@ -676,6 +909,12 @@ function innerForm(options, within) {
|
|
|
676
909
|
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
677
910
|
check,
|
|
678
911
|
save: saving,
|
|
912
|
+
async reload() {
|
|
913
|
+
if (state.recordId == null)
|
|
914
|
+
return { reason: 'cannot', message: 'The record is not saved yet: there is nothing to load again' };
|
|
915
|
+
await load(true);
|
|
916
|
+
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded again' } : null;
|
|
917
|
+
},
|
|
679
918
|
reset: () => form.reset(),
|
|
680
919
|
goTo: goToStep,
|
|
681
920
|
showing: inRun,
|
|
@@ -683,8 +922,50 @@ function innerForm(options, within) {
|
|
|
683
922
|
confirm: options.confirm,
|
|
684
923
|
onAction: options.onAction,
|
|
685
924
|
answered: (request, result) => emit('action', { request, result }),
|
|
925
|
+
changed: () => state.dirty.length > 0,
|
|
926
|
+
...(options.dataSource?.archive && page.data.kind === 'record' ? { archive: (archive) => track((options.dataSource?.archive)({ ...record(), archive })) } : {}),
|
|
927
|
+
...(options.dataSource?.copy && page.data.kind === 'record' ? { copy: async () => (await track((options.dataSource?.copy)(record()))).id } : {}),
|
|
928
|
+
...(options.dataSource?.delete && page.data.kind === 'record' ? { remove: () => track((options.dataSource?.delete)(record())) } : {}),
|
|
929
|
+
async showRecord(id) {
|
|
930
|
+
await openRecord(id);
|
|
931
|
+
return state.status === 'error' ? { reason: 'cannot', message: state.error ?? 'The record could not be loaded' } : null;
|
|
932
|
+
},
|
|
933
|
+
told(event, id) {
|
|
934
|
+
if ('archived' in event)
|
|
935
|
+
emit('archive', { recordId: id, archived: event.archived });
|
|
936
|
+
else if ('copied' in event)
|
|
937
|
+
emit('duplicate', { from: id, recordId: event.copied });
|
|
938
|
+
else {
|
|
939
|
+
emit('delete', { recordId: id });
|
|
940
|
+
// Nobody moved the form on: it starts a new record in the deleted one's place.
|
|
941
|
+
if (state.recordId === id)
|
|
942
|
+
void track(openRecord(null));
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
post,
|
|
946
|
+
wordsOf: (field) => wordsOfValue(fieldDef(field), state.values[field]),
|
|
686
947
|
});
|
|
687
948
|
const moment = createMoments((key, list, chain) => runTop(list, { id: key, chain }));
|
|
949
|
+
/** A press of a button: its confirmation first, then its steps and its app action as a final call, in `scope`. */
|
|
950
|
+
function press(source, scope) {
|
|
951
|
+
const list = [...(source.steps ?? [])];
|
|
952
|
+
if (source.action !== undefined)
|
|
953
|
+
list.push({ do: 'call', action: source.action, ...('params' in source && source.params ? { params: source.params } : {}) });
|
|
954
|
+
// A built-in item of the gear menu with nothing of its own: its step, asked about first when it archives or deletes.
|
|
955
|
+
const builtin = 'builtin' in source && !list.length ? source.builtin : undefined;
|
|
956
|
+
if (builtin)
|
|
957
|
+
list.push({ do: builtin });
|
|
958
|
+
const asked = builtin === 'archive' ? messages.archiveConfirm : builtin === 'delete' ? messages.deleteConfirm : undefined;
|
|
959
|
+
const confirm = 'confirm' in source && source.confirm !== undefined ? source.confirm : asked;
|
|
960
|
+
return track((async () => {
|
|
961
|
+
if (confirm && !(await runner.ask(confirm))) {
|
|
962
|
+
const result = { done: false, reason: 'no', message: confirm };
|
|
963
|
+
emit('run', { id: scope.id, steps: list, result });
|
|
964
|
+
return result;
|
|
965
|
+
}
|
|
966
|
+
return runTop(list, scope);
|
|
967
|
+
})());
|
|
968
|
+
}
|
|
688
969
|
const form = {
|
|
689
970
|
page,
|
|
690
971
|
getState: () => state,
|
|
@@ -701,8 +982,19 @@ function innerForm(options, within) {
|
|
|
701
982
|
},
|
|
702
983
|
setValues: (values) => write(values, 'app'),
|
|
703
984
|
node: nodeState,
|
|
985
|
+
openRecord: (id) => track(openRecord(id)),
|
|
986
|
+
post,
|
|
987
|
+
setEditing(next) {
|
|
988
|
+
if (next === editing)
|
|
989
|
+
return;
|
|
990
|
+
editing = next;
|
|
991
|
+
// Nothing of the record changed; what shows of it may have.
|
|
992
|
+
set({});
|
|
993
|
+
},
|
|
704
994
|
fieldReadonly: (name) => lockedField(fieldDef(name)),
|
|
705
995
|
loadChoices,
|
|
996
|
+
loadDefinitions,
|
|
997
|
+
addDefinition,
|
|
706
998
|
problem(name) {
|
|
707
999
|
fieldDef(name);
|
|
708
1000
|
// Only a field someone can see is asked anything, and required wherever it is shown so.
|
|
@@ -770,13 +1062,21 @@ function innerForm(options, within) {
|
|
|
770
1062
|
},
|
|
771
1063
|
removeLine(field, key) {
|
|
772
1064
|
lineField(field);
|
|
1065
|
+
// A table that keeps this line (its lineDelete does not hold on it) refuses, wherever the call comes from.
|
|
1066
|
+
for (const [nodeId, table] of tables) {
|
|
1067
|
+
if (table.field !== field || (index.get(nodeId)?.source).lineDelete === undefined)
|
|
1068
|
+
continue;
|
|
1069
|
+
if ((state.values[field] ?? []).some((line) => line.key === key) && !lineStateOf(nodeId, key).deletable) {
|
|
1070
|
+
throw new Error(`The line "${key}" of "${field}" cannot be deleted now: its table's lineDelete does not hold on it`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
773
1073
|
const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
|
|
774
1074
|
writeValues({ ...state.values, [field]: lines });
|
|
775
1075
|
afterEdit(field);
|
|
776
1076
|
changed(field, 'person');
|
|
777
1077
|
},
|
|
778
1078
|
async search(field, query, limit = 8, options = {}) {
|
|
779
|
-
return runSearch(fieldDef(field), field, context(), query, limit, options.model);
|
|
1079
|
+
return runSearch(fieldDef(field), field, context(), query, limit, options.model, options.ids);
|
|
780
1080
|
},
|
|
781
1081
|
async previewLine(field, key, values) {
|
|
782
1082
|
lineField(field);
|
|
@@ -784,16 +1084,16 @@ function innerForm(options, within) {
|
|
|
784
1084
|
const line = lines.find((l) => l.key === key);
|
|
785
1085
|
if (!line)
|
|
786
1086
|
throw new Error(`"${field}" has no line "${key}"`);
|
|
787
|
-
const merged = computed.line(field, { ...line.values, ...values });
|
|
1087
|
+
const merged = computed.line(field, { ...line.values, ...values }, state.values);
|
|
788
1088
|
const source = options.dataSource;
|
|
789
1089
|
if (!source?.onchange || page.data.kind !== 'record')
|
|
790
1090
|
return merged;
|
|
791
1091
|
const changed = lines.map((l) => (l.key === key ? { ...l, values: merged } : l));
|
|
792
1092
|
const result = await source.onchange({ model: page.data.model, id: state.recordId, changed: field, values: structuredCopy({ ...state.values, [field]: changed }) });
|
|
793
1093
|
const after = (result.values?.[field] ?? []).find((l) => l.key === key);
|
|
794
|
-
return after ? computed.line(field, { ...merged, ...after.values }) : merged;
|
|
1094
|
+
return after ? computed.line(field, { ...merged, ...after.values }, state.values) : merged;
|
|
795
1095
|
},
|
|
796
|
-
async searchLine(field, key, subfield, query, limit = 8) {
|
|
1096
|
+
async searchLine(field, key, subfield, query, limit = 8, options = {}) {
|
|
797
1097
|
const def = lineField(field);
|
|
798
1098
|
const sub = def.fields[subfield];
|
|
799
1099
|
if (!sub)
|
|
@@ -801,12 +1101,13 @@ function innerForm(options, within) {
|
|
|
801
1101
|
const line = (state.values[field] ?? []).find((l) => l.key === key);
|
|
802
1102
|
if (!line)
|
|
803
1103
|
throw new Error(`"${field}" has no line "${key}"`);
|
|
804
|
-
return runSearch(sub, subfield,
|
|
1104
|
+
return runSearch(sub, subfield, lineContext(line.values, def.fields, context(), line.id ?? null), query, limit, options.model, options.ids);
|
|
805
1105
|
},
|
|
806
1106
|
canCreate: (field) => creatable(page.fields[field]) !== null,
|
|
807
|
-
quickCreate: (field, typed) => runCreate(page.fields[field], field, typed),
|
|
1107
|
+
quickCreate: (field, typed) => runCreate(page.fields[field], field, typed, page.fields[field] ? createValuesOf(field) : {}),
|
|
808
1108
|
canCreateLine: (field, subfield) => creatable(lineField(field).fields[subfield]) !== null,
|
|
809
|
-
quickCreateLine: (field, subfield, typed) => runCreate(lineField(field).fields[subfield], subfield, typed),
|
|
1109
|
+
quickCreateLine: (field, subfield, typed, key) => runCreate(lineField(field).fields[subfield], subfield, typed, key ? createValuesOf(subfield, { lines: field, key }) : {}),
|
|
1110
|
+
createValues: (field, line) => createValuesOf(field, line),
|
|
810
1111
|
steps: () => steps.filter((id) => !nodeState(id).invisible),
|
|
811
1112
|
next() {
|
|
812
1113
|
if (!state.step)
|
|
@@ -880,21 +1181,85 @@ function innerForm(options, within) {
|
|
|
880
1181
|
throw new Error(`The page has no button "${id}"`);
|
|
881
1182
|
if (nodeState(id).invisible)
|
|
882
1183
|
return { done: false, reason: 'cannot', message: `The button "${id}" is hidden` };
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1184
|
+
// A button for a table's chosen lines runs with them: runLinesAction.
|
|
1185
|
+
if (node.parent && index.get(node.parent)?.source?.selectedButtons?.some((b) => b.id === id)) {
|
|
1186
|
+
return { done: false, reason: 'cannot', message: `The button "${id}" runs with the lines chosen in its table` };
|
|
1187
|
+
}
|
|
1188
|
+
return press(node.source, { id, chain: [], ...(chosen ? { recordIds: [...chosen.recordIds] } : {}) });
|
|
1189
|
+
},
|
|
1190
|
+
async linkedValues(field, ids, columns) {
|
|
1191
|
+
const def = fieldDef(field);
|
|
1192
|
+
if (def.type !== 'many2many')
|
|
1193
|
+
throw new Error(`"${field}" is not a many2many`);
|
|
1194
|
+
const source = options.dataSource;
|
|
1195
|
+
if (!source?.list || !ids.length)
|
|
1196
|
+
return [];
|
|
1197
|
+
const found = await source.list({ model: def.relation, fields: [...columns], filter: [{ field: 'id', op: 'in', value: [...ids] }], sort: [], offset: 0, limit: ids.length });
|
|
1198
|
+
const byId = new Map(found.records.map((record) => [String(record.id), record]));
|
|
1199
|
+
return ids.flatMap((id) => {
|
|
1200
|
+
const record = byId.get(String(id));
|
|
1201
|
+
return record ? [{ id: record.id, values: record.values }] : [];
|
|
1202
|
+
});
|
|
1203
|
+
},
|
|
1204
|
+
fieldTone(nodeId) {
|
|
1205
|
+
const compiled = fieldTones.get(nodeId);
|
|
1206
|
+
if (compiled) {
|
|
1207
|
+
const { context: ctx, env } = reading();
|
|
1208
|
+
return compiled(ctx, env);
|
|
1209
|
+
}
|
|
1210
|
+
if (index.get(nodeId)?.kind !== 'field')
|
|
1211
|
+
throw new Error(`"${nodeId}" is not a field on this page`);
|
|
1212
|
+
return { tone: null, bold: false };
|
|
1213
|
+
},
|
|
1214
|
+
placeholder(nodeId) {
|
|
1215
|
+
const own = placeholders.get(nodeId);
|
|
1216
|
+
if (!own) {
|
|
1217
|
+
const node = index.get(nodeId);
|
|
1218
|
+
if (node?.kind !== 'field')
|
|
1219
|
+
throw new Error(`"${nodeId}" is not a field on this page`);
|
|
1220
|
+
const source = node.source;
|
|
1221
|
+
return nodeId === '#title' && page.layout.type === 'sheet' ? page.layout.title?.placeholder : source.placeholder;
|
|
1222
|
+
}
|
|
1223
|
+
const { context: ctx, env } = reading();
|
|
1224
|
+
return own.when.find((item) => item.holds.evaluate(ctx, env))?.text ?? own.fixed;
|
|
1225
|
+
},
|
|
1226
|
+
lineState: (nodeId, key) => lineStateOf(nodeId, key),
|
|
1227
|
+
columnHidden(nodeId, column) {
|
|
1228
|
+
const table = tableOf(nodeId);
|
|
1229
|
+
const { context, env } = reading();
|
|
1230
|
+
return table ? table.compiled.hidden(column, context, env, userRoles) : false;
|
|
1231
|
+
},
|
|
1232
|
+
async runRowAction(nodeId, buttonId, key) {
|
|
1233
|
+
const table = tableOf(nodeId);
|
|
1234
|
+
const button = table?.compiled.buttons.find((b) => b.id === buttonId);
|
|
1235
|
+
if (!table || !button)
|
|
1236
|
+
throw new Error(`The table "${nodeId}" has no button "${buttonId}"`);
|
|
1237
|
+
if (!lineStateOf(nodeId, key).buttons[buttonId])
|
|
1238
|
+
return { done: false, reason: 'cannot', message: `The button "${buttonId}" is hidden on this line` };
|
|
1239
|
+
const line = (state.values[table.field] ?? []).find((l) => l.key === key);
|
|
1240
|
+
return press(button, { id: buttonId, chain: [], line: { field: table.field, key, values: structuredCopy(line.values) } });
|
|
1241
|
+
},
|
|
1242
|
+
async runLinesAction(nodeId, buttonId, keys) {
|
|
1243
|
+
const node = index.get(nodeId);
|
|
1244
|
+
const source = node?.source;
|
|
1245
|
+
const button = source?.selectedButtons?.find((b) => b.id === buttonId);
|
|
1246
|
+
if (!node || !source || !button)
|
|
1247
|
+
throw new Error(`The table "${nodeId}" has no button "${buttonId}" for its chosen lines`);
|
|
1248
|
+
const all = state.values[source.field] ?? [];
|
|
1249
|
+
const chosen = keys.map((key) => all.find((line) => line.key === key) ?? null);
|
|
1250
|
+
const missing = keys.find((_, i) => !chosen[i]);
|
|
1251
|
+
if (missing !== undefined)
|
|
1252
|
+
throw new Error(`"${source.field}" has no line "${missing}"`);
|
|
1253
|
+
if (nodeState(buttonId).invisible)
|
|
1254
|
+
return { done: false, reason: 'cannot', message: `The button "${buttonId}" is hidden` };
|
|
1255
|
+
if (!keys.length)
|
|
1256
|
+
return { done: false, reason: 'cannot', message: `The button "${buttonId}" runs with lines chosen: none is` };
|
|
1257
|
+
const lines = chosen;
|
|
1258
|
+
return press(button, {
|
|
1259
|
+
id: buttonId,
|
|
1260
|
+
chain: [],
|
|
1261
|
+
lines: { field: source.field, keys: [...keys], ids: lines.flatMap((line) => (line.id === undefined ? [] : [line.id])), values: lines.map((line) => structuredCopy(line.values)) },
|
|
1262
|
+
});
|
|
898
1263
|
},
|
|
899
1264
|
run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
|
|
900
1265
|
shown(tab) {
|
|
@@ -1012,6 +1377,29 @@ function inside(errors, name) {
|
|
|
1012
1377
|
found[key.slice(name.length + 1)] = message;
|
|
1013
1378
|
return found;
|
|
1014
1379
|
}
|
|
1380
|
+
/** One value against another in a table's order: empty and false first, a link by its name, words as people sort them. */
|
|
1381
|
+
function compareValues(a, b) {
|
|
1382
|
+
const empty = (v) => v === null || v === undefined || v === false || v === '';
|
|
1383
|
+
if (empty(a) || empty(b))
|
|
1384
|
+
return empty(a) === empty(b) ? 0 : empty(a) ? -1 : 1;
|
|
1385
|
+
const plain = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) && 'label' in v ? String(v.label) : v);
|
|
1386
|
+
const x = plain(a);
|
|
1387
|
+
const y = plain(b);
|
|
1388
|
+
if (typeof x === 'number' && typeof y === 'number')
|
|
1389
|
+
return x - y;
|
|
1390
|
+
if (typeof x === 'boolean' && typeof y === 'boolean')
|
|
1391
|
+
return Number(x) - Number(y);
|
|
1392
|
+
return String(x).localeCompare(String(y));
|
|
1393
|
+
}
|
|
1394
|
+
/** Two lines in a table's order: by the first field, then the next. */
|
|
1395
|
+
function compareLines(a, b, by) {
|
|
1396
|
+
for (const item of by) {
|
|
1397
|
+
const found = compareValues(a[item.field], b[item.field]);
|
|
1398
|
+
if (found)
|
|
1399
|
+
return item.desc ? -found : found;
|
|
1400
|
+
}
|
|
1401
|
+
return 0;
|
|
1402
|
+
}
|
|
1015
1403
|
/** A field no one types in: read-only by its definition, or worked out from others. */
|
|
1016
1404
|
function lockedField(def) {
|
|
1017
1405
|
return def.readonly === true || def.compute !== undefined;
|
|
@@ -1050,11 +1438,13 @@ function linkOps(before, now) {
|
|
|
1050
1438
|
function indexLayout(root) {
|
|
1051
1439
|
const index = new Map();
|
|
1052
1440
|
const add = (source, parent, kind, modifiers) => {
|
|
1441
|
+
const roles = source.roles;
|
|
1053
1442
|
index.set(source.id, {
|
|
1054
1443
|
id: source.id,
|
|
1055
1444
|
parent,
|
|
1056
1445
|
kind,
|
|
1057
1446
|
field: modifiers.field,
|
|
1447
|
+
...(roles?.length ? { roles } : {}),
|
|
1058
1448
|
invisible: compileModifier(modifiers.invisible),
|
|
1059
1449
|
readonly: compileModifier(modifiers.readonly),
|
|
1060
1450
|
required: compileModifier(modifiers.required),
|
|
@@ -1065,6 +1455,9 @@ function indexLayout(root) {
|
|
|
1065
1455
|
for (const node of nodes) {
|
|
1066
1456
|
if (node.type === 'field') {
|
|
1067
1457
|
add(node, parent, 'field', { invisible: node.invisible, readonly: node.readonly, required: node.required, field: node.field });
|
|
1458
|
+
// A table's buttons for its chosen lines and in its control row: buttons of the record, inside the table.
|
|
1459
|
+
for (const button of [...(node.selectedButtons ?? []), ...(node.controlButtons ?? [])])
|
|
1460
|
+
add(button, node.id, 'button', { invisible: button.invisible });
|
|
1068
1461
|
}
|
|
1069
1462
|
else if (node.type === 'button') {
|
|
1070
1463
|
add(node, parent, 'button', { invisible: node.invisible });
|
|
@@ -1102,6 +1495,8 @@ function indexLayout(root) {
|
|
|
1102
1495
|
}
|
|
1103
1496
|
else if (root.type === 'sections') {
|
|
1104
1497
|
walk(root.children, root.id);
|
|
1498
|
+
for (const button of root.footer ?? [])
|
|
1499
|
+
add(button, root.id, 'button', { invisible: button.invisible });
|
|
1105
1500
|
}
|
|
1106
1501
|
else if (root.type === 'list') {
|
|
1107
1502
|
// A list holds no fields of its own record: only the buttons for what is selected.
|
|
@@ -1118,23 +1513,64 @@ function indexLayout(root) {
|
|
|
1118
1513
|
shown('#title', root.title?.field);
|
|
1119
1514
|
shown('#subtitle', root.title?.subtitleField);
|
|
1120
1515
|
shown('#avatar', root.title?.avatarField);
|
|
1121
|
-
|
|
1122
|
-
|
|
1516
|
+
// The statusbar shows and hides by its own condition and roles.
|
|
1517
|
+
if (root.statusbar)
|
|
1518
|
+
add({ id: '#statusbar', ...(root.statusbar.roles ? { roles: root.statusbar.roles } : {}) }, root.id, 'field', { field: root.statusbar.field, invisible: root.statusbar.invisible });
|
|
1519
|
+
for (const button of [...(root.buttons ?? []), ...(root.footer ?? [])])
|
|
1123
1520
|
add(button, root.id, 'button', { invisible: button.invisible });
|
|
1124
1521
|
for (const stat of root.statButtons ?? [])
|
|
1125
1522
|
add(stat, root.id, 'stat', { invisible: stat.invisible });
|
|
1126
|
-
|
|
1127
|
-
add(
|
|
1128
|
-
for (const alert of root.alerts ?? [])
|
|
1523
|
+
for (const ribbon of [...(root.ribbon ? [root.ribbon] : []), ...(root.ribbons ?? [])])
|
|
1524
|
+
add(ribbon, root.id, 'other', { invisible: ribbon.invisible });
|
|
1525
|
+
for (const alert of root.alerts ?? []) {
|
|
1129
1526
|
add(alert, root.id, 'other', { invisible: alert.invisible });
|
|
1527
|
+
// An alert's buttons are hidden with it.
|
|
1528
|
+
for (const button of alert.buttons ?? [])
|
|
1529
|
+
add(button, alert.id, 'button', { invisible: button.invisible });
|
|
1530
|
+
}
|
|
1130
1531
|
for (const badge of root.badges ?? [])
|
|
1131
1532
|
add(badge, root.id, 'other', { invisible: badge.invisible });
|
|
1132
|
-
// Fields over and under the title are field nodes like any other.
|
|
1133
|
-
walk([...(root.title?.above ?? []), ...(root.title?.below ?? [])], root.id);
|
|
1533
|
+
// Fields over and under the title, and on its line, are field nodes like any other.
|
|
1534
|
+
walk([...(root.title?.above ?? []), ...(root.title?.before ?? []), ...(root.title?.after ?? []), ...(root.title?.below ?? [])], root.id);
|
|
1134
1535
|
walk(root.children, root.id);
|
|
1135
1536
|
if (root.sidePanel)
|
|
1136
1537
|
add(root.sidePanel, root.id, 'other', { invisible: root.sidePanel.invisible });
|
|
1538
|
+
// The gear menu's items are buttons of the record; Archive shows while it is active, Unarchive while it is not.
|
|
1539
|
+
for (const item of root.toolbar?.menu ?? []) {
|
|
1540
|
+
add(item, root.id, 'button', { invisible: item.invisible });
|
|
1541
|
+
if (item.builtin === 'archive' || item.builtin === 'unarchive') {
|
|
1542
|
+
const own = index.get(item.id);
|
|
1543
|
+
const mine = own.invisible;
|
|
1544
|
+
const archived = item.builtin === 'unarchive';
|
|
1545
|
+
own.invisible = { ...mine, evaluate: (context, env) => (context['active'] === false) !== archived || mine.evaluate(context, env) };
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (root.attachmentPreview)
|
|
1549
|
+
add({ id: '#preview', ...root.attachmentPreview }, root.id, 'other', { invisible: root.attachmentPreview.invisible });
|
|
1137
1550
|
}
|
|
1138
1551
|
return index;
|
|
1139
1552
|
}
|
|
1553
|
+
/** A field's value as words in a posted message: a link's name, a choice's label, a yes or no, a number or a date as written. */
|
|
1554
|
+
function wordsOfValue(def, value) {
|
|
1555
|
+
if (def.type === 'boolean')
|
|
1556
|
+
return value === true ? 'Yes' : 'No';
|
|
1557
|
+
if (value === null || value === undefined || value === false)
|
|
1558
|
+
return '';
|
|
1559
|
+
if (def.type === 'selection')
|
|
1560
|
+
return def.options.find((option) => option.value === value)?.label ?? String(value);
|
|
1561
|
+
if (def.type === 'html')
|
|
1562
|
+
return String(value).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1563
|
+
const one = (item) => (item && typeof item === 'object' && 'label' in item ? String(item.label) : item && typeof item === 'object' && 'name' in item ? String(item.name) : String(item));
|
|
1564
|
+
return Array.isArray(value) ? value.map(one).join(', ') : one(value);
|
|
1565
|
+
}
|
|
1566
|
+
/** Words held where a number goes ("4.001234567.5"): a box the person typed in and the browser could not read as one. */
|
|
1567
|
+
export function notANumber(def, value) {
|
|
1568
|
+
return !!def && (def.type === 'integer' || def.type === 'float' || def.type === 'monetary') && typeof value === 'string' && value.trim() !== '';
|
|
1569
|
+
}
|
|
1570
|
+
/** A number box holding words, the field's own or a line's cell. */
|
|
1571
|
+
function unreadIn(def, value) {
|
|
1572
|
+
if (def?.type !== 'one2many')
|
|
1573
|
+
return notANumber(def, value);
|
|
1574
|
+
return (value ?? []).some((line) => Object.entries(line.values).some(([sub, v]) => notANumber(def.fields[sub], v)));
|
|
1575
|
+
}
|
|
1140
1576
|
//# sourceMappingURL=form.mjs.map
|