fix(admin-ui): «Сохранить» всегда даёт видимый ответ

Принцип: клик по «Сохранить» не должен оставаться без реакции.

Раньше submit мог молча оборваться: AJV-ошибка ложилась через setError
на nested-путь (data.<field>.<locale>), а FieldError читает data.<field>
→ ошибка невидима, форма «не реагирует».

Добавлено:
- blockedMsg — состояние с причиной, рендерится видимым Alert сверху
  формы при любой блокировке submit (validFrom/validTo, AJV).
- onInvalid — обработчик RHF handleSubmit на случай когда RHF сам
  блокирует submit (required-поля); тоже ставит blockedMsg.
- scrollIntoView к началу формы — Alert гарантированно в зоне видимости.

Теперь Save либо сохраняет, либо показывает «Не удалось сохранить —
проверьте поля: …». Молчания не будет.
This commit is contained in:
Zimin A.N.
2026-05-21 15:52:06 +03:00
parent dcb5612c94
commit b48a5dd94c
2 changed files with 78 additions and 2 deletions
@@ -298,4 +298,5 @@ describe('SchemaDrivenForm', () => {
// mission не должна уйти в payload как объект из undefined'ов.
expect(onSubmit.mock.calls[0][0].data).not.toHaveProperty('mission')
})
})
@@ -1,5 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
import {
useForm,
Controller,
type Path,
type SubmitHandler,
type SubmitErrorHandler,
} from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import Ajv, { type ErrorObject } from 'ajv'
import addFormats from 'ajv-formats'
@@ -171,6 +177,10 @@ export const SchemaDrivenForm = ({
}: Props) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<SectionId>('identity')
// Сообщение «почему не сохранилось» — гарантия что клик по «Сохранить»
// никогда не остаётся без видимого ответа (даже если ошибка валидации
// легла на nested-путь без видимого FieldError).
const [blockedMsg, setBlockedMsg] = useState<string | null>(null)
const validator = useMemo(() => {
try {
@@ -268,12 +278,14 @@ export const SchemaDrivenForm = ({
}, [visibleFieldCount, totalFieldCount])
const submit: SubmitHandler<FormValues> = (values) => {
setBlockedMsg(null)
if (values.validFrom && values.validTo) {
const from = new Date(values.validFrom)
const to = new Date(values.validTo)
if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from >= to) {
setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') })
setActiveTab('identity')
setBlockedMsg(t('form.error.validToBeforeFrom'))
return
}
}
@@ -295,6 +307,22 @@ export const SchemaDrivenForm = ({
const tabForField = findSectionForField(firstField, buckets, sectionOrder)
if (tabForField) setActiveTab(tabForField)
}
// Гарантия обратной связи: ошибка AJV могла лечь на nested-путь
// (data.<field>.<locale>) который не рендерит видимый FieldError.
// Всегда показываем сводку сверху формы + скроллим к ней — клик по
// «Сохранить» никогда не остаётся «без ответа».
const blockedFields = summarizeBlockedFields(validator.errors)
setBlockedMsg(
blockedFields.length > 0
? t('form.error.blockedFields', {
fields: blockedFields.join(', '),
defaultValue: `Не удалось сохранить — проверьте поля: ${blockedFields.join(', ')}`,
})
: t('form.error.blockedGeneric', {
defaultValue: 'Не удалось сохранить — данные не прошли проверку',
}),
)
formContainerRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' })
return
}
}
@@ -309,6 +337,31 @@ export const SchemaDrivenForm = ({
})
}
// RHF заблокировал handleSubmit (required-поля и т.п.) и НЕ вызвал submit().
// Без этого обработчика клик по «Сохранить» оставался без ответа, если
// ошибка легла на поле вне видимой области. Показываем сводку сверху формы.
const onInvalid: SubmitErrorHandler<FormValues> = (errors) => {
const fields: string[] = []
for (const [k, v] of Object.entries(errors)) {
if (k === 'data' && v && typeof v === 'object') {
fields.push(...Object.keys(v))
} else {
fields.push(k)
}
}
setBlockedMsg(
fields.length > 0
? t('form.error.blockedFields', {
fields: fields.join(', '),
defaultValue: `Не удалось сохранить — проверьте поля: ${fields.join(', ')}`,
})
: t('form.error.blockedGeneric', {
defaultValue: 'Не удалось сохранить — данные не прошли проверку',
}),
)
formContainerRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' })
}
const tabErrorCount = countErrorsPerSection(formState.errors, buckets, sectionOrder)
const visibleTabs: TabItem[] = sectionOrder
.filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0)
@@ -483,10 +536,13 @@ export const SchemaDrivenForm = ({
return (
<form
id={formId}
onSubmit={handleSubmit(submit)}
onSubmit={handleSubmit(submit, onInvalid)}
ref={formContainerRef}
>
<div className="space-y-4 min-w-0">
{/* Гарантия обратной связи на «Сохранить» — всегда видимый Alert сверху
* формы, если submit заблокирован валидацией. Клик не «молчит». */}
{blockedMsg && <Alert variant="error">{blockedMsg}</Alert>}
{showAllSections ? sectionsChipStrip : (
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id)} />
)}
@@ -1016,6 +1072,25 @@ const pruneEmpty = (value: unknown): unknown => {
return value
}
/** Уникальные имена полей верхнего уровня из AJV-ошибок — для сводки юзеру. */
const summarizeBlockedFields = (errors: ErrorObject[]): string[] => {
const fields = new Set<string>()
for (const err of errors) {
let top = err.instancePath.replace(/^\//, '').split('/')[0]
if (
!top &&
err.keyword === 'required' &&
err.params &&
typeof err.params === 'object' &&
'missingProperty' in err.params
) {
top = String((err.params as { missingProperty: string }).missingProperty)
}
if (top) fields.add(top)
}
return [...fields]
}
const applyAjvErrors = (
errors: ErrorObject[],
setError: ReturnType<typeof useForm<FormValues>>['setError'],