Last active
February 6, 2025 19:57
-
-
Save artalar/d03e4cb93c0af755c6e5e2c77347ef40 to your computer and use it in GitHub Desktop.
reatomForm
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { atom, reatomAsync, withStatusesAtom } from '@reatom/framework'; | |
import { | |
FunnelMinorFileType, | |
FileType, | |
type FunnelMinorFile as AssetFile, | |
type UpdateFunnelDataInput, | |
} from 'src/__generated__/graphql'; | |
import { t } from 'src/admin/i18n'; | |
import { type FormFieldOptions, reatomForm } from 'src/reatom-form'; | |
import { reatomObjectUrl } from 'src/shared/reatomObjectUrl'; | |
import { z } from 'zod'; | |
import { | |
funnelId, | |
funnelQuery, | |
updateFunnelMutation, | |
uploadFileMutation, | |
} from './requests'; | |
export const uploadEndPageImage = reatomAsync( | |
async (ctx, file: File | null) => { | |
if (!file) { | |
return; | |
} | |
const id = ctx.get(funnelId); | |
await uploadFileMutation(ctx, { | |
data: { | |
funnelId: id, | |
type: FunnelMinorFileType.EndPageIllustration, | |
fileType: FileType.Image, | |
}, | |
file, | |
}); | |
}, | |
'FunnelSettings.uploadEndPageImage', | |
).pipe(withStatusesAtom()); | |
export const form = reatomForm( | |
{ | |
title: '' as string | null, | |
subtitle: '' as string | null, | |
showFunleeBanner: false as boolean, | |
showLinkButton: { | |
enabled: false as boolean, | |
text: { | |
initState: '' as string | null, | |
contract: z | |
.string() | |
.min(1, t('funnelSettings:endPageForm.textError')) | |
.nullable().parse, | |
validateOnChange: true, | |
} satisfies FormFieldOptions<string | null>, | |
link: { | |
initState: '' as string | null, | |
contract: z | |
.string() | |
.url(t('funnelSettings:endPageForm.linkError')) | |
.nullable().parse, | |
validateOnChange: true, | |
} satisfies FormFieldOptions<string | null>, | |
}, | |
showRepeatButton: false as boolean, | |
showLogo: false as boolean, | |
imageFileId: null as AssetFile | File | null, | |
}, | |
{ | |
name: 'FunnelSettings.endPageForm', | |
onSubmit: async (ctx, { imageFileId, ...state }) => { | |
const isImageFileDirty = ctx.get(form.fields.imageFileId.focus).dirty; | |
const input: UpdateFunnelDataInput = { | |
settings: { endPage: state }, | |
}; | |
if (isImageFileDirty) { | |
if (!imageFileId) { | |
input.endPageIllustrationFileId = null; | |
} | |
if (imageFileId instanceof File) | |
await uploadEndPageImage(ctx, imageFileId); | |
} | |
await updateFunnelMutation(ctx, input); | |
}, | |
}, | |
); | |
form.fields.showLinkButton.enabled.onChange((ctx, checked) => { | |
const isDirty = ctx.get(form.fields.showLinkButton.enabled.focus).dirty; | |
if (isDirty) { | |
if (!checked) { | |
form.fields.showLinkButton.text.initState(ctx, null); | |
form.fields.showLinkButton.link.initState(ctx, null); | |
form.fields.showLinkButton.text.reset(ctx); | |
form.fields.showLinkButton.link.reset(ctx); | |
} else { | |
form.fields.showLinkButton.text(ctx, ''); | |
form.fields.showLinkButton.link(ctx, ''); | |
} | |
} | |
}); | |
const endPageImageObjectUrl = reatomObjectUrl(form.fields.imageFileId); | |
export const endPageImageSrc = atom((ctx) => { | |
const image = ctx.spy(form.fields.imageFileId); | |
return image instanceof File | |
? ctx.spy(endPageImageObjectUrl) | |
: image?.fileUrl ?? null; | |
}); | |
funnelQuery.onFulfill.onCall((ctx, res) => { | |
const { getFunnel: data } = res; | |
const { endPage } = data.settings; | |
form.init(ctx, { | |
showLinkButton: { | |
enabled: endPage.showLinkButton.enabled, | |
text: endPage.showLinkButton.text, | |
link: endPage.showLinkButton.link, | |
}, | |
showLogo: endPage.showLogo, | |
showRepeatButton: endPage.showRepeatButton, | |
showFunleeBanner: endPage.showFunleeBanner, | |
subtitle: endPage.subtitle, | |
title: endPage.title, | |
imageFileId: | |
data.assets.find( | |
(f) => f.category === FunnelMinorFileType.EndPageIllustration, | |
) ?? null, | |
}); | |
form.reset(ctx); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export * from './reatomField'; | |
export * from './reatomForm'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
type Action, | |
type Atom, | |
type AtomMut, | |
type Ctx, | |
__count, | |
action, | |
atom, | |
} from '@reatom/core'; | |
import { __thenReatomed } from '@reatom/effects'; | |
import { | |
type CtxSpy, | |
abortCauseContext, | |
withAbortableSchedule, | |
} from '@reatom/framework'; | |
import { type RecordAtom, reatomRecord } from '@reatom/primitives'; | |
import { isDeepEqual, noop, toAbortError } from '@reatom/utils'; | |
import { toError } from './utils'; | |
export interface FieldFocus { | |
/** The field is focused. */ | |
active: boolean; | |
/** The field state is not equal to the initial state. */ | |
dirty: boolean; | |
/** The field has ever gained and lost focus. */ | |
touched: boolean; | |
} | |
export interface FieldValidation { | |
/** The field validation error text. */ | |
error: undefined | string; | |
/** The field validation meta. */ | |
meta: unknown | undefined; | |
/** The validation actuality status. */ | |
triggered: boolean; | |
/** The field async validation status */ | |
validating: boolean; | |
} | |
export interface FocusAtom extends RecordAtom<FieldFocus> { | |
/** Action for handling field focus. */ | |
in: Action<[], void>; | |
/** Action for handling field blur. */ | |
out: Action<[], void>; | |
} | |
export interface ValidationAtom extends RecordAtom<FieldValidation> { | |
/** Action to trigger field validation. */ | |
trigger: Action<[], FieldValidation>; | |
} | |
export interface FieldAtom<State = any, Value = State> extends AtomMut<State> { | |
/** Action for handling field changes, accepts the "value" parameter and applies it to `toState` option. */ | |
change: Action<[Value], Value>; | |
/** Atom of an object with all related focus statuses. */ | |
focus: FocusAtom; | |
/** The initial state of the atom. */ | |
initState: AtomMut<State>; | |
/** Action to reset the state, the value, the validation, and the focus. */ | |
reset: Action<[], void>; | |
/** Atom of an object with all related validation statuses. */ | |
validation: ValidationAtom; | |
/** Atom with the "value" data, computed by the `fromState` option */ | |
value: Atom<Value>; | |
} | |
export type FieldValidateOption<State = any, Value = State> = ( | |
ctx: Ctx, | |
meta: { | |
state: State; | |
value: Value; | |
focus: FieldFocus; | |
validation: FieldValidation; | |
}, | |
) => any; | |
export interface FieldOptions<State = any, Value = State> { | |
/** | |
* The callback to filter "value" changes (from the 'change' action). It should return 'false' to skip the update. | |
* By default, it always returns `true`. | |
*/ | |
filter?: (ctx: Ctx, newValue: Value, prevValue: Value) => boolean; | |
/** | |
* The callback to compute the "value" data from the "state" data. | |
* By default, it returns the "state" data without any transformations. | |
*/ | |
fromState?: (ctx: CtxSpy, state: State) => Value; | |
/** | |
* The callback used to determine whether the "value" has changed. | |
* By default, it utilizes `isDeepEqual` from reatom/utils. | |
*/ | |
isDirty?: (ctx: Ctx, newValue: Value, prevValue: Value) => boolean; | |
/** | |
* The name of the field and all related atoms and actions. | |
*/ | |
name?: string; | |
/** | |
* The callback to transform the "state" data from the "value" data from the `change` action. | |
* By default, it returns the "value" data without any transformations. | |
*/ | |
toState?: (ctx: Ctx, value: Value) => State; | |
/** | |
* The callback to validate the field. | |
*/ | |
validate?: FieldValidateOption<State, Value>; | |
contract?: (sate: State) => any; | |
/** | |
* Defines the reset behavior of the validation state during async validation. | |
* @default false | |
*/ | |
keepErrorDuringValidating?: boolean; | |
/** | |
* Defines the reset behavior of the validation state on field change. | |
* Useful if the validation is triggered on blur or submit only. | |
* @default !validateOnChange | |
*/ | |
keepErrorOnChange?: boolean; | |
/** | |
* Defines if the validation should be triggered with every field change. | |
* @default false | |
*/ | |
validateOnChange?: boolean; | |
/** | |
* Defines if the validation should be triggered on the field blur. | |
* @default false | |
*/ | |
validateOnBlur?: boolean; | |
} | |
export const fieldInitFocus: FieldFocus = { | |
active: false, | |
dirty: false, | |
touched: false, | |
}; | |
export const fieldInitValidation: FieldValidation = { | |
error: undefined, | |
meta: undefined, | |
triggered: false, | |
validating: false, | |
}; | |
export const fieldInitValidationLess: FieldValidation = { | |
error: undefined, | |
meta: undefined, | |
triggered: true, | |
validating: false, | |
}; | |
export const reatomField = <State, Value>( | |
_initState: State, | |
options: string | FieldOptions<State, Value> = {}, | |
): FieldAtom<State, Value> => { | |
interface This extends FieldAtom<State, Value> {} | |
const { | |
filter = () => true, | |
fromState = (ctx, state) => state as unknown as Value, | |
isDirty = (ctx, newValue, prevValue) => !isDeepEqual(newValue, prevValue), | |
name = __count(`${typeof _initState}Field`), | |
toState = (ctx, value) => value as unknown as State, | |
validate: validateFn, | |
contract, | |
validateOnBlur = false, | |
validateOnChange = false, | |
keepErrorDuringValidating = false, | |
keepErrorOnChange = validateOnChange, | |
} = typeof options === 'string' | |
? ({ name: options } as FieldOptions<State, Value>) | |
: options; | |
const initState = atom(_initState, `${name}.initState`); | |
const field = atom(_initState, `${name}.field`) as This; | |
const value: This['value'] = atom( | |
(ctx) => fromState(ctx, ctx.spy(field)), | |
`${name}.value`, | |
); | |
const focus = reatomRecord(fieldInitFocus, `${name}.focus`) as This['focus']; | |
// @ts-expect-error the original computed state can't be typed properly | |
focus.__reatom.computer = (ctx, state: FieldFocus) => { | |
const dirty = isDirty( | |
ctx, | |
ctx.spy(value), | |
fromState(ctx, ctx.spy(initState)), | |
); | |
return state.dirty === dirty ? state : { ...state, dirty }; | |
}; | |
focus.in = action((ctx) => { | |
focus.merge(ctx, { active: true }); | |
}, `${name}.focus.in`); | |
focus.out = action((ctx) => { | |
focus.merge(ctx, { active: false, touched: true }); | |
}, `${name}.focus.out`); | |
const validation = reatomRecord( | |
validateFn || contract ? fieldInitValidation : fieldInitValidationLess, | |
`${name}.validation`, | |
) as This['validation']; | |
if (validateFn || contract) { | |
// @ts-expect-error the original computed state can't be typed properly | |
validation.__reatom.computer = (ctx, state: FieldValidation) => { | |
ctx.spy(value); | |
return state.triggered ? { ...state, triggered: false } : state; | |
}; | |
} | |
const validationController = atom( | |
new AbortController(), | |
`${name}._validationController`, | |
); | |
// prevent collisions for different contexts | |
validationController.__reatom.initState = () => new AbortController(); | |
validation.trigger = action((ctx) => { | |
const validationValue = ctx.get(validation); | |
if (validationValue.triggered) return validationValue; | |
if (!validateFn && !contract) { | |
return validation.merge(ctx, { triggered: true }); | |
} | |
ctx.get(validationController).abort(toAbortError('concurrent')); | |
const controller = validationController(ctx, new AbortController()); | |
abortCauseContext.set(ctx.cause, controller); | |
const state = ctx.get(field); | |
const valueValue = ctx.get(value); | |
const focusValue = ctx.get(focus); | |
let promise: any; | |
let message: undefined | string; | |
try { | |
contract?.(state); | |
// eslint-disable-next-line no-var | |
promise = validateFn?.(withAbortableSchedule(ctx), { | |
state, | |
value: valueValue, | |
focus: focusValue, | |
validation: validationValue, | |
}); | |
} catch (error) { | |
// eslint-disable-next-line no-var | |
message = toError(error); | |
} | |
if (promise instanceof Promise) { | |
__thenReatomed( | |
ctx, | |
promise, | |
() => { | |
if (controller.signal.aborted) return; | |
validation.merge(ctx, { | |
error: undefined, | |
meta: undefined, | |
triggered: true, | |
validating: false, | |
}); | |
}, | |
(error) => { | |
if (controller.signal.aborted) return; | |
validation.merge(ctx, { | |
error: toError(error), | |
meta: undefined, | |
triggered: true, | |
validating: false, | |
}); | |
}, | |
).catch(noop); | |
return validation.merge(ctx, { | |
error: keepErrorDuringValidating ? validationValue.error : undefined, | |
meta: undefined, | |
triggered: true, | |
validating: true, | |
}); | |
} | |
return validation.merge(ctx, { | |
validating: false, | |
error: message, | |
meta: undefined, | |
triggered: true, | |
}); | |
}, `${name}.validation.trigger`); | |
const change: This['change'] = action((ctx, newValue) => { | |
const prevValue = ctx.get(value); | |
if (!filter(ctx, newValue, prevValue)) return prevValue; | |
field(ctx, toState(ctx, newValue)); | |
focus.merge(ctx, { touched: true }); | |
return ctx.get(value); | |
}, `${name}.change`); | |
const reset: This['reset'] = action((ctx) => { | |
field(ctx, ctx.get(initState)); | |
focus(ctx, fieldInitFocus); | |
validation(ctx, fieldInitValidation); | |
ctx.get(validationController).abort(toAbortError('reset')); | |
}, `${name}.reset`); | |
if (!keepErrorOnChange) { | |
field.onChange((ctx) => { | |
validation(ctx, fieldInitValidation); | |
ctx.get(validationController).abort(toAbortError('change')); | |
}); | |
} | |
if (validateOnChange) { | |
field.onChange((ctx) => validation.trigger(ctx)); | |
} | |
if (validateOnBlur) { | |
focus.out.onCall((ctx) => validation.trigger(ctx)); | |
} | |
return Object.assign(field, { | |
change, | |
focus, | |
initState, | |
reset, | |
validation, | |
value, | |
}); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// TODO remove this hardcode | |
import { z } from 'zod'; | |
export const toError = (thing: unknown) => { | |
return thing instanceof Error | |
? (thing as z.ZodError).issues?.length | |
? (thing as z.ZodError).issues[0]!.message | |
: thing.message | |
: String(thing ?? 'Unknown error'); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment