npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@idem.agency/form-builder

v0.0.20

Published

Построитель форм

Readme

@idem.agency/form-builder

Динамический конструктор форм для React с поддержкой валидации, условной видимости полей и расширяемой системой плагинов.

Содержание


Установка

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 } = inputs

TextField

Текстовый ввод. Поддерживает типы 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}
    />
  )
}