Files
mdm-ordinis/ordinis-admin-ui/src/components/lineage/CascadeConfirmDialog.tsx
T
Zimin A.N. 5a2220d779 feat(editor): ExportModal + TimeTravelModal + InfoPanel merge + fixes
UI redesign matching prototype (Ordinis (3) dump):
- ExportModal: format (CSV/JSON/Excel/SQL) + scope (all/filtered/selected) +
  column toggles + encoding/delimiter + preview filename per redesign line
  1059-1092. CSV uses backend mutation, JSON client-side from filtered rows,
  Excel/SQL disabled (backend pending).
- TimeTravelModal: full-page compare СЕЙЧАС vs ТОЧКА ВО ВРЕМЕНИ + quick
  presets (сейчас/неделя/месяц/год/при создании) + step toggle версия/день
  + slider + tabs (Что изменилось / Записи на момент / Структура и поля) +
  "Открыть как readonly" CTA. Replaces inline TimeTravelPicker.
- InfoPanel ← используют merge: rich rows с field path + active records
  count + onClose policy chip (BLOCK/WARN/CASCADE). Inline
  DictionaryDependentsPanel above records table removed (one source of truth).
- Sidebar logo: match redesign/ui-kit.html .brand SVG (outer ring + inner
  faded ring + orbit dot offset right cx=22.5). ORDINIS Tektur tracking 0.22em
  + MDM smaller mute 0.32em.
- Bg neutralized: #fbf8ee Earth cream → #faf9f5 warm neutral default
  (per user preference, Earth cream still described in comment как opt-in).
- WorkflowBanner: Case 1 Live (approvalRequired=false) returns null —
  permanent "Опубликовано" banner = noise, status already visible в
  InfoPanel scope/version/updatedAt. Banner показывается только когда
  есть actionable state.

Critical bug fixes:
- fix(CascadeConfirmDialog): infinite render loop "Maximum update depth
  exceeded". useEffect had cascadeMut (TanStack Query mutation wrapper) в
  deps — wrapper identity changes every render → effect fires → reset() →
  store update → render → loop. Extract stable `reset` reference перед
  useEffect. Симптомы у user: создание/редактирование записи не работали.
- fix(semantic-release): add missing peer dep
  conventional-changelog-conventionalcommits (9.3.1). semantic-release 22+
  больше не bundles preset. Без неё release job падал с
  "Cannot find module 'conventional-changelog-conventionalcommits'".
2026-05-11 19:01:57 +03:00

