GrSchemaForm

  • forms-schema
  • Формы по схеме
  • API · 20 props

Берут, когда бэкенд уже описывает контракт.

import { GrSchemaForm } from '@feugene/granularity-forms-schema/components/GrSchemaForm'

Обзор

Когда брать

  • бэкенд уже описывает контракт — zod, JSON Schema, OpenAPI: раскладывать по нему сорок полей руками дорого, а два описания неизбежно расходятся;
  • форм много и они однотипны — админка, где на каждую сущность своя форма создания и редактирования;
  • схема приходит с сервера — набор полей меняется без релиза фронта;
  • почти всё генерируется, а два поля свои — слот перехватывает любое поле по его пути, остальные остаются сгенерированными.

Когда взять другое

НужноБерите
Полей немного и они известны заранееGrForm
Обвязка одного поля: подпись, ошибка, подсказкаGrFormField
Запросить одно значение окномGrPromptDialog
Конструктор форм перетаскиваниемничего: это продукт, а не компонент

Примеры 3 у каждого — живое демо и его код

  • Клиент по собственной схеме API

    Каждое поле сгенерировано из схемы OpenAPI и нарисовано ядром — GrFormField, GrInput, GrRadioGroup для короткого перечисления, GrSelect для длинного, — поэтому форма выглядит и ведёт себя как написанная руками. ui-schema добавляет секции, две колонки и поля компании, которые появляются только для компании; скрытое поле в проверке не участвует.

    New customerGenerated from the CustomerCreate schema of the billing API

    Who is billed

    A company
    A person

    Two letters and the number, e.g. DE811569869

    How they are billed

    English
    German
    French

    Seen by your team only

  • Как едет заказ

    oneOf, варианты которого делят ключ с const, — ветвление, а не поле: форма рисует переключатель вариантов и поля выбранного. Смена варианта сохраняет общую заметку, отбрасывает ключи чужой ветки и проставляет method. additionalProperties со схемой значения даёт пары «ключ — значение»: ключ вводят, значение рисуется по схеме, — а данные рядом с формой показывают, что получит API.

    Delivery

    Courier to the door
    Pickup point
    Freight to a dock
    8–12
    12–17
    17–21

    Labels for the warehouse

    Дополнительные поля
    Sent to the warehouse API
    {
      "delivery": {
        "method": "courier",
        "address": "Hafenstraße 12",
        "city": "Hamburg",
        "window": "afternoon",
        "notes": "Ring at «Fabrikam», second floor"
      },
      "metadata": {
        "order-ref": "PO-77120"
      }
    }
  • Места и что ответил сервер

    Массив объектов — повторитель: строки добавляют, удаляют и переставляют, row-change сообщает о каждом действии, а ui-schema достаёт до каждой строки шаблонным путём (seats.*.role). server-errors берёт 422 как пришёл, в форме RFC 7807: seats[1].email садится на вторую строку и снимается первой правкой, а plan, у которого здесь нет поля, показывается сводкой, а не теряется.

    Team plan · seatsUp to 4 people

    Seats

    Seat 1
    Seat 2
    Seat 3

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
schemaunknownundefinedЧужая схема: JSON Schema, zod-объект. Адаптер подбирается по `supports()`.
schemaModelGrSchemaModel | undefinedundefinedГотовая нейтральная модель — сильнее `schema`.
adaptersGrSchemaAdapter<unknown>[] | undefinedundefinedЯвные адаптеры. Без них разбор возможен только из `schemaModel`.
parseOptionsGrSchemaParseOptions | undefinedundefinedНастройки разбора чужой схемы адаптером: глубина обхода, обязательность по умолчанию, префикс аннотаций, полная проверка.
modelValuev-modelTModel | undefinedundefinedДанные формы. Не заданы — собираются из умолчаний схемы.
uiSchemaGrUiSchema | undefinedundefinedРаскладка и вид поверх схемы: секции, колонки, порядок, скрытые поля, виджеты, подписи.
renderersGrSchemaRendererRegistry | readonly GrSchemaRenderer[] | undefinedundefinedРеестр или набор записей поверх дефолтного.
rulesGrFormRules | undefinedundefinedПравила поверх скомпилированных: ключ — инстанс-путь.
validationGrSchemaRuleCompilerOptions | undefinedundefinedНастройки компиляции правил из схемы: ярусы, свои форматы, когда идёт полная проверка.
serverErrorsunknownundefinedОтвет сервера: сырой, разобранный или карта `{ путь: сообщения }`.
validateOnBlurboolean | undefinedtrueПроверять поле при потере фокуса. По умолчанию `true`.
validateOnChangeboolean | undefinedfalseПроверять поле на каждом изменении значения. По умолчанию `false`.
scrollToErrorboolean | undefinedtrueПрокрутить к первому невалидному полю, когда проверка не прошла. По умолчанию `true`.
disabledboolean | undefinedfalseВсе поля формы недоступны.

