feat(admin-ui): Phase 1 lineage UI — "Used by" + record refs cards
dict-relationships-v2 epic, Phase 1 admin UI. Backend endpoints поднимаются
коммитом 46a0a9c, тут — UI surface'ит их.
Что добавлено:
- `DictionaryDependentsPanel` — schema-level "На этот словарь ссылаются:"
panel на странице справочника. Chip per (sourceDict.field → targetField,
active records count, onClose mode badge: block/warn/cascade).
Hide-on-empty: большинство справочников без incoming refs panel не видят.
- `RecordDependentsPanel` — record-level "На эту запись ссылаются:" section
внутри `RecordHistoryDrawer`. Per-source summary chips + paginated items
list (size=20, prev/next). Hide-on-empty.
Types в `client.ts`: `OnCloseAction`, `SchemaDependent`, `RecordDependent`,
`PerSourceSummary`, `RecordDependentsPage`.
Queries в `queries.ts`:
- `useDictionaryDependents(name)` — staleTime 5min (schemas slow-moving),
404/403 → empty.
- `useRecordDependents(dict, key, page, size)` — disabled пока key undefined.
Mount points:
- `routes/dictionaries.$name.tsx`: `<DictionaryDependentsPanel>` после
PageHeader, перед AOI banner.
- `components/record/RecordHistoryDrawer.tsx`: `<RecordDependentsPanel>`
выше existing version timeline.
i18n RU/EN:
- `lineage.usedBy.{title,count,activeCount,openSourceDict}`
- `lineage.refs.{title,total,field,created,openSourceRecord}`
- `lineage.onClose.{BLOCK,WARN,CASCADE}` (показ как лейблы)
- `pagination.{prev,next,pageOf}` — общие keys.
Plurals: `_one/_few/_many/_other` для RU (правильная грамматика count'ов).
Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS (4.04s).
- pnpm build: clean (warnings о chunk size — pre-existing для @nstart/ui).
Бекенд endpoints живут на:
- GET /api/v1/dictionaries/{name}/dependents
- GET /api/v1/records/{dict}/{key}/dependents?page&size
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Badge, Panel } from '@nstart/ui'
|
||||
import { ArrowRightIcon } from '@phosphor-icons/react'
|
||||
import { useDictionaryDependents } from '@/api/queries'
|
||||
import type { OnCloseAction } from '@/api/client'
|
||||
|
||||
type Props = {
|
||||
dictionaryName: string
|
||||
}
|
||||
|
||||
const onCloseVariant = (a: OnCloseAction): 'neutral' | 'warning' | 'error' => {
|
||||
if (a === 'CASCADE') return 'error'
|
||||
if (a === 'WARN') return 'warning'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level reverse FK card — "На этот словарь ссылаются:".
|
||||
*
|
||||
* <p>Показывает chip per (sourceDict.field → targetField, count, onClose mode).
|
||||
* Скрывает себя если 0 dependents (не loading и не error) — большинство
|
||||
* справочников не имеют incoming FK, не нужно занимать место.
|
||||
*
|
||||
* <p>Phase 1 (read-only). Phase 3 добавит preview cascade chain в close
|
||||
* confirmation dialog.
|
||||
*/
|
||||
export const DictionaryDependentsPanel = ({ dictionaryName }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading, error } = useDictionaryDependents(dictionaryName)
|
||||
|
||||
// Hide entirely в loading / error / empty — это "secondary" info card,
|
||||
// не должен конкурировать с records table за внимание.
|
||||
if (isLoading || error) return null
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-primary text-carbon">
|
||||
{t('lineage.usedBy.title')}
|
||||
</h3>
|
||||
<span className="text-2xs text-carbon/60">
|
||||
{t('lineage.usedBy.count', { count: data.length })}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{data.map((d) => (
|
||||
<li
|
||||
key={`${d.sourceDict}.${d.sourceField}`}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-sm border border-regolith bg-white text-2xs"
|
||||
>
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name: d.sourceDict }}
|
||||
className="text-ultramarain hover:underline font-mono"
|
||||
aria-label={t('lineage.usedBy.openSourceDict', {
|
||||
dict: d.sourceDisplayName ?? d.sourceDict,
|
||||
})}
|
||||
>
|
||||
{d.sourceDisplayName ?? d.sourceDict}
|
||||
</Link>
|
||||
<span className="font-mono text-carbon/70">.{d.sourceField}</span>
|
||||
<ArrowRightIcon weight="bold" size={12} className="text-carbon/40" />
|
||||
<span className="font-mono text-carbon/70">{d.targetField}</span>
|
||||
{d.activeRecordsInSourceDict > 0 && (
|
||||
<Badge variant="neutral">
|
||||
{t('lineage.usedBy.activeCount', {
|
||||
count: d.activeRecordsInSourceDict,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={onCloseVariant(d.onClose)}>
|
||||
{t(`lineage.onClose.${d.onClose}`)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user