fix(admin-ui): reviews drawer — dict link, live FK, JSON syntax+diff

Три проблемы по reviews-drawer (по фидбеку: «Открыть ломается / Согласование
не показывает справочник / не показывает live / JSON не подсвечен»):

1. useLiveRecord(draft.dictionaryId, ...) → useLiveRecord(dictName, ...).
   Endpoint /api/v1/dictionaries/{name}/records/{bk} ожидает name, не UUID;
   до фикса валился с 404 «Dictionary not found: <uuid>» → live-блок был
   пустой и показывался "noLive" даже для UPDATE/CLOSE drafts.

2. Header drawer'а теперь рендерит "<dictName> / <businessKey>" вместо
   просто businessKey. dictName — link на /dictionaries/$name. Fallback
   на UUID-prefix без link'а если dict не виден (scope-hide).

3. JsonView (новый компонент, ~120 строк, без deps) заменяет голые <pre>
   JSON.stringify(...):
   - syntax highlight: ключи (aurora), строки (pink), числа (warn),
     true/false/null (accent), пунктуация (mute)
   - diff highlight: top-level ключи из shallowDiffSummary получают
     фон+border (зелёный added / красный removed / жёлтый changed).
     В live-колонке — removed+changed, в proposed — added+changed.

Также добавлен error-state для liveQ (раньше 404 от useLiveRecord на UUID
молча превращался в "noLive"; теперь explicit "не удалось загрузить").
This commit is contained in:
Zimin A.N.
2026-05-25 14:55:51 +03:00
parent 126b797212
commit ac4f554e34
2 changed files with 217 additions and 34 deletions
@@ -0,0 +1,134 @@
import { useMemo, type ReactNode } from 'react'
/**
* Лёгкий JSON viewer с token-based syntax highlight + diff-подсветкой
* top-level ключей. Никаких внешних зависимостей: regex-проход по
* `JSON.stringify(..., null, 2)`.
*
* <p>Syntax: ключи (aurora), строки (pink), числа (warn), true/false/null
* (accent), пунктуация (mute).
*
* <p>Diff (опционально): передай diff — массив { kind, keys }. Строки
* JSON где key в added/removed/changed получают bg-цвет (зелёный /
* красный / жёлтый). Подсветка — top-level only (для глубокого diff'а
* есть DraftDiffSummary).
*/
export type JsonDiff = {
added?: Set<string> | readonly string[]
removed?: Set<string> | readonly string[]
changed?: Set<string> | readonly string[]
}
type JsonViewProps = {
value: unknown
diff?: JsonDiff
className?: string
}
const TOKEN_RE =
/("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)|([{}[\],])/g
const toSet = (v: Set<string> | readonly string[] | undefined): Set<string> =>
v instanceof Set ? v : new Set(v ?? [])
const tokenize = (line: string): ReactNode[] => {
const out: ReactNode[] = []
let last = 0
let i = 0
for (const m of line.matchAll(TOKEN_RE)) {
const idx = m.index ?? 0
if (idx > last) out.push(line.slice(last, idx))
const [whole, str, colon, num, lit, punct] = m
if (str && colon) {
out.push(
<span key={`k${i}`} className="text-aurora">
{str}
</span>,
)
out.push(
<span key={`c${i}`} className="text-mute">
{colon}
</span>,
)
} else if (str) {
out.push(
<span key={`s${i}`} className="text-pink">
{str}
</span>,
)
} else if (num) {
out.push(
<span key={`n${i}`} className="text-warn">
{num}
</span>,
)
} else if (lit) {
out.push(
<span key={`l${i}`} className="text-accent">
{lit}
</span>,
)
} else if (punct) {
out.push(
<span key={`p${i}`} className="text-mute">
{punct}
</span>,
)
} else {
out.push(whole)
}
last = idx + whole.length
i++
}
if (last < line.length) out.push(line.slice(last))
return out
}
const LINE_KEY_RE = /^ {2}"((?:\\.|[^"\\])*)":/
const lineDiffClass = (
line: string,
added: Set<string>,
removed: Set<string>,
changed: Set<string>,
): string => {
const m = LINE_KEY_RE.exec(line)
if (!m) return ''
const k = m[1]
if (added.has(k)) return 'bg-aurora/15 -mx-2 px-2 border-l-2 border-aurora'
if (removed.has(k)) return 'bg-danger/15 -mx-2 px-2 border-l-2 border-danger'
if (changed.has(k)) return 'bg-warn/15 -mx-2 px-2 border-l-2 border-warn'
return ''
}
export const JsonView = ({ value, diff, className }: JsonViewProps) => {
const added = useMemo(() => toSet(diff?.added), [diff?.added])
const removed = useMemo(() => toSet(diff?.removed), [diff?.removed])
const changed = useMemo(() => toSet(diff?.changed), [diff?.changed])
const lines = useMemo(() => {
try {
return JSON.stringify(value, null, 2).split('\n')
} catch {
return [String(value)]
}
}, [value])
return (
<pre
className={
'text-mono text-cell bg-line/20 rounded p-2 overflow-x-auto whitespace-pre ' +
(className ?? '')
}
>
{lines.map((line, i) => {
const dc = lineDiffClass(line, added, removed, changed)
return (
<div key={i} className={dc}>
{tokenize(line)}
</div>
)
})}
</pre>
)
}
+83 -34
View File
@@ -45,6 +45,7 @@ import {
classifyRecordStatus, classifyRecordStatus,
classifySchemaStatus, classifySchemaStatus,
} from '@/components/reviews/MyDraftsPanel' } from '@/components/reviews/MyDraftsPanel'
import { JsonView } from '@/components/JsonView'
export const Route = createFileRoute('/reviews')({ export const Route = createFileRoute('/reviews')({
component: ReviewsPage, component: ReviewsPage,
@@ -638,12 +639,21 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
const currentSub = auth.user?.profile?.sub const currentSub = auth.user?.profile?.sub
const draftQ = useDraft(draftId) const draftQ = useDraft(draftId)
const draft = draftQ.data const draft = draftQ.data
// DraftResponse несёт только dictionaryId (UUID); /api/v1/dictionaries/{name}/...
// ожидает имя словаря. Резолвим через useDictionaries (TanStack кеш).
// До этого useLiveRecord(draft.dictionaryId, ...) валился с 404
// "Dictionary not found: <uuid>" → live-блок drawer'а оставался пустым.
const dictionariesQ = useDictionaries()
const dictName = useMemo(
() => dictionariesQ.data?.find((d) => d.id === draft?.dictionaryId)?.name,
[dictionariesQ.data, draft?.dictionaryId],
)
// CREATE-черновик не имеет live-записи по определению — не пробуем её // CREATE-черновик не имеет live-записи по определению — не пробуем её
// загружать (GET /dictionaries/{id}/records/{key} → 404, красный шум в // загружать (GET /dictionaries/{name}/records/{key} → 404, красный шум в
// консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше. // консоли на каждый CREATE-draft). Для UPDATE/CLOSE — грузим как раньше.
const isCreateDraft = draft?.operation === 'CREATE' const isCreateDraft = draft?.operation === 'CREATE'
const liveQ = useLiveRecord( const liveQ = useLiveRecord(
draft && !isCreateDraft ? draft.dictionaryId : undefined, draft && !isCreateDraft ? dictName : undefined,
draft && !isCreateDraft ? draft.businessKey : undefined, draft && !isCreateDraft ? draft.businessKey : undefined,
) )
const approveMut = useApproveDraft() const approveMut = useApproveDraft()
@@ -773,6 +783,20 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
<div className="space-y-1 text-cell"> <div className="space-y-1 text-cell">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge> <Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
{dictName ? (
<Link
to="/dictionaries/$name"
params={{ name: dictName }}
className="text-mono text-accent hover:underline"
>
{dictName}
</Link>
) : (
<span className="text-mono text-mute" title={draft.dictionaryId}>
{draft.dictionaryId.slice(0, 8)}
</span>
)}
<span className="text-mute">/</span>
<span className="font-mono">{draft.businessKey}</span> <span className="font-mono">{draft.businessKey}</span>
<DrawerStatusBadge status={draft.status} /> <DrawerStatusBadge status={draft.status} />
</div> </div>
@@ -832,38 +856,63 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
operation={draft.operation} operation={draft.operation}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> {(() => {
<div> // Считаем diff один раз для обеих колонок: changed/added/removed
<p className="text-cap text-mute mb-1"> // keys (top-level) подсветим через JsonView в обоих side-by-side
{t('reviews.drawer.live')} // блоках — reviewer видит точку изменения визуально, не сравнивая
</p> // глазами длинные JSON-объекты.
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />} const liveData =
{(liveQ.data === null || isCreateDraft) && ( (liveQ.data?.data as Record<string, unknown> | null | undefined) ?? null
<p className="text-cell text-mute italic"> const proposedData =
{t('reviews.drawer.noLive')} (draft.data as Record<string, unknown> | null | undefined) ?? null
</p> const diff =
)} draft.operation === 'CLOSE'
{liveQ.data && ( ? null
<pre className="text-mono bg-line/30 rounded p-2 overflow-x-auto whitespace-pre-wrap"> : shallowDiffSummary(liveData, proposedData)
{JSON.stringify(liveQ.data.data, null, 2)} const liveDiff = diff
</pre> ? { removed: diff.removed, changed: diff.changed }
)} : undefined
</div> const proposedDiff = diff
<div> ? { added: diff.added, changed: diff.changed }
<p className="text-cap text-mute mb-1"> : undefined
{t('reviews.drawer.proposed')} return (
</p> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{draft.operation === 'CLOSE' ? ( <div>
<p className="text-cell text-aurora italic"> <p className="text-cap text-mute mb-1">
{t('reviews.drawer.closeNote')} {t('reviews.drawer.live')}
</p> </p>
) : ( {liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
<pre className="text-mono bg-accent/4 rounded p-2 overflow-x-auto whitespace-pre-wrap"> {liveQ.error && (
{JSON.stringify(draft.data, null, 2)} <p className="text-cell text-danger italic">
</pre> {t('reviews.drawer.liveError', {
)} defaultValue: 'Не удалось загрузить текущую запись',
</div> })}
</div> </p>
)}
{!liveQ.isLoading && !liveQ.error && (liveQ.data === null || isCreateDraft) && (
<p className="text-cell text-mute italic">
{t('reviews.drawer.noLive')}
</p>
)}
{liveQ.data && (
<JsonView value={liveQ.data.data} diff={liveDiff} />
)}
</div>
<div>
<p className="text-cap text-mute mb-1">
{t('reviews.drawer.proposed')}
</p>
{draft.operation === 'CLOSE' ? (
<p className="text-cell text-aurora italic">
{t('reviews.drawer.closeNote')}
</p>
) : (
<JsonView value={draft.data} diff={proposedDiff} />
)}
</div>
</div>
)
})()}
{(approveMut.error || rejectMut.error || withdrawMut.error) && (() => { {(approveMut.error || rejectMut.error || withdrawMut.error) && (() => {
const err = approveMut.error || rejectMut.error || withdrawMut.error const err = approveMut.error || rejectMut.error || withdrawMut.error