295 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, Badge, Button, LoadingBlock, Modal, TextInput } from '@/ui'
import { WarningIcon, XCircleIcon } from '@phosphor-icons/react'
import { useCascadePreview } from '@/api/queries'
import { useCascadeCloseRecord } from '@/api/mutations'
import type { CascadeEntry, OnCloseAction } from '@/api/client'
/**
* Cascade close confirmation dialog — Phase 3 dict-relationships-v2.
*
* <p>Поток:
* <ol>
* <li>Открывается из existing close flow когда DELETE возвращает 409
* (x_references_blocked_by_dependents или x_references_cascade_required).</li>
* <li>Загружает preview через {@code GET /cascade-preview}.</li>
* <li>Если есть BLOCKERS — показывает их + объясняет что resolve first;
* confirm button disabled.</li>
* <li>Если есть только CASCADE/WARN — показывает per-source breakdown +
* sample 3 records per source, plus "Type CONFIRM" guard если N {@literal >} 50.</li>
* <li>Confirm → {@code POST /cascade-close?confirmed=true} → success message.</li>
* <li>Cancel → close dialog без изменений.</li>
* </ol>
*
* <p>UX guards:
* <ul>
* <li>"Type CONFIRM" gate когда total &gt; 50 (защита от accidental cascade).</li>
* <li>Reason field — optional; пустой = backend audit fields пустые.</li>
* </ul>
*/
type Props = {
open: boolean
onClose: () => void
/** Called after successful cascade close. */
onSuccess: () => void
dictionaryName: string
businessKey: string | undefined
}
const onCloseVariant = (
a: OnCloseAction,
): 'neutral' | 'warning' | 'error' => {
if (a === 'CASCADE') return 'error'
if (a === 'WARN') return 'warning'
return 'neutral'
}
const SAMPLE_PER_SOURCE = 3
const CONFIRM_GATE_THRESHOLD = 50
export const CascadeConfirmDialog = ({
open,
onClose,
onSuccess,
dictionaryName,
businessKey,
}: Props) => {
const { t } = useTranslation()
const preview = useCascadePreview(dictionaryName, open ? businessKey : undefined)
const cascadeMut = useCascadeCloseRecord(dictionaryName)
// TanStack Query гарантирует что `reset` / `mutate` стабильны между
// renders, а сам wrapper object — нет. Экстрактим reset чтобы не
// включать unstable mutation object в useEffect deps (infinite loop:
// mutationObject identity → effect fires → reset → store update → render).
const cascadeReset = cascadeMut.reset
const [reason, setReason] = useState('')
const [confirmText, setConfirmText] = useState('')
// Reset state каждый раз когда dialog открывается с новой target.
useEffect(() => {
if (!open) {
setReason('')
setConfirmText('')
cascadeReset()
}
}, [open, businessKey, cascadeReset])
const plan = preview.data
const hasBlockers = (plan?.blockers.length ?? 0) > 0
const totalToClose = 1 + (plan?.cascade.length ?? 0)
const requiresGate = totalToClose > CONFIRM_GATE_THRESHOLD
const gatePassed = !requiresGate || confirmText.trim().toUpperCase() === 'CONFIRM'
// Group cascade entries by source dict для compact display.
const cascadeBySource = useMemo(() => {
const map = new Map<string, CascadeEntry[]>()
for (const e of plan?.cascade ?? []) {
const key = `${e.sourceDict}.${e.sourceField}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(e)
}
return Array.from(map.entries())
}, [plan?.cascade])
const handleConfirm = () => {
if (!businessKey) return
cascadeMut.mutate(
{ businessKey, reason: reason.trim() || undefined, confirmed: true },
{
onSuccess: () => {
onSuccess()
onClose()
},
},
)
}
return (
<Modal
isOpen={open}
onClose={onClose}
title={t('cascade.title')}
maxWidth="max-w-2xl"
>
<div className="space-y-4">
{preview.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{preview.error && (
<Alert variant="error" title={t('error.failed')}>
{String(preview.error)}
</Alert>
)}
{plan && (
<>
{/* Header summary */}
<p className="text-body text-ink">
{hasBlockers
? t('cascade.summary.blocked', {
blockers: plan.blockers.length,
target: businessKey,
})
: t('cascade.summary.willClose', {
total: totalToClose,
target: businessKey,
})}
</p>
{/* BLOCKERS section — non-resolvable. */}
{plan.blockers.length > 0 && (
<Alert
variant="error"
title={t('cascade.blockers.title', { count: plan.blockers.length })}
>
<ul className="space-y-1.5 mt-2 text-cell">
{plan.blockers.slice(0, 5).map((b) => (
<li key={b.recordId} className="flex items-center gap-2">
<XCircleIcon weight="bold" size={12} className="text-aurora shrink-0" />
<span className="font-mono">
{b.sourceDict}/{b.businessKey}
</span>
<span className="text-mute">.{b.sourceField}</span>
</li>
))}
{plan.blockers.length > 5 && (
<li className="text-mute">
{t('cascade.blockers.more', { count: plan.blockers.length - 5 })}
</li>
)}
</ul>
<p className="mt-2 text-cell text-ink-2">
{t('cascade.blockers.resolveFirst')}
</p>
</Alert>
)}
{/* CASCADE section — preview per-source. */}
{!hasBlockers && plan.cascade.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<WarningIcon weight="bold" size={14} className="text-horizon" />
<h4 className="text-body font-sans">
{t('cascade.cascade.title', { count: plan.cascade.length })}
</h4>
</div>
<ul className="space-y-2">
{cascadeBySource.map(([key, entries]) => {
const first = entries[0]
const sample = entries.slice(0, SAMPLE_PER_SOURCE)
const overflow = entries.length - sample.length
return (
<li
key={key}
className="border border-line rounded-sm px-3 py-2 text-cell"
>
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-accent">
{first.sourceDisplayName ?? first.sourceDict}
</span>
<span className="font-mono text-ink-2">
.{first.sourceField}
</span>
<Badge variant={onCloseVariant(first.onClose)}>
{t(`lineage.onClose.${first.onClose}`)}
</Badge>
<Badge variant="neutral">{entries.length}</Badge>
</div>
<ul className="text-cell text-ink-2 ml-1 space-y-0.5">
{sample.map((e) => (
<li key={e.recordId} className="font-mono">
· {e.businessKey}
</li>
))}
{overflow > 0 && (
<li className="text-mute">
{t('cascade.cascade.more', { count: overflow })}
</li>
)}
</ul>
</li>
)
})}
</ul>
</section>
)}
{/* WARNINGS section — proceed but emit warnings. */}
{!hasBlockers && plan.warnings.length > 0 && (
<Alert
variant="warning"
title={t('cascade.warnings.title', { count: plan.warnings.length })}
>
<p className="text-cell">{t('cascade.warnings.note')}</p>
</Alert>
)}
{/* Reason input — only when going to proceed. */}
{!hasBlockers && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
<div>
<label
htmlFor="cascade-reason"
className="text-cap text-ink-2"
>
{t('cascade.reason.label')}
</label>
<TextInput
id="cascade-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('cascade.reason.placeholder')}
/>
</div>
)}
{/* Type CONFIRM gate when N > 50. */}
{!hasBlockers && requiresGate && (
<div>
<label
htmlFor="cascade-confirm"
className="text-cap text-ink-2"
>
{t('cascade.confirmGate.label', { word: 'CONFIRM' })}
</label>
<TextInput
id="cascade-confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="CONFIRM"
/>
</div>
)}
{cascadeMut.error && (
<Alert variant="error" title={t('error.failed')}>
{String(cascadeMut.error)}
</Alert>
)}
</>
)}
{/* Footer actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-line">
<Button
variant="secondary"
onClick={onClose}
disabled={cascadeMut.isPending}
>
{t('cascade.action.cancel')}
</Button>
{!hasBlockers && plan && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
<Button
variant="danger"
onClick={handleConfirm}
disabled={cascadeMut.isPending || !gatePassed}
loading={cascadeMut.isPending}
>
{t('cascade.action.confirm', { count: totalToClose })}
</Button>
)}
</div>
</div>
</Modal>
)
}