GrSchemaForm
Берут, когда бэкенд уже описывает контракт.
Когда брать
- бэкенд уже описывает контракт — zod, JSON Schema, OpenAPI: раскладывать по нему сорок полей руками дорого, а два описания неизбежно расходятся;
- форм много и они однотипны — админка, где на каждую сущность своя форма создания и редактирования;
- схема приходит с сервера — набор полей меняется без релиза фронта;
- почти всё генерируется, а два поля свои — слот перехватывает любое поле по его пути, остальные остаются сгенерированными.
Когда взять другое
| Нужно | Берите |
|---|---|
| Полей немного и они известны заранее | GrForm |
| Обвязка одного поля: подпись, ошибка, подсказка | GrFormField |
| Запросить одно значение окном | GrPromptDialog |
| Конструктор форм перетаскиванием | ничего: это продукт, а не компонент |
Пакет ничего не рисует сам
Кроме сетки колонок. Оркестрацию даёт 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'] }] },
order: ['email', '*'],
fields: {
'bio': { widget: 'gr:textarea', span: 'full' },
'items.*.qty': { label: 'Количество' },
'company': { when: { path: 'type', eq: 'company' } },
},
}
Скрытое условием поле не участвует в проверке: иначе отправка блокировалась бы обязательностью поля, которого нет на экране.
Виджет выбирает реестр, а не схема
Реестр сопоставляет узел модели с компонентом: строка с 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) она строит — см. «Ветвление» ниже; - объединение без дискриминатора не ветвится: выбрать вариант нечем, и узел уходит в полную проверку с предупреждением.
Установка
npm i @feugene/granularity-forms-schemaИмпорт
import { GrSchemaForm } from '@feugene/granularity-forms-schema/components/GrSchemaForm'API
API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.
Примеры 11
Adapters
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
import { z } from 'zod'
import { GrJsonViewer, GrSegmented } from '@feugene/granularity'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import type { GrSchemaModel } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import { zodAdapter } from '@feugene/granularity-forms-schema/zod'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
/**
* Один и тот же контракт, записанный двумя способами.
*
* Переключатель меняет **источник**: слева уезжает то JSON Schema из OpenAPI, то
* zod-объект из общего с бэкендом пакета. Нейтральная модель и сама форма при
* этом не меняются — в этом и смысл слоя между схемой и виджетами.
*/
const jsonSchema: JsonSchemaDocument = {
type: 'object',
properties: {
email: { type: 'string', format: 'email', title: 'Почта' },
fullName: { type: 'string', minLength: 2, title: 'Имя' },
age: { type: 'integer', minimum: 18, maximum: 120, title: 'Возраст' },
role: { type: 'string', enum: ['admin', 'editor', 'viewer'], title: 'Роль' },
newsletter: { type: 'boolean', title: 'Присылать письма' },
},
required: ['email', 'fullName', 'role'],
}
// Подпись поля — это `meta({ title })`, а не `describe()`: последнее ложится в
// описание под полем. И `meta` ставится **до** `optional()` — на обёртке она
// потерялась бы вместе с подписью.
const zodSchema = z.object({
email: z.email().meta({ title: 'Почта' }),
fullName: z.string().min(2).meta({ title: 'Имя' }),
age: z.number().int().min(18).max(120).meta({ title: 'Возраст' }).optional(),
role: z.enum(['admin', 'editor', 'viewer']).meta({ title: 'Роль' }),
newsletter: z.boolean().meta({ title: 'Присылать письма' }).optional(),
})
const ui: GrUiSchema = {
layout: { columns: { base: 1, md: 2 } },
fields: { newsletter: { span: 'full' } },
}
const source = ref<'json' | 'zod'>('json')
const pane = ref<'schema' | 'model' | 'value'>('schema')
const model = ref<Record<string, unknown>>({})
// Нейтральная модель приезжает событием: считать её самому незачем — форма уже
// разобрала схему и отдаёт ровно то, по чему рисует.
const parsed = shallowRef<GrSchemaModel | null>(null)
const schema = computed(() => (source.value === 'json' ? jsonSchema : zodSchema))
const shown = computed(() => {
if (pane.value === 'schema')
return source.value === 'json' ? jsonSchema : '(zod-объект — код, а не данные; см. исходник демо)'
return pane.value === 'model' ? parsed.value : model.value
})
const paneHint: Record<typeof pane.value, string> = {
schema: 'Источник. Переключите адаптер — здесь поменяется всё, а форма справа останется прежней.',
model: 'Нейтральная модель: узлы, типы, ограничения. У обоих адаптеров она одинаковая — по ней и рисуется форма.',
value: 'Текущее значение `v-model`. Обновляется на каждое нажатие клавиши.',
}
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Схема приходит от бэкенда — формой занимается пакет
</span>
<GrSegmented
v-model="source"
size="sm"
:options="[
{ value: 'json', label: 'JSON Schema' },
{ value: 'zod', label: 'zod' },
]"
aria-label="Источник схемы"
/>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter, zodAdapter]"
:ui-schema="ui"
@parsed="value => (parsed = value)"
/>
<div class="grid content-start gap-2">
<GrSegmented
v-model="pane"
size="sm"
:options="[
{ value: 'schema', label: 'Схема' },
{ value: 'model', label: 'Модель' },
{ value: 'value', label: 'Значение' },
]"
aria-label="Что показать"
/>
<GrJsonViewer
:value="shown"
:default-expand-depth="3"
:max-height="320"
size="sm"
:aria-label="`Панель: ${pane}`"
/>
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
{{ paneHint[pane] }}
</span>
</div>
</div>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Адаптер выбирается сам — по <code>supports()</code>, а не по пропу: в <code>adapters</code>
переданы оба, и каждый узнаёт свою схему. Поэтому приложение, у которого часть форм из
OpenAPI, а часть из общего с бэкендом zod-пакета, не разделяется на две ветки кода.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Вкладка <strong>«Модель»</strong> — то, чего обычно не показывают: разобранный контракт, по
которому уже нет разницы, откуда он пришёл. Именно на него смотрит реестр рендереров, выбирая
контрол, и компилятор правил, собирая валидацию. Узлы, их типы, форматы и обязательность у
обоих адаптеров совпадают — различается только поле <code>adapter</code>, которое помнит, кто
разбирал.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Две тонкости на стороне zod, которые видно в исходнике демо. Подпись поля — это
<code>meta({ title })</code>, а не <code>describe()</code>: второе ложится в описание под
полем. И <code>meta</code> ставится <strong>до</strong> <code>optional()</code> — на обёртке
подпись теряется.
</p>
</div>
</template>Additional
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
/**
* Свободные ключи: часть контракта заранее неизвестна.
*
* Витрине важно показать не кнопку «добавить», а то, что схема значения
* соблюдается: `attributes` принимает строки, `limits` — целые числа с
* границами, и правило ядра работает на них так же, как на объявленном поле.
*/
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
sku: { type: 'string', minLength: 3, title: 'Артикул', default: 'TS-100' },
attributes: {
type: 'object',
title: 'Характеристики',
description: 'Набор зависит от категории товара и приходит от контент-менеджера',
properties: {},
additionalProperties: { type: 'string', minLength: 2 },
},
limits: {
type: 'object',
title: 'Лимиты по площадкам',
description: 'Площадки заводят по мере подключения',
properties: {},
additionalProperties: { type: 'integer', minimum: 1, maximum: 999 },
},
},
required: ['sku'],
}
const ui: GrUiSchema = { layout: { columns: { base: 1 } } }
const model = ref<Record<string, unknown>>({
sku: 'TS-100',
attributes: { Материал: 'хлопок', Состав: '100% хлопок' },
limits: { ozon: 10 },
})
const json = computed(() => JSON.stringify(model.value, null, 2))
</script>
<template>
<div class="grid gap-3 md:grid-cols-2">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:ui-schema="ui"
/>
<pre class="overflow-auto rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] p-3 text-[length:var(--gr-control-text-sm)] leading-[var(--gr-leading-sm)]">{{ json }}</pre>
<p class="md:col-span-2 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Имя ключа вводит пользователь, а <strong>значение рисуется по схеме</strong>: у характеристик
это строка от двух символов, у лимитов — целое от 1 до 999, и степпер тут не потому, что так
выбрали, а потому что так сказано в <code>additionalProperties</code>. Занятое имя не
применяется: две строки с одним ключом писали бы поверх друг друга.
</p>
<p class="md:col-span-2 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Пары рисуются <strong>хвостом объекта</strong>, а не полем среди полей: у них нет ни места в
схеме, ни записи в <code>uiSchema</code>, ни порядка среди объявленных. И обратное:
<code>additionalProperties: true</code> хвоста не даёт вовсе — ключи разрешены, но чем рисовать
значение, схема не сказала, и выдумывать текстовое поле значит молча потерять тип.
</p>
</div>
</template>Array
<script setup lang="ts">
import { ref } from 'vue'
import { GrCard } from '@feugene/granularity'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
// Массив объектов — то, ради чего генератор форм и заводят: руками повторяемая
// секция с добавлением, удалением и переиндексацией пишется дольше всего.
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
title: { type: 'string', title: 'Название заказа' },
items: {
type: 'array',
title: 'Позиции',
minItems: 1,
maxItems: 5,
items: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, title: 'Наименование' },
qty: { type: 'integer', minimum: 1, title: 'Количество' },
price: { type: 'number', minimum: 0, title: 'Цена' },
},
required: ['name', 'qty'],
},
},
},
required: ['title'],
}
const ui: GrUiSchema = {
fields: {
'items': { array: { columns: { base: 1, md: 3 }, addLabel: 'Добавить позицию' } },
'items.*.name': { span: { base: 1, md: 1 } },
},
}
const model = ref<Record<string, unknown>>({ items: [{ name: 'Кофе', qty: 2, price: 350 }] })
</script>
<template>
<GrCard class="grid gap-4 p-5">
<GrSchemaForm v-model="model" :schema="schema" :adapters="[jsonSchemaAdapter]" :ui-schema="ui" />
</GrCard>
</template>Branching
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
/**
* Ветвление: набор полей зависит от значения одного ключа.
*
* Демо намеренно даёт вариантам общее поле `comment` — на нём видно главное
* решение: смена ветки сохраняет то, что есть у обеих сторон, и отбрасывает
* чужое. Иначе пользователь терял бы уже написанное на каждом переключении.
*/
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
order: { type: 'string', title: 'Номер заказа', default: 'A-1043' },
delivery: {
title: 'Доставка',
// Общая часть принадлежит каждой ветке: адаптер сливает её в вариант,
// как `allOf`, — повторять поле во всех трёх не нужно.
properties: { comment: { type: 'string', title: 'Комментарий курьеру или кладовщику' } },
oneOf: [
{
type: 'object',
title: 'Самовывоз',
description: 'Заказ ждёт на складе трое суток',
properties: {
kind: { const: 'pickup' },
point: {
'type': 'string',
'enum': ['msk-sever', 'msk-yug', 'spb-centr'],
'x-enumNames': ['Москва, Северный', 'Москва, Южный', 'Петербург, Центральный'],
'title': 'Пункт выдачи',
},
},
required: ['point'],
},
{
type: 'object',
title: 'Курьер',
description: 'Привезём в выбранный интервал',
properties: {
kind: { const: 'courier' },
address: { type: 'string', minLength: 5, title: 'Адрес' },
slot: {
'type': 'string',
'enum': ['10-14', '14-18', '18-22'],
'x-enumNames': ['10:00 — 14:00', '14:00 — 18:00', '18:00 — 22:00'],
'title': 'Интервал',
},
},
required: ['address', 'slot'],
},
{
type: 'object',
title: 'Почта',
description: 'Отправим и пришлём трек-номер',
properties: {
kind: { const: 'post' },
zip: { type: 'string', pattern: '^\\d{6}$', title: 'Индекс' },
address: { type: 'string', minLength: 5, title: 'Адрес' },
},
required: ['zip', 'address'],
},
],
},
},
required: ['order'],
}
const ui: GrUiSchema = {
layout: { columns: { base: 1, md: 2 } },
fields: { 'delivery.comment': { span: 'full' } },
}
const model = ref<Record<string, unknown>>({})
const json = computed(() => JSON.stringify(model.value, null, 2))
</script>
<template>
<div class="grid gap-3 md:grid-cols-2">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:ui-schema="ui"
/>
<pre class="overflow-auto rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] p-3 text-[length:var(--gr-control-text-sm)] leading-[var(--gr-leading-sm)]">{{ json }}</pre>
<p class="md:col-span-2 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Переключите способ доставки и следите за панелью справа: в модели остаются
<strong>ровно ключи выбранной ветки</strong>. Комментарий есть у всех трёх — он переживает
переключение; адрес есть у курьера и почты — он тоже; пункт выдачи чужой для них обоих и
отбрасывается. Оставить чужие ключи нельзя, схема на них ругнётся, а сбрасывать всё значит
терять уже написанное.
</p>
<p class="md:col-span-2 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Поля <code>kind</code> в форме нет, хотя в схеме оно есть у каждого варианта: это
<strong>дискриминатор</strong>, и им управляет сам переключатель — второе поле с тем же именем
спорило бы с ним за значение. Вариантов до пяти — переключатели, больше — список.
Дискриминатор выводится тремя путями: <code>z.discriminatedUnion</code> в zod,
<code>discriminator.propertyName</code> в OpenAPI и — как здесь — общий <code>const</code> у
всех вариантов в чистой JSON Schema. Не вывелся ни одним — форма не гадает: узел уходит в
полную проверку схемой с предупреждением в <code>model.warnings</code>.
</p>
</div>
</template>Chrono
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrSegmented } from '@feugene/granularity'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import { chronoRenderers } from '@feugene/granularity-forms-schema/renderers/chrono'
/**
* Наборы рендереров подключаются **явно**, и это осознанно.
*
* Возьми пакет календарь сам — и приложение, которому нужны две строки и дата,
* получило бы в бандл `granularity-chrono` целиком, ни разу об этом не попросив.
* Поэтому по умолчанию `format: date` — нативный `input[type=date]`, а панель
* появляется, когда набор передали в проп `renderers`.
*/
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
title: { type: 'string', title: 'Событие' },
day: { type: 'string', format: 'date', title: 'Дата' },
at: { type: 'string', format: 'time', title: 'Время' },
createdAt: { type: 'string', format: 'date-time', title: 'Создано' },
},
required: ['title', 'day'],
}
const set = ref<'core' | 'chrono'>('core')
const renderers = computed(() => (set.value === 'chrono' ? chronoRenderers : undefined))
const model = ref<Record<string, unknown>>({ title: 'Ревью квартала' })
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Схема не менялась — менялся набор рендереров
</span>
<GrSegmented
v-model="set"
size="sm"
:options="[
{ value: 'core', label: 'Только ядро' },
{ value: 'chrono', label: '+ chrono' },
]"
aria-label="Набор рендереров"
/>
</div>
<GrSchemaForm
:key="set"
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:renderers="renderers"
/>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Слева от переключателя ничего не поменялось: та же схема, те же три поля с форматами
<code>date</code>, <code>time</code> и <code>date-time</code>. Разница только в реестре —
и вместо нативных полей появляются <code>GrDatePicker</code>, <code>GrTimePicker</code> и
<code>GrDateTimePicker</code> со своей клавиатурой, панелью и локалью.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Записи из <code>renderers</code> кладутся <strong>поверх</strong> дефолтных и по умолчанию
сильнее: потребитель регистрирует их последними и вправе ждать, что победят они. Тем же
способом подключается <code>./renderers/extended</code> и любой свой виджет.
</p>
</div>
</template>Conditions
<script setup lang="ts">
import { ref } from 'vue'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
/**
* Форма, которая реагирует: состав полей зависит от уже введённого.
*
* Условие живёт в `uiSchema`, а не в схеме, и это не мелочь: контракт данных от
* вида не зависит. Бэкенд по-прежнему знает, что у него есть и `inn`, и
* `passport`, — а показывать их одновременно бессмысленно.
*/
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
kind: {
'type': 'string',
'enum': ['person', 'company'],
// `x-enumNames` — расширение, которым OpenAPI-генераторы отдают подписи:
// в самом `enum` лежат значения контракта, а не текст для человека.
'x-enumNames': ['Физлицо', 'Компания'],
'title': 'Контрагент',
},
fullName: { type: 'string', minLength: 2, title: 'ФИО' },
passport: { type: 'string', pattern: '^\\d{4} \\d{6}$', title: 'Паспорт' },
companyName: { type: 'string', minLength: 2, title: 'Название' },
inn: { type: 'string', pattern: '^\\d{10}$', title: 'ИНН' },
vat: { type: 'boolean', title: 'Плательщик НДС' },
vatRate: { type: 'integer', enum: [10, 20], title: 'Ставка НДС, %' },
comment: { type: 'string', title: 'Комментарий' },
},
required: ['kind', 'fullName', 'passport'],
}
const ui: GrUiSchema = {
layout: { columns: { base: 1, md: 2 } },
fields: {
// Равенство: самое частое условие.
passport: { when: { path: 'kind', eq: 'person' } },
companyName: { when: { path: 'kind', eq: 'company' } },
inn: { when: { path: 'kind', eq: 'company' } },
vat: { when: { path: 'kind', eq: 'company' } },
// Составное: ставка нужна, только если это компания И она платит НДС.
vatRate: { when: { all: [{ path: 'kind', eq: 'company' }, { path: 'vat', truthy: true }] } },
comment: { span: 'full' },
},
}
const model = ref<Record<string, unknown>>({ kind: 'person' })
</script>
<template>
<div class="grid gap-3">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:ui-schema="ui"
/>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Переключите контрагента: у физлица спрашивают паспорт, у компании — название и ИНН. Ставка НДС
появляется только у компании, которая его платит: <code>all</code> складывает условия,
<code>any</code> сложил бы их через «или». Внутри повторителя есть третий способ —
относительный путь <code>../kind</code>: сослаться на соседа по строке абсолютным путём
пришлось бы через индекс, а он меняется при каждом удалении.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Следствие, о котором стоит знать заранее: <strong>скрытое поле не проверяется</strong>.
<code>passport</code> обязателен по схеме, но у компании его нет на экране — и форма
отправится. Иначе пользователь упирался бы в ошибку на поле, которого не видит, и починить её
было бы нечем.
</p>
</div>
</template>Json
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrCard } from '@feugene/granularity'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
// Схема, какой её отдаёт OpenAPI: типы, форматы, ограничения — и ни слова о виде.
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
email: { type: 'string', format: 'email', title: 'Почта' },
fullName: { type: 'string', minLength: 2, title: 'Имя' },
age: { type: 'integer', minimum: 18, maximum: 120, title: 'Возраст' },
role: { type: 'string', enum: ['admin', 'editor', 'viewer'], title: 'Роль' },
about: { type: 'string', maxLength: 500, title: 'О себе' },
newsletter: { type: 'boolean', title: 'Присылать письма' },
},
required: ['email', 'fullName', 'role'],
}
// Всё, что относится к виду, живёт отдельно от контракта данных.
const ui: GrUiSchema = {
layout: { columns: { base: 1, md: 2 } },
fields: {
about: { span: 'full' },
newsletter: { span: 'full' },
},
}
const model = ref<Record<string, unknown>>({})
const saved = ref('—')
function onSubmit(value: Record<string, unknown>): void {
saved.value = JSON.stringify(value)
}
</script>
<template>
<GrCard class="grid gap-4 p-5">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:ui-schema="ui"
@submit="onSubmit"
>
<template #actions>
<div class="mt-4 flex justify-end">
<GrButton type="submit">
Сохранить
</GrButton>
</div>
</template>
</GrSchemaForm>
<p class="text-sm text-[var(--gr-muted-fg)]">
Отправлено: <strong>{{ saved }}</strong>
</p>
</GrCard>
</template>Sections
<script setup lang="ts">
import { ref } from 'vue'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
/**
* Двенадцать полей подряд — это анкета, которую бросают на середине.
*
* Схема их порядка не задаёт и задавать не должна: в JSON у объекта порядок
* ключей формальный, а в zod — порядок объявления. Раскладка — работа
* `uiSchema`, и она же переживает добавление поля на бэкенде: новое поле не
* ломает разделы, а попадает в тот, где стоит `'*'`.
*/
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
lastName: { type: 'string', title: 'Фамилия' },
firstName: { type: 'string', title: 'Имя' },
birthday: { type: 'string', format: 'date', title: 'Дата рождения' },
email: { type: 'string', format: 'email', title: 'Почта' },
phone: { type: 'string', title: 'Телефон' },
telegram: { type: 'string', title: 'Telegram' },
country: { type: 'string', enum: ['RU', 'RS', 'AM', 'GE'], title: 'Страна' },
city: { type: 'string', title: 'Город' },
address: { type: 'string', title: 'Адрес' },
position: { type: 'string', title: 'Должность' },
department: { type: 'string', enum: ['Инженерия', 'Продукт', 'Продажи'], title: 'Отдел' },
startedAt: { type: 'string', format: 'date', title: 'В компании с' },
},
required: ['lastName', 'firstName', 'email'],
}
const ui: GrUiSchema = {
layout: {
columns: { base: 1, md: 2 },
sections: [
{
id: 'person',
title: 'Личные данные',
description: 'То, что не меняется от места работы.',
fields: ['lastName', 'firstName', 'birthday'],
},
{
id: 'contacts',
title: 'Связь',
fields: ['email', 'phone', 'telegram'],
},
{
id: 'work',
title: 'Работа',
columns: { base: 1, md: 3 },
// `'*'` — место для всего, что не перечислено выше. Новое поле в схеме
// приедет сюда, а не потеряется.
fields: ['position', 'department', 'startedAt', '*'],
},
],
},
fields: { address: { span: 'full' } },
}
const model = ref<Record<string, unknown>>({})
</script>
<template>
<div class="grid gap-3">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:ui-schema="ui"
:heading-level="4"
/>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Три раздела с заголовками и своей сеткой у каждого: «Работа» идёт в три колонки, остальные в
две. Поля <code>country</code>, <code>city</code> и <code>address</code> нигде не перечислены —
они попали в раздел со звёздочкой. Это не мелочь: бэкенд добавит поле, и оно окажется в форме
само, а не выпадет из неё молча.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Заголовки — настоящие <code>h4</code> (уровень задаётся пропом <code>headingLevel</code>, чтобы
встроиться в иерархию страницы), а не «жирный текст». Скринридер обходит форму по заголовкам,
как обходит статью.
</p>
</div>
</template>Server Errors
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrButton, GrJsonViewer, GrSegmented } from '@feugene/granularity'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
/**
* Ответ сервера, разложенный по полям.
*
* Клиентская валидация проверяет форму, а не мир: занятость почты, лимит склада
* и правила, живущие в базе, знает только бэкенд. Его ответ обязан вернуться на
* те поля, из-за которых он и случился, — иначе пользователю остаётся общий
* баннер «что-то пошло не так» над формой из двадцати полей.
*/
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
email: { type: 'string', format: 'email', title: 'Почта' },
company: { type: 'string', title: 'Компания' },
items: {
type: 'array',
title: 'Позиции',
items: {
type: 'object',
properties: {
sku: { type: 'string', title: 'Артикул' },
qty: { type: 'integer', minimum: 1, title: 'Количество' },
},
required: ['sku', 'qty'],
},
},
},
required: ['email'],
}
const ui: GrUiSchema = { layout: { columns: { base: 1, md: 2 } }, fields: { items: { span: 'full' } } }
/** Три формата, и все живые. Пакет разбирает каждый без настройки. */
const responses = {
laravel: {
message: 'The given data was invalid.',
errors: {
'email': ['Почта уже занята'],
'items.1.qty': ['На складе осталось 3 штуки'],
},
},
jsonapi: {
errors: [
{ source: { pointer: '/data/attributes/email' }, detail: 'Почта уже занята' },
{ source: { pointer: '/data/attributes/items/1/qty' }, detail: 'На складе осталось 3 штуки' },
{ detail: 'Заказ не прошёл проверку кредитного лимита' },
],
},
rfc7807: {
type: 'https://example.com/validation-error',
title: 'Validation Failed',
violations: [
{ propertyPath: 'email', message: 'Почта уже занята' },
{ propertyPath: 'items[1].qty', message: 'На складе осталось 3 штуки' },
],
},
} as const
const format = ref<keyof typeof responses>('laravel')
const answer = ref<unknown>(null)
const model = ref<Record<string, unknown>>({
email: '[email protected]',
company: 'Ромашка',
items: [{ sku: 'A-100', qty: 2 }, { sku: 'B-220', qty: 12 }],
})
const payload = computed(() => responses[format.value])
function send(): void {
// Сервер ответил 422 — отдаём ответ как есть, разбирать его форме.
answer.value = payload.value
}
function reset(): void {
answer.value = null
}
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<GrSegmented
v-model="format"
size="sm"
:options="[
{ value: 'laravel', label: 'Laravel' },
{ value: 'jsonapi', label: 'JSON:API' },
{ value: 'rfc7807', label: 'RFC 7807' },
]"
aria-label="Формат ответа сервера"
@update:model-value="reset"
/>
<div class="flex gap-2">
<GrButton size="sm" @click="send">
Ответ 422
</GrButton>
<GrButton size="sm" variant="outline" :disabled="answer === null" @click="reset">
Сбросить
</GrButton>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:ui-schema="ui"
:server-errors="answer"
show-form-errors
/>
<div class="grid content-start gap-2">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Что отдал сервер
</span>
<GrJsonViewer :value="payload" :default-expand-depth="4" :max-height="280" size="sm" aria-label="Ответ сервера" />
</div>
</div>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Три формата, и все три встречаются в жизни: Laravel отдаёт карту путей, JSON:API — указатели
вида <code>/data/attributes/items/1/qty</code>, RFC 7807 — <code>items[1].qty</code>. Пакет
приводит их к одному инстанс-пути сам, поэтому ошибка садится на <strong>вторую строку</strong>
позиций, а не на форму целиком. Свой формат подключается пропом <code>serverErrors</code> уже
готовой картой.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Сообщение, которому не нашлось поля, не пропадает: в ответе JSON:API третья ошибка без
<code>source</code> — она уходит в сводку над формой. Потерять её было бы хуже всего:
пользователь видел бы форму без единой пометки и кнопку, которая не срабатывает.
</p>
</div>
</template>Validation
<script setup lang="ts">
import { ref } from 'vue'
import { z } from 'zod'
import { GrButton } from '@feugene/granularity'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
import { zodAdapter } from '@feugene/granularity-forms-schema/zod'
/**
* Три яруса проверки — и почему их именно три.
*
* Ярусы не про удобство реализации, а про то, когда появляется сообщение.
* Первый успевает к нажатию клавиши, третий требует прогнать схему целиком, и
* платить за это на каждый символ незачем.
*/
const schema = z.object({
// Ярус 1 — ложится в правило ядра: сообщение из локали, то же, что у формы,
// написанной руками.
email: z.email().meta({ title: 'Почта' }),
// Ярус 2 — локальный валидатор: кратность правилом ядра не выражается.
seats: z.number().int().multipleOf(5).min(5).max(100).meta({ title: 'Мест в тарифе, кратно 5' }),
password: z.string().min(8).meta({ title: 'Пароль' }),
passwordAgain: z.string().min(8).meta({ title: 'Пароль ещё раз' }),
// Ярус 2 же: «обязан быть отмечен» — это не `required`, см. текст под формой.
terms: z.literal(true).meta({ title: 'Согласен с условиями' }),
})
// Ярус 3 — кросс-полевое правило. Из модели такое не выражается вовсе:
// `residual` получает корневой узел, и форма гоняет схему сама на отправке.
.refine(value => value.password === value.passwordAgain, {
path: ['passwordAgain'],
message: 'Пароли не совпадают',
})
const ui: GrUiSchema = {
layout: { columns: { base: 1, md: 2 } },
fields: { terms: { span: 'full' } },
}
const model = ref<Record<string, unknown>>({
email: 'не-почта',
seats: 7,
password: 'correct-horse',
passwordAgain: 'battery-staple',
})
const submitted = ref(false)
</script>
<template>
<div class="grid gap-3">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[zodAdapter]"
:ui-schema="ui"
show-form-errors
@submit="submitted = true"
@invalid="submitted = false"
>
<!-- Кнопку рисует потребитель: форма не знает, одна она на странице или
лежит в подвале мастера рядом с «Назад». -->
<template #actions>
<div class="mt-2 flex justify-end">
<GrButton type="submit">
Отправить
</GrButton>
</div>
</template>
</GrSchemaForm>
<p v-if="submitted" class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-fg)]">
Отправлено: прошли все три яруса.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Нажмите «Отправить», ничего не меняя, — сработают три разных механизма. Ядро возвращает
<strong>первое</strong> сработавшее правило на поле, поэтому третий ярус видно после того, как
починены первые два: поправьте почту и число мест, и появится «Пароли не совпадают».
<code>email</code> ловит <strong>правило ядра</strong> (ярус 1, сообщение из локали, то же
самое, что у формы, написанной руками). «Мест в тарифе» — <strong>локальный валидатор</strong>
(ярус 2: кратность пяти в правило ядра не укладывается, как и уникальность элементов или
строгие границы). «Пароли не совпадают» — <strong>полная проверка схемой</strong> (ярус 3:
кросс-полевое условие из модели не выражается вовсе, узел помечен <code>residual</code>).
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Третий ярус не требует ни строчки обвязки. <code>refine</code> на объекте помечает
<code>residual</code> у <strong>корневого</strong> узла, а не у <code>passwordAgain</code>:
путь ошибки схема сообщает только в момент проверки, объявить его заранее нечем. Поэтому
форма сама прогоняет схему на отправке и раскладывает её замечания по полям — тем же путём,
которым разбирает ответ сервера. Формам без кросс-полевых правил это не стоит ничего: проверка
не запускается вовсе. Выключается тем же <code>validation.tiers</code>, без второго пропа.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Флажок согласия — <strong>не</strong> <code>required</code>, и это ловушка, на которой
обжигаются все. Ядро не считает <code>false</code> пустым — иначе поле пряталось бы там, где
форма считает его заполненным, — поэтому <code>required</code> на чекбоксе спокойно пропускает
<strong>снятый</strong> флажок. Правильный способ — <code>z.literal(true)</code>: «обязан быть
отмечен» это утверждение о значении, а не о заполненности.
</p>
</div>
</template>Widget
<!-- SeverityField.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { GrButtonGroup, GrButton } from '@feugene/granularity'
/**
* Свой контрол на три кнопки.
*
* Ровно тот минимум, которого требует контракт форм-контрола ядра: принимает
* `modelValue`, отдаёт `update:modelValue`, уважает `disabled`/`readonly` и
* умеет показать себя ошибочным. Больше форме ничего и не нужно — подпись,
* обязательность, вывод ошибки и связь по `aria` берёт на себя обёртка поля.
*/
const props = defineProps<{
modelValue?: unknown
disabled?: boolean
readonly?: boolean
invalid?: boolean
ariaLabel?: string
}>()
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
const LEVELS = [
{ value: 'low', label: 'Низкая' },
{ value: 'normal', label: 'Обычная' },
{ value: 'high', label: 'Срочная' },
]
const current = computed(() => String(props.modelValue ?? ''))
function pick(value: string): void {
if (props.disabled || props.readonly)
return
emit('update:modelValue', value)
}
</script>
<template>
<GrButtonGroup :aria-label="ariaLabel" :class="invalid ? 'rounded-[var(--gr-radius-md)] ring-1 ring-[var(--gr-danger)]' : undefined">
<GrButton
v-for="level in LEVELS"
:key="level.value"
size="sm"
:variant="current === level.value ? 'primary' : 'outline'"
:disabled="disabled || readonly"
:aria-pressed="current === level.value"
@click="pick(level.value)"
>
{{ level.label }}
</GrButton>
</GrButtonGroup>
</template>
<!-- GrSchemaFormWidgetDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { GrRating } from '@feugene/granularity'
import { GrSchemaForm } from '@feugene/granularity-forms-schema'
import { jsonSchemaAdapter } from '@feugene/granularity-forms-schema/json-schema'
import type { JsonSchemaDocument } from '@feugene/granularity-forms-schema/json-schema'
import type { GrUiSchema } from '@feugene/granularity-forms-schema/ui-schema'
import SeverityField from './SeverityField.vue'
/**
* «Почти всё сгенерировано, а два поля свои» — обещание, ради которого форму по
* схеме вообще берут. Без такого выхода первое же нестандартное поле заставляет
* бросить генерацию и написать всю форму руками.
*/
const schema: JsonSchemaDocument = {
type: 'object',
properties: {
subject: { type: 'string', minLength: 3, title: 'Тема' },
severity: { type: 'string', enum: ['low', 'normal', 'high'], title: 'Важность' },
rating: { type: 'integer', minimum: 1, maximum: 5, title: 'Оценка поддержки' },
details: { type: 'string', title: 'Подробности' },
},
required: ['subject', 'severity'],
}
const ui: GrUiSchema = {
layout: { columns: { base: 1, md: 2 } },
fields: {
// Чужой компонент: реестр не зовётся вовсе.
severity: { component: SeverityField },
// Компонент ядра на месте числового поля — тот же способ, готовая деталь.
rating: { component: GrRating, controlProps: { max: 5 } },
details: { span: 'full', widget: 'gr:textarea' },
},
}
const model = ref<Record<string, unknown>>({ severity: 'normal' })
</script>
<template>
<div class="grid gap-3">
<GrSchemaForm
v-model="model"
:schema="schema"
:adapters="[jsonSchemaAdapter]"
:ui-schema="ui"
/>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Три поля из четырёх подменены, и все три — разными способами.
«Важность» рисует <strong>свой</strong> компонент (<code>SeverityField.vue</code> рядом в
исходнике): по схеме это <code>enum</code>, то есть был бы селект. «Оценка» — готовый
<code>GrRating</code> из ядра там, где схема обещала числовое поле. «Подробности» — запись
реестра по имени (<code>widget: 'gr:textarea'</code>), когда менять компонент не нужно, а
нужен другой из набора.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Своему контролу хватает контракта форм-контрола ядра: принять
<code>modelValue</code>, отдать <code>update:modelValue</code>, уважать
<code>disabled</code>/<code>readonly</code> и уметь показать себя ошибочным. Подпись,
звёздочку обязательности, вывод ошибки и связь по <code>aria-describedby</code> берёт на себя
обёртка поля — их писать не надо, и разойтись с остальной формой они не могут.
</p>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Виджет ищется по порядку: сначала слот поля, затем <code>component</code>, затем
<code>widget</code> по имени, затем записи реестра по убыванию приоритета и в самом конце —
<code>gr:string</code> вместе с событием <code>unresolved</code>. Последнее не тихий откат:
форма сообщает, что узел она не поняла.
</p>
</div>
</template>