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
| Need | Take |
|---|---|
| There are few fields and they are known in advance | GrForm |
| The wrapper of a single field: the label, the error, the hint | GrFormField |
| Ask for a single value with a window | GrPromptDialog |
| A form builder by dragging | nothing: 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,GrRadioGroupfor a short enum,GrSelectfor a long one — so it looks and behaves like a form written by hand.ui-schemaadds 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 How an order ships
A
oneOfwhose variants share aconstkey 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 setsmethod.additionalPropertieswith 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.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-changereports each, andui-schemareaches every row by a template path (seats.*.role).server-errorstakes the 422 as it came in the RFC 7807 shape:seats[1].emaillands on the second row and clears on the first edit, andplan, which has no field here, shows in the summary instead of disappearing.Team plan · seatsUp to 4 people
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
schema | unknown | undefined | A foreign schema: JSON Schema, a zod object. The adapter is chosen by `supports()`. |
schemaModel | GrSchemaModel | undefined | undefined | A ready neutral model — stronger than `schema`. |
adapters | GrSchemaAdapter<unknown>[] | undefined | undefined | Explicit adapters. Without them parsing is only possible from `schemaModel`. |
parseOptions | GrSchemaParseOptions | undefined | undefined | Settings for the adapter's parsing of a foreign schema: traversal depth, required-by-default, annotation prefix, full validation. |
modelValuev-model | TModel | undefined | undefined | Form data. Unset — assembled from the schema's defaults. |
uiSchema | GrUiSchema | undefined | undefined | Layout and appearance on top of the schema: sections, columns, order, hidden fields, widgets, labels. |
renderers | GrSchemaRendererRegistry | readonly GrSchemaRenderer[] | undefined | undefined | A registry or a set of entries on top of the default one. |
rules | GrFormRules | undefined | undefined | Rules on top of the compiled ones: the key is an instance path. |
validation | GrSchemaRuleCompilerOptions | undefined | undefined | Settings for compiling rules from the schema: tiers, custom formats, when full validation runs. |
serverErrors | unknown | undefined | The server response: raw, parsed, or a map `{ path: messages }`. |
validateOnBlur | boolean | undefined | true | Validate a field when it loses focus. `true` by default. |
validateOnChange | boolean | undefined | false | Validate a field on every change of its value. `false` by default. |
scrollToError | boolean | undefined | true | Scroll to the first invalid field when validation fails. `true` by default. |
disabled | boolean | undefined | false | All form fields are unavailable. |
readonly | boolean | undefined | false | All form fields are read-only: the values are visible but cannot be edited. |
columns | GrUiColumns | undefined | undefined | The columns of the field grid: a number or a map by breakpoint. Unset — from `uiSchema.layout.columns`, then from `GrConfigProvider`, otherwise one. |
labelPosition | "top" | "start" | undefined | undefined | The position of the field labels: `top` — above the field, `start` — on the left. Unset — from `GrConfigProvider`, otherwise `top`. |
labelWidth | string | number | undefined | undefined | The width of the label column with `labelPosition="start"`; a number is pixels. |
headingLevel | 4 | 2 | 3 | 6 | 5 | undefined | undefined | The level of the headings of sections and nested objects — to fit the place of the form on the page. Unset — from `GrConfigProvider`, otherwise `3`. |
showFormErrors | boolean | undefined | true | A summary of form errors and of orphaned server messages — above the fields, in the `danger` tone: the save did not go through. |
Slots
| Slot | Type | Description |
|---|---|---|
formErrors | { errors: string[]; } | — |
default | { fields: GrSchemaFieldInstance[]; model: Record<string, unknown>; } | — |
actions | {} | — |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (e: "update:modelValue", model: TModel): void | The form data has changed (`v-model`): an edit of a field or of an array row. |
submit | (e: "submit", model: TModel): void | The submission passed the field and schema validation — the form data. |
invalid | (e: "invalid", errors: Record<string, string>): void | The submission failed validation: field path → first error message. |
parsed | (e: "parsed", model: GrSchemaModel, warnings: GrSchemaWarning[]): void | The schema has been parsed; `warnings` — what the adapter did not understand. |
unresolved | (e: "unresolved", node: GrSchemaNode, name: string): void | No renderer was found for a schema node: the node and the instance path; the field is left without a control. |
rowChange |
| 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 type { GrSchemaFormConfigurableProps, GrSchemaFormContext, GrSchemaFormProps } from '@feugene/granularity-forms-schema/components/GrSchemaForm'Filedist/types/components/GrSchemaForm/config.d.ts
const grSchemaFormConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSchemaForm">;Filedist/types/components/GrSchemaForm/defaults.d.ts
interface GrSchemaFormConfigurableProps {
columns: GrUiColumns;
labelPosition: 'top' | 'start';
labelWidth: string | number;
headingLevel: 2 | 3 | 4 | 5 | 6;
}Filedist/types/components/GrSchemaForm/context.d.ts
interface GrSchemaFormContext {
root: ComputedRef<GrSchemaObjectNode | undefined>;
model: ComputedRef<Record<string, unknown>>;
ui: ComputedRef<GrUiSchema>;
renderers: ComputedRef<GrSchemaRendererRegistry>;
disabled: ComputedRef<boolean>;
readonly: ComputedRef<boolean>;
serverErrorAt: (name: string) => string[] | undefined;function useSchemaForm(): GrSchemaFormContext | null;Filedist/types/components/GrSchemaForm/GrSchemaForm.vue.d.ts
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;
}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
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);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
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;
}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
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:
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 (oneOfwith a sharedconst,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.
Props in this example