From 5e1b39db03101f2508a8497ff1ee094ebdd5b074 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 21:05:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(dict):=20name=20column=20=D0=B2=20records?= =?UTF-8?q?=20list=20=E2=80=94=20pick=20localized=20value,=20=D0=BD=D0=B5?= =?UTF-8?q?=20[object=20Object]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше для записей с 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). --- .../src/components/form/SchemaDrivenForm.tsx | 17 +---- ordinis-admin-ui/src/lib/locales.test.ts | 69 +++++++++++++++++++ ordinis-admin-ui/src/lib/locales.ts | 49 +++++++++++++ .../src/routes/dictionaries.$name.tsx | 6 +- 4 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 ordinis-admin-ui/src/lib/locales.test.ts create mode 100644 ordinis-admin-ui/src/lib/locales.ts diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index a1da121..e60efa7 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -29,6 +29,7 @@ import { formatIsoDate, parseFormDate, } from '@/lib/dates' +import { pickLocalized } from '@/lib/locales' type FormValues = { businessKey: string @@ -823,22 +824,6 @@ const buildOption = ( 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 - 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 = ( errors: ReturnType>['formState']['errors'], buckets: Record, diff --git a/ordinis-admin-ui/src/lib/locales.test.ts b/ordinis-admin-ui/src/lib/locales.test.ts new file mode 100644 index 0000000..489b88b --- /dev/null +++ b/ordinis-admin-ui/src/lib/locales.test.ts @@ -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') + }) +}) diff --git a/ordinis-admin-ui/src/lib/locales.ts b/ordinis-admin-ui/src/lib/locales.ts new file mode 100644 index 0000000..6ea8de0 --- /dev/null +++ b/ordinis-admin-ui/src/lib/locales.ts @@ -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 + 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 | 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 '—' +} diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 7e00c3c..b14e055 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -30,6 +30,7 @@ import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDial import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { nowIsoLocal } from '@/lib/dates' +import { recordDisplayName } from '@/lib/locales' import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' /** @@ -380,7 +381,10 @@ function DictionaryDetail() { {r.businessKey} - {String(r.data.name ?? r.data.code ?? '—')} + {recordDisplayName( + r.data, + detailQuery.data?.defaultLocale ?? 'ru-RU', + )} {r.dataScope}