From dcb5612c949dde3c8eaf122673833371b9249a6d Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Thu, 21 May 2026 15:34:18 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(admin-ui):=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D1=8C=20=D0=BD=D0=B5=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D0=BB=D0=B0=D1=81=D1=8C=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=BC=20optional=20x-localized?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Симптом: «Сохранить» (create и edit) ничего не делал, ошибок не видно. Причина: x-localized поле рендерит Controller на каждую локаль (data..). RHF при сабмите реконструирует values из имён зарегистрированных полей и материализует родительский объект даже когда исходное значение было null/отсутствовало → optional поле (например mission) превращалось в { "ru-RU": undefined, "en-US": undefined }. AJV отвергал undefined-локали ("must be string"), submit() делал return до onSubmit — запрос не уходил. Ошибка ложилась через setError на data.., а FieldError читает data. → невидима. Фикс: pruneEmpty() в submit() рекурсивно убирает undefined и опустевшие объекты из data перед AJV-валидацией и отправкой — нетронутое optional-поле снова валидируется как «отсутствует». Не трогает null/''/0/false. Откат временной диагностики [ordinis-diag] из MR !251. Regression-тест падает без фикса, проходит с фиксом. --- .../components/form/SchemaDrivenForm.test.tsx | 44 ++++++++++ .../src/components/form/SchemaDrivenForm.tsx | 85 +++++++++++-------- .../src/routes/dictionaries.$name.tsx | 22 +---- 3 files changed, 95 insertions(+), 56 deletions(-) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx index 6c74298..478177f 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx @@ -32,6 +32,23 @@ const minimalSchema: JsonSchema = { }, } as JsonSchema +// Схема с optional x-localized полем — для regression на «mission=null». +const localizedSchema: JsonSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + additionalProperties: false, + required: ['code'], + 'x-id-source': 'code', + properties: { + code: { type: 'string', minLength: 1 }, + mission: { + type: 'object', + 'x-localized': true, + patternProperties: { '^[a-z]{2}-[A-Z]{2}$': { type: 'string', minLength: 1 } }, + }, + }, +} as JsonSchema + describe('SchemaDrivenForm', () => { it('рендерит идентификационный таб с required-полями', () => { renderWithI18n( @@ -254,4 +271,31 @@ describe('SchemaDrivenForm', () => { await user.click(save()) expect(onSubmit).toHaveBeenCalledTimes(1) }) + + // Regression: optional x-localized поле с null-значением (mission=null). + // RHF при сабмите материализовал mission из null в + // { "ru-RU": undefined, "en-US": undefined } (рендерятся Controller'ы на + // каждую локаль). AJV отвергал undefined-локали ("must be string"), + // submit() молча обрывался — запись не сохранялась, ошибка невидима. + // pruneEmpty() в submit() убирает undefined → optional-поле отсутствует. + it('сохраняет запись с пустым optional x-localized полем (mission=null)', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + renderWithI18n( + {}} + />, + ) + await user.click(screen.getByRole('button', { name: /^Сохранить$/i })) + expect(onSubmit).toHaveBeenCalledTimes(1) + // 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 9a8fb59..faff047 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -268,32 +268,27 @@ export const SchemaDrivenForm = ({ }, [visibleFieldCount, totalFieldCount]) const submit: SubmitHandler = (values) => { - // [ordinis-diag] DIAGNOSTIC BUILD — временная инструментация для поиска - // причины «Save ничего не делает». Удалить после диагностики. - console.warn('[ordinis-diag] submit(): RHF-валидация пройдена, handler запущен', { - mode, - businessKey: values.businessKey, - dataKeys: Object.keys(values.data ?? {}), - }) 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) { - console.warn('[ordinis-diag] submit(): ЗАБЛОКИРОВАНО — validTo <= validFrom') setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') }) setActiveTab('identity') return } } + // Нормализуем data перед валидацией и отправкой. RHF материализует + // nested-пути зарегистрированных Controller'ов: для x-localized поля + // с Controller'ами data.. исходное null / отсутствие + // значения превращается в { "ru-RU": undefined, "en-US": undefined }. + // Такой объект из undefined'ов проваливает AJV ("must be string"), + // хотя optional-поле должно считаться отсутствующим — submit молча + // обрывался, ошибка ложилась на data.. и не отображалась. + // pruneEmpty убирает undefined и опустевшие объекты. + const cleanData = pruneEmpty(values.data) as Record if (validator) { - const ok = validator(values.data) - console.warn('[ordinis-diag] submit(): AJV-валидация →', { - ok, - errorCount: validator.errors?.length ?? 0, - errors: validator.errors, - }) + const ok = validator(cleanData) if (!ok && validator.errors && validator.errors.length > 0) { - console.warn('[ordinis-diag] submit(): ЗАБЛОКИРОВАНО — AJV errors, onSubmit НЕ вызван') applyAjvErrors(validator.errors, setError) const firstField = firstFieldFromErrors(validator.errors) if (firstField) { @@ -302,30 +297,18 @@ export const SchemaDrivenForm = ({ } return } - } else { - console.warn('[ordinis-diag] submit(): validator == null (схема не скомпилировалась)') } const derivedKey = idSource && mode === 'create' - ? String(values.data?.[idSource] ?? '').trim() + ? String(cleanData?.[idSource] ?? '').trim() : values.businessKey.trim() - console.warn('[ordinis-diag] submit(): вызываю onSubmit() → мутация', { - businessKey: derivedKey, - }) onSubmit({ businessKey: derivedKey, - data: values.data, + data: cleanData, validFrom: values.validFrom || undefined, validTo: values.validTo || undefined, }) } - // [ordinis-diag] RHF onInvalid — вызывается когда handleSubmit РЕШИЛ что - // форма невалидна и НЕ вызвал submit(). Показывает что именно RHF считает - // ошибкой (включая застрявшие manual-ошибки). Удалить после диагностики. - const diagOnInvalid = (errs: unknown) => { - console.warn('[ordinis-diag] handleSubmit ЗАБЛОКИРОВАН (onInvalid) — formState.errors:', errs) - } - const tabErrorCount = countErrorsPerSection(formState.errors, buckets, sectionOrder) const visibleTabs: TabItem[] = sectionOrder .filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0) @@ -500,12 +483,7 @@ export const SchemaDrivenForm = ({ return (
{ - // [ordinis-diag] DIAGNOSTIC — событие submit формы вообще дошло? - // Удалить после диагностики. - console.warn('[ordinis-diag] submit event сработал (formId=' + String(formId) + ')') - void handleSubmit(submit, diagOnInvalid)(e) - }} + onSubmit={handleSubmit(submit)} ref={formContainerRef} >
@@ -1001,6 +979,43 @@ const JsonField = ({ label, required, hint, error, value, onChange }: JsonFieldP ) } +/** + * Рекурсивно убирает `undefined`-значения и опустевшие объекты. + * + *

RHF при сабмите реконструирует values из имён зарегистрированных + * полей. Для x-localized поля рендерятся Controller'ы + * {@code data..} — RHF материализует родительский объект + * даже когда исходное значение было {@code null}/отсутствовало, давая + * {@code { "ru-RU": undefined, "en-US": undefined }}. AJV отвергает + * undefined-локали ("must be string"), хотя optional-поле должно + * считаться отсутствующим. Прунинг возвращает payload к «поле отсутствует». + * + *

Не трогает `null`, `''`, `0`, `false` — это значимые значения. + */ +const pruneEmpty = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(pruneEmpty) + } + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value)) { + const pv = pruneEmpty(v) + if (pv === undefined) continue + if ( + pv !== null && + typeof pv === 'object' && + !Array.isArray(pv) && + Object.keys(pv).length === 0 + ) { + continue + } + out[k] = pv + } + return out + } + return value +} + const applyAjvErrors = ( errors: ErrorObject[], setError: ReturnType>['setError'], diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index baa3e5f..6fc57e1 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -578,12 +578,6 @@ function DictionaryDetail() { const rawRecordQuery = useRecordRaw(name, editingKey) const handleSubmit = (req: CreateRecordRequest) => { - // [ordinis-diag] DIAGNOSTIC BUILD — удалить после диагностики. - console.warn('[ordinis-diag] route.handleSubmit(): получен req от формы', { - editKind: edit.kind, - approvalRequired, - businessKey: req.businessKey, - }) // Fill validFrom at submit (review #5): defaultValues в RHF берётся // ТОЛЬКО при mount. Если user открыл modal в 12:00 и submit в 12:05 — // `validFrom` в req это значение из 12:00. Backend check `validFrom > @@ -637,20 +631,14 @@ function DictionaryDetail() { ) return } - console.warn('[ordinis-diag] route: вызываю createMut.mutate() — POST /records') createMut.mutate( { payload: submitReq, idempotencyKey: recordIdempotencyKey }, { onSuccess: () => { - console.warn('[ordinis-diag] createMut onSuccess — запись создана') setEdit({ kind: 'closed' }) resetRecordIdempotencyKey() }, onError: (err) => { - console.warn('[ordinis-diag] createMut onError', { - status: (err as { response?: { status?: number } })?.response?.status, - data: (err as { response?: { data?: unknown } })?.response?.data, - }) // Create rarely conflicts (no prior record to lock), но если backend // вернёт 409 по уникальному ключу — businessKey возьмём из payload. handleSaveError(err, submitReq.businessKey ?? '') @@ -681,7 +669,6 @@ function DictionaryDetail() { ) return } - console.warn('[ordinis-diag] route: вызываю updateMut.mutate() — PUT /records/' + edit.record.businessKey) updateMut.mutate( { businessKey: edit.record.businessKey, @@ -690,17 +677,10 @@ function DictionaryDetail() { }, { onSuccess: () => { - console.warn('[ordinis-diag] updateMut onSuccess — запись обновлена') setEdit({ kind: 'closed' }) resetRecordIdempotencyKey() }, - onError: (err) => { - console.warn('[ordinis-diag] updateMut onError', { - status: (err as { response?: { status?: number } })?.response?.status, - data: (err as { response?: { data?: unknown } })?.response?.data, - }) - handleSaveError(err, edit.record.businessKey) - }, + onError: (err) => handleSaveError(err, edit.record.businessKey), }, ) } From b48a5dd94c6752a14505f1b8668747ee5911d5a2 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Thu, 21 May 2026 15:52:06 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(admin-ui):=20=C2=AB=D0=A1=D0=BE=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=C2=BB=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D0=B3=D0=B4=D0=B0=20=D0=B4=D0=B0=D1=91=D1=82=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=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'],