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), }, ) }