vultus-core 0.5.3 → 0.5.4
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 +117 -1
- package/dist/FieldContext.d.ts +37 -1
- package/dist/FieldContext.d.ts.map +1 -1
- package/dist/FieldContext.js +68 -11
- package/dist/FieldContext.js.map +1 -1
- package/dist/fieldGroupValues.d.ts +63 -0
- package/dist/fieldGroupValues.d.ts.map +1 -0
- package/dist/fieldGroupValues.js +119 -0
- package/dist/fieldGroupValues.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/kindHooks.d.ts +23 -0
- package/dist/kindHooks.d.ts.map +1 -0
- package/dist/kindHooks.js +38 -0
- package/dist/kindHooks.js.map +1 -0
- package/dist/useArg.d.ts +6 -0
- package/dist/useArg.d.ts.map +1 -1
- package/dist/useArg.js +6 -2
- package/dist/useArg.js.map +1 -1
- package/dist/useFieldGroup.d.ts +80 -0
- package/dist/useFieldGroup.d.ts.map +1 -0
- package/dist/useFieldGroup.js +266 -0
- package/dist/useFieldGroup.js.map +1 -0
- package/dist/useStoredField.d.ts +8 -0
- package/dist/useStoredField.d.ts.map +1 -1
- package/dist/useStoredField.js +4 -2
- package/dist/useStoredField.js.map +1 -1
- package/package.json +1 -1
- package/src/FieldContext.tsx +73 -11
- package/src/fieldGroupValues.ts +169 -0
- package/src/index.ts +1 -0
- package/src/kindHooks.ts +52 -0
- package/src/useArg.ts +6 -2
- package/src/useFieldGroup.ts +346 -0
- package/src/useStoredField.ts +4 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { extendFieldContext, guardKeys, MissingKeyError, type FieldCtx } from './FieldContext.js';
|
|
2
|
+
import { initialValueOf, sanitizeStored, type FieldDefinition } from './kinds.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One render's values of a field group (see useFieldGroup): each field's shown value and
|
|
6
|
+
* default, computed on demand by id from the kept values, with the definitions' options
|
|
7
|
+
* evaluated in the group's context, and each computed once.
|
|
8
|
+
*/
|
|
9
|
+
export interface GroupValues {
|
|
10
|
+
/**
|
|
11
|
+
* The field context the group's option functions receive: the app's own keys (guarded as a
|
|
12
|
+
* provider's value is) and `values`, the fields' shown values by id.
|
|
13
|
+
*/
|
|
14
|
+
ctx: object;
|
|
15
|
+
/**
|
|
16
|
+
* A field's shown value: its kept value, else its current default, made fit for its options
|
|
17
|
+
* as they are now (sanitizeStored); one that no longer fits shows the field's fallback. The
|
|
18
|
+
* kept value itself is never touched, so it shows again once it fits again.
|
|
19
|
+
*/
|
|
20
|
+
valueOf(id: string): unknown;
|
|
21
|
+
/**
|
|
22
|
+
* A field's default: its `initialValue` (which cannot read `values`), else its kind's
|
|
23
|
+
* implicit initial value from its options as they are now (the first enabled choice, `min`,
|
|
24
|
+
* ...), so it moves with the fields those options read.
|
|
25
|
+
*/
|
|
26
|
+
defaultOf(id: string): unknown;
|
|
27
|
+
/** The shown values by id, each computed when first read; `ctx.values` is the same object. */
|
|
28
|
+
values: Readonly<Record<string, unknown>>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What createGroupValues computes the values from. */
|
|
32
|
+
export interface GroupValuesParams {
|
|
33
|
+
/** The group's fields, as data: unique ids, no mixed-boolean. */
|
|
34
|
+
definitions: readonly FieldDefinition[];
|
|
35
|
+
/** The app's field context (useFieldContext), which `values` is added to. */
|
|
36
|
+
appCtx: FieldCtx;
|
|
37
|
+
/** A field's kept value; `undefined` when none is kept, so the field shows its current default. */
|
|
38
|
+
kept(id: string): unknown | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Where a kept value's sanitizing warnings go, with the field's id: the caller decides whether
|
|
41
|
+
* to show them (and how often). A default made fit is the definer's, not kept input, so it
|
|
42
|
+
* never warns.
|
|
43
|
+
*/
|
|
44
|
+
warn(id: string, message: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const WHERE = 'Field group';
|
|
48
|
+
const INITIAL_VALUE_READS_VALUES = "initialValue cannot read other fields' values";
|
|
49
|
+
const NOT_IN_A_GROUP = 'a mixed-boolean field shows an aggregate of other data, not a value of its own, so it cannot be in a field group';
|
|
50
|
+
|
|
51
|
+
function silently(_message: string): void {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A definition as its values are computed from it. Its `initialValue` function receives
|
|
55
|
+
* `initialCtx`, whose `values` throws: a default cannot depend on other fields' values. A
|
|
56
|
+
* number's or range's display options (`step`, `unit`, `format`) are left out, because resolving
|
|
57
|
+
* its bounds (resolveNumeric) resolves them too, and an option that never affects the value
|
|
58
|
+
* (`unit: (ctx) => ctx.values.copies === 1 ? ...`) must not count as a dependency, nor form a
|
|
59
|
+
* cycle.
|
|
60
|
+
*/
|
|
61
|
+
function forComputingValues(definition: FieldDefinition, initialCtx: object): FieldDefinition {
|
|
62
|
+
const computing = { ...definition };
|
|
63
|
+
const { initialValue } = definition;
|
|
64
|
+
if (typeof initialValue === 'function') {
|
|
65
|
+
Object.assign(computing, { initialValue: () => (initialValue as (ctx: object) => unknown)(initialCtx) });
|
|
66
|
+
}
|
|
67
|
+
if (computing.kind === 'numeric' || computing.kind === 'range') {
|
|
68
|
+
delete computing.step;
|
|
69
|
+
delete computing.unit;
|
|
70
|
+
delete computing.format;
|
|
71
|
+
}
|
|
72
|
+
return computing;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The values of a field group for one render, computed from its definitions and kept values.
|
|
77
|
+
*
|
|
78
|
+
* Every option function of the definitions receives `ctx`: the app's field context and
|
|
79
|
+
* `values`, the fields' shown values by id. A field's value is computed when an option (or the
|
|
80
|
+
* caller) first reads it, and kept for the rest of this snapshot; a new render makes a new one.
|
|
81
|
+
* The shown value is the kept value (else the current default) checked against the field's
|
|
82
|
+
* options as they are now, as a store's value is (sanitizeStored); one that no longer fits
|
|
83
|
+
* shows the fallback, which the other fields see too.
|
|
84
|
+
*
|
|
85
|
+
* Throws here for two definitions with the same id and for a mixed-boolean definition (naming
|
|
86
|
+
* the field), and for an app context that already has `values`. Throws when read, naming the
|
|
87
|
+
* field and the option, for an option reading an id not in the group and for `initialValue`
|
|
88
|
+
* reading `values`. Fields whose values depend on each other throw when read, naming the cycle
|
|
89
|
+
* (`Fields 'a' -> 'b' -> 'a' depend on each other's values`): a field's value and default are in
|
|
90
|
+
* progress while its options run, and reading one in progress is a cycle. Options that never
|
|
91
|
+
* affect a value (`visible`, `label`, `unit`, ...) are not run here, so they cannot form one.
|
|
92
|
+
*/
|
|
93
|
+
export function createGroupValues({ definitions, appCtx, kept, warn }: GroupValuesParams): GroupValues {
|
|
94
|
+
const ids = definitions.map((definition) => definition.id);
|
|
95
|
+
for (const definition of definitions) {
|
|
96
|
+
if (ids.indexOf(definition.id) !== ids.lastIndexOf(definition.id)) {
|
|
97
|
+
throw new Error(`Field '${definition.id}': another field in the group has the same id`);
|
|
98
|
+
}
|
|
99
|
+
if (definition.kind === 'mixed-boolean') throw new Error(`Field '${definition.id}': ${NOT_IN_A_GROUP}`);
|
|
100
|
+
}
|
|
101
|
+
const noField = (id: string) => `no field '${id}' in the group (fields: ${ids.join(', ')})`;
|
|
102
|
+
|
|
103
|
+
const getters: Record<string, unknown> = {};
|
|
104
|
+
for (const id of ids) Object.defineProperty(getters, id, { get: () => valueOf(id), enumerable: true });
|
|
105
|
+
// Own keys only: `values.toString` is a typo for an id, not Object's member. `constructor`
|
|
106
|
+
// reads as usual, as the probes do, because printers (vitest's) name an object by it.
|
|
107
|
+
const values = guardKeys(getters, noField, (target, key) => key === 'constructor' || Object.hasOwn(target, key)) as
|
|
108
|
+
Readonly<Record<string, unknown>>;
|
|
109
|
+
const ctx = extendFieldContext(appCtx, { values }, WHERE);
|
|
110
|
+
// `values` not enumerable: a missing key's message lists the keys provided, and this one only throws.
|
|
111
|
+
const initialCtx = extendFieldContext(appCtx, Object.defineProperty<Record<string, unknown>>({}, 'values', {
|
|
112
|
+
get(): never {
|
|
113
|
+
throw new MissingKeyError(INITIAL_VALUE_READS_VALUES);
|
|
114
|
+
},
|
|
115
|
+
}), WHERE);
|
|
116
|
+
const computingDefinitions = new Map(definitions.map((definition) => [definition.id, forComputingValues(definition, initialCtx)]));
|
|
117
|
+
|
|
118
|
+
const shown = new Map<string, unknown>();
|
|
119
|
+
const defaults = new Map<string, unknown>();
|
|
120
|
+
/** The fields whose value or default is being computed, outermost first. */
|
|
121
|
+
const inProgress: string[] = [];
|
|
122
|
+
|
|
123
|
+
function definitionOf(id: string): FieldDefinition {
|
|
124
|
+
const definition = computingDefinitions.get(id);
|
|
125
|
+
if (definition === undefined) throw new MissingKeyError(noField(id));
|
|
126
|
+
return definition;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Runs `compute` with `id` in progress; reading `id` again before it returns is a cycle. */
|
|
130
|
+
function computing<T>(id: string, compute: () => T): T {
|
|
131
|
+
const at = inProgress.indexOf(id);
|
|
132
|
+
if (at !== -1) {
|
|
133
|
+
const cycle = [...inProgress.slice(at), id].map((each) => `'${each}'`).join(' -> ');
|
|
134
|
+
throw new Error(`Fields ${cycle} depend on each other's values`);
|
|
135
|
+
}
|
|
136
|
+
inProgress.push(id);
|
|
137
|
+
try {
|
|
138
|
+
return compute();
|
|
139
|
+
} finally {
|
|
140
|
+
inProgress.pop();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The default, computed as part of `id`'s own value or default (already in progress). */
|
|
145
|
+
function ownDefault(id: string): unknown {
|
|
146
|
+
if (!defaults.has(id)) defaults.set(id, initialValueOf(definitionOf(id), ctx));
|
|
147
|
+
return defaults.get(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function defaultOf(id: string): unknown {
|
|
151
|
+
definitionOf(id);
|
|
152
|
+
return defaults.has(id) ? defaults.get(id) : computing(id, () => ownDefault(id));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function valueOf(id: string): unknown {
|
|
156
|
+
const definition = definitionOf(id);
|
|
157
|
+
if (!shown.has(id)) {
|
|
158
|
+
shown.set(id, computing(id, () => {
|
|
159
|
+
const value = kept(id);
|
|
160
|
+
return value === undefined
|
|
161
|
+
? sanitizeStored(definition, ownDefault(id), ctx, silently)
|
|
162
|
+
: sanitizeStored(definition, value, ctx, (message) => warn(id, message));
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
return shown.get(id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { ctx, valueOf, defaultOf, values };
|
|
169
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export {
|
|
|
72
72
|
export {
|
|
73
73
|
argOf, useStoredField, useStoredFields, type StoredField, type StoredFields,
|
|
74
74
|
} from './useStoredField.js';
|
|
75
|
+
export { useFieldGroup, type FieldGroup } from './useFieldGroup.js';
|
|
75
76
|
export { InternalLinkContext, useInternalLink, type InternalLinkComponent } from './LinkContext.js';
|
|
76
77
|
export { MessageSinkContext, useMessageSink, type MessageSink } from './MessageSink.js';
|
|
77
78
|
export {
|
package/src/kindHooks.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FieldControls, FieldKind } from './field.js';
|
|
2
|
+
import type { FieldDefinition } from './kinds.js';
|
|
3
|
+
import { useBoolField } from './useBoolField.js';
|
|
4
|
+
import { useManyOfField } from './useManyOfField.js';
|
|
5
|
+
import { useManyOfTreeField } from './useManyOfTreeField.js';
|
|
6
|
+
import { useMixedBoolField } from './useMixedBoolField.js';
|
|
7
|
+
import { useNumericField } from './useNumericField.js';
|
|
8
|
+
import { useOneOfField } from './useOneOfField.js';
|
|
9
|
+
import { useOneOfTreeField } from './useOneOfTreeField.js';
|
|
10
|
+
import { useRangeField } from './useRangeField.js';
|
|
11
|
+
import { useTextField } from './useTextField.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A definition in its outside form, as its kind's hook takes it: without `initialValue` (whoever
|
|
15
|
+
* keeps the value keeps its default too), with the value, its writer, and the link a value
|
|
16
|
+
* amounts to where whoever keeps it has one.
|
|
17
|
+
*/
|
|
18
|
+
export type KindFieldOptions = OutsideForm<FieldDefinition>;
|
|
19
|
+
|
|
20
|
+
/** The outside form of a definition, member by member for a union (the conditional type distributes). */
|
|
21
|
+
type OutsideForm<D> = D extends FieldDefinition
|
|
22
|
+
? Omit<D, 'initialValue'> & { value: unknown; onChange(value: any): void; hrefFor?(value: any): string | undefined }
|
|
23
|
+
: never;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Each kind's hook. Its options are checked against the definition in kinds.ts
|
|
27
|
+
* (DefinitionsFitTheirHooks); the mapped type makes leaving a kind out a compile error.
|
|
28
|
+
*/
|
|
29
|
+
const KIND_HOOKS: { [K in FieldKind]: (options: any) => FieldControls<any, any> } = {
|
|
30
|
+
boolean: useBoolField,
|
|
31
|
+
'mixed-boolean': useMixedBoolField,
|
|
32
|
+
oneOf: useOneOfField,
|
|
33
|
+
manyOf: useManyOfField,
|
|
34
|
+
numeric: useNumericField,
|
|
35
|
+
range: useRangeField,
|
|
36
|
+
manyOfTree: useManyOfTreeField,
|
|
37
|
+
oneOfTree: useOneOfTreeField,
|
|
38
|
+
text: useTextField,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs the hook of `definition`'s kind with `options` and returns its controls, exactly as that
|
|
43
|
+
* hook returns them. The hook called depends on the kind, so a caller running several fields
|
|
44
|
+
* (useFieldGroup) keeps each field's kind the same across renders. A kind that is none of
|
|
45
|
+
* FieldKind's (plain data can carry anything) throws, naming the field.
|
|
46
|
+
*/
|
|
47
|
+
export function useKindField(definition: FieldDefinition, options: KindFieldOptions): FieldControls<any, any> {
|
|
48
|
+
const { id, kind } = definition;
|
|
49
|
+
if (!Object.hasOwn(KIND_HOOKS, kind)) throw new Error(`Field '${id}': unknown kind '${String(kind)}'`);
|
|
50
|
+
const useHook = KIND_HOOKS[kind];
|
|
51
|
+
return useHook(options);
|
|
52
|
+
}
|
package/src/useArg.ts
CHANGED
|
@@ -22,8 +22,12 @@ export interface ArgControls<T> {
|
|
|
22
22
|
hrefFor(value: T): string | undefined;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The raw value a write needs for `value`: `undefined` (removes the key) when it equals the
|
|
27
|
+
* default, else its formatted form. Exported for the field group (useFieldGroup), which writes a
|
|
28
|
+
* store by the same rule; not part of the package's API.
|
|
29
|
+
*/
|
|
30
|
+
export function rawFor<T>(value: T, defaultValue: T, valueType: ValueType<T>): string | undefined {
|
|
27
31
|
return valueType.isEqual(value, defaultValue) ? undefined : valueType.format(value);
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import type { ArgStore, UpdateMethod } from './argStore.js';
|
|
3
|
+
import { useArgStore } from './ArgStoreContext.js';
|
|
4
|
+
import type { FieldControls } from './field.js';
|
|
5
|
+
import { useFieldContext, withFieldContext, type FieldCtx } from './FieldContext.js';
|
|
6
|
+
import { createGroupValues, type GroupValues } from './fieldGroupValues.js';
|
|
7
|
+
import { useKindField } from './kindHooks.js';
|
|
8
|
+
import { valueTypeOf, type FieldDefinition } from './kinds.js';
|
|
9
|
+
import { rawFor } from './useArg.js';
|
|
10
|
+
import { sameValue } from './useStoredField.js';
|
|
11
|
+
import type { ValueType } from './valueTypes.js';
|
|
12
|
+
|
|
13
|
+
/** A field group's fields as one component runs them (see useFieldGroup). */
|
|
14
|
+
export interface FieldGroup {
|
|
15
|
+
/**
|
|
16
|
+
* Each field's controls, exactly as its kind's hook returns them, in definition order: what the
|
|
17
|
+
* widgets render. Typed `FieldControls`; for a kind's own controls, cast by kind
|
|
18
|
+
* (`group.byId.toc as OneOfTreeFieldControls<string>`).
|
|
19
|
+
*/
|
|
20
|
+
fields: FieldControls<any, any>[];
|
|
21
|
+
/** The same controls, by field id. */
|
|
22
|
+
byId: Record<string, FieldControls<any, any>>;
|
|
23
|
+
/**
|
|
24
|
+
* The fields' shown values by id (after the fallback rule), as of this render: a plain object,
|
|
25
|
+
* which keeps its identity until a shown value changes (an effect keyed on it runs only then).
|
|
26
|
+
*/
|
|
27
|
+
values: Readonly<Record<string, unknown>>;
|
|
28
|
+
/** Whether the field with this id shows its current default; an id not in the group throws. */
|
|
29
|
+
isDefault(id: string): boolean;
|
|
30
|
+
/** How many of the fields do not show their defaults ("more filters (3)"). */
|
|
31
|
+
changedCount: number;
|
|
32
|
+
/**
|
|
33
|
+
* Every field back to its default: no field keeps a value any more. Stored, one store write
|
|
34
|
+
* removes every field's key (`method`, 'replace' unless told otherwise); the group's own state
|
|
35
|
+
* ignores `method`.
|
|
36
|
+
*/
|
|
37
|
+
resetAll(method?: UpdateMethod): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The values the group keeps, by field id; a field without one shows its current default. */
|
|
41
|
+
type Kept = Readonly<Record<string, unknown>>;
|
|
42
|
+
|
|
43
|
+
const NOTHING_KEPT: Kept = {};
|
|
44
|
+
const WHERE = 'Field group';
|
|
45
|
+
|
|
46
|
+
function silently(_id: string, _message: string): void {}
|
|
47
|
+
|
|
48
|
+
/** A field's kept value in `kept`, or `undefined` for none (an id such as `toString` is not Object's member). */
|
|
49
|
+
function keptIn(kept: Kept): (id: string) => unknown {
|
|
50
|
+
return (id) => (Object.hasOwn(kept, id) ? kept[id] : undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The ids and kinds of a list of definitions, in order: what has to stay the same across renders. */
|
|
54
|
+
function shapeOf(definitions: readonly FieldDefinition[]): string {
|
|
55
|
+
return definitions.map(({ id, kind }) => `${id} (${String(kind)})`).join(', ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Where a group keeps its values, in the words of the error for a change of it. */
|
|
59
|
+
function storageOf(stored: boolean): string {
|
|
60
|
+
return stored ? 'the nearest ArgStore ({ stored: true })' : "the group's own state";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a field shows its default, by useStoredField's rule: nothing is kept, or the kept
|
|
65
|
+
* value equals the current default, or what it shows does (a kept value that falls back to it).
|
|
66
|
+
*/
|
|
67
|
+
function showsDefault(definition: FieldDefinition, kept: unknown, group: GroupValues): boolean {
|
|
68
|
+
if (kept === undefined) return true;
|
|
69
|
+
const { isEqual } = valueTypeOf(definition.kind);
|
|
70
|
+
const fallback = group.defaultOf(definition.id);
|
|
71
|
+
return isEqual(kept, fallback) || isEqual(group.valueOf(definition.id), fallback);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A field's default under the given kept values (the state a write applies to, not necessarily
|
|
76
|
+
* this render's), computed afresh and silently. A write compares with it, so a write batched
|
|
77
|
+
* after another field's (in one event) compares with the default that one gives.
|
|
78
|
+
*/
|
|
79
|
+
function defaultUnder(
|
|
80
|
+
definitions: readonly FieldDefinition[], appCtx: FieldCtx, kept: (id: string) => unknown, id: string,
|
|
81
|
+
): unknown {
|
|
82
|
+
return createGroupValues({ definitions, appCtx, kept, warn: silently }).defaultOf(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A field's writer and link, as its kind's hook takes them (the outside form's `onChange` and `hrefFor`). */
|
|
86
|
+
interface Outside {
|
|
87
|
+
onChange(value: unknown): void;
|
|
88
|
+
hrefFor?(value: unknown): string | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Where a group keeps its values (its own state, or the nearest ArgStore), as one render sees them. */
|
|
92
|
+
interface Keeping {
|
|
93
|
+
/** A field's kept value; `undefined` for none, so the field shows its current default. */
|
|
94
|
+
kept(id: string): unknown;
|
|
95
|
+
/** Where a kept value's sanitizing warnings go, with the field's id (see createGroupValues). */
|
|
96
|
+
warn(id: string, message: string): void;
|
|
97
|
+
/** A field's writer and link, given this render's values. */
|
|
98
|
+
outside(definition: FieldDefinition, group: GroupValues): Outside;
|
|
99
|
+
/** Every field back to its default (see FieldGroup). */
|
|
100
|
+
resetAll(method?: UpdateMethod): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The group's own state: the kept values by id. A write keeps the value unless it equals the
|
|
105
|
+
* field's default under the state the write applies to, so a write batched after another
|
|
106
|
+
* field's (in one event) compares with the default that one gives. A fallback is silent: a value
|
|
107
|
+
* another field made invalid is how a group behaves, not bad input. There are no links.
|
|
108
|
+
*/
|
|
109
|
+
function useOwnState(definitions: readonly FieldDefinition[], appCtx: FieldCtx): Keeping {
|
|
110
|
+
const [kept, setKept] = useState<Kept>(NOTHING_KEPT);
|
|
111
|
+
const resetAll = useCallback(() => setKept((state) => (Object.keys(state).length === 0 ? state : NOTHING_KEPT)), []);
|
|
112
|
+
|
|
113
|
+
const write = ({ id, kind }: FieldDefinition) => (value: unknown) => setKept((state) => {
|
|
114
|
+
if (!valueTypeOf(kind).isEqual(value, defaultUnder(definitions, appCtx, keptIn(state), id))) return { ...state, [id]: value };
|
|
115
|
+
if (!Object.hasOwn(state, id)) return state;
|
|
116
|
+
const { [id]: _dropped, ...rest } = state;
|
|
117
|
+
return rest;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return { kept: keptIn(kept), warn: silently, outside: (definition) => ({ onChange: write(definition) }), resetAll };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** How a field's value is kept in a store; an unknown kind throws, naming the field (as argOf does). */
|
|
124
|
+
function valueTypeFor({ id, kind }: FieldDefinition): ValueType<any> {
|
|
125
|
+
try {
|
|
126
|
+
return valueTypeOf(kind);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new Error(`Field '${id}': ${(error as Error).message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A stored raw value as the field's value: `undefined` for an absent key, or for a raw value its kind's value type cannot read. */
|
|
133
|
+
function parseRaw(definition: FieldDefinition, raw: string | undefined): unknown {
|
|
134
|
+
return raw === undefined ? undefined : valueTypeFor(definition).parse(raw);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A field's link, with what it was made from: kept while those stay the same, so the controls' `hrefFor` keeps its identity. */
|
|
138
|
+
interface Link {
|
|
139
|
+
store: ArgStore;
|
|
140
|
+
defaultValue: unknown;
|
|
141
|
+
hrefFor(value: unknown): string | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The nearest ArgStore, under each field's id, as useStoredField keeps a field there, read in one
|
|
146
|
+
* subscription (as useStoredFields): its snapshot is the fields' raw values joined into one
|
|
147
|
+
* string, which changes exactly when one of them does. A raw value is parsed by the kind's value
|
|
148
|
+
* type; one that does not parse is no kept value, so the field shows its current default.
|
|
149
|
+
*
|
|
150
|
+
* A write carries the value's raw form, the key absent when the value equals the field's default
|
|
151
|
+
* as the store has it at that moment: the values computed afresh, silently, from the store's
|
|
152
|
+
* current raw values, which include its own writes not rendered yet (every store reads them back
|
|
153
|
+
* at once). So, as in the group's own state, a write batched after another field's (in one
|
|
154
|
+
* event) compares with the default that one gives. A link (the store's `hrefFor`, where it has
|
|
155
|
+
* one) is the render's: its key is absent when the value equals the default as rendered, and it
|
|
156
|
+
* keeps its identity while the store and that default stay the same (a tree list rebuilds its
|
|
157
|
+
* rows for a new one, and asks again when a link is used).
|
|
158
|
+
*
|
|
159
|
+
* A raw value warns (it does not parse, or it falls back) once, as useStoredField's does: in the
|
|
160
|
+
* render that first reads it, not again on a re-render, nor when another field's change makes it
|
|
161
|
+
* fall back later; another raw value warns again. The warnings are for raw values from elsewhere
|
|
162
|
+
* (the URL, another tab): the one the group itself last wrote for a field, read by the next render,
|
|
163
|
+
* does not warn, as a write chained after another field's in one event may fall back at once
|
|
164
|
+
* (`layout.setValue('rendered'); paper.setValue('letter')`, Letter offered under the old layout).
|
|
165
|
+
*/
|
|
166
|
+
function useStoredValues(definitions: readonly FieldDefinition[], appCtx: FieldCtx): Keeping {
|
|
167
|
+
const store = useArgStore();
|
|
168
|
+
// The first render's ids: the shape check keeps them the same.
|
|
169
|
+
const [ids] = useState(() => definitions.map((definition) => definition.id));
|
|
170
|
+
// Called on the store, not passed detached: a store written as a class uses `this`.
|
|
171
|
+
const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]);
|
|
172
|
+
const readAll = useCallback(() => JSON.stringify(ids.map((id) => store.read(id) ?? null)), [store, ids]);
|
|
173
|
+
const snapshot = useSyncExternalStore(subscribe, readAll);
|
|
174
|
+
const rawList = JSON.parse(snapshot) as (string | null)[];
|
|
175
|
+
const raws = new Map(ids.map((id, i) => [id, rawList[i]]));
|
|
176
|
+
|
|
177
|
+
const seenRef = useRef(new Map<string, string | null>());
|
|
178
|
+
/** The raw value the group itself last wrote for a field, until the next render reads the store. */
|
|
179
|
+
const writtenRef = useRef(new Map<string, string | undefined>());
|
|
180
|
+
const seen = seenRef.current;
|
|
181
|
+
const written = writtenRef.current;
|
|
182
|
+
/** The fields whose raw value this render reads first, from elsewhere than the group's own write: each may warn, once. */
|
|
183
|
+
const fresh = new Set<string>();
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const raw = raws.get(id) ?? null;
|
|
186
|
+
if (raw !== seen.get(id) && raw !== null && raw !== written.get(id)) fresh.add(id);
|
|
187
|
+
}
|
|
188
|
+
// Every store reads its own writes back at once, so the render after a write reads it: the memory
|
|
189
|
+
// lasts until then, and a write undone before any render is not remembered later.
|
|
190
|
+
written.clear();
|
|
191
|
+
seenRef.current = raws;
|
|
192
|
+
const warn = (id: string, message: string) => {
|
|
193
|
+
if (fresh.delete(id)) console.warn(message);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const byId = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
197
|
+
const parsed = new Map<string, unknown>();
|
|
198
|
+
const kept = (id: string) => {
|
|
199
|
+
if (!parsed.has(id)) {
|
|
200
|
+
const raw = raws.get(id) ?? undefined;
|
|
201
|
+
const value = parseRaw(byId.get(id)!, raw);
|
|
202
|
+
if (raw !== undefined && value === undefined) warn(id, `Argument '${id}': cannot read '${raw}', using the default`);
|
|
203
|
+
parsed.set(id, value);
|
|
204
|
+
}
|
|
205
|
+
return parsed.get(id);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** A field's default as the store has it now: under its current raw values, its own writes not rendered yet included. */
|
|
209
|
+
const defaultNow = (id: string) => defaultUnder(definitions, appCtx, (key) => parseRaw(byId.get(key)!, store.read(key)), id);
|
|
210
|
+
|
|
211
|
+
const linksRef = useRef(new Map<string, Link>());
|
|
212
|
+
const outside = (definition: FieldDefinition, group: GroupValues): Outside => {
|
|
213
|
+
const { id } = definition;
|
|
214
|
+
const valueType = valueTypeFor(definition);
|
|
215
|
+
const onChange = (value: unknown) => {
|
|
216
|
+
const raw = rawFor(value, defaultNow(id), valueType);
|
|
217
|
+
written.set(id, raw);
|
|
218
|
+
store.write([{ key: id, raw }], 'replace');
|
|
219
|
+
};
|
|
220
|
+
if (store.hrefFor === undefined) return { onChange };
|
|
221
|
+
const defaultValue = group.defaultOf(id);
|
|
222
|
+
let link = linksRef.current.get(id);
|
|
223
|
+
if (link === undefined || link.store !== store || !valueType.isEqual(link.defaultValue, defaultValue)) {
|
|
224
|
+
link = { store, defaultValue, hrefFor: (value) => store.hrefFor?.([{ key: id, raw: rawFor(value, defaultValue, valueType) }]) };
|
|
225
|
+
linksRef.current.set(id, link);
|
|
226
|
+
}
|
|
227
|
+
return { onChange, hrefFor: link.hrefFor };
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const resetAll = useCallback(
|
|
231
|
+
(method: UpdateMethod = 'replace') => store.write(ids.map((id) => ({ key: id, raw: undefined })), method),
|
|
232
|
+
[store, ids],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return { kept, warn, outside, resetAll };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Runs a list of field definitions together, as a group: every option function of each
|
|
240
|
+
* definition (but `initialValue`) receives `ctx.values`, the fields' shown values by id, beside
|
|
241
|
+
* the app's own field context, so a field's choices, bounds or visibility can follow another
|
|
242
|
+
* field. A field's default is its `initialValue` (which cannot read `values`), else its kind's
|
|
243
|
+
* implicit initial value from its options as they are now, so it moves with the fields those
|
|
244
|
+
* read. It throws, naming the field (and the option), for an option reading an id not in the
|
|
245
|
+
* group, two definitions with the same id and a mixed-boolean definition; naming the cycle for
|
|
246
|
+
* fields whose values depend on each other; and for an app context that already has `values`
|
|
247
|
+
* (`Field group: the field context already has 'values'; a field group provides it`).
|
|
248
|
+
*
|
|
249
|
+
* Storage, for the whole group, is the caller's choice. By default the group keeps the values in
|
|
250
|
+
* its own state, as a store keeps them: a field never set, or set to its current default, keeps
|
|
251
|
+
* nothing and shows its current default, which moves with the fields its options read. A kept
|
|
252
|
+
* value that another field's change made invalid shows the field's fallback, silently (that is
|
|
253
|
+
* how a group behaves, not bad input), and shows again once it is valid again; it is never
|
|
254
|
+
* rewritten. `resetAll()` clears every kept value (the update method is for a store; here it is
|
|
255
|
+
* ignored).
|
|
256
|
+
*
|
|
257
|
+
* With `{ stored: true }` the values live in the nearest ArgStore (the URL, localStorage) under
|
|
258
|
+
* each field's id, as useStoredField keeps a field there, all read in one subscription: an absent
|
|
259
|
+
* key reads as the field's current default, and a value equal to it is written as an absent key
|
|
260
|
+
* (`'replace'`). The default compared with is the one under the store's values at the moment of
|
|
261
|
+
* the write, as in the group's own state, so a write batched after another field's (in one
|
|
262
|
+
* event) compares with the default that one gives. The controls' `hrefFor` links a value
|
|
263
|
+
* through the store's `hrefFor` (`undefined` where the store has none), as rendered. The fallback
|
|
264
|
+
* rule is the same, and the stored value is never rewritten either, but a raw value that does not
|
|
265
|
+
* parse, or falls back, warns once, as useStoredField's does: an unparsable one reads as the
|
|
266
|
+
* current default. A raw value the group itself wrote does not warn (a write chained after
|
|
267
|
+
* another field's in one event may fall back at once). `resetAll(method = 'replace')` removes
|
|
268
|
+
* every field's key in one write.
|
|
269
|
+
*
|
|
270
|
+
* Each field runs its kind's hook with the definition in its outside form (the group's value and
|
|
271
|
+
* writer), with the group's context as its field context, so its controls are the kind's own,
|
|
272
|
+
* and a request goes through the kind's own checks before it is kept. The hooks run per field,
|
|
273
|
+
* so the ids and kinds, in order, are kept from the first render, and so is the storage: a list
|
|
274
|
+
* of another shape, or another `stored`, throws (remount the component running the group, with a
|
|
275
|
+
* React `key`, to swap lists). The definitions' contents may change, and new objects of the same
|
|
276
|
+
* shape on every render (inline literals) are fine.
|
|
277
|
+
*
|
|
278
|
+
* A field's shown value, in `values` and as passed to its kind's hook, keeps its identity while it
|
|
279
|
+
* stays equal (element by element, as useStoredField's does), so a list does not become a new array
|
|
280
|
+
* on every render; the `values` object keeps its identity until a shown value changes, so an
|
|
281
|
+
* effect keyed on it (one that loads data, say, and feeds the app's field context) runs only
|
|
282
|
+
* then. A kind's own controls may still rebuild a composite value: range's `[from, to]` pair is
|
|
283
|
+
* put in order and within bounds anew on every render.
|
|
284
|
+
*/
|
|
285
|
+
export function useFieldGroup(definitions: readonly FieldDefinition[], options: { stored?: boolean } = {}): FieldGroup {
|
|
286
|
+
const stored = options.stored === true;
|
|
287
|
+
const appCtx = useFieldContext();
|
|
288
|
+
|
|
289
|
+
const shape = shapeOf(definitions);
|
|
290
|
+
const firstRef = useRef({ shape, stored });
|
|
291
|
+
const first = firstRef.current;
|
|
292
|
+
if (stored !== first.stored) {
|
|
293
|
+
throw new Error(`${WHERE}: the storage changed from ${storageOf(first.stored)} to ${storageOf(stored)}; a group runs `
|
|
294
|
+
+ 'other hooks for each, so it keeps its storage across renders: to switch, remount the component running it with a React `key`');
|
|
295
|
+
}
|
|
296
|
+
if (shape !== first.shape) {
|
|
297
|
+
throw new Error(`${WHERE}: the fields changed from ${first.shape} to ${shape}; a group runs hooks per field, so it `
|
|
298
|
+
+ 'keeps the same ids and kinds, in order, across renders: to swap lists, remount the component running it with a React `key`');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const shownRef = useRef(new Map<string, unknown>());
|
|
302
|
+
const valuesRef = useRef<Readonly<Record<string, unknown>> | undefined>(undefined);
|
|
303
|
+
// The storage is the first render's (checked above), so the hooks it runs stay the same.
|
|
304
|
+
const keeping = stored ? useStoredValues(definitions, appCtx) : useOwnState(definitions, appCtx);
|
|
305
|
+
const group = createGroupValues({ definitions, appCtx, kept: keeping.kept, warn: keeping.warn });
|
|
306
|
+
|
|
307
|
+
const previous = shownRef.current;
|
|
308
|
+
const shown = new Map<string, unknown>();
|
|
309
|
+
for (const { id } of definitions) {
|
|
310
|
+
const value = group.valueOf(id);
|
|
311
|
+
shown.set(id, previous.has(id) && sameValue(previous.get(id), value) ? previous.get(id) : value);
|
|
312
|
+
}
|
|
313
|
+
shownRef.current = shown;
|
|
314
|
+
// The previous object while every shown value is the previous one (kept above while it stays
|
|
315
|
+
// equal): a host's effect keyed on `values` runs when a value changes, not on every render.
|
|
316
|
+
const unchanged = valuesRef.current !== undefined
|
|
317
|
+
&& definitions.every(({ id }) => previous.has(id) && Object.is(previous.get(id), shown.get(id)));
|
|
318
|
+
const values = unchanged ? valuesRef.current! : Object.fromEntries(shown);
|
|
319
|
+
valuesRef.current = values;
|
|
320
|
+
|
|
321
|
+
// One kind hook per definition, in order; the shape check above keeps the hooks' order.
|
|
322
|
+
const fields = definitions.map((definition) => {
|
|
323
|
+
const { initialValue: _default, ...outside } = definition;
|
|
324
|
+
const { onChange, hrefFor } = keeping.outside(definition, group);
|
|
325
|
+
return withFieldContext(group.ctx, () => useKindField(definition, {
|
|
326
|
+
...outside, value: shown.get(definition.id), onChange, hrefFor,
|
|
327
|
+
}));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const changed = new Set(definitions.filter((d) => !showsDefault(d, keeping.kept(d.id), group)).map((d) => d.id));
|
|
331
|
+
const isDefault = (id: string) => {
|
|
332
|
+
if (!shown.has(id)) {
|
|
333
|
+
throw new Error(`${WHERE}: no field '${id}' in the group (fields: ${definitions.map((d) => d.id).join(', ')})`);
|
|
334
|
+
}
|
|
335
|
+
return !changed.has(id);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
fields,
|
|
340
|
+
byId: Object.fromEntries(definitions.map((d, i) => [d.id, fields[i]])),
|
|
341
|
+
values,
|
|
342
|
+
isDefault,
|
|
343
|
+
changedCount: changed.size,
|
|
344
|
+
resetAll: keeping.resetAll,
|
|
345
|
+
};
|
|
346
|
+
}
|
package/src/useStoredField.ts
CHANGED
|
@@ -32,9 +32,11 @@ function warnOnConsole(message: string): void {
|
|
|
32
32
|
/**
|
|
33
33
|
* The same value element by element: a list in the same order, a range with the same ends, a
|
|
34
34
|
* scalar. Stricter than the value types' `isEqual` (lists as sets), because the order a field
|
|
35
|
-
* shows (choice order, tree order) matters to the identity kept below.
|
|
35
|
+
* shows (choice order, tree order) matters to the identity kept below. Exported for the field
|
|
36
|
+
* group (useFieldGroup), which keeps its values' identity by the same rule; not part of the
|
|
37
|
+
* package's API.
|
|
36
38
|
*/
|
|
37
|
-
function sameValue(a: unknown, b: unknown): boolean {
|
|
39
|
+
export function sameValue(a: unknown, b: unknown): boolean {
|
|
38
40
|
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
39
41
|
return a === b;
|
|
40
42
|
}
|