feat(catalog): row-based table per handoff prototype design/compact.html

Refactor каталога с card grid → compact row table. Handoff README line
46 явно говорит 'clicking any dictionary row in the catalog' — наша cards
implementation diverged от прототипа.

What's new:
- DictionaryListTable — single flat table (bundle стал column, не группа)
- DictRow — compact tr с scope dot prefix + title + truncated desc
- 8 columns per prototype: name / id / bundle / scope / records / → / ← / updated
- Responsive column hiding via hideBelow-style breakpoints:
  - id: hidden <md
  - bundle: hidden <lg
  - scope: hidden <sm
  - records: hidden <md
  - → ←: hidden <lg
  - updated: hidden <xl
- На mobile <640px показано только name column (с inline scope dot и desc)
- Row click → navigate к editor; Enter/Space keyboard accessible (role=link)

Removed:
- DictCard component (card layout)
- FkChip component (FK chips на cards — теперь just counts)
- SCOPE_STRIP (vertical 3px strip — заменён на inline scope dot)
- groupByBundle usage (bundle стал column, не group section)
- MAX_REFBY_CHIPS const

Note: outgoing FK count (→) пока показывает '—' — требует schemaJson per-dict
fetch (40+ requests). Batch endpoint GET /dictionaries/graph (handoff open
question #2) разблокирует это. Текущий incoming refBy через useDictionary-
Dependents работает per row.

Tests: 116 pass. TS strict clean.
This commit is contained in:
Zimin A.N.
2026-05-11 16:30:31 +03:00
parent d4000ddd3d
commit f43ce5563a
+127 -109
View File
@@ -14,7 +14,7 @@
* (followup), сейчас рендерится только если backend начнёт возвращать в DictionaryDefinition. * (followup), сейчас рендерится только если backend начнёт возвращать в DictionaryDefinition.
*/ */
import { useDeferredValue, useMemo, useState } from 'react' import { useDeferredValue, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui' import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui'
import { PlusIcon } from '@phosphor-icons/react' import { PlusIcon } from '@phosphor-icons/react'
@@ -83,14 +83,6 @@ export const groupByBundle = (
return out return out
} }
// Scope strip color — vertical 3px на левом крае карточки.
// Использует design handoff tokens — auto-switch в dark mode.
const SCOPE_STRIP: Record<DataScope, string> = {
PUBLIC: 'bg-accent',
INTERNAL: 'bg-warn',
RESTRICTED: 'bg-pink',
}
// ===== Route ===== // ===== Route =====
export const Route = createFileRoute('/dictionaries/')({ export const Route = createFileRoute('/dictionaries/')({
@@ -181,7 +173,6 @@ function DictionariesPage() {
return out return out
}, [data]) }, [data])
const grouped = useMemo(() => groupByBundle(filtered), [filtered])
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
const resetFilters = () => navigate({ search: {} }) const resetFilters = () => navigate({ search: {} })
@@ -365,24 +356,10 @@ function DictionariesPage() {
)} )}
</div> </div>
) : ( ) : (
// Bundle-grouped 2-col card grid // Row-based table per handoff prototype design/compact.html.
Array.from(grouped.entries()).map(([bundle, items]) => ( // Single flat table (bundle is a column, не group header).
<section key={bundle} className="space-y-3"> // Hover/keyboard клик на строку → navigate к editor.
<h2 className="flex items-baseline gap-3 pb-2 border-b border-line"> <DictionaryListTable rows={filtered} />
<span className="text-body uppercase tracking-[0.10em] text-accent font-mono font-medium">
{bundle}
</span>
<span className="text-cap text-mute">
· {items.length} {t('dict.list.records.short')}
</span>
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{items.map((d) => (
<DictCard key={d.id} d={d} t={t} />
))}
</div>
</section>
))
)} )}
<DictionaryEditorDialog <DictionaryEditorDialog
@@ -398,12 +375,10 @@ function DictionariesPage() {
) )
} }
// ===== Card ===== // ===== Helpers =====
type TFunc = ReturnType<typeof useTranslation>['t'] type TFunc = ReturnType<typeof useTranslation>['t']
const MAX_REFBY_CHIPS = 3
/** /**
* Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может * Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может
* указывать через несколько fields — на катаоге показываем как один chip). * указывать через несколько fields — на катаоге показываем как один chip).
@@ -421,99 +396,142 @@ export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => {
return out return out
} }
const FkChip = ({ // ===== Row table per handoff prototype design/compact.html =====
to,
label,
dim,
}: {
to: string
label: string
dim?: boolean
}) => (
<Link
to="/dictionaries/$name"
params={{ name: to }}
onClick={(e) => e.stopPropagation()}
title={to}
className={`inline-flex items-center px-[7px] py-[2px] rounded-[4px] font-mono text-[11px] transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
dim
? 'border border-dashed border-line text-mute hover:border-accent/60 hover:text-accent'
: 'bg-accent-bg text-accent hover:bg-accent hover:text-on-accent'
}`}
>
{label}
</Link>
)
const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => { /**
// refBy — backend возвращает SchemaDependent[] (cached 5min, parallel * Catalog list — compact row table per handoff Screen 1 (line 173+).
// queries dedup'ятся TanStack Query'ем). На каталоге фетчим per dict, что на * Columns: name+subtitle / id / bundle / scope / records / → / ← / updated.
// 40 dicts = 40 параллельных GET — bursty первый раз, кешировано далее. * Click row → navigate к editor.
// outgoing fk → ссылается потребует schemaJson per dict (отдельный followup, */
// backend batch endpoint желателен). function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
const { t } = useTranslation()
return (
<div className="rounded-lg border border-line overflow-hidden">
<table className="w-full">
<thead className="bg-surface-2 border-b border-line">
<tr>
<th scope="col" className="text-cap text-mute text-left px-3 py-2">
{t('dict.col.title', { defaultValue: 'Название' })}
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden md:table-cell">
{t('dict.col.id', { defaultValue: 'id' })}
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden lg:table-cell">
{t('dict.col.bundle', { defaultValue: 'bundle' })}
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden sm:table-cell">
{t('dict.col.scope', { defaultValue: 'scope' })}
</th>
<th scope="col" className="text-cap text-mute text-right px-3 py-2 hidden md:table-cell">
{t('dict.col.records', { defaultValue: 'записей' })}
</th>
<th
scope="col"
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
title={t('dict.col.outgoingFk', { defaultValue: 'Ссылается (outgoing FK)' })}
>
</th>
<th
scope="col"
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
title={t('dict.col.incomingFk', { defaultValue: 'Используют (incoming FK)' })}
>
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden xl:table-cell">
{t('dict.col.updated', { defaultValue: 'изменён' })}
</th>
</tr>
</thead>
<tbody>
{rows.map((d) => (
<DictRow key={d.id} d={d} t={t} />
))}
</tbody>
</table>
</div>
)
}
function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
const navigate = useNavigate({ from: '/dictionaries/' })
const { data: refByRaw } = useDictionaryDependents(d.name) const { data: refByRaw } = useDictionaryDependents(d.name)
const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw]) const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw])
const hasFkRow = refBy.length > 0 const incomingCount = refBy.length
const updatedLabel = useMemo(() => {
const date = new Date(d.updatedAt)
return isNaN(date.getTime())
? '—'
: date.toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}, [d.updatedAt])
const handleClick = () => {
void navigate({ to: '/dictionaries/$name', params: { name: d.name } })
}
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
return ( return (
<Link <tr
to="/dictionaries/$name" role="link"
params={{ name: d.name }} tabIndex={0}
className="grid grid-cols-[3px_1fr_auto] bg-surface border border-line rounded-lg overflow-hidden transition-all hover:-translate-y-px hover:shadow-md hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" onClick={handleClick}
onKeyDown={handleKey}
className="border-b border-line-2 last:border-b-0 cursor-pointer hover:bg-surface-2/40 focus-visible:outline-none focus-visible:bg-accent-bg/30 transition-colors"
> >
{/* Scope strip 3px вертикальная полоса (handoff spec) */} {/* name + subtitle (description short) */}
<span className={SCOPE_STRIP[d.scope]} aria-hidden="true" /> <td className="px-3 py-2 align-top min-w-0">
<div className="flex items-center gap-2">
{/* Body */} <span className={`inline-block size-1.5 rounded-full shrink-0 ${SCOPE_DOT[d.scope]}`} aria-hidden />
<div className="px-3.5 py-3 min-w-0"> <span className="text-body font-medium text-ink truncate">
<div className="flex items-baseline gap-2.5 flex-wrap">
<h3 className="text-title-md text-navy truncate">
{d.displayName ?? d.name} {d.displayName ?? d.name}
</h3> </span>
<span className="text-mono text-mute truncate">{d.name}</span>
{d.approvalRequired && ( {d.approvalRequired && (
<Badge variant="warning">{t('dict.list.approval')}</Badge> <Badge variant="warning">{t('dict.list.approval')}</Badge>
)} )}
</div> </div>
{d.description && ( {d.description && (
<p className="text-[12px] text-ink-2 mt-1 leading-snug line-clamp-2"> <p className="text-cell text-mute mt-0.5 truncate max-w-md">
{d.description} {d.description}
</p> </p>
)} )}
{hasFkRow && ( </td>
<div className="flex flex-wrap items-center gap-1.5 mt-2.5"> {/* id mono */}
<span className="text-cap font-display text-mute mr-1"> <td className="px-3 py-2 hidden md:table-cell">
{t('dict.list.fk.usedBy', { count: refBy.length })} <span className="text-mono text-ink-2">{d.name}</span>
</span> </td>
{refBy.slice(0, MAX_REFBY_CHIPS).map((dep) => ( {/* bundle */}
<FkChip <td className="px-3 py-2 hidden lg:table-cell">
key={dep.sourceDict} <span className="text-cap text-mute">{d.bundle}</span>
to={dep.sourceDict} </td>
label={dep.sourceDisplayName ?? dep.sourceDict} {/* scope badge */}
dim <td className="px-3 py-2 hidden sm:table-cell">
/>
))}
{refBy.length > MAX_REFBY_CHIPS && (
<span className="text-mono text-mute">
+{refBy.length - MAX_REFBY_CHIPS}
</span>
)}
</div>
)}
</div>
{/* Right rail: scope badge + version */}
<div className="px-3.5 py-3 flex flex-col items-end gap-1.5 shrink-0">
<Badge variant="info">{d.scope}</Badge> <Badge variant="info">{d.scope}</Badge>
<span className="text-mono text-mute whitespace-nowrap"> </td>
v{d.schemaVersion} {/* records count */}
</span> <td className="px-3 py-2 text-right tabular-nums text-mono text-ink-2 hidden md:table-cell">
{typeof d.recordCount === 'number' && ( {typeof d.recordCount === 'number' ? d.recordCount : '—'}
<span className="text-cap font-display text-mute/80 whitespace-nowrap"> </td>
{t('dict.list.recordCount', { count: d.recordCount })} {/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */}
</span> <td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell"></td>
)} {/* incoming FK count */}
</div> <td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell">
</Link> {incomingCount > 0 ? incomingCount : '—'}
</td>
{/* updated */}
<td className="px-3 py-2 text-mono text-mute tabular-nums hidden xl:table-cell whitespace-nowrap">
{updatedLabel}
</td>
</tr>
) )
} }