GrForm
Orchestrates form validation via declarative rules and the GrFormField context.
Overview
When to take it
- there is more than one field and they are validated — the rules are declared by field name rather than hung by hand;
- the fields depend on one another — a validator sees the whole model: matching passwords, “the end date is later than the start”;
- submission is blocked until it is valid — together with scrolling and focus to the first error;
- the form is blocked as a whole —
disabledon the form dims every control inside.
When to take something else
| Need | Take |
|---|---|
| There is a single field and no rules | GrFormField + GrInput |
| Ask for a single value with a window | GrPromptDialog |
| Only the layout and the labels, the checking is on the server | GrFormField |
The controls know nothing about the form: the orchestration is connected through
GrFormField by name. Any existing control therefore enters the validation with no edits
at all — one of your own, written on top of the form-control contract, included.
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrForm><div class="grid gap-3"><GrFormField label="Work email" hint="Product news once a month"><GrInput modelValue="[email protected]" type="email" /></GrFormField><div><GrButton type="submit">Subscribe</GrButton></div></div></GrForm>Examples 3 each with a live demo and its code
Rules that see the whole model
Rules are declared by field name next to the model, and a
validatorreceives the whole model — «differs from the current one» and «matches the new one» are rules like any other.submitcomes only from a valid form, andinvalidsays how many fields stopped it, so a refused submit does not look like a click that did nothing.Change passwordOther devices are signed out after the change. An edit form with a snapshot
The details arrive after mount, so
setSnapshot()is called once they are in — otherwise «Discard changes» would return the empty object.isDirtyfrom the slot enables Save and Discard,resetFieldsgoes back to the snapshot, anddisabledon the form covers every control while loading and saving.An address checked by the server
A
validatormay return a promise: while the availability check runs, the field is busy and says «Checking…» instead of keeping an error about the previous value.validatingFieldsfrom the slot lists what is still being checked, and «Create workspace» waits for the answer rather than sending an unchecked address.Create a workspace
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelrequired | TModel | — | The reactive object of the form data. The fields are addressed by `name` (`GrFormField`), a dot path included. The type is generic rather than `Record<string, unknown>`: a model is sometimes an object of someone else’s library (Inertia’s `useForm`, a store) where methods of its own lie beside the fields. It does not fit a dictionary, and the consumer would have to cast it through `as unknown as` in every form. |
rules | GrFormRules | undefined | undefined | The validation rules by field name: `{ email: [{ required: true, type: 'email' }] }`. |
validateOnBlur | boolean | undefined | true | Validate a field on the loss of focus. |
validateOnChange | boolean | undefined | false | Validate a field on every change of the value. |
scrollToError | boolean | undefined | true | Scroll to the first invalid field after `validate()`. |
scrollBehavior | ScrollBehavior | undefined | "smooth" | How to scroll to a field — to the first error and in `scrollToField`. `smooth` by default. |
disabled | boolean | undefined | false | Switch the whole form off — typically for the duration of a submission. It reaches the controls through the context of the field, so there is no need to walk them one by one. |
reserveMessage | boolean | undefined | false | All the fields of the form hold space for the message line: errors on submit do not shift the form, a dialog or a fixed-height panel. A field with its own `reserveMessage` decides for itself. |
Slots
| Slot | Type | Description |
|---|---|---|
default |
| The fields of the form. The slot props repeat the public API of the instance: the form is available in the template without a `ref`, and therefore without a `nextTick` after mounting. |
Events
| Event | Signature | Description |
|---|---|---|
submit | (e: "submit", model: TModel): void | The form passed validation on submit. It gives away `model`. |
validate | (e: "validate", name: string, valid: boolean, message: string | undefined): void | The result of the validation of a single field. |
invalid | (e: "invalid", errors: Record<string, string>): void | The submit did not pass validation. Without this event "the form is invalid" and "nothing happened" look the same to the consumer. |
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 { GrFormContext, GrFormFileRule, GrFormProps } from '@feugene/granularity/components/GrForm'Filedist/types/components/GrForm/config.d.ts
const grFormConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrForm">;Filedist/types/components/shared/formContext.d.ts
interface GrFormContext {
errors: Ref<Record<string, string | undefined>>;
requiredFields: ComputedRef<Set<string>>;
validatingFields?: ComputedRef<Set<string>>;
disabled?: ComputedRef<boolean>;
reserveMessage?: ComputedRef<boolean>;
hasField: (name: string) => boolean;function useGrFormContext(): GrFormContext | null;Filedist/types/components/shared/formFileRule.d.ts
interface GrFormFileRule {
accept?: string;
extensions?: string[];
mimeTypes?: string[];
maxSizeMb?: number;
maxSizeBytes?: number;
maxCount?: number;
maxTotalSizeMb?: number;
validators?: FileValidator[];
}Filedist/types/components/GrForm/GrForm.vue.d.ts
interface GrFormProps<TModel extends object = Record<string, unknown>> {
model: TModel;
rules?: GrFormRules;
validateOnBlur?: boolean;
validateOnChange?: boolean;
scrollToError?: boolean;
scrollBehavior?: ScrollBehavior;
disabled?: boolean;
reserveMessage?: boolean;
}interface GrFormEmits<TModel extends object = Record<string, unknown>> {
(e: 'submit', model: TModel): void;
(e: 'validate', name: string, valid: boolean, message: string | undefined): void;
(e: 'invalid', errors: Record<string, string>): void;
}Filedist/types/components/shared/formValidation.d.ts
type GrFormTrigger = 'blur' | 'change' | 'submit';type GrFormValidatorResult = boolean | string | void;interface GrFormRule {
required?: boolean;
message?: string;
min?: number;
max?: number;
len?: number;
pattern?: RegExp;
type?: 'email' | 'url';
file?: GrFormFileRule;
validator?: (value: unknown, model: Record<string, unknown>) => GrFormValidatorResult | Promise<GrFormValidatorResult>;type GrFormRules = Record<string, GrFormRule | GrFormRule[]>;type GrFormRuleFailure = 'required' | 'min' | 'max' | 'len' | 'pattern' | 'email' | 'url' | 'file' | 'invalid';type GrFormMessageResolver = (kind: GrFormRuleFailure, rule: GrFormRule, params: Record<string, unknown>) => string;
export type GrFormMessageTranslate = (key: string, fallback: string, params?: Record<string, unknown>) => string;
export declare function createGrFormMessageResolver(t: GrFormMessageTranslate, locale?: () => string | undefined): GrFormMessageResolver;
export declare function isEmpty(value: unknown): boolean;
export declare function getByPath(obj: Record<string, unknown>, path: string): unknown;
export declare function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
export declare function toRuleArray(rules: GrFormRule | GrFormRule[] | undefined): GrFormRule[];
export declare function rulesForTrigger(rules: GrFormRule[], trigger?: GrFormTrigger): GrFormRule[];
export declare function rulesRequired(rules: GrFormRule[]): boolean;
export declare function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;type GrFormMessageTranslate = (key: string, fallback: string, params?: Record<string, unknown>) => string;
export declare function createGrFormMessageResolver(t: GrFormMessageTranslate, locale?: () => string | undefined): GrFormMessageResolver;
export declare function isEmpty(value: unknown): boolean;
export declare function getByPath(obj: Record<string, unknown>, path: string): unknown;
export declare function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
export declare function toRuleArray(rules: GrFormRule | GrFormRule[] | undefined): GrFormRule[];
export declare function rulesForTrigger(rules: GrFormRule[], trigger?: GrFormTrigger): GrFormRule[];
export declare function rulesRequired(rules: GrFormRule[]): boolean;
export declare function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;function createGrFormMessageResolver(t: GrFormMessageTranslate, locale?: () => string | undefined): GrFormMessageResolver;
export declare function isEmpty(value: unknown): boolean;
export declare function getByPath(obj: Record<string, unknown>, path: string): unknown;
export declare function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
export declare function toRuleArray(rules: GrFormRule | GrFormRule[] | undefined): GrFormRule[];
export declare function rulesForTrigger(rules: GrFormRule[], trigger?: GrFormTrigger): GrFormRule[];
export declare function rulesRequired(rules: GrFormRule[]): boolean;
export declare function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;function isEmpty(value: unknown): boolean;function getByPath(obj: Record<string, unknown>, path: string): unknown;function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;Filedist/types/components/GrForm/index.d.ts
type GrFormInstance = ComponentExposed<typeof GrFormComponent>;Usage from the library prose
Being required arrives from two places
A field is required if it has a required rule or if the GrFormField itself carries
the required prop. Both sources take part both in the * marker and in the validation:
the second one used to draw the asterisk while the submit went through with an empty field.
A field with no rules is in that case given an implicit { required: true } — with the
same message from the locale as an explicit rule.
The snapshot and the reset
resetFields() returns the model to the snapshot. The snapshot is taken on mounting, so an
edit form needs to retake it after the data has loaded:
const form = ref<GrFormInstance>()
const data = await api.load()
Object.assign(model, data)
form.value?.setSnapshot()Without that the “reset” would return the empty object the model was before the server
answered. resetFields(names?) can also do a pointed reset; a key that is not in the
snapshot is deleted rather than turned into undefined. The exception is methods: they
do not enter the snapshot by construction, and deleting them would mean breaking the model
(see “The model may be someone else’s”).
isDirty compares the model with the snapshot, and isValid means “there are no known
errors” — until the first validate() that is not yet “the form is valid” but “nobody has
checked”. Both are available through the ref of the form and in the slot props.
Switching the form off
The disabled of the form travels into the context of the field and further into the
controls: “switch everything off for the duration of the submission” no longer requires
walking the controls one by one. The prop of a control and the prop of a field add to it by
“or” — the way it is already done with readonly.
The submit button usually stands below the fields rather than in a GrFormField, so
GrButton type="submit" reads the disabled of the form itself and is switched off
together with it — a “switched-off” form cannot be submitted a second time. The other
buttons (type="button", type="reset") are left alone: “Cancel” and “Reset” stay
working. A switched-off form also ignores the submit event itself — Enter in a field and
a button that is not a GrButton.
No layout shifts
reserveMessage on the form holds space for the message line under all of its fields:
errors on submit do not shift the form, a dialog or a fixed-height panel. A field with a
reserveMessage of its own decides for itself. The reserve is one line; several errors
on a field grow below it. The details are in GrFormField.md.
<template>
<GrForm :model="model" :rules="rules" reserve-message>
<GrFormField label="Recipient" name="name">
<GrInput v-model="model.name" />
</GrFormField>
</GrForm>
</template>Files as a rule of the form
The constraints on a file are described where the rest of the rules are — with the file
rule:
const rules: GrFormRules = {
contract: [{
required: true,
file: { accept: '.pdf,application/pdf', maxSizeMb: 1 },
}],
}| Key | What it limits |
|---|---|
accept | the W3C accept string ('image/*,.pdf') |
extensions | a whitelist of extensions |
mimeTypes | a whitelist of MIME types |
maxSizeMb / maxSizeBytes | the size of one file; if both are set, the smaller one applies |
maxCount | the number of files in the set |
maxTotalSizeMb | the total size of the set |
validators | your own FileValidators, asynchronous ones included — they run after the built-in ones |
The rule has no checks of its own at all: it assembles the same validators from
../file-validation.md that GrFormFile and v-dropzone run on
a choice and on a drop. The text of the error is therefore one as well — it comes from the
validator and is localised with its gr.fileValidation.* key rather than turning into a
separate “invalid file”. rule.message overrides it, as with any other rule.
File and File[] values are checked; everything else the rule lets through — it must not
invent a verdict for a string. Emptiness is dealt with by required.
If there are several problematic files, the field shows the first one — a form field has
one error line, and files are no exception here. A detailed breakdown per file remains the
job of GrFormFile.
GrFormFile itself does not change in the process. The constraints on the field (accept,
limit, validators) are quick feedback: a bad file does not reach the model at all. The
rule of the form is a guarantee at submit: it is visible to validate(), enters the
invalid event and takes part in the scroll to the first error. No duplication appears on
the screen: what the field did not let into the model is not shown to the rule of the form.
The typical division is the constraints in rules and accept on the field as a filter for
the choice dialog.
Asynchronous rules
While a rule is travelling to the server, the field is marked aria-busy and shows a string
from the locale (gr.form.validating) instead of the old error: the error belonged to the
previous value, and keeping it on the screen means lying about the current one.
The list of fields being checked is available as validatingFields — in the expose and in
the slot props.
Overlapping runs of one field are settled by a generation counter: the result of the last
run applies, and a run that was displaced gives the caller the verdict of the one that
displaced it, having waited for it. validate() and submit therefore cannot slip
through on a value nobody has checked yet.
clearValidate() and resetFields() cancel the checks in flight: their answer belongs
to the state before the reset, it will not reach the cleared form, and the field stops being
counted as under validation at once, without waiting for the server.
The model may be someone else's
model is typed with a generic rather than as a dictionary: a model is sometimes an object
of someone else’s library — Inertia’s useForm, a store — where methods of its own lie
beside the fields. It goes in as it is, with no as unknown as, and submit gives it away
with the same type.
const form = useForm({ email: '', password: '' })<GrForm :model="form" :rules="rules" @submit="form.post('/login')">The form addresses only the names declared by fields (GrFormField name), so everything
else in the object does not concern it. Two consequences are worth knowing in advance:
resetFields()returns the values of the fields and does not touch the methods. The snapshot is built as a clone, and a clone drops functions — without a separate guard “not in the snapshot” would mean “delete”, and the reset would sweeppost,resetanderrorsoff the object. The external form’s own reset (form.reset()) remains yours in the process: it knows about its service fields, andGrFormdoes not;isDirtyis computed over the whole object. An external form has reactive fields of its own beside the data ones (processing,errors), and the form will see their changes too. If a “the user edited the data” flag is needed, take it from the external form itself.
Submit is asynchronous
Rules are sometimes promises, so validate() always returns a Promise, and submit is
emitted after it resolves. In tests a single nextTick() is not enough:
await wrapper.find('form').trigger('submit')
await flushPromises() // not nextTick: the rules may have gone to the server
expect(onSubmit).toHaveBeenCalled()The events and the API
submit(model)— only when the form is valid;invalid(errors)— a map of messages when the submit did not go through. Without it “the form is invalid” and “nothing happened” look the same;validate(name, valid, message)— the result for a single field.
Imperatively: validate(), validateField(name, trigger?), clearValidate(),
resetFields(), setSnapshot(), scrollToField(name), plus isDirty, isValid,
validatingFields.
The type for a ref is GrFormInstance rather than InstanceType<typeof GrForm>: the form
is generic, and such a component compiles into a function, which has no constructor.
const form = ref<GrFormInstance>()
form.value?.resetFields()The rules and their messages — ../file-validation.md for files
and GrForm/validation.ts for the rest; the engine is public
(runFieldRules, createGrFormMessageResolver), and GrPromptDialog uses the same one.
Props in this example