Files
clean-architecture-frontend…/src/presentation/forms/use-app-form.ts
T

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);
}