GrSchemaForm

  • forms-schema
  • forms-schema
  • API · 20 props
import { GrSchemaForm } from '@feugene/granularity-forms-schema/components/GrSchemaForm'

Overview

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

Examples 3 each with a live demo and its code

  • A customer from the API's own schema

    Every field is generated from the OpenAPI schema and drawn by the core — GrFormField, GrInput, GrRadioGroup for a short enum, GrSelect for a long one — so it looks and behaves like a form written by hand. ui-schema adds the sections, two columns and the company fields that appear only for a company; a hidden field takes no part in the check.

    New customerGenerated from the CustomerCreate schema of the billing API

    Who is billed

    Two letters and the number, e.g. DE811569869

    How they are billed

    Seen by your team only

  • How an order ships

    A oneOf whose variants share a const key is a branch, not a field: the form draws a switch of the variants and the fields of the chosen one. Switching keeps the shared note, drops the other branch’s keys and sets method. additionalProperties with a value schema gives key–value pairs — the key is typed, the value is drawn by the schema — and the payload beside the form shows what the API gets.

    Delivery

    Labels for the warehouse

    Extra fields
    Sent to the warehouse API
    {
      "delivery": {
        "method": "courier",
        "address": "Hafenstraße 12",
        "city": "Hamburg",
        "window": "afternoon",
        "notes": "Ring at «Fabrikam», second floor"
      },
      "metadata": {
        "order-ref": "PO-77120"
      }
    }
  • Seats, and what the server said

    An array of objects is a repeater: rows are added, removed and reordered, row-change reports each, and ui-schema reaches every row by a template path (seats.*.role). server-errors takes the 422 as it came in the RFC 7807 shape: seats[1].email lands on the second row and clears on the first edit, and plan, which has no field here, shows in the summary instead of disappearing.

    Team plan · seatsUp to 4 people

    Seats

    Seat 1
    Seat 2
    Seat 3

API generated from the package · cannot drift

Props

PropTypedefaultDescription
schemaunknownundefinedA foreign schema: JSON Schema, a zod object. The adapter is chosen by `supports()`.
schemaModelGrSchemaModel | undefinedundefinedA ready neutral model — stronger than `schema`.
adaptersGrSchemaAdapter<unknown>[] | undefinedundefinedExplicit adapters. Without them parsing is only possible from `schemaModel`.
parseOptionsGrSchemaParseOptions | undefinedundefinedSettings for the adapter's parsing of a foreign schema: traversal depth, required-by-default, annotation prefix, full validation.
modelValuev-modelTModel | undefinedundefinedForm data. Unset — assembled from the schema's defaults.
uiSchemaGrUiSchema | undefinedundefinedLayout and appearance on top of the schema: sections, columns, order, hidden fields, widgets, labels.
renderersGrSchemaRendererRegistry | readonly GrSchemaRenderer[] | undefinedundefinedA registry or a set of entries on top of the default one.
rulesGrFormRules | undefinedundefinedRules on top of the compiled ones: the key is an instance path.
validationGrSchemaRuleCompilerOptions | undefinedundefinedSettings for compiling rules from the schema: tiers, custom formats, when full validation runs.
serverErrorsunknownundefinedThe server response: raw, parsed, or a map `{ path: messages }`.
validateOnBlurboolean | undefinedtrueValidate a field when it loses focus. `true` by default.
validateOnChangeboolean | undefinedfalseValidate a field on every change of its value. `false` by default.
scrollToErrorboolean | undefinedtrueScroll to the first invalid field when validation fails. `true` by default.
disabledboolean | undefinedfalseAll form fields are unavailable.

Slots

SlotTypeDescription
formErrors{ errors: string[]; }—
default{ fields: GrSchemaFieldInstance[]; model: Record<string, unknown>; }—
actions{}—

Events

EventSignatureDescription
update:modelValue(e: "update:modelValue", model: TModel): voidThe form data has changed (`v-model`): an edit of a field or of an array row.
submit(e: "submit", model: TModel): voidThe submission passed the field and schema validation — the form data.
invalid(e: "invalid", errors: Record<string, string>): voidThe submission failed validation: field path → first error message.
parsed(e: "parsed", model: GrSchemaModel, warnings: GrSchemaWarning[]): voidThe schema has been parsed; `warnings` — what the adapter did not understand.
unresolved(e: "unresolved", node: GrSchemaNode, name: string): voidNo renderer was found for a schema node: the node and the instance path; the field is left without a control.
rowChange
(e: "rowChange", path: string, action: "add" | "remove" | "m(e: "rowChange", path: string, action: "add" | "remove" | "move", index: number): void
An array row was added, removed or moved: the array path, the action and the row index (for `move` — the new one).

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrSchemaFormConfigurableProps, GrSchemaFormContext, GrSchemaFormProps } from '@feugene/granularity-forms-schema/components/GrSchemaForm'

Filedist/types/components/GrSchemaForm/config.d.ts

grSchemaFormConfigconst
const grSchemaFormConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSchemaForm">;

