0.3.0 • Published 5 months ago

formcraft v0.3.0

Weekly downloads
-
License
ISC
Repository
github
Last release
5 months ago

Formcraft

Philosophy

Formcraft provides a set of abstractions for convenient work with forms with two concepts:

  • Atomicity The library provides different building blocks that you can put together to create complex forms or use individually. This allows you to easily work with the entire form or just one specific part. For example, you can submit or reset the entire form or refer to just one block when displaying information in the user interface.
  • Clean and ordered declaration Sometimes a form needs complex validation that rely on outside data and happen on specific occasions. To do this, a big monster configuration is often written at the beginning of the file, making it hard to read and confusing. Furthermore, the order of the declaration matters, which can be problematic if a validation depends on a store, which has to be defined beforehand. Formcraft solves this by separating the form's logic from its declaration, so the declaration block is more understandable and saves order.
// one line declarations
const login = createField("");
const password = createField("");
const loginForm = groupFields({ login, password });

const loginFx = createEffect<{ login: string; password: string }, void>();
// logic
attachValidator({
  field: login,
  validator: (value) => !!value.length,
});

attachValidator({
  field: password,
  validator: (value) => !!value.length,
});

sample({
  clock: loginForm.resolved,
  target: loginFx,
});

Install

Do not use this in production. The library is currently unstable and may contain bugs.

npm install effector effector-react formcraft

Unit

The base abstraction that contains the main stores and events. All other entities except controlledFIeldList are inherited from Unit. Thus, a unit can be either an entire form or a single field.

interface FormUnit<Result, FillPayload, Error> {
  validate: Event<void>;
  reset: Event<void>;
  fill: Event<FillPayload>;
  refill: Event<void>;
  submit: Event<void>;
  resolved: Event<Result>;
  rejected: Event<Error>;
  $isError: Store<boolean>;
  $isDirty: Store<boolean>;
  $isTouched: Store<boolean>;
  $isLoading: Store<boolean>;
  $isFocused: Store<boolean>;
  $isReady: Store<boolean>;
}

Submit unit

To submit a unit, you need to trigger the submit event. then, if there is no error in the $isError, the resolved event with the prepared value will be fired, otherwise the rejected event with errors will be fired

const field = createField("");

const saveButtonClicked = createEvent();

const saveFieldFx = createEffect<string, void>();
const showErrorsFx = createEffect<string[], void>();

sample({
  clock: saveButtonClicked,
  target: field.submit,
});

sample({
  clock: field.resolved, // called with field.$value
  target: saveFieldFx,
});

sample({
  clock: field.rejected, // called with field.$errorMessages
  target: showErrorsFx,
});

Unit filling

Sometimes we need to change a form using data that comes from the server or local storage, not just from user input. In these situations fill event should be used. The difference between a fill and a regular setters is that they work differently with validation, fill allows you to set the value of a complex structures and plus, you can use refill alongside it.

const login = createField("");
const email = createField("");
const userSettings = groupFields({
  login,
  email,
});

const resetSettingsButtonClicked = createEvent();
const userSettingsPageOpened = createEvent();

const loadUserSettingsFx = createEffect<
  void,
  { login: string; email: string }
>();

sample({
  clock: userSettingsPageOpened,
  target: loadUserSettingsFx,
});

sample({
  clock: loadUserSettingsFx.doneData,
  target: userSettings.fill,
});

sample({
  clock: resetSettingsButtonClicked,
  target: userSettings.refill, // sets last filled payload
});

unit.$isReady

!isError && !isLoading

Field

Field is used to work with a single value

createField

Params:

  • initialValue: T
  • config?: { initialErrorState?: boolean = false }

Returns: Field<T>

import { createField } from "formcraft";
const field = createField<string | null>(null);

Field api

interface Field<Value> {
  $value: Store<Value>;
  $errorMessages: Store<string[]>;
  $isError: Store<boolean>;
  $isDirty: Store<boolean>;
  $isDisabled: Store<boolean>;
  $isTouched: Store<boolean>;
  $isLoading: Store<boolean>;
  $isFocused: Store<boolean>;
  $isReady: Store<boolean>;
  validate: Event<void>;
  reset: Event<void>;
  fill: Event<Value>;
  refill: Event<void>;
  submit: Event<void>;
  resolved: Event<Value>;
  rejected: Event<Error>;
  setLoading: Event<boolean>;
  setFocus: Event<boolean>;
  setValue: Event<Value>;
  setIsDisabled: Event<boolean>;
  touched: Event<void>;
  kind: "field";
}

