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 Как едет заказ
oneOf, варианты которого делят ключ сconst, — ветвление, а не поле: форма рисует переключатель вариантов и поля выбранного. Смена варианта сохраняет общую заметку, отбрасывает ключи чужой ветки и проставляетmethod.additionalPropertiesсо схемой значения даёт пары «ключ — значение»: ключ вводят, значение рисуется по схеме, — а данные рядом с формой показывают, что получит API.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
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
schema | unknown | undefined | Чужая схема: JSON Schema, zod-объект. Адаптер подбирается по `supports()`. |
schemaModel | GrSchemaModel | undefined | undefined | Готовая нейтральная модель — сильнее `schema`. |
adapters | GrSchemaAdapter<unknown>[] | undefined | undefined | Явные адаптеры. Без них разбор возможен только из `schemaModel`. |
parseOptions | GrSchemaParseOptions | undefined | undefined | Настройки разбора чужой схемы адаптером: глубина обхода, обязательность по умолчанию, префикс аннотаций, полная проверка. |
modelValuev-model | TModel | undefined | undefined | Данные формы. Не заданы — собираются из умолчаний схемы. |
uiSchema | GrUiSchema | undefined | undefined | Раскладка и вид поверх схемы: секции, колонки, порядок, скрытые поля, виджеты, подписи. |
renderers | GrSchemaRendererRegistry | readonly GrSchemaRenderer[] | undefined | undefined | Реестр или набор записей поверх дефолтного. |
rules | GrFormRules | undefined | undefined | Правила поверх скомпилированных: ключ — инстанс-путь. |
validation | GrSchemaRuleCompilerOptions | undefined | undefined | Настройки компиляции правил из схемы: ярусы, свои форматы, когда идёт полная проверка. |
serverErrors | unknown | undefined | Ответ сервера: сырой, разобранный или карта `{ путь: сообщения }`. |
validateOnBlur | boolean | undefined | true | Проверять поле при потере фокуса. По умолчанию `true`. |
validateOnChange | boolean | undefined | false | Проверять поле на каждом изменении значения. По умолчанию `false`. |
scrollToError | boolean | undefined | true | Прокрутить к первому невалидному полю, когда проверка не прошла. По умолчанию `true`. |
disabled | boolean | undefined | false | Все поля формы недоступны. |
readonly | boolean | undefined | false | Все поля формы только для чтения: значения видны, но не правятся. |
columns | GrUiColumns | undefined | undefined | Колонки сетки полей: число или карта по брейкпоинтам. Не задан — из `uiSchema.layout.columns`, затем из `GrConfigProvider`, иначе одна. |
labelPosition | "top" | "start" | undefined | undefined | Положение подписей полей: `top` — над полем, `start` — слева. Не задан — из `GrConfigProvider`, иначе `top`. |
labelWidth | string | number | undefined | undefined | Ширина колонки подписи при `labelPosition="start"`; число — пиксели. |
headingLevel | 4 | 2 | 3 | 6 | 5 | undefined | undefined | Уровень заголовков секций и вложенных объектов — под место формы на странице. Не задан — из `GrConfigProvider`, иначе `3`. |
showFormErrors | boolean | undefined | true | Сводка ошибок формы и осиротевших серверных сообщений — над полями, тоном `danger`: сохранение не прошло. |
Слоты
| Slot | Type | Описание |
|---|---|---|
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 |
| Строка массива добавлена, удалена или перенесена: путь массива, действие и индекс строки (у `move` — новый). |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrSchemaFormConfigurableProps, GrSchemaFormContext, GrSchemaFormProps } from '@feugene/granularity-forms-schema/components/GrSchemaForm'Файлdist/types/components/GrSchemaForm/config.d.ts
Дескриптор семейства GrSchemaForm (GrSchemaField, GrSchemaArrayField и
внутренние узлы лежат в той же директории и покрываются им же).
dependencies перечисляет **только дефолтный набор рендереров**. Иначе любой
потребитель формы оплачивал бы CSS и safelist всех контролов пакета — включая
те, которых в его схемах нет. Расширенные наборы и пикеры даты подключаются
явно, и их компоненты добавляются в селекцию потребителем — это описано в
docs/renderers.md.
const grSchemaFormConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSchemaForm">;Файлdist/types/components/GrSchemaForm/defaults.d.ts
Оформительские пропы, которые приложение задаёт один раз на всё поддерево. Раскладка формы — сквозное решение продукта: колонки, положение подписи и её ширина одинаковы во всех формах приложения, и повторять их на каждой значит гарантированно однажды разойтись.
interface GrSchemaFormConfigurableProps {
columns: GrUiColumns;
labelPosition: 'top' | 'start';
labelWidth: string | number;
headingLevel: 2 | 3 | 4 | 5 | 6;
}Файлdist/types/components/GrSchemaForm/context.d.ts
Контекст схемной формы.
Через него поле узнаёт свой узел, реестр и 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;function useSchemaForm(): GrSchemaFormContext | null;Файлdist/types/components/GrSchemaForm/GrSchemaForm.vue.d.ts
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;
}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
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);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
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;
}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
const grSchemaFormSafelist: string[];Использование из прозы библиотеки
Пакет ничего не рисует сам
Кроме сетки колонок. Оркестрацию даёт GrForm, обвязку и доступность —
GrFormField, разделы — GrFormSection, ввод — контролы ядра. Отсюда главное
свойство: сгенерированная форма выглядит и ведёт себя ровно как написанная
руками — те же тексты ошибок, та же клавиатура, та же тема.
Сетка — исключение, потому что колонок у ядра нет: GrFormField отвечает за одно
поле, а раскладку между полями каждый строит своей вёрсткой.
Валидация трёхъярусная, и это видно снаружи
Что ложится в правила ядра — ложится в них: required, длины, границы, email,
url, файлы. Что выразимо из схемы, но не из правил, — integer, multipleOf,
строгие границы, уникальность, «обязан быть отмечен» — становится локальным
валидатором. Всё остальное — refine, кросс-полевые условия, ветвления — уезжает
в одну проверку самой схемой, которая и остаётся источником правды.
Сообщение берётся из схемы, только если автор его написал. Иначе текст даёт резолвер ядра — с локалью ядра.
compiledRules в defineExpose отвечает на единственный вопрос, который задают
такому пакету в проде: почему это поле не проверяется.
Пункты собираются под цель, а раскладка — через uiSchema
Схема описывает данные, а не интерфейс. Всё, что относится к виду, живёт в
uiSchema и адресуется шаблонным путём (items.*.qty), а не путём
конкретной строки:
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) она строит — см. «Ветвление» ниже; - объединение без дискриминатора не ветвится: выбрать вариант нечем, и узел уходит в полную проверку с предупреждением.
Пропы этого примера