GrForm
Берут, когда полей больше одного и они проверяются.
Обзор
Когда брать
- полей больше одного и они проверяются — правила объявляются по имени поля, а не развешиваются руками;
- поля зависят друг от друга — валидатор видит всю модель: совпадение паролей, «дата конца позже начала»;
- отправка блокируется до валидности — вместе со скроллом и фокусом к первой ошибке;
- форма блокируется целиком —
disabledна форме гасит все контролы внутри.
Когда взять другое
| Нужно | Берите |
|---|---|
| Поле одно и правил нет | GrFormField + GrInput |
| Запросить одно значение окном | GrPromptDialog |
| Только раскладка и подписи, проверка на сервере | GrFormField |
Контролы про форму не знают: оркестрация подключается через GrFormField по
name. Поэтому любой существующий контрол попадает в валидацию без единой
правки — и свой, написанный поверх контракта форм-контрола, тоже.
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<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>Примеры 3 у каждого — живое демо и его код
Правила, которые видят всю модель
Правила объявлены по имени поля рядом с моделью, а
validatorполучает всю модель — «отличается от текущего» и «совпадает с новым» становятся обычными правилами.submitприходит только от валидной формы, аinvalidговорит, сколько полей её остановили, — отказ не выглядит кликом, который ничего не сделал.Change passwordOther devices are signed out after the change. Форма редактирования со снимком
Реквизиты приходят после монтирования, поэтому
setSnapshot()вызывается, когда они на месте, — иначе «Отменить изменения» вернуло бы пустой объект.isDirtyиз слота включает «Сохранить» и «Отменить»,resetFieldsвозвращает к снимку, аdisabledна форме гасит все контролы на время загрузки и сохранения.Адрес, проверенный сервером
validatorможет вернуть промис: пока идёт проверка занятости, поле занято и пишет «Проверяем…», а не держит ошибку о прежнем значении.validatingFieldsиз слота перечисляет, что ещё проверяется, и «Создать» ждёт ответа, а не отправляет непроверенный адрес.Create a workspace
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelобязательный | TModel | — | Реактивный объект данных формы. Поля адресуются по `name` (`GrFormField`), в т.ч. dot-path. Тип дженерик, а не `Record<string, unknown>`: моделью бывает объект чужой библиотеки (`useForm` Inertia, стор), у которого рядом с полями лежат собственные методы. Под словарь он не подходит, и потребителю пришлось бы приводить его через `as unknown as` в каждой форме. |
rules | GrFormRules | undefined | undefined | Правила валидации по имени поля: `{ email: [{ required: true, type: 'email' }] }`. |
validateOnBlur | boolean | undefined | true | Валидировать поле при потере фокуса. |
validateOnChange | boolean | undefined | false | Валидировать поле при каждом изменении значения. |
scrollToError | boolean | undefined | true | Скроллить к первому невалидному полю после `validate()`. |
scrollBehavior | ScrollBehavior | undefined | "smooth" | Как прокручивать к полю — к первой ошибке и в `scrollToField`. По умолчанию `smooth`. |
disabled | boolean | undefined | false | Выключить форму целиком — типично на время отправки. Доезжает до контролов через контекст поля, поэтому обходить их по одному не нужно. |
reserveMessage | boolean | undefined | false | Все поля формы держат место под строку сообщения: ошибки по submit не сдвигают форму, диалог и панель фиксированной высоты. Поле со своим `reserveMessage` решает за себя. |
Слоты
| Slot | Type | Описание |
|---|---|---|
default |
| Поля формы. Слот-пропы повторяют публичный API инстанса: форма в шаблоне доступна без `ref`, а значит и без `nextTick` после монтирования. |
События
| Event | Сигнатура | Описание |
|---|---|---|
submit | (e: "submit", model: TModel): void | Форма прошла валидацию по submit. Отдаёт `model`. |
validate | (e: "validate", name: string, valid: boolean, message: string | undefined): void | Результат валидации одного поля. |
invalid | (e: "invalid", errors: Record<string, string>): void | Submit не прошёл валидацию. Без этого события «форма невалидна» и «ничего не произошло» выглядят для потребителя одинаково. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrFormContext, GrFormFileRule, GrFormProps } from '@feugene/granularity/components/GrForm'Файлdist/types/components/GrForm/config.d.ts
const grFormConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrForm">;Файлdist/types/components/shared/formContext.d.ts
interface GrFormContext {
/** Реактивная карта ошибок по имени поля. */
errors: Ref<Record<string, string | undefined>>;
/** Имена полей, у которых в правилах есть `required` (для маркера `*`). */
requiredFields: ComputedRef<Set<string>>;
/**
* Поля, у которых прямо сейчас идёт асинхронная проверка. Необязательны:
* тип публичный, и контекст строят руками (в тестах и в своих обёртках) —
* старая форма контекста продолжает работать.
*/
validatingFields?: ComputedRef<Set<string>>;
/** Форма выключена целиком: значение видно, но ничего не редактируется. */
disabled?: ComputedRef<boolean>;
/** Поля держат место под строку сообщения, пока ошибки нет (`reserveMessage`). */
reserveMessage?: ComputedRef<boolean>;
/** Есть ли для поля правила (тогда `GrFormField` берёт ошибку из формы). */
hasField: (name: string) => boolean;Возвращает контекст формы или null, если поле вне GrForm.
function useGrFormContext(): GrFormContext | null;Файлdist/types/components/shared/formFileRule.d.ts
interface GrFormFileRule {
/** W3C `accept`: `'image/*,.pdf'`. */
accept?: string;
/** Белый список расширений (с точкой или без). */
extensions?: string[];
/** Белый список MIME-типов. */
mimeTypes?: string[];
/** Предел размера одного файла в мегабайтах. */
maxSizeMb?: number;
/** Предел размера одного файла в байтах. Задан вместе с `maxSizeMb` — берётся меньший. */
maxSizeBytes?: number;
/** Максимум файлов в наборе. */
maxCount?: number;
/** Суммарный размер набора в мегабайтах. */
maxTotalSizeMb?: number;
/** Свои валидаторы — тот же тип, что у `GrFormFile` и `v-dropzone`. Идут после встроенных. */
validators?: FileValidator[];
}Файлdist/types/components/GrForm/GrForm.vue.d.ts
Публичный GR-примитив «Form» — оркестрация валидации над GrFormField.
Модель формы (v-model-объект приложения) + декларативные rules по имени поля.
Форма прогоняет правила, раскладывает ошибки по контексту, скроллит к первой
ошибке и эмитит submit только при валидной форме. Императивный API — через
template ref (validate / validateField / clearValidate / resetFields /
scrollToField).
interface GrFormProps<TModel extends object = Record<string, unknown>> {
/**
* Реактивный объект данных формы. Поля адресуются по `name` (`GrFormField`),
* в т.ч. dot-path.
*
* Тип дженерик, а не `Record<string, unknown>`: моделью бывает объект чужой
* библиотеки (`useForm` Inertia, стор), у которого рядом с полями лежат
* собственные методы. Под словарь он не подходит, и потребителю пришлось бы
* приводить его через `as unknown as` в каждой форме.
*/
model: TModel;
/** Правила валидации по имени поля: `{ email: [{ required: true, type: 'email' }] }`. */
rules?: GrFormRules;
/** Валидировать поле при потере фокуса. */
validateOnBlur?: boolean;
/** Валидировать поле при каждом изменении значения. */
validateOnChange?: boolean;
/** Скроллить к первому невалидному полю после `validate()`. */
scrollToError?: boolean;
/** Как прокручивать к полю — к первой ошибке и в `scrollToField`. По умолчанию `smooth`. */
scrollBehavior?: ScrollBehavior;
/**
* Выключить форму целиком — типично на время отправки. Доезжает до контролов
* через контекст поля, поэтому обходить их по одному не нужно.
*/
disabled?: boolean;
/**
* Все поля формы держат место под строку сообщения: ошибки по submit не
* сдвигают форму, диалог и панель фиксированной высоты. Поле со своим
* `reserveMessage` решает за себя.
*/
reserveMessage?: boolean;
}interface GrFormEmits<TModel extends object = Record<string, unknown>> {
/** Форма прошла валидацию по submit. Отдаёт `model`. */
(e: 'submit', model: TModel): void;
/** Результат валидации одного поля. */
(e: 'validate', name: string, valid: boolean, message: string | undefined): void;
/**
* Submit не прошёл валидацию. Без этого события «форма невалидна» и «ничего
* не произошло» выглядят для потребителя одинаково.
*/
(e: 'invalid', errors: Record<string, string>): void;
}Файлdist/types/components/shared/formValidation.d.ts
type GrFormTrigger = 'blur' | 'change' | 'submit';Результат кастомного валидатора: true/void — ок; false — ошибка (дефолт-текст); строка — текст ошибки.
type GrFormValidatorResult = boolean | string | void;interface GrFormRule {
/** Значение обязательно (не пустое). */
required?: boolean;
/** Готовый текст ошибки — перекрывает дефолтное сообщение любой проверки этого правила. */
message?: string;
/** Минимум: длина для строки/массива, значение для числа. */
min?: number;
/** Максимум: длина для строки/массива, значение для числа. */
max?: number;
/** Точная длина/значение. */
len?: number;
/** Регулярное выражение (проверяется по `String(value)`). */
pattern?: RegExp;
/** Встроенный тип-валидатор. */
type?: 'email' | 'url';
/** Ограничения для значения `File | File[]`: тип, размер, количество. */
file?: GrFormFileRule;
/** Кастомный (в т.ч. async) валидатор. Второй аргумент — весь `model`. */
validator?: (value: unknown, model: Record<string, unknown>) => GrFormValidatorResult | Promise<GrFormValidatorResult>;type GrFormRules = Record<string, GrFormRule | GrFormRule[]>;Вид сработавшей проверки — ключ для дефолтного i18n-сообщения.
type GrFormRuleFailure = 'required' | 'min' | 'max' | 'len' | 'pattern' | 'email' | 'url' | 'file' | 'invalid';type GrFormMessageResolver = (kind: GrFormRuleFailure, rule: GrFormRule, params: Record<string, unknown>) => string;
/** Подпись `t()` из `useGranularityTranslations()`: ключ, фолбэк, параметры. */
export type GrFormMessageTranslate = (key: string, fallback: string, params?: Record<string, unknown>) => string;
/**
* Дефолтный резолвер сообщений: `rule.message` сильнее всего, иначе ключ
* `gr.form.<kind>` из локали, иначе английский фолбэк.
*
* Вынесен из `GrForm.vue` и экспортируется наружу не «на всякий случай»:
* `runFieldRules` публичен, но без резолвера бесполезен — любой, кто хочет
* прогнать те же правила вне формы, обязан был написать свой.
*/
export declare function createGrFormMessageResolver(t: GrFormMessageTranslate, locale?: () => string | undefined): GrFormMessageResolver;
/** Пустое значение: `null`/`undefined`/пустая (или пробельная) строка/пустой массив. */
export declare function isEmpty(value: unknown): boolean;
/** Достаёт значение из объекта по dot-path (`address.city`). */
export declare function getByPath(obj: Record<string, unknown>, path: string): unknown;
/** Записывает значение в объект по dot-path (создаёт промежуточные объекты). */
export declare function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
/** Нормализует правило(а) поля в массив. */
export declare function toRuleArray(rules: GrFormRule | GrFormRule[] | undefined): GrFormRule[];
/** Правила поля, применимые для данного триггера (undefined = все). */
export declare function rulesForTrigger(rules: GrFormRule[], trigger?: GrFormTrigger): GrFormRule[];
/** Есть ли среди правил `required` (для маркера обязательности в поле). */
export declare function rulesRequired(rules: GrFormRule[]): boolean;
/**
* Прогоняет правила по порядку, возвращает первое сообщение об ошибке или `undefined`.
* Пустое необязательное значение проходит все проверки (кроме `required`).
*/
export declare function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;Подпись t() из useGranularityTranslations(): ключ, фолбэк, параметры.
type GrFormMessageTranslate = (key: string, fallback: string, params?: Record<string, unknown>) => string;
/**
* Дефолтный резолвер сообщений: `rule.message` сильнее всего, иначе ключ
* `gr.form.<kind>` из локали, иначе английский фолбэк.
*
* Вынесен из `GrForm.vue` и экспортируется наружу не «на всякий случай»:
* `runFieldRules` публичен, но без резолвера бесполезен — любой, кто хочет
* прогнать те же правила вне формы, обязан был написать свой.
*/
export declare function createGrFormMessageResolver(t: GrFormMessageTranslate, locale?: () => string | undefined): GrFormMessageResolver;
/** Пустое значение: `null`/`undefined`/пустая (или пробельная) строка/пустой массив. */
export declare function isEmpty(value: unknown): boolean;
/** Достаёт значение из объекта по dot-path (`address.city`). */
export declare function getByPath(obj: Record<string, unknown>, path: string): unknown;
/** Записывает значение в объект по dot-path (создаёт промежуточные объекты). */
export declare function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
/** Нормализует правило(а) поля в массив. */
export declare function toRuleArray(rules: GrFormRule | GrFormRule[] | undefined): GrFormRule[];
/** Правила поля, применимые для данного триггера (undefined = все). */
export declare function rulesForTrigger(rules: GrFormRule[], trigger?: GrFormTrigger): GrFormRule[];
/** Есть ли среди правил `required` (для маркера обязательности в поле). */
export declare function rulesRequired(rules: GrFormRule[]): boolean;
/**
* Прогоняет правила по порядку, возвращает первое сообщение об ошибке или `undefined`.
* Пустое необязательное значение проходит все проверки (кроме `required`).
*/
export declare function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;Дефолтный резолвер сообщений: rule.message сильнее всего, иначе ключ
gr.form.<kind> из локали, иначе английский фолбэк.
Вынесен из GrForm.vue и экспортируется наружу не «на всякий случай»:
runFieldRules публичен, но без резолвера бесполезен — любой, кто хочет
прогнать те же правила вне формы, обязан был написать свой.
function createGrFormMessageResolver(t: GrFormMessageTranslate, locale?: () => string | undefined): GrFormMessageResolver;
/** Пустое значение: `null`/`undefined`/пустая (или пробельная) строка/пустой массив. */
export declare function isEmpty(value: unknown): boolean;
/** Достаёт значение из объекта по dot-path (`address.city`). */
export declare function getByPath(obj: Record<string, unknown>, path: string): unknown;
/** Записывает значение в объект по dot-path (создаёт промежуточные объекты). */
export declare function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
/** Нормализует правило(а) поля в массив. */
export declare function toRuleArray(rules: GrFormRule | GrFormRule[] | undefined): GrFormRule[];
/** Правила поля, применимые для данного триггера (undefined = все). */
export declare function rulesForTrigger(rules: GrFormRule[], trigger?: GrFormTrigger): GrFormRule[];
/** Есть ли среди правил `required` (для маркера обязательности в поле). */
export declare function rulesRequired(rules: GrFormRule[]): boolean;
/**
* Прогоняет правила по порядку, возвращает первое сообщение об ошибке или `undefined`.
* Пустое необязательное значение проходит все проверки (кроме `required`).
*/
export declare function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;Пустое значение: null/undefined/пустая (или пробельная) строка/пустой массив.
function isEmpty(value: unknown): boolean;Достаёт значение из объекта по dot-path (address.city).
function getByPath(obj: Record<string, unknown>, path: string): unknown;Записывает значение в объект по dot-path (создаёт промежуточные объекты).
function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;Прогоняет правила по порядку, возвращает первое сообщение об ошибке или undefined.
Пустое необязательное значение проходит все проверки (кроме required).
function runFieldRules(value: unknown, rules: GrFormRule[], model: Record<string, unknown>, resolveMessage: GrFormMessageResolver): Promise<string | undefined>;Файлdist/types/components/GrForm/index.d.ts
type GrFormInstance = ComponentExposed<typeof GrFormComponent>;Использование из прозы библиотеки
Обязательность приходит из двух мест
Поле обязательно, если у него есть правило required или у самого
GrFormField стоит проп required. Оба источника участвуют и в маркере *, и
в валидации: раньше второй рисовал звёздочку, но submit проходил с пустым
полем.
Полю без правил в этом случае применяется неявное { required: true } — с тем
же сообщением из локали, что и у явного правила.
Снимок и сброс
resetFields() возвращает модель к снимку. Снимок берётся при монтировании,
поэтому форме редактирования нужно переснять его после загрузки данных:
const form = ref<GrFormInstance>()
const data = await api.load()
Object.assign(model, data)
form.value?.setSnapshot()Без этого «сброс» возвращал бы пустой объект, каким модель была до ответа
сервера. resetFields(names?) умеет и точечный сброс; ключ, которого в снимке
нет, удаляется, а не превращается в undefined. Исключение — методы: они
в снимок не попадают по построению, и удалять их значило бы ломать модель
(см. «Модель может быть чужой»).
isDirty сравнивает модель со снимком, isValid означает «известных ошибок
нет» — до первого validate() это ещё не «форма валидна», а «никто не
проверял». Оба доступны и через ref формы, и в слот-пропах.
Выключение формы
disabled у формы уезжает в контекст поля и дальше в контролы: «выключить всё
на время отправки» больше не требует обходить контролы по одному. Проп
контрола и проп поля складываются с ним по «или» — как это уже сделано с
readonly.
Кнопка отправки обычно стоит под полями, а не в GrFormField, поэтому
GrButton type="submit" читает disabled формы сам и выключается вместе с ней —
второй раз «выключенную» форму не отправить. Остальные кнопки (type="button",
type="reset") не трогаются: «Отмена» и «Сбросить» остаются рабочими.
Выключенная форма игнорирует и само событие submit — Enter в поле и кнопку
не из GrButton.
Без сдвигов раскладки
reserveMessage у формы держит место под строку сообщения у всех её полей:
ошибки по submit не сдвигают форму, диалог и панель фиксированной высоты. Поле
со своим reserveMessage решает за себя. Резерв — одна строка; несколько
ошибок у поля растут ниже. Подробности — GrFormField.md.
<template>
<GrForm :model="model" :rules="rules" reserve-message>
<GrFormField label="Получатель" name="name">
<GrInput v-model="model.name" />
</GrFormField>
</GrForm>
</template>Файлы правилом формы
Ограничения файла описываются там же, где остальные правила, — правилом file:
const rules: GrFormRules = {
contract: [{
required: true,
file: { accept: '.pdf,application/pdf', maxSizeMb: 1 },
}],
}| Ключ | Что ограничивает |
|---|---|
accept | W3C-строка accept ('image/*,.pdf') |
extensions | белый список расширений |
mimeTypes | белый список MIME-типов |
maxSizeMb / maxSizeBytes | размер одного файла; заданы оба — применяется меньший |
maxCount | количество файлов в наборе |
maxTotalSizeMb | суммарный размер набора |
validators | свои FileValidator, в том числе async — идут после встроенных |
Своих проверок у правила нет ни одной: оно собирает те же валидаторы из
../file-validation.md, которые GrFormFile и
v-dropzone запускают на выборе и на drop. Поэтому и текст ошибки один — он
приходит от валидатора и локализуется его ключом gr.fileValidation.*, а не
превращается в отдельное «некорректный файл». rule.message перекрывает его,
как у любого другого правила.
Проверяются значения File и File[]; всё остальное правило пропускает —
придумывать вердикт за строку оно не должно. Пустоту разбирает required.
Если проблемных файлов несколько, поле показывает первую — у поля формы одна
строка ошибки, и файлы тут не исключение. Подробный разбор по каждому файлу
остаётся за GrFormFile.
Сам GrFormFile при этом не меняется. Ограничения на поле (accept, limit,
validators) — быстрая обратная связь: плохой файл не попадает в модель вообще.
Правило формы — гарантия на submit: оно видно validate(), попадает в событие
invalid и участвует в скролле к первой ошибке. Дублирования на экране не
возникает: то, что поле не пустило в модель, правилу формы уже не показывают.
Типичное разделение — ограничения в rules, а на поле accept как фильтр
диалога выбора.
Асинхронные правила
Пока правило ходит на сервер, поле помечается aria-busy и показывает строку
из локали (gr.form.validating) вместо старой ошибки: ошибка относилась к
прежнему значению, и держать её на экране — врать про текущее.
Список полей в проверке доступен как validatingFields — в expose и в
слот-пропах.
Перекрывающиеся прогоны одного поля разруливает счётчик поколений: применяется
результат последнего запуска, а вытесненный прогон отдаёт вызвавшему вердикт
вытеснившего, дождавшись его. Поэтому validate() и submit не могут
проскочить по значению, которое ещё никто не проверил.
clearValidate() и resetFields() летящие проверки отменяют: их ответ
относится к состоянию до сброса, в очищенную форму он не попадёт, и поле
перестаёт считаться проверяемым сразу, не дожидаясь сервера.
Модель может быть чужой
model типизирован дженериком, а не словарём: моделью бывает объект чужой
библиотеки — useForm Inertia, стор, — у которого рядом с полями лежат
собственные методы. Он заходит как есть, без as unknown as, и submit отдаёт
его тем же типом.
const form = useForm({ email: '', password: '' })<GrForm :model="form" :rules="rules" @submit="form.post('/login')">Форма адресует только имена, объявленные полями (GrFormField name), поэтому
всё остальное в объекте её не касается. Два следствия, которые стоит знать
заранее:
resetFields()возвращает значения полей и не трогает методы. Снимок строится клоном, а клон отбрасывает функции — без отдельной защиты «нет в снимке» означало бы «удалить», и сброс снёс бы с объектаpost,reset,errors. Собственный сброс внешней формы (form.reset()) при этом остаётся вашим: он знает про свои служебные поля, аGrForm— нет;isDirtyсчитается по всему объекту. У внешней формы рядом с полями живут её собственные реактивные поля (processing,errors), и их изменение форма тоже увидит. Нужен признак «пользователь правил данные» — берите его у самой внешней формы.
Submit асинхронный
Правила бывают промисами, поэтому validate() всегда возвращает Promise, а
submit эмитится после её разрешения. В тестах одного nextTick() не
хватает:
await wrapper.find('form').trigger('submit')
await flushPromises() // не nextTick: правила могли уйти на сервер
expect(onSubmit).toHaveBeenCalled()События и API
submit(model)— только при валидной форме;invalid(errors)— карта сообщений, когда submit не прошёл. Без него «форма невалидна» и «ничего не произошло» выглядят одинаково;validate(name, valid, message)— результат по одному полю.
Императивно: validate(), validateField(name, trigger?), clearValidate(),
resetFields(), setSnapshot(), scrollToField(name), плюс isDirty,
isValid, validatingFields.
Тип для ref — GrFormInstance, а не InstanceType<typeof GrForm>: форма
дженерик, а такой компонент компилируется в функцию, у которой конструктора нет.
const form = ref<GrFormInstance>()
form.value?.resetFields()Правила и их сообщения — ../file-validation.md для
файлов и GrForm/validation.ts для остального; движок публичный
(runFieldRules, createGrFormMessageResolver), и GrPromptDialog
пользуется тем же.
Пропы этого примера