$isError

error state, sets via validator or initialErrorState

$errorMessages

error list, sets via validator

$isDirty

true if $value not equals initial value.

$isTouched

becomes true if setFocus(true) and then setFocus(false) were called

touched

fires when $isTouched becomes true

Field examples

field as independent form

const commentTextArea = createField("");

const leaveCommentButtonClicked = createEvent();

const saveCommentFx = createEffect<string, void, string>();
const showErrorFx = createEffect<string, void>();

sample({
  clock: leaveCommentButtonClicked,
  target: commentTextArea.submit,
});

sample({
  clock: commentTextArea.resolved,
  target: [saveCommentFx, commentTextArea.setLoading.prepend(() => true)],
});

sample({
  clock: saveCommentFx.finally,
  fn: () => false,
  target: commentTextArea.setLoading,
});

sample({
  clock: saveCommentFx.done,
  target: commentTextArea.reset,
});

sample({
  clock: saveCommentFx.failData,
  target: showErrorFx,
});

FieldList

Similar to field but for work with a list of values

createFieldList

Params

  • initialValue: T
  • config?: { initialErrorState?: boolean = false, withId?: boolean = false }

Returns: FieldList<T, WithId = false>

initialValue: value that will be set to the new field in the list by default

initialErrorState: error state that will be given to the new list element

withId: indicates that fieldList should work with stable ids

const fieldList1 = createFieldList("");
const fieldList2 = createFieldList("", {
  withId: true,
  initialErrorState: true,
});

FieldList api

interface FieldList<Value> {
  $valueList: Store<Value[]>;
  $errorList: Store<{ isError: boolean; errorMessages: string[] }[]>;
  $idList: Store<string[]>;
  $isDirtyList: Store<boolean[]>;
  $isLoadingList: Store<boolean[]>;
  $isTouchedList: Store<boolean[]>;
  $isFocusedList: Store<boolean[]>;
  $isDisabledList: Store<boolean[]>;
  // Aggregated stores
  $isError: Store<boolean>;
  $isDirty: Store<boolean>;
  $isTouched: Store<boolean>;
  $isLoading: Store<boolean>;
  $isFocused: Store<boolean>;
  $isReady: Store<boolean>;

  append: Event<Value | void>;
  prepend: Event<Value | void>;
  insert: Event<{ index: number; value?: Value }>;
  remove: Event<{ index: number }>;
  resetField: Event<{ index: number }>;
  setValue: Event<{ index: number; value: Value }>;
  setLoading: Event<{ index: number; isLoading: boolean }>;
  setIsDisabled: Event<{ index: number; isDisabled: boolean }>;
  setFocus: Event<{ index: number; isFocused: boolean }>;
  touched: Event<{ index: number }>;
  validateField: Event<{ index: number }>;
  validate: Event<void>;
  reset: Event<void>;
  fill: Event<Value[]>;
  refill: Event<void>;
  submit: Event<void>;
  resolved: Event<Value[]>;
  rejected: Event<{ index: number; errorMessages: string[] }[]>;
  kind: "fieldList";

  // if WithId = true
  append: Event<{ id: string; value?: Value }>;
  prepend: Event<{ id: string; value?: Value }>;
  insert: Event<{ id: string; index: number; value?: Value }>;
  touched: Event<{ id: string; index: number }>;
  resolved: Event<{ id: string; value: Value }[]>;
  rejected: Event<{ id: string; index: number; errorMessages: string[] }[]>;
}

fieldList.append

adds a new field to the end of the list

fiedList.prepend

adds a new field before the first element in the list

fieldList.insert

adds a new element at the specified index, all subsequent elements will be shifted by one position

Lists

$valueList, $isDirtyList, $isLoadingList, $isTouchedList, $isFocusedList, $errorList, $isDisabledList equivalent to the corresponding stores from the field but work with lists. all these stores together contain all information about fieldList. the data in them is consistent and is updated in a single batch.

Aggregated stores

$isError, $isDirty, $isTouched, $isLoading, $isFocused simply indicate that in the corresponding list at least one element is equal to true

Stable id for list elements

Stable ids help to associate certain list elements with external data. To work with them, you need to pass withId = true to the factory config. After that, the api of some events will change taking into account the ids. id is not validated and can be any string.

