import { reatomAsync, withAbort } from '@reatom/async'; import { type Action, type Atom, type Ctx, type Rec, type Unsubscribe, __count, action, atom, isAtom, } from '@reatom/core'; import { take } from '@reatom/effects'; import { type ParseAtoms, type AsyncAction, withErrorAtom, withStatusesAtom, type AsyncStatusesAtom, } from '@reatom/framework'; import { parseAtoms } from '@reatom/lens'; import { isObject, isShallowEqual } from '@reatom/utils'; // TODO @artalar decouple from the project import { getGqlErrors } from '../../infrastructure/graphql'; import { type FieldAtom, type FieldFocus, type FieldValidation, fieldInitFocus, fieldInitValidation, reatomField, type FieldOptions, } from './reatomField'; export interface FormFieldOptions<State = any, Value = State> extends FieldOptions<State, Value> { initState: State; } export type FormInitState = Rec< | string | number | boolean | null | undefined | File | symbol | bigint | Date | Array<any> // TODO contract as parsing method // | ((state: any) => any) | FieldAtom | FormFieldOptions | FormInitState >; export type FormFields<T extends FormInitState = FormInitState> = { [K in keyof T]: T[K] extends FieldAtom ? T[K] : T[K] extends Date ? FieldAtom<T[K]> : T[K] extends FieldOptions & { initState: infer State } ? T[K] extends FieldOptions<State, State> ? FieldAtom<State> : T[K] extends FieldOptions<State, infer Value> ? FieldAtom<State, Value> : never : T[K] extends Rec ? FormFields<T[K]> : FieldAtom<T[K]>; }; export type FormState<T extends FormInitState = FormInitState> = ParseAtoms< FormFields<T> >; export type DeepPartial<T> = { [K in keyof T]?: T[K] extends Rec ? DeepPartial<T[K]> : T[K]; }; export type FormPartialState<T extends FormInitState = FormInitState> = DeepPartial<FormState<T>>; export interface FieldsAtom extends Atom<Array<FieldAtom>> { add: Action<[FieldAtom], Unsubscribe>; remove: Action<[FieldAtom], void>; } export interface SubmitAction extends AsyncAction<[], void> { error: Atom<string | undefined>; statusesAtom: AsyncStatusesAtom; } export interface Form<T extends FormInitState = any> { /** Fields from the init state */ fields: FormFields<T>; fieldsState: Atom<FormState<T>>; fieldsList: FieldsAtom; /** Atom with focus state of the form, computed from all the fields in `fieldsList` */ focus: Atom<FieldFocus>; init: Action<[initState: FormPartialState<T>], void>; /** Action to reset the state, the value, the validation, and the focus states. */ reset: Action<[], void>; /** Submit async handler. It checks the validation of all the fields in `fieldsList`, calls the form's `validate` options handler, and then the `onSubmit` options handler. Check the additional options properties of async action: https://www.reatom.dev/package/async/. */ submit: SubmitAction; submitted: Atom<boolean>; /** Atom with validation state of the form, computed from all the fields in `fieldsList` */ validation: Atom<FieldValidation>; } export interface FormOptions<T extends FormInitState = any> { name?: string; /** The callback to process valid form data */ onSubmit?: (ctx: Ctx, state: FormState<T>) => void | Promise<void>; /** Should reset the state after success submit? @default true */ resetOnSubmit?: boolean; /** The callback to validate form fields. */ validate?: (ctx: Ctx, state: FormState<T>) => any; } const reatomFormFields = <T extends FormInitState>( initState: T, name: string, ): FormFields<T> => { const fields = Array.isArray(initState) ? ([] as FormFields<T>) : ({} as FormFields<T>); for (const [key, value] of Object.entries(initState)) { if (isAtom(value)) { // @ts-expect-error bad keys type inference fields[key] = value as FieldAtom; } else if (isObject(value) && !(value instanceof Date)) { if ('initState' in value) { // @ts-expect-error bad keys type inference fields[key] = reatomField(value.initState, { name: `${name}.${key}`, ...(value as FieldOptions), }); } else { // @ts-expect-error bad keys type inference fields[key] = reatomFormFields(value, `${name}.${key}`); } } else { // @ts-expect-error bad keys type inference fields[key] = reatomField(value, { name: `${name}.${key}`, }); } } return fields; }; const getFieldsList = ( fields: FormFields<any>, acc: Array<FieldAtom> = [], ): Array<FieldAtom> => { for (const field of Object.values(fields)) { if (isAtom(field)) acc.push(field as FieldAtom); else getFieldsList(field as FormFields, acc); } return acc; }; export const reatomForm = <T extends FormInitState>( initState: T, options: string | FormOptions<T> = {}, ): Form<T> => { const { name = __count('form'), onSubmit, resetOnSubmit = true, validate, } = typeof options === 'string' ? ({ name: options } as FormOptions<T>) : options; const fields = reatomFormFields(initState, `${name}.fields`); const fieldsState = atom( (ctx) => parseAtoms(ctx, fields), `${name}.fieldsState`, ); const fieldsList = Object.assign( atom(getFieldsList(fields), `${name}.fieldsList`), { add: action((ctx, fieldAtom) => { fieldsList(ctx, (list) => [...list, fieldAtom]); return () => { fieldsList(ctx, (list) => list.filter((v) => v !== fieldAtom)); }; }), remove: action((ctx, fieldAtom) => { fieldsList(ctx, (list) => list.filter((v) => v !== fieldAtom)); }), }, ); const focus = atom((ctx, state = fieldInitFocus) => { const formFocus = { ...fieldInitFocus }; for (const field of ctx.spy(fieldsList)) { const { active, dirty, touched } = ctx.spy(field.focus); formFocus.active ||= active; formFocus.dirty ||= dirty; formFocus.touched ||= touched; } return isShallowEqual(formFocus, state) ? state : formFocus; }, `${name}.focus`); const validation = atom((ctx, state = fieldInitValidation) => { const formValid = { ...fieldInitValidation }; for (const field of ctx.spy(fieldsList)) { const { triggered, validating, error } = ctx.spy(field.validation); formValid.triggered &&= triggered; formValid.validating ||= validating; formValid.error ||= error; } return isShallowEqual(formValid, state) ? state : formValid; }, `${name}.validation`); const submitted = atom(false, `${name}.submitted`); const reset = action((ctx) => { ctx.get(fieldsList).forEach((fieldAtom) => fieldAtom.reset(ctx)); submitted(ctx, false); submit.errorAtom.reset(ctx); submit.abort(ctx, `${name}.reset`); }, `${name}.reset`); const reinitState = (ctx: Ctx, initState: FormState, fields: FormFields) => { for (const [key, value] of Object.entries(initState as Rec)) { if ( isObject(value) && !(value instanceof Date) && key in fields && !isAtom(fields[key]) ) { reinitState(ctx, value, fields[key] as unknown as FormFields); } else { fields[key]?.initState(ctx, value); } } }; const init = action((ctx, initState: FormState) => { reinitState(ctx, initState, fields as FormFields); }, `${name}.init`); const submit = reatomAsync(async (ctx) => { ctx.get(() => { for (const field of ctx.get(fieldsList)) { if (!ctx.get(field.validation).triggered) { field.validation.trigger(ctx); } } }); if (ctx.get(validation).validating) { await take(ctx, validation, (ctx, { validating }, skip) => { if (validating) return skip; }); } const error = ctx.get(validation).error; if (error) throw new Error(error); const state = ctx.get(fieldsState); if (validate) { const promise = validate(ctx, state); if (promise instanceof promise) { await ctx.schedule(() => promise); } } if (onSubmit) await ctx.schedule(() => onSubmit(ctx, state)); submitted(ctx, true); if (resetOnSubmit) { // do not use `reset` action here to not abort the success ctx.get(fieldsList).forEach((fieldAtom) => fieldAtom.reset(ctx)); submit.errorAtom.reset(ctx); submit.statusesAtom.reset(ctx); submitted(ctx, false); } }, `${name}.onSubmit`).pipe( withStatusesAtom(), withAbort(), withErrorAtom((ctx, error) => getGqlErrors(error)[0], { resetTrigger: 'onFulfill', initState: undefined, }), (submit) => Object.assign(submit, { error: submit.errorAtom }), ); return { fields, fieldsList, fieldsState, focus, init, reset, submit, submitted, validation, }; };