From b0e19951b30773545fdaf296e398ffe3ec4b5cae Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 20:19:27 +0300 Subject: [PATCH] feat(editor): restore 3-col HubView + hide AOI on non-geo dicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user feedback: - "hub view" — restore старый 3-col layout с central focused dict card + outgoing FK rail слева + refBy rail справа. Был simpler 2-col split, но user previously сказал "Связи и вкладка связи была удобнее" (старый hub более информативный + центральная карта показывает context). Revert к версии 5007e0f (pre-2-col-redesign). - "aoi там где нет полей гео лишний" — InfoPanel AOI button теперь скрыт если schema dict не имеет geo properties. Heuristic: property name matches /geom|location|bbox|polygon|coordinates|geo/i OR format=geojson|wkt. Edge case: если active aoi уже set → button показывается чтобы clear. --- .../components/lineage/DictionaryHubView.tsx | 353 +++++++++--------- .../src/routes/dictionaries.$name.tsx | 21 +- 2 files changed, 198 insertions(+), 176 deletions(-) diff --git a/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx b/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx index 1656ab7..3478965 100644 --- a/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx +++ b/ordinis-admin-ui/src/components/lineage/DictionaryHubView.tsx @@ -1,63 +1,99 @@ /** - * DictionaryHubView — Связи tab per redesign/screens/04-editor-links.png. + * Hub view (View B из design handoff) — focused dictionary в центре + соседи + * по обе стороны. * - *

2-column split: - *

+ * Layout: + * ┌──────────────┬─────────────────┬──────────────┐ + * │ → ссылается │ │ ← N использ. │ + * │ (outgoing) │ (focused) │ (refBy) │ + * └──────────────┴─────────────────┴──────────────┘ * - *

Replaces старый 3-col hub view с central dict card — center больше не - * нужен (всё info уже в InfoPanel слева). + * 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 { useDictionaries, useDictionaryDependents } from '@/api/queries' -import type { - DictionaryDefinition, - DictionaryDetail, - JsonSchema, - SchemaDependent, -} from '@/api/client' -import { cn } from '@/lib/utils' +import { Badge } from '@/ui' +import { useDictionaryDependents, useDictionaries } from '@/api/queries' +import type { DictionaryDetail, DictionaryDefinition, JsonSchema } from '@/api/client' -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 +const SCOPE_STRIP: Record = { + PUBLIC: 'bg-emerald-500', + INTERNAL: 'bg-amber-500', + RESTRICTED: 'bg-rose-500', } -/** Walk schema properties → build outgoing FK rows. */ -const extractOutgoingFk = (schema: JsonSchema | undefined): OutgoingFkRow[] => { +/** + * Walk schemaJson properties — собираем targets из x-references. + * Format: "targetDict.targetField" → возвращаем уникальные targetDict names. + */ +const extractOutgoingFk = (schema: JsonSchema | undefined): string[] => { if (!schema?.properties) return [] - const required = new Set(schema.required ?? []) - const rows: OutgoingFkRow[] = [] - for (const [field, prop] of Object.entries(schema.properties)) { + const targets = new Set() + for (const prop of Object.values(schema.properties)) { const ref = prop['x-references'] if (typeof ref === 'string' && ref.length > 0) { - const [target, targetField] = ref.split('.') - if (target) { - rows.push({ - target, - sourceField: field, - targetField: targetField ?? 'id', - required: required.has(field), - }) - } + const [target] = ref.split('.') + if (target) targets.add(target) } } - return rows.sort((a, b) => a.target.localeCompare(b.target)) + return Array.from(targets).sort() +} + +const NeighborCard = ({ + dict, + side, +}: { + dict: DictionaryDefinition + side: 'left' | 'right' +}) => ( + +

+
+
+ {dict.name} +
+ +) + +const SectionLabel = ({ + side, + count, +}: { + side: 'left' | 'right' + count: number +}) => { + const { t } = useTranslation() + return ( +
+ {side === 'left' + ? t('hub.outgoing', { count, defaultValue: '→ ссылается ({{count}})' }) + : t('hub.incoming', { count, defaultValue: '← {{count}} использ.' })} +
+ ) } export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) { @@ -65,146 +101,113 @@ export function DictionaryHubView({ detail }: { detail: DictionaryDetail }) { const { data: allDicts } = useDictionaries() const { data: refByRaw } = useDictionaryDependents(detail.name) - 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() - 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], + // Outgoing FK targets — names derived из schemaJson. + const outgoingNames = useMemo( + () => extractOutgoingFk(detail.schemaJson), + [detail.schemaJson], ) - 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() + 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 ( -
- {/* Outgoing → */} -
-
-
- → {t('hub.outgoing', { defaultValue: 'ссылается на' })} - ({outgoing.length}) -
-
- {outgoing.length === 0 ? ( -
- {t('hub.empty.outgoing', { - defaultValue: 'Нет исходящих FK', - })} +
+ {/* Left rail: outgoing FK */} +
+ + {outgoingDicts.length === 0 ? ( +
+ {t('hub.noOutgoing', { defaultValue: 'нет исходящих ссылок' })}
) : ( -
    - {outgoing.map((row) => { - const target = dictByName.get(row.target) - return ( -
  • - - - {row.target} - - {target?.recordCount !== undefined && ( - - {target.recordCount} - - )} - - {row.required ? 'required' : 'optional'} - - - {row.sourceField} - - -
  • - ) - })} -
+
+ {outgoingDicts.map((d) => ( + + ))} +
)} -
+
- {/* Incoming ← */} -
-
-
- ← {t('hub.incoming', { defaultValue: 'ссылаются на нас' })} - ({incoming.length}) + {/* Center: focused dict (large card) */} +
+
+
+ {detail.scope} + {detail.approvalRequired && ( + + {t('dict.list.approval', { defaultValue: 'approval' })} + + )} + + v{detail.schemaVersion} +
-
- {incoming.length === 0 ? ( -
- {t('hub.empty.incoming', { - defaultValue: 'Никто не ссылается', - })} +

+ {detail.displayName ?? detail.name} +

+
+ {detail.name} +
+ {detail.description && ( +

+ {detail.description} +

+ )} +
+ {detail.bundle} + {detail.supportedLocales.join(', ')} + {typeof detail.recordCount === 'number' && ( + + {t('dict.list.recordCount', { count: detail.recordCount })} + + )} +
+
+
+ + {/* Right rail: refBy */} +
+ + {incomingDicts.length === 0 ? ( +
+ {t('hub.noIncoming', { defaultValue: 'никто не ссылается' })}
) : ( - <> -
    - {incoming.map((dep) => ( -
  • - - - {dep.sourceDict} - - {dep.activeRecordsInSourceDict > 0 && ( - - {dep.activeRecordsInSourceDict} - - )} - {dep.onClose === 'BLOCK' && ( - - {t('hub.critical', { defaultValue: 'критично' })} - - )} - {dep.onClose === 'CASCADE' && ( - - CASCADE - - )} - - .{dep.sourceField} - - -
  • - ))} -
- {/* Footer warning если в this dict много incoming refs — удаление - * записи будет сложным (cascade chain или blockers). */} - {totalIncomingDeps > HEAVY_THRESHOLD && ( -
- {t('hub.heavyDeps', { - count: totalIncomingDeps, - defaultValue: `Удаление записи в этом справочнике сложно — ${totalIncomingDeps} ссылок`, - })} -
- )} - +
+ {incomingDicts.map((d) => ( + + ))} +
)} - -
+ + ) } diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index e4e6cae..f07627d 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -577,6 +577,21 @@ function DictionaryDetail() { const totalRecords = recordsResult.data?.length ?? 0 const scope = detailQuery.data?.scope + // Geo capability detection — для скрытия AOI button на non-geo dicts. + // Эвристика по schema properties: property name matches geom/location/ + // bbox/polygon/coordinates, OR format=geojson. Не идеально (false negatives + // если backend хранит geometry separately в записи без schema mention), + // но в practice все geo-dicts имеют geometry property в schemaJson. + const hasGeoRecords = useMemo(() => { + const props = detailQuery.data?.schemaJson?.properties + if (!props) return false + for (const [key, p] of Object.entries(props)) { + if (/geom|location|bbox|polygon|coordinates|geo/i.test(key)) return true + if (p.format === 'geojson' || p.format === 'wkt') return true + } + return false + }, [detailQuery.data?.schemaJson]) + return (
{scope && ( @@ -601,7 +616,11 @@ function DictionaryDetail() { actions={{ onTimeTravel: () => setTimeTravelOpen((v) => !v), timeTravelActive: Boolean(timeTravelAt), - onAoi: () => setAoiOpen(true), + // AOI button показываем только если у этого dict есть geo data — + // хотя бы одна record имеет geometryWkt. На non-geo dicts кнопка + // лишняя. Per user feedback: "aoi там где нет полей гео лишний". + // Edge case: если active aoi уже set → показываем чтобы юзер мог clear. + onAoi: hasGeoRecords || aoi ? () => setAoiOpen(true) : undefined, aoiActive: Boolean(aoi), // Открываем ExportModal вместо immediate triggera — пользователь // выбирает формат/scope/columns/encoding/delimiter.