append: Event<{ id: string; value?: Value }>;
prepend: Event<{ id: string; value?: Value }>;
insert: Event<{ id: string; index: number; value?: Value }>;
touched: Event<{ id: string; index: number }>;
resolved: Event<{ id: string; value: Value }[]>;
rejected: Event<{ id: string; index: number; errorMessages: string[] }[]>;

Validation

Validation rules in formcraft are described separately from the declaration and allow flexibly configure validation for individual fields

attachValidator

Params: config config.field: Field<any> | FIeldList<any, boolean> | ControlledFieldList<any, boolean>

config.validator: (...params: ValidatorParams) => ValidatorResult

config.external?: Store<any> | Record<string, Store<any>>

config.validateOne?: ValidationStrategy = 'touch'

updateByExternal?: boolean | 'afterFirstValidation' = 'afterFirstValidation'

Returns: void

const numberPicker = createField("");
const $allowedNumbers = createStore([1, 2, 3, 4]);

attachValidator({
  field: numberPicker,
  external: $allowedNumbers,
  validator: (stringNumber, allowedNumbers) => {
    if (allowedNumbers.includes(Number(stringNumber))) {
      return true;
    } else {
      return "Number not allowed";
    }
  },
  validateOn: ["change", "init"],
  updateByExternal: false,
});
const phoneModelSelectList = createFieldList("", { withId: true });

const $phoneModelOptionsMap = createStore<Record<string, string[]>>({});

attachValidator({
  field: phoneModelSelectList,
  external: $phoneModelOptionsMap,
  validator: ({ id, value }, phoneModelOptionsMap) => {
    const currentModelOptions = phoneModelOptionsMap[id];
    return currentModelOptions?.includes(value) || "Model not found";
  },
  validateOn: "submit",
});

Validator params

for field

  • field.$value
  • config.external if passed

