formcraft
v0.3.0
Published
Form manager for Effector
Downloads
37
Readme
Formcraft
- Philosophy
- Install
- Unit
- Field
- FieldList
- Validation
- ControlledFIeldList
- FieldListManager
- FieldGroup
- Hooks
- Examples
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
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
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
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
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>
);
};