diff --git a/packages/form/README.md b/packages/form/README.md index 824f90b18..21a27b200 100644 --- a/packages/form/README.md +++ b/packages/form/README.md @@ -1,3 +1,9 @@ +This is a general form library with a simple focus and validation management. If you are building a web application, learn about this library and check out [reatom/form-web](https://www.reatom.dev/package/form-web/) next. + +The form API is designed for the best type-safety and flexibility. Instead of setting up the form state with a single object, each field is created separately, giving you the ability to fine-tune each field perfectly. As the field and its meta statuses are stored in atoms, you can easily combine them, define hooks, and effects to describe any logic you need. + +The cherry on the cake is dynamic field management. You don't need to use weird string APIs like `form.${index}.property`. Instead, you can simply have a list of atom fields using [atomization](https://www.reatom.dev/guides/atomization/). + ## Installation ```sh @@ -7,7 +13,110 @@ npm i @reatom/form ## Usage ```ts -import {} from '@reatom/form' +import { reatomForm } from '@reatom/form' -// ... +export const loginForm = reatomForm( + { + async onSubmit(ctx) { + const user = await api.login(ctx, { + name: ctx.get(nameField), + password: ctx.get(passwordField), + }) + }, + }, + 'loginForm', +) +export const nameField = loginForm.reatomField({ initState: '' }, 'nameField') +export const passwordField = loginForm.reatomField( + { + initState: '', + validate(ctx, { state }) { + if (state.length < 6) + throw new Error('The password should have at least six characters.') + }, + }, + 'passwordField', +) ``` + +You could find more examples in [reatom/form-web](https://www.reatom.dev/package/form-web/) package. + +### Form API + +The `loginForm` above has a few fields to track and manage the form. + +- `fieldsListAtom`: Atom with a list of fields created by this form's `reatomField` method. +- `focusAtom`: Atom with focus state of the form, computed from all the fields in `fieldsListAtom` +- `onSubmit`: Submit async handler. It checks the validation of all the fields in `fieldsListAtom`, 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/. +- `reatomField`: This method is similar to the `reatomField` method, but it includes bindings to `fieldsListAtom`. It also provides an additional `remove` method to clean itself up from `fieldsListAtom`. +- `reset`: Action to reset the state, the value, the validation, and the focus states. +- `validationAtom`: Atom with validation state of the form, computed from all the fields in `fieldsListAtom` +- `formValidationAtom`: Atom with validation statuses around form `validate` options handler. + +### Form options + +- `onSubmit`: The callback to process valid form data +- `onSubmitError`: The callback to handle validation errors on the attempt to submit +- `validate`: The callback to validate form fields. + +### Form validation behavior + +The `form.onSubmit` call triggers the validation process of all related fields (which are stored in `fieldsListAtom`). After that, the validation function from the options will be called. If there are no validation errors, the `onSubmit` callback in the options is called. If the validation fails, the `onSubmitError` callback is called. + +You can track the submitting process in progress using `form.onSubmit.pendingAtom`. + +### Field API + +A field (`FieldAtom` type) itself is an atom, and you can change it like a regular atom by calling it with the new value or reducer callback. + +The atom stores "state" data. However, there is an additional `valueAtom` that stores "value" data, which could be a different kind of state related to the end UI. For example, for a select field, you want to store the `string` "state" and `{ value: string, label: string }` "value," which will be used in the "Select" UI component. + +Here is the list of all additional methods. + +- `initState`: The initial state of the atom, readonly. +- `focusAtom`: Atom of an object with all related focus statuses. This atom has additional `reset` action. State properties: + - `active`: The field is focused. + - `dirty`: The field's state is not equal to the initial state. You can manage this using the [`isDirty` option](#field-options). + - `touched`: The field has gained and lost focus at some point. +- `validationAtom`: Atom of an object with all related validation statuses. This atom has additional `reset` action. State properties: + - `error`: The field's validation error text, undefined if the field is valid. + - `valid`: The field's validation status. + - `validating`: The field's async validation status. +- `valueAtom`: Atom with the "value" data, computed by the [`fromState` option](#field-options). +- `blur`: Action for handling field blur. +- `change`: Action for handling field changes, accepts the "value" parameter and applies it to [`toState` option](#field-options). +- `focus`: Action for handling field focus. +- `reset`: Action to reset the state, the value, the validation, and the focus. +- `validate`: Action to trigger field validation. + +By combining this statuses you can know a different meta info too. + +- `!touched && active` - the field get focus first time +- `touched && active` - the field get focus another time + +### Field validation behavior + +You can set a validation function and manage validation triggers using [the options](#field-options). The flow looks like this. + +- _validation trigger_ + - _has validation function_ + - _call validation function_ + - _an error throwed_ + - _set_ `error: string, valid: false, validating: false` + - _a promise returned_ + - _if [`keepErrorDuringValidating` option](#field-options) is false set_ `error: undefined, valid: true, validating: true`_, else set_ `validating: true` + - _if promise fulfilled set_ `error: undefined, valid: true, validating: false`_, else set_ `error: string, valid: false, validating: false` + - _nothing returned or throwed_ + - _set_ `error: undefined, valid: true, validating: false` + +### Field options + +- `initState`: The initial state of the atom, which is the only required option. +- `filter`: The optional callback to filter "value" changes (from the 'change' action). It should return 'false' to skip the update. By default, it always returns `false`. +- `fromState`: The optional callback to compute the "value" data from the "state" data. By default, it returns the "state" data without any transformations. +- `isDirty`: The optional callback used to determine whether the "value" has changed. Accepts context, the new value and the old value. By default, it utilizes `isDeepEqual` from reatom/utils. +- `toState`: The optional callback to transform the "state" data from the "value" data from the `change` action. By default, it returns the "value" data without any transformations. +- `validate`: The optional callback to validate the field. +- `keepErrorDuringValidating`: Defines the reset behavior of the validation state during async validation. It is `false` by default. +- `validateOnBlur`: Defines if the validation should be triggered on the field blur. `true` by default +- `validateOnChange`: Defines if the validation should be triggered with every field change. `!validateOnBlur` by default diff --git a/packages/form/package.json b/packages/form/package.json index df9ed81d2..6f02219ea 100644 --- a/packages/form/package.json +++ b/packages/form/package.json @@ -29,8 +29,10 @@ "dependencies": { "@reatom/async": "^3.10.1", "@reatom/core": "^3.1.21", + "@reatom/effects": "^3.6.0", "@reatom/hooks": "^3.1.0", - "@reatom/primitives": "^3.1.0" + "@reatom/primitives": "^3.1.0", + "@reatom/utils": "^3.7.0" }, "author": "artalar", "license": "MIT", diff --git a/packages/form/src/reatomField.ts b/packages/form/src/reatomField.ts index 4f301df6e..3311e405b 100644 --- a/packages/form/src/reatomField.ts +++ b/packages/form/src/reatomField.ts @@ -7,32 +7,55 @@ import { toError } from './utils' export interface FieldFocus { /** The field is focused. */ active: boolean - /** The filed state is not equal to the initial state. */ + + /** The field state is not equal to the initial state. */ dirty: boolean - /** The field has ever gained and lost focus */ + + /** The field has ever gained and lost focus. */ touched: boolean } export interface FieldValidation { + /** The field validation error text. */ error: undefined | string + + /** The field validation status. */ valid: boolean + + /** The field async validation status */ validating: boolean } export interface FieldActions { + /** Action for handling field blur. */ blur: Action<[], void> + + /** Action for handling field changes, accepts the "value" parameter and applies it to `toState` option. */ change: Action<[Value], Value> + + /** Action for handling field focus. */ focus: Action<[], void> + + /** Action to reset the state, the value, the validation, and the focus. */ reset: Action<[], void> + + /** Action to trigger field validation. */ validate: Action<[], FieldValidation> } export interface FieldAtom extends AtomMut, FieldActions { + /** Atom of an object with all related focus statuses. */ focusAtom: RecordAtom + + /** The initial state of the atom, readonly. */ initState: State + + /** Atom of an object with all related validation statuses. */ validationAtom: RecordAtom + + /** Atom with the "value" data, computed by the `fromState` option */ valueAtom: Atom } @@ -49,16 +72,64 @@ export interface FieldValidateOption { } export interface FieldOptions { + /** + * The callback to filter "value" changes (from the 'change' action). It should return 'false' to skip the update. + * By default, it always returns `false`. + */ 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: Ctx, state: State) => Value + + /** + * The initial state of the atom, which is the only required option. + */ initState: State + + /** + * 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 - /** @deprecated use boolean flags instead */ + + /** + * Defines the reset behavior of the validation state during async validation. + * It is `false` by default. + */ + keepErrorDuringValidating?: boolean + + /** + * @deprecated Use boolean flags instead. It is `blur` by default. + */ validationTrigger?: 'change' | 'blur' | 'submit' + + /** + * Defines if the validation should be triggered with every field change. By default computes from the `validationTrigger` option and `!validateOnBlur`. + */ validateOnChange?: boolean + + /** + * Defines if the validation should be triggered on the field blur. By default computes from the `validationTrigger` option. + */ validateOnBlur?: boolean } @@ -83,9 +154,10 @@ export const reatomField = ( name: optionsName, toState = (ctx, value) => value as unknown as State, validate: validateFn, + keepErrorDuringValidating = false, validationTrigger = 'blur', - validateOnChange = validationTrigger === 'change', validateOnBlur = validationTrigger === 'blur', + validateOnChange = validationTrigger === 'change' && !validateOnBlur, }: FieldOptions, // this is out of the options for eslint compatibility name = optionsName ?? __count(`${typeof initState}Field`), @@ -121,7 +193,7 @@ export const reatomField = ( validation, }) } catch (error) { - var message = toError(error) + var message: undefined | string = toError(error) } if (promise instanceof Promise) { @@ -136,7 +208,16 @@ export const reatomField = ( validationAtom.merge(ctx, validation), ) - validation = validationAtom.merge(ctx, { validating: true }) + validation = validationAtom.merge( + ctx, + keepErrorDuringValidating + ? { validating: true } + : { + error: undefined, + valid: true, + validating: true, + }, + ) __thenReatomed( ctx, @@ -159,8 +240,8 @@ export const reatomField = ( } else { validation = validationAtom.merge(ctx, { validating: false, - error: String(message!), - valid: !message!, + error: message, + valid: !message, }) } } @@ -174,7 +255,6 @@ export const reatomField = ( const blur: This['blur'] = action((ctx) => { focusAtom.merge(ctx, { active: false, touched: true }) - if (validateOnBlur) validate(ctx) }, `${name}.blur`) const change: This['change'] = action((ctx, newValue) => { @@ -190,8 +270,6 @@ export const reatomField = ( dirty: isDirty(ctx, newValue, prevValue), }) - if (validateOnChange) validate(ctx) - return newValue }, `${name}.change`) @@ -201,6 +279,16 @@ export const reatomField = ( validationAtom(ctx, fieldInitValidation) }, `${name}.reset`) + if (validateOnChange) { + fieldAtom.onChange(validate) + } + + if (validateOnBlur) { + blur.onCall((ctx) => { + validate(ctx) + }) + } + return Object.assign(fieldAtom, { blur, change, diff --git a/packages/form/src/reatomForm.ts b/packages/form/src/reatomForm.ts index c02e1c4cd..8e51c293c 100644 --- a/packages/form/src/reatomForm.ts +++ b/packages/form/src/reatomForm.ts @@ -1,9 +1,10 @@ import { action, Action, Atom, atom, Ctx, Fn, __count } from '@reatom/core' -import { onConnect } from '@reatom/hooks' +import { isInit } from '@reatom/hooks' import { isShallowEqual } from '@reatom/utils' import { FieldAtom, FieldFocus, + FieldOptions, FieldValidation, fieldInitFocus, fieldInitValidation, @@ -14,20 +15,38 @@ import { AsyncAction, reatomAsync, withAbort } from '@reatom/async' import { reatomRecord } from '@reatom/primitives' import { toError } from './utils' +export interface FormFieldAtom + extends FieldAtom { + remove: Action<[], void> +} + export interface Form { - fieldsListAtom: Atom> + /** Atom with a list of currently connected fields created by this form's `reatomField` method. */ + fieldsListAtom: Atom> + /** Atom with focus state of the form, computed from all the fields in `fieldsListAtom` */ focusAtom: Atom + /** Submit async handler. It checks the validation of all the fields in `fieldsListAtom`, 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/. */ onSubmit: AsyncAction<[], void> - reatomField: typeof reatomField + /** The same `reatomField` method, but with bindings to `fieldsListAtom`. */ + reatomField( + options: FieldOptions, + name?: string, + ): FormFieldAtom + /** Action to reset the state, the value, the validation, and the focus states. */ reset: Action<[], void> + /** Atom with validation state of the form, computed from all the fields in `fieldsListAtom` */ validationAtom: Atom + /** Atom with validation statuses around form `validate` options handler. */ formValidationAtom: Atom } export interface FormOptions { name?: string + /** The callback to process valid form data */ onSubmit: (ctx: Ctx, form: Form) => void | Promise + /** The callback to handle validation errors on the attempt to submit */ onSubmitError?: Fn<[ctx: Ctx]> + /** The callback to validate form fields. */ validate?: (ctx: Ctx, form: Form) => any } @@ -36,7 +55,10 @@ export const reatomForm = ( // this is out of the options for eslint compatibility name = optionsName ?? __count('form'), ): Form => { - const fieldsListAtom = atom>([], `${name}.fieldsListAtom`) + const fieldsListAtom = atom>( + [], + `${name}.fieldsListAtom`, + ) const focusAtom = atom((ctx, state = fieldInitFocus) => { const formFocus = { ...fieldInitFocus } for (const fieldAtom of ctx.spy(fieldsListAtom)) { @@ -72,7 +94,7 @@ export const reatomForm = ( const reset = action((ctx) => { formValidationAtom.reset(ctx) ctx.get(fieldsListAtom).forEach((fieldAtom) => fieldAtom.reset(ctx)) - if (ctx.get(handleSubmit.pendingAtom)) handleSubmit.abort(ctx) + handleSubmit.abort(ctx) }, `${name}.reset`) const handleSubmit = reatomAsync(async (ctx) => { @@ -121,19 +143,21 @@ export const reatomForm = ( } }, `${name}.onSubmit`).pipe(withAbort()) - const reatomFormField: typeof reatomField = ( + const reatomFormField: Form['reatomField'] = ( options, fieldName = options.name ?? __count(`${typeof options.initState}Field`), ) => { - const atomField = reatomField(options, `${name}.${fieldName}`) - - onConnect(atomField, (ctx) => { - fieldsListAtom(ctx, (state) => [...state, atomField]) - return () => - fieldsListAtom(ctx, (state) => - state.filter((anAtom) => anAtom !== atomField), - ) + fieldName = `${name}.${fieldName}` + const atomField = reatomField(options, fieldName) as FormFieldAtom + + atomField.onChange((ctx) => { + if (isInit(ctx)) { + fieldsListAtom(ctx, (list) => [...list, atomField]) + } }) + atomField.remove = action((ctx) => { + fieldsListAtom(ctx, (list) => [...list, atomField]) + }, `${fieldName}.remove`) return atomField }