Merge branch 'fix/record-save-localized-null' into 'main'
fix: запись не сохраняется при пустом optional x-localized поле See merge request 2-6/2-6-4/terravault/ordinis!252
This commit is contained in:
@@ -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,32 @@ 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')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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<SectionId>('identity')
|
||||
// Сообщение «почему не сохранилось» — гарантия что клик по «Сохранить»
|
||||
// никогда не остаётся без видимого ответа (даже если ошибка валидации
|
||||
// легла на nested-путь без видимого FieldError).
|
||||
const [blockedMsg, setBlockedMsg] = useState<string | null>(null)
|
||||
|
||||
const validator = useMemo(() => {
|
||||
try {
|
||||
@@ -268,62 +278,88 @@ 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 ?? {}),
|
||||
})
|
||||
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) {
|
||||
console.warn('[ordinis-diag] submit(): ЗАБЛОКИРОВАНО — validTo <= validFrom')
|
||||
setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') })
|
||||
setActiveTab('identity')
|
||||
setBlockedMsg(t('form.error.validToBeforeFrom'))
|
||||
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) {
|
||||
const tabForField = findSectionForField(firstField, buckets, sectionOrder)
|
||||
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
|
||||
}
|
||||
} 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)
|
||||
// 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)
|
||||
@@ -500,15 +536,13 @@ 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, onInvalid)}
|
||||
ref={formContainerRef}
|
||||
>
|
||||
<div className="space-y-4 min-w-0">
|
||||
{/* Гарантия обратной связи на «Сохранить» — всегда видимый Alert сверху
|
||||
* формы, если submit заблокирован валидацией. Клик не «молчит». */}
|
||||
{blockedMsg && <Alert variant="error">{blockedMsg}</Alert>}
|
||||
{showAllSections ? sectionsChipStrip : (
|
||||
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id)} />
|
||||
)}
|
||||
@@ -1001,6 +1035,62 @@ 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
|
||||
}
|
||||
|
||||
/** Уникальные имена полей верхнего уровня из 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 = (
|
||||
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),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user