GrSchemaForm

Пакет: @feugene/granularity-forms-schemaспутникГруппа: Прочее

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

Когда брать

  • бэкенд уже описывает контракт — 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: emailGrInput[type=email], до пяти вариантов — GrRadioGroup, больше — GrSelect, массив строк — GrInputTag, массив объектов — повторитель.

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

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

Ответ 422 разбирается из Laravel, JSON:API и RFC 7807, путь нормализуется (items[0].nameitems.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

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

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

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

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

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

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

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

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

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

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

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>

Документация компонентаВсе компоненты