@panneau/core 4.1.1 → 5.0.0-alpha.1
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 +83 -0
- package/dist/definition/abilities.d.ts +21 -0
- package/dist/definition/abilities.d.ts.map +1 -0
- package/dist/definition/abilities.js +47 -0
- package/dist/definition/abilities.js.map +1 -0
- package/dist/definition/form.d.ts +31 -0
- package/dist/definition/form.d.ts.map +1 -0
- package/dist/definition/form.js +54 -0
- package/dist/definition/form.js.map +1 -0
- package/dist/definition/list.d.ts +44 -0
- package/dist/definition/list.d.ts.map +1 -0
- package/dist/definition/list.js +70 -0
- package/dist/definition/list.js.map +1 -0
- package/dist/definition/resource.d.ts +21 -0
- package/dist/definition/resource.d.ts.map +1 -0
- package/dist/definition/resource.js +24 -0
- package/dist/definition/resource.js.map +1 -0
- package/dist/form/form.d.ts +148 -0
- package/dist/form/form.d.ts.map +1 -0
- package/dist/form/form.js +239 -0
- package/dist/form/form.js.map +1 -0
- package/dist/form/messages.d.ts +7 -0
- package/dist/form/messages.d.ts.map +1 -0
- package/dist/form/messages.js +25 -0
- package/dist/form/messages.js.map +1 -0
- package/dist/form/problem.d.ts +16 -0
- package/dist/form/problem.d.ts.map +1 -0
- package/dist/form/problem.js +34 -0
- package/dist/form/problem.js.map +1 -0
- package/dist/form/selectors.d.ts +16 -0
- package/dist/form/selectors.d.ts.map +1 -0
- package/dist/form/selectors.js +27 -0
- package/dist/form/selectors.js.map +1 -0
- package/dist/form/store.d.ts +19 -0
- package/dist/form/store.d.ts.map +1 -0
- package/dist/form/store.js +29 -0
- package/dist/form/store.js.map +1 -0
- package/dist/index.d.ts +17 -692
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -536
- package/dist/list/list.d.ts +68 -0
- package/dist/list/list.d.ts.map +1 -0
- package/dist/list/list.js +191 -0
- package/dist/list/list.js.map +1 -0
- package/dist/list/router.d.ts +26 -0
- package/dist/list/router.d.ts.map +1 -0
- package/dist/list/router.js +54 -0
- package/dist/list/router.js.map +1 -0
- package/dist/plugins.d.ts +73 -0
- package/dist/plugins.d.ts.map +1 -0
- package/dist/plugins.js +54 -0
- package/dist/plugins.js.map +1 -0
- package/dist/react/provider.d.ts +38 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/provider.js +63 -0
- package/dist/react/provider.js.map +1 -0
- package/dist/react/useForm.d.ts +26 -0
- package/dist/react/useForm.d.ts.map +1 -0
- package/dist/react/useForm.js +115 -0
- package/dist/react/useForm.js.map +1 -0
- package/dist/react/useList.d.ts +11 -0
- package/dist/react/useList.d.ts.map +1 -0
- package/dist/react/useList.js +47 -0
- package/dist/react/useList.js.map +1 -0
- package/dist/values.d.ts +12 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +44 -0
- package/dist/values.js.map +1 -0
- package/package.json +19 -81
- package/dist/contexts.d.ts +0 -667
- package/dist/contexts.js +0 -1772
- package/dist/hooks.d.ts +0 -512
- package/dist/hooks.js +0 -2146
- package/dist/utils.d.ts +0 -457
- package/dist/utils.js +0 -557
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { isEqual, isPathWithin } from "../values.js";
|
|
2
|
+
import { formatRuleFailure } from "./messages.js";
|
|
3
|
+
import { getItemValues, getProblem, isItem } from "./problem.js";
|
|
4
|
+
import { selectErrorCount, selectErrors, selectValue, selectVisible } from "./selectors.js";
|
|
5
|
+
import { createLocalStore } from "./store.js";
|
|
6
|
+
import { checkValues, getValue } from "@panneau/protocol/runtime";
|
|
7
|
+
//#region src/form/form.ts
|
|
8
|
+
/** Initial values of fields that have a `default` and no value. */
|
|
9
|
+
function getDefaultValues(fields, values = {}) {
|
|
10
|
+
return fields.reduce((result, field) => field.default !== void 0 && result[field.name] === void 0 ? {
|
|
11
|
+
...result,
|
|
12
|
+
[field.name]: field.default
|
|
13
|
+
} : result, values);
|
|
14
|
+
}
|
|
15
|
+
function mergeErrors(...sources) {
|
|
16
|
+
const merged = {};
|
|
17
|
+
sources.forEach((source) => Object.entries(source).forEach(([path, messages]) => {
|
|
18
|
+
merged[path] = [...merged[path] ?? [], ...messages];
|
|
19
|
+
}));
|
|
20
|
+
return merged;
|
|
21
|
+
}
|
|
22
|
+
/** Creates a headless form. */
|
|
23
|
+
function createForm(options) {
|
|
24
|
+
const current = {
|
|
25
|
+
fields: options.fields,
|
|
26
|
+
locales: options.locales ?? [],
|
|
27
|
+
formatError: options.formatError ?? formatRuleFailure,
|
|
28
|
+
onSubmit: options.onSubmit ?? (() => void 0)
|
|
29
|
+
};
|
|
30
|
+
const mode = options.mode ?? (options.version !== void 0 && options.version !== null ? "update" : "create");
|
|
31
|
+
const startValues = mode === "create" ? getDefaultValues(options.fields, options.values) : options.values ?? {};
|
|
32
|
+
const store = options.store ?? createLocalStore(startValues);
|
|
33
|
+
if (options.store !== void 0 && options.values !== void 0) store.setValues(startValues);
|
|
34
|
+
let initialValues = store.getValues();
|
|
35
|
+
let version = options.version ?? null;
|
|
36
|
+
let touched = /* @__PURE__ */ new Set();
|
|
37
|
+
let serverErrors = {};
|
|
38
|
+
let status = "idle";
|
|
39
|
+
let conflict = null;
|
|
40
|
+
let error = null;
|
|
41
|
+
let submitCount = 0;
|
|
42
|
+
let pending = null;
|
|
43
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
44
|
+
let unsubscribeStore = null;
|
|
45
|
+
const clientErrors = (values, all) => {
|
|
46
|
+
const failures = checkValues(current.fields, values, {
|
|
47
|
+
mode,
|
|
48
|
+
locales: current.locales
|
|
49
|
+
});
|
|
50
|
+
return Object.fromEntries(Object.entries(failures).filter(([path]) => all || [...touched].some((parent) => isPathWithin(path, parent))).map(([path, list]) => [path, list.map((failure) => current.formatError(failure, path))]));
|
|
51
|
+
};
|
|
52
|
+
const compute = (values) => ({
|
|
53
|
+
values,
|
|
54
|
+
initialValues,
|
|
55
|
+
version,
|
|
56
|
+
dirty: !isEqual(values, initialValues),
|
|
57
|
+
errors: mergeErrors(clientErrors(values, submitCount > 0), serverErrors),
|
|
58
|
+
touched,
|
|
59
|
+
status,
|
|
60
|
+
conflict,
|
|
61
|
+
error,
|
|
62
|
+
submitCount
|
|
63
|
+
});
|
|
64
|
+
let state = compute(store.getValues());
|
|
65
|
+
const update = () => {
|
|
66
|
+
state = compute(store.getValues());
|
|
67
|
+
listeners.forEach((listener) => listener());
|
|
68
|
+
};
|
|
69
|
+
/** Server errors go away when their value changes. */
|
|
70
|
+
const onStoreChange = () => {
|
|
71
|
+
const previous = state.values;
|
|
72
|
+
const values = store.getValues();
|
|
73
|
+
if (values === previous) return;
|
|
74
|
+
serverErrors = Object.fromEntries(Object.entries(serverErrors).filter(([path]) => isEqual(getValue(previous, path), getValue(values, path))));
|
|
75
|
+
update();
|
|
76
|
+
};
|
|
77
|
+
const changes = (values) => Object.fromEntries(Object.entries({
|
|
78
|
+
...initialValues,
|
|
79
|
+
...values
|
|
80
|
+
}).flatMap(([key]) => isEqual(values[key], initialValues[key]) ? [] : [[key, values[key]]]));
|
|
81
|
+
const fieldNames = () => current.fields.map((field) => field.name);
|
|
82
|
+
const reset = (values, nextVersion) => {
|
|
83
|
+
if (nextVersion !== void 0) version = nextVersion;
|
|
84
|
+
touched = /* @__PURE__ */ new Set();
|
|
85
|
+
serverErrors = {};
|
|
86
|
+
status = "idle";
|
|
87
|
+
conflict = null;
|
|
88
|
+
error = null;
|
|
89
|
+
submitCount = 0;
|
|
90
|
+
if (values !== void 0) {
|
|
91
|
+
initialValues = values;
|
|
92
|
+
store.setValues(values);
|
|
93
|
+
} else store.setValues(initialValues);
|
|
94
|
+
update();
|
|
95
|
+
};
|
|
96
|
+
const runSubmit = async () => {
|
|
97
|
+
submitCount += 1;
|
|
98
|
+
serverErrors = {};
|
|
99
|
+
const values = store.getValues();
|
|
100
|
+
const errors = clientErrors(values, true);
|
|
101
|
+
if (Object.keys(errors).length > 0) {
|
|
102
|
+
status = "invalid";
|
|
103
|
+
update();
|
|
104
|
+
return {
|
|
105
|
+
status: "invalid",
|
|
106
|
+
errors
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
status = "submitting";
|
|
110
|
+
conflict = null;
|
|
111
|
+
error = null;
|
|
112
|
+
update();
|
|
113
|
+
try {
|
|
114
|
+
const result = await current.onSubmit(values, {
|
|
115
|
+
mode,
|
|
116
|
+
version,
|
|
117
|
+
changes: changes(values)
|
|
118
|
+
});
|
|
119
|
+
if (isItem(result)) {
|
|
120
|
+
const saved = getItemValues(result, fieldNames());
|
|
121
|
+
version = result.version;
|
|
122
|
+
initialValues = saved;
|
|
123
|
+
if (store.getValues() === values) store.setValues(saved);
|
|
124
|
+
} else initialValues = values;
|
|
125
|
+
touched = /* @__PURE__ */ new Set();
|
|
126
|
+
submitCount = 0;
|
|
127
|
+
status = "submitted";
|
|
128
|
+
update();
|
|
129
|
+
return {
|
|
130
|
+
status: "submitted",
|
|
131
|
+
result
|
|
132
|
+
};
|
|
133
|
+
} catch (caught) {
|
|
134
|
+
const problem = getProblem(caught);
|
|
135
|
+
if (problem?.status === 412) {
|
|
136
|
+
status = "conflict";
|
|
137
|
+
conflict = { current: problem.current ?? null };
|
|
138
|
+
update();
|
|
139
|
+
return {
|
|
140
|
+
status: "conflict",
|
|
141
|
+
current: conflict.current
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (problem?.status === 422 && problem.errors !== void 0) {
|
|
145
|
+
status = "invalid";
|
|
146
|
+
serverErrors = problem.errors;
|
|
147
|
+
update();
|
|
148
|
+
return {
|
|
149
|
+
status: "invalid",
|
|
150
|
+
errors: problem.errors
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
status = "error";
|
|
154
|
+
error = caught;
|
|
155
|
+
update();
|
|
156
|
+
return {
|
|
157
|
+
status: "error",
|
|
158
|
+
error: caught
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const form = {
|
|
163
|
+
getState() {
|
|
164
|
+
if (unsubscribeStore === null) onStoreChange();
|
|
165
|
+
return state;
|
|
166
|
+
},
|
|
167
|
+
subscribe(listener) {
|
|
168
|
+
listeners.add(listener);
|
|
169
|
+
unsubscribeStore ??= store.subscribe(onStoreChange);
|
|
170
|
+
onStoreChange();
|
|
171
|
+
return () => {
|
|
172
|
+
listeners.delete(listener);
|
|
173
|
+
if (listeners.size === 0 && unsubscribeStore !== null) {
|
|
174
|
+
unsubscribeStore();
|
|
175
|
+
unsubscribeStore = null;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
setOptions(next) {
|
|
180
|
+
const { fields, locales, formatError, onSubmit } = next;
|
|
181
|
+
if (onSubmit !== void 0) current.onSubmit = onSubmit;
|
|
182
|
+
if (formatError !== void 0) current.formatError = formatError;
|
|
183
|
+
const changed = fields !== void 0 && !isEqual(fields, current.fields) || locales !== void 0 && !isEqual(locales, current.locales);
|
|
184
|
+
if (fields !== void 0) current.fields = fields;
|
|
185
|
+
if (locales !== void 0) current.locales = locales;
|
|
186
|
+
if (changed) update();
|
|
187
|
+
},
|
|
188
|
+
getValue: (path) => selectValue(form.getState(), path),
|
|
189
|
+
setValue(path, value) {
|
|
190
|
+
store.setValue(path, value);
|
|
191
|
+
if (unsubscribeStore === null) onStoreChange();
|
|
192
|
+
},
|
|
193
|
+
blur(path) {
|
|
194
|
+
if (touched.has(path)) return;
|
|
195
|
+
touched = /* @__PURE__ */ new Set([...touched, path]);
|
|
196
|
+
update();
|
|
197
|
+
},
|
|
198
|
+
getErrors: (path, getErrorsOptions) => selectErrors(form.getState(), path, getErrorsOptions),
|
|
199
|
+
countErrors: (paths) => selectErrorCount(form.getState(), paths),
|
|
200
|
+
isVisible: (target, parentPath) => selectVisible(form.getState(), target, parentPath),
|
|
201
|
+
validate() {
|
|
202
|
+
submitCount = Math.max(submitCount, 1);
|
|
203
|
+
update();
|
|
204
|
+
return Object.keys(clientErrors(store.getValues(), true)).length === 0;
|
|
205
|
+
},
|
|
206
|
+
setServerErrors(errors) {
|
|
207
|
+
serverErrors = errors;
|
|
208
|
+
update();
|
|
209
|
+
},
|
|
210
|
+
submit() {
|
|
211
|
+
pending ??= runSubmit().finally(() => {
|
|
212
|
+
pending = null;
|
|
213
|
+
});
|
|
214
|
+
return pending;
|
|
215
|
+
},
|
|
216
|
+
reset,
|
|
217
|
+
async resolveConflict(choice) {
|
|
218
|
+
if (status !== "conflict" || conflict === null) return null;
|
|
219
|
+
const latest = conflict.current;
|
|
220
|
+
if (choice === "reload") {
|
|
221
|
+
if (latest !== null) reset(getItemValues(latest, fieldNames()), latest.version);
|
|
222
|
+
else {
|
|
223
|
+
status = "idle";
|
|
224
|
+
conflict = null;
|
|
225
|
+
update();
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (latest !== null) version = latest.version;
|
|
230
|
+
conflict = null;
|
|
231
|
+
return form.submit();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
return form;
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
export { createForm, getDefaultValues };
|
|
238
|
+
|
|
239
|
+
//# sourceMappingURL=form.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form.js","names":[],"sources":["../../src/form/form.ts"],"sourcesContent":["import type { Condition, Field, Item, RuleFailure } from '@panneau/protocol';\nimport { checkValues, getValue } from '@panneau/protocol/runtime';\n\nimport type { FormMode } from '../definition/form';\nimport { type Values, isEqual, isPathWithin } from '../values';\nimport { formatRuleFailure } from './messages';\nimport { getItemValues, getProblem, isItem } from './problem';\nimport { selectErrorCount, selectErrors, selectValue, selectVisible } from './selectors';\nimport { type FormStore, createLocalStore } from './store';\n\n/**\n * - `idle`: nothing submitted yet, or after a reset;\n * - `submitting`: waiting for `onSubmit`;\n * - `submitted`: the last submit succeeded;\n * - `invalid`: client validation or the server (`422`) found errors;\n * - `conflict`: the item changed since the form's version (`412`), see `resolveConflict()`;\n * - `error`: the last submit failed for another reason (network, `403`, `500`…), see `error`.\n */\nexport type FormStatus = 'idle' | 'submitting' | 'submitted' | 'invalid' | 'conflict' | 'error';\n\nexport interface FormConflict {\n /** The current version of the item, when the backend sent it. */\n current: Item | null;\n}\n\n/** A snapshot of a form. A new object is created on every change. */\nexport interface FormState {\n values: Values;\n /** The values the form started from, or was last saved with. `dirty` compares to them. */\n initialValues: Values;\n /** The version of the item the values were edited from, sent with the submit. */\n version: string | null;\n /** Whether the values differ from the initial values. */\n dirty: boolean;\n /**\n * Errors shown, by dot path (`title.fr`): client errors of touched fields (all of them after a\n * submit), then server errors. A server error goes away when its value changes.\n */\n errors: Record<string, string[]>;\n /** Paths of the fields that lost focus at least once. */\n touched: ReadonlySet<string>;\n status: FormStatus;\n /** With the `conflict` status: what the backend says is current. */\n conflict: FormConflict | null;\n /** With the `error` status: what `onSubmit` threw. */\n error: unknown;\n submitCount: number;\n}\n\nexport interface SubmitContext {\n mode: FormMode;\n /** The version to send as `If-Match`. */\n version: string | null;\n /** The top-level values that differ from the initial values, for partial updates. */\n changes: Values;\n}\n\nexport type SubmitResult =\n | { status: 'submitted'; result: unknown }\n | { status: 'invalid'; errors: Record<string, string[]> }\n | { status: 'conflict'; current: Item | null }\n | { status: 'error'; error: unknown };\n\nexport interface FormOptions {\n /** The fields of the form, as resolved by `resolveForm()`. */\n fields: Field[];\n /** Initial values. In `create` mode, fields without a value get their `default`. */\n values?: Values;\n /** The version of the item the values come from. */\n version?: string | null;\n /** Defaults to `update` when a version is given, `create` otherwise. */\n mode?: FormMode;\n /** Locales a `required` localized field must be filled in (all of them, usually). */\n locales?: string[];\n /** Where the values live. Defaults to a local store. */\n store?: FormStore;\n /** Message of a failed rule. Defaults to `formatRuleFailure()`. */\n formatError?: (failure: RuleFailure, path: string) => string;\n /**\n * Saves the values. When it returns an item, the form continues from that item. When it\n * throws an error that carries problem details (`getProblem()`), `422` errors are shown by\n * path and `412` puts the form in the `conflict` status.\n */\n onSubmit?: (values: Values, context: SubmitContext) => unknown;\n}\n\n/** Form options that can change after the form is created. */\nexport type UpdatableFormOptions = Pick<\n FormOptions,\n 'fields' | 'locales' | 'formatError' | 'onSubmit'\n>;\n\nexport interface GetErrorsOptions {\n /** Also return the errors of nested paths (`title.fr` for `title`). */\n nested?: boolean;\n}\n\n/**\n * A headless form: values, errors, visibility and submit. React components use `useForm()`.\n *\n * The methods that read the state (`getValue()`, `getErrors()`, `countErrors()`, `isVisible()`)\n * are not React APIs: the React Compiler memoizes their calls by their arguments, so they return\n * stale results in components. Components read the state they subscribed to with the selectors\n * (`selectValue()`, `selectErrors()`, `selectErrorCount()`, `selectVisible()`).\n */\nexport interface Form {\n getState(): FormState;\n subscribe(listener: () => void): () => void;\n /** Updates the options that can change between renders. */\n setOptions(options: Partial<UpdatableFormOptions>): void;\n /**\n * The value at a dot path. Not a React API: components use `selectValue(state, path)` (see\n * {@link Form}).\n */\n getValue(path: string): unknown;\n setValue(path: string, value: unknown): void;\n /** Marks a field as touched, which shows its client errors. */\n blur(path: string): void;\n /**\n * Errors at a path. Not a React API: components use `selectErrors(state, path, options)` (see\n * {@link Form}).\n */\n getErrors(path: string, options?: GetErrorsOptions): string[];\n /**\n * The number of paths with errors within the given paths, for counts on tabs and sections. Not\n * a React API: components use `selectErrorCount(state, paths)` (see {@link Form}).\n */\n countErrors(paths: readonly string[]): number;\n /**\n * Whether a field or a layout node is visible, from its `visibleWhen`. For nested fields, pass\n * the path of the object they belong to (`blocks.2`): conditions are relative to it. Not a\n * React API: components use `selectVisible(state, target, parentPath)` (see {@link Form}).\n */\n isVisible(target: { visibleWhen?: Condition | undefined }, parentPath?: string): boolean;\n /** Checks every value with the portable rules and shows every error. Returns whether valid. */\n validate(): boolean;\n /** Shows server errors, by dot path. */\n setServerErrors(errors: Record<string, string[]>): void;\n /** Validates, then calls `onSubmit`. */\n submit(): Promise<SubmitResult>;\n /** Starts over from new values (or the initial ones), and a new version. */\n reset(values?: Values, version?: string | null): void;\n /**\n * Leaves the `conflict` status:\n *\n * - `reload` drops the local changes and continues from the current item;\n * - `overwrite` keeps the local changes and submits them again over the current version.\n */\n resolveConflict(choice: 'reload' | 'overwrite'): Promise<SubmitResult | null>;\n}\n\n/** Initial values of fields that have a `default` and no value. */\nexport function getDefaultValues(fields: readonly Field[], values: Values = {}): Values {\n return fields.reduce<Values>(\n (result, field) =>\n field.default !== undefined && result[field.name] === undefined\n ? { ...result, [field.name]: field.default }\n : result,\n values,\n );\n}\n\nfunction mergeErrors(...sources: Record<string, string[]>[]): Record<string, string[]> {\n const merged: Record<string, string[]> = {};\n sources.forEach((source) =>\n Object.entries(source).forEach(([path, messages]) => {\n merged[path] = [...(merged[path] ?? []), ...messages];\n }),\n );\n return merged;\n}\n\n/** Creates a headless form. */\nexport function createForm(options: FormOptions): Form {\n const current: Required<UpdatableFormOptions> = {\n fields: options.fields,\n locales: options.locales ?? [],\n formatError: options.formatError ?? formatRuleFailure,\n onSubmit: options.onSubmit ?? (() => undefined),\n };\n const mode: FormMode =\n options.mode ??\n (options.version !== undefined && options.version !== null ? 'update' : 'create');\n const startValues =\n mode === 'create'\n ? getDefaultValues(options.fields, options.values)\n : (options.values ?? {});\n const store = options.store ?? createLocalStore(startValues);\n if (options.store !== undefined && options.values !== undefined) {\n store.setValues(startValues);\n }\n\n let initialValues = store.getValues();\n let version = options.version ?? null;\n let touched: ReadonlySet<string> = new Set();\n let serverErrors: Record<string, string[]> = {};\n let status: FormStatus = 'idle';\n let conflict: FormConflict | null = null;\n let error: unknown = null;\n let submitCount = 0;\n let pending: Promise<SubmitResult> | null = null;\n\n const listeners = new Set<() => void>();\n let unsubscribeStore: (() => void) | null = null;\n\n const clientErrors = (values: Values, all: boolean): Record<string, string[]> => {\n const failures = checkValues(current.fields, values, { mode, locales: current.locales });\n return Object.fromEntries(\n Object.entries(failures)\n .filter(\n ([path]) => all || [...touched].some((parent) => isPathWithin(path, parent)),\n )\n .map(([path, list]) => [\n path,\n list.map((failure) => current.formatError(failure, path)),\n ]),\n );\n };\n\n const compute = (values: Values): FormState => ({\n values,\n initialValues,\n version,\n dirty: !isEqual(values, initialValues),\n errors: mergeErrors(clientErrors(values, submitCount > 0), serverErrors),\n touched,\n status,\n conflict,\n error,\n submitCount,\n });\n\n let state = compute(store.getValues());\n\n const update = () => {\n state = compute(store.getValues());\n listeners.forEach((listener) => listener());\n };\n\n /** Server errors go away when their value changes. */\n const onStoreChange = () => {\n const previous = state.values;\n const values = store.getValues();\n if (values === previous) {\n return;\n }\n serverErrors = Object.fromEntries(\n Object.entries(serverErrors).filter(([path]) =>\n isEqual(getValue(previous, path), getValue(values, path)),\n ),\n );\n update();\n };\n\n const changes = (values: Values): Values =>\n Object.fromEntries(\n Object.entries({ ...initialValues, ...values }).flatMap(([key]) =>\n isEqual(values[key], initialValues[key]) ? [] : [[key, values[key]]],\n ),\n );\n\n const fieldNames = () => current.fields.map((field) => field.name);\n\n const reset = (values?: Values, nextVersion?: string | null) => {\n if (nextVersion !== undefined) {\n version = nextVersion;\n }\n touched = new Set();\n serverErrors = {};\n status = 'idle';\n conflict = null;\n error = null;\n submitCount = 0;\n if (values !== undefined) {\n initialValues = values;\n store.setValues(values);\n } else {\n store.setValues(initialValues);\n }\n update();\n };\n\n const runSubmit = async (): Promise<SubmitResult> => {\n submitCount += 1;\n serverErrors = {};\n const values = store.getValues();\n const errors = clientErrors(values, true);\n if (Object.keys(errors).length > 0) {\n status = 'invalid';\n update();\n return { status: 'invalid', errors };\n }\n status = 'submitting';\n conflict = null;\n error = null;\n update();\n try {\n const result = await current.onSubmit(values, {\n mode,\n version,\n changes: changes(values),\n });\n if (isItem(result)) {\n const saved = getItemValues(result, fieldNames());\n version = result.version;\n initialValues = saved;\n // Keep what was typed while saving; otherwise take the saved values.\n if (store.getValues() === values) {\n store.setValues(saved);\n }\n } else {\n initialValues = values;\n }\n touched = new Set();\n submitCount = 0;\n status = 'submitted';\n update();\n return { status: 'submitted', result };\n } catch (caught) {\n const problem = getProblem(caught);\n if (problem?.status === 412) {\n status = 'conflict';\n conflict = { current: problem.current ?? null };\n update();\n return { status: 'conflict', current: conflict.current };\n }\n if (problem?.status === 422 && problem.errors !== undefined) {\n status = 'invalid';\n serverErrors = problem.errors;\n update();\n return { status: 'invalid', errors: problem.errors };\n }\n status = 'error';\n error = caught;\n update();\n return { status: 'error', error: caught };\n }\n };\n\n const form: Form = {\n getState() {\n if (unsubscribeStore === null) {\n onStoreChange();\n }\n return state;\n },\n subscribe(listener) {\n listeners.add(listener);\n unsubscribeStore ??= store.subscribe(onStoreChange);\n onStoreChange();\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0 && unsubscribeStore !== null) {\n unsubscribeStore();\n unsubscribeStore = null;\n }\n };\n },\n setOptions(next) {\n const { fields, locales, formatError, onSubmit } = next;\n if (onSubmit !== undefined) {\n current.onSubmit = onSubmit;\n }\n if (formatError !== undefined) {\n current.formatError = formatError;\n }\n // Fields and locales change what is valid: compare them by value, since a render can\n // create new arrays with the same content.\n const changed =\n (fields !== undefined && !isEqual(fields, current.fields)) ||\n (locales !== undefined && !isEqual(locales, current.locales));\n if (fields !== undefined) {\n current.fields = fields;\n }\n if (locales !== undefined) {\n current.locales = locales;\n }\n if (changed) {\n update();\n }\n },\n getValue: (path) => selectValue(form.getState(), path),\n setValue(path, value) {\n store.setValue(path, value);\n if (unsubscribeStore === null) {\n onStoreChange();\n }\n },\n blur(path) {\n if (touched.has(path)) {\n return;\n }\n touched = new Set([...touched, path]);\n update();\n },\n getErrors: (path, getErrorsOptions) =>\n selectErrors(form.getState(), path, getErrorsOptions),\n countErrors: (paths) => selectErrorCount(form.getState(), paths),\n isVisible: (target, parentPath) => selectVisible(form.getState(), target, parentPath),\n validate() {\n submitCount = Math.max(submitCount, 1);\n update();\n return Object.keys(clientErrors(store.getValues(), true)).length === 0;\n },\n setServerErrors(errors) {\n serverErrors = errors;\n update();\n },\n submit() {\n pending ??= runSubmit().finally(() => {\n pending = null;\n });\n return pending;\n },\n reset,\n async resolveConflict(choice) {\n if (status !== 'conflict' || conflict === null) {\n return null;\n }\n const latest = conflict.current;\n if (choice === 'reload') {\n if (latest !== null) {\n reset(getItemValues(latest, fieldNames()), latest.version);\n } else {\n status = 'idle';\n conflict = null;\n update();\n }\n return null;\n }\n if (latest !== null) {\n version = latest.version;\n }\n conflict = null;\n return form.submit();\n },\n };\n return form;\n}\n"],"mappings":";;;;;;;;AAwJA,SAAgB,iBAAiB,QAA0B,SAAiB,CAAC,GAAW;CACpF,OAAO,OAAO,QACT,QAAQ,UACL,MAAM,YAAY,KAAA,KAAa,OAAO,MAAM,UAAU,KAAA,IAChD;EAAE,GAAG;GAAS,MAAM,OAAO,MAAM;CAAQ,IACzC,QACV,MACJ;AACJ;AAEA,SAAS,YAAY,GAAG,SAA+D;CACnF,MAAM,SAAmC,CAAC;CAC1C,QAAQ,SAAS,WACb,OAAO,QAAQ,MAAM,CAAC,CAAC,SAAS,CAAC,MAAM,cAAc;EACjD,OAAO,QAAQ,CAAC,GAAI,OAAO,SAAS,CAAA,GAAK,GAAG,QAAQ;CACxD,CAAC,CACL;CACA,OAAO;AACX;;AAGA,SAAgB,WAAW,SAA4B;CACnD,MAAM,UAA0C;EAC5C,QAAQ,QAAQ;EAChB,SAAS,QAAQ,WAAW,CAAA;EAC5B,aAAa,QAAQ,eAAe;EACpC,UAAU,QAAQ,mBAAmB,KAAA;CACzC;CACA,MAAM,OACF,QAAQ,SACP,QAAQ,YAAY,KAAA,KAAa,QAAQ,YAAY,OAAO,WAAW;CAC5E,MAAM,cACF,SAAS,WACH,iBAAiB,QAAQ,QAAQ,QAAQ,MAAM,IAC9C,QAAQ,UAAU,CAAC;CAC9B,MAAM,QAAQ,QAAQ,SAAS,iBAAiB,WAAW;CAC3D,IAAI,QAAQ,UAAU,KAAA,KAAa,QAAQ,WAAW,KAAA,GAClD,MAAM,UAAU,WAAW;CAG/B,IAAI,gBAAgB,MAAM,UAAU;CACpC,IAAI,UAAU,QAAQ,WAAW;CACjC,IAAI,0BAA+B,IAAI,IAAI;CAC3C,IAAI,eAAyC,CAAC;CAC9C,IAAI,SAAqB;CACzB,IAAI,WAAgC;CACpC,IAAI,QAAiB;CACrB,IAAI,cAAc;CAClB,IAAI,UAAwC;CAE5C,MAAM,4BAAY,IAAI,IAAgB;CACtC,IAAI,mBAAwC;CAE5C,MAAM,gBAAgB,QAAgB,QAA2C;EAC7E,MAAM,WAAW,YAAY,QAAQ,QAAQ,QAAQ;GAAE;GAAM,SAAS,QAAQ;EAAQ,CAAC;EACvF,OAAO,OAAO,YACV,OAAO,QAAQ,QAAQ,CAAC,CACnB,QACI,CAAC,UAAU,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,WAAW,aAAa,MAAM,MAAM,CAAC,CAC/E,CAAC,CACA,KAAK,CAAC,MAAM,UAAU,CACnB,MACA,KAAK,KAAK,YAAY,QAAQ,YAAY,SAAS,IAAI,CAAC,CAAC,CAC5D,CACT;CACJ;CAEA,MAAM,WAAW,YAA+B;EAC5C;EACA;EACA;EACA,OAAO,CAAC,QAAQ,QAAQ,aAAa;EACrC,QAAQ,YAAY,aAAa,QAAQ,cAAc,CAAC,GAAG,YAAY;EACvE;EACA;EACA;EACA;EACA;CACJ;CAEA,IAAI,QAAQ,QAAQ,MAAM,UAAU,CAAC;CAErC,MAAM,eAAe;EACjB,QAAQ,QAAQ,MAAM,UAAU,CAAC;EACjC,UAAU,SAAS,aAAa,SAAS,CAAC;CAC9C;;CAGA,MAAM,sBAAsB;EACxB,MAAM,WAAW,MAAM;EACvB,MAAM,SAAS,MAAM,UAAU;EAC/B,IAAI,WAAW,UACX;EAEJ,eAAe,OAAO,YAClB,OAAO,QAAQ,YAAY,CAAC,CAAC,QAAQ,CAAC,UAClC,QAAQ,SAAS,UAAU,IAAI,GAAG,SAAS,QAAQ,IAAI,CAAC,CAC5D,CACJ;EACA,OAAO;CACX;CAEA,MAAM,WAAW,WACb,OAAO,YACH,OAAO,QAAQ;EAAE,GAAG;EAAe,GAAG;CAAO,CAAC,CAAC,CAAC,SAAS,CAAC,SACtD,QAAQ,OAAO,MAAM,cAAc,IAAI,IAAI,CAAA,IAAK,CAAC,CAAC,KAAK,OAAO,IAAI,CAAC,CACvE,CACJ;CAEJ,MAAM,mBAAmB,QAAQ,OAAO,KAAK,UAAU,MAAM,IAAI;CAEjE,MAAM,SAAS,QAAiB,gBAAgC;EAC5D,IAAI,gBAAgB,KAAA,GAChB,UAAU;EAEd,0BAAU,IAAI,IAAI;EAClB,eAAe,CAAC;EAChB,SAAS;EACT,WAAW;EACX,QAAQ;EACR,cAAc;EACd,IAAI,WAAW,KAAA,GAAW;GACtB,gBAAgB;GAChB,MAAM,UAAU,MAAM;EAC1B,OACI,MAAM,UAAU,aAAa;EAEjC,OAAO;CACX;CAEA,MAAM,YAAY,YAAmC;EACjD,eAAe;EACf,eAAe,CAAC;EAChB,MAAM,SAAS,MAAM,UAAU;EAC/B,MAAM,SAAS,aAAa,QAAQ,IAAI;EACxC,IAAI,OAAO,KAAK,MAAM,CAAC,CAAC,SAAS,GAAG;GAChC,SAAS;GACT,OAAO;GACP,OAAO;IAAE,QAAQ;IAAW;GAAO;EACvC;EACA,SAAS;EACT,WAAW;EACX,QAAQ;EACR,OAAO;EACP,IAAI;GACA,MAAM,SAAS,MAAM,QAAQ,SAAS,QAAQ;IAC1C;IACA;IACA,SAAS,QAAQ,MAAM;GAC3B,CAAC;GACD,IAAI,OAAO,MAAM,GAAG;IAChB,MAAM,QAAQ,cAAc,QAAQ,WAAW,CAAC;IAChD,UAAU,OAAO;IACjB,gBAAgB;IAEhB,IAAI,MAAM,UAAU,MAAM,QACtB,MAAM,UAAU,KAAK;GAE7B,OACI,gBAAgB;GAEpB,0BAAU,IAAI,IAAI;GAClB,cAAc;GACd,SAAS;GACT,OAAO;GACP,OAAO;IAAE,QAAQ;IAAa;GAAO;EACzC,SAAS,QAAQ;GACb,MAAM,UAAU,WAAW,MAAM;GACjC,IAAI,SAAS,WAAW,KAAK;IACzB,SAAS;IACT,WAAW,EAAE,SAAS,QAAQ,WAAW,KAAK;IAC9C,OAAO;IACP,OAAO;KAAE,QAAQ;KAAY,SAAS,SAAS;IAAQ;GAC3D;GACA,IAAI,SAAS,WAAW,OAAO,QAAQ,WAAW,KAAA,GAAW;IACzD,SAAS;IACT,eAAe,QAAQ;IACvB,OAAO;IACP,OAAO;KAAE,QAAQ;KAAW,QAAQ,QAAQ;IAAO;GACvD;GACA,SAAS;GACT,QAAQ;GACR,OAAO;GACP,OAAO;IAAE,QAAQ;IAAS,OAAO;GAAO;EAC5C;CACJ;CAEA,MAAM,OAAa;EACf,WAAW;GACP,IAAI,qBAAqB,MACrB,cAAc;GAElB,OAAO;EACX;EACA,UAAU,UAAU;GAChB,UAAU,IAAI,QAAQ;GACtB,qBAAqB,MAAM,UAAU,aAAa;GAClD,cAAc;GACd,aAAa;IACT,UAAU,OAAO,QAAQ;IACzB,IAAI,UAAU,SAAS,KAAK,qBAAqB,MAAM;KACnD,iBAAiB;KACjB,mBAAmB;IACvB;GACJ;EACJ;EACA,WAAW,MAAM;GACb,MAAM,EAAE,QAAQ,SAAS,aAAa,aAAa;GACnD,IAAI,aAAa,KAAA,GACb,QAAQ,WAAW;GAEvB,IAAI,gBAAgB,KAAA,GAChB,QAAQ,cAAc;GAI1B,MAAM,UACD,WAAW,KAAA,KAAa,CAAC,QAAQ,QAAQ,QAAQ,MAAM,KACvD,YAAY,KAAA,KAAa,CAAC,QAAQ,SAAS,QAAQ,OAAO;GAC/D,IAAI,WAAW,KAAA,GACX,QAAQ,SAAS;GAErB,IAAI,YAAY,KAAA,GACZ,QAAQ,UAAU;GAEtB,IAAI,SACA,OAAO;EAEf;EACA,WAAW,SAAS,YAAY,KAAK,SAAS,GAAG,IAAI;EACrD,SAAS,MAAM,OAAO;GAClB,MAAM,SAAS,MAAM,KAAK;GAC1B,IAAI,qBAAqB,MACrB,cAAc;EAEtB;EACA,KAAK,MAAM;GACP,IAAI,QAAQ,IAAI,IAAI,GAChB;GAEJ,0BAAU,IAAI,IAAI,CAAC,GAAG,SAAS,IAAI,CAAC;GACpC,OAAO;EACX;EACA,YAAY,MAAM,qBACd,aAAa,KAAK,SAAS,GAAG,MAAM,gBAAgB;EACxD,cAAc,UAAU,iBAAiB,KAAK,SAAS,GAAG,KAAK;EAC/D,YAAY,QAAQ,eAAe,cAAc,KAAK,SAAS,GAAG,QAAQ,UAAU;EACpF,WAAW;GACP,cAAc,KAAK,IAAI,aAAa,CAAC;GACrC,OAAO;GACP,OAAO,OAAO,KAAK,aAAa,MAAM,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,WAAW;EACzE;EACA,gBAAgB,QAAQ;GACpB,eAAe;GACf,OAAO;EACX;EACA,SAAS;GACL,YAAY,UAAU,CAAC,CAAC,cAAc;IAClC,UAAU;GACd,CAAC;GACD,OAAO;EACX;EACA;EACA,MAAM,gBAAgB,QAAQ;GAC1B,IAAI,WAAW,cAAc,aAAa,MACtC,OAAO;GAEX,MAAM,SAAS,SAAS;GACxB,IAAI,WAAW,UAAU;IACrB,IAAI,WAAW,MACX,MAAM,cAAc,QAAQ,WAAW,CAAC,GAAG,OAAO,OAAO;SACtD;KACH,SAAS;KACT,WAAW;KACX,OAAO;IACX;IACA,OAAO;GACX;GACA,IAAI,WAAW,MACX,UAAU,OAAO;GAErB,WAAW;GACX,OAAO,KAAK,OAAO;EACvB;CACJ;CACA,OAAO;AACX"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RuleFailure } from '@panneau/protocol';
|
|
2
|
+
/**
|
|
3
|
+
* The message of a failed rule: the rule's own `message`, or a default English message. Forms
|
|
4
|
+
* take a `formatError` option to translate them.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatRuleFailure(failure: RuleFailure): string;
|
|
7
|
+
//# sourceMappingURL=messages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/form/messages.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAgBrD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE9D"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/form/messages.ts
|
|
2
|
+
var MESSAGES = {
|
|
3
|
+
required: () => "This field is required.",
|
|
4
|
+
min: (failure) => `This value must be at least ${String(failure.value)}.`,
|
|
5
|
+
max: (failure) => `This value must be at most ${String(failure.value)}.`,
|
|
6
|
+
pattern: () => "This value has an invalid format.",
|
|
7
|
+
email: () => "This value must be an email address.",
|
|
8
|
+
url: () => "This value must be a URL.",
|
|
9
|
+
in: () => "This value is not one of the choices.",
|
|
10
|
+
accept: () => "This file type is not accepted.",
|
|
11
|
+
maxSize: () => "This file is too large.",
|
|
12
|
+
before: () => "This date is too late.",
|
|
13
|
+
after: () => "This date is too early."
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The message of a failed rule: the rule's own `message`, or a default English message. Forms
|
|
17
|
+
* take a `formatError` option to translate them.
|
|
18
|
+
*/
|
|
19
|
+
function formatRuleFailure(failure) {
|
|
20
|
+
return failure.message ?? MESSAGES[failure.rule]?.(failure) ?? "This value is invalid.";
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
export { formatRuleFailure };
|
|
24
|
+
|
|
25
|
+
//# sourceMappingURL=messages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.js","names":[],"sources":["../../src/form/messages.ts"],"sourcesContent":["import type { RuleFailure } from '@panneau/protocol';\n\nconst MESSAGES: Record<string, (failure: RuleFailure) => string> = {\n required: () => 'This field is required.',\n min: (failure) => `This value must be at least ${String(failure.value)}.`,\n max: (failure) => `This value must be at most ${String(failure.value)}.`,\n pattern: () => 'This value has an invalid format.',\n email: () => 'This value must be an email address.',\n url: () => 'This value must be a URL.',\n in: () => 'This value is not one of the choices.',\n accept: () => 'This file type is not accepted.',\n maxSize: () => 'This file is too large.',\n before: () => 'This date is too late.',\n after: () => 'This date is too early.',\n};\n\n/**\n * The message of a failed rule: the rule's own `message`, or a default English message. Forms\n * take a `formatError` option to translate them.\n */\nexport function formatRuleFailure(failure: RuleFailure): string {\n return failure.message ?? MESSAGES[failure.rule]?.(failure) ?? 'This value is invalid.';\n}\n"],"mappings":";AAEA,IAAM,WAA6D;CAC/D,gBAAgB;CAChB,MAAM,YAAY,+BAA+B,OAAO,QAAQ,KAAK,EAAC;CACtE,MAAM,YAAY,8BAA8B,OAAO,QAAQ,KAAK,EAAC;CACrE,eAAe;CACf,aAAa;CACb,WAAW;CACX,UAAU;CACV,cAAc;CACd,eAAe;CACf,cAAc;CACd,aAAa;AACjB;;;;;AAMA,SAAgB,kBAAkB,SAA8B;CAC5D,OAAO,QAAQ,WAAW,SAAS,QAAQ,KAAK,GAAG,OAAO,KAAK;AACnE"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Item, Problem } from '@panneau/protocol';
|
|
2
|
+
import type { Values } from '../values';
|
|
3
|
+
/**
|
|
4
|
+
* The problem details carried by an error: the error itself when it is a problem, or its
|
|
5
|
+
* `problem` property, as on the errors of `@panneau/data`. A custom adapter only needs to throw
|
|
6
|
+
* errors with a `problem` for forms to show validation errors and conflicts.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getProblem(error: unknown): Problem | null;
|
|
9
|
+
/** Whether a value looks like an item of the protocol (`id`, `version`, `abilities`). */
|
|
10
|
+
export declare function isItem(value: unknown): value is Item;
|
|
11
|
+
/**
|
|
12
|
+
* The values of an item: every property except `id`, `version` and `abilities`, or only the
|
|
13
|
+
* given field names.
|
|
14
|
+
*/
|
|
15
|
+
export declare function getItemValues(item: Item, names?: readonly string[]): Values;
|
|
16
|
+
//# sourceMappingURL=problem.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem.d.ts","sourceRoot":"","sources":["../../src/form/problem.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAGvD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAQzD;AAID,yFAAyF;AACzF,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,IAAI,CAWpD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAM3E"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isProblem } from "@panneau/protocol/runtime";
|
|
2
|
+
//#region src/form/problem.ts
|
|
3
|
+
/**
|
|
4
|
+
* The problem details carried by an error: the error itself when it is a problem, or its
|
|
5
|
+
* `problem` property, as on the errors of `@panneau/data`. A custom adapter only needs to throw
|
|
6
|
+
* errors with a `problem` for forms to show validation errors and conflicts.
|
|
7
|
+
*/
|
|
8
|
+
function getProblem(error) {
|
|
9
|
+
if (isProblem(error)) return error;
|
|
10
|
+
if (error !== null && typeof error === "object" && "problem" in error) return isProblem(error.problem) ? error.problem : null;
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
var ITEM_KEYS = /* @__PURE__ */ new Set([
|
|
14
|
+
"id",
|
|
15
|
+
"version",
|
|
16
|
+
"abilities"
|
|
17
|
+
]);
|
|
18
|
+
/** Whether a value looks like an item of the protocol (`id`, `version`, `abilities`). */
|
|
19
|
+
function isItem(value) {
|
|
20
|
+
if (value === null || typeof value !== "object") return false;
|
|
21
|
+
const item = value;
|
|
22
|
+
return typeof item.id === "string" && (typeof item.version === "string" || item.version === null) && item.abilities !== null && typeof item.abilities === "object";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The values of an item: every property except `id`, `version` and `abilities`, or only the
|
|
26
|
+
* given field names.
|
|
27
|
+
*/
|
|
28
|
+
function getItemValues(item, names) {
|
|
29
|
+
return Object.fromEntries(Object.entries(item).filter(([key]) => names !== void 0 ? names.includes(key) : !ITEM_KEYS.has(key)));
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { getItemValues, getProblem, isItem };
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=problem.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem.js","names":[],"sources":["../../src/form/problem.ts"],"sourcesContent":["import type { Item, Problem } from '@panneau/protocol';\nimport { isProblem } from '@panneau/protocol/runtime';\n\nimport type { Values } from '../values';\n\n/**\n * The problem details carried by an error: the error itself when it is a problem, or its\n * `problem` property, as on the errors of `@panneau/data`. A custom adapter only needs to throw\n * errors with a `problem` for forms to show validation errors and conflicts.\n */\nexport function getProblem(error: unknown): Problem | null {\n if (isProblem(error)) {\n return error;\n }\n if (error !== null && typeof error === 'object' && 'problem' in error) {\n return isProblem(error.problem) ? error.problem : null;\n }\n return null;\n}\n\nconst ITEM_KEYS = new Set(['id', 'version', 'abilities']);\n\n/** Whether a value looks like an item of the protocol (`id`, `version`, `abilities`). */\nexport function isItem(value: unknown): value is Item {\n if (value === null || typeof value !== 'object') {\n return false;\n }\n const item = value as Record<string, unknown>;\n return (\n typeof item.id === 'string' &&\n (typeof item.version === 'string' || item.version === null) &&\n item.abilities !== null &&\n typeof item.abilities === 'object'\n );\n}\n\n/**\n * The values of an item: every property except `id`, `version` and `abilities`, or only the\n * given field names.\n */\nexport function getItemValues(item: Item, names?: readonly string[]): Values {\n return Object.fromEntries(\n Object.entries(item).filter(([key]) =>\n names !== undefined ? names.includes(key) : !ITEM_KEYS.has(key),\n ),\n );\n}\n"],"mappings":";;;;;;;AAUA,SAAgB,WAAW,OAAgC;CACvD,IAAI,UAAU,KAAK,GACf,OAAO;CAEX,IAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,aAAa,OAC5D,OAAO,UAAU,MAAM,OAAO,IAAI,MAAM,UAAU;CAEtD,OAAO;AACX;AAEA,IAAM,4BAAY,IAAI,IAAI;CAAC;CAAM;CAAW;AAAW,CAAC;;AAGxD,SAAgB,OAAO,OAA+B;CAClD,IAAI,UAAU,QAAQ,OAAO,UAAU,UACnC,OAAO;CAEX,MAAM,OAAO;CACb,OACI,OAAO,KAAK,OAAO,aAClB,OAAO,KAAK,YAAY,YAAY,KAAK,YAAY,SACtD,KAAK,cAAc,QACnB,OAAO,KAAK,cAAc;AAElC;;;;;AAMA,SAAgB,cAAc,MAAY,OAAmC;CACzE,OAAO,OAAO,YACV,OAAO,QAAQ,IAAI,CAAC,CAAC,QAAQ,CAAC,SAC1B,UAAU,KAAA,IAAY,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,IAAI,GAAG,CAClE,CACJ;AACJ"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Condition } from '@panneau/protocol';
|
|
2
|
+
import type { FormState, GetErrorsOptions } from './form';
|
|
3
|
+
/** The value at a dot path (`title.fr`). */
|
|
4
|
+
export declare function selectValue(state: FormState, path: string): unknown;
|
|
5
|
+
/** Errors at a path, and with `nested` at the paths inside it (`title.fr` for `title`). */
|
|
6
|
+
export declare function selectErrors(state: FormState, path: string, options?: GetErrorsOptions): string[];
|
|
7
|
+
/** The number of paths with errors within the given paths, for counts on tabs and sections. */
|
|
8
|
+
export declare function selectErrorCount(state: FormState, paths: readonly string[]): number;
|
|
9
|
+
/**
|
|
10
|
+
* Whether a field or a layout node is visible, from its `visibleWhen`. For nested fields, pass
|
|
11
|
+
* the path of the object they belong to (`blocks.2`): conditions are relative to it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function selectVisible(state: FormState, target: {
|
|
14
|
+
visibleWhen?: Condition | undefined;
|
|
15
|
+
}, parentPath?: string): boolean;
|
|
16
|
+
//# sourceMappingURL=selectors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"selectors.d.ts","sourceRoot":"","sources":["../../src/form/selectors.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAInD,OAAO,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAE1D,4CAA4C;AAC5C,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEnE;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CACxB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,gBAAqB,GAC/B,MAAM,EAAE,CAKV;AAED,+FAA+F;AAC/F,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAGnF;AAED;;;GAGG;AACH,wBAAgB,aAAa,CACzB,KAAK,EAAE,SAAS,EAChB,MAAM,EAAE;IAAE,WAAW,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;CAAE,EAC/C,UAAU,SAAK,GAChB,OAAO,CAKT"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isPathWithin } from "../values.js";
|
|
2
|
+
import { evaluateCondition, getValue } from "@panneau/protocol/runtime";
|
|
3
|
+
//#region src/form/selectors.ts
|
|
4
|
+
/** The value at a dot path (`title.fr`). */
|
|
5
|
+
function selectValue(state, path) {
|
|
6
|
+
return getValue(state.values, path);
|
|
7
|
+
}
|
|
8
|
+
/** Errors at a path, and with `nested` at the paths inside it (`title.fr` for `title`). */
|
|
9
|
+
function selectErrors(state, path, options = {}) {
|
|
10
|
+
const { nested = false } = options;
|
|
11
|
+
return Object.entries(state.errors).flatMap(([key, messages]) => key === path || nested && isPathWithin(key, path) ? messages : []);
|
|
12
|
+
}
|
|
13
|
+
/** The number of paths with errors within the given paths, for counts on tabs and sections. */
|
|
14
|
+
function selectErrorCount(state, paths) {
|
|
15
|
+
return Object.keys(state.errors).filter((key) => paths.some((path) => isPathWithin(key, path))).length;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether a field or a layout node is visible, from its `visibleWhen`. For nested fields, pass
|
|
19
|
+
* the path of the object they belong to (`blocks.2`): conditions are relative to it.
|
|
20
|
+
*/
|
|
21
|
+
function selectVisible(state, target, parentPath = "") {
|
|
22
|
+
return target.visibleWhen === void 0 || evaluateCondition(target.visibleWhen, getValue(state.values, parentPath));
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { selectErrorCount, selectErrors, selectValue, selectVisible };
|
|
26
|
+
|
|
27
|
+
//# sourceMappingURL=selectors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"selectors.js","names":[],"sources":["../../src/form/selectors.ts"],"sourcesContent":["/*\n * Reads of a form state. React components read the state they subscribed to (`useForm()`, or\n * `useSyncExternalStore(form.subscribe, form.getState)`) through these selectors, not through the\n * form's methods: the React Compiler memoizes calls by their arguments, and a method such as\n * `form.getErrors(path)` reads the store, so its memoized result goes stale. A selector takes the\n * state as an argument, so the compiler calls it again when the state changes.\n */\nimport type { Condition } from '@panneau/protocol';\nimport { evaluateCondition, getValue } from '@panneau/protocol/runtime';\n\nimport { isPathWithin } from '../values';\nimport type { FormState, GetErrorsOptions } from './form';\n\n/** The value at a dot path (`title.fr`). */\nexport function selectValue(state: FormState, path: string): unknown {\n return getValue(state.values, path);\n}\n\n/** Errors at a path, and with `nested` at the paths inside it (`title.fr` for `title`). */\nexport function selectErrors(\n state: FormState,\n path: string,\n options: GetErrorsOptions = {},\n): string[] {\n const { nested = false } = options;\n return Object.entries(state.errors).flatMap(([key, messages]) =>\n key === path || (nested && isPathWithin(key, path)) ? messages : [],\n );\n}\n\n/** The number of paths with errors within the given paths, for counts on tabs and sections. */\nexport function selectErrorCount(state: FormState, paths: readonly string[]): number {\n return Object.keys(state.errors).filter((key) => paths.some((path) => isPathWithin(key, path)))\n .length;\n}\n\n/**\n * Whether a field or a layout node is visible, from its `visibleWhen`. For nested fields, pass\n * the path of the object they belong to (`blocks.2`): conditions are relative to it.\n */\nexport function selectVisible(\n state: FormState,\n target: { visibleWhen?: Condition | undefined },\n parentPath = '',\n): boolean {\n return (\n target.visibleWhen === undefined ||\n evaluateCondition(target.visibleWhen, getValue(state.values, parentPath))\n );\n}\n"],"mappings":";;;;AAcA,SAAgB,YAAY,OAAkB,MAAuB;CACjE,OAAO,SAAS,MAAM,QAAQ,IAAI;AACtC;;AAGA,SAAgB,aACZ,OACA,MACA,UAA4B,CAAC,GACrB;CACR,MAAM,EAAE,SAAS,UAAU;CAC3B,OAAO,OAAO,QAAQ,MAAM,MAAM,CAAC,CAAC,SAAS,CAAC,KAAK,cAC/C,QAAQ,QAAS,UAAU,aAAa,KAAK,IAAI,IAAK,WAAW,CAAA,CACrE;AACJ;;AAGA,SAAgB,iBAAiB,OAAkB,OAAkC;CACjF,OAAO,OAAO,KAAK,MAAM,MAAM,CAAC,CAAC,QAAQ,QAAQ,MAAM,MAAM,SAAS,aAAa,KAAK,IAAI,CAAC,CAAC,CAAC,CAC1F;AACT;;;;;AAMA,SAAgB,cACZ,OACA,QACA,aAAa,IACN;CACP,OACI,OAAO,gBAAgB,KAAA,KACvB,kBAAkB,OAAO,aAAa,SAAS,MAAM,QAAQ,UAAU,CAAC;AAEhF"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Values } from '../values';
|
|
2
|
+
/**
|
|
3
|
+
* Where the values of a form live. The form only reads and writes values through this interface,
|
|
4
|
+
* so a shared store (Yjs, in `@panneau/collab`) can replace the local one. Errors, touched fields
|
|
5
|
+
* and the submit status stay local to each form.
|
|
6
|
+
*/
|
|
7
|
+
export interface FormStore {
|
|
8
|
+
/** The current values. The same object is returned until the values change. */
|
|
9
|
+
getValues(): Values;
|
|
10
|
+
/** Sets the value at a dot path. */
|
|
11
|
+
setValue(path: string, value: unknown): void;
|
|
12
|
+
/** Replaces every value. */
|
|
13
|
+
setValues(values: Values): void;
|
|
14
|
+
/** Calls `listener` after every change. Returns a function that unsubscribes. */
|
|
15
|
+
subscribe(listener: () => void): () => void;
|
|
16
|
+
}
|
|
17
|
+
/** A store that keeps the values in memory. */
|
|
18
|
+
export declare function createLocalStore(initialValues?: Values): FormStore;
|
|
19
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/form/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAY,MAAM,WAAW,CAAC;AAElD;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACtB,+EAA+E;IAC/E,SAAS,IAAI,MAAM,CAAC;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7C,4BAA4B;IAC5B,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,iFAAiF;IACjF,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC/C;AAED,+CAA+C;AAC/C,wBAAgB,gBAAgB,CAAC,aAAa,GAAE,MAAW,GAAG,SAAS,CAqBtE"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { setValue } from "../values.js";
|
|
2
|
+
//#region src/form/store.ts
|
|
3
|
+
/** A store that keeps the values in memory. */
|
|
4
|
+
function createLocalStore(initialValues = {}) {
|
|
5
|
+
let values = initialValues;
|
|
6
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
7
|
+
const notify = () => listeners.forEach((listener) => listener());
|
|
8
|
+
return {
|
|
9
|
+
getValues: () => values,
|
|
10
|
+
setValue(path, value) {
|
|
11
|
+
values = setValue(values, path, value);
|
|
12
|
+
notify();
|
|
13
|
+
},
|
|
14
|
+
setValues(next) {
|
|
15
|
+
values = next;
|
|
16
|
+
notify();
|
|
17
|
+
},
|
|
18
|
+
subscribe(listener) {
|
|
19
|
+
listeners.add(listener);
|
|
20
|
+
return () => {
|
|
21
|
+
listeners.delete(listener);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { createLocalStore };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","names":[],"sources":["../../src/form/store.ts"],"sourcesContent":["import { type Values, setValue } from '../values';\n\n/**\n * Where the values of a form live. The form only reads and writes values through this interface,\n * so a shared store (Yjs, in `@panneau/collab`) can replace the local one. Errors, touched fields\n * and the submit status stay local to each form.\n */\nexport interface FormStore {\n /** The current values. The same object is returned until the values change. */\n getValues(): Values;\n /** Sets the value at a dot path. */\n setValue(path: string, value: unknown): void;\n /** Replaces every value. */\n setValues(values: Values): void;\n /** Calls `listener` after every change. Returns a function that unsubscribes. */\n subscribe(listener: () => void): () => void;\n}\n\n/** A store that keeps the values in memory. */\nexport function createLocalStore(initialValues: Values = {}): FormStore {\n let values = initialValues;\n const listeners = new Set<() => void>();\n const notify = () => listeners.forEach((listener) => listener());\n return {\n getValues: () => values,\n setValue(path, value) {\n values = setValue(values, path, value);\n notify();\n },\n setValues(next) {\n values = next;\n notify();\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n"],"mappings":";;;AAmBA,SAAgB,iBAAiB,gBAAwB,CAAC,GAAc;CACpE,IAAI,SAAS;CACb,MAAM,4BAAY,IAAI,IAAgB;CACtC,MAAM,eAAe,UAAU,SAAS,aAAa,SAAS,CAAC;CAC/D,OAAO;EACH,iBAAiB;EACjB,SAAS,MAAM,OAAO;GAClB,SAAS,SAAS,QAAQ,MAAM,KAAK;GACrC,OAAO;EACX;EACA,UAAU,MAAM;GACZ,SAAS;GACT,OAAO;EACX;EACA,UAAU,UAAU;GAChB,UAAU,IAAI,QAAQ;GACtB,aAAa;IACT,UAAU,OAAO,QAAQ;GAC7B;EACJ;CACJ;AACJ"}
|