Filedist/types/components/GrSchemaForm/defaults.d.ts

GrSchemaFormConfigurablePropsinterface
interface GrSchemaFormConfigurableProps {
    columns: GrUiColumns;
    labelPosition: 'top' | 'start';
    labelWidth: string | number;
    headingLevel: 2 | 3 | 4 | 5 | 6;
}

Filedist/types/components/GrSchemaForm/context.d.ts

GrSchemaFormContextinterface
interface GrSchemaFormContext {
    root: ComputedRef<GrSchemaObjectNode | undefined>;
    model: ComputedRef<Record<string, unknown>>;
    ui: ComputedRef<GrUiSchema>;
    renderers: ComputedRef<GrSchemaRendererRegistry>;
    disabled: ComputedRef<boolean>;
    readonly: ComputedRef<boolean>;
    serverErrorAt: (name: string) => string[] | undefined;
useSchemaFormfunction
function useSchemaForm(): GrSchemaFormContext | null;

Filedist/types/components/GrSchemaForm/GrSchemaForm.vue.d.ts

GrSchemaFormPropsinterface
interface GrSchemaFormProps<T extends Record<string, unknown> = Record<string, unknown>> {
    schema?: unknown;
    schemaModel?: GrSchemaModel;
    adapters?: GrSchemaAdapter[];
    parseOptions?: GrSchemaParseOptions;
    modelValue?: T;
    uiSchema?: GrUiSchema;
    renderers?: GrSchemaRendererRegistry | readonly GrSchemaRenderer[];
    rules?: GrFormRules;
    validation?: GrSchemaRuleCompilerOptions;
    serverErrors?: unknown;
    validateOnBlur?: boolean;
    validateOnChange?: boolean;
    scrollToError?: boolean;
    disabled?: boolean;
    readonly?: boolean;
    columns?: GrUiColumns;
    labelPosition?: 'top' | 'start';
    labelWidth?: string | number;
    headingLevel?: 2 | 3 | 4 | 5 | 6;
    showFormErrors?: boolean;
}
GrSchemaFormEmitsinterface
interface GrSchemaFormEmits<T extends Record<string, unknown> = Record<string, unknown>> {
    (e: 'update:modelValue', model: T): void;
    (e: 'submit', model: T): void;
    (e: 'invalid', errors: Record<string, string>): void;
    (e: 'parsed', model: GrSchemaModel, warnings: GrSchemaWarning[]): void;
    (e: 'unresolved', node: GrSchemaNode, name: string): void;
    (e: 'rowChange', path: string, action: 'add' | 'remove' | 'move', index: number): void;
}

Filedist/types/components/GrSchemaForm/GrSchemaArrayField.vue.d.ts

GrSchemaArrayFieldPropsinterface
interface GrSchemaArrayFieldProps {
    node?: GrSchemaArrayNode;
    path?: string;
    name?: string;
    indices?: number[];
    ui?: GrUiArrayOptions;
    columns?: GrUiColumns;
    min?: number;
    max?: number;
    sortable?: boolean;
    duplicable?: boolean;
    title?: string;
    description?: string;
    headingLevel?: 2 | 3 | 4 | 5 | 6;
    addLabel?: string;
    removeLabel?: string;
    emptyText?: string;
    itemLabel?: string | ((item: unknown, index: number) => string);
GrSchemaArrayFieldEmitsinterface
interface GrSchemaArrayFieldEmits {
    (e: 'add', item: unknown, index: number): void;
    (e: 'remove', item: unknown, index: number): void;
    (e: 'move', from: number, to: number): void;
}

Filedist/types/components/GrSchemaForm/GrSchemaField.vue.d.ts

GrSchemaFieldPropsinterface
interface GrSchemaFieldProps {
    node?: GrSchemaNode;
    path?: string;
    name?: string;
    indices?: number[];
    modelValue?: unknown;
    ui?: GrUiFieldOptions;
    renderers?: GrSchemaRendererRegistry;
    disabled?: boolean;
    readonly?: boolean;
    error?: string | string[];
    bare?: boolean;
    ariaLabel?: string;
    label?: string;
    hint?: string;
    labelPosition?: 'top' | 'start';
    labelWidth?: string | number;
    showMessage?: boolean;
}
GrSchemaFieldEmitsinterface
interface GrSchemaFieldEmits {
    (e: 'update:modelValue', value: unknown): void;
    (e: 'change', value: unknown): void;
    (e: 'unresolved', node: GrSchemaNode, name: string): void;
}

Filedist/types/components/GrSchemaForm/safelist.d.ts

grSchemaFormSafelistconst
const grSchemaFormSafelist: string[];

Usage from the library prose

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:

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

A section draws exactly its own fields, and in their order. '*' expands in its place into the remainder — the root fields that no section has named — and follows order. A field that has not landed in any section, when there is no '*' anywhere, is not drawn: sections are both the layout and the selection. Without sections order sets the order of the whole form. Hidden (hidden) and conditional (when) fields behave inside a section the same as without one.

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].name → items.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.