GrForm

Package: @feugene/granularitycoreGroup: forms

Orchestrates form validation via declarative rules and the GrFormField context.

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

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

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.

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 },
  }],
}
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.

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

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.

Playground 4

Loading…

Code
<GrForm />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
modelrequiredTModelThe 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"
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.

Slots

SlotTypeDescription
default{ 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

EventTypeDescription
submit[TModel]The form passed validation on submit. It gives away `model`.
validate[string, boolean, string | undefined]The result of the validation of a single field.
invalid[Record<string, string>]The submit did not pass validation. Without this event "the form is invalid" and "nothing happened" look the same to the consumer.

Examples 5

Declarative validation & submit

At least 8 characters

Validation
<script setup lang="ts">
import { reactive, ref } from 'vue'

import { GrButton, GrForm, GrFormField, GrInput, type GrFormInstance, type GrFormRules } from '@feugene/granularity'

const model = reactive({ name: '', email: '', password: '' })

const rules: GrFormRules = {
  name: [{ required: true }],
  email: [{ required: true, type: 'email' }],
  password: [{ required: true, min: 8 }],
}

const formRef = ref<GrFormInstance>()
const submitted = ref(false)

function onSubmit() {
  submitted.value = true
}

function reset() {
  formRef.value?.resetFields()
  submitted.value = false
}
</script>

<template>
  <GrForm
    ref="formRef"
    :model="model"
    :rules="rules"
    class="grid max-w-sm gap-4"
    @submit="onSubmit"
  >
    <GrFormField name="name" label="Name">
      <GrInput v-model="model.name" placeholder="Ada Lovelace" />
    </GrFormField>

    <GrFormField name="email" label="Email">
      <GrInput v-model="model.email" type="email" placeholder="ada@example.com" />
    </GrFormField>

    <GrFormField name="password" label="Password" hint="At least 8 characters">
      <GrInput v-model="model.password" type="password" />
    </GrFormField>

    <div class="flex gap-2">
      <GrButton type="submit">
        Sign up
      </GrButton>
      <GrButton variant="secondary" type="button" @click="reset">
        Reset
      </GrButton>
    </div>

    <p v-if="submitted" class="text-sm text-[var(--gr-success)]">
      Submitted — form is valid.
    </p>
  </GrForm>
</template>

Any control + custom / async rules

At least 8 characters

Mixed Controls
<script setup lang="ts">
import { reactive, ref } from 'vue'

import {
  GrAutocomplete,
  GrButton,
  GrForm,
  GrFormField,
  GrInput,
  GrSelect,
  type GrFormInstance,
  type GrFormRules,
} from '@feugene/granularity'

const model = reactive({ username: '', country: '', framework: '', password: '', confirm: '' })

const countries = [
  { value: 'us', label: 'United States' },
  { value: 'de', label: 'Germany' },
  { value: 'jp', label: 'Japan' },
]

const frameworks = [
  { value: 'vue', label: 'Vue' },
  { value: 'react', label: 'React' },
  { value: 'svelte', label: 'Svelte' },
]

const rules: GrFormRules = {
  username: [{ required: true, min: 3, trigger: 'blur' }],
  country: [{ required: true }],
  framework: [{ required: true }],
  password: [{ required: true, min: 8 }],
  confirm: [
    { required: true },
    { validator: value => value === model.password || 'Passwords do not match' },
  ],
}

const formRef = ref<GrFormInstance>()
const result = ref('')

async function checkValidity() {
  const valid = await formRef.value?.validate()
  result.value = valid ? 'All fields valid ✓' : 'Fix the highlighted fields'
}
</script>

<template>
  <GrForm ref="formRef" :model="model" :rules="rules" class="grid max-w-md gap-4">
    <GrFormField name="username" label="Username">
      <GrInput v-model="model.username" placeholder="ada" />
    </GrFormField>

    <div class="grid gap-4 sm:grid-cols-2">
      <GrFormField name="country" label="Country">
        <GrSelect v-model="model.country" :options="countries" placeholder="Select…" aria-label="Country" />
      </GrFormField>

      <GrFormField name="framework" label="Framework">
        <GrAutocomplete v-model="model.framework" :options="frameworks" placeholder="Search…" aria-label="Framework" />
      </GrFormField>
    </div>

    <GrFormField name="password" label="Password" hint="At least 8 characters">
      <GrInput v-model="model.password" type="password" />
    </GrFormField>

    <GrFormField name="confirm" label="Confirm password">
      <GrInput v-model="model.confirm" type="password" />
    </GrFormField>

    <div class="flex items-center gap-3">
      <GrButton type="button" @click="checkValidity">
        Validate
      </GrButton>
      <span class="text-sm text-[var(--gr-muted-fg)]">{{ result }}</span>
    </div>
  </GrForm>
</template>

Custom control + custom validator

Custom control — validated like any GrInput

Custom Control
<!-- CustomColorInput.vue -->
<script setup lang="ts">
import { computed } from 'vue'

import { useGrFormFieldContext } from '@feugene/granularity'

const model = defineModel<string>({ default: '' })

// Кастомный контрол сам подключается к GrFormField через контекст: id (связка с
// label `for`), aria-describedby (hint + error), aria-invalid и aria-required —
// ровно так же, как это делают встроенные GrInput / GrSelect / GrAutocomplete.
const field = useGrFormFieldContext()
const invalid = computed(() => Boolean(field?.invalid.value))
const isHex = computed(() => /^#[0-9a-f]{6}$/i.test(model.value))
</script>

<template>
  <div class="flex items-center gap-2">
    <span
      class="h-9 w-9 shrink-0 rounded-lg border border-[var(--gr-brd)]"
      :style="{ background: isHex ? model : 'transparent' }"
    />
    <input
      :id="field?.id.value"
      v-model="model"
      :aria-describedby="field?.describedById.value"
      :aria-invalid="invalid || undefined"
      :aria-required="field?.required.value || undefined"
      placeholder="#3b82f6"
      class="h-9 w-full rounded-lg border bg-[var(--gr-bg)] px-3 text-sm outline-none focus:ring-2 focus:ring-[var(--gr-primary)]/40"
      :class="invalid ? 'border-[var(--gr-danger)]' : 'border-[var(--gr-brd)]'"
    >
  </div>
</template>

<!-- GrFormCustomControlDemo.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'

import { GrButton, GrForm, GrFormField, GrInput, type GrFormInstance, type GrFormRules } from '@feugene/granularity'

import CustomColorInput from './CustomColorInput.vue'

const model = reactive({ label: '', brandColor: '' })

// Свой валидатор: возвращает true (ок) или строку с текстом ошибки.
function isHexColor(value: unknown) {
  return /^#[0-9a-f]{6}$/i.test(String(value)) || 'Use a 6-digit hex color, e.g. #3b82f6'
}

const rules: GrFormRules = {
  label: [{ required: true, min: 2 }],
  brandColor: [{ required: true }, { validator: isHexColor }],
}

const formRef = ref<GrFormInstance>()
const saved = ref('')

function onSubmit() {
  saved.value = `Saved “${model.label}” · ${model.brandColor}`
}
</script>

<template>
  <GrForm
    ref="formRef"
    :model="model"
    :rules="rules"
    class="grid max-w-sm gap-4"
    @submit="onSubmit"
  >
    <GrFormField name="label" label="Label">
      <GrInput v-model="model.label" placeholder="Primary brand" />
    </GrFormField>

    <GrFormField name="brandColor" label="Brand color" hint="Custom control — validated like any GrInput">
      <CustomColorInput v-model="model.brandColor" />
    </GrFormField>

    <div class="flex gap-2">
      <GrButton type="submit">
        Save
      </GrButton>
      <GrButton variant="secondary" type="button" @click="formRef?.resetFields()">
        Reset
      </GrButton>
    </div>

    <p v-if="saved" class="text-sm text-[var(--gr-success)]">
      {{ saved }}
    </p>
  </GrForm>
</template>

Editing form: snapshot, dirty state and async rule

Введите «taken», чтобы увидеть отказ сервера

isDirty: false · isValid: false

Editing
<script setup lang="ts">
import { reactive, ref } from 'vue'

import { GrButton, GrForm, GrFormField, GrInput } from '@feugene/granularity'
import type { GrFormInstance, GrFormRules } from '@feugene/granularity'

type FormInstance = GrFormInstance

const form = ref<FormInstance>()
const model = reactive<Record<string, unknown>>({ name: '', login: '' })
const loaded = ref(false)
const saving = ref(false)
const status = ref('')

// Логин проверяется «на сервере»: пока ответ не пришёл, поле показывает
// состояние проверки, а не молчит.
const rules: GrFormRules = {
  login: [{
    async validator(value) {
      await new Promise(resolve => setTimeout(resolve, 900))
      return String(value).trim() === 'taken' ? 'Этот логин уже занят' : true
    },
  }],
}

async function load() {
  status.value = 'Загружаем…'
  await new Promise(resolve => setTimeout(resolve, 500))

  Object.assign(model, { name: 'Алан Тьюринг', login: 'alan' })
  // Снимок из `setup` был снят с пустой модели: без пересъёмки «Сбросить»
  // вернул бы форму к пустоте, а не к загруженным данным.
  form.value?.setSnapshot()

  loaded.value = true
  status.value = 'Данные загружены'
}

async function save() {
  saving.value = true
  status.value = 'Сохраняем…'
  await new Promise(resolve => setTimeout(resolve, 800))

  form.value?.setSnapshot()
  saving.value = false
  status.value = 'Сохранено'
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton variant="outline" :disabled="loaded" @click="load">
        Загрузить данные
      </GrButton>
      <span class="text-xs text-[var(--gr-muted-fg)]">{{ status }}</span>
    </div>

    <GrForm
      ref="form"
      :model="model"
      :rules="rules"
      :disabled="saving"
      class="grid gap-3"
      @submit="save"
    >
      <!-- Обязательность объявлена полем, а не правилом: submit её всё равно
           проверит. -->
      <GrFormField name="name" label="Имя" required>
        <GrInput v-model="model.name as string" placeholder="Как вас зовут" />
      </GrFormField>

      <GrFormField name="login" label="Логин" hint="Введите «taken», чтобы увидеть отказ сервера">
        <GrInput v-model="model.login as string" placeholder="alan" />
      </GrFormField>

      <div class="flex flex-wrap items-center gap-3">
        <GrButton type="submit" :disabled="!form?.isDirty || saving">
          Сохранить
        </GrButton>
        <GrButton variant="outline" :disabled="!form?.isDirty || saving" @click="form?.resetFields()">
          Сбросить
        </GrButton>
        <span class="text-xs text-[var(--gr-muted-fg)]">
          isDirty: {{ String(Boolean(form?.isDirty)) }} · isValid: {{ String(Boolean(form?.isValid)) }}
        </span>
      </div>
    </GrForm>
  </div>
</template>

Error Banner

showFieldLabels=true, canRetry=false, tone validation=warning, fieldLabels for nice field captions.

Error Banner
<script setup lang="ts">
import { computed, shallowRef } from 'vue'

import {
  GrButton,
  GrCard,
  GrFormErrorBanner,
  type ResponseErrorInfo,
  useResponseError,
} from '@feugene/granularity'

class FakeHttpError extends Error {
  isAxiosError = true
  response: { status: number, data: unknown, headers?: Record<string, string> }

  constructor(status: number, data: unknown, headers?: Record<string, string>) {
    super(`Request failed with status ${status}`)
    this.name = 'AxiosError'
    this.response = { status, data, headers }
  }
}

const formClassifier = useResponseError()
const fakeFormError = shallowRef<ResponseErrorInfo | null>(null)

const fieldLabels = computed(() => ({
  email: 'E-mail',
  password: 'Password',
}))

async function triggerFormDemo() {
  const info = await formClassifier.classify(new FakeHttpError(422, {
    message: 'Validation error',
    errors: {
      email: ['Enter a valid email'],
      password: ['Password is too short', 'Must contain digits'],
    },
  }))
  fakeFormError.value = info
}
</script>

<template>
  <GrCard class="grid gap-3 p-4">
    <p class="text-[12px] text-[var(--gr-muted-fg)]">
      showFieldLabels=true, canRetry=false, tone validation=warning, fieldLabels for nice field captions.
    </p>

    <div class="flex flex-wrap gap-2">
      <GrButton size="sm" @click="triggerFormDemo">
        Simulate 422 form validation
      </GrButton>
      <GrButton size="sm" variant="outline" @click="fakeFormError = null">
        Hide
      </GrButton>
    </div>

    <GrFormErrorBanner
      :error="fakeFormError"
      :field-labels="fieldLabels"
      @dismiss="fakeFormError = null"
    />
  </GrCard>
</template>

Component documentationAll components