GrSchemaForm

Package: @feugene/granularity-forms-schemacompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the backend already describes the contract — zod, JSON Schema, OpenAPI: laying out forty fields from it by hand is expensive, and two descriptions inevitably diverge;
  • there are many forms and they are of the same kind — an admin area where every entity has its own creation and editing form;
  • the schema arrives from the server — the set of fields changes with no frontend release;
  • almost everything is generated and two fields are your own — a slot intercepts any field by its path, and the rest stay generated.

When to take something else

NeedTake
There are few fields and they are known in advanceGrForm
The wrapper of a single field: the label, the error, the hintGrFormField
Ask for a single value with a windowGrPromptDialog
A form builder by draggingnothing: that is a product rather than a component

The package draws nothing itself

Apart from the grid of columns. The orchestration is given by GrForm, the wrapper and the accessibility by GrFormField, the sections by GrFormSection, and the input by the controls of the core. Hence the main property: a generated form looks and behaves exactly like a hand-written one — the same texts of errors, the same keyboard, the same theme.

The grid is the exception, because the core has no columns: GrFormField answers for one field, and the layout between fields is built by everyone with markup of their own.

The validation has three tiers, and that is visible from the outside

What fits into the rules of the core goes into them: required, lengths, bounds, email, url, files. What is expressible from the schema but not from the rules — integer, multipleOf, strict bounds, uniqueness, “has to be ticked” — becomes a local validator. Everything else — refine, cross-field conditions, branches — travels into one check by the schema itself, which remains the source of truth.

The message is taken from the schema only if the author wrote one. Otherwise the text is given by the resolver of the core — with the locale of the core.

compiledRules in defineExpose answers the single question such a package gets asked in production: why is this field not being validated.

The items are assembled for the target, and the layout goes through `uiSchema`

A schema describes the data rather than the interface. Everything to do with the look lives in uiSchema and is addressed by a template path (items.*.qty) rather than by the path of a particular row:

const ui: GrUiSchema = {
  layout: { columns: 2, sections: [{ id: 'main', title: 'Main', fields: ['email', 'nick'] }] },
  order: ['email', '*'],
  fields: {
    'bio': { widget: 'gr:textarea', span: 'full' },
    'items.*.qty': { label: 'Quantity' },
    'company': { when: { path: 'type', eq: 'company' } },
  },
}

A field hidden by a condition does not take part in the validation: otherwise the submission would be blocked by the requiredness of a field that is not on the screen.

The widget is chosen by the registry rather than by the schema

The registry maps a node of the model to a component: a string with format: email is a GrInput[type=email], up to five options a GrRadioGroup, more than that a GrSelect, an array of strings a GrInputTag, an array of objects a repeater.

The default set is deliberately narrow: every entry is a component in dependencies, that is, its CSS in the bundle of every consumer of the form. Sliders, ratings and colour pickers live in extendedRenderers, and the date pickers in ./renderers/chrono; both are connected explicitly, see ../renderers.md.

Server errors are not lost

A 422 answer is parsed from Laravel, JSON:API and RFC 7807, the path is normalised (items[0].nameitems.0.name), the error sits on the field and is removed by the very first edit. An error on a field the form does not draw is shown in a summary at the top: “the save did not go through, and why is nowhere” is the worst possible outcome.

A branch is a switch rather than a field

The delivery method, the payment type, the kind of document: the set of fields depends on the value of one key. The form draws a switch of the options (up to five a GrRadioGroup, beyond that a GrSelect) and the fields of the chosen branch under it.

The discriminator is not drawn as a separate field in the process: it is governed by the switch, and a second field with the same name would argue with it over the value.

A change of branch rewrites the value: the shared keys are kept, foreign ones are discarded, and the discriminator is set. Keeping foreign ones is not allowed — the schema will complain about them; resetting everything would lose a shared field such as a note that every option has.

It is drawn by GrSchemaUnionField. A branch is a structure rather than a control, and that is why it is not in the registry of renderers: an entry in the registry would give one GrFormField with one label for the whole group.

Free keys are an appendix to the object

The tail is drawn by GrSchemaAdditionalFields. An additionalProperties with a schema of the value (or a catchall in zod) gives a list of “key — value” pairs: the name is entered by the user, and the value is drawn by an ordinary control from the schema. As the tail of the object rather than as a field among fields: a pair has neither a place in the schema, nor an entry in uiSchema, nor an order among the declared fields.

An additionalProperties: true without a schema of the value gives no tail: the keys are allowed, but the schema did not say what to draw the value with.

A pair has no visible label — the field of the key plays that role — so the name for the control of the value is given by the key itself, and it moves along with a rename. The details and the way to name the field from the outside (the ariaLabel of GrSchemaField) are in the accessibility document.

Limits

  • there is no form builder by dragging here and there will not be — that is a product rather than a component of a library;
  • the package does not replace server-side validation. A schema on the client saves a request and hints earlier, but the decision is made by the server;
  • conditions — if/then/else, not — are not unfolded by the form. Such nodes are marked for a full check by the schema: the error will be shown, but the form will not build a field for every outcome. A branch by a discriminator (oneOf with a shared const, z.discriminatedUnion) it does build — see “A branch” below;
  • a union without a discriminator does not branch: there is nothing to choose the option with, and the node goes into the full check with a warning.

Install

npm i @feugene/granularity-forms-schema

Import

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

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 11

Adapters

Adaptersdepends on the showcase environment
<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: 'ivan@example.com',
  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

Validationdepends on the showcase environment
<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>

Component documentationAll components