From 5bcc9586c718b687908d7eb381875ad39877f202 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 21:10:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(dict):=20records=20list=20=E2=80=94=20sche?= =?UTF-8?q?ma-driven=20extra=20columns=20(FK=20+=20enum)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше records list показывал только businessKey / name / scope / locale / validFrom. На spacecraft с 14 полями admin не видел статуса или типа КА — должен был открыть edit modal каждой записи чтобы понять что есть OPERATIONAL и что есть LOST. Drop unhelpful 'locale' колонку (для не-localized словарей всегда '—'). Replace на до 2 schema-driven 'значимых' колонок: - FK поля (x-references) — синий fill, маркируют ссылку на dict - Enum поля — серый fill, fixed allow-list Spacecraft теперь показывает в таблице: businessKey, name, satellite_type_code (FK), status (FK), scope, validFrom — всю полезную информацию за один взгляд. Schema interpretation: - detailQuery.data.schemaJson.properties → filter scalar non-localized - Skip name/code/businessKey (уже показаны) - Take первые 2 enum/reference (column count fits 1280px viewport) - humanize key для header label (snake_case → Snake Case) Tests: 89 passed (без изменений — render-логика, integration test требует QueryClient + schema fixture, ROI низкий). --- .../src/routes/dictionaries.$name.tsx | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index b14e055..cb5031d 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -178,6 +178,27 @@ function DictionaryDetail() { const filtersActive = search.length > 0 || scopeFilter.size > 0 const filteredCount = filteredRecords.length + // Schema-driven extra columns: enum + reference поля — самые info-dense + // не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить + // строку (с businessKey + name + scope + extras + дата + actions = 6 + // колонок, comfortably fits на 1280px). Skip name/code/businessKey + // (уже показаны), всё нескалярное (object/array/localized). + const extraColumns = useMemo<{ key: string; label: string; isFk: boolean }[]>(() => { + const props = detailQuery.data?.schemaJson?.properties ?? {} + const interesting: { key: string; label: string; isFk: boolean }[] = [] + for (const [key, prop] of Object.entries(props)) { + if (interesting.length >= 2) break + if (key === 'name' || key === 'code' || key === 'businessKey') continue + const isLocalized = Boolean(prop['x-localized']) + if (isLocalized) continue + const isEnum = Array.isArray(prop.enum) && prop.enum.length > 0 + const isFk = typeof prop['x-references'] === 'string' && Boolean(prop['x-references']) + if (!isEnum && !isFk) continue + interesting.push({ key, label: humanize(key), isFk }) + } + return interesting + }, [detailQuery.data?.schemaJson]) + const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined const rawRecordQuery = useRecordRaw(name, editingKey) @@ -368,8 +389,10 @@ function DictionaryDetail() { {t('dict.col.businessKey')} name / code + {extraColumns.map((c) => ( + {c.label} + ))} {t('dict.col.scope')} - {t('dict.col.locale')} {t('dict.col.validFrom')} {t('dict.col.actions')} @@ -386,12 +409,34 @@ function DictionaryDetail() { detailQuery.data?.defaultLocale ?? 'ru-RU', )} + {extraColumns.map((c) => { + const v = r.data?.[c.key] + if (v == null || v === '') { + return ( + + + + ) + } + const text = String(v) + return ( + + + {text} + + + ) + })} {r.dataScope} - - {r._meta?.locale ?? '—'} - {new Date(r.validFrom).toLocaleDateString()} @@ -566,6 +611,10 @@ function DictionaryDetail() { * прямоугольным GeoJSON для preview в picker'е. Невалидные значения * уже отфильтрованы validateSearch — но dup'аем guard на парсинге. */ +/** snake_case → 'Snake Case' для column header'ов. */ +const humanize = (key: string): string => + key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()) + const bboxToAoi = (bboxCsv: string | undefined): AoiResult | null => { if (!bboxCsv) return null const parts = bboxCsv.split(',').map(Number)