feat(editor): match redesign prototype layout — TopBar title + tab counts + InfoPanel actions

Per /Users/zimin/Downloads/redesign/screens/03-editor-records.png редизайн
prototype editor layout отличается от current MVP:

1. TopBar absorbs editor title + version (was в PageHeader)
2. PageHeader удалён — title и breadcrumb теперь только в TopBar
3. Tab labels получили counts (Записи 127, Связи 6, Поля 12)
4. Action buttons распределены per role:
   - Time-travel + AOI → InfoPanel bottom (close к data context)
   - Schema edit + +Запись → tab toolbar right (close к tab actions)

Changes:

src/components/layout/TopBar.tsx
  - Breadcrumb получил subtitle slot — mono '<name> · v<version>'
  - useBreadcrumb fetches dict detail для editor route '/dictionaries/$name'
  - Render: '← Справочники / Космические аппараты satellites · v1.0.0'

src/api/queries.ts
  - useDictionaryDetail принимает name: string | undefined + enabled flag
  - Guard для editor breadcrumb когда route не matches dict

src/routes/dictionaries.$name.tsx
  - Removed <PageHeader> — title/breadcrumb теперь в TopBar
  - EditorInfoPanel получает actions prop:
    onTimeTravel + timeTravelActive + onAoi + aoiActive
  - EditorTabBar получает counts (per-tab badge) + actions slot
    (right-aligned Schema + +Запись buttons)
  - extractOutgoingFkCount helper для relations count

src/components/editor/EditorInfoPanel.tsx
  - actions prop с Time-travel + AOI buttons
  - Rendered в panel выше Relations section, между metadata и FK list
  - aria-pressed когда active (time-travel или AOI с filter)

src/components/editor/EditorTabBar (inline in route)
  - counts prop: Partial<Record<EditorTab, number>>
  - actions prop: ReactNode для right-aligned cluster
  - inline count badge — text-mono text-mute next to label

