@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
|
@@ -0,0 +1,620 @@
|
|
|
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.js";
|
|
9
|
+
import * as FormBuilder from "./FormBuilder.js";
|
|
10
|
+
import { recalculateDirtyFieldsForArray, recalculateDirtySubtree, } from "./internal/dirty.js";
|
|
11
|
+
import * as Mode from "./Mode.js";
|
|
12
|
+
import { getNestedValue, isPathOrParentDirty, setNestedValue } from "./Path.js";
|
|
13
|
+
import * as Validation from "./Validation.js";
|
|
14
|
+
const getStateOrThrow = (state, fieldPath) => {
|
|
15
|
+
if (Option.isNone(state)) {
|
|
16
|
+
throw new Error(`Field "${fieldPath}" was read before the form was initialized. ` +
|
|
17
|
+
"Form state does not exist until initialization: render your fields inside " +
|
|
18
|
+
"<form.Initialize defaultValues={...}> (React/Solid), or set the form state before reading field atoms. " +
|
|
19
|
+
`See the "Basic Form Setup" section of the README.`);
|
|
20
|
+
}
|
|
21
|
+
return state.value;
|
|
22
|
+
};
|
|
23
|
+
export const make = (config) => {
|
|
24
|
+
const { formBuilder, runtime } = config;
|
|
25
|
+
const { fields } = formBuilder;
|
|
26
|
+
const parsedMode = Mode.parse(config.mode);
|
|
27
|
+
// A zero (or absent) debounce means "fire synchronously", so only a strictly
|
|
28
|
+
// positive duration goes through `Atom.debounce`.
|
|
29
|
+
const positiveDebounce = (input) => input !== null && Duration.toMillis(Duration.fromInputUnsafe(input)) > 0
|
|
30
|
+
? input
|
|
31
|
+
: null;
|
|
32
|
+
const validationDebounce = parsedMode.validation === "onChange" && !parsedMode.autoSubmit
|
|
33
|
+
? positiveDebounce(parsedMode.debounce)
|
|
34
|
+
: null;
|
|
35
|
+
const autoSubmitDebounce = positiveDebounce(parsedMode.debounce);
|
|
36
|
+
const combinedSchema = FormBuilder.buildSchema(formBuilder);
|
|
37
|
+
const stateAtom = Atom.make(Option.none()).pipe(Atom.setIdleTTL(0));
|
|
38
|
+
const errorsAtom = Atom.make(new Map()).pipe(Atom.setIdleTTL(0));
|
|
39
|
+
const rootErrorAtom = Atom.readable((get) => {
|
|
40
|
+
const errors = get(errorsAtom);
|
|
41
|
+
const entry = errors.get("");
|
|
42
|
+
return entry ? Option.some(entry.message) : Option.none();
|
|
43
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
44
|
+
const valuesAtom = Atom.readable((get) => Option.map(get(stateAtom), (state) => state.values)).pipe(Atom.setIdleTTL(0));
|
|
45
|
+
const dirtyFieldsAtom = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
46
|
+
onNone: () => new Set(),
|
|
47
|
+
onSome: (state) => state.dirtyFields,
|
|
48
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
49
|
+
const isDirtyAtom = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
50
|
+
onNone: () => false,
|
|
51
|
+
onSome: (state) => state.dirtyFields.size > 0,
|
|
52
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
53
|
+
const submitCountAtom = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
54
|
+
onNone: () => 0,
|
|
55
|
+
onSome: (state) => state.submitCount,
|
|
56
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
57
|
+
const validationCountAtom = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
58
|
+
onNone: () => 0,
|
|
59
|
+
onSome: (state) => state.validationCount,
|
|
60
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
61
|
+
const lastSubmittedValuesAtom = Atom.readable((get) => Option.flatMap(get(stateAtom), (state) => state.lastSubmittedValues)).pipe(Atom.setIdleTTL(0));
|
|
62
|
+
const changedSinceSubmitFieldsAtom = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
63
|
+
onNone: () => new Set(),
|
|
64
|
+
onSome: (state) => Option.match(state.lastSubmittedValues, {
|
|
65
|
+
onNone: () => new Set(),
|
|
66
|
+
onSome: (lastSubmitted) => recalculateDirtySubtree(new Set(), lastSubmitted.encoded, state.values, ""),
|
|
67
|
+
}),
|
|
68
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
69
|
+
const hasChangedSinceSubmitAtom = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
70
|
+
onNone: () => false,
|
|
71
|
+
onSome: (state) => {
|
|
72
|
+
if (Option.isNone(state.lastSubmittedValues))
|
|
73
|
+
return false;
|
|
74
|
+
if (state.values === state.lastSubmittedValues.value.encoded)
|
|
75
|
+
return false;
|
|
76
|
+
return get(changedSinceSubmitFieldsAtom).size > 0;
|
|
77
|
+
},
|
|
78
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
79
|
+
const fieldSchemasByKey = new Map();
|
|
80
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
81
|
+
if (Field.isArrayFieldDef(def)) {
|
|
82
|
+
fieldSchemasByKey.set(key, Schema.Array(def.itemSchema));
|
|
83
|
+
}
|
|
84
|
+
else if (Field.isFieldDef(def)) {
|
|
85
|
+
fieldSchemasByKey.set(key, def.schema);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// `Atom.family` does not expose iteration, but `resetValidationAtoms` must visit
|
|
89
|
+
// every created entry. These maps record the latest key per path (paths are
|
|
90
|
+
// bounded by the form's field/array-item structure, so no GC concern) and double
|
|
91
|
+
// as the schema-identity record: `family(key)` with a recorded key returns the
|
|
92
|
+
// memoized entry, or harmlessly recreates a fresh one if it was collected.
|
|
93
|
+
const validationKeys = new Map();
|
|
94
|
+
const fieldAtomsKeys = new Map();
|
|
95
|
+
const validationAtomFamily = Atom.family(({ schema, }) => runtime
|
|
96
|
+
.fn()((value) => pipe(Schema.decodeUnknownEffect(schema)(value), Effect.asVoid))
|
|
97
|
+
.pipe(Atom.setIdleTTL(0)));
|
|
98
|
+
const getOrCreateValidationAtom = (fieldPath, schema) => {
|
|
99
|
+
const key = { path: fieldPath, schema };
|
|
100
|
+
validationKeys.set(fieldPath, key);
|
|
101
|
+
return validationAtomFamily(key);
|
|
102
|
+
};
|
|
103
|
+
const isDirtyAtomFamily = Atom.family((fieldPath) => Atom.readable((get) => isPathOrParentDirty(Option.match(get(stateAtom), {
|
|
104
|
+
onNone: () => new Set(),
|
|
105
|
+
onSome: (state) => state.dirtyFields,
|
|
106
|
+
}), fieldPath)).pipe(Atom.setIdleTTL(0)));
|
|
107
|
+
const fieldAtomsFamily = Atom.family(({ path: fieldPath, schema }) => {
|
|
108
|
+
const valueAtom = Atom.writable((get) => getNestedValue(getStateOrThrow(get(stateAtom), fieldPath).values, fieldPath), (ctx, value) => {
|
|
109
|
+
const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
|
|
110
|
+
ctx.set(stateAtom, Option.some(operations.setFieldValue(currentState, fieldPath, value)));
|
|
111
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
112
|
+
const initialValueAtom = Atom.readable((get) => getNestedValue(getStateOrThrow(get(stateAtom), fieldPath).initialValues, fieldPath)).pipe(Atom.setIdleTTL(0));
|
|
113
|
+
const touchedAtom = Atom.writable((get) => (getNestedValue(getStateOrThrow(get(stateAtom), fieldPath).touched, fieldPath) ?? false), (ctx, value) => {
|
|
114
|
+
const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
|
|
115
|
+
ctx.set(stateAtom, Option.some({
|
|
116
|
+
...currentState,
|
|
117
|
+
touched: setNestedValue(currentState.touched, fieldPath, value),
|
|
118
|
+
}));
|
|
119
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
120
|
+
const errorAtom = Atom.readable((get) => {
|
|
121
|
+
const errors = get(errorsAtom);
|
|
122
|
+
const entry = errors.get(fieldPath);
|
|
123
|
+
return entry
|
|
124
|
+
? Option.some(entry)
|
|
125
|
+
: Option.none();
|
|
126
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
127
|
+
const isDirtyAtom = isDirtyAtomFamily(fieldPath);
|
|
128
|
+
const validationAtom = getOrCreateValidationAtom(fieldPath, schema);
|
|
129
|
+
const fieldValidationCountAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
|
|
130
|
+
const shouldValidateAtom = Atom.readable((get) => {
|
|
131
|
+
if (parsedMode.validation === "onChange")
|
|
132
|
+
return true;
|
|
133
|
+
if (parsedMode.validation === "onBlur")
|
|
134
|
+
return get(touchedAtom) || get(fieldValidationCountAtom) > 0;
|
|
135
|
+
return (get(submitCountAtom) > 0 ||
|
|
136
|
+
get(validationCountAtom) > 0 ||
|
|
137
|
+
get(fieldValidationCountAtom) > 0);
|
|
138
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
139
|
+
const displayErrorAtom = Atom.readable((get) => {
|
|
140
|
+
const validationResult = get(validationAtom);
|
|
141
|
+
const storedError = get(errorAtom);
|
|
142
|
+
const isDirty = get(isDirtyAtom);
|
|
143
|
+
const isTouched = get(touchedAtom);
|
|
144
|
+
const submitCount = get(submitCountAtom);
|
|
145
|
+
const livePerFieldError = Option.flatMap(AsyncResult.error(validationResult), Validation.extractFirstError);
|
|
146
|
+
let validationError = Option.none();
|
|
147
|
+
if (Option.isSome(livePerFieldError)) {
|
|
148
|
+
validationError = livePerFieldError;
|
|
149
|
+
}
|
|
150
|
+
else if (Option.isSome(storedError)) {
|
|
151
|
+
const shouldHideStoredError = storedError.value.source === "field" &&
|
|
152
|
+
(AsyncResult.isSuccess(validationResult) ||
|
|
153
|
+
AsyncResult.isWaiting(validationResult));
|
|
154
|
+
if (!shouldHideStoredError) {
|
|
155
|
+
validationError = Option.some(storedError.value.message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const validationCount = get(validationCountAtom);
|
|
159
|
+
const fieldValidationCount = get(fieldValidationCountAtom);
|
|
160
|
+
const hasAttemptedValidation = submitCount > 0 || validationCount > 0 || fieldValidationCount > 0;
|
|
161
|
+
const shouldShowError = parsedMode.validation === "onChange"
|
|
162
|
+
? isDirty || hasAttemptedValidation
|
|
163
|
+
: parsedMode.validation === "onBlur"
|
|
164
|
+
? isTouched || hasAttemptedValidation
|
|
165
|
+
: hasAttemptedValidation;
|
|
166
|
+
return shouldShowError ? validationError : Option.none();
|
|
167
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
168
|
+
// Every value change produces a fresh box, so `Atom.debounce` (which drops
|
|
169
|
+
// updates that are `Object.is`-equal to its current value) still emits when
|
|
170
|
+
// the value returns to what it was before the burst of changes.
|
|
171
|
+
const debouncedChangeAtom = validationDebounce === null
|
|
172
|
+
? null
|
|
173
|
+
: Atom.debounce(Atom.readable((get) => ({ value: get(valueAtom) })).pipe(Atom.setIdleTTL(0)), validationDebounce);
|
|
174
|
+
const triggerValidationAtom = Atom.readable((get) => {
|
|
175
|
+
if (debouncedChangeAtom !== null) {
|
|
176
|
+
get.mount(debouncedChangeAtom);
|
|
177
|
+
get.subscribe(debouncedChangeAtom, (change) => {
|
|
178
|
+
if (!get.once(shouldValidateAtom))
|
|
179
|
+
return;
|
|
180
|
+
get.set(validationAtom, change.value);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
let lastValue = get.once(valueAtom);
|
|
185
|
+
get.subscribe(valueAtom, (newValue) => {
|
|
186
|
+
if (newValue === lastValue)
|
|
187
|
+
return;
|
|
188
|
+
lastValue = newValue;
|
|
189
|
+
if (!get.once(shouldValidateAtom))
|
|
190
|
+
return;
|
|
191
|
+
get.set(validationAtom, newValue);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (parsedMode.validation === "onBlur") {
|
|
195
|
+
get.subscribe(touchedAtom, (isTouched) => {
|
|
196
|
+
if (isTouched) {
|
|
197
|
+
const currentValue = get.once(valueAtom);
|
|
198
|
+
get.set(validationAtom, currentValue);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
203
|
+
return {
|
|
204
|
+
valueAtom,
|
|
205
|
+
initialValueAtom,
|
|
206
|
+
touchedAtom,
|
|
207
|
+
errorAtom,
|
|
208
|
+
isDirtyAtom,
|
|
209
|
+
validationAtom,
|
|
210
|
+
fieldValidationCountAtom,
|
|
211
|
+
displayErrorAtom,
|
|
212
|
+
shouldValidateAtom,
|
|
213
|
+
triggerValidationAtom,
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
const getOrCreateFieldAtoms = (fieldPath, schema) => {
|
|
217
|
+
const key = { path: fieldPath, schema };
|
|
218
|
+
fieldAtomsKeys.set(fieldPath, key);
|
|
219
|
+
return fieldAtomsFamily(key);
|
|
220
|
+
};
|
|
221
|
+
const resetValidationAtoms = (ctx) => {
|
|
222
|
+
for (const key of validationKeys.values()) {
|
|
223
|
+
ctx.set(validationAtomFamily(key), Atom.Reset);
|
|
224
|
+
}
|
|
225
|
+
for (const key of fieldAtomsKeys.values()) {
|
|
226
|
+
ctx.set(fieldAtomsFamily(key).fieldValidationCountAtom, 0);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const submitAtom = runtime
|
|
230
|
+
.fn()((args, get) => Effect.gen(function* () {
|
|
231
|
+
const state = get(stateAtom);
|
|
232
|
+
if (Option.isNone(state)) {
|
|
233
|
+
return yield* Effect.die(new Error("submit was called before the form was initialized — mount " +
|
|
234
|
+
"<form.Initialize defaultValues={...}> before submitting. " +
|
|
235
|
+
`See the "Basic Form Setup" section of the README.`));
|
|
236
|
+
}
|
|
237
|
+
const values = state.value.values;
|
|
238
|
+
get.set(errorsAtom, new Map());
|
|
239
|
+
const decoded = yield* pipe(Schema.decodeUnknownEffect(combinedSchema)(values, {
|
|
240
|
+
errors: "all",
|
|
241
|
+
}), Effect.tapError((parseError) => Effect.sync(() => {
|
|
242
|
+
const routedErrors = Validation.routeErrorsWithSource(parseError);
|
|
243
|
+
get.set(errorsAtom, routedErrors);
|
|
244
|
+
// Rebase onto the latest state so edits made during the in-flight
|
|
245
|
+
// async decode are preserved instead of clobbered by the snapshot.
|
|
246
|
+
const latest = get(stateAtom);
|
|
247
|
+
const base = Option.isSome(latest) ? latest.value : state.value;
|
|
248
|
+
get.set(stateAtom, Option.some(operations.createSubmitState(base)));
|
|
249
|
+
})));
|
|
250
|
+
// Rebase onto the latest state so a field edit made while the async
|
|
251
|
+
// decode was running is not silently reverted to the pre-submit snapshot.
|
|
252
|
+
const latestState = get(stateAtom);
|
|
253
|
+
const baseState = Option.isSome(latestState)
|
|
254
|
+
? latestState.value
|
|
255
|
+
: state.value;
|
|
256
|
+
const submitState = operations.createSubmitState(baseState);
|
|
257
|
+
get.set(stateAtom, Option.some(submitState));
|
|
258
|
+
const result = config.onSubmit(args, {
|
|
259
|
+
decoded,
|
|
260
|
+
encoded: values,
|
|
261
|
+
get,
|
|
262
|
+
});
|
|
263
|
+
const output = Effect.isEffect(result)
|
|
264
|
+
? yield* result
|
|
265
|
+
: result;
|
|
266
|
+
// Only record the values as "last submitted" once onSubmit has
|
|
267
|
+
// succeeded. A failed onSubmit must not be reported as a successful
|
|
268
|
+
// submit, otherwise revertToLastSubmit / hasChangedSinceSubmit would
|
|
269
|
+
// treat unsaved, failed values as persisted.
|
|
270
|
+
const afterSubmit = get(stateAtom);
|
|
271
|
+
if (Option.isSome(afterSubmit)) {
|
|
272
|
+
get.set(stateAtom, Option.some({
|
|
273
|
+
...afterSubmit.value,
|
|
274
|
+
lastSubmittedValues: Option.some({ encoded: values, decoded }),
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
return output;
|
|
278
|
+
}), config.reactivityKeys
|
|
279
|
+
? { reactivityKeys: config.reactivityKeys }
|
|
280
|
+
: undefined)
|
|
281
|
+
.pipe(Atom.setIdleTTL(0));
|
|
282
|
+
const validateAtom = runtime
|
|
283
|
+
.fn()((_, get) => Effect.gen(function* () {
|
|
284
|
+
const state = get(stateAtom);
|
|
285
|
+
if (Option.isNone(state))
|
|
286
|
+
return;
|
|
287
|
+
const values = state.value.values;
|
|
288
|
+
get.set(errorsAtom, new Map());
|
|
289
|
+
yield* pipe(Schema.decodeUnknownEffect(combinedSchema)(values, {
|
|
290
|
+
errors: "all",
|
|
291
|
+
}), Effect.catchTag("SchemaError", (parseError) => Effect.sync(() => {
|
|
292
|
+
const routedErrors = Validation.routeErrorsWithSource(parseError);
|
|
293
|
+
get.set(errorsAtom, routedErrors);
|
|
294
|
+
})));
|
|
295
|
+
const currentState = get(stateAtom);
|
|
296
|
+
if (Option.isSome(currentState)) {
|
|
297
|
+
get.set(stateAtom, Option.some({
|
|
298
|
+
...currentState.value,
|
|
299
|
+
validationCount: currentState.value.validationCount + 1,
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
}))
|
|
303
|
+
.pipe(Atom.setIdleTTL(0));
|
|
304
|
+
const fieldRefs = Object.fromEntries(Object.keys(fields).map((key) => [key, FormBuilder.makeFieldRef(key)]));
|
|
305
|
+
const operations = {
|
|
306
|
+
createInitialState: (defaultValues) => ({
|
|
307
|
+
values: defaultValues,
|
|
308
|
+
initialValues: defaultValues,
|
|
309
|
+
lastSubmittedValues: Option.none(),
|
|
310
|
+
touched: Field.createTouchedRecord(fields, false),
|
|
311
|
+
submitCount: 0,
|
|
312
|
+
validationCount: 0,
|
|
313
|
+
dirtyFields: new Set(),
|
|
314
|
+
}),
|
|
315
|
+
createResetState: (state) => ({
|
|
316
|
+
values: state.initialValues,
|
|
317
|
+
initialValues: state.initialValues,
|
|
318
|
+
lastSubmittedValues: Option.none(),
|
|
319
|
+
touched: Field.createTouchedRecord(fields, false),
|
|
320
|
+
submitCount: 0,
|
|
321
|
+
validationCount: 0,
|
|
322
|
+
dirtyFields: new Set(),
|
|
323
|
+
}),
|
|
324
|
+
createSubmitState: (state) => ({
|
|
325
|
+
...state,
|
|
326
|
+
touched: Field.createTouchedRecord(fields, true),
|
|
327
|
+
submitCount: state.submitCount + 1,
|
|
328
|
+
}),
|
|
329
|
+
setFieldValue: (state, fieldPath, value) => {
|
|
330
|
+
const newValues = setNestedValue(state.values, fieldPath, value);
|
|
331
|
+
const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, newValues, fieldPath);
|
|
332
|
+
return {
|
|
333
|
+
...state,
|
|
334
|
+
values: newValues,
|
|
335
|
+
dirtyFields: newDirtyFields,
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
setFormValues: (state, values) => {
|
|
339
|
+
const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, values, "");
|
|
340
|
+
return {
|
|
341
|
+
...state,
|
|
342
|
+
values,
|
|
343
|
+
dirtyFields: newDirtyFields,
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
setFieldTouched: (state, fieldPath, touched) => ({
|
|
347
|
+
...state,
|
|
348
|
+
touched: setNestedValue(state.touched, fieldPath, touched),
|
|
349
|
+
}),
|
|
350
|
+
appendArrayItem: (state, arrayPath, itemSchema, value) => {
|
|
351
|
+
const newItem = value ?? Field.getDefaultFromSchema(itemSchema);
|
|
352
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
353
|
+
[]);
|
|
354
|
+
const newItems = [...currentItems, newItem];
|
|
355
|
+
return {
|
|
356
|
+
...state,
|
|
357
|
+
values: setNestedValue(state.values, arrayPath, newItems),
|
|
358
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
removeArrayItem: (state, arrayPath, index) => {
|
|
362
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
363
|
+
[]);
|
|
364
|
+
const newItems = currentItems.filter((_, i) => i !== index);
|
|
365
|
+
return {
|
|
366
|
+
...state,
|
|
367
|
+
values: setNestedValue(state.values, arrayPath, newItems),
|
|
368
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
swapArrayItems: (state, arrayPath, indexA, indexB) => {
|
|
372
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
373
|
+
[]);
|
|
374
|
+
if (indexA < 0 ||
|
|
375
|
+
indexA >= currentItems.length ||
|
|
376
|
+
indexB < 0 ||
|
|
377
|
+
indexB >= currentItems.length ||
|
|
378
|
+
indexA === indexB) {
|
|
379
|
+
return state;
|
|
380
|
+
}
|
|
381
|
+
const newItems = [...currentItems];
|
|
382
|
+
const temp = newItems[indexA];
|
|
383
|
+
newItems[indexA] = newItems[indexB];
|
|
384
|
+
newItems[indexB] = temp;
|
|
385
|
+
return {
|
|
386
|
+
...state,
|
|
387
|
+
values: setNestedValue(state.values, arrayPath, newItems),
|
|
388
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
moveArrayItem: (state, arrayPath, fromIndex, toIndex) => {
|
|
392
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ??
|
|
393
|
+
[]);
|
|
394
|
+
if (fromIndex < 0 ||
|
|
395
|
+
fromIndex >= currentItems.length ||
|
|
396
|
+
toIndex < 0 ||
|
|
397
|
+
toIndex > currentItems.length ||
|
|
398
|
+
fromIndex === toIndex) {
|
|
399
|
+
return state;
|
|
400
|
+
}
|
|
401
|
+
const newItems = [...currentItems];
|
|
402
|
+
const [item] = newItems.splice(fromIndex, 1);
|
|
403
|
+
newItems.splice(toIndex, 0, item);
|
|
404
|
+
return {
|
|
405
|
+
...state,
|
|
406
|
+
values: setNestedValue(state.values, arrayPath, newItems),
|
|
407
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
revertToLastSubmit: (state) => {
|
|
411
|
+
if (Option.isNone(state.lastSubmittedValues)) {
|
|
412
|
+
return state;
|
|
413
|
+
}
|
|
414
|
+
const lastEncoded = state.lastSubmittedValues.value.encoded;
|
|
415
|
+
if (state.values === lastEncoded) {
|
|
416
|
+
return state;
|
|
417
|
+
}
|
|
418
|
+
const newDirtyFields = recalculateDirtySubtree(state.dirtyFields, state.initialValues, lastEncoded, "");
|
|
419
|
+
return {
|
|
420
|
+
...state,
|
|
421
|
+
values: lastEncoded,
|
|
422
|
+
dirtyFields: newDirtyFields,
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
const resetAtom = Atom.fnSync()((_, get) => {
|
|
427
|
+
const state = get(stateAtom);
|
|
428
|
+
if (Option.isNone(state))
|
|
429
|
+
return;
|
|
430
|
+
get.set(stateAtom, Option.some(operations.createResetState(state.value)));
|
|
431
|
+
get.set(errorsAtom, new Map());
|
|
432
|
+
resetValidationAtoms(get);
|
|
433
|
+
get.set(submitAtom, Atom.Reset);
|
|
434
|
+
get.set(validateAtom, Atom.Reset);
|
|
435
|
+
}, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
|
|
436
|
+
const revertToLastSubmitAtom = Atom.fnSync()((_, get) => {
|
|
437
|
+
const state = get(stateAtom);
|
|
438
|
+
if (Option.isNone(state))
|
|
439
|
+
return;
|
|
440
|
+
get.set(stateAtom, Option.some(operations.revertToLastSubmit(state.value)));
|
|
441
|
+
get.set(errorsAtom, new Map());
|
|
442
|
+
}, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
|
|
443
|
+
const setValuesAtom = Atom.writable((get) => pipe(get(stateAtom), Option.map((s) => s.values), Option.getOrElse(() => undefined)), (ctx, values) => {
|
|
444
|
+
const state = ctx.get(stateAtom);
|
|
445
|
+
if (Option.isNone(state))
|
|
446
|
+
return;
|
|
447
|
+
ctx.set(stateAtom, Option.some(operations.setFormValues(state.value, values)));
|
|
448
|
+
ctx.set(errorsAtom, new Map());
|
|
449
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
450
|
+
const setValueFamily = Atom.family((fieldKey) => Atom.fnSync()((update, get) => {
|
|
451
|
+
const state = get(stateAtom);
|
|
452
|
+
if (Option.isNone(state))
|
|
453
|
+
return;
|
|
454
|
+
const currentValue = getNestedValue(state.value.values, fieldKey);
|
|
455
|
+
const newValue = typeof update === "function" ? update(currentValue) : update;
|
|
456
|
+
get.set(stateAtom, Option.some(operations.setFieldValue(state.value, fieldKey, newValue)));
|
|
457
|
+
// Don't clear errors - display logic handles showing/hiding based on source + validation state
|
|
458
|
+
}, { initialValue: undefined }).pipe(Atom.setIdleTTL(0)));
|
|
459
|
+
const publicFieldAtomsFamily = Atom.family((fieldKey) => {
|
|
460
|
+
const schema = fieldSchemasByKey.get(fieldKey);
|
|
461
|
+
if (!schema)
|
|
462
|
+
throw new Error(`No schema found for field "${fieldKey}"`);
|
|
463
|
+
const internal = getOrCreateFieldAtoms(fieldKey, schema);
|
|
464
|
+
const value = Atom.readable((get) => Option.map(get(stateAtom), (state) => getNestedValue(state.values, fieldKey))).pipe(Atom.setIdleTTL(0));
|
|
465
|
+
const error = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
466
|
+
onNone: () => Option.none(),
|
|
467
|
+
onSome: () => get(internal.displayErrorAtom),
|
|
468
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
469
|
+
const isDirty = isDirtyAtomFamily(fieldKey);
|
|
470
|
+
const isTouched = Atom.readable((get) => Option.match(get(stateAtom), {
|
|
471
|
+
onNone: () => false,
|
|
472
|
+
onSome: (state) => (getNestedValue(state.touched, fieldKey) ?? false),
|
|
473
|
+
})).pipe(Atom.setIdleTTL(0));
|
|
474
|
+
const isValidating = Atom.readable((get) => AsyncResult.isWaiting(get(internal.validationAtom))).pipe(Atom.setIdleTTL(0));
|
|
475
|
+
const setValueAtom = setValueFamily(fieldKey);
|
|
476
|
+
const setTouchedAtom = Atom.fnSync()((touched, get) => {
|
|
477
|
+
const state = get(stateAtom);
|
|
478
|
+
if (Option.isNone(state))
|
|
479
|
+
return;
|
|
480
|
+
get.set(stateAtom, Option.some(operations.setFieldTouched(state.value, fieldKey, touched)));
|
|
481
|
+
}, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
|
|
482
|
+
const validateFieldAtom = Atom.fnSync()((_, get) => {
|
|
483
|
+
const value = get(internal.valueAtom);
|
|
484
|
+
get.set(internal.validationAtom, value);
|
|
485
|
+
get.set(internal.fieldValidationCountAtom, get(internal.fieldValidationCountAtom) + 1);
|
|
486
|
+
}, { initialValue: undefined }).pipe(Atom.setIdleTTL(0));
|
|
487
|
+
return {
|
|
488
|
+
value,
|
|
489
|
+
error,
|
|
490
|
+
isDirty,
|
|
491
|
+
isTouched,
|
|
492
|
+
isValidating,
|
|
493
|
+
setValue: setValueAtom,
|
|
494
|
+
setTouched: setTouchedAtom,
|
|
495
|
+
validate: validateFieldAtom,
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
const getFieldAtoms = (field) => publicFieldAtomsFamily(field.key);
|
|
499
|
+
const mountAtom = Atom.readable((get) => {
|
|
500
|
+
get(stateAtom);
|
|
501
|
+
get(errorsAtom);
|
|
502
|
+
get(submitAtom);
|
|
503
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
504
|
+
const keepAliveActiveAtom = Atom.make(false).pipe(Atom.setIdleTTL(0));
|
|
505
|
+
const autoSubmitAtom = parsedMode.autoSubmit && parsedMode.validation === "onChange"
|
|
506
|
+
? (() => {
|
|
507
|
+
// Submit requests are funneled through a monotonically increasing counter
|
|
508
|
+
// so `Atom.debounce` can own the timer lifecycle: every bump restarts the
|
|
509
|
+
// trailing debounce window, and the subscriber below fires once it lands.
|
|
510
|
+
const submitRequestAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
|
|
511
|
+
const debouncedSubmitRequestAtom = autoSubmitDebounce === null
|
|
512
|
+
? null
|
|
513
|
+
: Atom.debounce(submitRequestAtom, autoSubmitDebounce);
|
|
514
|
+
return Atom.readable((get) => {
|
|
515
|
+
const initialState = get.once(stateAtom);
|
|
516
|
+
let lastValues = Option.isSome(initialState)
|
|
517
|
+
? initialState.value.values
|
|
518
|
+
: null;
|
|
519
|
+
let pendingChanges = false;
|
|
520
|
+
let wasSubmitting = false;
|
|
521
|
+
const triggerSubmit = () => {
|
|
522
|
+
if (AsyncResult.isWaiting(get.once(submitAtom))) {
|
|
523
|
+
pendingChanges = true;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
get.set(submitAtom, undefined);
|
|
527
|
+
};
|
|
528
|
+
let requestSubmit;
|
|
529
|
+
if (debouncedSubmitRequestAtom === null) {
|
|
530
|
+
requestSubmit = triggerSubmit;
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
get.mount(debouncedSubmitRequestAtom);
|
|
534
|
+
get.subscribe(debouncedSubmitRequestAtom, () => {
|
|
535
|
+
triggerSubmit();
|
|
536
|
+
});
|
|
537
|
+
requestSubmit = () => {
|
|
538
|
+
get.set(submitRequestAtom, get.once(submitRequestAtom) + 1);
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
get.subscribe(stateAtom, () => {
|
|
542
|
+
const state = get.once(stateAtom);
|
|
543
|
+
if (Option.isNone(state))
|
|
544
|
+
return;
|
|
545
|
+
const currentValues = state.value.values;
|
|
546
|
+
if (currentValues === lastValues)
|
|
547
|
+
return;
|
|
548
|
+
lastValues = currentValues;
|
|
549
|
+
const submitResult = get.once(submitAtom);
|
|
550
|
+
if (AsyncResult.isWaiting(submitResult)) {
|
|
551
|
+
pendingChanges = true;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
requestSubmit();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
get.subscribe(submitAtom, () => {
|
|
558
|
+
const result = get.once(submitAtom);
|
|
559
|
+
const isSubmitting = AsyncResult.isWaiting(result);
|
|
560
|
+
const justFinished = wasSubmitting && !isSubmitting;
|
|
561
|
+
// Update wasSubmitting BEFORE triggering a follow-up submit. requestSubmit
|
|
562
|
+
// (no debounce) synchronously re-enters this subscription with the new
|
|
563
|
+
// waiting=true state; if we assigned wasSubmitting afterwards we'd clobber
|
|
564
|
+
// that re-entrant true with the stale false, losing the next change.
|
|
565
|
+
wasSubmitting = isSubmitting;
|
|
566
|
+
if (justFinished && pendingChanges) {
|
|
567
|
+
pendingChanges = false;
|
|
568
|
+
requestSubmit();
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
572
|
+
})()
|
|
573
|
+
: Atom.readable(() => { }).pipe(Atom.setIdleTTL(0));
|
|
574
|
+
const onBlurSubmitAtom = parsedMode.autoSubmit && parsedMode.validation === "onBlur"
|
|
575
|
+
? Atom.fnSync()((_, get) => {
|
|
576
|
+
if (AsyncResult.isWaiting(get(submitAtom)))
|
|
577
|
+
return;
|
|
578
|
+
const stateOption = get(stateAtom);
|
|
579
|
+
if (Option.isNone(stateOption))
|
|
580
|
+
return;
|
|
581
|
+
const { lastSubmittedValues, values } = stateOption.value;
|
|
582
|
+
if (Option.isSome(lastSubmittedValues) &&
|
|
583
|
+
values === lastSubmittedValues.value.encoded)
|
|
584
|
+
return;
|
|
585
|
+
get.set(submitAtom, undefined);
|
|
586
|
+
}, { initialValue: undefined }).pipe(Atom.setIdleTTL(0))
|
|
587
|
+
: Atom.fnSync()((_) => { }, {
|
|
588
|
+
initialValue: undefined,
|
|
589
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
590
|
+
return {
|
|
591
|
+
stateAtom,
|
|
592
|
+
errorsAtom,
|
|
593
|
+
rootErrorAtom,
|
|
594
|
+
valuesAtom,
|
|
595
|
+
dirtyFieldsAtom,
|
|
596
|
+
isDirtyAtom,
|
|
597
|
+
submitCountAtom,
|
|
598
|
+
validationCountAtom,
|
|
599
|
+
lastSubmittedValuesAtom,
|
|
600
|
+
changedSinceSubmitFieldsAtom,
|
|
601
|
+
hasChangedSinceSubmitAtom,
|
|
602
|
+
submitAtom,
|
|
603
|
+
validateAtom,
|
|
604
|
+
combinedSchema,
|
|
605
|
+
fieldRefs,
|
|
606
|
+
getOrCreateValidationAtom,
|
|
607
|
+
getOrCreateFieldAtoms,
|
|
608
|
+
resetValidationAtoms,
|
|
609
|
+
operations,
|
|
610
|
+
resetAtom,
|
|
611
|
+
revertToLastSubmitAtom,
|
|
612
|
+
setValuesAtom,
|
|
613
|
+
getFieldAtoms,
|
|
614
|
+
autoSubmitAtom,
|
|
615
|
+
onBlurSubmitAtom,
|
|
616
|
+
mountAtom,
|
|
617
|
+
keepAliveActiveAtom,
|
|
618
|
+
};
|
|
619
|
+
};
|
|
620
|
+
//# sourceMappingURL=FormAtoms.js.map
|