for fieldList

  • { value: T, index?: number, id: string // if withId = true }
  • config.external if passed

Validator result

  • true: is error: false, error messages: []
  • false: - is error: true, error messages: []
  • string - is error: true, error messages: returned message
  • string[]: - is error: true, error messages: returned messages

ValidationStrategy

ValidationStrategy describes at what stages the validation will be performed. The strategy is specified in the validator config and can be either one or several. Submit strategy is applied in any case, regardless of the strategies passed in the config. This is necessary in order not to accidentally submit invalid data.

  • init: for the field, validation will be performed at the time of the attachValidator call. for fieldList at the time of element creation using *append, prepend, insert or fill events
  • change: every time after calling setValue
  • touch: on the touched event and then on each change event
  • submit: on submit call and before resolved or rejected

updateByExternal

Specifies whether validation will be performed when the config.external is updated

  • false: do not validate
  • true: always validate on update
  • afterFirstValidation: always validate but after the field or fieldList element has been validated at least once

ControlledFIeldList

Similar to a fieldList but does not contain events that can change the size of the list, such fill, reset, append etc. This abstraction is needed to work with fieldListManager.

createControlledFieldList

Params

  • initialValue: T
  • config?: { initialErrorState?: boolean = false, withId?: boolean = false }

Returns: ControlledFieldList<T, WithId = false>

initialValue: value that will be set to the new field in the list by default

initialErrorState: error state that will be given to the new list element

withId: indicates that fieldList should work with stable ids

const fieldList1 = createControlledFieldList("");
const fieldList2 = createControlledFieldList("", {
  withId: true,
  initialErrorState: true,
});

ControlledFIeldList api

interface ControlledFieldList<Value> {
  $valueList: Store<Value[]>;
  $errorList: Store<{ isError: boolean; errorMessages: string[] }[]>;
  $idList: Store<string[]>;
  $isDirtyList: Store<boolean[]>;
  $isLoadingList: Store<boolean[]>;
  $isTouchedList: Store<boolean[]>;
  $isFocusedList: Store<boolean[]>;
  $isDisabledList: Store<boolean[]>;
  // Aggregated stores
  $isError: Store<boolean>;
  $isDirty: Store<boolean>;
  $isTouched: Store<boolean>;
  $isLoading: Store<boolean>;
  $isFocused: Store<boolean>;
  $isReady: Store<boolean>;

  resetField: Event<{ index: number }>;
  setValue: Event<{ index: number; value: Value }>;
  setLoading: Event<{ index: number; isLoading: boolean }>;
  setIsDisabled: Event<{ index: number; isDisabled: boolean }>;
  setFocus: Event<{ index: number; isFocused: boolean }>;
  touched: Event<{ index: number }>;
  validateField: Event<{ index: number }>;
  validate: Event<void>;
  submit: Event<void>;
  resolved: Event<Value[]>;
  rejected: Event<{ index: number; errorMessages: string[] }[]>;
  kind: "controlledFieldList";

  // if WithId = true
  touched: Event<{ id: string; index: number }>;
  resolved: Event<{ id: string; value: Value }[]>;
  rejected: Event<{ id: string; index: number; errorMessages: string[] }[]>;
}

FieldListManager

Quite often we need to work with complex lists, where the elements are not just primitive values, but structures. For example, if we are creating a todo application, we need to create 2 fields for the title and for the description.

const title = createField("");
const description = createField("");
const todoCretionForm = groupFields({ title, description });

const createTodo = createEvent<{ title: string; description: string }>();

sample({
  clock: todoCreationForm.resolved,
  target: createTodo,
});

But what if we want every item in the list to be editable:

const TodoItem = () => (
  <div>
    <Input title="title" />
    <Input title="description" />
  </div>
);

Now we have to work with the structure like { title: string, diescription: string }[]. So in this case we can create fieldList for title and description and match their elements.

const titleList = createFieldList("");
const descriptionList = createFieldList("");

const $todoList = combine(
  titleList.$valueList,
  descriptionList.$valueList,
  (titleList, descriptionList) =>
    titleList.map((title, index) => ({
      title,
      desription: descriptionList[index],
    }))
);

sample({
  clock: createTodoItemButtonClicked,
  target: [titleList.append, descriptionList.append],
});

sample({
  clock: saveButtonClicked,
  source: $todoList,
  target: saveTodosFx,
});

This will work, but managing lists this way is inconvenient. That's why formcraft adds FieldListManager abstraction that takes care of list management.

createFieldListManager

Params:

  • FieldListTemplate: Record<string, ControlledFieldList<any, true>> | Record<string, ControlledFieldList<any, false>>

Returns: FieldListManager

FieldListTemplate : template by which lists will be matched

const titleList = createControlledFieldList("");
const descriptionList = createControlledFieldList("");

const todoList = createFieldListManager({
  title: titleList,
  description: descriptionList,
});

FieldListManager dont work with Fieldlist, but with ControlledFieldList, this is so that the user does not change the list with which the manager works and does not bring the system into an inconsistent state. The lists with which the manager works must either all have withId = true or all withId = false.

FieldListManager api

consider api where template is: { title: ControlledFIeldList<any, false>, description: ControlledFIeldList<any, false>}

interface FieldListManager<Template> {
  $idList: Store<string[]>;

  // Aggregated stores
  $isError: Store<boolean>;
  $isDirty: Store<boolean>;
  $isTouched: Store<boolean>;
  $isLoading: Store<boolean>;
  $isFocused: Store<boolean>;
  $isReady: Store<boolean>;

  resolved: Event<{ title: string; description: string }[]>;
  rejected: Event<
    { index: number; errors: { title?: string[]; description?: string[] } }[]
  >;
  resetSlice: Event<{ index: number }>;

  // delegating events
  fill: Event<{ title: string; description: string }[]>;
  submit: Event<void>;
  validate: Event<void>;
  reset: Event<void>;
  refill: Event<void>;
  append: Event<{ title?: string; description?: string } | void>;
  prepend: Event<{ title?: string; description?: string } | void>;
  insert: Event<{
    index: number;
    values: { title?: string; description?: string };
  }>;
  remove: Event<{ index: number }>;

  // if ControlledFields WithId = true
  resolved: Event<
    { id: string; values: { title: string; description: string } }[]
  >;
  rejected: Event<
    {
      id: string;
      index: number;
      errors: { title?: string[]; description?: string[] };
    }[]
  >;
  append: Event<{
    id: string;
    values?: { title?: string; description?: string };
  }>;
  prepend: Event<{
    id: string;
    values?: { title?: string; description?: string };
  }>;
  insert: Event<{
    id: string;
    index: number;
    values: { title?: string; description?: string };
  }>;
}

Delegating events

Events that simply trigger events of the same name in downstream units, modifying the payload if needed For example, fieldlistManager.reset will simply call reset event for all lists with which it works

FieldGroup

groupFields

Params

  • unitShape: Record<string, FormUnit<any, any, any>>
  • keys?: Store<keyof unitShape | (keyof unitShape)[]> = all keys

Returns: FieldGroup<Shape, Keys>

unitShape: structure of units to be grouped keys: specifies which units should be active

const field = createField("");
const fieldList = createFieldList("");
const group = groupFields({ field, fieldList });
const group2 = groupFields({ field, fieldList }, createStore("field"));
const group3 = groupFields(
  { field, fieldList },
  createStore(["field", "fieldList"])
);

Active unit

If the unit is active, then it is used when calculating aggregated stores and gets into the payload of resolved and rejected events and was also used by delegating events. if the unit is not active, then it is not used in aggregation, is not contained in the payload of resolved and rejected events, but continues to be used by delegating methods like fill, reset, validate etc.

const unvalidField = createField("", { initialErrorState: true });
const validField = createField("");
const group = groupFields(
  { validField, unvalidField },
  createStore(["validField"])
);

group.$isError.getState(); // false. unvalidFIeld is not included in the aggregation.

group.resolved.watch((result) => console.log(result)); // will be { validField: '' }
group.submit();

group.fill({ unvalidField: "foo", validField: "bar" }); // still can fill not active unit

unvalidField.$value.getState(); // foo

FieldGroup api

consider api where unit shape is: { title: Field<string>, description: FIeld<string>}

common types:

{
  // Aggregated stores
  $isError: Store<boolean>;
  $isDirty: Store<boolean>;
  $isTouched: Store<boolean>;
  $isLoading: Store<boolean>;
  $isFocused: Store<boolean>;
  $isReady: Store<boolean>;

  // delegating events
  validate: Event<void>;
  reset: Event<void>;
  fill: Event<FillPayload>;
  refill: Event<void>;
  submit: Event<void>;
}

if the keys are not set, then all units are considered active and resolved and rejected events will be

{
  resolved: Event<{ title: string; description: string }>;
  rejected: Event<{ title?: string[]; description?: string[] }>;
}

If the keys are specified as an array then

{
  resolved: Event<{ title?: string, description?: string }>;
  rejected: Event<{ title?: string[], description?: string[] }>,
  $keys: Store<'title' | 'description'[]> // link to the keys that were passed in the parameters
}

if the key is passed as a string then

{
  resolved: Event<{ key: 'title', value: string } | { key: 'description', value: 'string' }>;
  rejected: Event<{ key: 'title' error: string[] } | { key: 'description', error: string[] }>,
  $keys: Store<'title' | 'description'> // link to the keys that were passed in the parameters
}

Nested groups

Since the group extends unit, the group can be nested in another group

const level3 = groupFields({});
const level2 = groupFields({ level3 });
const level1 = groupFields({ level2 });

level1.resolved.watch(console.log); // { level2: { level3: {} } };

Hooks

  • useField
  • useFieldListElement
  • useFieldListKeys: provides stable list keys that can be passed in the react key attribute***
  • useFormUnit

examples with hooks

const field = createField("initialValue");

const Input = () => {
  const {
    value,
    isError,
    errorMessages,
    isDirty,
    isDisabled,
    isFocused,
    isLoading,
    isReady,
    isTouched,
    onBlur,
    onChange,
    onFocus,
  } = useField(field);

  if (isLoading) {
    return <span>Loading...</span>;
  }

  const classNames = ["input"];
  if (isError) {
    classNames.push("input__error");
  }
  if (isFocused) {
    classNames.push("input__focused");
  }

  return (
    <div>
      <input
        disabled={isDisabled}
        className={classNames.join(" ")}
        value={value}
        onChange={({ target: { value } }) => onChange(value)}
        onFocus={onFocus}
        onBlur={onBlur}
      />
      {isError && errorMessages.map((msg, i) => <span key={i}>{msg}</span>)}
    </div>
  );
};
const SubmitButton = () => {
  const { isReady } = useFormUnit(form);

  return <button disabled={!isReady}>Save</button>;
};
const fieldList = createFieldList("");

const FieldListElement = ({ index }: { index: number }) => {
  const {
    value,
    isDirty,
    isDisabled,
    errorMessages = [],
    isError,
    isFocused,
    isLoading,
    isTouched,
    onChange,
    onBlur,
    onFocus,
  } = useFieldListElement(fieldList, { index });

  if (isLoading) {
    return <span>Loading...</span>;
  }

  const classNames = ["input"];
  if (isError) {
    classNames.push("input__error");
  }
  if (isFocused) {
    classNames.push("input__focused");
  }

  return (
    <div>
      <input
        disabled={isDisabled}
        className={classNames.join(" ")}
        value={value}
        onChange={({ target: { value } }) => onChange(value)}
        onFocus={onFocus}
        onBlur={onBlur}
      />
      {isError && errorMessages.map((msg, i) => <span key={i}>{msg}</span>)}
    </div>
  );
};

const FieldList = () => {
  const keys = useFieldListKeys(fieldList);

  return (
    <div>
      <button onClick={() => fieldList.append()}>create field</button>
      <div>
        {keys.map((key, index) => (
          <FieldListElement key={key} index={index} />
        ))}
      </div>
    </div>
  );
};

Examples

Conditional form

Live example

import React, { FC, InputHTMLAttributes } from "react";
import { createEffect, createEvent, createStore, sample } from "effector";
import { useUnit } from "effector-react";
import {
  createField,
  attachValidator,
  groupFields,
  useField,
  Field,
} from "formcraft";

type RegistrationPayload = {
  userName: string;
  contactInfo: { email: string } | { phone: { code: number; number: string } };
};

export const userName = createField("");
export const email = createField("");
export const phoneCountryCode = createField("");
export const phoneNumber = createField("");
const phone = groupFields({
  code: phoneCountryCode,
  number: phoneNumber,
});
export const contactInfo = groupFields({ phone, email }, createStore("email"));
const registrationForm = groupFields({ userName, contactInfo });

export const registerByPhoneButtonClicked = createEvent();
export const registerByEmailButtonClicked = createEvent();
export const registerButtonClicked = createEvent();

const registerFx = createEffect<RegistrationPayload, void>();

export const $contactInfoType = contactInfo.$keys;

attachValidator({
  field: userName,
  validator: (userName) =>
    userName.length >= 5 || "Name is too short (minimum 5 characters)",
});

attachValidator({
  field: email,
  validator: (email) => /^\S+@\S+\.\S+$/.test(email) || "Email is not correct",
});

attachValidator({
  field: phoneCountryCode,
  validator: (code) => /^\d{1,4}$/.test(code),
});

attachValidator({
  field: phoneNumber,
  validator: (phone) => /^\d{5}/.test(phone),
});

sample({
  clock: registerByEmailButtonClicked,
  fn: () => "email" as const,
  target: [$contactInfoType, phone.reset] as const,
});

sample({
  clock: registerByPhoneButtonClicked,
  fn: () => "phone" as const,
  target: [$contactInfoType, email.reset] as const,
});

sample({
  clock: registerButtonClicked,
  target: registrationForm.submit,
});

sample({
  clock: registrationForm.resolved,
  fn: ({ userName, contactInfo }): RegistrationPayload => ({
    userName,
    contactInfo:
      contactInfo.key === "email"
        ? { email: contactInfo.value }
        : {
            phone: {
              code: Number(contactInfo.value.code),
              number: contactInfo.value.number,
            },
          },
  }),
  target: registerFx,
});

sample({
  clock: registerFx.doneData,
  target: registrationForm.reset,
});

registerFx.use((d) => {
  alert(JSON.stringify(d, null, 2));
});

const Input: FC<{ field: Field<string> } & InputHTMLAttributes<{}>> = ({
  field,
  ...inputProps
}) => {
  const { value, onBlur, onChange, onFocus, isError, errorMessages } =
    useField(field);
  return (
    <div>
      <input
        style={isError ? { border: "1px solid red" } : {}}
        value={value}
        onBlur={onBlur}
        onFocus={onFocus}
        onChange={({ target: { value } }) => onChange(value)}
        {...inputProps}
      />
      {isError && errorMessages.length ? errorMessages.join(", ") : ""}
    </div>
  );
};

export const RegistrationForm: FC = () => {
  const [contactInfoType, onLoginByPhone, onLoginByEmail, onRegister] = useUnit(
    [
      $contactInfoType,
      registerByPhoneButtonClicked,
      registerByEmailButtonClicked,
      registerButtonClicked,
    ]
  );

  return (
    <div>
      <h1>Registration form</h1>
      <button onClick={onLoginByPhone}>register by phone</button>
      <button onClick={onLoginByEmail}>register by email</button>
      <div>
        <Input field={userName} placeholder="user name" />
        {contactInfoType === "email" ? (
          <Input field={email} placeholder="email" />
        ) : (
          <div>
            <Input field={phoneCountryCode} placeholder="code" />
            <Input field={phoneNumber} placeholder="number" />
          </div>
        )}
      </div>
      <button onClick={onRegister}>register</button>
    </div>
  );
};

Complex shape list

Live example

import React, { FC, InputHTMLAttributes } from "react";
import { createEffect, createEvent, createStore, sample } from "effector";
import {
  ControlledFieldList,
  createControlledFieldList,
  createFieldListManager,
  attachValidator,
  useFieldListKeys,
  useFieldListElement,
} from "formcraft";

type Todo = {
  title: string;
  description: string;
  isCompleted: boolean;
};

export const titleList = createControlledFieldList("");
export const descriptionList = createControlledFieldList("");
export const isCompletedList = createControlledFieldList(false);
export const todoList = createFieldListManager({
  title: titleList,
  description: descriptionList,
  isCompleted: isCompletedList,
});

export const saveButtonClicked = createEvent();
export const addTodoButtonClicked = createEvent();
export const removeTodoButtonClicked = createEvent<{ index: number }>();
const todoPageOpened = createEvent();

const loadTodosFx = createEffect<void, Todo[]>();
const saveTodosFx = createEffect<Todo[], void>();

attachValidator({
  field: titleList,
  validator: ({ value: title }) => title.length > 0 || "title cannot be empty",
});

sample({
  clock: todoPageOpened,
  target: loadTodosFx,
});

sample({
  clock: addTodoButtonClicked,
  target: todoList.append,
});

sample({
  clock: removeTodoButtonClicked,
  target: todoList.remove,
});

sample({
  clock: saveButtonClicked,
  target: todoList.submit,
});

sample({
  clock: todoList.resolved,
  target: saveTodosFx,
});

sample({
  clock: loadTodosFx.doneData,
  target: todoList.fill,
});

loadTodosFx.use(async () => {
  const todos = localStorage.getItem("todos");
  if (todos === null) {
    return [
      { title: "open example", description: "", isCompleted: true },
      { title: "play with it", description: "", isCompleted: false },
      { title: "share feedback", description: "please", isCompleted: false },
    ];
  }
  try {
    return JSON.parse(todos);
  } catch {
    return [];
  }
});

saveTodosFx.use(async (todos) => {
  localStorage.setItem("todos", JSON.stringify(todos));
});

todoPageOpened();

const Input: FC<
  {
    fieldList: ControlledFieldList<string, boolean>;
    index: number;
  } & InputHTMLAttributes<{}>
> = ({ fieldList, index, ...inputProps }) => {
  const { value, onBlur, onChange, onFocus, isError, errorMessages } =
    useFieldListElement(fieldList, { index });
  return (
    <div>
      <input
        style={isError ? { border: "1px solid red" } : {}}
        value={value}
        onBlur={onBlur}
        onFocus={onFocus}
        onChange={({ target: { value } }) => onChange(value)}
        {...inputProps}
      />
      {isError && errorMessages?.length ? errorMessages.join(", ") : ""}
    </div>
  );
};

const Checkbox: FC<
  {
    fieldList: ControlledFieldList<boolean, boolean>;
    index: number;
  } & InputHTMLAttributes<{}>
> = ({ fieldList, index, ...inputProps }) => {
  const { value, onBlur, onChange, onFocus, isError, errorMessages } =
    useFieldListElement(fieldList, { index });
  return (
    <div>
      <input
        {...inputProps}
        style={isError ? { border: "1px solid red" } : {}}
        checked={value}
        onBlur={onBlur}
        onFocus={onFocus}
        type="checkbox"
        onChange={() => onChange(!value)}
      />
      {inputProps.placeholder}
    </div>
  );
};

const TodoItem: FC<{ index: number }> = ({ index }) => {
  return (
    <div style={{ marginBottom: 15 }}>
      <Input fieldList={titleList} index={index} placeholder={"title"} />
      <Input
        fieldList={descriptionList}
        index={index}
        placeholder={"description"}
      />
      <Checkbox
        fieldList={isCompletedList}
        index={index}
        placeholder={"is completed"}
      />
      <button onClick={() => removeTodoButtonClicked({ index })}>delete</button>
    </div>
  );
};

export const TodoList: FC = () => {
  const keys = useFieldListKeys(todoList);

  return (
    <div>
      <h1>Todo list</h1>
      <button onClick={() => addTodoButtonClicked()}>new todo + </button>
      <ul>
        {keys.map((key, index) => (
          <TodoItem key={key} index={index} />
        ))}
      </ul>
      <button onClick={() => saveButtonClicked()}>save</button>
    </div>
  );
};

Server side validation

Live example

import React, { FC } from "react";
import { useField } from "formcraft";
import { createEffect, createEvent, createStore, sample } from "effector";
import { debounce } from "patronum/debounce";
import { createField, attachValidator } from "formcraft";

export const email = createField("");

const checkEmail = createEvent<string>();

const checkEmailFx = createEffect<string, string[]>();

const $emailErrors = createStore<string[]>([]);

attachValidator({
  field: email,
  external: $emailErrors,
  validator: (email, serverErrors) => {
    if (!/^\S+@\S+\.\S+$/.test(email)) {
      return "Email is not correct";
    }
    return !serverErrors.length || serverErrors;
  },
  validateOn: "change",
});

sample({
  clock: email.$value,
  target: $emailErrors.reinit!,
});

sample({
  clock: checkEmail,
  fn: () => true,
  target: email.setLoading,
});

sample({
  clock: checkEmailFx.finally,
  fn: () => false,
  target: email.setLoading,
});

sample({
  clock: email.$value,
  source: email.$isError,
  filter: (isError) => !isError,
  fn: (_, value) => value,
  target: checkEmail,
});

sample({
  clock: checkEmailFx.doneData,
  target: $emailErrors,
});

debounce({
  source: checkEmail,
  timeout: 500,
  target: checkEmailFx,
});

checkEmailFx.use(
  async (val) =>
    new Promise((res, rej) => {
      console.log("check", val);
      setTimeout(() => {
        res(Math.random() > 0.5 ? [] : ["email not found"]);
      }, 500);
    })
);

export const Email: FC = () => {
  const {
    value,
    onBlur,
    onChange,
    onFocus,
    isError,
    isLoading,
    errorMessages,
  } = useField(email);
  return (
    <div>
      <input
        style={isError ? { border: "1px solid red" } : {}}
        placeholder={"email"}
        value={value}
        onBlur={onBlur}
        onFocus={onFocus}
        onChange={({ target: { value } }) => onChange(value)}
      />
      {isError && errorMessages.length ? errorMessages.join(", ") : ""}
      {isLoading && <div>loading...</div>}
    </div>
  );
};

Related field validation

Live example

import React, { FC, InputHTMLAttributes } from "react";
import { createEffect, createEvent, sample } from "effector";
import {
  createField,
  groupFields,
  attachValidator,
  useField,
  Field,
} from "formcraft";

export const password = createField("");
export const repeatedPassword = createField("");
const passwordForm = groupFields({ password, repeatedPassword });

export const savePasswordButtonClicked = createEvent();

const savePasswordFx = createEffect<string, void>();

attachValidator({
  field: password,
  validator: (password) =>
    password.length > 5 || "the password has to be longer than 5 characters",
  validateOn: "change",
});

attachValidator({
  field: repeatedPassword,
  external: password.$value,
  validator: (repeatedPassword, password) =>
    repeatedPassword === password || "passwords are not equal",
  validateOn: "change",
});

sample({
  clock: savePasswordButtonClicked,
  target: password.submit,
});

sample({
  clock: password.resolved,
  target: savePasswordFx,
});

sample({
  clock: savePasswordFx.done,
  target: passwordForm.reset,
});

savePasswordFx.use((pw) => {
  alert(pw);
});

const Input: FC<{ field: Field<string> } & InputHTMLAttributes<{}>> = ({
  field,
  ...inputProps
}) => {
  const { value, onBlur, onChange, onFocus, isError, errorMessages } =
    useField(field);
  return (
    <div>
      <input
        style={isError ? { border: "1px solid red" } : {}}
        value={value}
        onBlur={onBlur}
        onFocus={onFocus}
        onChange={({ target: { value } }) => onChange(value)}
        {...inputProps}
      />
      {isError && errorMessages.length ? errorMessages.join(", ") : ""}
    </div>
  );
};

export const PasswordForm: FC = () => {
  return (
    <div>
      <h1>Password creation form</h1>
      <div>
        <Input field={password} placeholder="password" />
        <Input field={repeatedPassword} placeholder="repeat password" />
      </div>
      <button onClick={() => savePasswordButtonClicked()}>save password</button>
    </div>
  );
};
0.3.0

5 months ago

0.2.1

5 months ago

0.2.0

5 months ago

0.1.3

1 year ago

0.1.2

1 year ago

0.1.1

1 year ago

0.1.0

1 year ago