@idem.agency/form-builder
v0.0.20
Published
Построитель форм
Readme
@idem.agency/form-builder
Динамический конструктор форм для React с поддержкой валидации, условной видимости полей и расширяемой системой плагинов.
Содержание
- Установка
- Быстрый старт
- Структура схемы формы
- Встроенные компоненты полей
- Создание кастомных полей
- Валидация
- Условная видимость полей
- Работа с ref (программное управление)
- Подписка на значение поля
- useBuilder — рендеринг вложенных полей
- Система плагинов
- Примеры
Установка
npm install @idem.agency/form-builderБыстрый старт
Минимальный рабочий пример формы с одним полем:
import { useRef } from 'react'
import { FormBuilder } from '@idem.agency/form-builder'
import type { FormElementProps, FormBuilderRef } from '@idem.agency/form-builder'
// 1. Создаём компонент поля
const TextField = ({ field, value, errors, onChange }: FormElementProps) => (
<div>
<label>{field.label}</label>
<input
value={value ?? ''}
onChange={e => onChange(e.target.value)}
/>
{errors && <span style={{ color: 'red' }}>{Object.values(errors)[0]}</span>}
</div>
)
// 2. Описываем схему формы
const layout = [
{ type: 'text', name: 'username', label: 'Имя пользователя' },
{ type: 'text', name: 'email', label: 'Email' },
]
// 3. Регистрируем поля и рендерим форму
function App() {
const formRef = useRef<FormBuilderRef>(null)
return (
<FormBuilder
ref={formRef}
layout={layout}
fields={{ text: TextField }}
onSubmit={(data) => console.log('Данные формы:', data)}
/>
)
}Структура схемы формы
layout — это массив объектов, каждый из которых описывает одно поле формы.
Базовые свойства
| Свойство | Тип | Обязательно | Описание |
|---|---|---|---|
| name | string | Да | Уникальный идентификатор поля |
| type | string | Да | Тип поля — ключ в объекте fields |
| label | string | Нет | Подпись поля |
| validation | string[] | Нет | Правила валидации |
| visibility | TGroupRules | Нет | Условия показа поля |
Вложенность данных
Данные формы повторяют структуру layout. Вложенные группы создают вложенные объекты:
const layout = [
{ type: 'text', name: 'username' },
{
type: 'group',
name: 'address',
fields: [
{ type: 'text', name: 'city' },
{ type: 'text', name: 'street' },
]
}
]
// onSubmit получит:
// {
// username: 'john',
// address: {
// city: 'Москва',
// street: 'Ленина 1'
// }
// }Встроенные компоненты полей
Пакет поставляется с готовыми базовыми компонентами:
import { inputs } from '@idem.agency/form-builder'
const { TextField, FormGroup } = inputsTextField
Текстовый ввод. Поддерживает типы text, email, password.
<FormBuilder
layout={[
{ type: 'text', name: 'name', label: 'Имя' },
{ type: 'email', name: 'email', label: 'Email' },
{ type: 'password', name: 'password', label: 'Пароль' },
]}
fields={{
text: inputs.TextField,
email: inputs.TextField,
password: inputs.TextField,
}}
/>FormGroup
Контейнер для группировки полей. Поддерживает горизонтальное и вертикальное расположение.
{
type: 'group',
name: 'contacts',
label: 'Контактные данные',
variant: 'row', // 'row' | 'col' (по умолчанию 'col')
fields: [
{ type: 'text', name: 'phone', label: 'Телефон' },
{ type: 'email', name: 'email', label: 'Email' },
]
}Создание кастомных полей
Любой React-компонент, принимающий нужные props, может стать полем формы.
Типизация props
import type { FormElementProps } from '@idem.agency/form-builder'
// FormElementProps можно обобщить своим типом конфига поля
type SelectConfig = {
name: string
label?: string
type: 'select'
options: { label: string; value: string }[]
}
const SelectField = ({ field, value, errors, onChange }: FormElementProps<SelectConfig>) => {
return (
<div>
<label>{field.label}</label>
<select
value={value ?? ''}
onChange={e => onChange(e.target.value)}
>
<option value="">Выберите...</option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{errors && (
<span style={{ color: 'red' }}>
{Object.values(errors)[0]}
</span>
)}
</div>
)
}Описание props
| Prop | Тип | Описание |
|---|---|---|
| field | FormFieldConfig | Объект конфигурации поля из layout |
| path | string | Полный путь к полю (например "address.city") |
| value | any | Текущее значение поля |
| errors | Record<string, string> | Ошибки валидации для этого поля |
| onChange | (value: any) => void | Вызывается при изменении значения |
Регистрация и использование
<FormBuilder
layout={[
{
type: 'select',
name: 'country',
label: 'Страна',
options: [
{ label: 'Россия', value: 'ru' },
{ label: 'Беларусь', value: 'by' },
]
}
]}
fields={{
select: SelectField, // ключ совпадает с type в layout
}}
/>Валидация
Валидация подключается через плагин createValidationPlugin:
import { FormBuilder, plugins } from '@idem.agency/form-builder'
const { createValidationPlugin } = plugins
function App() {
return (
<FormBuilder
layout={layout}
fields={fields}
plugins={[
createValidationPlugin() // базовая валидация
]}
/>
)
}Правила указываются в поле validation каждого поля:
const layout = [
{
type: 'email',
name: 'email',
label: 'Email',
validation: ['required', 'email']
}
]Встроенные правила
| Правило | Пример | Описание |
|---|---|---|
| required | 'required' | Поле обязательно для заполнения |
| email | 'email' | Значение должно быть валидным email |
| confirm:field | 'confirm:password' | Значение должно совпадать с другим полем |
const layout = [
{
type: 'password',
name: 'password',
label: 'Пароль',
validation: ['required']
},
{
type: 'password',
name: 'passwordConfirm',
label: 'Повторите пароль',
validation: ['required', 'confirm:password']
// ^ путь к полю, с которым сравниваем
}
]Режим валидации
По умолчанию валидация срабатывает при изменении поля. Можно включить валидацию только при отправке:
createValidationPlugin({ onSubmit: true })Кастомные правила валидации
Если встроенных правил недостаточно, создайте своё, реализовав интерфейс IUserRule.
import type { IUserRule } from '@idem.agency/form-builder'
// Правило: минимальная длина строки
const minLength: IUserRule = {
code: 'minLength', // имя правила — используется в layout
// value — текущее значение поля
// data — все данные формы (FormData)
// args — аргументы из строки правила ['8'] для 'minLength:8'
fn: (value, data, args) => {
const min = parseInt(args[0], 10)
return String(value ?? '').length >= min
},
// ::attr(N) — подставляет N-й аргумент из args
message: 'Минимальная длина — ::attr(0) символов'
}
// Правило: только числа
const onlyDigits: IUserRule = {
code: 'onlyDigits',
fn: (value) => /^\d+$/.test(String(value ?? '')),
message: 'Поле должно содержать только цифры'
}
// Правило с проверкой другого поля
const notEqualTo: IUserRule = {
code: 'notEqualTo',
fn: (value, data, args) => {
const otherValue = data.get(args[0]) // args[0] — имя другого поля
return value !== otherValue
},
message: 'Значение не должно совпадать с ::attr(0)'
}Передаём правила в плагин:
<FormBuilder
plugins={[
createValidationPlugin({
rules: [minLength, onlyDigits, notEqualTo],
onSubmit: false // валидировать в реальном времени
})
]}
layout={[
{
type: 'text',
name: 'phone',
label: 'Телефон',
validation: ['required', 'onlyDigits', 'minLength:10']
// ^ аргумент передаётся через ':'
},
{
type: 'text',
name: 'oldPassword',
label: 'Старый пароль',
},
{
type: 'text',
name: 'newPassword',
label: 'Новый пароль',
validation: ['required', 'notEqualTo:oldPassword']
}
]}
fields={fields}
/>Условная видимость полей
Плагин createVisibilityPlugin позволяет показывать и скрывать поля в зависимости от значений других полей.
import { FormBuilder, plugins } from '@idem.agency/form-builder'
const { createVisibilityPlugin } = plugins
<FormBuilder
plugins={[createVisibilityPlugin()]}
layout={layout}
fields={fields}
/>Структура правил
Правила описываются в свойстве visibility поля:
visibility: {
logic: 'and', // 'and' — все условия | 'or' — любое условие
rules: [
{
operator: '=', // оператор сравнения
field: 'fieldName', // имя поля для проверки
value: 'some value' // ожидаемое значение
}
]
}Операторы
| Оператор | Описание | Пример значения |
|---|---|---|
| = | Строгое равенство | 'business', true, 42 |
| in | Значение входит в массив | ['option1', 'option2'] |
Простой пример
const layout = [
{
type: 'text',
name: 'accountType',
label: 'Тип аккаунта'
// подсказка: пусть значения будут 'personal' или 'business'
},
{
type: 'text',
name: 'companyName',
label: 'Название компании',
// Показываем только если accountType === 'business'
visibility: {
logic: 'and',
rules: [
{ operator: '=', field: 'accountType', value: 'business' }
]
}
}
]Пример с оператором in
{
type: 'text',
name: 'vatNumber',
label: 'НДС номер',
visibility: {
logic: 'and',
rules: [
{
operator: 'in',
field: 'country',
value: ['ru', 'by', 'kz'] // показываем только для этих стран
}
]
}
}Сложные условия с вложенностью
Правила можно вкладывать друг в друга для создания условий типа (A и B) или (C и D):
{
type: 'text',
name: 'seniorBonus',
label: 'Надбавка за стаж',
visibility: {
logic: 'or',
rules: [
// Условие 1: должность директор И стаж > 10 лет
{
logic: 'and',
rules: [
{ operator: '=', field: 'position', value: 'director' },
{ operator: 'in', field: 'experience', value: ['11-20', '20+'] }
]
},
// Условие 2: должность тимлид И статус senior
{
logic: 'and',
rules: [
{ operator: '=', field: 'position', value: 'teamlead' },
{ operator: '=', field: 'level', value: 'senior' }
]
}
]
}
}Работа с ref (программное управление)
Через ref можно программно управлять формой:
import { useRef } from 'react'
import type { FormBuilderRef } from '@idem.agency/form-builder'
function App() {
const formRef = useRef<FormBuilderRef>(null)
const handleSubmit = async () => {
await formRef.current?.submit()
// Если есть ошибки, onSubmit не вызовется
}
const handleReset = () => {
formRef.current?.reset()
}
const showErrors = () => {
const errors = formRef.current?.errors()
console.log('Ошибки:', errors)
}
return (
<>
<FormBuilder ref={formRef} layout={layout} fields={fields} />
<button onClick={handleSubmit}>Отправить</button>
<button onClick={handleReset}>Сбросить</button>
<button onClick={showErrors}>Показать ошибки</button>
</>
)
}API ref
| Метод | Описание |
|---|---|
| submit() | Запускает валидацию и, если ошибок нет, вызывает onSubmit |
| reset() | Очищает все значения и ошибки |
| errors() | Возвращает объект с текущими ошибками валидации |
Подписка на значение поля
useSubscribeFormData — хук для реактивной подписки на значение конкретного поля формы по его пути. Компонент будет перерисовываться только при изменении значения по указанному пути.
import { useSubscribeFormData } from '@idem.agency/form-builder'
// Подписка на значение поля верхнего уровня
const name = useSubscribeFormData('name')
// Подписка на вложенное поле
const city = useSubscribeFormData('address.city')Хук должен использоваться внутри дерева компонентов, обёрнутого в FormBuilder, например в кастомных компонентах полей или дочерних элементах формы.
const DeliverySection = () => {
const deliveryType = useSubscribeFormData('deliveryType')
return (
<div>
{deliveryType === 'courier' && <span>Курьер доставит в течение 2 дней</span>}
{deliveryType === 'pickup' && <span>Самовывоз из ближайшего пункта</span>}
</div>
)
}
function App() {
return (
<FormBuilder layout={layout} fields={fields}>
<DeliverySection />
</FormBuilder>
)
}useBuilder — рендеринг вложенных полей
useBuilder — хук, который возвращает функцию-рендерер для динамической отрисовки вложенного layout. Он предназначен для создания кастомных контейнерных полей (группировщиков, аккордеонов, табов и т.п.), которым нужно самостоятельно рендерить дочерние поля формы.
import { useBuilder } from '@idem.agency/form-builder'
// Сигнатура возвращаемой функции:
// builder(layout, path?, children?) => ReactNode
const builder = useBuilder()Хук должен использоваться внутри компонента поля, зарегистрированного в FormBuilder.
Параметры функции builder
| Параметр | Тип | Описание |
|---|---|---|
| layout | FormFieldConfig[] | Массив дочерних полей для рендеринга |
| path | string \| undefined | Путь-префикс для вложенных полей (передаётся из props.path) |
| children | ReactNode | Дополнительный контент после полей |
Пример: кастомная группа с аккордеоном
import type { FormElementProps, FormFieldConfig } from '@idem.agency/form-builder'
import { useBuilder } from '@idem.agency/form-builder'
import { useState } from 'react'
type AccordionConfig = {
name: string
label?: string
type: 'accordion'
fields: FormFieldConfig[]
}
const AccordionGroup = ({ field, path }: FormElementProps<AccordionConfig>) => {
const [open, setOpen] = useState(false)
const builder = useBuilder()
if (!Array.isArray(field.fields)) return null
return (
<div style={{ border: '1px solid #ccc', borderRadius: 4 }}>
<button type="button" onClick={() => setOpen(v => !v)}>
{field.label} {open ? '▲' : '▼'}
</button>
{open && (
<div style={{ padding: 16 }}>
{builder(field.fields, path)}
</div>
)}
</div>
)
}
// Регистрируем и используем:
const layout = [
{ type: 'text', name: 'name', label: 'Имя' },
{
type: 'accordion',
name: 'details',
label: 'Дополнительно',
fields: [
{ type: 'text', name: 'bio', label: 'О себе' },
{ type: 'text', name: 'site', label: 'Сайт' },
]
}
]
function App() {
return (
<FormBuilder
layout={layout}
fields={{
text: TextField,
accordion: AccordionGroup,
}}
onSubmit={console.log}
/>
)
}Как это работает
useBuilder берёт текущий контекст FormBuilder и возвращает функцию, которая рендерит DynamicBuilder — тот же механизм, что используется корневой формой. Все поля, отрисованные через builder(...), полностью интегрированы в состояние формы, валидацию и систему плагинов.
Встроенный компонент
FormGroupреализован именно черезuseBuilder:const Builder = useBuilder()(field.fields, path)
Система плагинов
Плагины — основной способ расширить функциональность FormBuilder. С их помощью можно:
- Перехватывать события формы
- Трансформировать данные в пайплайне
- Регистрировать кастомные типы полей
- Перехватывать обновления состояния (middleware)
Написание собственного плагина
Плагин — это объект с методом install, который получает контекст и подписывается на нужные события.
import type { IPlugin, IPluginContext } from '@idem.agency/form-builder'
const myPlugin: IPlugin = {
name: 'my-plugin',
version: '1.0.0',
install(ctx: IPluginContext) {
// Всё взаимодействие с формой происходит здесь
},
uninstall() {
// Вызывается при размонтировании формы
// Можно убрать сайд-эффекты (таймеры, подписки и т.п.)
}
}
// Подключаем плагин
<FormBuilder
plugins={[myPlugin]}
layout={layout}
fields={fields}
/>API контекста плагина
ctx.events — подписка на события формы
ctx.events.tap('form:submit:before', ({ formData }) => {
console.log('Форма отправляется:', formData)
})
ctx.events.tap('field:change', ({ path, value }) => {
console.log(`Поле ${path} изменилось на`, value)
})Доступные события:
| Событие | Когда срабатывает |
|---|---|
| form:init | При инициализации формы |
| form:destroy | При размонтировании формы |
| form:reset | При сбросе формы |
| form:submit:before | Перед валидацией и отправкой |
| form:submit:after | После попытки отправки |
| field:register | При регистрации поля |
| field:change | При изменении значения поля |
| field:validate | После завершения валидации поля |
ctx.pipeline — трансформация данных
Пайплайн — это цепочка функций, каждая из которых получает данные, может их изменить и передаёт дальше через next.
Асинхронные пайплайны:
// Трансформация значения при вводе
ctx.pipeline.use('field:change', (data, next) => {
// data.value — текущее значение
// data.path — путь к полю
// data.field — конфиг поля
// data.formData — всё состояние формы
const trimmed = typeof data.value === 'string'
? data.value.trim()
: data.value
return next({ ...data, value: trimmed })
})
// Трансформация данных перед отправкой
ctx.pipeline.use('form:submit', (data, next) => {
// data.formData — данные формы
const cleaned = removeEmptyFields(data.formData)
return next({ ...data, formData: cleaned })
})Синхронный пайплайн видимости:
ctx.pipeline.useSync('field:visible', (data, next) => {
// data.visible — текущее решение о видимости
// Можно переопределить видимость
const isHidden = someExternalCondition(data.path)
return next({ ...data, visible: data.visible && !isHidden })
})ctx.fields — регистрация кастомных типов полей
ctx.fields.register('datepicker', MyDatepickerComponent)
ctx.fields.register('richtext', MyRichTextComponent)
// Теперь в layout можно использовать:
// { type: 'datepicker', name: 'birthDate', label: 'Дата рождения' }ctx.middleware — перехват обновлений состояния
ctx.middleware.use((action, next, getState) => {
if (action.type === 'setValue') {
const stateBefore = getState()
console.log('Было:', stateBefore.formData[action.path])
next(action)
const stateAfter = getState()
console.log('Стало:', stateAfter.formData[action.path])
} else {
next(action)
}
})Примеры готовых плагинов
Плагин маскировки телефона
const phoneMaskPlugin: IPlugin = {
name: 'phone-mask',
install(ctx) {
ctx.pipeline.use('field:change', (data, next) => {
// Применяем маску только к полям с типом 'phone'
if (data.field.type !== 'phone') {
return next(data)
}
const digits = String(data.value).replace(/\D/g, '')
let masked = ''
if (digits.length > 0) masked = `+7 (${digits.slice(1, 4)}`
if (digits.length > 4) masked += `) ${digits.slice(4, 7)}`
if (digits.length > 7) masked += `-${digits.slice(7, 9)}`
if (digits.length > 9) masked += `-${digits.slice(9, 11)}`
return next({ ...data, value: masked })
})
}
}Плагин логирования аналитики
const analyticsPlugin: IPlugin = {
name: 'analytics',
install(ctx) {
ctx.events.tap('form:submit:after', ({ formData }) => {
analytics.track('form_submitted', {
fields: Object.keys(formData)
})
})
ctx.events.tap('field:change', ({ path }) => {
analytics.track('field_interacted', { field: path })
})
}
}Плагин автосохранения в localStorage
const autosavePlugin = (storageKey: string): IPlugin => ({
name: 'autosave',
install(ctx) {
// Загружаем сохранённые данные при инициализации
ctx.pipeline.use('form:init', (data, next) => {
const saved = localStorage.getItem(storageKey)
if (saved) {
try {
return next({ ...data, formData: JSON.parse(saved) })
} catch {}
}
return next(data)
})
// Сохраняем при каждом изменении поля
ctx.pipeline.use('field:change', (data, next) => {
localStorage.setItem(storageKey, JSON.stringify(data.formData))
return next(data)
})
// Очищаем при сбросе
ctx.events.tap('form:reset', () => {
localStorage.removeItem(storageKey)
})
}
})
// Использование:
<FormBuilder
plugins={[autosavePlugin('my-form-draft')]}
layout={layout}
fields={fields}
/>Примеры
Форма регистрации
import { useRef } from 'react'
import { FormBuilder, inputs, plugins } from '@idem.agency/form-builder'
import type { FormBuilderRef } from '@idem.agency/form-builder'
const { createValidationPlugin, createVisibilityPlugin } = plugins
const layout = [
{ type: 'text', name: 'name', label: 'Имя', validation: ['required'] },
{ type: 'email', name: 'email', label: 'Email', validation: ['required', 'email'] },
{ type: 'password', name: 'password', label: 'Пароль', validation: ['required'] },
{ type: 'password', name: 'passwordConfirm', label: 'Повторите пароль', validation: ['required', 'confirm:password'] },
]
function RegistrationForm() {
const formRef = useRef<FormBuilderRef>(null)
return (
<FormBuilder
ref={formRef}
layout={layout}
fields={{
text: inputs.TextField,
email: inputs.TextField,
password: inputs.TextField,
}}
plugins={[
createValidationPlugin({ onSubmit: true }),
createVisibilityPlugin(),
]}
onSubmit={async (data) => {
await registerUser(data)
}}
>
<button type="button" onClick={() => formRef.current?.submit()}>
Зарегистрироваться
</button>
</FormBuilder>
)
}Форма с условными полями
const layout = [
{
type: 'text',
name: 'deliveryType',
label: 'Тип доставки',
// значения: 'courier' | 'pickup' | 'post'
},
{
type: 'text',
name: 'address',
label: 'Адрес доставки',
validation: ['required'],
visibility: {
logic: 'and',
rules: [
{ operator: 'in', field: 'deliveryType', value: ['courier', 'post'] }
]
}
},
{
type: 'text',
name: 'pickupPoint',
label: 'Пункт самовывоза',
visibility: {
logic: 'and',
rules: [
{ operator: '=', field: 'deliveryType', value: 'pickup' }
]
}
}
]Форма с загрузкой начальных данных
function EditProfileForm({ userId }: { userId: string }) {
const [initialData, setInitialData] = useState<FormData | undefined>(undefined)
useEffect(() => {
fetchUser(userId).then(user => {
const fd = new FormData()
fd.set('name', user.name)
fd.set('email', user.email)
setInitialData(fd)
})
}, [userId])
if (!initialData) return <div>Загрузка...</div>
return (
<FormBuilder
formData={initialData}
layout={layout}
fields={fields}
onSubmit={(data) => updateUser(userId, data)}
/>
)
}Форма с кастомным правилом и плагином
import type { IUserRule, IPlugin } from '@idem.agency/form-builder'
// Кастомное правило: строка не должна содержать запрещённые слова
const noProhibitedWords: IUserRule = {
code: 'noProhibitedWords',
fn: (value, _, args) => {
// args — это аргументы из строки 'noProhibitedWords:word1:word2'
const text = String(value ?? '').toLowerCase()
return !args.some(word => text.includes(word))
},
message: 'Поле содержит недопустимые слова'
}
// Кастомный плагин: обрезает пробелы у всех строковых полей
const trimPlugin: IPlugin = {
name: 'trim',
install(ctx) {
ctx.pipeline.use('field:change', (data, next) => {
if (typeof data.value === 'string') {
return next({ ...data, value: data.value.trimStart() })
}
return next(data)
})
}
}
function CommentForm() {
return (
<FormBuilder
layout={[
{
type: 'text',
name: 'comment',
label: 'Комментарий',
validation: ['required', 'noProhibitedWords:badword1:badword2']
// аргументы передаются через ':' ^
}
]}
fields={{ text: MyTextField }}
plugins={[
trimPlugin,
createValidationPlugin({ rules: [noProhibitedWords] }),
]}
onSubmit={console.log}
/>
)
}