Формы на сорок полей: как не сойти с ума
Схема вместо ручных if-ов, ошибки бэка в поля формы, зависимые блоки и черновики. Всё, до чего я дошёл на корпоративных заявках в четыре экрана.
Форма на три поля пишется за десять минут любым способом, и спорить тут не о чем. А вот форма на сорок полей, с шагами, зависимыми блоками и валидацией на бэке — это отдельная подсистема, которую кто-то потом будет поддерживать.
У меня были заявки на четыре экрана, где правила менялись каждый спринт. Вот что осталось после всех итераций.
Правила — это данные
Ручные проверки расползаются и дублируются:
if (!form.email) errors.email = 'Обязательное поле';
else if (!/.+@.+/.test(form.email)) errors.email = 'Некорректный email';
if (form.password.length < 8) errors.password = 'Минимум 8 символов';
// и так сорок раз
Схема описывает то же самое, но её можно переиспользовать — в тестах, для проверки данных с сервера, для генерации подсказок:
export const applicationSchema = z.object({
email: z.string().min(1, 'Обязательное поле').email('Некорректный email'),
inn: z.string().regex(/^\d{10}$|^\d{12}$/, 'ИНН - 10 или 12 цифр'),
sum: z.coerce.number().positive('Сумма должна быть больше нуля'),
});
export type ApplicationForm = z.infer<typeof applicationSchema>;
Тип формы выводится из схемы, отдельный interface поддерживать не надо.
const { handleSubmit, errors, defineField, setErrors } = useForm({
validationSchema: toTypedSchema(applicationSchema),
});
Ошибки бэка — это ошибки полей
Самая частая недоделка, которую я вижу: сервер ответил «ИНН уже зарегистрирован», а пользователь получил красный тост в углу экрана. И сидит гадает, какое из сорока полей чинить.
Раз ошибка у нас нормализована (об этом в заметке про слой API), маппинг делается в одну строчку:
const onSubmit = handleSubmit(async (values) => {
try {
await applicationApi.create(values);
} catch (e) {
const err = e as AppError;
if (err.fields) setErrors(err.fields); // { inn: 'ИНН уже зарегистрирован' }
else notify.error(err.message);
}
});
И скролл к первому невалидному полю. На длинной форме без этого человек просто не найдёт, где ошибка:
document.querySelector('[data-invalid="true"]')?.scrollIntoView({ block: 'center' });
Момент валидации
Если подсвечивать ошибку с первого символа, поле краснеет раньше, чем человек успел напечатать хоть что-то осмысленное. Раздражает страшно.
Нормальное поведение:
- до первого
blurмолчим; - после
blurвалидируем; - после первой неудачной отправки - валидируем на ввод, чтобы человек видел, что ошибка ушла.
В vee-validate это validateOnBlur и validateOnModelUpdate, но идея важнее конкретной библиотеки.
Зависимые поля
«Если заявитель юрлицо - показать ИНН и КПП» это не три v-if в разных местах, а условие в схеме:
const schema = z.discriminatedUnion('type', [
z.object({ type: z.literal('person'), passport: z.string().min(1) }),
z.object({ type: z.literal('company'), inn: z.string().length(10), kpp: z.string().length(9) }),
]);
Тот же приём, что и с размеченными объединениями в TS: у физлица поля inn нет вообще - ни в типах, ни в отправляемых данных. Никаких пустых строк на всякий случай.
И обязательно чистить значения скрытого блока. Иначе на бэк уедут данные от предыдущего выбора, а разбираться в этом будут по логам через месяц.
Черновик
Длинные формы теряются: закрыл вкладку, отвалился вайфай, случайно нажал «назад». Автосохранение в стор плюс localStorage пишется за вечер, а жалоб снимает много:
watchDebounced(values, (v) => draftStore.save('application', v), { debounce: 800 });
Только не сохраняй туда пароли, коды подтверждения и данные карт. Звучит очевидно ровно до того момента, пока кто-то не сохранит форму целиком одним объектом.
Двойной клик
Две одинаковые заявки в базе - классика. Блокировка кнопки на время запроса это минимум на клиенте, но настоящая защита - идемпотентность на бэке. Разговор про неё лучше провести до начала работ, а не после инцидента.
Формы - это место, где хуже всего видно техническое качество и лучше всего видно пользовательское. Схема, нормализованные ошибки и явные зависимости превращают страшный компонент на 900 строк в набор понятных кусков.