Формы и валидация
Формы в MineFlow строятся из трёх кусков: react-hook-form (состояние полей), zodResolver со схемой z<Dto> из @mineflow/api-zod (instant-валидация на клиенте) и мутация-хук из @mineflow/client-react (отправка). Этот рецепт показывает связку end-to-end и две темы, которые ломают новичков: что валидация на клиенте принципиально неполная (финальное слово — за сервером, прилетает 422) и как докрутить недостающие checksum-правила через .refine() из @mineflow/shared-validation.
Клиентская валидация — это UX (мгновенная подсветка полей), а не граница доверия. Источник истины — сервер. Любая форма должна корректно обрабатывать 422 от бэка, даже если на клиенте всё «зелёное».
Базовая связка: resolver + хук
z<Dto> соответствует ровно тому DTO, что принимает эндпоинт (zCreateAssetDto → CreateAssetDto), поэтому валидные по схеме values можно отдавать в mutate без преобразований.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateAssetDto } from '@mineflow/api-zod';
import { useRegisterAsset } from '@mineflow/client-react';
function RegisterAssetForm() {
const form = useForm({
resolver: zodResolver(zCreateAssetDto),
mode: 'onBlur', // instant-проверка по уходу с поля
});
const register = useRegisterAsset(); // авто Idempotency-Key + инвалидация списка
const onSubmit = form.handleSubmit((values) => register.mutate(values));
return (
<form onSubmit={onSubmit}>
<input {...form.register('inventoryNumber')} placeholder="BG-001" />
{form.formState.errors.inventoryNumber && (
<span>{form.formState.errors.inventoryNumber.message}</span>
)}
<input {...form.register('name')} />
{form.formState.errors.name && <span>{form.formState.errors.name.message}</span>}
<button type="submit" disabled={register.isPending}>
Зарегистрировать
</button>
</form>
);
}
Имена схем — по правилу z + имя DTO; полный список и таблица соответствий DTO → схема — в гайде api-zod. Идемпотентность и инвалидация кэша на мутации — забота useRegisterAsset (см. client-react), руками Idempotency-Key не передаём.
Поля-ссылки (assetClassId, currentObjectId, positionId) — это UUID. Не давай вводить их руками: подгружай варианты через refs-хуки (useAssetClasses, useProductionObjects, usePositions) и клади выбранный id в форму через form.setValue(...). См. client-react § Справочники.
Что ловит клиент, а что — только сервер
z<Dto> генерируются из OpenAPI-спеки (тот же источник, что у @mineflow/api-client — поэтому не дрейфуют от бэка). Из OpenAPI выражается только структурная валидация:
- типы полей,
required; min/max,minLength/maxLength;regex(формат инвентарного номера, телефона);enum, формат (uuid,date).
Кастомные .refine() бэка в OpenAPI не выражаются и в z<Dto> не попадают. Это осознанный trade-off codegen-подхода (ADR-0042). Сервером — и только сервером — проверяются:
- cross-field правила (например
consumed ≤ issued); - checksum ИИН/БИН, mod-97 для IBAN;
- темпоральные инварианты («
commissionedAtне в будущем»); - доступность ресурса и object-scope.
Что из этого следует: клиентская валидация всегда неполна. Форма, прошедшая zodResolver, всё равно может получить 422.
Окончательная валидация: 422 → поля формы
Бэк отдаёт ошибки Zod-валидации как MineflowApiError со status === 422 и деталями в detail.errors (формат RFC 7807, см. рецепт Обработка ошибок). Каждый элемент — { path, message }, где path — массив сегментов пути к полю. Маппим их на react-hook-form через setError:
import { MineflowApiError } from '@mineflow/client-core';
import type { UseFormSetError, FieldValues, Path } from 'react-hook-form';
interface FieldError {
path: (string | number)[];
message: string;
}
/** Достаёт ошибки полей из 422; для остального возвращает []. */
function fieldErrors(e: unknown): FieldError[] {
if (
e instanceof MineflowApiError &&
e.status === 422 &&
e.detail &&
typeof e.detail === 'object' &&
Array.isArray((e.detail as { errors?: unknown }).errors)
) {
return (e.detail as { errors: FieldError[] }).errors;
}
return [];
}
/** Раскладывает серверные 422-ошибки по полям react-hook-form. */
function applyServerErrors<T extends FieldValues>(e: unknown, setError: UseFormSetError<T>) {
const errs = fieldErrors(e);
for (const { path, message } of errs) {
// path: ['body','inventoryNumber'] → 'inventoryNumber'; пропускаем префикс 'body'.
const field = path.filter((seg) => seg !== 'body').join('.');
if (field) setError(field as Path<T>, { type: 'server', message });
}
return errs.length > 0;
}
Подключаем в onError мутации:
const onSubmit = form.handleSubmit((values) =>
register.mutate(values, {
onError: (e) => {
const mapped = applyServerErrors(e, form.setError);
if (!mapped) {
// не 422 (409 FSM, 403 RBAC, 500…) — гейти по e.code, а не по тексту.
// Полный разбор — рецепт «Обработка ошибок».
toast('Не удалось сохранить');
}
},
}),
);
detail слепоdetail типизирован как unknown — его форма зависит от статуса и домена. Структуру { errors: [...] } бэк гарантирует только для 422. Всегда проверяй форму перед чтением (как в fieldErrors) и держи ветку «это не 422» — там может быть 409 (FSM-конфликт), 403 (RBAC) или 500. Разбор по error.code — в рецепте Обработка ошибок.
Закрыть разрыв: checksum-правила через .refine()
Чтобы не гонять пользователя на сервер за заведомо битым ИИН/БИН/IBAN, добавь те же checksum-проверки на клиент. Хелперы — в @mineflow/shared-validation (browser/RN-safe, без зависимостей). Это те же функции, что использует бэк, так что клиентская проверка совпадёт с серверной.
Доступные хелперы (полный справочник — /api/shared-validation/):
import { validIinChecksum, validBinChecksum, validIbanMod97 } from '@mineflow/shared-validation';
validIinChecksum('123456789012'); // ИИН: 12 цифр + контрольная сумма НК РК
validBinChecksum('123456789012'); // БИН: 12 цифр + контрольная сумма НК РК
validIbanMod97('KZ86125KZT5004100100'); // KZ IBAN: 20 символов, ISO 13616 mod-97
Каждая принимает string и возвращает boolean (на нестроке / неверной длине / неверном формате — false, без исключений).
Навешиваем их на сгенерированную схему через .refine() (а cross-field — через .superRefine()), не трогая исходную z<Dto>:
import { z } from 'zod';
import { zCreatePersonnelDto } from '@mineflow/api-zod';
import { validIinChecksum } from '@mineflow/shared-validation';
// Расширяем сгенерированную схему клиентским правилом checksum.
const createPersonnelForm = zCreatePersonnelDto.refine(
(v) => validIinChecksum(v.iin),
{ path: ['iin'], message: 'Неверная контрольная сумма ИИН' },
);
const form = useForm({ resolver: zodResolver(createPersonnelForm) });
Cross-field пример (consumed ≤ issued) — через superRefine, чтобы повесить ошибку на нужное поле:
import { z } from 'zod';
import { zCreateFuelEntryDto } from '@mineflow/api-zod';
const fuelEntryForm = zCreateFuelEntryDto.superRefine((v, ctx) => {
if (v.consumed > v.issued) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['consumed'],
message: 'Расход не может превышать выдачу',
});
}
});
iin, consumed, issued выше — иллюстрация шаблона. Перед написанием .refine() сверь реальные имена полей конкретного DTO: смотри REST-референс или автокомплит IDE по z<Dto>.shape. Не выдумывай поля — path должен указывать на существующее.
.refine() дублирует, а не заменяет серверную проверкуКлиентский .refine() — это ускорение UX, не гарантия. Сервер всё равно прогонит свои правила и может вернуть 422 (например, если БД отвергнет дубль ИИН — это не выразимо .refine() без запроса). Оставляй обработчик 422 из предыдущего раздела включённым всегда.
React Native
Связка react-hook-form + zodResolver + z<Dto> platform-agnostic и работает в RN без изменений — @mineflow/api-zod и @mineflow/shared-validation чисто браузерные/RN-safe (только zod, без серверных зависимостей). Меняется лишь UI-слой: вместо <input {...form.register(...)}> — <Controller> поверх <TextInput>. Платформенные нюансы провайдера — рецепт React Native.
Связанные материалы
- Обработка ошибок — разбор
MineflowApiError, гейт UI поerror.code, нормализация сырых ответов. - Гайд api-zod — именование схем, регенерация, ограничения codegen.
- Гайд shared-validation — checksum-хелперы РК.
- Гайд client-react § Мутации — write-хуки, идемпотентность, инвалидация.
- RBAC-гейт форм — показывать ли форму/кнопку по ролям (
useCan). - REST-референс — точные поля каждого DTO.