fix(dict): name column в records list — pick localized value, не [object Object]
Раньше для записей с x-localized name (КА, антенна, ground_station,
operator) колонка 'name / code' показывала literal '[object Object]'
потому что String({'ru-RU': 'Канопус', 'en-US': 'Kanopus'}) =
toString() стандартный → '[object Object]'.
Решение:
- Новый shared util lib/locales.ts:
- pickLocalized(value, defaultLocale) — извлекает строку из i18n
объекта или plain string. Fallback на first non-empty key
если defaultLocale не найден.
- recordDisplayName(data, defaultLocale) — name → code → '—' chain.
- Records list использует recordDisplayName с dict.defaultLocale
(берётся из DictionaryDetail).
- SchemaDrivenForm дедуплицирован — был свой локальный pickLocalized,
теперь импортирует из util'ы.
Tests: +13 (76 → 89), unit tests на pickLocalized + recordDisplayName
покрывают все edge cases (plain string, localized object, fallback
chain, null/undefined input).
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
formatIsoDate,
|
formatIsoDate,
|
||||||
parseFormDate,
|
parseFormDate,
|
||||||
} from '@/lib/dates'
|
} from '@/lib/dates'
|
||||||
|
import { pickLocalized } from '@/lib/locales'
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
businessKey: string
|
businessKey: string
|
||||||
@@ -823,22 +824,6 @@ const buildOption = (
|
|||||||
return { id: idStr, label }
|
return { id: idStr, label }
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickLocalized = (
|
|
||||||
value: unknown,
|
|
||||||
defaultLocale: string,
|
|
||||||
): string | undefined => {
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
if (!value || typeof value !== 'object') return undefined
|
|
||||||
const map = value as Record<string, unknown>
|
|
||||||
const direct = map[defaultLocale]
|
|
||||||
if (typeof direct === 'string' && direct.length > 0) return direct
|
|
||||||
for (const key of Object.keys(map)) {
|
|
||||||
const v = map[key]
|
|
||||||
if (typeof v === 'string' && v.length > 0) return v
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const countErrorsPerTab = (
|
const countErrorsPerTab = (
|
||||||
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
|
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
|
||||||
buckets: Record<TabId, string[]>,
|
buckets: Record<TabId, string[]>,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { pickLocalized, recordDisplayName } from './locales'
|
||||||
|
|
||||||
|
describe('pickLocalized', () => {
|
||||||
|
test('plain string возвращается as-is', () => {
|
||||||
|
expect(pickLocalized('hello', 'ru-RU')).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('пустая string → undefined (caller покажет placeholder)', () => {
|
||||||
|
expect(pickLocalized('', 'ru-RU')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('object с defaultLocale → значение defaultLocale', () => {
|
||||||
|
expect(
|
||||||
|
pickLocalized({ 'ru-RU': 'Канопус', 'en-US': 'Kanopus' }, 'ru-RU'),
|
||||||
|
).toBe('Канопус')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('defaultLocale пустой → fallback на любой non-empty key', () => {
|
||||||
|
expect(pickLocalized({ 'ru-RU': '', 'en-US': 'Kanopus' }, 'ru-RU')).toBe(
|
||||||
|
'Kanopus',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('defaultLocale отсутствует → fallback на первый non-empty key', () => {
|
||||||
|
expect(pickLocalized({ 'en-US': 'Kanopus' }, 'ru-RU')).toBe('Kanopus')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('null / undefined / number → undefined', () => {
|
||||||
|
expect(pickLocalized(null, 'ru-RU')).toBeUndefined()
|
||||||
|
expect(pickLocalized(undefined, 'ru-RU')).toBeUndefined()
|
||||||
|
expect(pickLocalized(42, 'ru-RU')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('объект без string значений → undefined', () => {
|
||||||
|
expect(pickLocalized({ 'ru-RU': { nested: 'oops' } }, 'ru-RU')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recordDisplayName', () => {
|
||||||
|
test('localized name → строка локали', () => {
|
||||||
|
expect(
|
||||||
|
recordDisplayName({ name: { 'ru-RU': 'Канопус', 'en-US': 'Kanopus' } }, 'ru-RU'),
|
||||||
|
).toBe('Канопус')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plain name string', () => {
|
||||||
|
expect(recordDisplayName({ name: 'Plain' }, 'ru-RU')).toBe('Plain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('нет name → fallback на code', () => {
|
||||||
|
expect(recordDisplayName({ code: 'OPERATIONAL' }, 'ru-RU')).toBe('OPERATIONAL')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('нет name+code → em-dash', () => {
|
||||||
|
expect(recordDisplayName({ other: 'x' }, 'ru-RU')).toBe('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('undefined data → em-dash', () => {
|
||||||
|
expect(recordDisplayName(undefined, 'ru-RU')).toBe('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('localized с empty default locale → fallback на code не происходит, берём другую локаль', () => {
|
||||||
|
// Если name есть в other locale — оно приоритетнее code (name всегда нагляднее).
|
||||||
|
expect(
|
||||||
|
recordDisplayName({ name: { 'en-US': 'Eng only' }, code: 'X' }, 'ru-RU'),
|
||||||
|
).toBe('Eng only')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Извлекает строковое значение из i18n-объекта `{ "ru-RU": "...", "en-US": "..." }`.
|
||||||
|
* Schema marker — `x-localized: true`. Backend хранит как JSONB-объект, UI
|
||||||
|
* рендерит на основе текущего locale пользователя.
|
||||||
|
*
|
||||||
|
* Логика выбора:
|
||||||
|
* 1. value уже string → return as-is (legacy / non-localized fallback).
|
||||||
|
* 2. value object → key === defaultLocale если есть и не пустой.
|
||||||
|
* 3. иначе первая non-empty value по любому ключу (стабильный порядок
|
||||||
|
* через Object.keys итерацию).
|
||||||
|
* 4. nothing → undefined.
|
||||||
|
*
|
||||||
|
* Возвращает undefined вместо пустой строки чтобы caller мог показать `'—'` /
|
||||||
|
* placeholder, а не пустое поле.
|
||||||
|
*/
|
||||||
|
export const pickLocalized = (
|
||||||
|
value: unknown,
|
||||||
|
defaultLocale: string,
|
||||||
|
): string | undefined => {
|
||||||
|
if (typeof value === 'string') return value.length > 0 ? value : undefined
|
||||||
|
if (!value || typeof value !== 'object') return undefined
|
||||||
|
const map = value as Record<string, unknown>
|
||||||
|
const direct = map[defaultLocale]
|
||||||
|
if (typeof direct === 'string' && direct.length > 0) return direct
|
||||||
|
for (const key of Object.keys(map)) {
|
||||||
|
const v = map[key]
|
||||||
|
if (typeof v === 'string' && v.length > 0) return v
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display value для record name column. Tries:
|
||||||
|
* 1. localized name (i18n-object)
|
||||||
|
* 2. plain `name` string (non-localized dicts)
|
||||||
|
* 3. `code` field
|
||||||
|
* 4. fallback '—'
|
||||||
|
*/
|
||||||
|
export const recordDisplayName = (
|
||||||
|
data: Record<string, unknown> | undefined,
|
||||||
|
defaultLocale: string,
|
||||||
|
): string => {
|
||||||
|
if (!data) return '—'
|
||||||
|
const localized = pickLocalized(data.name, defaultLocale)
|
||||||
|
if (localized) return localized
|
||||||
|
const code = data.code
|
||||||
|
if (typeof code === 'string' && code.length > 0) return code
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDial
|
|||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
import { nowIsoLocal } from '@/lib/dates'
|
import { nowIsoLocal } from '@/lib/dates'
|
||||||
|
import { recordDisplayName } from '@/lib/locales'
|
||||||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -380,7 +381,10 @@ function DictionaryDetail() {
|
|||||||
<span className="font-mono text-2xs">{r.businessKey}</span>
|
<span className="font-mono text-2xs">{r.businessKey}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{String(r.data.name ?? r.data.code ?? '—')}
|
{recordDisplayName(
|
||||||
|
r.data,
|
||||||
|
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="info">{r.dataScope}</Badge>
|
<Badge variant="info">{r.dataScope}</Badge>
|
||||||
|
|||||||
Reference in New Issue
Block a user