Merge branch 'feat/conflict-diff-modal' into 'main'
feat(record): conflict diff modal на 409 save See merge request 2-6/2-6-4/terravault/ordinis!76
This commit is contained in:
@@ -0,0 +1,225 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ArrowsClockwiseIcon, WarningIcon } from '@phosphor-icons/react'
|
||||||
|
import { Dialog, DialogContent } from '@/ui'
|
||||||
|
import { useRecordRaw } from '@/api/queries'
|
||||||
|
import type { CreateRecordRequest } from '@/api/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConflictDiffModal — handoff line 444 ("Save → optimistic update + toast;
|
||||||
|
* conflict → modal with diff").
|
||||||
|
*
|
||||||
|
* <p>Опoшается когда PUT /records/{key} вернул 409 (optimistic-lock fail —
|
||||||
|
* другой пользователь сохранил тот же record между нашим load и submit'ом).
|
||||||
|
*
|
||||||
|
* <p>Сравнивает локальный payload (что юзер пытался сохранить) с серверным
|
||||||
|
* текущим состоянием (refetch через useRecordRaw). Per-field diff list:
|
||||||
|
* показываем только те properties у которых local≠server. Footer actions:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Отменить</b> — close modal, оставить editor open (user сам решит)</li>
|
||||||
|
* <li><b>Загрузить серверное</b> — discard local edits, reload server state</li>
|
||||||
|
* <li><b>Перезаписать сервером</b> — force-apply local payload (retry without
|
||||||
|
* optimistic lock; backend всё ещё применит business rules). UX warning:
|
||||||
|
* это перетрёт чужие правки.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ConflictDiffModalProps = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** Local payload пользователя — что он только что попытался сохранить. */
|
||||||
|
localPayload: CreateRecordRequest | null
|
||||||
|
/** Dict name + business key для refetch server state. */
|
||||||
|
dictionaryName: string
|
||||||
|
businessKey: string | undefined
|
||||||
|
/** "Загрузить серверное" — discard local, refetch & open editor с server data. */
|
||||||
|
onReloadServer: () => void
|
||||||
|
/** "Перезаписать" — force update (re-submit with current data, expecting backend
|
||||||
|
* либо примет либо вернёт 409 ещё раз). */
|
||||||
|
onOverwrite: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConflictDiffModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
localPayload,
|
||||||
|
dictionaryName,
|
||||||
|
businessKey,
|
||||||
|
onReloadServer,
|
||||||
|
onOverwrite,
|
||||||
|
}: ConflictDiffModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const serverQuery = useRecordRaw(dictionaryName, open ? businessKey : undefined)
|
||||||
|
|
||||||
|
// Diff: иди по union ключей local + server, оставь только отличающиеся.
|
||||||
|
const diffRows = useMemo(() => {
|
||||||
|
if (!localPayload || !serverQuery.data) return []
|
||||||
|
const localData = (localPayload.data ?? {}) as Record<string, unknown>
|
||||||
|
const serverData = (serverQuery.data.data ?? {}) as Record<string, unknown>
|
||||||
|
const keys = new Set<string>([
|
||||||
|
...Object.keys(localData),
|
||||||
|
...Object.keys(serverData),
|
||||||
|
])
|
||||||
|
const rows: { field: string; local: unknown; server: unknown }[] = []
|
||||||
|
for (const k of keys) {
|
||||||
|
const lv = localData[k]
|
||||||
|
const sv = serverData[k]
|
||||||
|
if (!valuesEqual(lv, sv)) {
|
||||||
|
rows.push({ field: k, local: lv, server: sv })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => a.field.localeCompare(b.field))
|
||||||
|
}, [localPayload, serverQuery.data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent size="lg" className="!p-0 gap-0 max-h-[85vh]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
|
||||||
|
<div className="size-8 rounded-md bg-warn-bg inline-flex items-center justify-center shrink-0">
|
||||||
|
<WarningIcon weight="fill" size={18} className="text-warn" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-cap text-mute tracking-[0.18em] uppercase">
|
||||||
|
{t('conflict.label', { defaultValue: 'Конфликт сохранения' })}
|
||||||
|
<span className="mx-2 text-line">·</span>
|
||||||
|
<span className="text-ink">{dictionaryName.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-title-md text-ink font-semibold mt-0.5">
|
||||||
|
{t('conflict.title', {
|
||||||
|
defaultValue: 'Запись изменена другим пользователем',
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
{businessKey && (
|
||||||
|
<div className="text-mono text-cell text-mute mt-0.5">{businessKey}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diff body */}
|
||||||
|
<div className="px-6 py-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
{serverQuery.isLoading && (
|
||||||
|
<div className="text-mute text-body py-8 text-center">
|
||||||
|
{t('conflict.loading', {
|
||||||
|
defaultValue: 'Загружаем актуальное состояние сервера…',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{serverQuery.error && (
|
||||||
|
<div className="text-pink text-body py-4">
|
||||||
|
{t('conflict.loadError', {
|
||||||
|
defaultValue: 'Не удалось загрузить серверное состояние',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!serverQuery.isLoading && !serverQuery.error && diffRows.length === 0 && (
|
||||||
|
<div className="text-mute text-body py-8 text-center">
|
||||||
|
{t('conflict.noFieldDiff', {
|
||||||
|
defaultValue:
|
||||||
|
'Поля совпадают. Возможно конфликт на уровне метаданных (validFrom / dataScope).',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{diffRows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr,1fr,1fr] gap-2 mb-2 px-2">
|
||||||
|
<div className="text-cap text-mute tracking-[0.16em] uppercase">
|
||||||
|
{t('conflict.col.field', { defaultValue: 'Поле' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-cap text-mute tracking-[0.16em] uppercase">
|
||||||
|
{t('conflict.col.local', { defaultValue: 'Ваши изменения' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-cap text-mute tracking-[0.16em] uppercase">
|
||||||
|
{t('conflict.col.server', { defaultValue: 'На сервере' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{diffRows.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.field}
|
||||||
|
className="grid grid-cols-[1fr,1fr,1fr] gap-2 px-2 py-2 rounded-md border border-line bg-surface text-body"
|
||||||
|
>
|
||||||
|
<div className="text-mono text-ink truncate" title={row.field}>
|
||||||
|
{row.field}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-cell text-accent break-words bg-accent-bg/30 rounded-sm px-1.5 py-0.5">
|
||||||
|
{formatValue(row.local)}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-cell text-ink-2 break-words bg-surface-2 rounded-sm px-1.5 py-0.5">
|
||||||
|
{formatValue(row.server)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-3 border-t border-line flex items-center justify-between gap-3 flex-wrap mt-auto">
|
||||||
|
<div className="text-cap text-mute tracking-[0.14em] uppercase max-w-md">
|
||||||
|
{t('conflict.disclaimer', {
|
||||||
|
defaultValue:
|
||||||
|
'Перезапись потеряет чужие изменения. Загрузить — потеряет ваши.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 hover:border-line-2 text-body transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.cancel', { defaultValue: 'Отмена' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReloadServer}
|
||||||
|
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 hover:border-ink-2 text-body inline-flex items-center gap-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowsClockwiseIcon weight="regular" size={14} />
|
||||||
|
{t('conflict.reload', { defaultValue: 'Загрузить серверное' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOverwrite}
|
||||||
|
className={cn(
|
||||||
|
'h-9 px-4 rounded-md bg-pink text-on-accent hover:opacity-90 text-body font-semibold transition-opacity',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('conflict.overwrite', { defaultValue: 'Перезаписать' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-equality для JSON-safe values (string/number/bool/null/array/object). */
|
||||||
|
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
if (a === null || b === null) return a === b
|
||||||
|
if (typeof a !== typeof b) return false
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render value as a short readable string. Objects → JSON, undefined → «—». */
|
||||||
|
function formatValue(v: unknown): string {
|
||||||
|
if (v === undefined || v === null || v === '') return '—'
|
||||||
|
if (typeof v === 'string') return v
|
||||||
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(v)
|
||||||
|
return s.length > 120 ? s.slice(0, 117) + '…' : s
|
||||||
|
} catch {
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ 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 { RecordDrawer } from '@/components/record/RecordDrawer'
|
import { RecordDrawer } from '@/components/record/RecordDrawer'
|
||||||
|
import { ConflictDiffModal } from '@/components/record/ConflictDiffModal'
|
||||||
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
|
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
|
||||||
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
|
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
|
||||||
import { ExportModal } from '@/components/editor/ExportModal'
|
import { ExportModal } from '@/components/editor/ExportModal'
|
||||||
@@ -189,6 +190,12 @@ function DictionaryDetail() {
|
|||||||
const [aoiOpen, setAoiOpen] = useState(false)
|
const [aoiOpen, setAoiOpen] = useState(false)
|
||||||
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
||||||
const [exportOpen, setExportOpen] = useState(false)
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
|
/** 409 save conflict: capture local payload + businessKey для diff modal.
|
||||||
|
* Открывается из createMut/updateMut onError когда status===409. */
|
||||||
|
const [conflict, setConflict] = useState<{
|
||||||
|
payload: CreateRecordRequest
|
||||||
|
businessKey: string
|
||||||
|
} | null>(null)
|
||||||
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
|
||||||
* на close, или явным action из record menu. */
|
* на close, или явным action из record menu. */
|
||||||
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
|
||||||
@@ -474,6 +481,26 @@ function DictionaryDetail() {
|
|||||||
...req,
|
...req,
|
||||||
validFrom: req.validFrom && req.validFrom.length > 0 ? req.validFrom : nowIsoLocal(),
|
validFrom: req.validFrom && req.validFrom.length > 0 ? req.validFrom : nowIsoLocal(),
|
||||||
}
|
}
|
||||||
|
// 409 conflict handler — handoff line 444. Detect optimistic-lock
|
||||||
|
// mismatch and open ConflictDiffModal (diff editor's payload vs server
|
||||||
|
// current state). Other 409 codes (cascade, draft_required) обрабатываются
|
||||||
|
// отдельно — здесь только generic conflict ("entity changed concurrently").
|
||||||
|
const handleSaveError = (err: unknown, bk: string) => {
|
||||||
|
const status = (err as { response?: { status?: number } })?.response?.status
|
||||||
|
const code = (err as { response?: { data?: { code?: string } } })
|
||||||
|
?.response?.data?.code
|
||||||
|
if (status !== 409) return
|
||||||
|
// Specific 409 codes use their own dialogs — skip those.
|
||||||
|
if (
|
||||||
|
code === 'x_references_blocked_by_dependents' ||
|
||||||
|
code === 'x_references_cascade_required' ||
|
||||||
|
code === 'draft_required'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Generic 409 = optimistic-lock conflict. Open diff modal.
|
||||||
|
setConflict({ payload: submitReq, businessKey: bk })
|
||||||
|
}
|
||||||
if (edit.kind === 'create') {
|
if (edit.kind === 'create') {
|
||||||
createMut.mutate(
|
createMut.mutate(
|
||||||
{ payload: submitReq, idempotencyKey: recordIdempotencyKey },
|
{ payload: submitReq, idempotencyKey: recordIdempotencyKey },
|
||||||
@@ -482,6 +509,11 @@ function DictionaryDetail() {
|
|||||||
setEdit({ kind: 'closed' })
|
setEdit({ kind: 'closed' })
|
||||||
resetRecordIdempotencyKey()
|
resetRecordIdempotencyKey()
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
// Create rarely conflicts (no prior record to lock), но если backend
|
||||||
|
// вернёт 409 по уникальному ключу — businessKey возьмём из payload.
|
||||||
|
handleSaveError(err, submitReq.businessKey ?? '')
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -498,6 +530,7 @@ function DictionaryDetail() {
|
|||||||
setEdit({ kind: 'closed' })
|
setEdit({ kind: 'closed' })
|
||||||
resetRecordIdempotencyKey()
|
resetRecordIdempotencyKey()
|
||||||
},
|
},
|
||||||
|
onError: (err) => handleSaveError(err, edit.record.businessKey),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1123,6 +1156,58 @@ function DictionaryDetail() {
|
|||||||
initial={aoi?.geojson ?? null}
|
initial={aoi?.geojson ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Conflict diff modal — opens on 409 generic conflict (optimistic lock).
|
||||||
|
* Diff: editor payload vs server current state (refetched). Three actions:
|
||||||
|
* - Отмена: close modal, keep editor (user retry/edit manually)
|
||||||
|
* - Загрузить серверное: discard local, open editor с server snapshot
|
||||||
|
* - Перезаписать: re-submit with current payload (will overwrite if no
|
||||||
|
* new conflict — backend has no force-overwrite endpoint поэтому
|
||||||
|
* просто retry; если опять 409 — modal снова откроется). */}
|
||||||
|
{conflict && (
|
||||||
|
<ConflictDiffModal
|
||||||
|
open
|
||||||
|
onClose={() => setConflict(null)}
|
||||||
|
localPayload={conflict.payload}
|
||||||
|
dictionaryName={name}
|
||||||
|
businessKey={conflict.businessKey}
|
||||||
|
onReloadServer={() => {
|
||||||
|
// Discard local edits, refetch records → пользователь увидит
|
||||||
|
// актуальное состояние. Закрываем editor; user может открыть
|
||||||
|
// запись ещё раз для редактирования с правильной базы.
|
||||||
|
setConflict(null)
|
||||||
|
setEdit({ kind: 'closed' })
|
||||||
|
resetRecordIdempotencyKey()
|
||||||
|
void recordsResult.refetch()
|
||||||
|
}}
|
||||||
|
onOverwrite={() => {
|
||||||
|
// Re-submit с current payload. Backend re-evaluates; если record
|
||||||
|
// изменился ещё раз → новый 409, modal откроется заново.
|
||||||
|
const payload = conflict.payload
|
||||||
|
const bk = conflict.businessKey
|
||||||
|
setConflict(null)
|
||||||
|
if (edit.kind === 'edit') {
|
||||||
|
updateMut.mutate(
|
||||||
|
{ businessKey: bk, payload, idempotencyKey: recordIdempotencyKey },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEdit({ kind: 'closed' })
|
||||||
|
resetRecordIdempotencyKey()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const status = (err as { response?: { status?: number } })
|
||||||
|
?.response?.status
|
||||||
|
if (status === 409) {
|
||||||
|
// Re-open diff modal с свежим server state.
|
||||||
|
setConflict({ payload, businessKey: bk })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Export modal — only mount when open. Same lazy pattern as Time-travel. */}
|
{/* Export modal — only mount when open. Same lazy pattern as Time-travel. */}
|
||||||
{exportOpen && detailQuery.data && (
|
{exportOpen && detailQuery.data && (
|
||||||
<ExportModal
|
<ExportModal
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { useDeferredValue, useMemo, useState } from 'react'
|
import { useDeferredValue, useMemo, useState } from 'react'
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui'
|
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@/ui'
|
||||||
import { PlusIcon } from '@phosphor-icons/react'
|
import { PlusIcon } from '@phosphor-icons/react'
|
||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries'
|
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries'
|
||||||
@@ -167,11 +167,6 @@ function DictionariesPage() {
|
|||||||
return out
|
return out
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const scopeCounts = useMemo(() => {
|
|
||||||
const out: Record<DataScope, number> = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 }
|
|
||||||
if (data) for (const d of data) out[d.scope]++
|
|
||||||
return out
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
|
const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly
|
||||||
const resetFilters = () => navigate({ search: {} })
|
const resetFilters = () => navigate({ search: {} })
|
||||||
@@ -223,40 +218,27 @@ function DictionariesPage() {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label={t('dict.list.heading')}
|
aria-label={t('dict.list.heading')}
|
||||||
className="space-y-6"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<PageHeader
|
{/* PageHeader per redesign: title + inline "N шт." count, без description.
|
||||||
title={t('nav.dictionaries')}
|
Actions удалены — + Создать переехала в toolbar справа от Граф ⇄. */}
|
||||||
description={t('dict.list.subtitle')}
|
<div className="flex items-baseline gap-3">
|
||||||
actions={createButton}
|
<h1 className="text-title-lg text-ink">{t('nav.dictionaries')}</h1>
|
||||||
/>
|
<span className="text-cap text-mute">
|
||||||
|
{data.length} {t('dict.list.countSuffix', { defaultValue: 'шт.' })}
|
||||||
{/* Compact toolbar: всё в одной строке (wraps на narrow viewport).
|
|
||||||
Tradeoff: scope/bundle chips меньше padding'а + краткие labels.
|
|
||||||
UPPERCASE labels оставлены для consistency с brand voice (Tektur-like). */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
||||||
<div className="flex-1 min-w-[240px] max-w-sm">
|
|
||||||
<SearchInput
|
|
||||||
value={search.q ?? ''}
|
|
||||||
onChange={(e) => setSearch({ q: e.target.value })}
|
|
||||||
placeholder={t('dict.list.search.placeholder')}
|
|
||||||
aria-label={t('dict.list.search.placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-mono text-mute whitespace-nowrap tabular-nums">
|
|
||||||
{filtered.length}/{data.length}
|
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Scope chips: компактные, dot + count, full label по hover'у через title */}
|
{/* Toolbar per redesign prototype: [scope pills] | [bundle pills] [...] N/M [Граф ⇄] [+ Создать] */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={t('dict.list.filter.scope')}
|
aria-label={t('dict.list.filter.scope')}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{SCOPE_ORDER.map((scope) => {
|
{SCOPE_ORDER.map((scope) => {
|
||||||
const selected = scopeFilter.has(scope)
|
const selected = scopeFilter.has(scope)
|
||||||
const count = scopeCounts[scope]
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={scope}
|
key={scope}
|
||||||
@@ -264,27 +246,22 @@ function DictionariesPage() {
|
|||||||
onClick={() => toggleScope(scope)}
|
onClick={() => toggleScope(scope)}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
title={t(`dict.list.section.${scope}`)}
|
title={t(`dict.list.section.${scope}`)}
|
||||||
className={`text-cap px-2 py-1 rounded-full flex items-center gap-1 border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
className={`text-cap tracking-[0.14em] uppercase font-semibold px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||||
selected
|
selected
|
||||||
? 'border-accent bg-accent-bg text-accent'
|
? 'border-ink bg-surface text-ink ring-1 ring-ink'
|
||||||
: 'border-line hover:border-accent/60 text-ink'
|
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
{scope}
|
||||||
className={`inline-block size-1.5 rounded-full ${SCOPE_DOT[scope]}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t(`dict.list.section.${scope}.short`)}
|
|
||||||
<span className="text-mute ml-0.5">{count}</span>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical divider */}
|
{/* Vertical divider между scope и bundle */}
|
||||||
<span className="h-5 w-px bg-line" aria-hidden="true" />
|
<span className="h-6 w-px bg-line shrink-0" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Bundle filter — chips inline */}
|
{/* Bundle filter — все / cuod / geo / i18n / core */}
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Bundle"
|
aria-label="Bundle"
|
||||||
@@ -294,17 +271,17 @@ function DictionariesPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBundle(undefined)}
|
onClick={() => setBundle(undefined)}
|
||||||
aria-pressed={!bundleFilter}
|
aria-pressed={!bundleFilter}
|
||||||
className={`text-cap px-2 py-1 rounded-sm border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
className={`text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||||
!bundleFilter
|
!bundleFilter
|
||||||
? 'border-accent bg-accent-bg text-accent'
|
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
|
||||||
: 'border-line hover:border-accent/60 text-ink'
|
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('dict.list.bundle.all')}
|
{t('dict.list.bundle.all', { defaultValue: 'все' })}
|
||||||
</button>
|
</button>
|
||||||
{Array.from(bundleCounts.entries())
|
{Array.from(bundleCounts.entries())
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([bundle, count]) => {
|
.map(([bundle]) => {
|
||||||
const selected = bundleFilter === bundle
|
const selected = bundleFilter === bundle
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -312,35 +289,39 @@ function DictionariesPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBundle(selected ? undefined : bundle)}
|
onClick={() => setBundle(selected ? undefined : bundle)}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
className={`text-cap px-2 py-1 rounded-sm flex items-center gap-1 border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
className={`text-mono text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||||
selected
|
selected
|
||||||
? 'border-accent bg-accent-bg text-accent'
|
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
|
||||||
: 'border-line hover:border-accent/60 text-ink'
|
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{bundle}
|
{bundle}
|
||||||
<span className="text-mute">{count}</span>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical divider + deps toggle */}
|
{/* Spacer */}
|
||||||
<span className="h-5 w-px bg-line" aria-hidden="true" />
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* N / M counter */}
|
||||||
|
<span className="text-cell text-mute whitespace-nowrap tabular-nums">
|
||||||
|
{filtered.length} / {data.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
<button
|
{/* Граф ⇄ — link to relations graph view */}
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSearch({ deps: withDepsOnly ? undefined : '1' })}
|
type="button"
|
||||||
aria-pressed={withDepsOnly}
|
onClick={() => navigate({ to: '/graph' })}
|
||||||
title={t('dict.list.deps.only')}
|
className="h-9 px-3 rounded-md border border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2 text-body inline-flex items-center gap-1.5 transition-colors"
|
||||||
className={`text-cap px-2 py-1 rounded-sm border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
title={t('dict.list.graph', { defaultValue: 'Граф связей' })}
|
||||||
withDepsOnly
|
>
|
||||||
? 'border-accent bg-accent-bg text-accent'
|
{t('dict.list.graph', { defaultValue: 'Граф' })}
|
||||||
: 'border-line hover:border-accent/60 text-ink'
|
<span aria-hidden className="text-mute">⇄</span>
|
||||||
}`}
|
</button>
|
||||||
>
|
|
||||||
{t('dict.list.deps.short')}
|
{/* + Создать (moved here from PageHeader actions per redesign) */}
|
||||||
</button>
|
{createButton}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
|
|||||||
Reference in New Issue
Block a user