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