Skip to content

Instantly share code, notes, and snippets.

@artalar
Created May 28, 2024 21:43
Show Gist options
  • Save artalar/d03e4cb93c0af755c6e5e2c77347ef40 to your computer and use it in GitHub Desktop.
Save artalar/d03e4cb93c0af755c6e5e2c77347ef40 to your computer and use it in GitHub Desktop.
reatomForm
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);
});
export * from './reatomField';
export * from './reatomForm';
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 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,
triggered: false,
validating: false,
};
export const fieldInitValidationLess: FieldValidation = {
error: 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);
try {
contract?.(state);
// eslint-disable-next-line no-var
var promise = validateFn?.(withAbortableSchedule(ctx), {
state,
value: valueValue,
focus: focusValue,
validation: validationValue,
});
} catch (error) {
// eslint-disable-next-line no-var
var message: undefined | string = toError(error);
}
if (promise instanceof Promise) {
__thenReatomed(
ctx,
promise,
() => {
if (controller.signal.aborted) return;
validation.merge(ctx, {
error: undefined,
triggered: true,
validating: false,
});
},
(error) => {
if (controller.signal.aborted) return;
validation.merge(ctx, {
error: toError(error),
triggered: true,
validating: false,
});
},
).catch(noop);
return validation.merge(ctx, {
error: keepErrorDuringValidating ? validationValue.error : undefined,
triggered: true,
validating: true,
});
}
return validation.merge(ctx, {
validating: false,
error: message,
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,
});
};
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';
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<Error | 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`);
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,
// @ts-expect-error bad keys type inference
fields[key] 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(undefined, { resetTrigger: 'onFulfill' }),
(submit) => Object.assign(submit, { error: submit.errorAtom }),
);
return {
fields,
fieldsList,
fieldsState,
focus,
init,
reset,
submit,
submitted,
validation,
};
};
// TODO remove this hardcode
import { z } from 'zod';
export const toError = (thing: unknown) => {
return thing instanceof Error
? thing instanceof z.ZodError
? thing.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