Слоты

SlotTypeОписание
formErrors{ errors: string[]; }—
default{ fields: GrSchemaFieldInstance[]; model: Record<string, unknown>; }—
actions{}—

События

EventСигнатураОписание
update:modelValue(e: "update:modelValue", model: TModel): voidДанные формы изменились (`v-model`): правка поля или строки массива.
submit(e: "submit", model: TModel): voidОтправка прошла проверку полей и схемы — данные формы.
invalid(e: "invalid", errors: Record<string, string>): voidОтправка не прошла проверку: путь поля → первое сообщение об ошибке.
parsed(e: "parsed", model: GrSchemaModel, warnings: GrSchemaWarning[]): voidСхема разобрана; `warnings` — то, чего адаптер не понял.
unresolved(e: "unresolved", node: GrSchemaNode, name: string): voidДля узла схемы не нашлось рендерера: узел и инстанс-путь; поле остаётся без контрола.
rowChange
(e: "rowChange", path: string, action: "add" | "remove" | "m(e: "rowChange", path: string, action: "add" | "remove" | "move", index: number): void
Строка массива добавлена, удалена или перенесена: путь массива, действие и индекс строки (у `move` — новый).

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrSchemaFormConfigurableProps, GrSchemaFormContext, GrSchemaFormProps } from '@feugene/granularity-forms-schema/components/GrSchemaForm'

Файлdist/types/components/GrSchemaForm/config.d.ts

grSchemaFormConfigconst

Дескриптор семейства GrSchemaForm (GrSchemaField, GrSchemaArrayField и внутренние узлы лежат в той же директории и покрываются им же). dependencies перечисляет **только дефолтный набор рендереров**. Иначе любой потребитель формы оплачивал бы CSS и safelist всех контролов пакета — включая те, которых в его схемах нет. Расширенные наборы и пикеры даты подключаются явно, и их компоненты добавляются в селекцию потребителем — это описано в docs/renderers.md.

const grSchemaFormConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSchemaForm">;

Файлdist/types/components/GrSchemaForm/defaults.d.ts

GrSchemaFormConfigurablePropsinterface

Оформительские пропы, которые приложение задаёт один раз на всё поддерево. Раскладка формы — сквозное решение продукта: колонки, положение подписи и её ширина одинаковы во всех формах приложения, и повторять их на каждой значит гарантированно однажды разойтись.

interface GrSchemaFormConfigurableProps {
    columns: GrUiColumns;
    labelPosition: 'top' | 'start';
    labelWidth: string | number;
    headingLevel: 2 | 3 | 4 | 5 | 6;
}

Файлdist/types/components/GrSchemaForm/context.d.ts

GrSchemaFormContextinterface

Контекст схемной формы. Через него поле узнаёт свой узел, реестр и uiSchema, не получая их пропами с каждого уровня вложенности: у повторителя внутри секции таких уровней три, и прокидывание руками расходилось бы при первой же правке.

