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