From b48a5dd94c6752a14505f1b8668747ee5911d5a2 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Thu, 21 May 2026 15:52:06 +0300 Subject: [PATCH] =?UTF-8?q?fix(admin-ui):=20=C2=AB=D0=A1=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=C2=BB=20=D0=B2=D1=81=D0=B5=D0=B3?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B4=D0=B0=D1=91=D1=82=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=B8=D0=BC=D1=8B=D0=B9=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Принцип: клик по «Сохранить» не должен оставаться без реакции. Раньше submit мог молча оборваться: AJV-ошибка ложилась через setError на nested-путь (data..), а FieldError читает data. → ошибка невидима, форма «не реагирует». Добавлено: - blockedMsg — состояние с причиной, рендерится видимым Alert сверху формы при любой блокировке submit (validFrom/validTo, AJV). - onInvalid — обработчик RHF handleSubmit на случай когда RHF сам блокирует submit (required-поля); тоже ставит blockedMsg. - scrollIntoView к началу формы — Alert гарантированно в зоне видимости. Теперь Save либо сохраняет, либо показывает «Не удалось сохранить — проверьте поля: …». Молчания не будет. --- .../components/form/SchemaDrivenForm.test.tsx | 1 + .../src/components/form/SchemaDrivenForm.tsx | 79 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx index 478177f..fa9c41b 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx @@ -298,4 +298,5 @@ describe('SchemaDrivenForm', () => { // mission не должна уйти в payload как объект из undefined'ов. expect(onSubmit.mock.calls[0][0].data).not.toHaveProperty('mission') }) + }) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index faff047..881b6f3 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -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('identity') + // Сообщение «почему не сохранилось» — гарантия что клик по «Сохранить» + // никогда не остаётся без видимого ответа (даже если ошибка валидации + // легла на nested-путь без видимого FieldError). + const [blockedMsg, setBlockedMsg] = useState(null) const validator = useMemo(() => { try { @@ -268,12 +278,14 @@ export const SchemaDrivenForm = ({ }, [visibleFieldCount, totalFieldCount]) const submit: SubmitHandler = (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..) который не рендерит видимый 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 = (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 (
+ {/* Гарантия обратной связи на «Сохранить» — всегда видимый Alert сверху + * формы, если submit заблокирован валидацией. Клик не «молчит». */} + {blockedMsg && {blockedMsg}} {showAllSections ? sectionsChipStrip : ( setActiveTab(id)} /> )} @@ -1016,6 +1072,25 @@ const pruneEmpty = (value: unknown): unknown => { return value } +/** Уникальные имена полей верхнего уровня из AJV-ошибок — для сводки юзеру. */ +const summarizeBlockedFields = (errors: ErrorObject[]): string[] => { + const fields = new Set() + 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>['setError'],