254 lines
6.8 KiB
TypeScript
254 lines
6.8 KiB
TypeScript
import {
|
|
useCallback,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type FormEvent,
|
|
} from "react";
|
|
import type { ZodType, ZodIssue } from "zod";
|
|
import {
|
|
useLocale,
|
|
type ParameterlessMessageKey,
|
|
} from "../i18n/index.js";
|
|
|
|
import {
|
|
mapValidationFailureToFields,
|
|
validationMessage,
|
|
type FieldErrors,
|
|
type FieldName,
|
|
type FormResult,
|
|
type FormResultState,
|
|
type FormValues,
|
|
} from "./form-contracts.js";
|
|
|
|
type AppFormOptions<
|
|
Values extends FormValues,
|
|
Command,
|
|
Output,
|
|
> = Readonly<{
|
|
schema: ZodType<Values>;
|
|
defaultValues: Values;
|
|
allowedServerFields: readonly FieldName<Values>[];
|
|
mapToCommand(values: Values): Command;
|
|
submit(command: Command): Promise<FormResult<Output>>;
|
|
resetOnSuccess?: boolean;
|
|
}>;
|
|
|
|
export function useAppForm<
|
|
Values extends FormValues,
|
|
Command,
|
|
Output,
|
|
>(options: AppFormOptions<Values, Command, Output>) {
|
|
const { message } = useLocale();
|
|
const {
|
|
schema,
|
|
defaultValues,
|
|
allowedServerFields,
|
|
mapToCommand,
|
|
submit,
|
|
resetOnSuccess = true,
|
|
} = options;
|
|
const generatedId = useId().replaceAll(":", "");
|
|
const formId = `app-form-${generatedId}`;
|
|
const [values, setValues] = useState<Values>(defaultValues);
|
|
const [initialValues, setInitialValues] = useState<Values>(defaultValues);
|
|
const [touched, setTouched] = useState<ReadonlySet<FieldName<Values>>>(
|
|
() => new Set(),
|
|
);
|
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors<Values>>(
|
|
() => ({} as FieldErrors<Values>),
|
|
);
|
|
const [formErrors, setFormErrors] = useState<readonly string[]>([]);
|
|
const [pending, setPending] = useState(false);
|
|
const [result, setResult] = useState<FormResultState>("idle");
|
|
const pendingRef = useRef<Promise<FormResult<Output>> | null>(null);
|
|
|
|
const dirty = useMemo(
|
|
() => JSON.stringify(values) !== JSON.stringify(initialValues),
|
|
[initialValues, values],
|
|
);
|
|
|
|
const fieldId = useCallback(
|
|
(name: FieldName<Values>) => `${formId}-${name}`,
|
|
[formId],
|
|
);
|
|
|
|
const focusField = useCallback(
|
|
(name: FieldName<Values>) => {
|
|
const field = document.getElementById(fieldId(name));
|
|
if (field instanceof HTMLElement) field.focus();
|
|
},
|
|
[fieldId],
|
|
);
|
|
|
|
const focusFirstError = useCallback(
|
|
(errors: FieldErrors<Values>) => {
|
|
const first = allowedServerFields.find((name) => Boolean(errors[name]));
|
|
if (first) focusField(first);
|
|
},
|
|
[allowedServerFields, focusField],
|
|
);
|
|
|
|
const setValue = useCallback(
|
|
(name: FieldName<Values>, value: Values[FieldName<Values>]) => {
|
|
setValues((current) => ({ ...current, [name]: value }) as Values);
|
|
setFieldErrors((current) => {
|
|
if (!current[name]) return current;
|
|
const next = { ...current };
|
|
delete next[name];
|
|
return next;
|
|
});
|
|
setFormErrors([]);
|
|
setResult("idle");
|
|
},
|
|
[],
|
|
);
|
|
|
|
const field = useCallback(
|
|
(name: FieldName<Values>) => ({
|
|
id: fieldId(name),
|
|
name,
|
|
value: String(values[name] ?? ""),
|
|
onChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
|
setValue(name, event.currentTarget.value as Values[FieldName<Values>]);
|
|
},
|
|
onBlur() {
|
|
setTouched((current) => new Set(current).add(name));
|
|
},
|
|
error: fieldErrors[name],
|
|
"aria-invalid": fieldErrors[name] ? ("true" as const) : undefined,
|
|
}),
|
|
[fieldErrors, fieldId, setValue, values],
|
|
);
|
|
|
|
const reset = useCallback(
|
|
(nextValues: Values = defaultValues) => {
|
|
setValues(nextValues);
|
|
setInitialValues(nextValues);
|
|
setTouched(new Set());
|
|
setFieldErrors({} as FieldErrors<Values>);
|
|
setFormErrors([]);
|
|
setResult("idle");
|
|
},
|
|
[defaultValues],
|
|
);
|
|
|
|
const submitForm = useCallback(
|
|
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
|
|
event?.preventDefault();
|
|
if (pendingRef.current) return pendingRef.current;
|
|
setFieldErrors({} as FieldErrors<Values>);
|
|
setFormErrors([]);
|
|
|
|
const parsed = await schema.safeParseAsync(values);
|
|
if (!parsed.success) {
|
|
const errors = issuesToFieldErrors<Values>(
|
|
parsed.error.issues,
|
|
allowedServerFields,
|
|
message,
|
|
);
|
|
setFieldErrors(errors);
|
|
setFormErrors(
|
|
parsed.error.issues.some(
|
|
(issue) => !allowedServerFields.includes(issue.path[0] as FieldName<Values>),
|
|
)
|
|
? [message("form.validation.configuration")]
|
|
: [],
|
|
);
|
|
setTouched(new Set(allowedServerFields));
|
|
setResult("validation-error");
|
|
focusFirstError(errors);
|
|
return null;
|
|
}
|
|
|
|
const command = mapToCommand(parsed.data);
|
|
setPending(true);
|
|
const execution = submit(command);
|
|
pendingRef.current = execution;
|
|
try {
|
|
const outcome = await execution;
|
|
if (outcome.ok) {
|
|
setResult("success");
|
|
if (resetOnSuccess) {
|
|
setValues(defaultValues);
|
|
setInitialValues(defaultValues);
|
|
setTouched(new Set());
|
|
} else {
|
|
setInitialValues(parsed.data);
|
|
}
|
|
return outcome;
|
|
}
|
|
if (outcome.error.kind === "VALIDATION_REJECTED") {
|
|
const mapped = mapValidationFailureToFields<Values>(
|
|
outcome.error,
|
|
allowedServerFields,
|
|
message,
|
|
);
|
|
setFieldErrors(mapped.fieldErrors);
|
|
setFormErrors(mapped.formErrors);
|
|
setResult("validation-error");
|
|
focusFirstError(mapped.fieldErrors);
|
|
} else if (outcome.error.kind === "CONFLICT") {
|
|
setFormErrors([
|
|
message("form.conflict"),
|
|
]);
|
|
setResult("conflict");
|
|
} else {
|
|
setFormErrors([message("form.unavailable")]);
|
|
setResult("unavailable");
|
|
}
|
|
return outcome;
|
|
} finally {
|
|
pendingRef.current = null;
|
|
setPending(false);
|
|
}
|
|
},
|
|
[
|
|
allowedServerFields,
|
|
defaultValues,
|
|
focusFirstError,
|
|
mapToCommand,
|
|
message,
|
|
resetOnSuccess,
|
|
schema,
|
|
submit,
|
|
values,
|
|
],
|
|
);
|
|
|
|
return Object.freeze({
|
|
formId,
|
|
values,
|
|
dirty,
|
|
touched,
|
|
fieldErrors,
|
|
formErrors,
|
|
pending,
|
|
result,
|
|
field,
|
|
fieldId,
|
|
focusField,
|
|
setValue,
|
|
submitForm,
|
|
reset,
|
|
});
|
|
}
|
|
|
|
function issuesToFieldErrors<Values extends FormValues>(
|
|
issues: readonly ZodIssue[],
|
|
allowedFields: readonly FieldName<Values>[],
|
|
message: (key: ParameterlessMessageKey) => string,
|
|
): FieldErrors<Values> {
|
|
const allowed = new Set<PropertyKey>(allowedFields);
|
|
const errors: Partial<Record<FieldName<Values>, string>> = {};
|
|
for (const issue of issues) {
|
|
const field = issue.path[0];
|
|
if (!allowed.has(field)) continue;
|
|
const name = field as FieldName<Values>;
|
|
errors[name] ??= validationMessage(issue.code, message);
|
|
}
|
|
return Object.freeze(errors);
|
|
}
|