@fieldia/core 0.10.0 → 0.11.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 +199 -0
- package/package.json +1 -1
- package/schema/page.schema.json +829 -125
- package/src/index.d.mts +1 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +4 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +1 -0
- package/src/index.mjs.map +1 -1
- package/src/lib/format/actions.d.mts +692 -0
- package/src/lib/format/actions.d.ts +692 -0
- package/src/lib/format/actions.js +53 -0
- package/src/lib/format/actions.js.map +1 -0
- package/src/lib/format/actions.mjs +50 -0
- package/src/lib/format/actions.mjs.map +1 -0
- package/src/lib/format/field.js +4 -2
- package/src/lib/format/field.js.map +1 -1
- package/src/lib/format/field.mjs +4 -2
- package/src/lib/format/field.mjs.map +1 -1
- package/src/lib/format/layout.d.mts +745 -15
- package/src/lib/format/layout.d.ts +745 -15
- package/src/lib/format/layout.js +18 -3
- package/src/lib/format/layout.js.map +1 -1
- package/src/lib/format/layout.mjs +17 -2
- package/src/lib/format/layout.mjs.map +1 -1
- package/src/lib/format/layout.types.d.mts +2 -1
- package/src/lib/format/layout.types.d.ts +2 -1
- package/src/lib/format/page.d.mts +616 -3
- package/src/lib/format/page.d.ts +616 -3
- package/src/lib/format/page.js +17 -2
- package/src/lib/format/page.js.map +1 -1
- package/src/lib/format/page.mjs +17 -2
- package/src/lib/format/page.mjs.map +1 -1
- package/src/lib/format/part-look.d.mts +36 -0
- package/src/lib/format/part-look.d.ts +36 -0
- package/src/lib/format/part-look.js +23 -0
- package/src/lib/format/part-look.js.map +1 -0
- package/src/lib/format/part-look.mjs +20 -0
- package/src/lib/format/part-look.mjs.map +1 -0
- package/src/lib/format/references.d.mts +19 -0
- package/src/lib/format/references.d.ts +19 -0
- package/src/lib/format/references.js +131 -2
- package/src/lib/format/references.js.map +1 -1
- package/src/lib/format/references.mjs +131 -2
- package/src/lib/format/references.mjs.map +1 -1
- package/src/lib/record/data-source.d.mts +5 -1
- package/src/lib/record/data-source.d.ts +5 -1
- package/src/lib/record/data-source.js.map +1 -1
- package/src/lib/record/data-source.mjs.map +1 -1
- package/src/lib/record/form.d.mts +86 -18
- package/src/lib/record/form.d.ts +86 -18
- package/src/lib/record/form.js +343 -46
- package/src/lib/record/form.js.map +1 -1
- package/src/lib/record/form.mjs +343 -46
- package/src/lib/record/form.mjs.map +1 -1
- package/src/lib/record/run.d.mts +169 -0
- package/src/lib/record/run.d.ts +169 -0
- package/src/lib/record/run.js +350 -0
- package/src/lib/record/run.js.map +1 -0
- package/src/lib/record/run.mjs +345 -0
- package/src/lib/record/run.mjs.map +1 -0
- package/src/runtime.d.mts +5 -2
- package/src/runtime.d.ts +5 -2
- package/src/runtime.js +3 -1
- package/src/runtime.js.map +1 -1
- package/src/runtime.mjs +1 -0
- package/src/runtime.mjs.map +1 -1
package/src/lib/record/form.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { expressionEnv } from './env.mjs';
|
|
|
9
9
|
import { MESSAGES } from './messages.mjs';
|
|
10
10
|
import { saveProblemOf } from './data-source.mjs';
|
|
11
11
|
import { hostScheduler } from './scheduler.mjs';
|
|
12
|
+
import { createMoments, createRunner } from './run.mjs';
|
|
12
13
|
import { emptyValue, expressionContext, initialValues, isEmpty, lineKind, structuredCopy, } from './values.mjs';
|
|
13
14
|
/**
|
|
14
15
|
* The record state behind a page: values, what changed, what is visible and
|
|
@@ -16,6 +17,13 @@ import { emptyValue, expressionContext, initialValues, isEmpty, lineKind, struct
|
|
|
16
17
|
* binding renders from `getState()` and calls these methods.
|
|
17
18
|
*/
|
|
18
19
|
export function createForm(options) {
|
|
20
|
+
return innerForm(options, []).form;
|
|
21
|
+
}
|
|
22
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
/** A saved form's answers, as the one value they are in the form round it. */
|
|
24
|
+
const asValue = (values) => values;
|
|
25
|
+
/** `within` are the pages round this one, outermost first: a page among them placed again would hold itself. */
|
|
26
|
+
function innerForm(options, within) {
|
|
19
27
|
const { page } = options;
|
|
20
28
|
const scheduler = options.scheduler ?? hostScheduler;
|
|
21
29
|
const messages = options.messages ?? MESSAGES.en;
|
|
@@ -34,7 +42,20 @@ export function createForm(options) {
|
|
|
34
42
|
const today = () => localDay(options.now?.() ?? new Date());
|
|
35
43
|
const computed = compileComputed(page, today);
|
|
36
44
|
const setWhen = compileSetWhen(page, today);
|
|
37
|
-
|
|
45
|
+
/** The saved forms placed on the page, by the part's id: each one's answers sit under its name, as JSON. */
|
|
46
|
+
const parts = new Map();
|
|
47
|
+
for (const node of index.values())
|
|
48
|
+
if (node.kind === 'form')
|
|
49
|
+
parts.set(node.id, node.source);
|
|
50
|
+
/** Those given their page, with the inner form that holds their answers. */
|
|
51
|
+
const attached = new Map();
|
|
52
|
+
/** The page's fields, and each saved form's answers as a JSON value: what is loaded, told as changed and saved. */
|
|
53
|
+
const fields = { ...page.fields };
|
|
54
|
+
for (const part of parts.values())
|
|
55
|
+
fields[part.name] = { type: 'json', label: part.title || part.name };
|
|
56
|
+
/** True while answers are handed to an inner form: what it says back then is no change of its own. */
|
|
57
|
+
let syncing = false;
|
|
58
|
+
let baseline = computed.apply({ ...initialValues(fields, today()), ...structuredCopy(options.values ?? {}) });
|
|
38
59
|
/** The setWhen conditions holding for the values as they last settled: one starts to hold only against these. */
|
|
39
60
|
let holding = setWhen.holding(baseline);
|
|
40
61
|
let state = {
|
|
@@ -61,12 +82,25 @@ export function createForm(options) {
|
|
|
61
82
|
let autosaveTimer = null;
|
|
62
83
|
const pending = new Set();
|
|
63
84
|
const listeners = new Set();
|
|
85
|
+
/** The app's listeners, by event. */
|
|
86
|
+
const events = new Map();
|
|
87
|
+
/** The moments the run going to a step or tab right now was set off by: the `show` that entry runs knows them, and does not set them off again. */
|
|
88
|
+
let entering = [];
|
|
89
|
+
/** Whether wizard steps entered are told: from once the form has opened and run its `open` steps. */
|
|
90
|
+
let showing = false;
|
|
91
|
+
/** The wizard step last told as entered. */
|
|
92
|
+
let told = null;
|
|
64
93
|
let contextCache = null;
|
|
65
94
|
/** The latest load of each field's choices: an answer to an older one is let go. */
|
|
66
95
|
const choiceLoads = {};
|
|
67
96
|
function set(patch) {
|
|
68
97
|
const was = state.values;
|
|
98
|
+
const shown = state.errors;
|
|
69
99
|
state = { ...state, ...patch };
|
|
100
|
+
// The problems found inside a saved form are shown on it, beside its own fields.
|
|
101
|
+
if (state.errors !== shown)
|
|
102
|
+
for (const part of attached.values())
|
|
103
|
+
part.inner.show(inside(state.errors, part.name));
|
|
70
104
|
for (const listener of [...listeners])
|
|
71
105
|
listener(state);
|
|
72
106
|
// Choices that change with a value that just changed are asked for again.
|
|
@@ -75,6 +109,43 @@ export function createForm(options) {
|
|
|
75
109
|
if (fromList(name).dependsOn?.some((other) => was[other] !== state.values[other]))
|
|
76
110
|
loadChoices(name);
|
|
77
111
|
}
|
|
112
|
+
if (showing && state.step !== told)
|
|
113
|
+
tellStep(entering);
|
|
114
|
+
}
|
|
115
|
+
function emit(event, payload) {
|
|
116
|
+
const heard = events.get(event);
|
|
117
|
+
if (heard)
|
|
118
|
+
for (const listener of [...heard])
|
|
119
|
+
listener(payload);
|
|
120
|
+
}
|
|
121
|
+
/** A field written: told to the app, and — a person's change — the page's `change` steps for it run. */
|
|
122
|
+
function changed(field, by) {
|
|
123
|
+
emit('change', { field, value: state.values[field], values: state.values, by });
|
|
124
|
+
const list = page.on?.change?.[field];
|
|
125
|
+
if (by === 'person' && list)
|
|
126
|
+
void track(moment(`on.change.${field}`, list, [], true));
|
|
127
|
+
}
|
|
128
|
+
/** A wizard step entered: told to the app, and its `show` steps run. */
|
|
129
|
+
function tellStep(chain) {
|
|
130
|
+
const step = state.step;
|
|
131
|
+
if (step === told || step === null)
|
|
132
|
+
return;
|
|
133
|
+
told = step;
|
|
134
|
+
emit('step', { step });
|
|
135
|
+
const list = page.on?.show?.[step];
|
|
136
|
+
if (list)
|
|
137
|
+
void track(moment(`on.show.${step}`, list, chain, true));
|
|
138
|
+
}
|
|
139
|
+
/** Do something while `chain` is the run's that does it, so a step or tab it enters knows. */
|
|
140
|
+
function inRun(chain, act) {
|
|
141
|
+
const was = entering;
|
|
142
|
+
entering = chain;
|
|
143
|
+
try {
|
|
144
|
+
act();
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
entering = was;
|
|
148
|
+
}
|
|
78
149
|
}
|
|
79
150
|
const fromList = (name) => page.fields[name].optionsFrom;
|
|
80
151
|
function loadChoices(name) {
|
|
@@ -109,13 +180,13 @@ export function createForm(options) {
|
|
|
109
180
|
}
|
|
110
181
|
const context = () => reading().context;
|
|
111
182
|
function fieldDef(name) {
|
|
112
|
-
const def =
|
|
183
|
+
const def = fields[name];
|
|
113
184
|
if (!def)
|
|
114
185
|
throw new Error(`The page has no field "${name}"`);
|
|
115
186
|
return def;
|
|
116
187
|
}
|
|
117
188
|
function dirtyFields(values) {
|
|
118
|
-
return Object.keys(
|
|
189
|
+
return Object.keys(fields).filter((name) => JSON.stringify(values[name] ?? null) !== JSON.stringify(baseline[name] ?? null));
|
|
119
190
|
}
|
|
120
191
|
function nodeState(id) {
|
|
121
192
|
const node = index.get(id);
|
|
@@ -138,8 +209,8 @@ export function createForm(options) {
|
|
|
138
209
|
required: node.required.evaluate(ctx, env) || def?.required === true,
|
|
139
210
|
};
|
|
140
211
|
}
|
|
141
|
-
/** Field nodes inside `root` (or the whole page), visible right now. */
|
|
142
|
-
function visibleFieldNodes(root) {
|
|
212
|
+
/** Field nodes — or saved forms placed on the page — inside `root` (or the whole page), visible right now. */
|
|
213
|
+
function visibleFieldNodes(root, kind = 'field') {
|
|
143
214
|
const inside = (node) => {
|
|
144
215
|
if (!root)
|
|
145
216
|
return true;
|
|
@@ -157,7 +228,7 @@ export function createForm(options) {
|
|
|
157
228
|
return true;
|
|
158
229
|
return false;
|
|
159
230
|
};
|
|
160
|
-
return [...index.values()].filter((node) => node.kind ===
|
|
231
|
+
return [...index.values()].filter((node) => node.kind === kind && inside(node) && !skipped(node) && !nodeState(node.id).invisible);
|
|
161
232
|
}
|
|
162
233
|
/** What a field node's answer rules say of its value now: the first error and the first warning. */
|
|
163
234
|
function ruleCheck(node) {
|
|
@@ -229,8 +300,36 @@ export function createForm(options) {
|
|
|
229
300
|
}
|
|
230
301
|
}
|
|
231
302
|
}
|
|
303
|
+
// A saved form placed here answers for its fields by its own page's rules.
|
|
304
|
+
for (const node of visibleFieldNodes(root, 'form')) {
|
|
305
|
+
const part = attached.get(node.id);
|
|
306
|
+
if (part)
|
|
307
|
+
for (const [key, message] of Object.entries(part.inner.errorsNow()))
|
|
308
|
+
errors[`${part.name}.${key}`] = message;
|
|
309
|
+
}
|
|
232
310
|
return errors;
|
|
233
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Answers changed from outside a saved form — loaded, put back, set — handed
|
|
314
|
+
* to it, and what it holds after taken back: its defaults and worked-out
|
|
315
|
+
* values filled in. A new starting point (`start`) is handed to each.
|
|
316
|
+
*/
|
|
317
|
+
function syncParts(values, how) {
|
|
318
|
+
let out = values;
|
|
319
|
+
for (const part of attached.values()) {
|
|
320
|
+
if (how !== 'start' && out[part.name] === part.inner.form.getState().values)
|
|
321
|
+
continue;
|
|
322
|
+
syncing = true;
|
|
323
|
+
try {
|
|
324
|
+
part.inner.take(isRecord(out[part.name]) ? out[part.name] : {}, how);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
syncing = false;
|
|
328
|
+
}
|
|
329
|
+
out = { ...out, [part.name]: asValue(part.inner.form.getState().values) };
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
234
333
|
/**
|
|
235
334
|
* Values as they settle after a change: worked out, then set where a
|
|
236
335
|
* condition started to hold, then worked out again from what was set — a
|
|
@@ -255,7 +354,7 @@ export function createForm(options) {
|
|
|
255
354
|
}
|
|
256
355
|
/** Write values someone or something changed. `fresh` values are a starting point instead (a restored draft). */
|
|
257
356
|
function writeValues(written, extra = {}, fresh = false) {
|
|
258
|
-
const values = fresh ? startFrom(written) : settle(written);
|
|
357
|
+
const values = syncParts(fresh ? startFrom(written) : settle(written), fresh ? 'fresh' : 'edit');
|
|
259
358
|
const errors = Object.keys(state.errors).length ? revalidateShown(values) : state.errors;
|
|
260
359
|
set({ values, dirty: dirtyFields(values), errors, warnings: withValues(values, collectWarnings), ...extra });
|
|
261
360
|
}
|
|
@@ -347,10 +446,10 @@ export function createForm(options) {
|
|
|
347
446
|
// included; a stored one sends only what changed since it was loaded.
|
|
348
447
|
const creating = state.recordId === null || state.recordId === undefined;
|
|
349
448
|
const names = creating
|
|
350
|
-
? Object.keys(
|
|
449
|
+
? Object.keys(fields).filter((name) => state.dirty.includes(name) || !isEmpty(fields[name], state.values[name]))
|
|
351
450
|
: state.dirty;
|
|
352
451
|
for (const name of names) {
|
|
353
|
-
const def =
|
|
452
|
+
const def = fields[name];
|
|
354
453
|
const now = state.values[name];
|
|
355
454
|
const before = creating ? emptyValue(def) : baseline[name];
|
|
356
455
|
if (def.type === 'one2many')
|
|
@@ -369,13 +468,38 @@ export function createForm(options) {
|
|
|
369
468
|
const name = node.field;
|
|
370
469
|
shown[name] = structuredCopy(state.values[name]);
|
|
371
470
|
}
|
|
471
|
+
// And each saved form shown, by its page: one not found shows nothing to answer.
|
|
472
|
+
for (const node of visibleFieldNodes(undefined, 'form')) {
|
|
473
|
+
const part = attached.get(node.id);
|
|
474
|
+
if (part)
|
|
475
|
+
shown[part.name] = structuredCopy(state.values[part.name]);
|
|
476
|
+
}
|
|
372
477
|
return shown;
|
|
373
478
|
}
|
|
374
|
-
async function save() {
|
|
479
|
+
async function save(chain = []) {
|
|
480
|
+
return (await saving(chain)) === null;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Save, or send: null once done, or why not. The page's `beforeSave` steps
|
|
484
|
+
* run first — before its own check, so they may fill in what it asks for —
|
|
485
|
+
* and a stop keeps it unsaved; `afterSave` runs once it is saved.
|
|
486
|
+
*/
|
|
487
|
+
async function saving(chain) {
|
|
488
|
+
if (page.on?.beforeSave) {
|
|
489
|
+
const before = await moment('on.beforeSave', page.on.beforeSave, chain, false);
|
|
490
|
+
if (!before.done) {
|
|
491
|
+
// A check's problems, or an inner save's, are shown already; any other stop is this save's problem.
|
|
492
|
+
if (before.reason !== 'check' && before.reason !== 'save') {
|
|
493
|
+
const problem = { kind: 'stopped', reason: before.reason, message: before.message ?? '' };
|
|
494
|
+
set({ status: 'error', error: problem.message || null, saveProblem: problem });
|
|
495
|
+
}
|
|
496
|
+
return { reason: 'save', ...(before.message !== undefined ? { message: before.message } : {}) };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
375
499
|
const errors = collectErrors();
|
|
376
500
|
if (Object.keys(errors).length) {
|
|
377
501
|
set({ errors });
|
|
378
|
-
return
|
|
502
|
+
return { reason: 'save', message: Object.values(errors)[0] };
|
|
379
503
|
}
|
|
380
504
|
const source = options.dataSource;
|
|
381
505
|
serverErrors = {};
|
|
@@ -384,33 +508,98 @@ export function createForm(options) {
|
|
|
384
508
|
if (page.data.kind === 'responses') {
|
|
385
509
|
if (!source?.submit)
|
|
386
510
|
throw new Error('This page has no data source to send its answers to');
|
|
387
|
-
|
|
511
|
+
const sent = shownValues();
|
|
512
|
+
await source.submit({ pageId: page.id, values: sent });
|
|
388
513
|
forgetDraft();
|
|
389
514
|
set({ status: 'saved' });
|
|
390
|
-
|
|
515
|
+
emit('send', { values: sent });
|
|
516
|
+
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
517
|
+
return null;
|
|
391
518
|
}
|
|
392
519
|
if (!source?.save)
|
|
393
520
|
throw new Error('This page has no data source to save to');
|
|
394
521
|
const result = await source.save({
|
|
395
522
|
model: page.data.model,
|
|
396
523
|
id: state.recordId,
|
|
397
|
-
fields
|
|
524
|
+
fields,
|
|
398
525
|
changes: changes(),
|
|
399
526
|
values: structuredCopy(state.values),
|
|
400
527
|
});
|
|
401
528
|
forgetDraft();
|
|
402
|
-
const values = startFrom(result.values ? { ...state.values, ...result.values } : state.values);
|
|
529
|
+
const values = syncParts(startFrom(result.values ? { ...state.values, ...result.values } : state.values), 'start');
|
|
403
530
|
baseline = structuredCopy(values);
|
|
404
531
|
set({ status: 'saved', recordId: result.id, values, dirty: [], warnings: withValues(values, collectWarnings) });
|
|
405
|
-
|
|
532
|
+
emit('save', { recordId: state.recordId, values: state.values });
|
|
533
|
+
void track(moment('on.afterSave', page.on?.afterSave, chain, false));
|
|
534
|
+
return null;
|
|
406
535
|
}
|
|
407
536
|
catch (error) {
|
|
408
537
|
const problem = saveProblemOf(error);
|
|
409
538
|
const fields = problem.kind === 'fields' ? problem.fields ?? {} : {};
|
|
410
539
|
serverErrors = Object.fromEntries(Object.entries(fields).map(([name, message]) => [name, { message, value: JSON.stringify(state.values[name] ?? null) }]));
|
|
411
540
|
set({ status: 'error', error: problem.message, saveProblem: problem, errors: { ...fields } });
|
|
412
|
-
return
|
|
541
|
+
return { reason: 'save', message: problem.message };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** The form has its values: told to the app, its `open` steps run, then the wizard step it shows is told. */
|
|
545
|
+
async function opening() {
|
|
546
|
+
emit('open', { recordId: state.recordId, values: state.values });
|
|
547
|
+
await moment('on.open', page.on?.open, [], true);
|
|
548
|
+
showing = true;
|
|
549
|
+
tellStep([]);
|
|
550
|
+
}
|
|
551
|
+
/** Write fields — a step's or the app's — worked out once, and tell each. */
|
|
552
|
+
function write(written, by) {
|
|
553
|
+
const names = Object.keys(written);
|
|
554
|
+
names.forEach(fieldDef);
|
|
555
|
+
if (!names.length)
|
|
556
|
+
return;
|
|
557
|
+
writeValues({ ...state.values, ...structuredCopy(written) });
|
|
558
|
+
for (const name of names)
|
|
559
|
+
afterEdit(name);
|
|
560
|
+
for (const name of names)
|
|
561
|
+
changed(name, by);
|
|
562
|
+
}
|
|
563
|
+
function addLine(field, values, by) {
|
|
564
|
+
const def = lineField(field);
|
|
565
|
+
const key = `new-${++lineKeys}`;
|
|
566
|
+
const current = state.values[field] ?? [];
|
|
567
|
+
const line = { key, values: { ...initialValues(def.fields, today()), ...structuredCopy(values) } };
|
|
568
|
+
// A new line comes last, so it is numbered after the last.
|
|
569
|
+
if (def.sequenceField && line.values[def.sequenceField] == null) {
|
|
570
|
+
const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
|
|
571
|
+
line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
|
|
413
572
|
}
|
|
573
|
+
const lines = [...current, line];
|
|
574
|
+
writeValues({ ...state.values, [field]: lines });
|
|
575
|
+
afterEdit(field);
|
|
576
|
+
changed(field, by);
|
|
577
|
+
return key;
|
|
578
|
+
}
|
|
579
|
+
/** Check the form, or only these fields, showing what is found as sending does. */
|
|
580
|
+
function check(names) {
|
|
581
|
+
names?.forEach(fieldDef);
|
|
582
|
+
const all = collectErrors();
|
|
583
|
+
const found = names ? Object.fromEntries(Object.entries(all).filter(([key]) => names.some((name) => key === name || key.startsWith(`${name}.`)))) : all;
|
|
584
|
+
set({ errors: found });
|
|
585
|
+
const first = Object.values(found)[0];
|
|
586
|
+
return first === undefined ? null : { reason: 'check', message: first };
|
|
587
|
+
}
|
|
588
|
+
/** Go to a wizard's step for a run; undefined when the target is no step of it, but a tab. */
|
|
589
|
+
function goToStep(target, chain) {
|
|
590
|
+
if (!steps.includes(target))
|
|
591
|
+
return undefined;
|
|
592
|
+
if (!form.steps().includes(target))
|
|
593
|
+
return { reason: 'cannot', message: `The step "${target}" does not apply now` };
|
|
594
|
+
let went = false;
|
|
595
|
+
inRun(chain, () => (went = form.goTo(target)));
|
|
596
|
+
return went ? null : { reason: 'check', message: Object.values(state.errors)[0] };
|
|
597
|
+
}
|
|
598
|
+
/** Run steps from the top — a button's, a moment's, the app's — and tell how it ended. */
|
|
599
|
+
async function runTop(list, scope) {
|
|
600
|
+
const result = await runner.runSteps(list, scope);
|
|
601
|
+
emit('run', { id: scope.id, steps: list, result });
|
|
602
|
+
return result;
|
|
414
603
|
}
|
|
415
604
|
/** The model a link field's new record belongs to, or null when it cannot make one. */
|
|
416
605
|
function creatable(def) {
|
|
@@ -467,15 +656,35 @@ export function createForm(options) {
|
|
|
467
656
|
}
|
|
468
657
|
set({ status: 'loading', error: null });
|
|
469
658
|
try {
|
|
470
|
-
const loaded = await source.load({ model: page.data.model, id: state.recordId, fields
|
|
471
|
-
baseline = startFrom({ ...initialValues(
|
|
659
|
+
const loaded = await source.load({ model: page.data.model, id: state.recordId, fields });
|
|
660
|
+
baseline = syncParts(startFrom({ ...initialValues(fields, today()), ...loaded }), 'start');
|
|
472
661
|
set({ status: 'ready', values: structuredCopy(baseline), dirty: [], errors: {}, warnings: withValues(baseline, collectWarnings) });
|
|
473
662
|
offerDraft();
|
|
663
|
+
void track(opening());
|
|
474
664
|
}
|
|
475
665
|
catch (error) {
|
|
476
666
|
set({ status: 'error', error: error.message });
|
|
477
667
|
}
|
|
478
668
|
}
|
|
669
|
+
const runner = createRunner({
|
|
670
|
+
fields,
|
|
671
|
+
values: () => state.values,
|
|
672
|
+
recordId: () => state.recordId,
|
|
673
|
+
reading,
|
|
674
|
+
today,
|
|
675
|
+
write: (values) => write(values, 'step'),
|
|
676
|
+
addLine: (field, values) => void addLine(field, values, 'step'),
|
|
677
|
+
check,
|
|
678
|
+
save: saving,
|
|
679
|
+
reset: () => form.reset(),
|
|
680
|
+
goTo: goToStep,
|
|
681
|
+
showing: inRun,
|
|
682
|
+
host: options.host,
|
|
683
|
+
confirm: options.confirm,
|
|
684
|
+
onAction: options.onAction,
|
|
685
|
+
answered: (request, result) => emit('action', { request, result }),
|
|
686
|
+
});
|
|
687
|
+
const moment = createMoments((key, list, chain) => runTop(list, { id: key, chain }));
|
|
479
688
|
const form = {
|
|
480
689
|
page,
|
|
481
690
|
getState: () => state,
|
|
@@ -484,11 +693,13 @@ export function createForm(options) {
|
|
|
484
693
|
return () => listeners.delete(listener);
|
|
485
694
|
},
|
|
486
695
|
load: () => track(load()),
|
|
487
|
-
setValue(name, value) {
|
|
696
|
+
setValue(name, value, how) {
|
|
488
697
|
fieldDef(name);
|
|
489
698
|
writeValues({ ...state.values, [name]: structuredCopy(value) });
|
|
490
699
|
afterEdit(name);
|
|
700
|
+
changed(name, how?.by ?? 'person');
|
|
491
701
|
},
|
|
702
|
+
setValues: (values) => write(values, 'app'),
|
|
492
703
|
node: nodeState,
|
|
493
704
|
fieldReadonly: (name) => lockedField(fieldDef(name)),
|
|
494
705
|
loadChoices,
|
|
@@ -513,24 +724,11 @@ export function createForm(options) {
|
|
|
513
724
|
forgetDraft();
|
|
514
725
|
serverErrors = {};
|
|
515
726
|
holding = setWhen.holding(baseline);
|
|
516
|
-
|
|
727
|
+
const values = syncParts(structuredCopy(baseline), 'start');
|
|
728
|
+
set({ values, dirty: [], errors: {}, warnings: withValues(values, collectWarnings), warning: null, warningField: null, draft: null, step: steps[0] ?? null, skipped: [], saveProblem: null });
|
|
517
729
|
},
|
|
518
730
|
changes,
|
|
519
|
-
addLine(field, values = {})
|
|
520
|
-
const def = lineField(field);
|
|
521
|
-
const key = `new-${++lineKeys}`;
|
|
522
|
-
const current = state.values[field] ?? [];
|
|
523
|
-
const line = { key, values: { ...initialValues(def.fields, today()), ...structuredCopy(values) } };
|
|
524
|
-
// A new line comes last, so it is numbered after the last.
|
|
525
|
-
if (def.sequenceField && line.values[def.sequenceField] == null) {
|
|
526
|
-
const numbers = current.map((l) => l.values[def.sequenceField]).filter((n) => typeof n === 'number');
|
|
527
|
-
line.values[def.sequenceField] = numbers.length ? Math.max(...numbers) + 1 : 1;
|
|
528
|
-
}
|
|
529
|
-
const lines = [...current, line];
|
|
530
|
-
writeValues({ ...state.values, [field]: lines });
|
|
531
|
-
afterEdit(field);
|
|
532
|
-
return key;
|
|
533
|
-
},
|
|
731
|
+
addLine: (field, values = {}) => addLine(field, values, 'person'),
|
|
534
732
|
updateLine(field, key, name, value) {
|
|
535
733
|
const def = lineField(field);
|
|
536
734
|
if (!def.fields[name])
|
|
@@ -538,6 +736,7 @@ export function createForm(options) {
|
|
|
538
736
|
const lines = (state.values[field] ?? []).map((line) => line.key === key ? { ...line, values: { ...line.values, [name]: structuredCopy(value) } } : line);
|
|
539
737
|
writeValues({ ...state.values, [field]: lines });
|
|
540
738
|
afterEdit(field);
|
|
739
|
+
changed(field, 'person');
|
|
541
740
|
},
|
|
542
741
|
moveLine(field, key, to) {
|
|
543
742
|
const def = lineField(field);
|
|
@@ -567,12 +766,14 @@ export function createForm(options) {
|
|
|
567
766
|
});
|
|
568
767
|
writeValues({ ...state.values, [field]: renumbered });
|
|
569
768
|
afterEdit(field);
|
|
769
|
+
changed(field, 'person');
|
|
570
770
|
},
|
|
571
771
|
removeLine(field, key) {
|
|
572
772
|
lineField(field);
|
|
573
773
|
const lines = (state.values[field] ?? []).filter((line) => line.key !== key);
|
|
574
774
|
writeValues({ ...state.values, [field]: lines });
|
|
575
775
|
afterEdit(field);
|
|
776
|
+
changed(field, 'person');
|
|
576
777
|
},
|
|
577
778
|
async search(field, query, limit = 8, options = {}) {
|
|
578
779
|
return runSearch(fieldDef(field), field, context(), query, limit, options.model);
|
|
@@ -678,16 +879,36 @@ export function createForm(options) {
|
|
|
678
879
|
if (!node || (node.kind !== 'button' && node.kind !== 'stat'))
|
|
679
880
|
throw new Error(`The page has no button "${id}"`);
|
|
680
881
|
if (nodeState(id).invisible)
|
|
681
|
-
return;
|
|
882
|
+
return { done: false, reason: 'cannot', message: `The button "${id}" is hidden` };
|
|
682
883
|
const source = node.source;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
...('params' in source && source.params ? { params: source.params } : {})
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
884
|
+
// Its steps, then its app action as a final call.
|
|
885
|
+
const list = [...(source.steps ?? [])];
|
|
886
|
+
if (source.action !== undefined)
|
|
887
|
+
list.push({ do: 'call', action: source.action, ...('params' in source && source.params ? { params: source.params } : {}) });
|
|
888
|
+
const scope = { id, chain: [], ...(chosen ? { recordIds: [...chosen.recordIds] } : {}) };
|
|
889
|
+
const confirm = 'confirm' in source ? source.confirm : undefined;
|
|
890
|
+
return track((async () => {
|
|
891
|
+
if (confirm && !(await runner.ask(confirm))) {
|
|
892
|
+
const result = { done: false, reason: 'no', message: confirm };
|
|
893
|
+
emit('run', { id, steps: list, result });
|
|
894
|
+
return result;
|
|
895
|
+
}
|
|
896
|
+
return runTop(list, scope);
|
|
897
|
+
})());
|
|
898
|
+
},
|
|
899
|
+
run: (list, context = {}) => track(runTop(list, { id: context.id ?? 'run', chain: [], ...(context.recordIds ? { recordIds: [...context.recordIds] } : {}) })),
|
|
900
|
+
shown(tab) {
|
|
901
|
+
// A wizard's steps are told by the form itself, as they are entered.
|
|
902
|
+
if (steps.includes(tab))
|
|
903
|
+
return;
|
|
904
|
+
void track(moment(`on.show.${tab}`, page.on?.show?.[tab], entering, true));
|
|
905
|
+
},
|
|
906
|
+
on(event, listener) {
|
|
907
|
+
let heard = events.get(event);
|
|
908
|
+
if (!heard)
|
|
909
|
+
events.set(event, (heard = new Set()));
|
|
910
|
+
heard.add(listener);
|
|
911
|
+
return () => void heard.delete(listener);
|
|
691
912
|
},
|
|
692
913
|
restoreDraft() {
|
|
693
914
|
const raw = options.drafts?.store.getItem(draftKey());
|
|
@@ -704,19 +925,92 @@ export function createForm(options) {
|
|
|
704
925
|
while (pending.size)
|
|
705
926
|
await Promise.allSettled([...pending]);
|
|
706
927
|
},
|
|
928
|
+
embed(id, inner) {
|
|
929
|
+
const known = attached.get(id);
|
|
930
|
+
if (known)
|
|
931
|
+
return known.inner.form;
|
|
932
|
+
const part = parts.get(id);
|
|
933
|
+
if (!part)
|
|
934
|
+
throw new Error(`"${id}" is not a saved form placed on this page`);
|
|
935
|
+
const chain = [...within, page.id];
|
|
936
|
+
if (chain.includes(inner.id))
|
|
937
|
+
throw new Error(`a page cannot be placed inside itself: ${[...chain.slice(chain.indexOf(inner.id)), inner.id].join(' → ')}`);
|
|
938
|
+
const given = state.values[part.name];
|
|
939
|
+
// A record of no model of its own: its links search and its lists load, but it never loads, saves, sends or recalculates alone.
|
|
940
|
+
const made = innerForm({
|
|
941
|
+
page: { ...inner, data: { kind: 'responses' } },
|
|
942
|
+
dataSource: options.dataSource,
|
|
943
|
+
values: isRecord(given) ? given : {},
|
|
944
|
+
onAction: options.onAction,
|
|
945
|
+
host: options.host,
|
|
946
|
+
confirm: options.confirm,
|
|
947
|
+
scheduler,
|
|
948
|
+
messages,
|
|
949
|
+
now: options.now,
|
|
950
|
+
}, chain);
|
|
951
|
+
attached.set(id, { name: part.name, inner: made });
|
|
952
|
+
// A change inside it is told as a change of its answers here, whoever made it.
|
|
953
|
+
made.form.on('change', ({ by }) => {
|
|
954
|
+
if (!syncing)
|
|
955
|
+
changed(part.name, by);
|
|
956
|
+
});
|
|
957
|
+
// A change made inside it is a change of this form's: shown, kept in a draft, saved.
|
|
958
|
+
made.form.subscribe((inside) => {
|
|
959
|
+
if (syncing || inside.values === state.values[part.name])
|
|
960
|
+
return;
|
|
961
|
+
writeValues({ ...state.values, [part.name]: asValue(inside.values) });
|
|
962
|
+
afterEdit(part.name);
|
|
963
|
+
});
|
|
964
|
+
// What it starts with, its defaults and worked-out values filled in, is this form's starting point too — unless changed already.
|
|
965
|
+
const start = asValue(made.form.getState().values);
|
|
966
|
+
if (!state.dirty.includes(part.name))
|
|
967
|
+
baseline = { ...baseline, [part.name]: start };
|
|
968
|
+
const values = { ...state.values, [part.name]: start };
|
|
969
|
+
set({ values, dirty: dirtyFields(values) });
|
|
970
|
+
return made.form;
|
|
971
|
+
},
|
|
972
|
+
embedded: (id) => attached.get(id)?.inner.form ?? null,
|
|
707
973
|
dispose() {
|
|
708
974
|
if (draftTimer !== null)
|
|
709
975
|
scheduler.clearTimeout(draftTimer);
|
|
710
976
|
if (autosaveTimer !== null)
|
|
711
977
|
scheduler.clearTimeout(autosaveTimer);
|
|
712
978
|
listeners.clear();
|
|
979
|
+
events.clear();
|
|
980
|
+
for (const part of attached.values())
|
|
981
|
+
part.inner.form.dispose();
|
|
713
982
|
},
|
|
714
983
|
};
|
|
715
984
|
// A record's starting values may already break a warning rule; nothing listens yet.
|
|
716
985
|
state = { ...state, warnings: collectWarnings() };
|
|
717
986
|
if (state.recordId == null)
|
|
718
987
|
offerDraft();
|
|
719
|
-
|
|
988
|
+
// A new form has its values now: it opens once whoever made it has had the chance to listen.
|
|
989
|
+
if (state.recordId == null)
|
|
990
|
+
void track(Promise.resolve().then(opening));
|
|
991
|
+
return {
|
|
992
|
+
form,
|
|
993
|
+
take(values, how) {
|
|
994
|
+
if (how !== 'start')
|
|
995
|
+
return writeValues({ ...initialValues(page.fields, today()), ...values }, {}, how === 'fresh');
|
|
996
|
+
baseline = startFrom({ ...initialValues(page.fields, today()), ...structuredCopy(values) });
|
|
997
|
+
const start = syncParts(structuredCopy(baseline), 'start');
|
|
998
|
+
set({ values: start, dirty: [], errors: {}, warnings: withValues(start, collectWarnings), warning: null, warningField: null });
|
|
999
|
+
},
|
|
1000
|
+
errorsNow: () => collectErrors(),
|
|
1001
|
+
show(errors) {
|
|
1002
|
+
if (JSON.stringify(errors) !== JSON.stringify(state.errors))
|
|
1003
|
+
set({ errors });
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/** The problems found inside a saved form, by its own fields' names. */
|
|
1008
|
+
function inside(errors, name) {
|
|
1009
|
+
const found = {};
|
|
1010
|
+
for (const [key, message] of Object.entries(errors))
|
|
1011
|
+
if (key.startsWith(`${name}.`))
|
|
1012
|
+
found[key.slice(name.length + 1)] = message;
|
|
1013
|
+
return found;
|
|
720
1014
|
}
|
|
721
1015
|
/** A field no one types in: read-only by its definition, or worked out from others. */
|
|
722
1016
|
function lockedField(def) {
|
|
@@ -786,6 +1080,9 @@ function indexLayout(root) {
|
|
|
786
1080
|
add(node, parent, 'other', { invisible: node.invisible, readonly: node.readonly });
|
|
787
1081
|
walk(node.children, node.id);
|
|
788
1082
|
}
|
|
1083
|
+
else if (node.type === 'form') {
|
|
1084
|
+
add(node, parent, 'form', { invisible: node.invisible, readonly: node.readonly });
|
|
1085
|
+
}
|
|
789
1086
|
else {
|
|
790
1087
|
add(node, parent, 'other', { invisible: node.invisible });
|
|
791
1088
|
}
|