Merge branch 'feat/catalog-scope-color-rows' into 'main'

feat(catalog): scope-colored row stripes + filter pills + chips

See merge request 2-6/2-6-4/terravault/ordinis!80
This commit is contained in:
Александр Зимин
2026-05-11 16:35:57 +00:00
2 changed files with 199 additions and 188 deletions
@@ -1,99 +1,63 @@
/** /**
* Hub view (View B из design handoff) — focused dictionary в центре + соседи * DictionaryHubView — Связи tab per redesign/screens/04-editor-links.png.
* по обе стороны.
* *
* Layout: * <p>2-column split:
* ┌──────────────┬─────────────────┬──────────────┐ * <ul>
* │ → ссылается │ <current> │ ← N использ. │ * <li><b>→ ССЫЛАЕТСЯ НА (N)</b>: outgoing FK — каждая row показывает
* (outgoing) │ (focused) │ (refBy) │ * target_dict (link) + record_count + required/optional chip + source_field
* └──────────────┴─────────────────┴──────────────┘ * (mono right).</li>
* <li><b>← ССЫЛАЮТСЯ НА НАС (M)</b>: incoming FK — source_dict (link) +
* activeRecordsInSourceDict + onClose policy chip ("критично" для BLOCK).
* Footer warning если total dependents > N — указывает что удаление
* записи в этом dict сложно.</li>
* </ul>
* *
* Outgoing FK derived from schemaJson properties[*].x-references * <p>Replaces старый 3-col hub view с central dict card — center больше не
* (format: "dict.field"). Incoming refBy via useDictionaryDependents. * нужен (всё info уже в InfoPanel слева).
*
* Click на neighbor card → navigate к его Hub view (recursion fine).
*/ */
import { useMemo } from 'react' import { useMemo } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Badge } from '@/ui' import { useDictionaries, useDictionaryDependents } from '@/api/queries'
import { useDictionaryDependents, useDictionaries } from '@/api/queries' import type {
import type { DictionaryDetail, DictionaryDefinition, JsonSchema } from '@/api/client' DictionaryDefinition,
DictionaryDetail,
JsonSchema,
SchemaDependent,
} from '@/api/client'
import { cn } from '@/lib/utils'
const SCOPE_STRIP: Record<string, string> = { type OutgoingFkRow = {
PUBLIC: 'bg-emerald-500', /** Target dict name. */
INTERNAL: 'bg-amber-500', target: string
RESTRICTED: 'bg-rose-500', /** Source field name in current schema. */
sourceField: string
/** Target field name (usually .id или .code). */
targetField: string
/** Required в JSON schema. */
required: boolean
} }
/** /** Walk schema properties → build outgoing FK rows. */
* Walk schemaJson properties — собираем targets из x-references. const extractOutgoingFk = (schema: JsonSchema | undefined): OutgoingFkRow[] => {
* Format: "targetDict.targetField" → возвращаем уникальные targetDict names.
*/
const extractOutgoingFk = (schema: JsonSchema | undefined): string[] => {
if (!schema?.properties) return [] if (!schema?.properties) return []
const targets = new Set<string>() const required = new Set(schema.required ?? [])
for (const prop of Object.values(schema.properties)) { const rows: OutgoingFkRow[] = []
for (const [field, prop] of Object.entries(schema.properties)) {
const ref = prop['x-references'] const ref = prop['x-references']
if (typeof ref === 'string' && ref.length > 0) { if (typeof ref === 'string' && ref.length > 0) {
const [target] = ref.split('.') const [target, targetField] = ref.split('.')
if (target) targets.add(target) if (target) {
rows.push({
target,
sourceField: field,
targetField: targetField ?? 'id',
required: required.has(field),
})
}
} }
} }
return Array.from(targets).sort() return rows.sort((a, b) => a.target.localeCompare(b.target))
}
const NeighborCard = ({
dict,
side,
}: {
dict: DictionaryDefinition
side: 'left' | 'right'
}) => (
<Link
to="/dictionaries/$name"
params={{ name: dict.name }}
search={{ tab: 'relations' }}
className={`group block bg-white border border-line rounded-lg p-3 transition hover:shadow-hover hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
side === 'left' ? 'text-right' : 'text-left'
}`}
>
<div className={`flex items-baseline gap-2 ${side === 'left' ? 'justify-end' : ''}`}>
<span
className={`inline-block size-1.5 rounded-full shrink-0 ${
SCOPE_STRIP[dict.scope] ?? 'bg-ink/40'
}`}
aria-hidden="true"
/>
<h4 className="font-sans text-[14px] font-semibold text-accent truncate group-hover:underline">
{dict.displayName ?? dict.name}
</h4>
</div>
<div className="mt-0.5 font-mono text-[10px] text-mute truncate">
{dict.name}
</div>
</Link>
)
const SectionLabel = ({
side,
count,
}: {
side: 'left' | 'right'
count: number
}) => {
const { t } = useTranslation()
return (
<div
className={`text-cap text-mute mb-2 ${
side === 'left' ? 'text-right' : 'text-left'
}`}
>
{side === 'left'
? t('hub.outgoing', { count, defaultValue: '→ ссылается ({{count}})' })
: t('hub.incoming', { count, defaultValue: '← {{count}} использ.' })}
</div>
)
} }
export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) { export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) {
@@ -101,113 +65,146 @@ export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) {
const { data: allDicts } = useDictionaries() const { data: allDicts } = useDictionaries()
const { data: refByRaw } = useDictionaryDependents(detail.name) const { data: refByRaw } = useDictionaryDependents(detail.name)
// Outgoing FK targets — names derived из schemaJson. const outgoing = useMemo(() => extractOutgoingFk(detail.schemaJson), [detail.schemaJson])
const outgoingNames = useMemo( const incoming: SchemaDependent[] = useMemo(() => refByRaw ?? [], [refByRaw])
() => extractOutgoingFk(detail.schemaJson),
[detail.schemaJson], // Resolve target dict definitions для record count badge.
const dictByName = useMemo(() => {
if (!allDicts) return new Map<string, DictionaryDefinition>()
return new Map(allDicts.map((d) => [d.name, d]))
}, [allDicts])
// Aggregate total incoming deps count для warning footer.
const totalIncomingDeps = useMemo(
() => incoming.reduce((sum, d) => sum + d.activeRecordsInSourceDict, 0),
[incoming],
) )
const HEAVY_THRESHOLD = 100
// Resolve names → DictionaryDefinition (для card rendering).
// Filter недоступные / unknown names (защита от устаревших schema refs).
const outgoingDicts = useMemo(() => {
if (!allDicts) return []
const byName = new Map(allDicts.map((d) => [d.name, d]))
return outgoingNames
.map((name) => byName.get(name))
.filter((d): d is DictionaryDefinition => Boolean(d))
}, [allDicts, outgoingNames])
// refBy: уникальные source dicts (те что ссылаются на нас) → resolve в definitions.
const incomingDicts = useMemo(() => {
if (!allDicts || !refByRaw) return []
const byName = new Map(allDicts.map((d) => [d.name, d]))
const seen = new Set<string>()
const out: DictionaryDefinition[] = []
for (const dep of refByRaw) {
if (seen.has(dep.sourceDict)) continue
seen.add(dep.sourceDict)
const def = byName.get(dep.sourceDict)
if (def) out.push(def)
}
return out
}, [allDicts, refByRaw])
return ( return (
<section <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
aria-label={t('hub.heading', { defaultValue: 'Связи справочника' })} {/* Outgoing → */}
className="grid grid-cols-1 md:grid-cols-[1fr_minmax(280px,_360px)_1fr] gap-4 md:gap-6 items-start py-2" <section className="rounded-lg border border-line bg-surface overflow-hidden">
> <header className="px-4 py-2.5 border-b border-line-2">
{/* Left rail: outgoing FK */} <div className="text-cap text-mute tracking-[0.16em] uppercase">
<div className="md:order-1 order-2 space-y-2"> {t('hub.outgoing', { defaultValue: 'ссылается на' })}
<SectionLabel side="left" count={outgoingDicts.length} /> <span className="text-mono text-ink ml-1">({outgoing.length})</span>
{outgoingDicts.length === 0 ? ( </div>
<div className="text-cell text-mute/70 text-right italic"> </header>
{t('hub.noOutgoing', { defaultValue: 'нет исходящих ссылок' })} {outgoing.length === 0 ? (
<div className="px-4 py-8 text-center text-body text-mute">
{t('hub.empty.outgoing', {
defaultValue: 'Нет исходящих FK',
})}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <ul>
{outgoingDicts.map((d) => ( {outgoing.map((row) => {
<NeighborCard key={d.id} dict={d} side="left" /> const target = dictByName.get(row.target)
))} return (
</div> <li
key={`${row.target}.${row.sourceField}`}
className="border-b border-line-2 last:border-b-0"
>
<Link
to="/dictionaries/$name"
params={{ name: row.target }}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-surface-2/50 transition-colors"
>
<span className="text-mono text-body text-accent font-medium truncate flex-1 min-w-0">
{row.target}
</span>
{target?.recordCount !== undefined && (
<span className="text-mono text-cell text-mute tabular-nums shrink-0">
{target.recordCount}
</span>
)}
<span
className={cn(
'text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm shrink-0',
row.required
? 'bg-accent-bg text-accent'
: 'bg-surface-2 text-mute',
)}
>
{row.required ? 'required' : 'optional'}
</span>
<span className="text-mono text-cell text-ink-2 shrink-0 min-w-[80px] text-right">
{row.sourceField}
</span>
</Link>
</li>
)
})}
</ul>
)} )}
</div> </section>
{/* Center: focused dict (large card) */} {/* Incoming ← */}
<div className="md:order-2 order-1 relative"> <section className="rounded-lg border border-line bg-surface overflow-hidden flex flex-col">
<div <header className="px-4 py-2.5 border-b border-line-2">
className="relative bg-white rounded-xl p-5 border-2 border-accent" <div className="text-cap text-mute tracking-[0.16em] uppercase">
style={{ boxShadow: '0 18px 36px -22px rgba(20,30,140,.5)' }} {t('hub.incoming', { defaultValue: 'ссылаются на нас' })}
> <span className="text-mono text-ink ml-1">({incoming.length})</span>
<div className="flex items-start gap-2 mb-2 flex-wrap">
<Badge variant="info">{detail.scope}</Badge>
{detail.approvalRequired && (
<Badge variant="warning">
{t('dict.list.approval', { defaultValue: 'approval' })}
</Badge>
)}
<span className="text-mono ml-auto text-mute">
v{detail.schemaVersion}
</span>
</div> </div>
<h2 className="text-title-xl font-sans text-accent leading-tight"> </header>
{detail.displayName ?? detail.name} {incoming.length === 0 ? (
</h2> <div className="px-4 py-8 text-center text-body text-mute">
<div className="text-mono text-mute mt-0.5"> {t('hub.empty.incoming', {
{detail.name} defaultValue: 'Никто не ссылается',
</div> })}
{detail.description && (
<p className="text-[13px] text-ink mt-3 leading-relaxed">
{detail.description}
</p>
)}
<div className="text-cap mt-4 pt-3 border-t border-line flex items-center justify-between text-mute">
<span>{detail.bundle}</span>
<span>{detail.supportedLocales.join(', ')}</span>
{typeof detail.recordCount === 'number' && (
<span>
{t('dict.list.recordCount', { count: detail.recordCount })}
</span>
)}
</div>
</div>
</div>
{/* Right rail: refBy */}
<div className="md:order-3 order-3 space-y-2">
<SectionLabel side="right" count={incomingDicts.length} />
{incomingDicts.length === 0 ? (
<div className="text-cell text-mute/70 italic">
{t('hub.noIncoming', { defaultValue: 'никто не ссылается' })}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <>
{incomingDicts.map((d) => ( <ul className="flex-1">
<NeighborCard key={d.id} dict={d} side="right" /> {incoming.map((dep) => (
))} <li
</div> key={`${dep.sourceDict}.${dep.sourceField}`}
className="border-b border-line-2 last:border-b-0"
>
<Link
to="/dictionaries/$name"
params={{ name: dep.sourceDict }}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-surface-2/50 transition-colors"
>
<span className="text-mono text-body text-accent font-medium truncate flex-1 min-w-0">
{dep.sourceDict}
</span>
{dep.activeRecordsInSourceDict > 0 && (
<span className="text-mono text-cell text-mute tabular-nums shrink-0">
{dep.activeRecordsInSourceDict}
</span>
)}
{dep.onClose === 'BLOCK' && (
<span className="text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm bg-pink-bg text-pink shrink-0">
{t('hub.critical', { defaultValue: 'критично' })}
</span>
)}
{dep.onClose === 'CASCADE' && (
<span className="text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm bg-warn-bg text-warn shrink-0">
CASCADE
</span>
)}
<span className="text-mono text-cell text-ink-2 shrink-0 min-w-[80px] text-right">
.{dep.sourceField}
</span>
</Link>
</li>
))}
</ul>
{/* Footer warning если в this dict много incoming refs — удаление
* записи будет сложным (cascade chain или blockers). */}
{totalIncomingDeps > HEAVY_THRESHOLD && (
<div className="px-4 py-2.5 border-t border-line-2 bg-warn-bg/30 text-cell text-ink-2">
{t('hub.heavyDeps', {
count: totalIncomingDeps,
defaultValue: `Удаление записи в этом справочнике сложно — ${totalIncomingDeps} ссылок`,
})}
</div>
)}
</>
)} )}
</div> </section>
</section> </div>
) )
} }
@@ -23,7 +23,7 @@ import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } f
import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client' import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { useCanMutate } from '@/auth/useCanMutate' import { useCanMutate } from '@/auth/useCanMutate'
import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' import { SCOPE_BG_TINT, SCOPE_DOT, SCOPE_ORDER, SCOPE_TEXT } from '@/lib/scope-style'
// ===== Search params ===== // ===== Search params =====
@@ -248,7 +248,7 @@ function DictionariesPage() {
title={t(`dict.list.section.${scope}`)} title={t(`dict.list.section.${scope}`)}
className={`text-cap tracking-[0.14em] uppercase font-semibold px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${ className={`text-cap tracking-[0.14em] uppercase font-semibold px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
selected selected
? 'border-ink bg-surface text-ink ring-1 ring-ink' ? `${SCOPE_BG_TINT[scope]} ${SCOPE_TEXT[scope]} border-transparent`
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2' : 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
}`} }`}
> >
@@ -473,18 +473,28 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
} }
} }
// Per redesign prototype: row левый край имеет 4px stripe в scope color +
// INTERNAL/RESTRICTED rows получают subtle bg tint для visual scope grouping.
// PUBLIC = plain white (default — самый common scope не нужно подкрашивать).
const rowTint = d.scope === 'PUBLIC' ? '' : `${SCOPE_BG_TINT[d.scope]}/30`
return ( return (
<tr <tr
role="link" role="link"
tabIndex={0} tabIndex={0}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKey} 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" className={`relative 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 ${rowTint}`}
> >
{/* name + subtitle (description short) */} {/* name + subtitle (description short) + scope-color left stripe */}
<td className="px-3 py-2 align-top min-w-0"> <td className="relative px-3 py-2 align-top min-w-0">
<div className="flex items-center gap-2"> {/* 3px scope stripe — absolutely positioned на левом краю row.
<span className={`inline-block size-1.5 rounded-full shrink-0 ${SCOPE_DOT[d.scope]}`} aria-hidden /> * td имеет relative чтобы before работало в table context. */}
<span
aria-hidden
className={`absolute left-0 top-0 bottom-0 w-[3px] ${SCOPE_DOT[d.scope]}`}
/>
<div className="flex items-center gap-2 pl-1">
<span className="text-body font-medium text-ink truncate"> <span className="text-body font-medium text-ink truncate">
{d.displayName ?? d.name} {d.displayName ?? d.name}
</span> </span>
@@ -493,7 +503,7 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
)} )}
</div> </div>
{d.description && ( {d.description && (
<p className="text-cell text-mute mt-0.5 truncate max-w-md"> <p className="text-cell text-mute mt-0.5 truncate max-w-md pl-1">
{d.description} {d.description}
</p> </p>
)} )}
@@ -506,9 +516,13 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
<td className="px-3 py-2 hidden lg:table-cell"> <td className="px-3 py-2 hidden lg:table-cell">
<span className="text-cap text-mute">{d.bundle}</span> <span className="text-cap text-mute">{d.bundle}</span>
</td> </td>
{/* scope badge */} {/* scope badge — colored по scope (PUBLIC accent / INTERNAL warn / RESTRICTED pink) */}
<td className="px-3 py-2 hidden sm:table-cell"> <td className="px-3 py-2 hidden sm:table-cell">
<Badge variant="info">{d.scope}</Badge> <span
className={`inline-flex items-center px-2 py-0.5 rounded-sm text-cap tracking-wider font-semibold uppercase ${SCOPE_BG_TINT[d.scope]} ${SCOPE_TEXT[d.scope]}`}
>
{d.scope}
</span>
</td> </td>
{/* records count */} {/* records count */}
<td className="px-3 py-2 text-right tabular-nums text-mono text-ink-2 hidden md:table-cell"> <td className="px-3 py-2 text-right tabular-nums text-mono text-ink-2 hidden md:table-cell">