Skip to content

Commit 487a956

Browse files
committed
refactor(core): split useForm internals by concern
1 parent 1757698 commit 487a956

File tree

7 files changed

+1836
-1422
lines changed

7 files changed

+1836
-1422
lines changed
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import type {
2+
ArrayFieldItem,
3+
ArrayFieldName,
4+
FormState,
5+
UseFieldArrayReturn,
6+
ValidationResult,
7+
} from "../types.js";
8+
import { computeFieldDirty, normalizeBooleanArray, normalizeErrorArray } from "./state.js";
9+
10+
type UpdateFormState<T extends Record<string, unknown>> = (
11+
nextState: FormState<T> | ((prev: FormState<T>) => FormState<T>),
12+
) => void;
13+
14+
type AsyncValidatorRef<T extends Record<string, unknown>> = {
15+
current:
16+
| Readonly<{
17+
run: (values: T) => void;
18+
cancel: () => void;
19+
}>
20+
| undefined;
21+
};
22+
23+
type FieldArrayKeysRef<T extends Record<string, unknown>> = {
24+
current: Partial<Record<keyof T, string[]>>;
25+
};
26+
27+
type CounterRef = { current: number };
28+
29+
export function getArrayFieldValues<T extends Record<string, unknown>, K extends ArrayFieldName<T>>(
30+
values: T,
31+
field: K,
32+
): Array<ArrayFieldItem<T, K>> {
33+
const raw = values[field];
34+
if (!Array.isArray(raw)) {
35+
return [];
36+
}
37+
return [...(raw as Array<ArrayFieldItem<T, K>>)];
38+
}
39+
40+
function removeAtIndex<TValue>(value: ReadonlyArray<TValue>, index: number): TValue[] {
41+
const next = [...value];
42+
next.splice(index, 1);
43+
return next;
44+
}
45+
46+
function moveIndex<TValue>(value: ReadonlyArray<TValue>, from: number, to: number): TValue[] {
47+
const next = [...value];
48+
const [moved] = next.splice(from, 1);
49+
if (moved === undefined) {
50+
return next;
51+
}
52+
next.splice(to, 0, moved);
53+
return next;
54+
}
55+
56+
function nextFieldArrayKey(field: string, counterRef: CounterRef): string {
57+
const next = counterRef.current;
58+
counterRef.current += 1;
59+
return `${field}_${next}`;
60+
}
61+
62+
function ensureFieldArrayKeys<T extends Record<string, unknown>>(
63+
field: keyof T,
64+
length: number,
65+
fieldArrayKeysRef: FieldArrayKeysRef<T>,
66+
fieldArrayKeyCounterRef: CounterRef,
67+
): string[] {
68+
const existing = [...(fieldArrayKeysRef.current[field] ?? [])];
69+
70+
if (existing.length > length) {
71+
existing.length = length;
72+
}
73+
while (existing.length < length) {
74+
existing.push(nextFieldArrayKey(String(field), fieldArrayKeyCounterRef));
75+
}
76+
77+
fieldArrayKeysRef.current[field] = existing;
78+
return existing;
79+
}
80+
81+
function appendFieldArrayKey<T extends Record<string, unknown>>(
82+
field: keyof T,
83+
fieldArrayKeysRef: FieldArrayKeysRef<T>,
84+
fieldArrayKeyCounterRef: CounterRef,
85+
): void {
86+
const existing = [...(fieldArrayKeysRef.current[field] ?? [])];
87+
existing.push(nextFieldArrayKey(String(field), fieldArrayKeyCounterRef));
88+
fieldArrayKeysRef.current[field] = existing;
89+
}
90+
91+
function removeFieldArrayKey<T extends Record<string, unknown>>(
92+
field: keyof T,
93+
index: number,
94+
fieldArrayKeysRef: FieldArrayKeysRef<T>,
95+
): void {
96+
const existing = [...(fieldArrayKeysRef.current[field] ?? [])];
97+
if (index < 0 || index >= existing.length) {
98+
return;
99+
}
100+
existing.splice(index, 1);
101+
fieldArrayKeysRef.current[field] = existing;
102+
}
103+
104+
function moveFieldArrayKey<T extends Record<string, unknown>>(
105+
field: keyof T,
106+
from: number,
107+
to: number,
108+
fieldArrayKeysRef: FieldArrayKeysRef<T>,
109+
): void {
110+
const existing = [...(fieldArrayKeysRef.current[field] ?? [])];
111+
if (from < 0 || to < 0 || from >= existing.length || to >= existing.length || from === to) {
112+
return;
113+
}
114+
const [moved] = existing.splice(from, 1);
115+
if (moved === undefined) {
116+
return;
117+
}
118+
existing.splice(to, 0, moved);
119+
fieldArrayKeysRef.current[field] = existing;
120+
}
121+
122+
export function createFieldArrayApi<T extends Record<string, unknown>>(options: {
123+
state: FormState<T>;
124+
validateOnChange: boolean | undefined;
125+
initialValuesRef: { current: T };
126+
fieldArrayKeysRef: FieldArrayKeysRef<T>;
127+
fieldArrayKeyCounterRef: CounterRef;
128+
pendingAsyncValuesRef: { current: T | null };
129+
asyncValidatorRef: AsyncValidatorRef<T>;
130+
updateFormState: UpdateFormState<T>;
131+
isFieldEditableInternal: (
132+
field: keyof T,
133+
source?: Pick<FormState<T>, "disabled" | "fieldDisabled" | "readOnly" | "fieldReadOnly">,
134+
) => boolean;
135+
runSyncValidationFiltered: (
136+
values: T,
137+
source?: Pick<FormState<T>, "disabled" | "fieldDisabled">,
138+
) => ValidationResult<T>;
139+
}): Readonly<{
140+
useFieldArray: <K extends ArrayFieldName<T>>(field: K) => UseFieldArrayReturn<T, K>;
141+
}> {
142+
const useFieldArray = <K extends ArrayFieldName<T>>(field: K): UseFieldArrayReturn<T, K> => {
143+
const fieldKey = field as keyof T;
144+
const values = getArrayFieldValues(options.state.values, field);
145+
const keys = ensureFieldArrayKeys(
146+
fieldKey,
147+
values.length,
148+
options.fieldArrayKeysRef,
149+
options.fieldArrayKeyCounterRef,
150+
);
151+
152+
const append = (item: ArrayFieldItem<T, K>): void => {
153+
options.pendingAsyncValuesRef.current = null;
154+
155+
options.updateFormState((prev) => {
156+
if (prev.isSubmitting || !options.isFieldEditableInternal(fieldKey, prev)) {
157+
return prev;
158+
}
159+
160+
const currentValues = getArrayFieldValues(prev.values, field);
161+
const nextValuesArray = [...currentValues, item];
162+
const nextValues = {
163+
...prev.values,
164+
[field]: nextValuesArray as unknown as T[K],
165+
};
166+
options.pendingAsyncValuesRef.current = nextValues;
167+
168+
const nextTouched = [
169+
...normalizeBooleanArray(prev.touched[fieldKey], currentValues.length, false),
170+
false,
171+
];
172+
const previousDirty = normalizeBooleanArray(
173+
prev.dirty[fieldKey],
174+
currentValues.length,
175+
false,
176+
);
177+
const initialArray = Array.isArray(options.initialValuesRef.current[fieldKey])
178+
? (options.initialValuesRef.current[fieldKey] as ReadonlyArray<unknown>)
179+
: [];
180+
const appendedDirty = !Object.is(item as unknown, initialArray[nextValuesArray.length - 1]);
181+
const nextDirty = [...previousDirty, appendedDirty];
182+
183+
let nextErrors = prev.errors;
184+
if (options.validateOnChange) {
185+
nextErrors = options.runSyncValidationFiltered(nextValues, prev);
186+
} else {
187+
const currentErrors = normalizeErrorArray(prev.errors[fieldKey], currentValues.length);
188+
nextErrors = {
189+
...prev.errors,
190+
[fieldKey]: [...currentErrors, undefined],
191+
};
192+
}
193+
194+
appendFieldArrayKey(fieldKey, options.fieldArrayKeysRef, options.fieldArrayKeyCounterRef);
195+
196+
return {
197+
...prev,
198+
values: nextValues,
199+
errors: nextErrors,
200+
submitError: undefined,
201+
touched: {
202+
...prev.touched,
203+
[fieldKey]: nextTouched,
204+
},
205+
dirty: {
206+
...prev.dirty,
207+
[fieldKey]: nextDirty,
208+
},
209+
};
210+
});
211+
212+
const asyncValues = options.pendingAsyncValuesRef.current;
213+
options.pendingAsyncValuesRef.current = null;
214+
if (options.validateOnChange && options.asyncValidatorRef.current && asyncValues) {
215+
options.asyncValidatorRef.current.run(asyncValues);
216+
}
217+
};
218+
219+
const remove = (index: number): void => {
220+
options.pendingAsyncValuesRef.current = null;
221+
222+
options.updateFormState((prev) => {
223+
if (prev.isSubmitting || !options.isFieldEditableInternal(fieldKey, prev)) {
224+
return prev;
225+
}
226+
227+
const currentValues = getArrayFieldValues(prev.values, field);
228+
if (index < 0 || index >= currentValues.length) {
229+
return prev;
230+
}
231+
232+
const nextValuesArray = removeAtIndex(currentValues, index);
233+
const nextValues = {
234+
...prev.values,
235+
[field]: nextValuesArray as unknown as T[K],
236+
};
237+
options.pendingAsyncValuesRef.current = nextValues;
238+
239+
const nextTouched = removeAtIndex(
240+
normalizeBooleanArray(prev.touched[fieldKey], currentValues.length, false),
241+
index,
242+
);
243+
const nextDirty = removeAtIndex(
244+
normalizeBooleanArray(prev.dirty[fieldKey], currentValues.length, false),
245+
index,
246+
);
247+
248+
let nextErrors = prev.errors;
249+
if (options.validateOnChange) {
250+
nextErrors = options.runSyncValidationFiltered(nextValues, prev);
251+
} else {
252+
const nextErrorArray = removeAtIndex(
253+
normalizeErrorArray(prev.errors[fieldKey], currentValues.length),
254+
index,
255+
);
256+
nextErrors = {
257+
...prev.errors,
258+
[fieldKey]: nextErrorArray,
259+
};
260+
}
261+
262+
removeFieldArrayKey(fieldKey, index, options.fieldArrayKeysRef);
263+
264+
return {
265+
...prev,
266+
values: nextValues,
267+
errors: nextErrors,
268+
submitError: undefined,
269+
touched: {
270+
...prev.touched,
271+
[fieldKey]: nextTouched,
272+
},
273+
dirty: {
274+
...prev.dirty,
275+
[fieldKey]: nextDirty,
276+
},
277+
};
278+
});
279+
280+
const asyncValues = options.pendingAsyncValuesRef.current;
281+
options.pendingAsyncValuesRef.current = null;
282+
if (options.validateOnChange && options.asyncValidatorRef.current && asyncValues) {
283+
options.asyncValidatorRef.current.run(asyncValues);
284+
}
285+
};
286+
287+
const move = (from: number, to: number): void => {
288+
options.pendingAsyncValuesRef.current = null;
289+
290+
options.updateFormState((prev) => {
291+
if (prev.isSubmitting || !options.isFieldEditableInternal(fieldKey, prev)) {
292+
return prev;
293+
}
294+
295+
const currentValues = getArrayFieldValues(prev.values, field);
296+
if (
297+
from < 0 ||
298+
to < 0 ||
299+
from >= currentValues.length ||
300+
to >= currentValues.length ||
301+
from === to
302+
) {
303+
return prev;
304+
}
305+
306+
const nextValuesArray = moveIndex(currentValues, from, to);
307+
const nextValues = {
308+
...prev.values,
309+
[field]: nextValuesArray as unknown as T[K],
310+
};
311+
options.pendingAsyncValuesRef.current = nextValues;
312+
313+
const nextTouched = moveIndex(
314+
normalizeBooleanArray(prev.touched[fieldKey], currentValues.length, false),
315+
from,
316+
to,
317+
);
318+
const nextDirty = moveIndex(
319+
normalizeBooleanArray(prev.dirty[fieldKey], currentValues.length, false),
320+
from,
321+
to,
322+
);
323+
const nextErrorArray = moveIndex(
324+
normalizeErrorArray(prev.errors[fieldKey], currentValues.length),
325+
from,
326+
to,
327+
);
328+
329+
let nextErrors = {
330+
...prev.errors,
331+
[fieldKey]: nextErrorArray,
332+
} as ValidationResult<T>;
333+
if (options.validateOnChange) {
334+
nextErrors = options.runSyncValidationFiltered(nextValues, prev);
335+
}
336+
337+
moveFieldArrayKey(fieldKey, from, to, options.fieldArrayKeysRef);
338+
339+
return {
340+
...prev,
341+
values: nextValues,
342+
errors: nextErrors,
343+
submitError: undefined,
344+
touched: {
345+
...prev.touched,
346+
[fieldKey]: nextTouched,
347+
},
348+
dirty: {
349+
...prev.dirty,
350+
[fieldKey]: nextDirty,
351+
},
352+
};
353+
});
354+
355+
const asyncValues = options.pendingAsyncValuesRef.current;
356+
options.pendingAsyncValuesRef.current = null;
357+
if (options.validateOnChange && options.asyncValidatorRef.current && asyncValues) {
358+
options.asyncValidatorRef.current.run(asyncValues);
359+
}
360+
};
361+
362+
return Object.freeze({
363+
values,
364+
keys,
365+
append,
366+
remove,
367+
move,
368+
});
369+
};
370+
371+
return Object.freeze({
372+
useFieldArray,
373+
});
374+
}

0 commit comments

Comments
 (0)