feat(dict): records list — schema-driven extra columns (FK + enum)

Раньше 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 низкий).
This commit is contained in:
Zimin A.N.
2026-05-06 21:10:19 +03:00
parent 5e1b39db03
commit 5bcc9586c7
@@ -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() {
<TableRow>
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>name / code</TableHeaderCell>
{extraColumns.map((c) => (
<TableHeaderCell key={c.key}>{c.label}</TableHeaderCell>
))}
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.locale')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow>
@@ -386,12 +409,34 @@ function DictionaryDetail() {
detailQuery.data?.defaultLocale ?? 'ru-RU',
)}
</TableCell>
{extraColumns.map((c) => {
const v = r.data?.[c.key]
if (v == null || v === '') {
return (
<TableCell key={c.key}>
<span className="text-2xs text-carbon/40"></span>
</TableCell>
)
}
const text = String(v)
return (
<TableCell key={c.key}>
<span
className={[
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-2xs',
c.isFk
? 'bg-ultramarain/8 text-ultramarain'
: 'bg-carbon/8 text-carbon/80',
].join(' ')}
>
{text}
</span>
</TableCell>
)
})}
<TableCell>
<Badge variant="info">{r.dataScope}</Badge>
</TableCell>
<TableCell>
<span className="text-2xs text-carbon/60">{r._meta?.locale ?? '—'}</span>
</TableCell>
<TableCell>
<span className="text-2xs text-carbon/60">
{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)