import { useEffect, useState } from 'react'; import type { ZodErrorLike, ZodTypeLike } from '@douglasneuroinformatics/libjs'; import type { FormContent, FormDataType, FormFields, PartialFormDataType, PartialNullableFormDataType } from '@douglasneuroinformatics/libui-form-types'; import { get, set } from 'lodash-es'; import { twMerge } from 'tailwind-merge'; import type { Promisable } from 'type-fest'; import { useTranslation } from '#hooks'; import { cn } from '#utils'; import { Button } from '../Button/Button.tsx'; import { Heading } from '../Heading/Heading.tsx'; import { Separator } from '../Separator/Separator.tsx'; import { ErrorMessage } from './ErrorMessage.tsx'; import { FieldsComponent } from './FieldsComponent.tsx'; import { getInitialValues } from './utils.ts'; import type { FormErrors } from './types.ts'; type FormSubmitResult = { errorMessage: string; success: false } | { success: true }; type FormSubmitHandler = (data: NoInfer) => Promisable; type FormProps, TData extends TSchema['_output'] = TSchema['_output']> = { [key: `data-${string}`]: unknown; additionalButtons?: { left?: React.ReactNode; right?: React.ReactNode; }; className?: string; content: FormContent; customStyles?: { resetBtn?: string; submitBtn?: string; }; fieldsFooter?: React.ReactNode; id?: string; initialValues?: PartialNullableFormDataType>; onBeforeSubmit?: FormSubmitHandler> | null; onError?: (error: ZodErrorLike) => void; onSubmit: FormSubmitHandler>; preventResetValuesOnReset?: boolean; readOnly?: boolean; resetBtn?: boolean; revalidateOnBlur?: boolean; submitBtnLabel?: string; subscribe?: { onChange: ( values: PartialFormDataType, setValues: React.Dispatch>> ) => Promisable; selector: (values: PartialFormDataType) => unknown; }; suspendWhileSubmitting?: boolean; validationSchema: ZodTypeLike; }; const Form = , TData extends TSchema['_output'] = TSchema['_output']>({ additionalButtons, className, content, customStyles, fieldsFooter, id, initialValues, onBeforeSubmit, onError, onSubmit, preventResetValuesOnReset, readOnly, resetBtn, revalidateOnBlur, submitBtnLabel, subscribe, suspendWhileSubmitting, validationSchema, ...props }: FormProps) => { const { resolvedLanguage, t } = useTranslation('libui'); const [rootErrors, setRootErrors] = useState([]); const [errors, setErrors] = useState>({}); const [values, setValues] = useState>( initialValues ? getInitialValues(initialValues) : {} ); const [isSubmitting, setIsSubmitting] = useState(false); const handleError = (error: ZodErrorLike) => { const fieldErrors: FormErrors = {}; const rootErrors: string[] = []; for (const issue of error.issues) { if (issue.path.length > 0) { const current = get(fieldErrors, issue.path) as string[] | undefined; if (current) { current.push(issue.message); } else { set(fieldErrors, issue.path, [issue.message]); } } else { rootErrors.push(issue.message); } } setErrors(fieldErrors); setRootErrors(rootErrors); if (onError) { onError(error); } }; useEffect(() => { if (!subscribe) { return; } subscribe.onChange(values, setValues); }, [subscribe?.selector(values)]); const reset = () => { setRootErrors([]); setErrors({}); if (!preventResetValuesOnReset) { setValues({}); } }; const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); const result = await validationSchema.safeParseAsync(values); if (!result.success) { console.error(result.error.issues); handleError(result.error); return; } if (onBeforeSubmit) { const beforeSubmitResult = await onBeforeSubmit(result.data); if (beforeSubmitResult && !beforeSubmitResult.success) { setErrors({}); setRootErrors([beforeSubmitResult.errorMessage]); return; } } try { setIsSubmitting(true); const [formSubmitResult] = await Promise.all([ onSubmit(result.data), new Promise((resolve) => { return suspendWhileSubmitting ? setTimeout(resolve, 500) : resolve(); }) ]); if (formSubmitResult && !formSubmitResult.success) { setErrors({}); setRootErrors([formSubmitResult.errorMessage]); return; } reset(); } finally { setIsSubmitting(false); } }; const isGrouped = Array.isArray(content); const revalidate = () => { const hasErrors = Object.keys(errors).length > 0 || rootErrors.length; if (hasErrors) { validationSchema .safeParseAsync(values) .then((result) => { if (!result.success) { handleError(result.error); } }) .catch(console.error); } }; useEffect(() => { setErrors({}); setRootErrors([]); }, [resolvedLanguage]); const isSuspended = Boolean(suspendWhileSubmitting && isSubmitting); return (
void handleSubmit(event)} {...props} > {isSubmitting &&
} {isGrouped ? ( content.map((fieldGroup, i) => { return ( <>
{fieldGroup.title && ( {fieldGroup.title} )} {fieldGroup.description && (

{fieldGroup.description}

)}
} readOnly={readOnly} setErrors={setErrors} setValues={setValues} values={values} />
); }) ) : ( )} {Boolean(rootErrors.length) && (
)} {fieldsFooter}
{additionalButtons?.left} {/** Note - aria-label is used for testing in downstream packages */} {resetBtn && ( )} {additionalButtons?.right}
); }; export { Form, type FormProps };