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

    Two letters and the number, e.g. DE811569869

    How they are billed

    Seen by your team only

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

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

    Delivery

    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 отвечает за одно поле, а раскладку между полями каждый строит своей вёрсткой.

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

Что ложится в правила ядра — ложится в них: 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) она строит — см. «Ветвление» ниже;
  • объединение без дискриминатора не ветвится: выбрать вариант нечем, и узел уходит в полную проверку с предупреждением.