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:
@@ -298,4 +298,5 @@ describe('SchemaDrivenForm', () => {
|
|||||||
// mission не должна уйти в payload как объект из undefined'ов.
|
// mission не должна уйти в payload как объект из undefined'ов.
|
||||||
expect(onSubmit.mock.calls[0][0].data).not.toHaveProperty('mission')
|
expect(onSubmit.mock.calls[0][0].data).not.toHaveProperty('mission')
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import Ajv, { type ErrorObject } from 'ajv'
|
import Ajv, { type ErrorObject } from 'ajv'
|
||||||
import addFormats from 'ajv-formats'
|
import addFormats from 'ajv-formats'
|
||||||
@@ -171,6 +177,10 @@ export const SchemaDrivenForm = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState<SectionId>('identity')
|
const [activeTab, setActiveTab] = useState<SectionId>('identity')
|
||||||
|
// Сообщение «почему не сохранилось» — гарантия что клик по «Сохранить»
|
||||||
|
// никогда не остаётся без видимого ответа (даже если ошибка валидации
|
||||||
|
// легла на nested-путь без видимого FieldError).
|
||||||
|
const [blockedMsg, setBlockedMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
const validator = useMemo(() => {
|
const validator = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -268,12 +278,14 @@ export const SchemaDrivenForm = ({
|
|||||||
}, [visibleFieldCount, totalFieldCount])
|
}, [visibleFieldCount, totalFieldCount])
|
||||||
|
|
||||||
const submit: SubmitHandler<FormValues> = (values) => {
|
const submit: SubmitHandler<FormValues> = (values) => {
|
||||||
|
setBlockedMsg(null)
|
||||||
if (values.validFrom && values.validTo) {
|
if (values.validFrom && values.validTo) {
|
||||||
const from = new Date(values.validFrom)
|
const from = new Date(values.validFrom)
|
||||||
const to = new Date(values.validTo)
|
const to = new Date(values.validTo)
|
||||||
if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from >= to) {
|
if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from >= to) {
|
||||||
setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') })
|
setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') })
|
||||||
setActiveTab('identity')
|
setActiveTab('identity')
|
||||||
|
setBlockedMsg(t('form.error.validToBeforeFrom'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +307,22 @@ export const SchemaDrivenForm = ({
|
|||||||
const tabForField = findSectionForField(firstField, buckets, sectionOrder)
|
const tabForField = findSectionForField(firstField, buckets, sectionOrder)
|
||||||
if (tabForField) setActiveTab(tabForField)
|
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
|
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 tabErrorCount = countErrorsPerSection(formState.errors, buckets, sectionOrder)
|
||||||
const visibleTabs: TabItem[] = sectionOrder
|
const visibleTabs: TabItem[] = sectionOrder
|
||||||
.filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0)
|
.filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0)
|
||||||
@@ -483,10 +536,13 @@ export const SchemaDrivenForm = ({
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
id={formId}
|
id={formId}
|
||||||
onSubmit={handleSubmit(submit)}
|
onSubmit={handleSubmit(submit, onInvalid)}
|
||||||
ref={formContainerRef}
|
ref={formContainerRef}
|
||||||
>
|
>
|
||||||
<div className="space-y-4 min-w-0">
|
<div className="space-y-4 min-w-0">
|
||||||
|
{/* Гарантия обратной связи на «Сохранить» — всегда видимый Alert сверху
|
||||||
|
* формы, если submit заблокирован валидацией. Клик не «молчит». */}
|
||||||
|
{blockedMsg && <Alert variant="error">{blockedMsg}</Alert>}
|
||||||
{showAllSections ? sectionsChipStrip : (
|
{showAllSections ? sectionsChipStrip : (
|
||||||
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id)} />
|
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id)} />
|
||||||
)}
|
)}
|
||||||
@@ -1016,6 +1072,25 @@ const pruneEmpty = (value: unknown): unknown => {
|
|||||||
return value
|
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 = (
|
const applyAjvErrors = (
|
||||||
errors: ErrorObject[],
|
errors: ErrorObject[],
|
||||||
setError: ReturnType<typeof useForm<FormValues>>['setError'],
|
setError: ReturnType<typeof useForm<FormValues>>['setError'],
|
||||||
|
|||||||
Reference in New Issue
Block a user