fix(admin-ui): запись не сохранялась при пустом optional x-localized поле

Симптом: «Сохранить» (create и edit) ничего не делал, ошибок не видно.

Причина: x-localized поле рендерит Controller на каждую локаль
(data.<field>.<locale>). RHF при сабмите реконструирует values из имён
зарегистрированных полей и материализует родительский объект даже когда
исходное значение было null/отсутствовало → optional поле (например
mission) превращалось в { "ru-RU": undefined, "en-US": undefined }.

AJV отвергал undefined-локали ("must be string"), submit() делал return
до onSubmit — запрос не уходил. Ошибка ложилась через setError на
data.<field>.<locale>, а FieldError читает data.<field> → невидима.

Фикс: pruneEmpty() в submit() рекурсивно убирает undefined и опустевшие
объекты из data перед AJV-валидацией и отправкой — нетронутое
optional-поле снова валидируется как «отсутствует». Не трогает
null/''/0/false.

Откат временной диагностики [ordinis-diag] из MR !251.
Regression-тест падает без фикса, проходит с фиксом.
This commit is contained in:
Zimin A.N.
2026-05-21 15:34:18 +03:00
parent ffffacb538
commit dcb5612c94
3 changed files with 95 additions and 56 deletions
@@ -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(
<SchemaDrivenForm
schema={localizedSchema}
supportedLocales={['ru-RU', 'en-US']}
defaultLocale="ru-RU"
mode="edit"
isPending={false}
defaultValues={{ businessKey: 'X1', data: { code: 'X1', mission: null } }}
onSubmit={onSubmit}
onCancel={() => {}}
/>,
)
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')
})
})
@@ -268,32 +268,27 @@ export const SchemaDrivenForm = ({
}, [visibleFieldCount, totalFieldCount])
const submit: SubmitHandler<FormValues> = (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.<field>.<locale> исходное null / отсутствие
// значения превращается в { "ru-RU": undefined, "en-US": undefined }.
// Такой объект из undefined'ов проваливает AJV ("must be string"),
// хотя optional-поле должно считаться отсутствующим — submit молча
// обрывался, ошибка ложилась на data.<field>.<locale> и не отображалась.
// pruneEmpty убирает undefined и опустевшие объекты.
const cleanData = pruneEmpty(values.data) as Record<string, unknown>
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 (
<form
id={formId}
onSubmit={(e) => {
// [ordinis-diag] DIAGNOSTIC — событие submit формы вообще дошло?
// Удалить после диагностики.
console.warn('[ordinis-diag] <form> submit event сработал (formId=' + String(formId) + ')')
void handleSubmit(submit, diagOnInvalid)(e)
}}
onSubmit={handleSubmit(submit)}
ref={formContainerRef}
>
<div className="space-y-4 min-w-0">
@@ -1001,6 +979,43 @@ const JsonField = ({ label, required, hint, error, value, onChange }: JsonFieldP
)
}
/**
* Рекурсивно убирает `undefined`-значения и опустевшие объекты.
*
* <p>RHF при сабмите реконструирует values из имён зарегистрированных
* полей. Для x-localized поля рендерятся Controller'ы
* {@code data.<field>.<locale>} — RHF материализует родительский объект
* даже когда исходное значение было {@code null}/отсутствовало, давая
* {@code { "ru-RU": undefined, "en-US": undefined }}. AJV отвергает
* undefined-локали ("must be string"), хотя optional-поле должно
* считаться отсутствующим. Прунинг возвращает payload к «поле отсутствует».
*
* <p>Не трогает `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<string, unknown> = {}
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<typeof useForm<FormValues>>['setError'],
@@ -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),
},
)
}