@idem.agency/form-builder 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +922 -12
- package/dist/index.cjs +272 -88
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -16
- package/dist/index.d.mts +18 -16
- package/dist/index.mjs +273 -89
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -2
- package/src/index.ts +19 -5
- package/CHANGELOG.md +0 -8
- package/eslint.config.js +0 -23
- package/public/index.html +0 -13
- package/public/main.tsx +0 -90
- package/src/app/debug.tsx +0 -0
- package/src/app/index.tsx +0 -80
- package/src/app/test.css +0 -1
- package/src/entity/inputs/index.ts +0 -2
- package/src/entity/inputs/ui/group/index.tsx +0 -28
- package/src/entity/inputs/ui/input/index.tsx +0 -31
- package/src/shared/hook/useUpdateEffect.tsx +0 -23
- package/src/shared/lib/VisibleCore.spec.ts +0 -103
- package/src/shared/lib/VisibleCore.ts +0 -43
- package/src/shared/lib/validation/core.spec.ts +0 -103
- package/src/shared/lib/validation/core.ts +0 -79
- package/src/shared/lib/validation/rules/base.ts +0 -10
- package/src/shared/lib/validation/rules/confirm.spec.ts +0 -17
- package/src/shared/lib/validation/rules/confirm.ts +0 -32
- package/src/shared/lib/validation/rules/email.spec.ts +0 -12
- package/src/shared/lib/validation/rules/email.ts +0 -13
- package/src/shared/lib/validation/rules/require.spec.ts +0 -13
- package/src/shared/lib/validation/rules/require.ts +0 -12
- package/src/shared/model/builder/createContext.tsx +0 -40
- package/src/shared/model/builder/index.ts +0 -6
- package/src/shared/model/index.ts +0 -12
- package/src/shared/model/store/createStoreContext.tsx +0 -74
- package/src/shared/model/store/index.ts +0 -46
- package/src/shared/model/store/store.ts +0 -27
- package/src/shared/types/common.ts +0 -79
- package/src/shared/utils.ts +0 -25
- package/src/widgets/dynamicBuilder/element.tsx +0 -31
- package/src/widgets/dynamicBuilder/index.tsx +0 -33
- package/tsconfig.json +0 -24
- package/tsdown.config.ts +0 -10
- package/vite.config.ts +0 -11
package/README.md
CHANGED
|
@@ -1,20 +1,930 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @idem.agency/form-builder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Динамический конструктор форм для React с поддержкой валидации, условной видимости полей и расширяемой системой плагинов.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Содержание
|
|
6
6
|
|
|
7
|
+
- [Установка](#установка)
|
|
8
|
+
- [Быстрый старт](#быстрый-старт)
|
|
9
|
+
- [Структура схемы формы](#структура-схемы-формы)
|
|
10
|
+
- [Встроенные компоненты полей](#встроенные-компоненты-полей)
|
|
11
|
+
- [Создание кастомных полей](#создание-кастомных-полей)
|
|
12
|
+
- [Валидация](#валидация)
|
|
13
|
+
- [Встроенные правила](#встроенные-правила)
|
|
14
|
+
- [Кастомные правила валидации](#кастомные-правила-валидации)
|
|
15
|
+
- [Условная видимость полей](#условная-видимость-полей)
|
|
16
|
+
- [Работа с ref (программное управление)](#работа-с-ref-программное-управление)
|
|
17
|
+
- [Система плагинов](#система-плагинов)
|
|
18
|
+
- [Написание собственного плагина](#написание-собственного-плагина)
|
|
19
|
+
- [API контекста плагина](#api-контекста-плагина)
|
|
20
|
+
- [Примеры](#примеры)
|
|
7
21
|
|
|
8
|
-
|
|
22
|
+
---
|
|
9
23
|
|
|
10
|
-
|
|
11
|
-
|:-------------------|:------------------------------------------|:---------------------:|
|
|
12
|
-
| formData | Стартовые данные формы | |
|
|
13
|
-
| layout | Структура формы | Да |
|
|
14
|
-
| plugins | Объект с доступными компонентами формы | Да |
|
|
15
|
-
| onChange | Событие на изменение данных формы | |
|
|
16
|
-
| onSubmit | Событие на отправку формы | |
|
|
17
|
-
| children | Элемент который добавляется внутри формы | |
|
|
24
|
+
## Установка
|
|
18
25
|
|
|
26
|
+
```bash
|
|
27
|
+
npm install @idem.agency/form-builder
|
|
28
|
+
```
|
|
19
29
|
|
|
30
|
+
---
|
|
20
31
|
|
|
32
|
+
## Быстрый старт
|
|
33
|
+
|
|
34
|
+
Минимальный рабочий пример формы с одним полем:
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useRef } from 'react'
|
|
38
|
+
import { FormBuilder } from '@idem.agency/form-builder'
|
|
39
|
+
import type { FormElementProps, FormBuilderRef } from '@idem.agency/form-builder'
|
|
40
|
+
|
|
41
|
+
// 1. Создаём компонент поля
|
|
42
|
+
const TextField = ({ field, value, errors, onChange }: FormElementProps) => (
|
|
43
|
+
<div>
|
|
44
|
+
<label>{field.label}</label>
|
|
45
|
+
<input
|
|
46
|
+
value={value ?? ''}
|
|
47
|
+
onChange={e => onChange(e.target.value)}
|
|
48
|
+
/>
|
|
49
|
+
{errors && <span style={{ color: 'red' }}>{Object.values(errors)[0]}</span>}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// 2. Описываем схему формы
|
|
54
|
+
const layout = [
|
|
55
|
+
{ type: 'text', name: 'username', label: 'Имя пользователя' },
|
|
56
|
+
{ type: 'text', name: 'email', label: 'Email' },
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
// 3. Регистрируем поля и рендерим форму
|
|
60
|
+
function App() {
|
|
61
|
+
const formRef = useRef<FormBuilderRef>(null)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<FormBuilder
|
|
65
|
+
ref={formRef}
|
|
66
|
+
layout={layout}
|
|
67
|
+
fields={{ text: TextField }}
|
|
68
|
+
onSubmit={(data) => console.log('Данные формы:', data)}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Структура схемы формы
|
|
77
|
+
|
|
78
|
+
`layout` — это массив объектов, каждый из которых описывает одно поле формы.
|
|
79
|
+
|
|
80
|
+
### Базовые свойства
|
|
81
|
+
|
|
82
|
+
| Свойство | Тип | Обязательно | Описание |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| `name` | `string` | Да | Уникальный идентификатор поля |
|
|
85
|
+
| `type` | `string` | Да | Тип поля — ключ в объекте `fields` |
|
|
86
|
+
| `label` | `string` | Нет | Подпись поля |
|
|
87
|
+
| `validation` | `string[]` | Нет | Правила валидации |
|
|
88
|
+
| `visibility` | `TGroupRules` | Нет | Условия показа поля |
|
|
89
|
+
|
|
90
|
+
### Вложенность данных
|
|
91
|
+
|
|
92
|
+
Данные формы повторяют структуру `layout`. Вложенные группы создают вложенные объекты:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
const layout = [
|
|
96
|
+
{ type: 'text', name: 'username' },
|
|
97
|
+
{
|
|
98
|
+
type: 'group',
|
|
99
|
+
name: 'address',
|
|
100
|
+
fields: [
|
|
101
|
+
{ type: 'text', name: 'city' },
|
|
102
|
+
{ type: 'text', name: 'street' },
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
// onSubmit получит:
|
|
108
|
+
// {
|
|
109
|
+
// username: 'john',
|
|
110
|
+
// address: {
|
|
111
|
+
// city: 'Москва',
|
|
112
|
+
// street: 'Ленина 1'
|
|
113
|
+
// }
|
|
114
|
+
// }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Встроенные компоненты полей
|
|
120
|
+
|
|
121
|
+
Пакет поставляется с готовыми базовыми компонентами:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { inputs } from '@idem.agency/form-builder'
|
|
125
|
+
|
|
126
|
+
const { TextField, FormGroup } = inputs
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### TextField
|
|
130
|
+
|
|
131
|
+
Текстовый ввод. Поддерживает типы `text`, `email`, `password`.
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
<FormBuilder
|
|
135
|
+
layout={[
|
|
136
|
+
{ type: 'text', name: 'name', label: 'Имя' },
|
|
137
|
+
{ type: 'email', name: 'email', label: 'Email' },
|
|
138
|
+
{ type: 'password', name: 'password', label: 'Пароль' },
|
|
139
|
+
]}
|
|
140
|
+
fields={{
|
|
141
|
+
text: inputs.TextField,
|
|
142
|
+
email: inputs.TextField,
|
|
143
|
+
password: inputs.TextField,
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### FormGroup
|
|
149
|
+
|
|
150
|
+
Контейнер для группировки полей. Поддерживает горизонтальное и вертикальное расположение.
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
{
|
|
154
|
+
type: 'group',
|
|
155
|
+
name: 'contacts',
|
|
156
|
+
label: 'Контактные данные',
|
|
157
|
+
variant: 'row', // 'row' | 'col' (по умолчанию 'col')
|
|
158
|
+
fields: [
|
|
159
|
+
{ type: 'text', name: 'phone', label: 'Телефон' },
|
|
160
|
+
{ type: 'email', name: 'email', label: 'Email' },
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Создание кастомных полей
|
|
168
|
+
|
|
169
|
+
Любой React-компонент, принимающий нужные props, может стать полем формы.
|
|
170
|
+
|
|
171
|
+
### Типизация props
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import type { FormElementProps } from '@idem.agency/form-builder'
|
|
175
|
+
|
|
176
|
+
// FormElementProps можно обобщить своим типом конфига поля
|
|
177
|
+
type SelectConfig = {
|
|
178
|
+
name: string
|
|
179
|
+
label?: string
|
|
180
|
+
type: 'select'
|
|
181
|
+
options: { label: string; value: string }[]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const SelectField = ({ field, value, errors, onChange }: FormElementProps<SelectConfig>) => {
|
|
185
|
+
return (
|
|
186
|
+
<div>
|
|
187
|
+
<label>{field.label}</label>
|
|
188
|
+
<select
|
|
189
|
+
value={value ?? ''}
|
|
190
|
+
onChange={e => onChange(e.target.value)}
|
|
191
|
+
>
|
|
192
|
+
<option value="">Выберите...</option>
|
|
193
|
+
{field.options.map(opt => (
|
|
194
|
+
<option key={opt.value} value={opt.value}>
|
|
195
|
+
{opt.label}
|
|
196
|
+
</option>
|
|
197
|
+
))}
|
|
198
|
+
</select>
|
|
199
|
+
{errors && (
|
|
200
|
+
<span style={{ color: 'red' }}>
|
|
201
|
+
{Object.values(errors)[0]}
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Описание props
|
|
210
|
+
|
|
211
|
+
| Prop | Тип | Описание |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `field` | `FormFieldConfig` | Объект конфигурации поля из `layout` |
|
|
214
|
+
| `path` | `string` | Полный путь к полю (например `"address.city"`) |
|
|
215
|
+
| `value` | `any` | Текущее значение поля |
|
|
216
|
+
| `errors` | `Record<string, string>` | Ошибки валидации для этого поля |
|
|
217
|
+
| `onChange` | `(value: any) => void` | Вызывается при изменении значения |
|
|
218
|
+
|
|
219
|
+
### Регистрация и использование
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
<FormBuilder
|
|
223
|
+
layout={[
|
|
224
|
+
{
|
|
225
|
+
type: 'select',
|
|
226
|
+
name: 'country',
|
|
227
|
+
label: 'Страна',
|
|
228
|
+
options: [
|
|
229
|
+
{ label: 'Россия', value: 'ru' },
|
|
230
|
+
{ label: 'Беларусь', value: 'by' },
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
]}
|
|
234
|
+
fields={{
|
|
235
|
+
select: SelectField, // ключ совпадает с type в layout
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Валидация
|
|
243
|
+
|
|
244
|
+
Валидация подключается через плагин `createValidationPlugin`:
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
import { FormBuilder, plugins } from '@idem.agency/form-builder'
|
|
248
|
+
|
|
249
|
+
const { createValidationPlugin } = plugins
|
|
250
|
+
|
|
251
|
+
function App() {
|
|
252
|
+
return (
|
|
253
|
+
<FormBuilder
|
|
254
|
+
layout={layout}
|
|
255
|
+
fields={fields}
|
|
256
|
+
plugins={[
|
|
257
|
+
createValidationPlugin() // базовая валидация
|
|
258
|
+
]}
|
|
259
|
+
/>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Правила указываются в поле `validation` каждого поля:
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
const layout = [
|
|
268
|
+
{
|
|
269
|
+
type: 'email',
|
|
270
|
+
name: 'email',
|
|
271
|
+
label: 'Email',
|
|
272
|
+
validation: ['required', 'email']
|
|
273
|
+
}
|
|
274
|
+
]
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Встроенные правила
|
|
278
|
+
|
|
279
|
+
| Правило | Пример | Описание |
|
|
280
|
+
|---|---|---|
|
|
281
|
+
| `required` | `'required'` | Поле обязательно для заполнения |
|
|
282
|
+
| `email` | `'email'` | Значение должно быть валидным email |
|
|
283
|
+
| `confirm:field` | `'confirm:password'` | Значение должно совпадать с другим полем |
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
const layout = [
|
|
287
|
+
{
|
|
288
|
+
type: 'password',
|
|
289
|
+
name: 'password',
|
|
290
|
+
label: 'Пароль',
|
|
291
|
+
validation: ['required']
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
type: 'password',
|
|
295
|
+
name: 'passwordConfirm',
|
|
296
|
+
label: 'Повторите пароль',
|
|
297
|
+
validation: ['required', 'confirm:password']
|
|
298
|
+
// ^ путь к полю, с которым сравниваем
|
|
299
|
+
}
|
|
300
|
+
]
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Режим валидации
|
|
304
|
+
|
|
305
|
+
По умолчанию валидация срабатывает при изменении поля. Можно включить валидацию только при отправке:
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
createValidationPlugin({ onSubmit: true })
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Кастомные правила валидации
|
|
312
|
+
|
|
313
|
+
Если встроенных правил недостаточно, создайте своё, реализовав интерфейс `IUserRule`.
|
|
314
|
+
|
|
315
|
+
```tsx
|
|
316
|
+
import type { IUserRule } from '@idem.agency/form-builder'
|
|
317
|
+
|
|
318
|
+
// Правило: минимальная длина строки
|
|
319
|
+
const minLength: IUserRule = {
|
|
320
|
+
code: 'minLength', // имя правила — используется в layout
|
|
321
|
+
|
|
322
|
+
// value — текущее значение поля
|
|
323
|
+
// data — все данные формы (FormData)
|
|
324
|
+
// args — аргументы из строки правила ['8'] для 'minLength:8'
|
|
325
|
+
fn: (value, data, args) => {
|
|
326
|
+
const min = parseInt(args[0], 10)
|
|
327
|
+
return String(value ?? '').length >= min
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// ::attr(N) — подставляет N-й аргумент из args
|
|
331
|
+
message: 'Минимальная длина — ::attr(0) символов'
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Правило: только числа
|
|
335
|
+
const onlyDigits: IUserRule = {
|
|
336
|
+
code: 'onlyDigits',
|
|
337
|
+
fn: (value) => /^\d+$/.test(String(value ?? '')),
|
|
338
|
+
message: 'Поле должно содержать только цифры'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Правило с проверкой другого поля
|
|
342
|
+
const notEqualTo: IUserRule = {
|
|
343
|
+
code: 'notEqualTo',
|
|
344
|
+
fn: (value, data, args) => {
|
|
345
|
+
const otherValue = data.get(args[0]) // args[0] — имя другого поля
|
|
346
|
+
return value !== otherValue
|
|
347
|
+
},
|
|
348
|
+
message: 'Значение не должно совпадать с ::attr(0)'
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Передаём правила в плагин:
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
<FormBuilder
|
|
356
|
+
plugins={[
|
|
357
|
+
createValidationPlugin({
|
|
358
|
+
rules: [minLength, onlyDigits, notEqualTo],
|
|
359
|
+
onSubmit: false // валидировать в реальном времени
|
|
360
|
+
})
|
|
361
|
+
]}
|
|
362
|
+
layout={[
|
|
363
|
+
{
|
|
364
|
+
type: 'text',
|
|
365
|
+
name: 'phone',
|
|
366
|
+
label: 'Телефон',
|
|
367
|
+
validation: ['required', 'onlyDigits', 'minLength:10']
|
|
368
|
+
// ^ аргумент передаётся через ':'
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
type: 'text',
|
|
372
|
+
name: 'oldPassword',
|
|
373
|
+
label: 'Старый пароль',
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
type: 'text',
|
|
377
|
+
name: 'newPassword',
|
|
378
|
+
label: 'Новый пароль',
|
|
379
|
+
validation: ['required', 'notEqualTo:oldPassword']
|
|
380
|
+
}
|
|
381
|
+
]}
|
|
382
|
+
fields={fields}
|
|
383
|
+
/>
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## Условная видимость полей
|
|
389
|
+
|
|
390
|
+
Плагин `createVisibilityPlugin` позволяет показывать и скрывать поля в зависимости от значений других полей.
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
import { FormBuilder, plugins } from '@idem.agency/form-builder'
|
|
394
|
+
|
|
395
|
+
const { createVisibilityPlugin } = plugins
|
|
396
|
+
|
|
397
|
+
<FormBuilder
|
|
398
|
+
plugins={[createVisibilityPlugin()]}
|
|
399
|
+
layout={layout}
|
|
400
|
+
fields={fields}
|
|
401
|
+
/>
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Структура правил
|
|
405
|
+
|
|
406
|
+
Правила описываются в свойстве `visibility` поля:
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
visibility: {
|
|
410
|
+
logic: 'and', // 'and' — все условия | 'or' — любое условие
|
|
411
|
+
rules: [
|
|
412
|
+
{
|
|
413
|
+
operator: '=', // оператор сравнения
|
|
414
|
+
field: 'fieldName', // имя поля для проверки
|
|
415
|
+
value: 'some value' // ожидаемое значение
|
|
416
|
+
}
|
|
417
|
+
]
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Операторы
|
|
422
|
+
|
|
423
|
+
| Оператор | Описание | Пример значения |
|
|
424
|
+
|---|---|---|
|
|
425
|
+
| `=` | Строгое равенство | `'business'`, `true`, `42` |
|
|
426
|
+
| `in` | Значение входит в массив | `['option1', 'option2']` |
|
|
427
|
+
|
|
428
|
+
### Простой пример
|
|
429
|
+
|
|
430
|
+
```tsx
|
|
431
|
+
const layout = [
|
|
432
|
+
{
|
|
433
|
+
type: 'text',
|
|
434
|
+
name: 'accountType',
|
|
435
|
+
label: 'Тип аккаунта'
|
|
436
|
+
// подсказка: пусть значения будут 'personal' или 'business'
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
type: 'text',
|
|
440
|
+
name: 'companyName',
|
|
441
|
+
label: 'Название компании',
|
|
442
|
+
// Показываем только если accountType === 'business'
|
|
443
|
+
visibility: {
|
|
444
|
+
logic: 'and',
|
|
445
|
+
rules: [
|
|
446
|
+
{ operator: '=', field: 'accountType', value: 'business' }
|
|
447
|
+
]
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
]
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Пример с оператором `in`
|
|
454
|
+
|
|
455
|
+
```tsx
|
|
456
|
+
{
|
|
457
|
+
type: 'text',
|
|
458
|
+
name: 'vatNumber',
|
|
459
|
+
label: 'НДС номер',
|
|
460
|
+
visibility: {
|
|
461
|
+
logic: 'and',
|
|
462
|
+
rules: [
|
|
463
|
+
{
|
|
464
|
+
operator: 'in',
|
|
465
|
+
field: 'country',
|
|
466
|
+
value: ['ru', 'by', 'kz'] // показываем только для этих стран
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Сложные условия с вложенностью
|
|
474
|
+
|
|
475
|
+
Правила можно вкладывать друг в друга для создания условий типа `(A и B) или (C и D)`:
|
|
476
|
+
|
|
477
|
+
```tsx
|
|
478
|
+
{
|
|
479
|
+
type: 'text',
|
|
480
|
+
name: 'seniorBonus',
|
|
481
|
+
label: 'Надбавка за стаж',
|
|
482
|
+
visibility: {
|
|
483
|
+
logic: 'or',
|
|
484
|
+
rules: [
|
|
485
|
+
// Условие 1: должность директор И стаж > 10 лет
|
|
486
|
+
{
|
|
487
|
+
logic: 'and',
|
|
488
|
+
rules: [
|
|
489
|
+
{ operator: '=', field: 'position', value: 'director' },
|
|
490
|
+
{ operator: 'in', field: 'experience', value: ['11-20', '20+'] }
|
|
491
|
+
]
|
|
492
|
+
},
|
|
493
|
+
// Условие 2: должность тимлид И статус senior
|
|
494
|
+
{
|
|
495
|
+
logic: 'and',
|
|
496
|
+
rules: [
|
|
497
|
+
{ operator: '=', field: 'position', value: 'teamlead' },
|
|
498
|
+
{ operator: '=', field: 'level', value: 'senior' }
|
|
499
|
+
]
|
|
500
|
+
}
|
|
501
|
+
]
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Работа с ref (программное управление)
|
|
509
|
+
|
|
510
|
+
Через `ref` можно программно управлять формой:
|
|
511
|
+
|
|
512
|
+
```tsx
|
|
513
|
+
import { useRef } from 'react'
|
|
514
|
+
import type { FormBuilderRef } from '@idem.agency/form-builder'
|
|
515
|
+
|
|
516
|
+
function App() {
|
|
517
|
+
const formRef = useRef<FormBuilderRef>(null)
|
|
518
|
+
|
|
519
|
+
const handleSubmit = async () => {
|
|
520
|
+
await formRef.current?.submit()
|
|
521
|
+
// Если есть ошибки, onSubmit не вызовется
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const handleReset = () => {
|
|
525
|
+
formRef.current?.reset()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const showErrors = () => {
|
|
529
|
+
const errors = formRef.current?.errors()
|
|
530
|
+
console.log('Ошибки:', errors)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<>
|
|
535
|
+
<FormBuilder ref={formRef} layout={layout} fields={fields} />
|
|
536
|
+
<button onClick={handleSubmit}>Отправить</button>
|
|
537
|
+
<button onClick={handleReset}>Сбросить</button>
|
|
538
|
+
<button onClick={showErrors}>Показать ошибки</button>
|
|
539
|
+
</>
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### API ref
|
|
545
|
+
|
|
546
|
+
| Метод | Описание |
|
|
547
|
+
|---|---|
|
|
548
|
+
| `submit()` | Запускает валидацию и, если ошибок нет, вызывает `onSubmit` |
|
|
549
|
+
| `reset()` | Очищает все значения и ошибки |
|
|
550
|
+
| `errors()` | Возвращает объект с текущими ошибками валидации |
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## Система плагинов
|
|
555
|
+
|
|
556
|
+
Плагины — основной способ расширить функциональность `FormBuilder`. С их помощью можно:
|
|
557
|
+
|
|
558
|
+
- Перехватывать события формы
|
|
559
|
+
- Трансформировать данные в пайплайне
|
|
560
|
+
- Регистрировать кастомные типы полей
|
|
561
|
+
- Перехватывать обновления состояния (middleware)
|
|
562
|
+
|
|
563
|
+
### Написание собственного плагина
|
|
564
|
+
|
|
565
|
+
Плагин — это объект с методом `install`, который получает контекст и подписывается на нужные события.
|
|
566
|
+
|
|
567
|
+
```tsx
|
|
568
|
+
import type { IPlugin, IPluginContext } from '@idem.agency/form-builder'
|
|
569
|
+
|
|
570
|
+
const myPlugin: IPlugin = {
|
|
571
|
+
name: 'my-plugin',
|
|
572
|
+
version: '1.0.0',
|
|
573
|
+
|
|
574
|
+
install(ctx: IPluginContext) {
|
|
575
|
+
// Всё взаимодействие с формой происходит здесь
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
uninstall() {
|
|
579
|
+
// Вызывается при размонтировании формы
|
|
580
|
+
// Можно убрать сайд-эффекты (таймеры, подписки и т.п.)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Подключаем плагин
|
|
585
|
+
<FormBuilder
|
|
586
|
+
plugins={[myPlugin]}
|
|
587
|
+
layout={layout}
|
|
588
|
+
fields={fields}
|
|
589
|
+
/>
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
### API контекста плагина
|
|
593
|
+
|
|
594
|
+
#### `ctx.events` — подписка на события формы
|
|
595
|
+
|
|
596
|
+
```tsx
|
|
597
|
+
ctx.events.tap('form:submit:before', ({ formData }) => {
|
|
598
|
+
console.log('Форма отправляется:', formData)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
ctx.events.tap('field:change', ({ path, value }) => {
|
|
602
|
+
console.log(`Поле ${path} изменилось на`, value)
|
|
603
|
+
})
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Доступные события:
|
|
607
|
+
|
|
608
|
+
| Событие | Когда срабатывает |
|
|
609
|
+
|---|---|
|
|
610
|
+
| `form:init` | При инициализации формы |
|
|
611
|
+
| `form:destroy` | При размонтировании формы |
|
|
612
|
+
| `form:reset` | При сбросе формы |
|
|
613
|
+
| `form:submit:before` | Перед валидацией и отправкой |
|
|
614
|
+
| `form:submit:after` | После попытки отправки |
|
|
615
|
+
| `field:register` | При регистрации поля |
|
|
616
|
+
| `field:change` | При изменении значения поля |
|
|
617
|
+
| `field:validate` | После завершения валидации поля |
|
|
618
|
+
|
|
619
|
+
#### `ctx.pipeline` — трансформация данных
|
|
620
|
+
|
|
621
|
+
Пайплайн — это цепочка функций, каждая из которых получает данные, может их изменить и передаёт дальше через `next`.
|
|
622
|
+
|
|
623
|
+
**Асинхронные пайплайны:**
|
|
624
|
+
|
|
625
|
+
```tsx
|
|
626
|
+
// Трансформация значения при вводе
|
|
627
|
+
ctx.pipeline.use('field:change', (data, next) => {
|
|
628
|
+
// data.value — текущее значение
|
|
629
|
+
// data.path — путь к полю
|
|
630
|
+
// data.field — конфиг поля
|
|
631
|
+
// data.formData — всё состояние формы
|
|
632
|
+
const trimmed = typeof data.value === 'string'
|
|
633
|
+
? data.value.trim()
|
|
634
|
+
: data.value
|
|
635
|
+
|
|
636
|
+
return next({ ...data, value: trimmed })
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
// Трансформация данных перед отправкой
|
|
640
|
+
ctx.pipeline.use('form:submit', (data, next) => {
|
|
641
|
+
// data.formData — данные формы
|
|
642
|
+
const cleaned = removeEmptyFields(data.formData)
|
|
643
|
+
return next({ ...data, formData: cleaned })
|
|
644
|
+
})
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
**Синхронный пайплайн видимости:**
|
|
648
|
+
|
|
649
|
+
```tsx
|
|
650
|
+
ctx.pipeline.useSync('field:visible', (data, next) => {
|
|
651
|
+
// data.visible — текущее решение о видимости
|
|
652
|
+
// Можно переопределить видимость
|
|
653
|
+
const isHidden = someExternalCondition(data.path)
|
|
654
|
+
return next({ ...data, visible: data.visible && !isHidden })
|
|
655
|
+
})
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
#### `ctx.fields` — регистрация кастомных типов полей
|
|
659
|
+
|
|
660
|
+
```tsx
|
|
661
|
+
ctx.fields.register('datepicker', MyDatepickerComponent)
|
|
662
|
+
ctx.fields.register('richtext', MyRichTextComponent)
|
|
663
|
+
|
|
664
|
+
// Теперь в layout можно использовать:
|
|
665
|
+
// { type: 'datepicker', name: 'birthDate', label: 'Дата рождения' }
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
#### `ctx.middleware` — перехват обновлений состояния
|
|
669
|
+
|
|
670
|
+
```tsx
|
|
671
|
+
ctx.middleware.use((action, next, getState) => {
|
|
672
|
+
if (action.type === 'setValue') {
|
|
673
|
+
const stateBefore = getState()
|
|
674
|
+
console.log('Было:', stateBefore.formData[action.path])
|
|
675
|
+
next(action)
|
|
676
|
+
const stateAfter = getState()
|
|
677
|
+
console.log('Стало:', stateAfter.formData[action.path])
|
|
678
|
+
} else {
|
|
679
|
+
next(action)
|
|
680
|
+
}
|
|
681
|
+
})
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
### Примеры готовых плагинов
|
|
685
|
+
|
|
686
|
+
#### Плагин маскировки телефона
|
|
687
|
+
|
|
688
|
+
```tsx
|
|
689
|
+
const phoneMaskPlugin: IPlugin = {
|
|
690
|
+
name: 'phone-mask',
|
|
691
|
+
install(ctx) {
|
|
692
|
+
ctx.pipeline.use('field:change', (data, next) => {
|
|
693
|
+
// Применяем маску только к полям с типом 'phone'
|
|
694
|
+
if (data.field.type !== 'phone') {
|
|
695
|
+
return next(data)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const digits = String(data.value).replace(/\D/g, '')
|
|
699
|
+
let masked = ''
|
|
700
|
+
if (digits.length > 0) masked = `+7 (${digits.slice(1, 4)}`
|
|
701
|
+
if (digits.length > 4) masked += `) ${digits.slice(4, 7)}`
|
|
702
|
+
if (digits.length > 7) masked += `-${digits.slice(7, 9)}`
|
|
703
|
+
if (digits.length > 9) masked += `-${digits.slice(9, 11)}`
|
|
704
|
+
|
|
705
|
+
return next({ ...data, value: masked })
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
#### Плагин логирования аналитики
|
|
712
|
+
|
|
713
|
+
```tsx
|
|
714
|
+
const analyticsPlugin: IPlugin = {
|
|
715
|
+
name: 'analytics',
|
|
716
|
+
install(ctx) {
|
|
717
|
+
ctx.events.tap('form:submit:after', ({ formData }) => {
|
|
718
|
+
analytics.track('form_submitted', {
|
|
719
|
+
fields: Object.keys(formData)
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
ctx.events.tap('field:change', ({ path }) => {
|
|
724
|
+
analytics.track('field_interacted', { field: path })
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
#### Плагин автосохранения в localStorage
|
|
731
|
+
|
|
732
|
+
```tsx
|
|
733
|
+
const autosavePlugin = (storageKey: string): IPlugin => ({
|
|
734
|
+
name: 'autosave',
|
|
735
|
+
install(ctx) {
|
|
736
|
+
// Загружаем сохранённые данные при инициализации
|
|
737
|
+
ctx.pipeline.use('form:init', (data, next) => {
|
|
738
|
+
const saved = localStorage.getItem(storageKey)
|
|
739
|
+
if (saved) {
|
|
740
|
+
try {
|
|
741
|
+
return next({ ...data, formData: JSON.parse(saved) })
|
|
742
|
+
} catch {}
|
|
743
|
+
}
|
|
744
|
+
return next(data)
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
// Сохраняем при каждом изменении поля
|
|
748
|
+
ctx.pipeline.use('field:change', (data, next) => {
|
|
749
|
+
localStorage.setItem(storageKey, JSON.stringify(data.formData))
|
|
750
|
+
return next(data)
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
// Очищаем при сбросе
|
|
754
|
+
ctx.events.tap('form:reset', () => {
|
|
755
|
+
localStorage.removeItem(storageKey)
|
|
756
|
+
})
|
|
757
|
+
}
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// Использование:
|
|
761
|
+
<FormBuilder
|
|
762
|
+
plugins={[autosavePlugin('my-form-draft')]}
|
|
763
|
+
layout={layout}
|
|
764
|
+
fields={fields}
|
|
765
|
+
/>
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
---
|
|
769
|
+
|
|
770
|
+
## Примеры
|
|
771
|
+
|
|
772
|
+
### Форма регистрации
|
|
773
|
+
|
|
774
|
+
```tsx
|
|
775
|
+
import { useRef } from 'react'
|
|
776
|
+
import { FormBuilder, inputs, plugins } from '@idem.agency/form-builder'
|
|
777
|
+
import type { FormBuilderRef } from '@idem.agency/form-builder'
|
|
778
|
+
|
|
779
|
+
const { createValidationPlugin, createVisibilityPlugin } = plugins
|
|
780
|
+
|
|
781
|
+
const layout = [
|
|
782
|
+
{ type: 'text', name: 'name', label: 'Имя', validation: ['required'] },
|
|
783
|
+
{ type: 'email', name: 'email', label: 'Email', validation: ['required', 'email'] },
|
|
784
|
+
{ type: 'password', name: 'password', label: 'Пароль', validation: ['required'] },
|
|
785
|
+
{ type: 'password', name: 'passwordConfirm', label: 'Повторите пароль', validation: ['required', 'confirm:password'] },
|
|
786
|
+
]
|
|
787
|
+
|
|
788
|
+
function RegistrationForm() {
|
|
789
|
+
const formRef = useRef<FormBuilderRef>(null)
|
|
790
|
+
|
|
791
|
+
return (
|
|
792
|
+
<FormBuilder
|
|
793
|
+
ref={formRef}
|
|
794
|
+
layout={layout}
|
|
795
|
+
fields={{
|
|
796
|
+
text: inputs.TextField,
|
|
797
|
+
email: inputs.TextField,
|
|
798
|
+
password: inputs.TextField,
|
|
799
|
+
}}
|
|
800
|
+
plugins={[
|
|
801
|
+
createValidationPlugin({ onSubmit: true }),
|
|
802
|
+
createVisibilityPlugin(),
|
|
803
|
+
]}
|
|
804
|
+
onSubmit={async (data) => {
|
|
805
|
+
await registerUser(data)
|
|
806
|
+
}}
|
|
807
|
+
>
|
|
808
|
+
<button type="button" onClick={() => formRef.current?.submit()}>
|
|
809
|
+
Зарегистрироваться
|
|
810
|
+
</button>
|
|
811
|
+
</FormBuilder>
|
|
812
|
+
)
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
### Форма с условными полями
|
|
817
|
+
|
|
818
|
+
```tsx
|
|
819
|
+
const layout = [
|
|
820
|
+
{
|
|
821
|
+
type: 'text',
|
|
822
|
+
name: 'deliveryType',
|
|
823
|
+
label: 'Тип доставки',
|
|
824
|
+
// значения: 'courier' | 'pickup' | 'post'
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
type: 'text',
|
|
828
|
+
name: 'address',
|
|
829
|
+
label: 'Адрес доставки',
|
|
830
|
+
validation: ['required'],
|
|
831
|
+
visibility: {
|
|
832
|
+
logic: 'and',
|
|
833
|
+
rules: [
|
|
834
|
+
{ operator: 'in', field: 'deliveryType', value: ['courier', 'post'] }
|
|
835
|
+
]
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
type: 'text',
|
|
840
|
+
name: 'pickupPoint',
|
|
841
|
+
label: 'Пункт самовывоза',
|
|
842
|
+
visibility: {
|
|
843
|
+
logic: 'and',
|
|
844
|
+
rules: [
|
|
845
|
+
{ operator: '=', field: 'deliveryType', value: 'pickup' }
|
|
846
|
+
]
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
]
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
### Форма с загрузкой начальных данных
|
|
853
|
+
|
|
854
|
+
```tsx
|
|
855
|
+
function EditProfileForm({ userId }: { userId: string }) {
|
|
856
|
+
const [initialData, setInitialData] = useState<FormData | undefined>(undefined)
|
|
857
|
+
|
|
858
|
+
useEffect(() => {
|
|
859
|
+
fetchUser(userId).then(user => {
|
|
860
|
+
const fd = new FormData()
|
|
861
|
+
fd.set('name', user.name)
|
|
862
|
+
fd.set('email', user.email)
|
|
863
|
+
setInitialData(fd)
|
|
864
|
+
})
|
|
865
|
+
}, [userId])
|
|
866
|
+
|
|
867
|
+
if (!initialData) return <div>Загрузка...</div>
|
|
868
|
+
|
|
869
|
+
return (
|
|
870
|
+
<FormBuilder
|
|
871
|
+
formData={initialData}
|
|
872
|
+
layout={layout}
|
|
873
|
+
fields={fields}
|
|
874
|
+
onSubmit={(data) => updateUser(userId, data)}
|
|
875
|
+
/>
|
|
876
|
+
)
|
|
877
|
+
}
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
### Форма с кастомным правилом и плагином
|
|
881
|
+
|
|
882
|
+
```tsx
|
|
883
|
+
import type { IUserRule, IPlugin } from '@idem.agency/form-builder'
|
|
884
|
+
|
|
885
|
+
// Кастомное правило: строка не должна содержать запрещённые слова
|
|
886
|
+
const noProhibitedWords: IUserRule = {
|
|
887
|
+
code: 'noProhibitedWords',
|
|
888
|
+
fn: (value, _, args) => {
|
|
889
|
+
// args — это аргументы из строки 'noProhibitedWords:word1:word2'
|
|
890
|
+
const text = String(value ?? '').toLowerCase()
|
|
891
|
+
return !args.some(word => text.includes(word))
|
|
892
|
+
},
|
|
893
|
+
message: 'Поле содержит недопустимые слова'
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Кастомный плагин: обрезает пробелы у всех строковых полей
|
|
897
|
+
const trimPlugin: IPlugin = {
|
|
898
|
+
name: 'trim',
|
|
899
|
+
install(ctx) {
|
|
900
|
+
ctx.pipeline.use('field:change', (data, next) => {
|
|
901
|
+
if (typeof data.value === 'string') {
|
|
902
|
+
return next({ ...data, value: data.value.trimStart() })
|
|
903
|
+
}
|
|
904
|
+
return next(data)
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function CommentForm() {
|
|
910
|
+
return (
|
|
911
|
+
<FormBuilder
|
|
912
|
+
layout={[
|
|
913
|
+
{
|
|
914
|
+
type: 'text',
|
|
915
|
+
name: 'comment',
|
|
916
|
+
label: 'Комментарий',
|
|
917
|
+
validation: ['required', 'noProhibitedWords:badword1:badword2']
|
|
918
|
+
// аргументы передаются через ':' ^
|
|
919
|
+
}
|
|
920
|
+
]}
|
|
921
|
+
fields={{ text: MyTextField }}
|
|
922
|
+
plugins={[
|
|
923
|
+
trimPlugin,
|
|
924
|
+
createValidationPlugin({ rules: [noProhibitedWords] }),
|
|
925
|
+
]}
|
|
926
|
+
onSubmit={console.log}
|
|
927
|
+
/>
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
```
|