feat(record): conflict diff modal на 409 save (handoff line 444)

Когда PUT /records/{key} возвращает 409 (optimistic-lock fail — другой
пользователь сохранил тот же record между нашим load и submit'ом),
открываем ConflictDiffModal.

Component:
- Header: 'Конфликт сохранения · {DICT}' + business key
- Diff body: refetch server state via useRecordRaw, иди по union ключей
  local + server, оставь только отличающиеся. Per-row: field name (mono)
  + ваше значение (accent-bg) + серверное (surface-2)
- Footer 3 actions:
  - Отмена: close, оставить editor open
  - Загрузить серверное: discard local, refetch records, close editor
  - Перезаписать: re-submit current payload (если опять 409 → новый diff)
- Backend-friendly: не требует server diff endpoint, client считает diff
  client-side через JSON.stringify equality

Route wiring:
- onError handler distinguishes generic 409 (open diff modal) от
  специальных кодов: x_references_blocked_by_dependents,
  x_references_cascade_required, draft_required — те идут в свои dialogs
- Conflict state ({payload, businessKey}) capture'ит local data чтобы
  показать в diff даже после reset формы
This commit is contained in:
Zimin A.N.
2026-05-11 19:13:30 +03:00
parent ad7c51d93f
commit 16da694d8c
2 changed files with 310 additions and 0 deletions
@@ -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 { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { RecordDrawer } from '@/components/record/RecordDrawer'
import { ConflictDiffModal } from '@/components/record/ConflictDiffModal'
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
import { ExportModal } from '@/components/editor/ExportModal'
@@ -189,6 +190,12 @@ function DictionaryDetail() {
const [aoiOpen, setAoiOpen] = useState(false)
const [timeTravelOpen, setTimeTravelOpen] = 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
* на close, или явным action из record menu. */
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
@@ -474,6 +481,26 @@ function DictionaryDetail() {
...req,
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') {
createMut.mutate(
{ payload: submitReq, idempotencyKey: recordIdempotencyKey },
@@ -482,6 +509,11 @@ function DictionaryDetail() {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
onError: (err) => {
// Create rarely conflicts (no prior record to lock), но если backend
// вернёт 409 по уникальному ключу — businessKey возьмём из payload.
handleSaveError(err, submitReq.businessKey ?? '')
},
},
)
return
@@ -498,6 +530,7 @@ function DictionaryDetail() {
setEdit({ kind: 'closed' })
resetRecordIdempotencyKey()
},
onError: (err) => handleSaveError(err, edit.record.businessKey),
},
)
}
@@ -1123,6 +1156,58 @@ function DictionaryDetail() {
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. */}
{exportOpen && detailQuery.data && (
<ExportModal