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:
@@ -178,6 +178,27 @@ function DictionaryDetail() {
|
|||||||
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
||||||
const filteredCount = filteredRecords.length
|
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 editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
|
||||||
const rawRecordQuery = useRecordRaw(name, editingKey)
|
const rawRecordQuery = useRecordRaw(name, editingKey)
|
||||||
|
|
||||||
@@ -368,8 +389,10 @@ function DictionaryDetail() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
||||||
<TableHeaderCell>name / code</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.scope')}</TableHeaderCell>
|
||||||
<TableHeaderCell>{t('dict.col.locale')}</TableHeaderCell>
|
|
||||||
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
|
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
|
||||||
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
|
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -386,12 +409,34 @@ function DictionaryDetail() {
|
|||||||
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<Badge variant="info">{r.dataScope}</Badge>
|
<Badge variant="info">{r.dataScope}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<span className="text-2xs text-carbon/60">{r._meta?.locale ?? '—'}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-2xs text-carbon/60">
|
<span className="text-2xs text-carbon/60">
|
||||||
{new Date(r.validFrom).toLocaleDateString()}
|
{new Date(r.validFrom).toLocaleDateString()}
|
||||||
@@ -566,6 +611,10 @@ function DictionaryDetail() {
|
|||||||
* прямоугольным GeoJSON для preview в picker'е. Невалидные значения
|
* прямоугольным GeoJSON для preview в picker'е. Невалидные значения
|
||||||
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
|
* уже отфильтрованы 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 => {
|
const bboxToAoi = (bboxCsv: string | undefined): AoiResult | null => {
|
||||||
if (!bboxCsv) return null
|
if (!bboxCsv) return null
|
||||||
const parts = bboxCsv.split(',').map(Number)
|
const parts = bboxCsv.split(',').map(Number)
|
||||||
|
|||||||
Reference in New Issue
Block a user