@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.
Files changed (44) hide show
  1. package/README.md +922 -12
  2. package/dist/index.cjs +272 -88
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +18 -16
  5. package/dist/index.d.mts +18 -16
  6. package/dist/index.mjs +273 -89
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +5 -2
  9. package/src/index.ts +19 -5
  10. package/CHANGELOG.md +0 -8
  11. package/eslint.config.js +0 -23
  12. package/public/index.html +0 -13
  13. package/public/main.tsx +0 -90
  14. package/src/app/debug.tsx +0 -0
  15. package/src/app/index.tsx +0 -80
  16. package/src/app/test.css +0 -1
  17. package/src/entity/inputs/index.ts +0 -2
  18. package/src/entity/inputs/ui/group/index.tsx +0 -28
  19. package/src/entity/inputs/ui/input/index.tsx +0 -31
  20. package/src/shared/hook/useUpdateEffect.tsx +0 -23
  21. package/src/shared/lib/VisibleCore.spec.ts +0 -103
  22. package/src/shared/lib/VisibleCore.ts +0 -43
  23. package/src/shared/lib/validation/core.spec.ts +0 -103
  24. package/src/shared/lib/validation/core.ts +0 -79
  25. package/src/shared/lib/validation/rules/base.ts +0 -10
  26. package/src/shared/lib/validation/rules/confirm.spec.ts +0 -17
  27. package/src/shared/lib/validation/rules/confirm.ts +0 -32
  28. package/src/shared/lib/validation/rules/email.spec.ts +0 -12
  29. package/src/shared/lib/validation/rules/email.ts +0 -13
  30. package/src/shared/lib/validation/rules/require.spec.ts +0 -13
  31. package/src/shared/lib/validation/rules/require.ts +0 -12
  32. package/src/shared/model/builder/createContext.tsx +0 -40
  33. package/src/shared/model/builder/index.ts +0 -6
  34. package/src/shared/model/index.ts +0 -12
  35. package/src/shared/model/store/createStoreContext.tsx +0 -74
  36. package/src/shared/model/store/index.ts +0 -46
  37. package/src/shared/model/store/store.ts +0 -27
  38. package/src/shared/types/common.ts +0 -79
  39. package/src/shared/utils.ts +0 -25
  40. package/src/widgets/dynamicBuilder/element.tsx +0 -31
  41. package/src/widgets/dynamicBuilder/index.tsx +0 -33
  42. package/tsconfig.json +0 -24
  43. package/tsdown.config.ts +0 -10
  44. package/vite.config.ts +0 -11
package/README.md CHANGED
@@ -1,20 +1,930 @@
1
- # React Form Builder
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
+ ```