@voila.dev/effect-form 0.27.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/Field.d.ts +30 -0
- package/dist/Field.d.ts.map +1 -0
- package/dist/Field.js +86 -0
- package/dist/Field.js.map +1 -0
- package/dist/FieldState.d.ts +21 -0
- package/dist/FieldState.d.ts.map +1 -0
- package/dist/FieldState.js +2 -0
- package/dist/FieldState.js.map +1 -0
- package/dist/FormAtoms.d.ts +108 -0
- package/dist/FormAtoms.d.ts.map +1 -0
- package/dist/FormAtoms.js +620 -0
- package/dist/FormAtoms.js.map +1 -0
- package/dist/FormBuilder.d.ts +74 -0
- package/dist/FormBuilder.d.ts.map +1 -0
- package/dist/FormBuilder.js +86 -0
- package/dist/FormBuilder.js.map +1 -0
- package/dist/Mode.d.ts +34 -0
- package/dist/Mode.d.ts.map +1 -0
- package/dist/Mode.js +19 -0
- package/dist/Mode.js.map +1 -0
- package/dist/Path.d.ts +6 -0
- package/dist/Path.d.ts.map +1 -0
- package/dist/Path.js +69 -0
- package/dist/Path.js.map +1 -0
- package/dist/Validation.d.ts +11 -0
- package/dist/Validation.d.ts.map +1 -0
- package/dist/Validation.js +128 -0
- package/dist/Validation.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/dirty.d.ts +3 -0
- package/dist/internal/dirty.d.ts.map +1 -0
- package/dist/internal/dirty.js +89 -0
- package/dist/internal/dirty.js.map +1 -0
- package/package.json +43 -0
- package/src/Field.ts +149 -0
- package/src/FieldState.ts +23 -0
- package/src/FormAtoms.ts +1202 -0
- package/src/FormBuilder.ts +235 -0
- package/src/Mode.ts +63 -0
- package/src/Path.ts +76 -0
- package/src/Validation.ts +167 -0
- package/src/index.ts +13 -0
- package/src/internal/dirty.ts +113 -0
package/src/FormAtoms.ts
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import { pipe } from "effect/Function";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
7
|
+
import * as Atom from "effect/unstable/reactivity/Atom";
|
|
8
|
+
import * as Field from "./Field.ts";
|
|
9
|
+
import * as FormBuilder from "./FormBuilder.ts";
|
|
10
|
+
import {
|
|
11
|
+
recalculateDirtyFieldsForArray,
|
|
12
|
+
recalculateDirtySubtree,
|
|
13
|
+
} from "./internal/dirty.ts";
|
|
14
|
+
import * as Mode from "./Mode.ts";
|
|
15
|
+
import { getNestedValue, isPathOrParentDirty, setNestedValue } from "./Path.ts";
|
|
16
|
+
import * as Validation from "./Validation.ts";
|
|
17
|
+
|
|
18
|
+
export interface FieldAtoms {
|
|
19
|
+
readonly valueAtom: Atom.Writable<unknown, unknown>;
|
|
20
|
+
readonly initialValueAtom: Atom.Atom<unknown>;
|
|
21
|
+
readonly touchedAtom: Atom.Writable<boolean, boolean>;
|
|
22
|
+
readonly errorAtom: Atom.Atom<Option.Option<Validation.ErrorEntry>>;
|
|
23
|
+
readonly isDirtyAtom: Atom.Atom<boolean>;
|
|
24
|
+
readonly validationAtom: Atom.AtomResultFn<unknown, void, Schema.SchemaError>;
|
|
25
|
+
readonly displayErrorAtom: Atom.Atom<Option.Option<string>>;
|
|
26
|
+
readonly fieldValidationCountAtom: Atom.Writable<number, number>;
|
|
27
|
+
readonly shouldValidateAtom: Atom.Atom<boolean>;
|
|
28
|
+
readonly triggerValidationAtom: Atom.Atom<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PublicFieldAtoms<E> {
|
|
32
|
+
readonly value: Atom.Atom<Option.Option<E>>;
|
|
33
|
+
readonly error: Atom.Atom<Option.Option<string>>;
|
|
34
|
+
readonly isDirty: Atom.Atom<boolean>;
|
|
35
|
+
readonly isTouched: Atom.Atom<boolean>;
|
|
36
|
+
readonly isValidating: Atom.Atom<boolean>;
|
|
37
|
+
readonly setValue: Atom.Writable<void, E | ((prev: E) => E)>;
|
|
38
|
+
readonly setTouched: Atom.Writable<void, boolean>;
|
|
39
|
+
readonly validate: Atom.Writable<void, void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FormAtomsConfig<
|
|
43
|
+
TFields extends Field.FieldsRecord,
|
|
44
|
+
R,
|
|
45
|
+
A,
|
|
46
|
+
E,
|
|
47
|
+
SubmitArgs = void,
|
|
48
|
+
> {
|
|
49
|
+
readonly runtime: Atom.AtomRuntime<R, any>;
|
|
50
|
+
readonly formBuilder: FormBuilder.FormBuilder<TFields, R>;
|
|
51
|
+
readonly mode?: Mode.FormMode;
|
|
52
|
+
readonly reactivityKeys?:
|
|
53
|
+
| ReadonlyArray<unknown>
|
|
54
|
+
| Readonly<Record<string, ReadonlyArray<unknown>>>
|
|
55
|
+
| undefined;
|
|
56
|
+
readonly onSubmit: (
|
|
57
|
+
args: SubmitArgs,
|
|
58
|
+
ctx: {
|
|
59
|
+
readonly decoded: Field.DecodedFromFields<TFields>;
|
|
60
|
+
readonly encoded: Field.EncodedFromFields<TFields>;
|
|
61
|
+
readonly get: Atom.FnContext;
|
|
62
|
+
},
|
|
63
|
+
) => A | Effect.Effect<A, E, R>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type FieldRefs<TFields extends Field.FieldsRecord> = {
|
|
67
|
+
readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S>
|
|
68
|
+
? FormBuilder.FieldRef<Schema.Codec.Encoded<S>>
|
|
69
|
+
: TFields[K] extends Field.ArrayFieldDef<any, infer S>
|
|
70
|
+
? FormBuilder.FieldRef<ReadonlyArray<Schema.Codec.Encoded<S>>>
|
|
71
|
+
: never;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export interface FormAtoms<
|
|
75
|
+
TFields extends Field.FieldsRecord,
|
|
76
|
+
R,
|
|
77
|
+
A = void,
|
|
78
|
+
E = never,
|
|
79
|
+
SubmitArgs = void,
|
|
80
|
+
> {
|
|
81
|
+
readonly stateAtom: Atom.Writable<
|
|
82
|
+
Option.Option<FormBuilder.FormState<TFields>>,
|
|
83
|
+
Option.Option<FormBuilder.FormState<TFields>>
|
|
84
|
+
>;
|
|
85
|
+
readonly errorsAtom: Atom.Writable<
|
|
86
|
+
Map<string, Validation.ErrorEntry>,
|
|
87
|
+
Map<string, Validation.ErrorEntry>
|
|
88
|
+
>;
|
|
89
|
+
readonly rootErrorAtom: Atom.Atom<Option.Option<string>>;
|
|
90
|
+
readonly valuesAtom: Atom.Atom<
|
|
91
|
+
Option.Option<Field.EncodedFromFields<TFields>>
|
|
92
|
+
>;
|
|
93
|
+
readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>;
|
|
94
|
+
readonly isDirtyAtom: Atom.Atom<boolean>;
|
|
95
|
+
readonly submitCountAtom: Atom.Atom<number>;
|
|
96
|
+
readonly validationCountAtom: Atom.Atom<number>;
|
|
97
|
+
readonly lastSubmittedValuesAtom: Atom.Atom<
|
|
98
|
+
Option.Option<FormBuilder.SubmittedValues<TFields>>
|
|
99
|
+
>;
|
|
100
|
+
readonly changedSinceSubmitFieldsAtom: Atom.Atom<ReadonlySet<string>>;
|
|
101
|
+
readonly hasChangedSinceSubmitAtom: Atom.Atom<boolean>;
|
|
102
|
+
|
|
103
|
+
readonly submitAtom: Atom.AtomResultFn<SubmitArgs, A, E | Schema.SchemaError>;
|
|
104
|
+
readonly validateAtom: Atom.AtomResultFn<void, void, never>;
|
|
105
|
+
|
|
106
|
+
readonly combinedSchema: Schema.Codec<
|
|
107
|
+
Field.DecodedFromFields<TFields>,
|
|
108
|
+
Field.EncodedFromFields<TFields>,
|
|
109
|
+
R
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
readonly fieldRefs: FieldRefs<TFields>;
|
|
113
|
+
|
|
114
|
+
readonly getOrCreateValidationAtom: (
|
|
115
|
+
fieldPath: string,
|
|
116
|
+
schema: Schema.Top,
|
|
117
|
+
) => Atom.AtomResultFn<unknown, void, Schema.SchemaError>;
|
|
118
|
+
|
|
119
|
+
readonly getOrCreateFieldAtoms: (
|
|
120
|
+
fieldPath: string,
|
|
121
|
+
schema: Schema.Top,
|
|
122
|
+
) => FieldAtoms;
|
|
123
|
+
|
|
124
|
+
readonly resetValidationAtoms: (ctx: {
|
|
125
|
+
set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void;
|
|
126
|
+
}) => void;
|
|
127
|
+
|
|
128
|
+
readonly operations: FormOperations<TFields>;
|
|
129
|
+
|
|
130
|
+
readonly resetAtom: Atom.Writable<void, void>;
|
|
131
|
+
readonly revertToLastSubmitAtom: Atom.Writable<void, void>;
|
|
132
|
+
readonly setValuesAtom: Atom.Writable<Field.EncodedFromFields<TFields>>;
|
|
133
|
+
|
|
134
|
+
readonly getFieldAtoms: <S>(
|
|
135
|
+
field: FormBuilder.FieldRef<S>,
|
|
136
|
+
) => PublicFieldAtoms<S>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Root anchor atom for the form's dependency graph.
|
|
140
|
+
* Mount this atom to keep all form state alive even when field components unmount.
|
|
141
|
+
*
|
|
142
|
+
* Useful for:
|
|
143
|
+
* - Multi-step wizards where steps unmount but state should persist
|
|
144
|
+
* - Conditional fields (toggles) where state should survive visibility changes
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```tsx
|
|
148
|
+
* // Keep form state alive at wizard root level
|
|
149
|
+
* function Wizard() {
|
|
150
|
+
* useAtomMount(step1Form.mount)
|
|
151
|
+
* useAtomMount(step2Form.mount)
|
|
152
|
+
* return currentStep === 1 ? <Step1 /> : <Step2 />
|
|
153
|
+
* }
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
readonly autoSubmitAtom: Atom.Atom<void>;
|
|
157
|
+
readonly onBlurSubmitAtom: Atom.Writable<void, void>;
|
|
158
|
+
|
|
159
|
+
readonly mountAtom: Atom.Atom<void>;
|
|
160
|
+
|
|
161
|
+
readonly keepAliveActiveAtom: Atom.Writable<boolean, boolean>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface FormOperations<TFields extends Field.FieldsRecord> {
|
|
165
|
+
readonly createInitialState: (
|
|
166
|
+
defaultValues: Field.EncodedFromFields<TFields>,
|
|
167
|
+
) => FormBuilder.FormState<TFields>;
|
|
168
|
+
|
|
169
|
+
readonly createResetState: (
|
|
170
|
+
state: FormBuilder.FormState<TFields>,
|
|
171
|
+
) => FormBuilder.FormState<TFields>;
|
|
172
|
+
|
|
173
|
+
readonly createSubmitState: (
|
|
174
|
+
state: FormBuilder.FormState<TFields>,
|
|
175
|
+
) => FormBuilder.FormState<TFields>;
|
|
176
|
+
|
|
177
|
+
readonly setFieldValue: (
|
|
178
|
+
state: FormBuilder.FormState<TFields>,
|
|
179
|
+
fieldPath: string,
|
|
180
|
+
value: unknown,
|
|
181
|
+
) => FormBuilder.FormState<TFields>;
|
|
182
|
+
|
|
183
|
+
readonly setFormValues: (
|
|
184
|
+
state: FormBuilder.FormState<TFields>,
|
|
185
|
+
values: Field.EncodedFromFields<TFields>,
|
|
186
|
+
) => FormBuilder.FormState<TFields>;
|
|
187
|
+
|
|
188
|
+
readonly setFieldTouched: (
|
|
189
|
+
state: FormBuilder.FormState<TFields>,
|
|
190
|
+
fieldPath: string,
|
|
191
|
+
touched: boolean,
|
|
192
|
+
) => FormBuilder.FormState<TFields>;
|
|
193
|
+
|
|
194
|
+
readonly appendArrayItem: (
|
|
195
|
+
state: FormBuilder.FormState<TFields>,
|
|
196
|
+
arrayPath: string,
|
|
197
|
+
itemSchema: Schema.Top,
|
|
198
|
+
value?: unknown,
|
|
199
|
+
) => FormBuilder.FormState<TFields>;
|
|
200
|
+
|
|
201
|
+
readonly removeArrayItem: (
|
|
202
|
+
state: FormBuilder.FormState<TFields>,
|
|
203
|
+
arrayPath: string,
|
|
204
|
+
index: number,
|
|
205
|
+
) => FormBuilder.FormState<TFields>;
|
|
206
|
+
|
|
207
|
+
readonly swapArrayItems: (
|
|
208
|
+
state: FormBuilder.FormState<TFields>,
|
|
209
|
+
arrayPath: string,
|
|
210
|
+
indexA: number,
|
|
211
|
+
indexB: number,
|
|
212
|
+
) => FormBuilder.FormState<TFields>;
|
|
213
|
+
|
|
214
|
+
readonly moveArrayItem: (
|
|
215
|
+
state: FormBuilder.FormState<TFields>,
|
|
216
|
+
arrayPath: string,
|
|
217
|
+
fromIndex: number,
|
|
218
|
+
toIndex: number,
|
|
219
|
+
) => FormBuilder.FormState<TFields>;
|
|
220
|
+
|
|
221
|
+
readonly revertToLastSubmit: (
|
|
222
|
+
state: FormBuilder.FormState<TFields>,
|
|
223
|
+
) => FormBuilder.FormState<TFields>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const getStateOrThrow = <TFields extends Field.FieldsRecord>(
|
|
227
|
+
state: Option.Option<FormBuilder.FormState<TFields>>,
|
|
228
|
+
fieldPath: string,
|
|
229
|
+
): FormBuilder.FormState<TFields> => {
|
|
230
|
+
if (Option.isNone(state)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Field "${fieldPath}" was read before the form was initialized. ` +
|
|
233
|
+
"Form state does not exist until initialization: render your fields inside " +
|
|
234
|
+
"<form.Initialize defaultValues={...}> (React/Solid), or set the form state before reading field atoms. " +
|
|
235
|
+
`See the "Basic Form Setup" section of the README.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return state.value;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export const make = <
|
|
242
|
+
TFields extends Field.FieldsRecord,
|
|
243
|
+
R,
|
|
244
|
+
A,
|
|
245
|
+
E,
|
|
246
|
+
SubmitArgs = void,
|
|
247
|
+
>(
|
|
248
|
+
config: FormAtomsConfig<TFields, R, A, E, SubmitArgs>,
|
|
249
|
+
): FormAtoms<TFields, R, A, E, SubmitArgs> => {
|
|
250
|
+
const { formBuilder, runtime } = config;
|
|
251
|
+
const { fields } = formBuilder;
|
|
252
|
+
const parsedMode = Mode.parse(config.mode);
|
|
253
|
+
|
|
254
|
+
// A zero (or absent) debounce means "fire synchronously", so only a strictly
|
|
255
|
+
// positive duration goes through `Atom.debounce`.
|
|
256
|
+
const positiveDebounce = (
|
|
257
|
+
input: Duration.Input | null,
|
|
258
|
+
): Duration.Input | null =>
|
|
259
|
+
input !== null && Duration.toMillis(Duration.fromInputUnsafe(input)) > 0
|
|
260
|
+
? input
|
|
261
|
+
: null;
|
|
262
|
+
|
|
263
|
+
const validationDebounce =
|
|
264
|
+
parsedMode.validation === "onChange" && !parsedMode.autoSubmit
|
|
265
|
+
? positiveDebounce(parsedMode.debounce)
|
|
266
|
+
: null;
|
|
267
|
+
const autoSubmitDebounce = positiveDebounce(parsedMode.debounce);
|
|
268
|
+
|
|
269
|
+
const combinedSchema = FormBuilder.buildSchema(formBuilder);
|
|
270
|
+
|
|
271
|
+
const stateAtom = Atom.make(
|
|
272
|
+
Option.none<FormBuilder.FormState<TFields>>(),
|
|
273
|
+
).pipe(Atom.setIdleTTL(0));
|
|
274
|
+
const errorsAtom = Atom.make<Map<string, Validation.ErrorEntry>>(
|
|
275
|
+
new Map(),
|
|
276
|
+
).pipe(Atom.setIdleTTL(0));
|
|
277
|
+
|
|
278
|
+
const rootErrorAtom = Atom.readable((get) => {
|
|
279
|
+
const errors = get(errorsAtom);
|
|
280
|
+
const entry = errors.get("");
|
|
281
|
+
return entry ? Option.some(entry.message) : Option.none<string>();
|
|
282
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
283
|
+
|
|
284
|
+
const valuesAtom = Atom.readable((get) =>
|
|
285
|
+
Option.map(get(stateAtom), (state) => state.values),
|
|
286
|
+
).pipe(Atom.setIdleTTL(0));
|
|
287
|
+
|
|
288
|
+
const dirtyFieldsAtom = Atom.readable((get) =>
|
|
289
|
+
Option.match(get(stateAtom), {
|
|
290
|
+
onNone: () => new Set<string>(),
|
|
291
|
+
onSome: (state) => state.dirtyFields,
|
|
292
|
+
}),
|
|
293
|
+
).pipe(Atom.setIdleTTL(0));
|
|
294
|
+
|
|
295
|
+
const isDirtyAtom = Atom.readable((get) =>
|
|
296
|
+
Option.match(get(stateAtom), {
|
|
297
|
+
onNone: () => false,
|
|
298
|
+
onSome: (state) => state.dirtyFields.size > 0,
|
|
299
|
+
}),
|
|
300
|
+
).pipe(Atom.setIdleTTL(0));
|
|
301
|
+
|
|
302
|
+
const submitCountAtom = Atom.readable((get) =>
|
|
303
|
+
Option.match(get(stateAtom), {
|
|
304
|
+
onNone: () => 0,
|
|
305
|
+
onSome: (state) => state.submitCount,
|
|
306
|
+
}),
|
|
307
|
+
).pipe(Atom.setIdleTTL(0));
|
|
308
|
+
|
|
309
|
+
const validationCountAtom = Atom.readable((get) =>
|
|
310
|
+
Option.match(get(stateAtom), {
|
|
311
|
+
onNone: () => 0,
|
|
312
|
+
onSome: (state) => state.validationCount,
|
|
313
|
+
}),
|
|
314
|
+
).pipe(Atom.setIdleTTL(0));
|
|
315
|
+
|
|
316
|
+
const lastSubmittedValuesAtom = Atom.readable((get) =>
|
|
317
|
+
Option.flatMap(get(stateAtom), (state) => state.lastSubmittedValues),
|
|
318
|
+
).pipe(Atom.setIdleTTL(0));
|
|
319
|
+
|
|
320
|
+
const changedSinceSubmitFieldsAtom = Atom.readable((get) =>
|
|
321
|
+
Option.match(get(stateAtom), {
|
|
322
|
+
onNone: () => new Set<string>(),
|
|
323
|
+
onSome: (state) =>
|
|
324
|
+
Option.match(state.lastSubmittedValues, {
|
|
325
|
+
onNone: () => new Set<string>(),
|
|
326
|
+
onSome: (lastSubmitted) =>
|
|
327
|
+
recalculateDirtySubtree(
|
|
328
|
+
new Set(),
|
|
329
|
+
lastSubmitted.encoded,
|
|
330
|
+
state.values,
|
|
331
|
+
"",
|
|
332
|
+
),
|
|
333
|
+
}),
|
|
334
|
+
}),
|
|
335
|
+
).pipe(Atom.setIdleTTL(0));
|
|
336
|
+
|
|
337
|
+
const hasChangedSinceSubmitAtom = Atom.readable((get) =>
|
|
338
|
+
Option.match(get(stateAtom), {
|
|
339
|
+
onNone: () => false,
|
|
340
|
+
onSome: (state) => {
|
|
341
|
+
if (Option.isNone(state.lastSubmittedValues)) return false;
|
|
342
|
+
if (state.values === state.lastSubmittedValues.value.encoded)
|
|
343
|
+
return false;
|
|
344
|
+
return get(changedSinceSubmitFieldsAtom).size > 0;
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
).pipe(Atom.setIdleTTL(0));
|
|
348
|
+
|
|
349
|
+
const fieldSchemasByKey = new Map<string, Schema.Top>();
|
|
350
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
351
|
+
if (Field.isArrayFieldDef(def)) {
|
|
352
|
+
fieldSchemasByKey.set(key, Schema.Array(def.itemSchema));
|
|
353
|
+
} else if (Field.isFieldDef(def)) {
|
|
354
|
+
fieldSchemasByKey.set(key, def.schema);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Families are keyed by `{ path, schema }` plain objects. `Atom.family` compares
|
|
359
|
+
// keys with structural Hash/Equal, so `path` compares by value while `schema`
|
|
360
|
+
// compares by reference — passing a different schema instance for the same path
|
|
361
|
+
// produces a fresh family entry, preserving the previous schema-identity
|
|
362
|
+
// recreation behavior of the hand-rolled registries.
|
|
363
|
+
interface FieldFamilyKey {
|
|
364
|
+
readonly path: string;
|
|
365
|
+
readonly schema: Schema.Top;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// `Atom.family` does not expose iteration, but `resetValidationAtoms` must visit
|
|
369
|
+
// every created entry. These maps record the latest key per path (paths are
|
|
370
|
+
// bounded by the form's field/array-item structure, so no GC concern) and double
|
|
371
|
+
// as the schema-identity record: `family(key)` with a recorded key returns the
|
|
372
|
+
// memoized entry, or harmlessly recreates a fresh one if it was collected.
|
|
373
|
+
const validationKeys = new Map<string, FieldFamilyKey>();
|
|
374
|
+
const fieldAtomsKeys = new Map<string, FieldFamilyKey>();
|
|
375
|
+
|
|
376
|
+
const validationAtomFamily = Atom.family(
|
|
377
|
+
({
|
|
378
|
+
schema,
|
|
379
|
+
}: FieldFamilyKey): Atom.AtomResultFn<unknown, void, Schema.SchemaError> =>
|
|
380
|
+
runtime
|
|
381
|
+
.fn<unknown>()((value: unknown) =>
|
|
382
|
+
pipe(
|
|
383
|
+
Schema.decodeUnknownEffect(schema)(value) as Effect.Effect<
|
|
384
|
+
unknown,
|
|
385
|
+
Schema.SchemaError,
|
|
386
|
+
R
|
|
387
|
+
>,
|
|
388
|
+
Effect.asVoid,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
.pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<
|
|
392
|
+
unknown,
|
|
393
|
+
void,
|
|
394
|
+
Schema.SchemaError
|
|
395
|
+
>,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const getOrCreateValidationAtom = (
|
|
399
|
+
fieldPath: string,
|
|
400
|
+
schema: Schema.Top,
|
|
401
|
+
): Atom.AtomResultFn<unknown, void, Schema.SchemaError> => {
|
|
402
|
+
const key: FieldFamilyKey = { path: fieldPath, schema };
|
|
403
|
+
validationKeys.set(fieldPath, key);
|
|
404
|
+
return validationAtomFamily(key);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const isDirtyAtomFamily = Atom.family(
|
|
408
|
+
(fieldPath: string): Atom.Atom<boolean> =>
|
|
409
|
+
Atom.readable((get) =>
|
|
410
|
+
isPathOrParentDirty(
|
|
411
|
+
Option.match(get(stateAtom), {
|
|
412
|
+
onNone: () => new Set<string>(),
|
|
413
|
+
onSome: (state) => state.dirtyFields,
|
|
414
|
+
}),
|
|
415
|
+
fieldPath,
|
|
416
|
+
),
|
|
417
|
+
).pipe(Atom.setIdleTTL(0)),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const fieldAtomsFamily = Atom.family(
|
|
421
|
+
({ path: fieldPath, schema }: FieldFamilyKey): FieldAtoms => {
|
|
422
|
+
const valueAtom = Atom.writable(
|
|
423
|
+
(get) =>
|
|
424
|
+
getNestedValue(
|
|
425
|
+
getStateOrThrow(get(stateAtom), fieldPath).values,
|
|
426
|
+
fieldPath,
|
|
427
|
+
),
|
|
428
|
+
(ctx, value) => {
|
|
429
|
+
const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
|
|
430
|
+
ctx.set(
|
|
431
|
+
stateAtom,
|
|
432
|
+
Option.some(
|
|
433
|
+
operations.setFieldValue(currentState, fieldPath, value),
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
},
|
|
437
|
+
).pipe(Atom.setIdleTTL(0));
|
|
438
|
+
|
|
439
|
+
const initialValueAtom = Atom.readable((get) =>
|
|
440
|
+
getNestedValue(
|
|
441
|
+
getStateOrThrow(get(stateAtom), fieldPath).initialValues,
|
|
442
|
+
fieldPath,
|
|
443
|
+
),
|
|
444
|
+
).pipe(Atom.setIdleTTL(0));
|
|
445
|
+
|
|
446
|
+
const touchedAtom = Atom.writable(
|
|
447
|
+
(get) =>
|
|
448
|
+
(getNestedValue(
|
|
449
|
+
getStateOrThrow(get(stateAtom), fieldPath).touched,
|
|
450
|
+
fieldPath,
|
|
451
|
+
) ?? false) as boolean,
|
|
452
|
+
(ctx, value) => {
|
|
453
|
+
const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
|
|
454
|
+
ctx.set(
|
|
455
|
+
stateAtom,
|
|
456
|
+
Option.some({
|
|
457
|
+
...currentState,
|
|
458
|
+
touched: setNestedValue(currentState.touched, fieldPath, value),
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
},
|
|
462
|
+
).pipe(Atom.setIdleTTL(0));
|
|
463
|
+
|
|
464
|
+
const errorAtom = Atom.readable((get) => {
|
|
465
|
+
const errors = get(errorsAtom);
|
|
466
|
+
const entry = errors.get(fieldPath);
|
|
467
|
+
return entry
|
|
468
|
+
? Option.some(entry)
|
|
469
|
+
: Option.none<Validation.ErrorEntry>();
|
|
470
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
471
|
+
|
|
472
|
+
const isDirtyAtom = isDirtyAtomFamily(fieldPath);
|
|
473
|
+
|
|
474
|
+
const validationAtom = getOrCreateValidationAtom(fieldPath, schema);
|
|
475
|
+
|
|
476
|
+
const fieldValidationCountAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
|
|
477
|
+
|
|
478
|
+
const shouldValidateAtom = Atom.readable((get) => {
|
|
479
|
+
if (parsedMode.validation === "onChange") return true;
|
|
480
|
+
if (parsedMode.validation === "onBlur")
|
|
481
|
+
return get(touchedAtom) || get(fieldValidationCountAtom) > 0;
|
|
482
|
+
return (
|
|
483
|
+
get(submitCountAtom) > 0 ||
|
|
484
|
+
get(validationCountAtom) > 0 ||
|
|
485
|
+
get(fieldValidationCountAtom) > 0
|
|
486
|
+
);
|
|
487
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
488
|
+
|
|
489
|
+
const displayErrorAtom = Atom.readable((get) => {
|
|
490
|
+
const validationResult = get(validationAtom);
|
|
491
|
+
const storedError = get(errorAtom);
|
|
492
|
+
const isDirty = get(isDirtyAtom);
|
|
493
|
+
const isTouched = get(touchedAtom);
|
|
494
|
+
const submitCount = get(submitCountAtom);
|
|
495
|
+
|
|
496
|
+
const livePerFieldError = Option.flatMap(
|
|
497
|
+
AsyncResult.error(validationResult),
|
|
498
|
+
Validation.extractFirstError,
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
let validationError: Option.Option<string> = Option.none();
|
|
502
|
+
if (Option.isSome(livePerFieldError)) {
|
|
503
|
+
validationError = livePerFieldError;
|
|
504
|
+
} else if (Option.isSome(storedError)) {
|
|
505
|
+
const shouldHideStoredError =
|
|
506
|
+
storedError.value.source === "field" &&
|
|
507
|
+
(AsyncResult.isSuccess(validationResult) ||
|
|
508
|
+
AsyncResult.isWaiting(validationResult));
|
|
509
|
+
if (!shouldHideStoredError) {
|
|
510
|
+
validationError = Option.some(storedError.value.message);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const validationCount = get(validationCountAtom);
|
|
515
|
+
const fieldValidationCount = get(fieldValidationCountAtom);
|
|
516
|
+
const hasAttemptedValidation =
|
|
517
|
+
submitCount > 0 || validationCount > 0 || fieldValidationCount > 0;
|
|
518
|
+
const shouldShowError =
|
|
519
|
+
parsedMode.validation === "onChange"
|
|
520
|
+
? isDirty || hasAttemptedValidation
|
|
521
|
+
: parsedMode.validation === "onBlur"
|
|
522
|
+
? isTouched || hasAttemptedValidation
|
|
523
|
+
: hasAttemptedValidation;
|
|
524
|
+
|
|
525
|
+
return shouldShowError ? validationError : Option.none();
|
|
526
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
527
|
+
|
|
528
|
+
// Every value change produces a fresh box, so `Atom.debounce` (which drops
|
|
529
|
+
// updates that are `Object.is`-equal to its current value) still emits when
|
|
530
|
+
// the value returns to what it was before the burst of changes.
|
|
531
|
+
const debouncedChangeAtom =
|
|
532
|
+
validationDebounce === null
|
|
533
|
+
? null
|
|
534
|
+
: Atom.debounce(
|
|
535
|
+
Atom.readable((get) => ({ value: get(valueAtom) })).pipe(
|
|
536
|
+
Atom.setIdleTTL(0),
|
|
537
|
+
),
|
|
538
|
+
validationDebounce,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const triggerValidationAtom = Atom.readable((get) => {
|
|
542
|
+
if (debouncedChangeAtom !== null) {
|
|
543
|
+
get.mount(debouncedChangeAtom);
|
|
544
|
+
get.subscribe(debouncedChangeAtom, (change) => {
|
|
545
|
+
if (!get.once(shouldValidateAtom)) return;
|
|
546
|
+
get.set(validationAtom, change.value);
|
|
547
|
+
});
|
|
548
|
+
} else {
|
|
549
|
+
let lastValue = get.once(valueAtom);
|
|
550
|
+
get.subscribe(valueAtom, (newValue) => {
|
|
551
|
+
if (newValue === lastValue) return;
|
|
552
|
+
lastValue = newValue;
|
|
553
|
+
if (!get.once(shouldValidateAtom)) return;
|
|
554
|
+
get.set(validationAtom, newValue);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (parsedMode.validation === "onBlur") {
|
|
559
|
+
get.subscribe(touchedAtom, (isTouched) => {
|
|
560
|
+
if (isTouched) {
|
|
561
|
+
const currentValue = get.once(valueAtom);
|
|
562
|
+
get.set(validationAtom, currentValue);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
valueAtom,
|
|
570
|
+
initialValueAtom,
|
|
571
|
+
touchedAtom,
|
|
572
|
+
errorAtom,
|
|
573
|
+
isDirtyAtom,
|
|
574
|
+
validationAtom,
|
|
575
|
+
fieldValidationCountAtom,
|
|
576
|
+
displayErrorAtom,
|
|
577
|
+
shouldValidateAtom,
|
|
578
|
+
triggerValidationAtom,
|
|
579
|
+
};
|
|
580
|
+
},
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const getOrCreateFieldAtoms = (
|
|
584
|
+
fieldPath: string,
|
|
585
|
+
schema: Schema.Top,
|
|
586
|
+
): FieldAtoms => {
|
|
587
|
+
const key: FieldFamilyKey = { path: fieldPath, schema };
|
|
588
|
+
fieldAtomsKeys.set(fieldPath, key);
|
|
589
|
+
return fieldAtomsFamily(key);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const resetValidationAtoms = (ctx: {
|
|
593
|
+
set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void;
|
|
594
|
+
}) => {
|
|
595
|
+
for (const key of validationKeys.values()) {
|
|
596
|
+
ctx.set(validationAtomFamily(key), Atom.Reset);
|
|
597
|
+
}
|
|
598
|
+
for (const key of fieldAtomsKeys.values()) {
|
|
599
|
+
ctx.set(fieldAtomsFamily(key).fieldValidationCountAtom, 0);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const submitAtom = runtime
|
|
604
|
+
.fn<SubmitArgs>()(
|
|
605
|
+
(args, get) =>
|
|
606
|
+
Effect.gen(function* () {
|
|
607
|
+
const state = get(stateAtom);
|
|
608
|
+
if (Option.isNone(state)) {
|
|
609
|
+
return yield* Effect.die(
|
|
610
|
+
new Error(
|
|
611
|
+
"submit was called before the form was initialized — mount " +
|
|
612
|
+
"<form.Initialize defaultValues={...}> before submitting. " +
|
|
613
|
+
`See the "Basic Form Setup" section of the README.`,
|
|
614
|
+
),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
const values = state.value.values;
|
|
618
|
+
get.set(errorsAtom, new Map());
|
|
619
|
+
const decoded = yield* pipe(
|
|
620
|
+
Schema.decodeUnknownEffect(combinedSchema)(values, {
|
|
621
|
+
errors: "all",
|
|
622
|
+
}) as Effect.Effect<
|
|
623
|
+
Field.DecodedFromFields<TFields>,
|
|
624
|
+
Schema.SchemaError,
|
|
625
|
+
R
|
|
626
|
+
>,
|
|
627
|
+
Effect.tapError((parseError) =>
|
|
628
|
+
Effect.sync(() => {
|
|
629
|
+
const routedErrors =
|
|
630
|
+
Validation.routeErrorsWithSource(parseError);
|
|
631
|
+
get.set(errorsAtom, routedErrors);
|
|
632
|
+
// Rebase onto the latest state so edits made during the in-flight
|
|
633
|
+
// async decode are preserved instead of clobbered by the snapshot.
|
|
634
|
+
const latest = get(stateAtom);
|
|
635
|
+
const base = Option.isSome(latest) ? latest.value : state.value;
|
|
636
|
+
get.set(
|
|
637
|
+
stateAtom,
|
|
638
|
+
Option.some(operations.createSubmitState(base)),
|
|
639
|
+
);
|
|
640
|
+
}),
|
|
641
|
+
),
|
|
642
|
+
);
|
|
643
|
+
// Rebase onto the latest state so a field edit made while the async
|
|
644
|
+
// decode was running is not silently reverted to the pre-submit snapshot.
|
|
645
|
+
const latestState = get(stateAtom);
|
|
646
|
+
const baseState = Option.isSome(latestState)
|
|
647
|
+
? latestState.value
|
|
648
|
+
: state.value;
|
|
649
|
+
const submitState = operations.createSubmitState(baseState);
|
|
650
|
+
get.set(stateAtom, Option.some(submitState));
|
|
651
|
+
const result = config.onSubmit(args, {
|
|
652
|
+
decoded,
|
|
653
|
+
encoded: values,
|
|
654
|
+
get,
|
|
655
|
+
});
|
|
656
|
+
const output = Effect.isEffect(result)
|
|
657
|
+
? yield* result as Effect.Effect<A, E, R>
|
|
658
|
+
: (result as A);
|
|
659
|
+
// Only record the values as "last submitted" once onSubmit has
|
|
660
|
+
// succeeded. A failed onSubmit must not be reported as a successful
|
|
661
|
+
// submit, otherwise revertToLastSubmit / hasChangedSinceSubmit would
|
|
662
|
+
// treat unsaved, failed values as persisted.
|
|
663
|
+
const afterSubmit = get(stateAtom);
|
|
664
|
+
if (Option.isSome(afterSubmit)) {
|
|
665
|
+
get.set(
|
|
666
|
+
stateAtom,
|
|
667
|
+
Option.some({
|
|
668
|
+
...afterSubmit.value,
|
|
669
|
+
lastSubmittedValues: Option.some({ encoded: values, decoded }),
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
return output;
|
|
674
|
+
}),
|
|
675
|
+
config.reactivityKeys
|
|
676
|
+
? { reactivityKeys: config.reactivityKeys }
|
|
677
|
+
: undefined,
|
|
678
|
+
)
|
|
679
|
+
.pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<
|
|
680
|
+
SubmitArgs,
|
|
681
|
+
A,
|
|
682
|
+
E | Schema.SchemaError
|
|
683
|
+
>;
|
|
684
|
+
|
|
685
|
+
const validateAtom = runtime
|
|
686
|
+
.fn<void>()((_: void, get) =>
|
|
687
|
+
Effect.gen(function* () {
|
|
688
|
+
const state = get(stateAtom);
|
|
689
|
+
if (Option.isNone(state)) return;
|
|
690
|
+
const values = state.value.values;
|
|
691
|
+
get.set(errorsAtom, new Map());
|
|
692
|
+
yield* pipe(
|
|
693
|
+
Schema.decodeUnknownEffect(combinedSchema)(values, {
|
|
694
|
+
errors: "all",
|
|
695
|
+
}) as Effect.Effect<
|
|
696
|
+
Field.DecodedFromFields<TFields>,
|
|
697
|
+
Schema.SchemaError,
|
|
698
|
+
R
|
|
699
|
+
>,
|
|
700
|
+
Effect.catchTag("SchemaError", (parseError) =>
|
|
701
|
+
Effect.sync(() => {
|
|
702
|
+
const routedErrors = Validation.routeErrorsWithSource(parseError);
|
|
703
|
+
get.set(errorsAtom, routedErrors);
|
|
704
|
+
}),
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
const currentState = get(stateAtom);
|
|
708
|
+
if (Option.isSome(currentState)) {
|
|
709
|
+
get.set(
|
|
710
|
+
stateAtom,
|
|
711
|
+
Option.some({
|
|
712
|
+
...currentState.value,
|
|
713
|
+
validationCount: currentState.value.validationCount + 1,
|
|
714
|
+
}),
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
}),
|
|
718
|
+
)
|
|
719
|
+
.pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<void, void, never>;
|
|
720
|
+
|
|
721
|
+
const fieldRefs = Object.fromEntries(
|
|
722
|
+
Object.keys(fields).map((key) => [key, FormBuilder.makeFieldRef(key)]),
|
|
723
|
+
) as FieldRefs<TFields>;
|
|
724
|
+
|
|
725
|
+
const operations: FormOperations<TFields> = {
|
|
726
|
+
createInitialState: (defaultValues) => ({
|
|
727
|
+
values: defaultValues,
|
|
728
|
+
initialValues: defaultValues,
|
|
729
|
+
lastSubmittedValues: Option.none(),
|
|
730
|
+
touched: Field.createTouchedRecord(fields, false) as {
|
|
731
|
+
readonly [K in keyof TFields]: boolean;
|
|
732
|
+
},
|
|
733
|
+
submitCount: 0,
|
|
734
|
+
validationCount: 0,
|
|
735
|
+
dirtyFields: new Set(),
|
|
736
|
+
}),
|
|
737
|
+
|
|
738
|
+
createResetState: (state) => ({
|
|
739
|
+
values: state.initialValues,
|
|
740
|
+
initialValues: state.initialValues,
|
|
741
|
+
lastSubmittedValues: Option.none(),
|
|
742
|
+
touched: Field.createTouchedRecord(fields, false) as {
|
|
743
|
+
readonly [K in keyof TFields]: boolean;
|
|
744
|
+
},
|
|
745
|
+
submitCount: 0,
|
|
746
|
+
validationCount: 0,
|
|
747
|
+
dirtyFields: new Set(),
|
|
748
|
+
}),
|
|
749
|
+
|
|
750
|
+
createSubmitState: (state) => ({
|
|
751
|
+
...state,
|
|
752
|
+
touched: Field.createTouchedRecord(fields, true) as {
|
|
753
|
+
readonly [K in keyof TFields]: boolean;
|
|
754
|
+
},
|
|
755
|
+
submitCount: state.submitCount + 1,
|
|
756
|
+
}),
|
|
757
|
+
|
|
758
|
+
setFieldValue: (state, fieldPath, value) => {
|
|
759
|
+
const newValues = setNestedValue(state.values, fieldPath, value);
|
|
760
|
+
const newDirtyFields = recalculateDirtySubtree(
|
|
761
|
+
state.dirtyFields,
|
|
762
|
+
state.initialValues,
|
|
763
|
+
newValues,
|
|
764
|
+
fieldPath,
|
|
765
|
+
);
|
|
766
|
+
return {
|
|
767
|
+
...state,
|
|
768
|
+
values: newValues as Field.EncodedFromFields<TFields>,
|
|
769
|
+
dirtyFields: newDirtyFields,
|
|
770
|
+
};
|
|
771
|
+
},
|
|
772
|
+
|
|
773
|
+
setFormValues: (state, values) => {
|
|
774
|
+
const newDirtyFields = recalculateDirtySubtree(
|
|
775
|
+
state.dirtyFields,
|
|
776
|
+
state.initialValues,
|
|
777
|
+
values,
|
|
778
|
+
"",
|
|
779
|
+
);
|
|
780
|
+
return {
|
|
781
|
+
...state,
|
|
782
|
+
values,
|
|
783
|
+
dirtyFields: newDirtyFields,
|
|
784
|
+
};
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
setFieldTouched: (state, fieldPath, touched) => ({
|
|
788
|
+
...state,
|
|
789
|
+
touched: setNestedValue(state.touched, fieldPath, touched) as {
|
|
790
|
+
readonly [K in keyof TFields]: boolean;
|
|
791
|
+
},
|
|
792
|
+
}),
|
|
793
|
+
|
|
794
|
+
appendArrayItem: (state, arrayPath, itemSchema, value) => {
|
|
795
|
+
const newItem = value ?? Field.getDefaultFromSchema(itemSchema);
|
|
796
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
797
|
+
[]) as ReadonlyArray<unknown>;
|
|
798
|
+
const newItems = [...currentItems, newItem];
|
|
799
|
+
return {
|
|
800
|
+
...state,
|
|
801
|
+
values: setNestedValue(
|
|
802
|
+
state.values,
|
|
803
|
+
arrayPath,
|
|
804
|
+
newItems,
|
|
805
|
+
) as Field.EncodedFromFields<TFields>,
|
|
806
|
+
dirtyFields: recalculateDirtyFieldsForArray(
|
|
807
|
+
state.dirtyFields,
|
|
808
|
+
state.initialValues,
|
|
809
|
+
arrayPath,
|
|
810
|
+
newItems,
|
|
811
|
+
),
|
|
812
|
+
};
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
removeArrayItem: (state, arrayPath, index) => {
|
|
816
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
817
|
+
[]) as ReadonlyArray<unknown>;
|
|
818
|
+
const newItems = currentItems.filter((_, i) => i !== index);
|
|
819
|
+
return {
|
|
820
|
+
...state,
|
|
821
|
+
values: setNestedValue(
|
|
822
|
+
state.values,
|
|
823
|
+
arrayPath,
|
|
824
|
+
newItems,
|
|
825
|
+
) as Field.EncodedFromFields<TFields>,
|
|
826
|
+
dirtyFields: recalculateDirtyFieldsForArray(
|
|
827
|
+
state.dirtyFields,
|
|
828
|
+
state.initialValues,
|
|
829
|
+
arrayPath,
|
|
830
|
+
newItems,
|
|
831
|
+
),
|
|
832
|
+
};
|
|
833
|
+
},
|
|
834
|
+
|
|
835
|
+
swapArrayItems: (state, arrayPath, indexA, indexB) => {
|
|
836
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
837
|
+
[]) as ReadonlyArray<unknown>;
|
|
838
|
+
if (
|
|
839
|
+
indexA < 0 ||
|
|
840
|
+
indexA >= currentItems.length ||
|
|
841
|
+
indexB < 0 ||
|
|
842
|
+
indexB >= currentItems.length ||
|
|
843
|
+
indexA === indexB
|
|
844
|
+
) {
|
|
845
|
+
return state;
|
|
846
|
+
}
|
|
847
|
+
const newItems = [...currentItems];
|
|
848
|
+
const temp = newItems[indexA];
|
|
849
|
+
newItems[indexA] = newItems[indexB];
|
|
850
|
+
newItems[indexB] = temp;
|
|
851
|
+
return {
|
|
852
|
+
...state,
|
|
853
|
+
values: setNestedValue(
|
|
854
|
+
state.values,
|
|
855
|
+
arrayPath,
|
|
856
|
+
newItems,
|
|
857
|
+
) as Field.EncodedFromFields<TFields>,
|
|
858
|
+
dirtyFields: recalculateDirtyFieldsForArray(
|
|
859
|
+
state.dirtyFields,
|
|
860
|
+
state.initialValues,
|
|
861
|
+
arrayPath,
|
|
862
|
+
newItems,
|
|
863
|
+
),
|
|
864
|
+
};
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
moveArrayItem: (state, arrayPath, fromIndex, toIndex) => {
|
|
868
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
869
|
+
[]) as ReadonlyArray<unknown>;
|
|
870
|
+
if (
|
|
871
|
+
fromIndex < 0 ||
|
|
872
|
+
fromIndex >= currentItems.length ||
|
|
873
|
+
toIndex < 0 ||
|
|
874
|
+
toIndex > currentItems.length ||
|
|
875
|
+
fromIndex === toIndex
|
|
876
|
+
) {
|
|
877
|
+
return state;
|
|
878
|
+
}
|
|
879
|
+
const newItems = [...currentItems];
|
|
880
|
+
const [item] = newItems.splice(fromIndex, 1);
|
|
881
|
+
newItems.splice(toIndex, 0, item);
|
|
882
|
+
return {
|
|
883
|
+
...state,
|
|
884
|
+
values: setNestedValue(
|
|
885
|
+
state.values,
|
|
886
|
+
arrayPath,
|
|
887
|
+
newItems,
|
|
888
|
+
) as Field.EncodedFromFields<TFields>,
|
|
889
|
+
dirtyFields: recalculateDirtyFieldsForArray(
|
|
890
|
+
state.dirtyFields,
|
|
891
|
+
state.initialValues,
|
|
892
|
+
arrayPath,
|
|
893
|
+
newItems,
|
|
894
|
+
),
|
|
895
|
+
};
|
|
896
|
+
},
|
|
897
|
+
|
|
898
|
+
revertToLastSubmit: (state) => {
|
|
899
|
+
if (Option.isNone(state.lastSubmittedValues)) {
|
|
900
|
+
return state;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const lastEncoded = state.lastSubmittedValues.value.encoded;
|
|
904
|
+
if (state.values === lastEncoded) {
|
|
905
|
+
return state;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const newDirtyFields = recalculateDirtySubtree(
|
|
909
|
+
state.dirtyFields,
|
|
910
|
+
state.initialValues,
|
|
911
|
+
lastEncoded,
|
|
912
|
+
"",
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
...state,
|
|
917
|
+
values: lastEncoded,
|
|
918
|
+
dirtyFields: newDirtyFields,
|
|
919
|
+
};
|
|
920
|
+
},
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
const resetAtom = Atom.fnSync<void>()(
|
|
924
|
+
(_: void, get) => {
|
|
925
|
+
const state = get(stateAtom);
|
|
926
|
+
if (Option.isNone(state)) return;
|
|
927
|
+
get.set(stateAtom, Option.some(operations.createResetState(state.value)));
|
|
928
|
+
get.set(errorsAtom, new Map());
|
|
929
|
+
resetValidationAtoms(get);
|
|
930
|
+
get.set(submitAtom, Atom.Reset);
|
|
931
|
+
get.set(validateAtom, Atom.Reset);
|
|
932
|
+
},
|
|
933
|
+
{ initialValue: undefined as void },
|
|
934
|
+
).pipe(Atom.setIdleTTL(0));
|
|
935
|
+
|
|
936
|
+
const revertToLastSubmitAtom = Atom.fnSync<void>()(
|
|
937
|
+
(_: void, get) => {
|
|
938
|
+
const state = get(stateAtom);
|
|
939
|
+
if (Option.isNone(state)) return;
|
|
940
|
+
get.set(
|
|
941
|
+
stateAtom,
|
|
942
|
+
Option.some(operations.revertToLastSubmit(state.value)),
|
|
943
|
+
);
|
|
944
|
+
get.set(errorsAtom, new Map());
|
|
945
|
+
},
|
|
946
|
+
{ initialValue: undefined as void },
|
|
947
|
+
).pipe(Atom.setIdleTTL(0));
|
|
948
|
+
|
|
949
|
+
const setValuesAtom = Atom.writable(
|
|
950
|
+
(get): Field.EncodedFromFields<TFields> =>
|
|
951
|
+
pipe(
|
|
952
|
+
get(stateAtom),
|
|
953
|
+
Option.map((s) => s.values),
|
|
954
|
+
Option.getOrElse(() => undefined as never),
|
|
955
|
+
),
|
|
956
|
+
(ctx, values: Field.EncodedFromFields<TFields>) => {
|
|
957
|
+
const state = ctx.get(stateAtom);
|
|
958
|
+
if (Option.isNone(state)) return;
|
|
959
|
+
ctx.set(
|
|
960
|
+
stateAtom,
|
|
961
|
+
Option.some(operations.setFormValues(state.value, values)),
|
|
962
|
+
);
|
|
963
|
+
ctx.set(errorsAtom, new Map());
|
|
964
|
+
},
|
|
965
|
+
).pipe(Atom.setIdleTTL(0));
|
|
966
|
+
|
|
967
|
+
const setValueFamily = Atom.family(
|
|
968
|
+
(fieldKey: string): Atom.Writable<void, any> =>
|
|
969
|
+
Atom.fnSync<any>()(
|
|
970
|
+
(update, get) => {
|
|
971
|
+
const state = get(stateAtom);
|
|
972
|
+
if (Option.isNone(state)) return;
|
|
973
|
+
|
|
974
|
+
const currentValue = getNestedValue(state.value.values, fieldKey);
|
|
975
|
+
const newValue =
|
|
976
|
+
typeof update === "function" ? update(currentValue) : update;
|
|
977
|
+
|
|
978
|
+
get.set(
|
|
979
|
+
stateAtom,
|
|
980
|
+
Option.some(
|
|
981
|
+
operations.setFieldValue(state.value, fieldKey, newValue),
|
|
982
|
+
),
|
|
983
|
+
);
|
|
984
|
+
// Don't clear errors - display logic handles showing/hiding based on source + validation state
|
|
985
|
+
},
|
|
986
|
+
{ initialValue: undefined as void },
|
|
987
|
+
).pipe(Atom.setIdleTTL(0)),
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
const publicFieldAtomsFamily = Atom.family(
|
|
991
|
+
(fieldKey: string): PublicFieldAtoms<unknown> => {
|
|
992
|
+
const schema = fieldSchemasByKey.get(fieldKey);
|
|
993
|
+
if (!schema) throw new Error(`No schema found for field "${fieldKey}"`);
|
|
994
|
+
|
|
995
|
+
const internal = getOrCreateFieldAtoms(fieldKey, schema);
|
|
996
|
+
|
|
997
|
+
const value = Atom.readable((get) =>
|
|
998
|
+
Option.map(get(stateAtom), (state) =>
|
|
999
|
+
getNestedValue(state.values, fieldKey),
|
|
1000
|
+
),
|
|
1001
|
+
).pipe(Atom.setIdleTTL(0));
|
|
1002
|
+
|
|
1003
|
+
const error = Atom.readable((get) =>
|
|
1004
|
+
Option.match(get(stateAtom), {
|
|
1005
|
+
onNone: () => Option.none<string>(),
|
|
1006
|
+
onSome: () => get(internal.displayErrorAtom),
|
|
1007
|
+
}),
|
|
1008
|
+
).pipe(Atom.setIdleTTL(0));
|
|
1009
|
+
|
|
1010
|
+
const isDirty = isDirtyAtomFamily(fieldKey);
|
|
1011
|
+
|
|
1012
|
+
const isTouched = Atom.readable((get) =>
|
|
1013
|
+
Option.match(get(stateAtom), {
|
|
1014
|
+
onNone: () => false,
|
|
1015
|
+
onSome: (state) =>
|
|
1016
|
+
(getNestedValue(state.touched, fieldKey) ?? false) as boolean,
|
|
1017
|
+
}),
|
|
1018
|
+
).pipe(Atom.setIdleTTL(0));
|
|
1019
|
+
|
|
1020
|
+
const isValidating = Atom.readable((get) =>
|
|
1021
|
+
AsyncResult.isWaiting(get(internal.validationAtom)),
|
|
1022
|
+
).pipe(Atom.setIdleTTL(0));
|
|
1023
|
+
|
|
1024
|
+
const setValueAtom = setValueFamily(fieldKey);
|
|
1025
|
+
|
|
1026
|
+
const setTouchedAtom = Atom.fnSync<boolean>()(
|
|
1027
|
+
(touched, get) => {
|
|
1028
|
+
const state = get(stateAtom);
|
|
1029
|
+
if (Option.isNone(state)) return;
|
|
1030
|
+
get.set(
|
|
1031
|
+
stateAtom,
|
|
1032
|
+
Option.some(
|
|
1033
|
+
operations.setFieldTouched(state.value, fieldKey, touched),
|
|
1034
|
+
),
|
|
1035
|
+
);
|
|
1036
|
+
},
|
|
1037
|
+
{ initialValue: undefined as void },
|
|
1038
|
+
).pipe(Atom.setIdleTTL(0));
|
|
1039
|
+
|
|
1040
|
+
const validateFieldAtom = Atom.fnSync<void>()(
|
|
1041
|
+
(_: void, get) => {
|
|
1042
|
+
const value = get(internal.valueAtom);
|
|
1043
|
+
get.set(internal.validationAtom as Atom.Writable<any, any>, value);
|
|
1044
|
+
get.set(
|
|
1045
|
+
internal.fieldValidationCountAtom,
|
|
1046
|
+
get(internal.fieldValidationCountAtom) + 1,
|
|
1047
|
+
);
|
|
1048
|
+
},
|
|
1049
|
+
{ initialValue: undefined as void },
|
|
1050
|
+
).pipe(Atom.setIdleTTL(0));
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
value,
|
|
1054
|
+
error,
|
|
1055
|
+
isDirty,
|
|
1056
|
+
isTouched,
|
|
1057
|
+
isValidating,
|
|
1058
|
+
setValue: setValueAtom,
|
|
1059
|
+
setTouched: setTouchedAtom,
|
|
1060
|
+
validate: validateFieldAtom,
|
|
1061
|
+
};
|
|
1062
|
+
},
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
const getFieldAtoms = <S>(
|
|
1066
|
+
field: FormBuilder.FieldRef<S>,
|
|
1067
|
+
): PublicFieldAtoms<S> =>
|
|
1068
|
+
publicFieldAtomsFamily(field.key) as PublicFieldAtoms<S>;
|
|
1069
|
+
|
|
1070
|
+
const mountAtom = Atom.readable((get) => {
|
|
1071
|
+
get(stateAtom);
|
|
1072
|
+
get(errorsAtom);
|
|
1073
|
+
get(submitAtom);
|
|
1074
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
1075
|
+
|
|
1076
|
+
const keepAliveActiveAtom = Atom.make(false).pipe(Atom.setIdleTTL(0));
|
|
1077
|
+
|
|
1078
|
+
const autoSubmitAtom: Atom.Atom<void> =
|
|
1079
|
+
parsedMode.autoSubmit && parsedMode.validation === "onChange"
|
|
1080
|
+
? (() => {
|
|
1081
|
+
// Submit requests are funneled through a monotonically increasing counter
|
|
1082
|
+
// so `Atom.debounce` can own the timer lifecycle: every bump restarts the
|
|
1083
|
+
// trailing debounce window, and the subscriber below fires once it lands.
|
|
1084
|
+
const submitRequestAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
|
|
1085
|
+
const debouncedSubmitRequestAtom =
|
|
1086
|
+
autoSubmitDebounce === null
|
|
1087
|
+
? null
|
|
1088
|
+
: Atom.debounce(submitRequestAtom, autoSubmitDebounce);
|
|
1089
|
+
|
|
1090
|
+
return Atom.readable((get) => {
|
|
1091
|
+
const initialState = get.once(stateAtom);
|
|
1092
|
+
let lastValues: unknown = Option.isSome(initialState)
|
|
1093
|
+
? initialState.value.values
|
|
1094
|
+
: null;
|
|
1095
|
+
let pendingChanges = false;
|
|
1096
|
+
let wasSubmitting = false;
|
|
1097
|
+
|
|
1098
|
+
const triggerSubmit = () => {
|
|
1099
|
+
if (AsyncResult.isWaiting(get.once(submitAtom))) {
|
|
1100
|
+
pendingChanges = true;
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
get.set(submitAtom as Atom.Writable<any, any>, undefined);
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
let requestSubmit: () => void;
|
|
1107
|
+
if (debouncedSubmitRequestAtom === null) {
|
|
1108
|
+
requestSubmit = triggerSubmit;
|
|
1109
|
+
} else {
|
|
1110
|
+
get.mount(debouncedSubmitRequestAtom);
|
|
1111
|
+
get.subscribe(debouncedSubmitRequestAtom, () => {
|
|
1112
|
+
triggerSubmit();
|
|
1113
|
+
});
|
|
1114
|
+
requestSubmit = () => {
|
|
1115
|
+
get.set(submitRequestAtom, get.once(submitRequestAtom) + 1);
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
get.subscribe(stateAtom, () => {
|
|
1120
|
+
const state = get.once(stateAtom);
|
|
1121
|
+
if (Option.isNone(state)) return;
|
|
1122
|
+
const currentValues = state.value.values;
|
|
1123
|
+
if (currentValues === lastValues) return;
|
|
1124
|
+
lastValues = currentValues;
|
|
1125
|
+
|
|
1126
|
+
const submitResult = get.once(submitAtom);
|
|
1127
|
+
if (AsyncResult.isWaiting(submitResult)) {
|
|
1128
|
+
pendingChanges = true;
|
|
1129
|
+
} else {
|
|
1130
|
+
requestSubmit();
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
get.subscribe(submitAtom, () => {
|
|
1135
|
+
const result = get.once(submitAtom);
|
|
1136
|
+
const isSubmitting = AsyncResult.isWaiting(result);
|
|
1137
|
+
const justFinished = wasSubmitting && !isSubmitting;
|
|
1138
|
+
// Update wasSubmitting BEFORE triggering a follow-up submit. requestSubmit
|
|
1139
|
+
// (no debounce) synchronously re-enters this subscription with the new
|
|
1140
|
+
// waiting=true state; if we assigned wasSubmitting afterwards we'd clobber
|
|
1141
|
+
// that re-entrant true with the stale false, losing the next change.
|
|
1142
|
+
wasSubmitting = isSubmitting;
|
|
1143
|
+
if (justFinished && pendingChanges) {
|
|
1144
|
+
pendingChanges = false;
|
|
1145
|
+
requestSubmit();
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
1149
|
+
})()
|
|
1150
|
+
: Atom.readable(() => {}).pipe(Atom.setIdleTTL(0));
|
|
1151
|
+
|
|
1152
|
+
const onBlurSubmitAtom: Atom.Writable<void, void> =
|
|
1153
|
+
parsedMode.autoSubmit && parsedMode.validation === "onBlur"
|
|
1154
|
+
? Atom.fnSync<void>()(
|
|
1155
|
+
(_: void, get) => {
|
|
1156
|
+
if (AsyncResult.isWaiting(get(submitAtom))) return;
|
|
1157
|
+
const stateOption = get(stateAtom);
|
|
1158
|
+
if (Option.isNone(stateOption)) return;
|
|
1159
|
+
const { lastSubmittedValues, values } = stateOption.value;
|
|
1160
|
+
if (
|
|
1161
|
+
Option.isSome(lastSubmittedValues) &&
|
|
1162
|
+
values === lastSubmittedValues.value.encoded
|
|
1163
|
+
)
|
|
1164
|
+
return;
|
|
1165
|
+
get.set(submitAtom as Atom.Writable<any, any>, undefined);
|
|
1166
|
+
},
|
|
1167
|
+
{ initialValue: undefined as void },
|
|
1168
|
+
).pipe(Atom.setIdleTTL(0))
|
|
1169
|
+
: Atom.fnSync<void>()((_: void) => {}, {
|
|
1170
|
+
initialValue: undefined as void,
|
|
1171
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
stateAtom,
|
|
1175
|
+
errorsAtom,
|
|
1176
|
+
rootErrorAtom,
|
|
1177
|
+
valuesAtom,
|
|
1178
|
+
dirtyFieldsAtom,
|
|
1179
|
+
isDirtyAtom,
|
|
1180
|
+
submitCountAtom,
|
|
1181
|
+
validationCountAtom,
|
|
1182
|
+
lastSubmittedValuesAtom,
|
|
1183
|
+
changedSinceSubmitFieldsAtom,
|
|
1184
|
+
hasChangedSinceSubmitAtom,
|
|
1185
|
+
submitAtom,
|
|
1186
|
+
validateAtom,
|
|
1187
|
+
combinedSchema,
|
|
1188
|
+
fieldRefs,
|
|
1189
|
+
getOrCreateValidationAtom,
|
|
1190
|
+
getOrCreateFieldAtoms,
|
|
1191
|
+
resetValidationAtoms,
|
|
1192
|
+
operations,
|
|
1193
|
+
resetAtom,
|
|
1194
|
+
revertToLastSubmitAtom,
|
|
1195
|
+
setValuesAtom,
|
|
1196
|
+
getFieldAtoms,
|
|
1197
|
+
autoSubmitAtom,
|
|
1198
|
+
onBlurSubmitAtom,
|
|
1199
|
+
mountAtom,
|
|
1200
|
+
keepAliveActiveAtom,
|
|
1201
|
+
} as FormAtoms<TFields, R, A, E, SubmitArgs>;
|
|
1202
|
+
};
|