feat(editor): Связи tab 2-col split per redesign prototype
Per /Users/zimin/Downloads/Ordinis (4)/redesign/screens/04-editor-links.png.
Replaced 3-col hub view (left rail + center dict card + right rail) с simple
2-col grid. Central dict card стал избыточен — все его info уже в InfoPanel
слева, дублирование тратило место и attention.
New layout:
┌────────────────────────────┬────────────────────────────┐
│ → ССЫЛАЕТСЯ НА (N) │ ← ССЫЛАЮТСЯ НА НАС (M) │
│ ┌──────────────────────┐ │ ┌──────────────────────┐ │
│ │ {target} {count} │ │ │ {source} {count} │ │
│ │ {req|opt} {.field} │ │ │ {крит} {.field} │ │
│ └──────────────────────┘ │ ├──────────────────────┤ │
│ │ │ Warning N ссылок │ │
│ │ └──────────────────────┘ │
└────────────────────────────┴────────────────────────────┘
Each outgoing row: target_dict (link accent) | recordCount | required/optional
chip | source_field (mono right).
Each incoming row: source_dict (link) | activeRecordsInSourceDict | onClose
policy chip (BLOCK=критично pink, CASCADE=warn amber) | .source_field.
Footer warning в incoming panel когда totalDeps > 100 — "удаление сложно".
This commit is contained in:
@@ -1,99 +1,63 @@
|
||||
/**
|
||||
* Hub view (View B из design handoff) — focused dictionary в центре + соседи
|
||||
* по обе стороны.
|
||||
* DictionaryHubView — Связи tab per redesign/screens/04-editor-links.png.
|
||||
*
|
||||
* Layout:
|
||||
* ┌──────────────┬─────────────────┬──────────────┐
|
||||
* │ → ссылается │ <current> │ ← N использ. │
|
||||
* │ (outgoing) │ (focused) │ (refBy) │
|
||||
* └──────────────┴─────────────────┴──────────────┘
|
||||
* <p>2-column split:
|
||||
* <ul>
|
||||
* <li><b>→ ССЫЛАЕТСЯ НА (N)</b>: outgoing FK — каждая row показывает
|
||||
* 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
|
||||
* (format: "dict.field"). Incoming refBy via useDictionaryDependents.
|
||||
*
|
||||
* Click на neighbor card → navigate к его Hub view (recursion fine).
|
||||
* <p>Replaces старый 3-col hub view с central dict card — center больше не
|
||||
* нужен (всё info уже в InfoPanel слева).
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/ui'
|
||||
import { useDictionaryDependents, useDictionaries } from '@/api/queries'
|
||||
import type { DictionaryDetail, DictionaryDefinition, JsonSchema } from '@/api/client'
|
||||
import { useDictionaries, useDictionaryDependents } from '@/api/queries'
|
||||
import type {
|
||||
DictionaryDefinition,
|
||||
DictionaryDetail,
|
||||
JsonSchema,
|
||||
SchemaDependent,
|
||||
} from '@/api/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SCOPE_STRIP: Record<string, string> = {
|
||||
PUBLIC: 'bg-emerald-500',
|
||||
INTERNAL: 'bg-amber-500',
|
||||
RESTRICTED: 'bg-rose-500',
|
||||
type OutgoingFkRow = {
|
||||
/** Target dict name. */
|
||||
target: string
|
||||
/** Source field name in current schema. */
|
||||
sourceField: string
|
||||
/** Target field name (usually .id или .code). */
|
||||
targetField: string
|
||||
/** Required в JSON schema. */
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk schemaJson properties — собираем targets из x-references.
|
||||
* Format: "targetDict.targetField" → возвращаем уникальные targetDict names.
|
||||
*/
|
||||
const extractOutgoingFk = (schema: JsonSchema | undefined): string[] => {
|
||||
/** Walk schema properties → build outgoing FK rows. */
|
||||
const extractOutgoingFk = (schema: JsonSchema | undefined): OutgoingFkRow[] => {
|
||||
if (!schema?.properties) return []
|
||||
const targets = new Set<string>()
|
||||
for (const prop of Object.values(schema.properties)) {
|
||||
const required = new Set(schema.required ?? [])
|
||||
const rows: OutgoingFkRow[] = []
|
||||
for (const [field, prop] of Object.entries(schema.properties)) {
|
||||
const ref = prop['x-references']
|
||||
if (typeof ref === 'string' && ref.length > 0) {
|
||||
const [target] = ref.split('.')
|
||||
if (target) targets.add(target)
|
||||
const [target, targetField] = ref.split('.')
|
||||
if (target) {
|
||||
rows.push({
|
||||
target,
|
||||
sourceField: field,
|
||||
targetField: targetField ?? 'id',
|
||||
required: required.has(field),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(targets).sort()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return rows.sort((a, b) => a.target.localeCompare(b.target))
|
||||
}
|
||||
|
||||
export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) {
|
||||
@@ -101,113 +65,146 @@ export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) {
|
||||
const { data: allDicts } = useDictionaries()
|
||||
const { data: refByRaw } = useDictionaryDependents(detail.name)
|
||||
|
||||
// Outgoing FK targets — names derived из schemaJson.
|
||||
const outgoingNames = useMemo(
|
||||
() => extractOutgoingFk(detail.schemaJson),
|
||||
[detail.schemaJson],
|
||||
const outgoing = useMemo(() => extractOutgoingFk(detail.schemaJson), [detail.schemaJson])
|
||||
const incoming: SchemaDependent[] = useMemo(() => refByRaw ?? [], [refByRaw])
|
||||
|
||||
// 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],
|
||||
)
|
||||
|
||||
// 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])
|
||||
const HEAVY_THRESHOLD = 100
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('hub.heading', { defaultValue: 'Связи справочника' })}
|
||||
className="grid grid-cols-1 md:grid-cols-[1fr_minmax(280px,_360px)_1fr] gap-4 md:gap-6 items-start py-2"
|
||||
>
|
||||
{/* Left rail: outgoing FK */}
|
||||
<div className="md:order-1 order-2 space-y-2">
|
||||
<SectionLabel side="left" count={outgoingDicts.length} />
|
||||
{outgoingDicts.length === 0 ? (
|
||||
<div className="text-cell text-mute/70 text-right italic">
|
||||
{t('hub.noOutgoing', { defaultValue: 'нет исходящих ссылок' })}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Outgoing → */}
|
||||
<section className="rounded-lg border border-line bg-surface overflow-hidden">
|
||||
<header className="px-4 py-2.5 border-b border-line-2">
|
||||
<div className="text-cap text-mute tracking-[0.16em] uppercase">
|
||||
→ {t('hub.outgoing', { defaultValue: 'ссылается на' })}
|
||||
<span className="text-mono text-ink ml-1">({outgoing.length})</span>
|
||||
</div>
|
||||
</header>
|
||||
{outgoing.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-body text-mute">
|
||||
{t('hub.empty.outgoing', {
|
||||
defaultValue: 'Нет исходящих FK',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{outgoingDicts.map((d) => (
|
||||
<NeighborCard key={d.id} dict={d} side="left" />
|
||||
))}
|
||||
</div>
|
||||
<ul>
|
||||
{outgoing.map((row) => {
|
||||
const target = dictByName.get(row.target)
|
||||
return (
|
||||
<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) */}
|
||||
<div className="md:order-2 order-1 relative">
|
||||
<div
|
||||
className="relative bg-white rounded-xl p-5 border-2 border-accent"
|
||||
style={{ boxShadow: '0 18px 36px -22px rgba(20,30,140,.5)' }}
|
||||
>
|
||||
<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>
|
||||
{/* Incoming ← */}
|
||||
<section className="rounded-lg border border-line bg-surface overflow-hidden flex flex-col">
|
||||
<header className="px-4 py-2.5 border-b border-line-2">
|
||||
<div className="text-cap text-mute tracking-[0.16em] uppercase">
|
||||
← {t('hub.incoming', { defaultValue: 'ссылаются на нас' })}
|
||||
<span className="text-mono text-ink ml-1">({incoming.length})</span>
|
||||
</div>
|
||||
<h2 className="text-title-xl font-sans text-accent leading-tight">
|
||||
{detail.displayName ?? detail.name}
|
||||
</h2>
|
||||
<div className="text-mono text-mute mt-0.5">
|
||||
{detail.name}
|
||||
</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: 'никто не ссылается' })}
|
||||
</header>
|
||||
{incoming.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-body text-mute">
|
||||
{t('hub.empty.incoming', {
|
||||
defaultValue: 'Никто не ссылается',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{incomingDicts.map((d) => (
|
||||
<NeighborCard key={d.id} dict={d} side="right" />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<ul className="flex-1">
|
||||
{incoming.map((dep) => (
|
||||
<li
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user