interface GrSchemaFormContext {
    root: ComputedRef<GrSchemaObjectNode | undefined>;
    model: ComputedRef<Record<string, unknown>>;
    ui: ComputedRef<GrUiSchema>;
    renderers: ComputedRef<GrSchemaRendererRegistry>;
    disabled: ComputedRef<boolean>;
    readonly: ComputedRef<boolean>;
    /** Серверные ошибки по инстанс-пути — сильнее ошибки формы. */
    serverErrorAt: (name: string) => string[] | undefined;
useSchemaFormfunction
function useSchemaForm(): GrSchemaFormContext | null;

Файлdist/types/components/GrSchemaForm/GrSchemaForm.vue.d.ts

GrSchemaFormPropsinterface
interface GrSchemaFormProps<T extends Record<string, unknown> = Record<string, unknown>> {
    /** Чужая схема: JSON Schema, zod-объект. Адаптер подбирается по `supports()`. */
    schema?: unknown;
    /** Готовая нейтральная модель — сильнее `schema`. */
    schemaModel?: GrSchemaModel;
    /** Явные адаптеры. Без них разбор возможен только из `schemaModel`. */
    adapters?: GrSchemaAdapter[];
    /**
     * Настройки разбора чужой схемы адаптером: глубина обхода, обязательность по умолчанию,
     * префикс аннотаций, полная проверка.
     */
    parseOptions?: GrSchemaParseOptions;
    /** Данные формы. Не заданы — собираются из умолчаний схемы. */
    modelValue?: T;
    /** Раскладка и вид поверх схемы: секции, колонки, порядок, скрытые поля, виджеты, подписи. */
    uiSchema?: GrUiSchema;
    /** Реестр или набор записей поверх дефолтного. */
    renderers?: GrSchemaRendererRegistry | readonly GrSchemaRenderer[];
    /** Правила поверх скомпилированных: ключ — инстанс-путь. */
    rules?: GrFormRules;
    /** Настройки компиляции правил из схемы: ярусы, свои форматы, когда идёт полная проверка. */
    validation?: GrSchemaRuleCompilerOptions;
    /** Ответ сервера: сырой, разобранный или карта `{ путь: сообщения }`. */
    serverErrors?: unknown;
    /** Проверять поле при потере фокуса. По умолчанию `true`. */
    validateOnBlur?: boolean;
    /** Проверять поле на каждом изменении значения. По умолчанию `false`. */
    validateOnChange?: boolean;
    /** Прокрутить к первому невалидному полю, когда проверка не прошла. По умолчанию `true`. */
    scrollToError?: boolean;
    /** Все поля формы недоступны. */
    disabled?: boolean;
    /** Все поля формы только для чтения: значения видны, но не правятся. */
    readonly?: boolean;
    /**
     * Колонки сетки полей: число или карта по брейкпоинтам. Не задан — из `uiSchema.layout.columns`,
     * затем из `GrConfigProvider`, иначе одна.
     */
    columns?: GrUiColumns;
    /**
     * Положение подписей полей: `top` — над полем, `start` — слева. Не задан — из `GrConfigProvider`,
     * иначе `top`.
     */
    labelPosition?: 'top' | 'start';
    /** Ширина колонки подписи при `labelPosition="start"`; число — пиксели. */
    labelWidth?: string | number;
    /**
     * Уровень заголовков секций и вложенных объектов — под место формы на странице. Не задан — из
     * `GrConfigProvider`, иначе `3`.
     */
    headingLevel?: 2 | 3 | 4 | 5 | 6;
    /**
     * Сводка ошибок формы и осиротевших серверных сообщений — над полями, тоном
     * `danger`: сохранение не прошло.
     */
    showFormErrors?: boolean;
}
GrSchemaFormEmitsinterface
interface GrSchemaFormEmits<T extends Record<string, unknown> = Record<string, unknown>> {
    /** Данные формы изменились (`v-model`): правка поля или строки массива. */
    (e: 'update:modelValue', model: T): void;
    /** Отправка прошла проверку полей и схемы — данные формы. */
    (e: 'submit', model: T): void;
    /** Отправка не прошла проверку: путь поля → первое сообщение об ошибке. */
    (e: 'invalid', errors: Record<string, string>): void;
    /** Схема разобрана; `warnings` — то, чего адаптер не понял. */
    (e: 'parsed', model: GrSchemaModel, warnings: GrSchemaWarning[]): void;
    /** Для узла схемы не нашлось рендерера: узел и инстанс-путь; поле остаётся без контрола. */
    (e: 'unresolved', node: GrSchemaNode, name: string): void;
    /**
     * Строка массива добавлена, удалена или перенесена: путь массива, действие и индекс строки
     * (у `move` — новый).
     */
    (e: 'rowChange', path: string, action: 'add' | 'remove' | 'move', index: number): void;
}

Файлdist/types/components/GrSchemaForm/GrSchemaArrayField.vue.d.ts

GrSchemaArrayFieldPropsinterface
interface GrSchemaArrayFieldProps {
    /** Узел-массив. Не задан — берётся из контекста формы по `path`. */
    node?: GrSchemaArrayNode;
    path?: string;
    /** Инстанс-путь массива: `order.items`. */
    name?: string;
    indices?: number[];
    ui?: GrUiArrayOptions;
    columns?: GrUiColumns;
    min?: number;
    max?: number;
    sortable?: boolean;
    duplicable?: boolean;
    title?: string;
    description?: string;
    headingLevel?: 2 | 3 | 4 | 5 | 6;
    addLabel?: string;
    removeLabel?: string;
    emptyText?: string;
    itemLabel?: string | ((item: unknown, index: number) => string);
GrSchemaArrayFieldEmitsinterface
interface GrSchemaArrayFieldEmits {
    (e: 'add', item: unknown, index: number): void;
    (e: 'remove', item: unknown, index: number): void;
    (e: 'move', from: number, to: number): void;
}

Файлdist/types/components/GrSchemaForm/GrSchemaField.vue.d.ts

GrSchemaFieldPropsinterface
interface GrSchemaFieldProps {
    /** Узел схемы. Не задан — берётся из контекста формы по `path`. */
    node?: GrSchemaNode;
    /** Шаблонный путь — ключ в `uiSchema` и реестре. */
    path?: string;
    /** Инстанс-путь. Не задан — равен `path`: вне массива они совпадают. */
    name?: string;
    /** Индексы массивов на пути — нужны условиям с относительными путями. */
    indices?: number[];
    /** Значение. Не задано — читается из модели формы по `name`. */
    modelValue?: unknown;
    ui?: GrUiFieldOptions;
    renderers?: GrSchemaRendererRegistry;
    disabled?: boolean;
    readonly?: boolean;
    /** Явные ошибки. Сильнее ошибки формы — правило `GrFormField`. */
    error?: string | string[];
    /** Не рисовать обвязку — только контрол. Для поля в ячейке таблицы. */
    bare?: boolean;
    /**
     * Доступное имя контрола там, где видимой подписи нет и быть не должно:
     * строка свободного ключа, ячейка таблицы. Слабее `ui.controlProps`, чтобы
     * потребитель мог назвать поле по-своему.
     */
    ariaLabel?: string;
    label?: string;
    hint?: string;
    labelPosition?: 'top' | 'start';
    labelWidth?: string | number;
    showMessage?: boolean;
}
GrSchemaFieldEmitsinterface
interface GrSchemaFieldEmits {
    (e: 'update:modelValue', value: unknown): void;
    (e: 'change', value: unknown): void;
    (e: 'unresolved', node: GrSchemaNode, name: string): void;
}

Файлdist/types/components/GrSchemaForm/safelist.d.ts

grSchemaFormSafelistconst
const grSchemaFormSafelist: string[];

Использование из прозы библиотеки

Пакет ничего не рисует сам

Кроме сетки колонок. Оркестрацию даёт GrForm, обвязку и доступность — GrFormField, разделы — GrFormSection, ввод — контролы ядра. Отсюда главное свойство: сгенерированная форма выглядит и ведёт себя ровно как написанная руками — те же тексты ошибок, та же клавиатура, та же тема.

Сетка — исключение, потому что колонок у ядра нет: GrFormField отвечает за одно поле, а раскладку между полями каждый строит своей вёрсткой.

Своего вида у формы два места, и оба решает тема: промежуток между разделами и строка повторителя. --gr-schema-form-section-gap, -section-divider и -section-divider-space задают воздух и волосяную линию между разделами; --gr-schema-form-row-brd, -row-radius, -row-padding и -row-divider — строку повторителя плиткой в рамке или рядом списка под линией. Линии нарисованы фоном и места не занимают. «Сланец» держит разделы воздухом, а строки — плитками; «Глина» отбивает и то и другое линиями: форма — одна поверхность.

Валидация трёхъярусная, и это видно снаружи

Что ложится в правила ядра — ложится в них: required, длины, границы, email, url, файлы. Что выразимо из схемы, но не из правил, — integer, multipleOf, строгие границы, уникальность, «обязан быть отмечен» — становится локальным валидатором. Всё остальное — refine, кросс-полевые условия, ветвления — уезжает в одну проверку самой схемой, которая и остаётся источником правды.

Сообщение берётся из схемы, только если автор его написал. Иначе текст даёт резолвер ядра — с локалью ядра.

compiledRules в defineExpose отвечает на единственный вопрос, который задают такому пакету в проде: почему это поле не проверяется.

Пункты собираются под цель, а раскладка — через uiSchema

Схема описывает данные, а не интерфейс. Всё, что относится к виду, живёт в uiSchema и адресуется шаблонным путём (items.*.qty), а не путём конкретной строки:

ts
const ui: GrUiSchema = {
  layout: {
    columns: 2,
    sections: [
      { id: 'main', title: 'Основное', fields: ['email', 'nick'] },
      { id: 'more', title: 'Дополнительно', fields: ['*'] },
    ],
  },
  order: ['type', '*'],
  fields: {
    'bio': { widget: 'gr:textarea', span: 'full' },
    'items.*.qty': { label: 'Количество' },
    'company': { when: { path: 'type', eq: 'company' } },
  },
}

Секция рисует ровно свои fields и в их порядке. '*' раскрывается на своём месте в остаток — корневые поля, которых не назвала ни одна секция, — и идёт по order. Поле, не попавшее ни в одну секцию, когда '*' нет нигде, не рисуется: секции — это и раскладка, и отбор. Без секций order задаёт порядок всей формы. Скрытые (hidden) и условные (when) поля ведут себя внутри секции так же, как без неё.

Скрытое условием поле не участвует в проверке: иначе отправка блокировалась бы обязательностью поля, которого нет на экране.

Виджет выбирает реестр, а не схема

Реестр сопоставляет узел модели с компонентом: строка с format: email — GrInput[type=email], до пяти вариантов — GrRadioGroup, больше — GrSelect, массив строк — GrInputTag, массив объектов — повторитель.

Дефолтный набор узкий намеренно: каждая запись — это компонент в dependencies, то есть его CSS в бандле каждого потребителя формы. Ползунки, рейтинги и палитры живут в extendedRenderers, пикеры даты — в ./renderers/chrono; и то и другое подключается явно, см. ../renderers.md.

Серверные ошибки не теряются

Ответ 422 разбирается из Laravel, JSON:API и RFC 7807, путь нормализуется (items[0].name → items.0.name), ошибка садится на поле и снимается первой же правкой. Ошибка на поле, которого форма не рисует, показывается сводкой сверху: «сохранение не прошло, а почему — нигде» — худший из возможных исходов.

Ветвление — переключатель, а не поле

Способ доставки, тип платежа, вид документа: набор полей зависит от значения одного ключа. Форма рисует переключатель вариантов (до пяти — GrRadioGroup, дальше GrSelect) и под ним поля выбранной ветки.

Дискриминатор при этом не рисуется отдельным полем: им управляет переключатель, и второе поле с тем же именем спорило бы с ним за значение.

Смена ветки переписывает значение: общие ключи сохраняются, чужие отбрасываются, дискриминатор проставляется. Оставить чужие нельзя — схема на них ругнётся; сбросить всё — потерять общее поле вроде примечания, которое есть у всех вариантов.

Рисует его GrSchemaUnionField. Ветвление — структура, а не контрол, и потому оно не в реестре рендереров: запись реестра дала бы один GrFormField с одной подписью на всю группу.

Свободные ключи — приписка к объекту

Хвост рисует GrSchemaAdditionalFields. additionalProperties со схемой значения (или catchall в zod) даёт список пар «ключ — значение»: имя вводит пользователь, значение рисуется обычным контролом по схеме. Хвостом объекта, а не полем среди полей: у пары нет ни места в схеме, ни записи в uiSchema, ни порядка среди объявленных полей.

additionalProperties: true без схемы значения хвоста не даёт: ключи разрешены, но чем рисовать значение, схема не сказала.

Видимой подписи у пары нет — её роль играет поле ключа, — поэтому имя контролу значения даёт сам ключ, и оно переезжает вместе с переименованием. Подробности и способ назвать поле снаружи (ariaLabel у GrSchemaField) — в доступности.

Границы
  • конструктора форм перетаскиванием здесь нет и не будет — это продукт, а не компонент библиотеки;
  • пакет не заменяет серверную валидацию. Схема на клиенте экономит запрос и подсказывает раньше, но решение принимает сервер;
  • условия — if/then/else, not — форма не разворачивает. Такие узлы помечаются для полной проверки схемой: ошибка покажется, но поле под каждый исход форма не построит. Ветвление по дискриминатору (oneOf с общим const, z.discriminatedUnion) она строит — см. «Ветвление» ниже;
  • объединение без дискриминатора не ветвится: выбрать вариант нечем, и узел уходит в полную проверку с предупреждением.