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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user