@open-mercato/ui 0.6.6-develop.6330.1.a261878aa8 → 0.6.6-develop.6332.1.6e73f0f55b
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/dist/backend/AppShell.js +12 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/conflicts/index.js +50 -13
- package/dist/backend/conflicts/index.js.map +2 -2
- package/dist/backend/detail/AddressesSection.js +9 -5
- package/dist/backend/detail/AddressesSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +11 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/injection/recordContext.js +40 -0
- package/dist/backend/injection/recordContext.js.map +7 -0
- package/dist/backend/utils/optimisticLock.js +19 -1
- package/dist/backend/utils/optimisticLock.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +23 -2
- package/src/backend/__tests__/NotesSection.test.tsx +71 -0
- package/src/backend/conflicts/__tests__/recordLockDeferral.test.ts +99 -0
- package/src/backend/conflicts/index.ts +96 -19
- package/src/backend/detail/AddressesSection.tsx +12 -7
- package/src/backend/detail/NotesSection.tsx +19 -8
- package/src/backend/injection/recordContext.tsx +101 -0
- package/src/backend/utils/optimisticLock.ts +36 -0
|
@@ -4,9 +4,12 @@ import * as React from 'react'
|
|
|
4
4
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
6
6
|
import { NotesSection, type NotesDataAdapter } from '../detail/NotesSection'
|
|
7
|
+
import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
|
|
8
|
+
import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
7
9
|
|
|
8
10
|
describe('NotesSection', () => {
|
|
9
11
|
beforeEach(() => {
|
|
12
|
+
dismissRecordConflict()
|
|
10
13
|
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
|
11
14
|
configurable: true,
|
|
12
15
|
writable: true,
|
|
@@ -22,6 +25,10 @@ describe('NotesSection', () => {
|
|
|
22
25
|
})
|
|
23
26
|
})
|
|
24
27
|
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
dismissRecordConflict()
|
|
30
|
+
})
|
|
31
|
+
|
|
25
32
|
it('keeps an add-note action visible after notes already exist', async () => {
|
|
26
33
|
const dataAdapter: NotesDataAdapter = {
|
|
27
34
|
list: jest.fn(async () => [
|
|
@@ -60,4 +67,68 @@ describe('NotesSection', () => {
|
|
|
60
67
|
expect(container.querySelector('textarea')).not.toBeNull()
|
|
61
68
|
})
|
|
62
69
|
})
|
|
70
|
+
|
|
71
|
+
it('surfaces the unified conflict bar when a write fails with a 409', async () => {
|
|
72
|
+
const conflict = {
|
|
73
|
+
status: 409,
|
|
74
|
+
body: {
|
|
75
|
+
error: 'optimistic_lock_conflict',
|
|
76
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
77
|
+
currentUpdatedAt: '2026-06-02T00:00:00.000Z',
|
|
78
|
+
expectedUpdatedAt: '2026-06-01T00:00:00.000Z',
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
const dataAdapter: NotesDataAdapter = {
|
|
82
|
+
list: jest.fn(async () => [
|
|
83
|
+
{
|
|
84
|
+
id: 'note-1',
|
|
85
|
+
body: 'Existing note',
|
|
86
|
+
createdAt: '2026-04-10T08:00:00.000Z',
|
|
87
|
+
authorName: 'Ada Lovelace',
|
|
88
|
+
},
|
|
89
|
+
]),
|
|
90
|
+
create: jest.fn(async () => {
|
|
91
|
+
throw conflict
|
|
92
|
+
}),
|
|
93
|
+
update: jest.fn(async () => undefined),
|
|
94
|
+
delete: jest.fn(async () => undefined),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { container } = renderWithProviders(
|
|
98
|
+
<NotesSection
|
|
99
|
+
entityId="person-1"
|
|
100
|
+
emptyLabel="—"
|
|
101
|
+
viewerUserId="user-1"
|
|
102
|
+
viewerName="Ada Lovelace"
|
|
103
|
+
addActionLabel="Add note"
|
|
104
|
+
emptyState={{
|
|
105
|
+
title: 'No notes yet',
|
|
106
|
+
actionLabel: 'Add note',
|
|
107
|
+
}}
|
|
108
|
+
dataAdapter={dataAdapter}
|
|
109
|
+
disableMarkdown
|
|
110
|
+
/>,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
await screen.findByText('Existing note')
|
|
114
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add note' }))
|
|
115
|
+
|
|
116
|
+
const textarea = await waitFor(() => {
|
|
117
|
+
const el = container.querySelector('textarea')
|
|
118
|
+
if (!el) throw new Error('composer not open')
|
|
119
|
+
return el as HTMLTextAreaElement
|
|
120
|
+
})
|
|
121
|
+
fireEvent.change(textarea, { target: { value: 'Conflicting note' } })
|
|
122
|
+
|
|
123
|
+
const form = container.querySelector('form')
|
|
124
|
+
expect(form).not.toBeNull()
|
|
125
|
+
fireEvent.submit(form as HTMLFormElement)
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(dataAdapter.create).toHaveBeenCalledTimes(1)
|
|
129
|
+
const entry = getRecordConflictForTest()
|
|
130
|
+
expect(entry).not.toBeNull()
|
|
131
|
+
expect(entry?.currentUpdatedAt).toBe('2026-06-02T00:00:00.000Z')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
63
134
|
})
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
registerRecordLockConflictHandler,
|
|
8
|
+
resetRecordLockConflictHandlerForTest,
|
|
9
|
+
surfaceRecordConflict,
|
|
10
|
+
} from '..'
|
|
11
|
+
|
|
12
|
+
const t = (key: string, fallback?: string) => fallback ?? key
|
|
13
|
+
|
|
14
|
+
function recordLockConflict() {
|
|
15
|
+
return new CrudHttpError(409, {
|
|
16
|
+
error: 'Record conflict detected',
|
|
17
|
+
code: 'record_lock_conflict',
|
|
18
|
+
lock: null,
|
|
19
|
+
conflict: { id: 'c1' },
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ossConflict() {
|
|
24
|
+
return new CrudHttpError(409, {
|
|
25
|
+
error: 'record_modified',
|
|
26
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
27
|
+
currentUpdatedAt: '2026-06-01T00:00:01.000Z',
|
|
28
|
+
expectedUpdatedAt: '2026-06-01T00:00:00.000Z',
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('surfaceRecordConflict — single surface (S3)', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
dismissRecordConflict()
|
|
35
|
+
resetRecordLockConflictHandlerForTest()
|
|
36
|
+
})
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
dismissRecordConflict()
|
|
39
|
+
resetRecordLockConflictHandlerForTest()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('defers a record_lock_conflict to the registered merge-dialog handler that owns the record (returns true → no OSS bar)', () => {
|
|
43
|
+
const handler = jest.fn(() => true)
|
|
44
|
+
registerRecordLockConflictHandler(handler)
|
|
45
|
+
|
|
46
|
+
const handled = surfaceRecordConflict(recordLockConflict(), t)
|
|
47
|
+
|
|
48
|
+
expect(handled).toBe(true)
|
|
49
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(handler.mock.calls[0][0]).toMatchObject({ code: 'record_lock_conflict' })
|
|
51
|
+
// The OSS conflict bar must NOT render when the dialog handles it.
|
|
52
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('SAFETY: handler registered but DECLINES the record (returns false) → OSS bar still renders (never swallowed)', () => {
|
|
56
|
+
const handler = jest.fn(() => false)
|
|
57
|
+
registerRecordLockConflictHandler(handler)
|
|
58
|
+
|
|
59
|
+
const handled = surfaceRecordConflict(recordLockConflict(), t)
|
|
60
|
+
|
|
61
|
+
expect(handled).toBe(true)
|
|
62
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
63
|
+
expect(getRecordConflictForTest()).not.toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('SAFETY: with NO handler registered, a record_lock_conflict still renders the OSS bar (never swallowed)', () => {
|
|
67
|
+
const handled = surfaceRecordConflict(recordLockConflict(), t)
|
|
68
|
+
expect(handled).toBe(true)
|
|
69
|
+
expect(getRecordConflictForTest()).not.toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders the OSS conflict bar for an optimistic_lock_conflict regardless of handler registration', () => {
|
|
73
|
+
const handler = jest.fn(() => true)
|
|
74
|
+
registerRecordLockConflictHandler(handler)
|
|
75
|
+
const handled = surfaceRecordConflict(ossConflict(), t)
|
|
76
|
+
expect(handled).toBe(true)
|
|
77
|
+
// The record-lock handler must NOT be consulted for an OSS optimistic-lock conflict.
|
|
78
|
+
expect(handler).not.toHaveBeenCalled()
|
|
79
|
+
const entry = getRecordConflictForTest()
|
|
80
|
+
expect(entry).not.toBeNull()
|
|
81
|
+
expect(entry?.currentUpdatedAt).toBe('2026-06-01T00:00:01.000Z')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('unregister restores the no-handler behavior', () => {
|
|
85
|
+
const handler = jest.fn(() => true)
|
|
86
|
+
const unregister = registerRecordLockConflictHandler(handler)
|
|
87
|
+
unregister()
|
|
88
|
+
|
|
89
|
+
surfaceRecordConflict(recordLockConflict(), t)
|
|
90
|
+
expect(handler).not.toHaveBeenCalled()
|
|
91
|
+
expect(getRecordConflictForTest()).not.toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns false for a non-conflict error', () => {
|
|
95
|
+
expect(surfaceRecordConflict(new CrudHttpError(422, { error: 'validation_failed' }), t)).toBe(false)
|
|
96
|
+
expect(surfaceRecordConflict(new Error('boom'), t)).toBe(false)
|
|
97
|
+
expect(getRecordConflictForTest()).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
extractOptimisticLockConflict,
|
|
3
|
+
extractRecordLockConflict,
|
|
4
|
+
type RecordLockConflictBody,
|
|
5
|
+
} from '../utils/optimisticLock'
|
|
2
6
|
import { showRecordConflict } from './store'
|
|
3
7
|
|
|
4
8
|
export {
|
|
@@ -21,29 +25,102 @@ export type SurfaceRecordConflictOptions = {
|
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* Handler the enterprise `record_locks` merge-dialog widget registers (on mount)
|
|
29
|
+
* so `surfaceRecordConflict` can defer the OSS conflict bar in favor of the
|
|
30
|
+
* field-level merge dialog. Core/UI stays enterprise-free: enterprise calls
|
|
31
|
+
* {@link registerRecordLockConflictHandler}; this module never imports it.
|
|
28
32
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* MUST return `true` only when it actually owns/renders the surface for THIS
|
|
34
|
+
* conflict (its widget is mounted for the conflicting record). Returning `false`
|
|
35
|
+
* (different record / cannot render) makes `surfaceRecordConflict` fall through
|
|
36
|
+
* to the OSS conflict bar, so a conflict is never swallowed and we never render
|
|
37
|
+
* BOTH the merge dialog and the bar (single surface, S3).
|
|
38
|
+
*/
|
|
39
|
+
export type RecordLockConflictHandler = (conflict: RecordLockConflictBody, error: unknown) => boolean
|
|
40
|
+
|
|
41
|
+
let recordLockConflictHandler: RecordLockConflictHandler | null = null
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register the record-lock conflict handler (enterprise merge-dialog widget).
|
|
45
|
+
* Returns an unregister function. When a handler is registered,
|
|
46
|
+
* `surfaceRecordConflict` defers `record_lock_conflict` 409s to it instead of
|
|
47
|
+
* rendering the OSS conflict bar (single surface, S3).
|
|
48
|
+
*
|
|
49
|
+
* SAFETY: when NO handler is registered (widget absent/removed),
|
|
50
|
+
* `surfaceRecordConflict` still renders the OSS conflict bar for ANY 409 — a
|
|
51
|
+
* conflict is never silently swallowed.
|
|
52
|
+
*/
|
|
53
|
+
export function registerRecordLockConflictHandler(handler: RecordLockConflictHandler): () => void {
|
|
54
|
+
recordLockConflictHandler = handler
|
|
55
|
+
return () => {
|
|
56
|
+
if (recordLockConflictHandler === handler) recordLockConflictHandler = null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Test-only: inspect whether a record-lock conflict handler is registered. */
|
|
61
|
+
export function hasRecordLockConflictHandlerForTest(): boolean {
|
|
62
|
+
return recordLockConflictHandler !== null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Test-only: clear any registered record-lock conflict handler. */
|
|
66
|
+
export function resetRecordLockConflictHandlerForTest(): void {
|
|
67
|
+
recordLockConflictHandler = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Single conflict surface (S3). Resolves a 409 to exactly one UI:
|
|
72
|
+
*
|
|
73
|
+
* - `record_lock_conflict` payload AND a merge-dialog handler registered →
|
|
74
|
+
* defer to the handler (no conflict bar) and return `true`.
|
|
75
|
+
* - otherwise (OSS `optimistic_lock_conflict`, OR a `record_lock_conflict`
|
|
76
|
+
* with NO handler registered) → render the OSS conflict bar and return
|
|
77
|
+
* `true`. A conflict is ALWAYS surfaced; the worst case is plainer UX, never
|
|
78
|
+
* a silently-swallowed conflict.
|
|
79
|
+
* - not a recognized conflict → return `false` so callers fall back to their
|
|
80
|
+
* normal error handling.
|
|
31
81
|
*/
|
|
32
82
|
export function surfaceRecordConflict(
|
|
33
83
|
error: unknown,
|
|
34
84
|
t: Translate,
|
|
35
85
|
options: SurfaceRecordConflictOptions = {},
|
|
36
86
|
): boolean {
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
87
|
+
const recordLockConflict = extractRecordLockConflict(error)
|
|
88
|
+
if (recordLockConflict && recordLockConflictHandler) {
|
|
89
|
+
// The widget owns the surface ONLY when it handles this record's conflict
|
|
90
|
+
// (returns true). If it declines (different record / not mounted for it),
|
|
91
|
+
// fall through to the OSS bar so the conflict is never swallowed and we
|
|
92
|
+
// never render both the merge dialog AND the bar.
|
|
93
|
+
if (recordLockConflictHandler(recordLockConflict, error)) return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ossConflict = extractOptimisticLockConflict(error)
|
|
97
|
+
if (ossConflict) {
|
|
98
|
+
showRecordConflict({
|
|
99
|
+
message: t(
|
|
100
|
+
'ui.forms.flash.recordModified',
|
|
101
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
102
|
+
),
|
|
103
|
+
title: options.title ?? null,
|
|
104
|
+
currentUpdatedAt: ossConflict.currentUpdatedAt,
|
|
105
|
+
onRefresh: options.onRefresh ?? null,
|
|
106
|
+
})
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// No handler is registered but the server returned a record-lock 409: still
|
|
111
|
+
// render the OSS bar so the conflict is never swallowed.
|
|
112
|
+
if (recordLockConflict) {
|
|
113
|
+
showRecordConflict({
|
|
114
|
+
message: t(
|
|
115
|
+
'ui.forms.flash.recordModified',
|
|
116
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
117
|
+
),
|
|
118
|
+
title: options.title ?? null,
|
|
119
|
+
currentUpdatedAt: null,
|
|
120
|
+
onRefresh: options.onRefresh ?? null,
|
|
121
|
+
})
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return false
|
|
49
126
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
5
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
5
6
|
import { LoadingMessage } from '@open-mercato/ui/backend/detail'
|
|
6
7
|
import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
7
8
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -40,13 +41,14 @@ export type AddressSummary = {
|
|
|
40
41
|
postalCode?: string | null
|
|
41
42
|
country?: string | null
|
|
42
43
|
isPrimary?: boolean
|
|
44
|
+
updatedAt?: string | null
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export type AddressDataAdapter<C = unknown> = {
|
|
46
48
|
list: (params: { entityId: string | null; context?: C }) => Promise<AddressSummary[]>
|
|
47
49
|
create: (params: { entityId: string; payload: AddressInput; context?: C }) => Promise<{ id?: string } | void>
|
|
48
|
-
update: (params: { id: string; payload: AddressInput; context?: C }) => Promise<void>
|
|
49
|
-
delete: (params: { id: string; context?: C }) => Promise<void>
|
|
50
|
+
update: (params: { id: string; payload: AddressInput; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
51
|
+
delete: (params: { id: string; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export type AddressesSectionProps<C = unknown> = {
|
|
@@ -205,7 +207,8 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
205
207
|
pushLoading()
|
|
206
208
|
setIsSubmitting(true)
|
|
207
209
|
try {
|
|
208
|
-
|
|
210
|
+
const addressUpdatedAt = addresses.find((address) => address.id === id)?.updatedAt ?? null
|
|
211
|
+
await dataAdapter.update({ id, payload, updatedAt: addressUpdatedAt, context: dataContext })
|
|
209
212
|
setAddresses((prev) => {
|
|
210
213
|
return prev.map((address) => {
|
|
211
214
|
if (address.id !== id) {
|
|
@@ -230,6 +233,7 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
230
233
|
})
|
|
231
234
|
flash(label('success', 'Address saved.'), 'success')
|
|
232
235
|
} catch (err) {
|
|
236
|
+
surfaceRecordConflict(err, t)
|
|
233
237
|
const message =
|
|
234
238
|
err instanceof Error && err.message ? err.message : label('error', 'Failed to save address.')
|
|
235
239
|
const error = new Error(message) as Error & { details?: unknown }
|
|
@@ -242,7 +246,7 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
242
246
|
popLoading()
|
|
243
247
|
}
|
|
244
248
|
},
|
|
245
|
-
[dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading],
|
|
249
|
+
[addresses, dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading, t],
|
|
246
250
|
)
|
|
247
251
|
|
|
248
252
|
const handleDelete = React.useCallback(
|
|
@@ -253,20 +257,21 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
253
257
|
pushLoading()
|
|
254
258
|
setIsSubmitting(true)
|
|
255
259
|
try {
|
|
256
|
-
|
|
260
|
+
const addressUpdatedAt = addresses.find((address) => address.id === id)?.updatedAt ?? null
|
|
261
|
+
await dataAdapter.delete({ id, updatedAt: addressUpdatedAt, context: dataContext })
|
|
257
262
|
setAddresses((prev) => prev.filter((address) => address.id !== id))
|
|
258
263
|
flash(label('deleted', 'Address deleted.'), 'success')
|
|
259
264
|
} catch (err) {
|
|
260
265
|
const message =
|
|
261
266
|
err instanceof Error && err.message ? err.message : label('error', 'Failed to delete address.')
|
|
262
|
-
flash(message, 'error')
|
|
267
|
+
if (!surfaceRecordConflict(err, t)) flash(message, 'error')
|
|
263
268
|
throw err
|
|
264
269
|
} finally {
|
|
265
270
|
setIsSubmitting(false)
|
|
266
271
|
popLoading()
|
|
267
272
|
}
|
|
268
273
|
},
|
|
269
|
-
[dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading],
|
|
274
|
+
[addresses, dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading, t],
|
|
270
275
|
)
|
|
271
276
|
|
|
272
277
|
const displayAddresses = React.useMemo<AddressValue[]>(() => {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
SelectValue,
|
|
17
17
|
} from '@open-mercato/ui/primitives/select'
|
|
18
18
|
import { flash } from '../FlashMessages'
|
|
19
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
19
20
|
import { SwitchableMarkdownInput } from '../inputs/SwitchableMarkdownInput'
|
|
20
21
|
import { ErrorMessage } from './ErrorMessage'
|
|
21
22
|
import { LoadingMessage } from './LoadingMessage'
|
|
@@ -48,6 +49,7 @@ export type CommentSummary = {
|
|
|
48
49
|
id: string
|
|
49
50
|
body: string
|
|
50
51
|
createdAt: string
|
|
52
|
+
updatedAt?: string | null
|
|
51
53
|
authorUserId?: string | null
|
|
52
54
|
authorName?: string | null
|
|
53
55
|
authorEmail?: string | null
|
|
@@ -87,8 +89,8 @@ export type NotesDataAdapter<C = unknown> = {
|
|
|
87
89
|
totalPages: number
|
|
88
90
|
}>
|
|
89
91
|
create: (params: NotesCreatePayload & { context?: C }) => Promise<Partial<CommentSummary> | void>
|
|
90
|
-
update: (params: { id: string; patch: NotesUpdatePayload; context?: C }) => Promise<void>
|
|
91
|
-
delete: (params: { id: string; context?: C }) => Promise<void>
|
|
92
|
+
update: (params: { id: string; patch: NotesUpdatePayload; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
93
|
+
delete: (params: { id: string; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
type RenderIconFn = (icon: string, className?: string) => React.ReactNode
|
|
@@ -221,6 +223,12 @@ export function mapCommentSummary(input: unknown): CommentSummary {
|
|
|
221
223
|
: typeof data.created_at === 'string'
|
|
222
224
|
? data.created_at
|
|
223
225
|
: new Date().toISOString()
|
|
226
|
+
const updatedAt =
|
|
227
|
+
typeof data.updatedAt === 'string'
|
|
228
|
+
? data.updatedAt
|
|
229
|
+
: typeof data.updated_at === 'string'
|
|
230
|
+
? data.updated_at
|
|
231
|
+
: null
|
|
224
232
|
const authorUserId =
|
|
225
233
|
typeof data.authorUserId === 'string'
|
|
226
234
|
? data.authorUserId
|
|
@@ -267,6 +275,7 @@ export function mapCommentSummary(input: unknown): CommentSummary {
|
|
|
267
275
|
id,
|
|
268
276
|
body,
|
|
269
277
|
createdAt,
|
|
278
|
+
updatedAt,
|
|
270
279
|
authorUserId,
|
|
271
280
|
authorName,
|
|
272
281
|
authorEmail,
|
|
@@ -678,7 +687,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
678
687
|
return true
|
|
679
688
|
} catch (err) {
|
|
680
689
|
const message = err instanceof Error ? err.message : label('error')
|
|
681
|
-
flash(message, 'error')
|
|
690
|
+
if (!surfaceRecordConflict(err, t)) flash(message, 'error')
|
|
682
691
|
return false
|
|
683
692
|
} finally {
|
|
684
693
|
setIsSubmitting(false)
|
|
@@ -699,6 +708,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
699
708
|
: undefined
|
|
700
709
|
const sanitizedColor =
|
|
701
710
|
patch.appearanceColor !== undefined ? sanitizeHexColor(patch.appearanceColor ?? null) : undefined
|
|
711
|
+
const noteUpdatedAt = notes.find((comment) => comment.id === noteId)?.updatedAt ?? null
|
|
702
712
|
try {
|
|
703
713
|
await dataAdapter.update({
|
|
704
714
|
id: noteId,
|
|
@@ -707,6 +717,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
707
717
|
appearanceIcon: sanitizedIcon,
|
|
708
718
|
appearanceColor: sanitizedColor,
|
|
709
719
|
},
|
|
720
|
+
updatedAt: noteUpdatedAt,
|
|
710
721
|
context: dataContext,
|
|
711
722
|
})
|
|
712
723
|
setNotes((prev) => {
|
|
@@ -723,11 +734,11 @@ function NotesSectionImpl<C = unknown>({
|
|
|
723
734
|
flash(label('updateSuccess'), 'success')
|
|
724
735
|
} catch (error) {
|
|
725
736
|
const message = error instanceof Error ? error.message : label('updateError')
|
|
726
|
-
flash(message, 'error')
|
|
737
|
+
if (!surfaceRecordConflict(error, t)) flash(message, 'error')
|
|
727
738
|
throw error instanceof Error ? error : new Error(message)
|
|
728
739
|
}
|
|
729
740
|
},
|
|
730
|
-
[dataAdapter, dataContext, t],
|
|
741
|
+
[dataAdapter, dataContext, notes, t],
|
|
731
742
|
)
|
|
732
743
|
|
|
733
744
|
const handleDeleteNote = React.useCallback(
|
|
@@ -740,7 +751,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
740
751
|
setDeletingNoteId(note.id)
|
|
741
752
|
pushLoading()
|
|
742
753
|
try {
|
|
743
|
-
await dataAdapter.delete({ id: note.id, context: dataContext })
|
|
754
|
+
await dataAdapter.delete({ id: note.id, updatedAt: note.updatedAt ?? null, context: dataContext })
|
|
744
755
|
setNotes((prev) => prev.filter((existing) => existing.id !== note.id))
|
|
745
756
|
if (pagedMode) {
|
|
746
757
|
setVisibleCount((prev) => Math.max(0, prev - 1))
|
|
@@ -748,13 +759,13 @@ function NotesSectionImpl<C = unknown>({
|
|
|
748
759
|
flash(label('deleteSuccess', 'Note deleted'), 'success')
|
|
749
760
|
} catch (err) {
|
|
750
761
|
const message = err instanceof Error ? err.message : label('deleteError', 'Failed to delete note')
|
|
751
|
-
flash(message, 'error')
|
|
762
|
+
if (!surfaceRecordConflict(err, t)) flash(message, 'error')
|
|
752
763
|
} finally {
|
|
753
764
|
setDeletingNoteId(null)
|
|
754
765
|
popLoading()
|
|
755
766
|
}
|
|
756
767
|
},
|
|
757
|
-
[confirm, dataAdapter, dataContext, label, popLoading, pushLoading],
|
|
768
|
+
[confirm, dataAdapter, dataContext, label, popLoading, pushLoading, t],
|
|
758
769
|
)
|
|
759
770
|
|
|
760
771
|
const handleSubmit = React.useCallback(
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AppShell-owned transport for the current detail record's injection context
|
|
6
|
+
* (Phase 0 / S2). The `backend:record:current` injection spot is mounted once,
|
|
7
|
+
* globally, by `AppShell` — before page `children` — so a page cannot pass props
|
|
8
|
+
* "up" to it. Instead, a detail page publishes its record context through this
|
|
9
|
+
* provider; `AppShell` merges it into the spot's base `{ path, query }` context.
|
|
10
|
+
*
|
|
11
|
+
* Keeps `packages/ui`/core enterprise-free: this is a plain context object, no
|
|
12
|
+
* record_locks dependency, no second `<InjectionSpot>`.
|
|
13
|
+
*/
|
|
14
|
+
export type RecordInjectionContext = {
|
|
15
|
+
/** record_locks resource key, e.g. `customers.person`, `sales.order`. */
|
|
16
|
+
resourceKind: string
|
|
17
|
+
/** The record's id. */
|
|
18
|
+
resourceId: string
|
|
19
|
+
/** Server `updated_at` (ISO) for the optimistic-lock floor + action-log base. */
|
|
20
|
+
updatedAt?: string | null
|
|
21
|
+
/** Optional record payload for field-level merge UX. */
|
|
22
|
+
data?: Record<string, unknown> | null
|
|
23
|
+
/** The detail screen path this context belongs to (used for stale-context guarding). */
|
|
24
|
+
path?: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a normalized record injection context. Returns `null` when the required
|
|
29
|
+
* keys are missing so callers can publish unconditionally.
|
|
30
|
+
*/
|
|
31
|
+
export function buildRecordInjectionContext(input: {
|
|
32
|
+
resourceKind: string | null | undefined
|
|
33
|
+
resourceId: string | null | undefined
|
|
34
|
+
updatedAt?: string | Date | null
|
|
35
|
+
data?: Record<string, unknown> | null
|
|
36
|
+
path?: string | null
|
|
37
|
+
}): RecordInjectionContext | null {
|
|
38
|
+
const resourceKind = typeof input.resourceKind === 'string' ? input.resourceKind.trim() : ''
|
|
39
|
+
const resourceId = typeof input.resourceId === 'string' ? input.resourceId.trim() : ''
|
|
40
|
+
if (!resourceKind || !resourceId) return null
|
|
41
|
+
const updatedAt = input.updatedAt instanceof Date
|
|
42
|
+
? input.updatedAt.toISOString()
|
|
43
|
+
: (typeof input.updatedAt === 'string' && input.updatedAt.trim().length ? input.updatedAt.trim() : null)
|
|
44
|
+
return {
|
|
45
|
+
resourceKind,
|
|
46
|
+
resourceId,
|
|
47
|
+
updatedAt,
|
|
48
|
+
data: input.data ?? null,
|
|
49
|
+
path: typeof input.path === 'string' && input.path.length ? input.path : null,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type RecordInjectionContextSetter = (context: RecordInjectionContext | null) => void
|
|
54
|
+
|
|
55
|
+
const BackendRecordInjectionContextSetterContext = React.createContext<RecordInjectionContextSetter | null>(null)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* AppShell renders this provider around page `children`, supplying the setter
|
|
59
|
+
* that publishes record context into AppShell-owned state. Detail pages call
|
|
60
|
+
* `useSetCurrentRecordInjectionContext()` to publish/clear.
|
|
61
|
+
*/
|
|
62
|
+
export function BackendRecordInjectionContextProvider({
|
|
63
|
+
setCurrentRecordInjectionContext,
|
|
64
|
+
children,
|
|
65
|
+
}: {
|
|
66
|
+
setCurrentRecordInjectionContext: RecordInjectionContextSetter
|
|
67
|
+
children: React.ReactNode
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<BackendRecordInjectionContextSetterContext.Provider value={setCurrentRecordInjectionContext}>
|
|
71
|
+
{children}
|
|
72
|
+
</BackendRecordInjectionContextSetterContext.Provider>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Publish the current detail record's injection context to the AppShell-owned
|
|
78
|
+
* `backend:record:current` mount, and clear it automatically on unmount /
|
|
79
|
+
* dependency change. Pass `null` to clear (e.g. while the record is loading).
|
|
80
|
+
*
|
|
81
|
+
* ```tsx
|
|
82
|
+
* useSetCurrentRecordInjectionContext(
|
|
83
|
+
* buildRecordInjectionContext({ resourceKind: 'customers.person', resourceId: id, updatedAt, data, path }),
|
|
84
|
+
* )
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function useSetCurrentRecordInjectionContext(context: RecordInjectionContext | null): void {
|
|
88
|
+
const setter = React.useContext(BackendRecordInjectionContextSetterContext)
|
|
89
|
+
// Serialize so we only re-publish when the meaningful fields change.
|
|
90
|
+
const serialized = context
|
|
91
|
+
? JSON.stringify({ k: context.resourceKind, i: context.resourceId, u: context.updatedAt ?? null, p: context.path ?? null })
|
|
92
|
+
: null
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
if (!setter) return
|
|
95
|
+
setter(context)
|
|
96
|
+
return () => {
|
|
97
|
+
setter(null)
|
|
98
|
+
}
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
}, [setter, serialized])
|
|
101
|
+
}
|
|
@@ -68,3 +68,39 @@ export function extractOptimisticLockConflict(
|
|
|
68
68
|
expectedUpdatedAt,
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The enterprise `record_locks` 409 conflict code. Kept as a literal here so
|
|
74
|
+
* core/UI stays enterprise-free (no import from `@open-mercato/enterprise`).
|
|
75
|
+
*/
|
|
76
|
+
export const RECORD_LOCK_CONFLICT_CODE = 'record_lock_conflict' as const
|
|
77
|
+
|
|
78
|
+
export type RecordLockConflictBody = {
|
|
79
|
+
code: typeof RECORD_LOCK_CONFLICT_CODE
|
|
80
|
+
error?: string
|
|
81
|
+
lock?: unknown
|
|
82
|
+
conflict?: unknown
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Detect whether an error is an enterprise `record_locks` conflict (HTTP 409
|
|
87
|
+
* with `code: 'record_lock_conflict'`). Returns the conflict body when matched
|
|
88
|
+
* so `surfaceRecordConflict` can defer to the merge-dialog widget, or `null`.
|
|
89
|
+
*/
|
|
90
|
+
export function extractRecordLockConflict(err: unknown): RecordLockConflictBody | null {
|
|
91
|
+
if (!err || typeof err !== 'object') return null
|
|
92
|
+
const candidate = err as Record<string, unknown>
|
|
93
|
+
if (candidate.status !== 409) return null
|
|
94
|
+
const body = candidate.body && typeof candidate.body === 'object'
|
|
95
|
+
? candidate.body
|
|
96
|
+
: candidate
|
|
97
|
+
if (!body || typeof body !== 'object') return null
|
|
98
|
+
const bodyRecord = body as Record<string, unknown>
|
|
99
|
+
if (bodyRecord.code !== RECORD_LOCK_CONFLICT_CODE) return null
|
|
100
|
+
return {
|
|
101
|
+
code: RECORD_LOCK_CONFLICT_CODE,
|
|
102
|
+
error: typeof bodyRecord.error === 'string' ? bodyRecord.error : undefined,
|
|
103
|
+
lock: bodyRecord.lock ?? null,
|
|
104
|
+
conflict: bodyRecord.conflict ?? null,
|
|
105
|
+
}
|
|
106
|
+
}
|