@open-mercato/ui 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +57 -11
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +20 -8
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/package.json +7 -3
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +99 -9
- package/src/backend/DataTable.tsx +22 -8
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { RefreshCw, X } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../primitives/button'
|
|
5
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { dismissRecordConflict, useRecordConflict } from './store'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Persistent, error-styled bar that surfaces an optimistic-lock conflict
|
|
11
|
+
* ("this record was modified by someone else"). Rendered once in `AppShell`
|
|
12
|
+
* next to the undo `LastOperationBanner`, it is the unified, clearly-visible
|
|
13
|
+
* surface for the conflict across every form (CrudForm, useGuardedMutation,
|
|
14
|
+
* and custom pages all push through `conflicts/store`). Unlike the undo bar it
|
|
15
|
+
* does NOT auto-dismiss — the user resolves it by refreshing or dismissing.
|
|
16
|
+
*/
|
|
17
|
+
export function RecordConflictBanner() {
|
|
18
|
+
const t = useT()
|
|
19
|
+
const conflict = useRecordConflict()
|
|
20
|
+
|
|
21
|
+
const handleRefresh = React.useCallback(() => {
|
|
22
|
+
const onRefresh = conflict?.onRefresh ?? null
|
|
23
|
+
dismissRecordConflict()
|
|
24
|
+
if (onRefresh) {
|
|
25
|
+
onRefresh()
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
|
|
29
|
+
try {
|
|
30
|
+
const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
31
|
+
? navigator.userAgent.toLowerCase().includes('jsdom')
|
|
32
|
+
: false
|
|
33
|
+
if (!isJSDOM) window.location.reload()
|
|
34
|
+
} catch {
|
|
35
|
+
// noop in non-browser / jsdom environments
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}, [conflict])
|
|
39
|
+
|
|
40
|
+
if (!conflict) return null
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
role="alert"
|
|
45
|
+
data-testid="record-conflict-banner"
|
|
46
|
+
className="mb-4 flex items-center justify-between gap-3 rounded-md border border-status-error-border bg-status-error-bg pl-3 pr-2 py-2 text-sm text-status-error-text shadow-xs sm:pr-3"
|
|
47
|
+
>
|
|
48
|
+
<div className="min-w-0">
|
|
49
|
+
<span className="font-medium text-status-error-text">
|
|
50
|
+
{conflict.title ?? t('ui.forms.conflict.title', 'Record changed')}
|
|
51
|
+
</span>
|
|
52
|
+
<span className="ml-2 text-status-error-text">{conflict.message}</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="flex items-center gap-1">
|
|
55
|
+
<Button
|
|
56
|
+
type="button"
|
|
57
|
+
variant="outline"
|
|
58
|
+
size="sm"
|
|
59
|
+
onClick={handleRefresh}
|
|
60
|
+
className="border-status-error-border bg-status-error-bg text-status-error-text hover:bg-status-error-border hover:text-status-error-text px-2.5 sm:px-3"
|
|
61
|
+
>
|
|
62
|
+
<RefreshCw className="mr-1 size-4" aria-hidden="true" />
|
|
63
|
+
{t('ui.forms.conflict.refresh', 'Refresh')}
|
|
64
|
+
</Button>
|
|
65
|
+
<IconButton
|
|
66
|
+
type="button"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="sm"
|
|
69
|
+
aria-label={t('ui.forms.conflict.dismiss', 'Dismiss')}
|
|
70
|
+
onClick={() => dismissRecordConflict()}
|
|
71
|
+
>
|
|
72
|
+
<X className="size-4" aria-hidden="true" />
|
|
73
|
+
</IconButton>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { act, render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
6
|
+
useT: () => (key: string, fallback?: string) => fallback ?? key,
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import { RecordConflictBanner } from '../RecordConflictBanner'
|
|
10
|
+
import { dismissRecordConflict, showRecordConflict } from '../store'
|
|
11
|
+
|
|
12
|
+
describe('RecordConflictBanner', () => {
|
|
13
|
+
beforeEach(() => dismissRecordConflict())
|
|
14
|
+
afterEach(() => dismissRecordConflict())
|
|
15
|
+
|
|
16
|
+
it('renders nothing when there is no active conflict', () => {
|
|
17
|
+
const { container } = render(<RecordConflictBanner />)
|
|
18
|
+
expect(container.querySelector('[data-testid="record-conflict-banner"]')).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders the localized message + Refresh/Dismiss as an alert when a conflict is active', () => {
|
|
22
|
+
render(<RecordConflictBanner />)
|
|
23
|
+
act(() => {
|
|
24
|
+
showRecordConflict({
|
|
25
|
+
message: 'This record was modified by someone else. Refresh and try again.',
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
const banner = screen.getByTestId('record-conflict-banner')
|
|
29
|
+
expect(banner.getAttribute('role')).toBe('alert')
|
|
30
|
+
expect(banner.textContent).toContain('Record changed')
|
|
31
|
+
expect(banner.textContent).toContain('This record was modified by someone else. Refresh and try again.')
|
|
32
|
+
expect(screen.getByRole('button', { name: 'Refresh' })).not.toBeNull()
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Dismiss' })).not.toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('invokes a custom onRefresh and clears the bar when Refresh is clicked', () => {
|
|
37
|
+
const onRefresh = jest.fn()
|
|
38
|
+
render(<RecordConflictBanner />)
|
|
39
|
+
act(() => {
|
|
40
|
+
showRecordConflict({ message: 'Changed', onRefresh })
|
|
41
|
+
})
|
|
42
|
+
act(() => {
|
|
43
|
+
screen.getByRole('button', { name: 'Refresh' }).click()
|
|
44
|
+
})
|
|
45
|
+
expect(onRefresh).toHaveBeenCalledTimes(1)
|
|
46
|
+
expect(screen.queryByTestId('record-conflict-banner')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('Dismiss clears the bar without refreshing', () => {
|
|
50
|
+
const onRefresh = jest.fn()
|
|
51
|
+
render(<RecordConflictBanner />)
|
|
52
|
+
act(() => {
|
|
53
|
+
showRecordConflict({ message: 'Changed', onRefresh })
|
|
54
|
+
})
|
|
55
|
+
act(() => {
|
|
56
|
+
screen.getByRole('button', { name: 'Dismiss' }).click()
|
|
57
|
+
})
|
|
58
|
+
expect(onRefresh).not.toHaveBeenCalled()
|
|
59
|
+
expect(screen.queryByTestId('record-conflict-banner')).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
3
|
+
import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
4
|
+
import {
|
|
5
|
+
dismissRecordConflict,
|
|
6
|
+
getRecordConflictForTest,
|
|
7
|
+
showRecordConflict,
|
|
8
|
+
surfaceRecordConflict,
|
|
9
|
+
} from '..'
|
|
10
|
+
|
|
11
|
+
const t = (key: string, fallback?: string) => fallback ?? key
|
|
12
|
+
|
|
13
|
+
describe('record conflict store + surfaceRecordConflict', () => {
|
|
14
|
+
beforeEach(() => dismissRecordConflict())
|
|
15
|
+
|
|
16
|
+
it('showRecordConflict sets the active entry; dismiss clears it', () => {
|
|
17
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
18
|
+
showRecordConflict({ message: 'Changed', currentUpdatedAt: '2026-05-25T08:00:01.000Z' })
|
|
19
|
+
const entry = getRecordConflictForTest()
|
|
20
|
+
expect(entry?.message).toBe('Changed')
|
|
21
|
+
expect(entry?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
|
|
22
|
+
dismissRecordConflict()
|
|
23
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('surfaceRecordConflict pushes the localized message for an optimistic-lock 409 and returns true', () => {
|
|
27
|
+
const conflict = new CrudHttpError(409, {
|
|
28
|
+
error: 'record_modified',
|
|
29
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
30
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
31
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
32
|
+
})
|
|
33
|
+
const handled = surfaceRecordConflict(conflict, t)
|
|
34
|
+
expect(handled).toBe(true)
|
|
35
|
+
expect(getRecordConflictForTest()?.message).toBe(
|
|
36
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('surfaceRecordConflict recognizes a plain apiCall-result envelope ({ status, body }) — used by custom admin pages whose apiCall does not throw on 409', () => {
|
|
41
|
+
const apiCallEnvelope = {
|
|
42
|
+
status: 409,
|
|
43
|
+
body: {
|
|
44
|
+
error: 'record_modified',
|
|
45
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
46
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
47
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
expect(surfaceRecordConflict(apiCallEnvelope, t)).toBe(true)
|
|
51
|
+
expect(getRecordConflictForTest()?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('surfaceRecordConflict ignores a non-409 apiCall envelope (e.g. 500 with no conflict body)', () => {
|
|
55
|
+
expect(surfaceRecordConflict({ status: 500, body: { error: 'server_error' } }, t)).toBe(false)
|
|
56
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('surfaceRecordConflict is a no-op (returns false) for non-conflict errors', () => {
|
|
60
|
+
expect(surfaceRecordConflict(new CrudHttpError(422, { error: 'validation_failed' }), t)).toBe(false)
|
|
61
|
+
expect(surfaceRecordConflict(new Error('boom'), t)).toBe(false)
|
|
62
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('forwards a custom onRefresh callback', () => {
|
|
66
|
+
const onRefresh = jest.fn()
|
|
67
|
+
const conflict = new CrudHttpError(409, {
|
|
68
|
+
error: 'record_modified',
|
|
69
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
70
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
71
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
72
|
+
})
|
|
73
|
+
surfaceRecordConflict(conflict, t, { onRefresh, title: 'Order changed' })
|
|
74
|
+
const entry = getRecordConflictForTest()
|
|
75
|
+
expect(entry?.title).toBe('Order changed')
|
|
76
|
+
expect(entry?.onRefresh).toBe(onRefresh)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { extractOptimisticLockConflict } from '../utils/optimisticLock'
|
|
2
|
+
import { showRecordConflict } from './store'
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
showRecordConflict,
|
|
6
|
+
dismissRecordConflict,
|
|
7
|
+
useRecordConflict,
|
|
8
|
+
getRecordConflictForTest,
|
|
9
|
+
type RecordConflictEntry,
|
|
10
|
+
type ShowRecordConflictInput,
|
|
11
|
+
} from './store'
|
|
12
|
+
export { RecordConflictBanner } from './RecordConflictBanner'
|
|
13
|
+
|
|
14
|
+
type Translate = (key: string, fallback?: string) => string
|
|
15
|
+
|
|
16
|
+
export type SurfaceRecordConflictOptions = {
|
|
17
|
+
/** Custom refresh handler. Omit to let the banner reload the page. */
|
|
18
|
+
onRefresh?: (() => void) | null
|
|
19
|
+
/** Localized title override; the banner falls back to a generic title. */
|
|
20
|
+
title?: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* If `error` is an OSS optimistic-lock conflict (HTTP 409 with
|
|
25
|
+
* `code: 'optimistic_lock_conflict'`), push the localized "record modified"
|
|
26
|
+
* message onto the shared conflict bar and return `true`. Otherwise return
|
|
27
|
+
* `false` so callers can fall back to their normal error handling.
|
|
28
|
+
*
|
|
29
|
+
* This is the single entry point every form uses, so the conflict surfaces the
|
|
30
|
+
* same persistent, error-styled bar everywhere.
|
|
31
|
+
*/
|
|
32
|
+
export function surfaceRecordConflict(
|
|
33
|
+
error: unknown,
|
|
34
|
+
t: Translate,
|
|
35
|
+
options: SurfaceRecordConflictOptions = {},
|
|
36
|
+
): boolean {
|
|
37
|
+
const conflict = extractOptimisticLockConflict(error)
|
|
38
|
+
if (!conflict) return false
|
|
39
|
+
showRecordConflict({
|
|
40
|
+
message: t(
|
|
41
|
+
'ui.forms.flash.recordModified',
|
|
42
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
43
|
+
),
|
|
44
|
+
title: options.title ?? null,
|
|
45
|
+
currentUpdatedAt: conflict.currentUpdatedAt,
|
|
46
|
+
onRefresh: options.onRefresh ?? null,
|
|
47
|
+
})
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory store for the unified record-conflict bar.
|
|
6
|
+
*
|
|
7
|
+
* When a mutation fails with an OSS optimistic-lock conflict (HTTP 409 with
|
|
8
|
+
* `code: 'optimistic_lock_conflict'`), forms surface it through this store so a
|
|
9
|
+
* persistent, error-styled bar — the `RecordConflictBanner` rendered once in
|
|
10
|
+
* `AppShell` — shows the "this record was modified" message app-wide, instead
|
|
11
|
+
* of a transient toast that scrolls away. Mirrors the undo `LastOperationBanner`
|
|
12
|
+
* pattern but for the error case, and is shared by `CrudForm`,
|
|
13
|
+
* `useGuardedMutation`, and any custom page, so every form behaves the same.
|
|
14
|
+
*
|
|
15
|
+
* Unlike the operations store this is NOT persisted to localStorage — it holds
|
|
16
|
+
* a live `onRefresh` callback and a conflict only matters for the current page
|
|
17
|
+
* session.
|
|
18
|
+
*/
|
|
19
|
+
export type RecordConflictEntry = {
|
|
20
|
+
id: string
|
|
21
|
+
/** Localized, user-facing message (e.g. ui.forms.flash.recordModified). */
|
|
22
|
+
message: string
|
|
23
|
+
/** Optional localized title; the banner falls back to a generic title. */
|
|
24
|
+
title?: string | null
|
|
25
|
+
/** The server's current `updated_at`, for diagnostics / future diffing. */
|
|
26
|
+
currentUpdatedAt?: string | null
|
|
27
|
+
/**
|
|
28
|
+
* Invoked when the user clicks "Refresh". When omitted the banner reloads
|
|
29
|
+
* the page (the safe default — re-fetches the latest server state).
|
|
30
|
+
*/
|
|
31
|
+
onRefresh?: (() => void) | null
|
|
32
|
+
createdAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let internalEntry: RecordConflictEntry | null = null
|
|
36
|
+
let sequence = 0
|
|
37
|
+
|
|
38
|
+
const emitter = new EventTarget()
|
|
39
|
+
|
|
40
|
+
function emit(): void {
|
|
41
|
+
emitter.dispatchEvent(new Event('change'))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function subscribe(listener: () => void): () => void {
|
|
45
|
+
const wrapped = () => listener()
|
|
46
|
+
emitter.addEventListener('change', wrapped)
|
|
47
|
+
return () => emitter.removeEventListener('change', wrapped)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nextId(): string {
|
|
51
|
+
sequence += 1
|
|
52
|
+
return `conflict:${sequence}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ShowRecordConflictInput = {
|
|
56
|
+
message: string
|
|
57
|
+
title?: string | null
|
|
58
|
+
currentUpdatedAt?: string | null
|
|
59
|
+
onRefresh?: (() => void) | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Show (or replace) the active record-conflict bar. */
|
|
63
|
+
export function showRecordConflict(input: ShowRecordConflictInput): void {
|
|
64
|
+
internalEntry = {
|
|
65
|
+
id: nextId(),
|
|
66
|
+
message: input.message,
|
|
67
|
+
title: input.title ?? null,
|
|
68
|
+
currentUpdatedAt: input.currentUpdatedAt ?? null,
|
|
69
|
+
onRefresh: input.onRefresh ?? null,
|
|
70
|
+
createdAt: typeof Date !== 'undefined' ? Date.now() : 0,
|
|
71
|
+
}
|
|
72
|
+
emit()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clear the active record-conflict bar. */
|
|
76
|
+
export function dismissRecordConflict(): void {
|
|
77
|
+
if (internalEntry === null) return
|
|
78
|
+
internalEntry = null
|
|
79
|
+
emit()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read the active conflict (null when none). Subscribes to changes. */
|
|
83
|
+
export function useRecordConflict(): RecordConflictEntry | null {
|
|
84
|
+
return React.useSyncExternalStore(
|
|
85
|
+
subscribe,
|
|
86
|
+
() => internalEntry,
|
|
87
|
+
() => null,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Test-only accessor for the current entry without React. */
|
|
92
|
+
export function getRecordConflictForTest(): RecordConflictEntry | null {
|
|
93
|
+
return internalEntry
|
|
94
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
4
|
+
import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
5
|
+
|
|
6
|
+
const triggerEventMock = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('../InjectionSpot', () => ({
|
|
9
|
+
useInjectionSpotEvents: () => ({
|
|
10
|
+
triggerEvent: (...args: unknown[]) => triggerEventMock(...args),
|
|
11
|
+
}),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
15
|
+
useT: () => (key: string, fallback?: string) => fallback ?? key,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { act, renderHook } from '@testing-library/react'
|
|
19
|
+
import { useGuardedMutation } from '../useGuardedMutation'
|
|
20
|
+
import { dismissRecordConflict, getRecordConflictForTest } from '../../conflicts'
|
|
21
|
+
|
|
22
|
+
function setupTriggerEventOk() {
|
|
23
|
+
triggerEventMock.mockImplementation(async () => ({ ok: true, requestHeaders: undefined }))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('useGuardedMutation — surfaces optimistic-lock conflict on the unified bar', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
triggerEventMock.mockClear()
|
|
29
|
+
dismissRecordConflict()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('shows the record-conflict bar when the operation throws CrudHttpError(409, optimistic_lock_conflict)', async () => {
|
|
33
|
+
setupTriggerEventOk()
|
|
34
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
35
|
+
const conflict = new CrudHttpError(409, {
|
|
36
|
+
error: 'record_modified',
|
|
37
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
38
|
+
currentUpdatedAt: '2026-05-25T08:00:01.000Z',
|
|
39
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
await act(async () => {
|
|
43
|
+
try {
|
|
44
|
+
await result.current.runMutation({
|
|
45
|
+
operation: async () => { throw conflict },
|
|
46
|
+
context: {},
|
|
47
|
+
mutationPayload: {},
|
|
48
|
+
})
|
|
49
|
+
} catch (err) {
|
|
50
|
+
expect(err).toBe(conflict)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const entry = getRecordConflictForTest()
|
|
55
|
+
expect(entry).not.toBeNull()
|
|
56
|
+
expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
|
|
57
|
+
expect(entry?.currentUpdatedAt).toBe('2026-05-25T08:00:01.000Z')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does NOT show the bar for non-optimistic-lock 409s (e.g. plain conflicts)', async () => {
|
|
61
|
+
setupTriggerEventOk()
|
|
62
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
63
|
+
const otherConflict = new CrudHttpError(409, {
|
|
64
|
+
error: 'duplicate',
|
|
65
|
+
code: 'duplicate_resource',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await act(async () => {
|
|
69
|
+
try {
|
|
70
|
+
await result.current.runMutation({
|
|
71
|
+
operation: async () => { throw otherConflict },
|
|
72
|
+
context: {},
|
|
73
|
+
mutationPayload: {},
|
|
74
|
+
})
|
|
75
|
+
} catch (err) {
|
|
76
|
+
expect(err).toBe(otherConflict)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does NOT show the bar for non-409 errors', async () => {
|
|
84
|
+
setupTriggerEventOk()
|
|
85
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
86
|
+
const validationError = new CrudHttpError(422, { error: 'validation_failed' })
|
|
87
|
+
|
|
88
|
+
await act(async () => {
|
|
89
|
+
try {
|
|
90
|
+
await result.current.runMutation({
|
|
91
|
+
operation: async () => { throw validationError },
|
|
92
|
+
context: {},
|
|
93
|
+
mutationPayload: {},
|
|
94
|
+
})
|
|
95
|
+
} catch (err) {
|
|
96
|
+
expect(err).toBe(validationError)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('does NOT show the bar when the operation resolves successfully', async () => {
|
|
104
|
+
setupTriggerEventOk()
|
|
105
|
+
const { result } = renderHook(() => useGuardedMutation<Record<string, unknown>>({ contextId: 'test-form' }))
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
const value = await result.current.runMutation({
|
|
109
|
+
operation: async () => 'ok',
|
|
110
|
+
context: {},
|
|
111
|
+
mutationPayload: {},
|
|
112
|
+
})
|
|
113
|
+
expect(value).toBe('ok')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -4,6 +4,8 @@ import * as React from 'react'
|
|
|
4
4
|
import { useInjectionSpotEvents } from './InjectionSpot'
|
|
5
5
|
import { GLOBAL_MUTATION_INJECTION_SPOT_ID, dispatchBackendMutationError } from './mutationEvents'
|
|
6
6
|
import { withScopedApiRequestHeaders } from '../utils/apiCall'
|
|
7
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
8
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
9
|
|
|
8
10
|
type GuardedMutationContext = Record<string, unknown>
|
|
9
11
|
|
|
@@ -30,6 +32,7 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
|
|
|
30
32
|
blockedMessage = 'Save blocked by validation',
|
|
31
33
|
spotId = GLOBAL_MUTATION_INJECTION_SPOT_ID,
|
|
32
34
|
}: UseGuardedMutationOptions) {
|
|
35
|
+
const t = useT()
|
|
33
36
|
const { triggerEvent } = useInjectionSpotEvents<TContext, Record<string, unknown>>(spotId)
|
|
34
37
|
const lastMutationRef = React.useRef<StoredMutation<TContext> | null>(null)
|
|
35
38
|
|
|
@@ -39,7 +42,19 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
|
|
|
39
42
|
formId: contextId,
|
|
40
43
|
error,
|
|
41
44
|
})
|
|
42
|
-
|
|
45
|
+
// Default UX for OSS optimistic-lock conflicts (spec
|
|
46
|
+
// .ai/specs/2026-05-25-oss-optimistic-locking.md §3.6): when the
|
|
47
|
+
// server returns 409 with `code: 'optimistic_lock_conflict'`,
|
|
48
|
+
// surface the conflict on the unified, persistent, error-styled
|
|
49
|
+
// RecordConflictBanner (rendered in AppShell) instead of a transient
|
|
50
|
+
// toast. Callers that have their own handler still see the dispatched
|
|
51
|
+
// event above and can suppress/override by catching the error first.
|
|
52
|
+
try {
|
|
53
|
+
surfaceRecordConflict(error, t)
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore conflict-bar dispatch failures
|
|
56
|
+
}
|
|
57
|
+
}, [contextId, t])
|
|
43
58
|
|
|
44
59
|
const runMutation = React.useCallback(async <TResult,>({
|
|
45
60
|
operation,
|
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
SelectTrigger,
|
|
22
22
|
SelectValue,
|
|
23
23
|
} from '../../primitives/select'
|
|
24
|
-
import { apiCall } from '../utils/apiCall'
|
|
24
|
+
import { apiCall, withScopedApiRequestHeaders } from '../utils/apiCall'
|
|
25
|
+
import { buildOptimisticLockHeader } from '../utils/optimisticLock'
|
|
26
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
25
27
|
import { flash } from '../FlashMessages'
|
|
26
28
|
import { Page, PageBody } from '../Page'
|
|
27
29
|
import { useBackendChrome } from '../BackendChromeProvider'
|
|
@@ -231,6 +233,9 @@ export function SidebarCustomizationEditor({
|
|
|
231
233
|
const [addDialogError, setAddDialogError] = React.useState<string | null>(null)
|
|
232
234
|
const baseSnapshotRef = React.useRef<SidebarGroup[] | null>(null)
|
|
233
235
|
const hasInitializedRef = React.useRef(false)
|
|
236
|
+
// Tracks the user-scope sidebar-preference version so concurrent edits from another
|
|
237
|
+
// tab are caught by the server's optimistic-lock guard (409 → conflict bar).
|
|
238
|
+
const preferencesUpdatedAtRef = React.useRef<string | null>(null)
|
|
234
239
|
|
|
235
240
|
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
236
241
|
formId: string
|
|
@@ -285,11 +290,12 @@ export function SidebarCustomizationEditor({
|
|
|
285
290
|
}, [variantsApiPath])
|
|
286
291
|
|
|
287
292
|
const loadRolesPayload = React.useCallback(async (): Promise<{ canApplyToRoles: boolean; roles: RoleTarget[] }> => {
|
|
288
|
-
const call = await apiCall<{ canApplyToRoles?: boolean; roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }
|
|
293
|
+
const call = await apiCall<{ canApplyToRoles?: boolean; roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>; updatedAt?: string | null }>(preferencesApiPath)
|
|
289
294
|
if (!call.ok) {
|
|
290
295
|
return { canApplyToRoles: false, roles: [] }
|
|
291
296
|
}
|
|
292
297
|
const data = call.result ?? null
|
|
298
|
+
preferencesUpdatedAtRef.current = typeof data?.updatedAt === 'string' ? data.updatedAt : null
|
|
293
299
|
const can = data?.canApplyToRoles === true
|
|
294
300
|
const roles = Array.isArray(data?.roles)
|
|
295
301
|
? (data!.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>)
|
|
@@ -695,19 +701,37 @@ export function SidebarCustomizationEditor({
|
|
|
695
701
|
}
|
|
696
702
|
const preferencesCall = await runMutation({
|
|
697
703
|
operation: () =>
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
704
|
+
withScopedApiRequestHeaders(
|
|
705
|
+
buildOptimisticLockHeader(preferencesUpdatedAtRef.current),
|
|
706
|
+
() =>
|
|
707
|
+
apiCall<{ updatedAt?: string | null }>(preferencesApiPath, {
|
|
708
|
+
method: 'PUT',
|
|
709
|
+
headers: { 'content-type': 'application/json' },
|
|
710
|
+
body: JSON.stringify(preferencesPayload),
|
|
711
|
+
}),
|
|
712
|
+
),
|
|
703
713
|
context: buildMutationContext('savePreferences', selectedVariantId),
|
|
704
714
|
mutationPayload: preferencesPayload,
|
|
705
715
|
})
|
|
716
|
+
if (preferencesCall.ok) {
|
|
717
|
+
preferencesUpdatedAtRef.current =
|
|
718
|
+
typeof preferencesCall.result?.updatedAt === 'string' ? preferencesCall.result.updatedAt : null
|
|
719
|
+
}
|
|
706
720
|
if (!preferencesCall.ok) {
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
721
|
+
// A stale save (another tab changed the sidebar customization first) returns a
|
|
722
|
+
// 409 with `code: 'optimistic_lock_conflict'`. apiCall does not throw, so surface
|
|
723
|
+
// the conflict explicitly on the shared, persistent conflict bar instead of a
|
|
724
|
+
// generic save error.
|
|
725
|
+
const surfaced = surfaceRecordConflict(
|
|
726
|
+
{ status: preferencesCall.status, body: preferencesCall.result },
|
|
727
|
+
t,
|
|
728
|
+
)
|
|
729
|
+
if (!surfaced) {
|
|
730
|
+
// The variant entity is the canonical layout; the preferences sync is what the
|
|
731
|
+
// AppShell sidebar actually reads. A failed sync would leave the saved variant
|
|
732
|
+
// not reflected live, so surface it as a save error rather than flashing success.
|
|
733
|
+
setError(formatVariantApiError(preferencesCall, t))
|
|
734
|
+
}
|
|
711
735
|
return
|
|
712
736
|
}
|
|
713
737
|
try { window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT)) } catch { /* no listener attached — fine, AppShell will refresh on next navigation */ }
|