Tests: 116 pass. TS strict clean.
This commit is contained in:
Zimin A.N.
2026-05-11 17:45:06 +03:00
parent ac80c3f4a1
commit ec99010697
4 changed files with 152 additions and 87 deletions
@@ -11,7 +11,6 @@ import {
IconButton,
LoadingBlock,
Modal,
PageHeader,
Panel,
SearchInput,
Table,
@@ -22,7 +21,7 @@ import {
TableRow,
TextInput,
} from '@/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
import {
useAudit,
useDictionaryDetail,
@@ -553,81 +552,9 @@ function DictionaryDetail() {
aria-hidden="true"
/>
)}
<PageHeader
breadcrumb={
<div className="flex items-center gap-3 text-body">
<Link to="/dictionaries" className="text-ink-2 hover:text-accent">
{t('nav.dictionaries')}
</Link>
{scope && (
<span className="flex items-center gap-1.5 text-ink-2">
<span className="text-mute/70">·</span>
<span
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
aria-hidden="true"
/>
<span className="font-medium">{t(`dict.list.section.${scope}`)}</span>
</span>
)}
</div>
}
title={detailQuery.data?.displayName ?? name}
description={
detailQuery.data?.description
? `${detailQuery.data.description} · ${totalRecords} ${t('dict.list.records')}`
: `${totalRecords} ${t('dict.list.records')}`
}
actions={
// flex-wrap: при узком viewport кнопки переносятся на новую row
// вместо вмятия в 2 строки внутри одной кнопки. whitespace-nowrap на
// каждой Button — label всегда single line. Combination: buttons
// expand to fit text, и весь toolbar wraps below header при узком
// экране, а не давит лейблы.
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
leftIcon={<MapTrifoldIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setAoiOpen(true)}
className="whitespace-nowrap"
>
{t('aoi.button')}
</Button>
<Button
variant="secondary"
leftIcon={<ClockCounterClockwiseIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setTimeTravelOpen((v) => !v)}
aria-pressed={Boolean(timeTravelAt)}
className="whitespace-nowrap"
>
{t('timeTravel.button')}
</Button>
{canMutate && (
<>
<Button
variant="secondary"
leftIcon={<GearIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setSchemaEditOpen(true)}
className="whitespace-nowrap"
>
{t('schema.action.editSchema')}
</Button>
<Button
variant="primary"
leftIcon={<PlusIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setEdit({ kind: 'create' })}
className="whitespace-nowrap"
>
{t('dict.action.create')}
</Button>
</>
)}
</div>
}
/>
{/* PageHeader removed per redesign: title/version вынесены в TopBar
* breadcrumb (см. components/layout/TopBar.tsx). Actions перенесены
* в InfoPanel (Time-travel, AOI) + tab toolbar (Schema, +Запись). */}
{/* 3-col layout per handoff prototype design/compact.html:
[Sidebar (220px global)] [InfoPanel 220px] [main content]
@@ -635,7 +562,16 @@ function DictionaryDetail() {
На <lg viewport schedule переключается в stacked (info above content). */}
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6 mt-2">
{detailQuery.data && (
<EditorInfoPanel detail={detailQuery.data} recordCount={totalRecords} />
<EditorInfoPanel
detail={detailQuery.data}
recordCount={totalRecords}
actions={{
onTimeTravel: () => setTimeTravelOpen((v) => !v),
timeTravelActive: Boolean(timeTravelAt),
onAoi: () => setAoiOpen(true),
aoiActive: Boolean(aoi),
}}
/>
)}
<div className="min-w-0 space-y-4">
@@ -644,7 +580,9 @@ function DictionaryDetail() {
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
)}
{/* Editor tabs per handoff Stage 3.4 */}
{/* Editor tabs per handoff Stage 3.4 + redesign:
* - counts в labels (Записи 127, Связи 6, Поля 12)
* - right-aligned actions: Schema edit + +Запись per редизайн */}
{detailQuery.data && (
<EditorTabBar
active={urlSearch.tab ?? 'records'}
@@ -657,6 +595,36 @@ function DictionaryDetail() {
replace: true,
})
}
counts={{
records: totalRecords,
relations: (detailQuery.data ? extractOutgoingFkCount(detailQuery.data.schemaJson) : 0),
fields: Object.keys(detailQuery.data?.schemaJson?.properties ?? {}).length,
}}
actions={
canMutate ? (
<>
<IconButton
type="button"
label={t('schema.action.editSchema')}
variant="default"
icon={<GearIcon weight="regular" />}
disabled={!detailQuery.data}
onClick={() => setSchemaEditOpen(true)}
/>
<Button
type="button"
variant="primary"
size="sm"
leftIcon={<PlusIcon weight="bold" size={14} />}
disabled={!detailQuery.data}
onClick={() => setEdit({ kind: 'create' })}
className="whitespace-nowrap"
>
{t('dict.action.create')}
</Button>
</>
) : undefined
}
/>
)}
@@ -1401,6 +1369,20 @@ function BulkCloseDialog({
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
*/
/** snake_case → 'Snake Case' для column header'ов. */
/** Count unique outgoing FK targets across schema properties (x-references). */
const extractOutgoingFkCount = (schema: import('@/api/client').JsonSchema | undefined): number => {
if (!schema?.properties) return 0
const targets = new Set<string>()
for (const prop of Object.values(schema.properties)) {
const ref = prop['x-references']
if (typeof ref === 'string' && ref.length > 0) {
const [target] = ref.split('.')
if (target) targets.add(target)
}
}
return targets.size
}
const humanize = (key: string): string =>
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
@@ -1445,9 +1427,15 @@ type EditorTab = 'records' | 'relations' | 'fields' | 'json' | 'events' | 'histo
function EditorTabBar({
active,
onChange,
counts,
actions,
}: {
active: EditorTab
onChange: (tab: EditorTab) => void
/** Optional per-tab counters — render как inline mono badge per redesign. */
counts?: Partial<Record<EditorTab, number | undefined>>
/** Right-aligned action buttons (Schema / +Запись) per redesign prototype. */
actions?: React.ReactNode
}) {
const { t } = useTranslation()
const tabs: { id: EditorTab; label: string }[] = [
@@ -1462,6 +1450,7 @@ function EditorTabBar({
<div className="flex items-center gap-0 border-b border-line w-full">
{tabs.map((tab) => {
const isActive = active === tab.id
const count = counts?.[tab.id]
return (
<button
key={tab.id}
@@ -1469,16 +1458,20 @@ function EditorTabBar({
onClick={() => onChange(tab.id)}
aria-pressed={isActive}
className={[
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2',
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2 inline-flex items-center gap-1.5',
isActive
? 'border-accent text-ink font-semibold'
: 'border-transparent text-ink-2 hover:text-ink',
].join(' ')}
>
{tab.label}
{typeof count === 'number' && count > 0 && (
<span className="text-mono text-mute">{count}</span>
)}
</button>
)
})}
{actions && <div className="ml-auto flex items-center gap-2 pl-2">{actions}</div>}
</div>
)
}