GrForm

  • core
  • forms
  • API · 8 props

Orchestrates form validation via declarative rules and the GrFormField context.

import { GrForm } from '@feugene/granularity/components/GrForm'

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 — disabled on the form dims every control inside.

When to take something else

NeedTake
There is a single field and no rulesGrFormField + GrInput
Ask for a single value with a windowGrPromptDialog
Only the layout and the labels, the checking is on the serverGrFormField

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

Stage

Loading…

0 changed· state is in the URL

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 validator receives the whole model — «differs from the current one» and «matches the new one» are rules like any other. submit comes only from a valid form, and invalid says 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.

    12 characters or more

  • 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. isDirty from the slot enables Save and Discard, resetFields goes back to the snapshot, and disabled on the form covers every control while loading and saving.

  • An address checked by the server

    A validator may return a promise: while the availability check runs, the field is busy and says «Checking…» instead of keeping an error about the previous value. validatingFields from the slot lists what is still being checked, and «Create workspace» waits for the answer rather than sending an unchecked address.

    Create a workspace

    Your team signs in here

API generated from the package · cannot drift

Props

PropTypedefaultDescription
modelrequiredTModel—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.
rulesGrFormRules | undefinedundefinedThe validation rules by field name: `{ email: [{ required: true, type: 'email' }] }`.
validateOnBlurboolean | undefinedtrueValidate a field on the loss of focus.
validateOnChangeboolean | undefinedfalseValidate a field on every change of the value.
scrollToErrorboolean | undefinedtrueScroll to the first invalid field after `validate()`.
scrollBehaviorScrollBehavior | undefined"smooth"How to scroll to a field — to the first error and in `scrollToField`. `smooth` by default.
disabledboolean | undefinedfalseSwitch 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.
reserveMessageboolean | undefinedfalseAll 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

SlotTypeDescription
default
{ validate: () => Promise<boolean>; errors: Record<string, s{ validate: () => Promise<boolean>; errors: Record<string, string | undefined>; isDirty: boolean; isValid: boolean; validatingFields: Set<string>; resetFields: (names?: string | string[] | undefined) => void; setSnapshot: (model?: Record<string, unknown> | undefined) => void; }
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

EventSignatureDescription
submit(e: "submit", model: TModel): voidThe form passed validation on submit. It gives away `model`.
validate(e: "validate", name: string, valid: boolean, message: string | undefined): voidThe result of the validation of a single field.
invalid(e: "invalid", errors: Record<string, string>): voidThe 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
import type { GrFormContext, GrFormFileRule, GrFormProps } from '@feugene/granularity/components/GrForm'

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

grFormConfigconst
const grFormConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrForm">;

Filedist/types/components/shared/formContext.d.ts

GrFormContextinterface
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;
useGrFormContextfunction
function useGrFormContext(): GrFormContext | null;

Filedist/types/components/shared/formFileRule.d.ts

GrFormFileRuleinterface
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

GrFormPropsinterface
interface GrFormProps<TModel extends object = Record<string, unknown>> {
    model: TModel;
    rules?: GrFormRules;
    validateOnBlur?: boolean;
    validateOnChange?: boolean;
    scrollToError?: boolean;
    scrollBehavior?: ScrollBehavior;
    disabled?: boolean;
    reserveMessage?: boolean;
}
GrFormEmitsinterface
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

GrFormTriggertype
type GrFormTrigger = 'blur' | 'change' | 'submit';
GrFormValidatorResulttype
type GrFormValidatorResult = boolean | string | void;
GrFormRuleinterface
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>;
GrFormRulestype
type GrFormRules = Record<string, GrFormRule | GrFormRule[]>;
GrFormRuleFailuretype
type GrFormRuleFailure = 'required' | 'min' | 'max' | 'len' | 'pattern' | 'email' | 'url' | 'file' | 'invalid';
GrFormMessageResolvertype
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>;
GrFormMessageTranslatetype
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>;
createGrFormMessageResolverfunction
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>;
isEmptyfunction
function isEmpty(value: unknown): boolean;
getByPathfunction
function getByPath(obj: Record<string, unknown>, path: string): unknown;
setByPathfunction
function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
runFieldRulesfunction
function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;

Filedist/types/components/GrForm/index.d.ts

GrFormInstancetype
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:

ts
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.

vue
<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:

ts
const rules: GrFormRules = {
  contract: [{
    required: true,
    file: { accept: '.pdf,application/pdf', maxSizeMb: 1 },
  }],
}
KeyWhat it limits
acceptthe W3C accept string ('image/*,.pdf')
extensionsa whitelist of extensions
mimeTypesa whitelist of MIME types
maxSizeMb / maxSizeBytesthe size of one file; if both are set, the smaller one applies
maxCountthe number of files in the set
maxTotalSizeMbthe total size of the set
validatorsyour 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.

ts
const form = useForm({ email: '', password: '' })
vue
<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 sweep post, reset and errors off the object. The external form’s own reset (form.reset()) remains yours in the process: it knows about its service fields, and GrForm does not;
  • isDirty is 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:

ts
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.

ts
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.