Merge branch 'feat/catalog-hub-view' into 'main'
feat(admin-ui): Hub view (B) per design handoff — dict relations 3-col layout See merge request 2-6/2-6-4/terravault/ordinis!20
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Hub view (View B из design handoff) — focused dictionary в центре + соседи
|
||||||
|
* по обе стороны.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* ┌──────────────┬─────────────────┬──────────────┐
|
||||||
|
* │ → ссылается │ <current> │ ← N использ. │
|
||||||
|
* │ (outgoing) │ (focused) │ (refBy) │
|
||||||
|
* └──────────────┴─────────────────┴──────────────┘
|
||||||
|
*
|
||||||
|
* Outgoing FK derived from schemaJson properties[*].x-references
|
||||||
|
* (format: "dict.field"). Incoming refBy via useDictionaryDependents.
|
||||||
|
*
|
||||||
|
* Click на neighbor card → navigate к его Hub view (recursion fine).
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Badge } from '@nstart/ui'
|
||||||
|
import { useDictionaryDependents, useDictionaries } from '@/api/queries'
|
||||||
|
import type { DictionaryDetail, DictionaryDefinition, JsonSchema } from '@/api/client'
|
||||||
|
|
||||||
|
const SCOPE_STRIP: Record<string, string> = {
|
||||||
|
PUBLIC: 'bg-emerald-500',
|
||||||
|
INTERNAL: 'bg-amber-500',
|
||||||
|
RESTRICTED: 'bg-rose-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk schemaJson properties — собираем targets из x-references.
|
||||||
|
* Format: "targetDict.targetField" → возвращаем уникальные targetDict names.
|
||||||
|
*/
|
||||||
|
const extractOutgoingFk = (schema: JsonSchema | undefined): string[] => {
|
||||||
|
if (!schema?.properties) return []
|
||||||
|
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 Array.from(targets).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const NeighborCard = ({
|
||||||
|
dict,
|
||||||
|
side,
|
||||||
|
}: {
|
||||||
|
dict: DictionaryDefinition
|
||||||
|
side: 'left' | 'right'
|
||||||
|
}) => (
|
||||||
|
<Link
|
||||||
|
to="/dictionaries/$name"
|
||||||
|
params={{ name: dict.name }}
|
||||||
|
search={{ view: 'hub' }}
|
||||||
|
className={`group block bg-white border border-regolith rounded-lg p-3 transition hover:shadow-hover hover:border-ultramarain/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ultramarain/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-carbon/40'
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h4 className="font-primary text-[14px] font-semibold text-ultramarain truncate group-hover:underline">
|
||||||
|
{dict.displayName ?? dict.name}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[10px] text-carbon/60 truncate">
|
||||||
|
{dict.name}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SectionLabel = ({
|
||||||
|
side,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
side: 'left' | 'right'
|
||||||
|
count: number
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`text-2xs uppercase tracking-label text-carbon/60 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 }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { data: allDicts } = useDictionaries()
|
||||||
|
const { data: refByRaw } = useDictionaryDependents(detail.name)
|
||||||
|
|
||||||
|
// Outgoing FK targets — names derived из schemaJson.
|
||||||
|
const outgoingNames = useMemo(
|
||||||
|
() => extractOutgoingFk(detail.schemaJson),
|
||||||
|
[detail.schemaJson],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<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-2xs text-carbon/40 text-right italic">
|
||||||
|
{t('hub.noOutgoing', { defaultValue: 'нет исходящих ссылок' })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{outgoingDicts.map((d) => (
|
||||||
|
<NeighborCard key={d.id} dict={d} side="left" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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-ultramarain"
|
||||||
|
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="ml-auto font-mono text-[11px] text-carbon/60">
|
||||||
|
v{detail.schemaVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-primary text-[22px] font-semibold text-ultramarain leading-tight">
|
||||||
|
{detail.displayName ?? detail.name}
|
||||||
|
</h2>
|
||||||
|
<div className="font-mono text-[11px] text-carbon/60 mt-0.5">
|
||||||
|
{detail.name}
|
||||||
|
</div>
|
||||||
|
{detail.description && (
|
||||||
|
<p className="text-[13px] text-carbon mt-3 leading-relaxed">
|
||||||
|
{detail.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 pt-3 border-t border-regolith flex items-center justify-between text-2xs uppercase tracking-label text-carbon/60">
|
||||||
|
<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-2xs text-carbon/40 italic">
|
||||||
|
{t('hub.noIncoming', { defaultValue: 'никто не ссылается' })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{incomingDicts.map((d) => (
|
||||||
|
<NeighborCard key={d.id} dict={d} side="right" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord
|
|||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
|
||||||
|
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
|
||||||
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
@@ -57,6 +58,9 @@ type DictSearch = {
|
|||||||
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
|
/** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
|
||||||
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
|
* findActiveAt(at) возвращает active version на этот момент вместо now(). */
|
||||||
at?: string
|
at?: string
|
||||||
|
/** View mode: 'records' (default — table + filters) | 'hub' (relations graph
|
||||||
|
* с current dict в центре + neighbors на боках). См. DictionaryHubView. */
|
||||||
|
view?: 'records' | 'hub'
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
|
const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
|
||||||
@@ -78,6 +82,7 @@ const validateSearch = (raw: Record<string, unknown>): DictSearch => {
|
|||||||
if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
|
if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
|
||||||
out.at = raw.at
|
out.at = raw.at
|
||||||
}
|
}
|
||||||
|
if (raw.view === 'hub' || raw.view === 'records') out.view = raw.view
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,10 +519,49 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Phase 1 dict-relationships-v2: schema-level reverse FK card.
|
{/* View toggle: Записи (default table-based view) | Связи (Hub view с
|
||||||
Hide-on-empty — большинство справочников без incoming refs не
|
neighbors на боках). Per design handoff B. */}
|
||||||
увидят этот блок. */}
|
{detailQuery.data && (
|
||||||
<DictionaryDependentsPanel dictionaryName={name} />
|
<div role="group" aria-label="View mode" className="flex border border-regolith rounded-sm overflow-hidden w-fit">
|
||||||
|
{(['records', 'hub'] as const).map((mode) => {
|
||||||
|
const active = (urlSearch.view ?? 'records') === mode
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void navigate({
|
||||||
|
search: (prev) => ({
|
||||||
|
...prev,
|
||||||
|
view: mode === 'records' ? undefined : mode,
|
||||||
|
}),
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`px-3 py-1 text-2xs uppercase tracking-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
|
||||||
|
active
|
||||||
|
? 'bg-orbit/40 text-ultramarain'
|
||||||
|
: 'hover:bg-orbit/20 text-carbon'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`dict.view.${mode}`, {
|
||||||
|
defaultValue: mode === 'records' ? 'Записи' : 'Связи',
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{urlSearch.view === 'hub' && detailQuery.data ? (
|
||||||
|
<DictionaryHubView detail={detailQuery.data} />
|
||||||
|
) : (
|
||||||
|
/* Phase 1 dict-relationships-v2: schema-level reverse FK card.
|
||||||
|
Hide-on-empty — большинство справочников без incoming refs не
|
||||||
|
увидят этот блок. */
|
||||||
|
<DictionaryDependentsPanel dictionaryName={name} />
|
||||||
|
)}
|
||||||
|
|
||||||
{aoi && (
|
{aoi && (
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">
|
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user