@open-mercato/ui 0.6.5-develop.4516.1.88e6ab71a9 → 0.6.5-develop.4544.1.71c003c861

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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +120 -15
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +20 -8
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/PerspectiveSidebar.js +8 -1
  10. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  11. package/dist/backend/RowActions.js +1 -1
  12. package/dist/backend/RowActions.js.map +2 -2
  13. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  14. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  15. package/dist/backend/conflicts/index.js +32 -0
  16. package/dist/backend/conflicts/index.js.map +7 -0
  17. package/dist/backend/conflicts/store.js +50 -0
  18. package/dist/backend/conflicts/store.js.map +7 -0
  19. package/dist/backend/injection/useGuardedMutation.js +8 -1
  20. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  21. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  23. package/dist/backend/utils/optimisticLock.js +34 -0
  24. package/dist/backend/utils/optimisticLock.js.map +7 -0
  25. package/package.json +7 -3
  26. package/src/backend/AppShell.tsx +10 -0
  27. package/src/backend/CrudForm.tsx +189 -12
  28. package/src/backend/DataTable.tsx +22 -8
  29. package/src/backend/PerspectiveSidebar.tsx +8 -1
  30. package/src/backend/RowActions.tsx +1 -1
  31. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +312 -0
  32. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
  33. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  34. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  35. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  36. package/src/backend/conflicts/index.ts +49 -0
  37. package/src/backend/conflicts/store.ts +94 -0
  38. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  39. package/src/backend/injection/useGuardedMutation.ts +16 -1
  40. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  41. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  42. package/src/backend/utils/optimisticLock.ts +70 -0
@@ -0,0 +1,312 @@
1
+ /** @jest-environment jsdom */
2
+ jest.setTimeout(15000)
3
+
4
+ // Regression coverage for issue #2503 ("Latent framework gap"): CrudForm must
5
+ // honor dot-path ids for declared base fields — hydrate the flat key from nested
6
+ // initial values on load, and project the flat key back into a nested object
7
+ // before schema.safeParse on submit. The fix coexists with forms whose schema
8
+ // declares the flat dot-path key directly (the module-local remedy shipped in
9
+ // PR #2513), because the non-strict parse keeps whichever shape the schema declares.
10
+
11
+ const triggerEventMock = jest.fn()
12
+
13
+ jest.mock('next/navigation', () => ({
14
+ useRouter: () => ({ push: jest.fn() }),
15
+ usePathname: () => '/',
16
+ useSearchParams: () => new URLSearchParams(),
17
+ }))
18
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
19
+ jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
20
+ jest.mock('../confirm-dialog', () => ({
21
+ useConfirmDialog: () => ({
22
+ confirm: jest.fn().mockResolvedValue(true),
23
+ ConfirmDialogElement: null,
24
+ }),
25
+ }))
26
+ jest.mock('../injection/InjectionSpot', () => ({
27
+ __esModule: true,
28
+ InjectionSpot: () => null,
29
+ useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
30
+ useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
31
+ }))
32
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
33
+ __esModule: true,
34
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
35
+ }))
36
+
37
+ import * as React from 'react'
38
+ import { act, fireEvent, screen } from '@testing-library/react'
39
+ import { z } from 'zod'
40
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
41
+ import { CrudForm, type CrudField } from '../CrudForm'
42
+
43
+ const dict = { 'ui.forms.actions.save': 'Save' }
44
+
45
+ const dotPathFields: CrudField[] = [
46
+ { id: 'workflowName', label: 'Name', type: 'text' },
47
+ { id: 'metadata.category', label: 'Category', type: 'text' },
48
+ ]
49
+
50
+ function flushMicrotasks() {
51
+ return act(async () => {
52
+ await Promise.resolve()
53
+ await Promise.resolve()
54
+ await Promise.resolve()
55
+ })
56
+ }
57
+
58
+ describe('CrudForm dot-path base fields (issue #2503)', () => {
59
+ beforeEach(() => {
60
+ triggerEventMock.mockReset()
61
+ triggerEventMock.mockImplementation(async (event: string, data: Record<string, unknown>) => {
62
+ if (event === 'onBeforeSave') return { ok: true }
63
+ if (event === 'onAfterSave') return { ok: true }
64
+ return { ok: true, data }
65
+ })
66
+ window.history.replaceState({}, '', '/current')
67
+ })
68
+
69
+ it('hydrates a declared dot-path field from nested initial values on load', async () => {
70
+ renderWithProviders(
71
+ <CrudForm
72
+ title="Edit"
73
+ fields={dotPathFields}
74
+ initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
75
+ onSubmit={() => {}}
76
+ />,
77
+ { dict },
78
+ )
79
+
80
+ await flushMicrotasks()
81
+
82
+ // The Category input reads values['metadata.category'], hydrated from the
83
+ // nested metadata.category in initialValues.
84
+ expect(screen.queryByDisplayValue('Sales')).toBeInTheDocument()
85
+ })
86
+
87
+ it('collapses the flat dot-path key into a nested object for a nested schema on save', async () => {
88
+ const schema = z.object({
89
+ workflowName: z.string(),
90
+ metadata: z
91
+ .object({ category: z.string().optional() })
92
+ .optional()
93
+ .nullable(),
94
+ })
95
+
96
+ function Harness() {
97
+ const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
98
+ return (
99
+ <>
100
+ <CrudForm
101
+ title="Edit"
102
+ fields={dotPathFields}
103
+ schema={schema as any}
104
+ initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
105
+ onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
106
+ />
107
+ <div data-testid="submitted">{JSON.stringify(submitted)}</div>
108
+ </>
109
+ )
110
+ }
111
+
112
+ const { container } = renderWithProviders(<Harness />, { dict })
113
+ await flushMicrotasks()
114
+
115
+ const categoryInput = container.querySelector(
116
+ '[data-crud-field-id="metadata.category"] input[type="text"]',
117
+ ) as HTMLInputElement
118
+ const form = container.querySelector('form') as HTMLFormElement
119
+
120
+ await act(async () => {
121
+ fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
122
+ fireEvent.blur(categoryInput)
123
+ })
124
+
125
+ await act(async () => {
126
+ fireEvent.submit(form)
127
+ await Promise.resolve()
128
+ await Promise.resolve()
129
+ })
130
+
131
+ const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
132
+ expect(submitted).not.toBeNull()
133
+ // Edited value is reassembled into the nested metadata object the schema declares.
134
+ expect(submitted.metadata).toEqual({ category: 'qa-category-2466' })
135
+ // The flat dot-path key is stripped by the non-strict parse for a nested schema.
136
+ expect(submitted['metadata.category']).toBeUndefined()
137
+ })
138
+
139
+ it('preserves the flat dot-path key for a schema that declares it directly (PR #2513 coexistence)', async () => {
140
+ const schema = z.object({
141
+ workflowName: z.string(),
142
+ 'metadata.category': z.string().optional(),
143
+ })
144
+
145
+ function Harness() {
146
+ const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
147
+ return (
148
+ <>
149
+ <CrudForm
150
+ title="Edit"
151
+ fields={dotPathFields}
152
+ schema={schema as any}
153
+ initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
154
+ onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
155
+ />
156
+ <div data-testid="submitted">{JSON.stringify(submitted)}</div>
157
+ </>
158
+ )
159
+ }
160
+
161
+ const { container } = renderWithProviders(<Harness />, { dict })
162
+ await flushMicrotasks()
163
+
164
+ const categoryInput = container.querySelector(
165
+ '[data-crud-field-id="metadata.category"] input[type="text"]',
166
+ ) as HTMLInputElement
167
+ const form = container.querySelector('form') as HTMLFormElement
168
+
169
+ await act(async () => {
170
+ fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
171
+ fireEvent.blur(categoryInput)
172
+ })
173
+
174
+ await act(async () => {
175
+ fireEvent.submit(form)
176
+ await Promise.resolve()
177
+ await Promise.resolve()
178
+ })
179
+
180
+ const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
181
+ expect(submitted).not.toBeNull()
182
+ // A flat-key schema keeps the flat representation untouched.
183
+ expect(submitted['metadata.category']).toBe('qa-category-2466')
184
+ expect(submitted.metadata).toBeUndefined()
185
+ })
186
+
187
+ it('does not flag a required nested field on blur once the dot-path input is filled', async () => {
188
+ const schema = z.object({
189
+ workflowName: z.string(),
190
+ metadata: z.object({ category: z.string().min(1) }),
191
+ })
192
+
193
+ const { container } = renderWithProviders(
194
+ <CrudForm
195
+ title="Edit"
196
+ fields={dotPathFields}
197
+ schema={schema as any}
198
+ initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
199
+ onSubmit={() => {}}
200
+ />,
201
+ { dict },
202
+ )
203
+
204
+ await flushMicrotasks()
205
+
206
+ const categoryInput = container.querySelector(
207
+ '[data-crud-field-id="metadata.category"] input[type="text"]',
208
+ ) as HTMLInputElement
209
+
210
+ await act(async () => {
211
+ fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
212
+ fireEvent.blur(categoryInput)
213
+ await Promise.resolve()
214
+ await Promise.resolve()
215
+ })
216
+
217
+ // Blur validation collapses the flat key before schema.safeParse, so the
218
+ // required nested metadata.category is seen as satisfied — no error surfaces.
219
+ const fieldContainer = container.querySelector('[data-crud-field-id="metadata.category"]') as HTMLElement
220
+ expect(fieldContainer.querySelector('.text-status-error-text')).toBeNull()
221
+ })
222
+
223
+ it('merges multiple dot-path fields sharing a parent into one nested object', async () => {
224
+ const fields: CrudField[] = [
225
+ { id: 'workflowName', label: 'Name', type: 'text' },
226
+ { id: 'metadata.category', label: 'Category', type: 'text' },
227
+ { id: 'metadata.icon', label: 'Icon', type: 'text' },
228
+ ]
229
+ const schema = z.object({
230
+ workflowName: z.string(),
231
+ metadata: z
232
+ .object({ category: z.string().optional(), icon: z.string().optional() })
233
+ .optional(),
234
+ })
235
+
236
+ function Harness() {
237
+ const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
238
+ return (
239
+ <>
240
+ <CrudForm
241
+ title="Edit"
242
+ fields={fields}
243
+ schema={schema as any}
244
+ initialValues={{
245
+ id: 'wf-1',
246
+ workflowName: 'Pipeline',
247
+ metadata: { category: 'Sales', icon: 'shopping-cart' },
248
+ } as any}
249
+ onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
250
+ />
251
+ <div data-testid="submitted">{JSON.stringify(submitted)}</div>
252
+ </>
253
+ )
254
+ }
255
+
256
+ const { container } = renderWithProviders(<Harness />, { dict })
257
+ await flushMicrotasks()
258
+
259
+ const form = container.querySelector('form') as HTMLFormElement
260
+ await act(async () => {
261
+ fireEvent.submit(form)
262
+ await Promise.resolve()
263
+ await Promise.resolve()
264
+ })
265
+
266
+ const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
267
+ expect(submitted.metadata).toEqual({ category: 'Sales', icon: 'shopping-cart' })
268
+ })
269
+
270
+ it('does not pollute Object.prototype via a prototype-polluting dot-path field id', async () => {
271
+ const maliciousFields: CrudField[] = [
272
+ { id: 'workflowName', label: 'Name', type: 'text' },
273
+ { id: '__proto__.polluted', label: 'Evil', type: 'text' },
274
+ ]
275
+ const schema = z.object({ workflowName: z.string() }).passthrough()
276
+
277
+ function Harness() {
278
+ const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
279
+ return (
280
+ <>
281
+ <CrudForm
282
+ title="Edit"
283
+ fields={maliciousFields}
284
+ schema={schema as any}
285
+ initialValues={{ id: 'wf-1', workflowName: 'Pipeline', '__proto__.polluted': 'pwned' } as any}
286
+ onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
287
+ />
288
+ <div data-testid="submitted">{JSON.stringify(submitted)}</div>
289
+ </>
290
+ )
291
+ }
292
+
293
+ const { container } = renderWithProviders(<Harness />, { dict })
294
+ await flushMicrotasks()
295
+
296
+ const form = container.querySelector('form') as HTMLFormElement
297
+ await act(async () => {
298
+ fireEvent.submit(form)
299
+ await Promise.resolve()
300
+ await Promise.resolve()
301
+ })
302
+
303
+ // The collapse step must skip prototype-polluting segments entirely, so
304
+ // neither a fresh object nor Object.prototype gains the injected key.
305
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined()
306
+ expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined()
307
+ // The submit still completes for the legitimate fields.
308
+ const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
309
+ expect(submitted).not.toBeNull()
310
+ expect(submitted.workflowName).toBe('Pipeline')
311
+ })
312
+ })
@@ -0,0 +1,288 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import {
4
+ OPTIMISTIC_LOCK_CONFLICT_CODE,
5
+ OPTIMISTIC_LOCK_HEADER_NAME,
6
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
7
+
8
+ const buildFormFieldFromCustomFieldDefMock = jest.fn()
9
+ const fetchCustomFieldFormStructureMock = jest.fn()
10
+ const withScopedApiRequestHeadersMock = jest.fn(async (_headers: Record<string, string>, fn: () => Promise<unknown>) => fn())
11
+ const flashMock = jest.fn()
12
+
13
+ jest.mock('next/navigation', () => ({
14
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
15
+ usePathname: () => '/',
16
+ useSearchParams: () => new URLSearchParams(),
17
+ }))
18
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
19
+ jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
20
+ jest.mock('../confirm-dialog', () => ({
21
+ useConfirmDialog: () => ({
22
+ confirm: jest.fn().mockResolvedValue(true),
23
+ ConfirmDialogElement: null,
24
+ }),
25
+ }))
26
+ jest.mock('../custom-fields/FieldDefinitionsManager', () => {
27
+ const React = require('react')
28
+ return {
29
+ __esModule: true,
30
+ FieldDefinitionsManager: React.forwardRef(() => <div>Field definitions manager</div>),
31
+ }
32
+ })
33
+ jest.mock('../utils/customFieldForms', () => ({
34
+ __esModule: true,
35
+ buildFormFieldFromCustomFieldDef: (...args: unknown[]) => buildFormFieldFromCustomFieldDefMock(...args),
36
+ buildFormFieldsFromCustomFields: jest.fn(() => []),
37
+ fetchCustomFieldFormStructure: (...args: unknown[]) => fetchCustomFieldFormStructureMock(...args),
38
+ }))
39
+ jest.mock('../utils/apiCall', () => {
40
+ const actual = jest.requireActual('../utils/apiCall')
41
+ return {
42
+ ...actual,
43
+ withScopedApiRequestHeaders: (...args: unknown[]) =>
44
+ withScopedApiRequestHeadersMock(
45
+ args[0] as Record<string, string>,
46
+ args[1] as () => Promise<unknown>,
47
+ ),
48
+ }
49
+ })
50
+ jest.mock('../FlashMessages', () => ({
51
+ flash: (...args: unknown[]) => flashMock(...args),
52
+ }))
53
+
54
+ import * as React from 'react'
55
+ import { act, fireEvent, waitFor } from '@testing-library/react'
56
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
57
+ import { CrudForm, type CrudField } from '../CrudForm'
58
+ import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
59
+
60
+ const dict = {
61
+ 'ui.forms.actions.save': 'Save',
62
+ 'ui.forms.flash.recordModified': 'This record was modified by someone else. Refresh and try again.',
63
+ }
64
+
65
+ const fields: CrudField[] = [
66
+ { id: 'displayName', label: 'Display name', type: 'text' },
67
+ ]
68
+
69
+ function renderForm(opts: {
70
+ optimisticLockUpdatedAt?: string | null
71
+ onSubmit: jest.Mock
72
+ }) {
73
+ return renderWithProviders(
74
+ <CrudForm<{ displayName: string; id?: string }>
75
+ fields={fields}
76
+ initialValues={{ id: 'rec-1', displayName: 'old' }}
77
+ onSubmit={opts.onSubmit}
78
+ optimisticLockUpdatedAt={opts.optimisticLockUpdatedAt}
79
+ />,
80
+ { dict },
81
+ )
82
+ }
83
+
84
+ describe('CrudForm — optimisticLockUpdatedAt prop wiring', () => {
85
+ beforeEach(() => {
86
+ flashMock.mockClear()
87
+ withScopedApiRequestHeadersMock.mockClear()
88
+ fetchCustomFieldFormStructureMock.mockResolvedValue({
89
+ fields: [],
90
+ definitions: [],
91
+ metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
92
+ })
93
+ })
94
+
95
+ it('wraps onSubmit in withScopedApiRequestHeaders carrying the extension header when prop is set', async () => {
96
+ const onSubmit = jest.fn(async () => undefined)
97
+ const { container } = renderForm({ optimisticLockUpdatedAt: '2026-05-25T08:42:18.123Z', onSubmit })
98
+ const form = container.querySelector('form') as HTMLFormElement
99
+ expect(form).not.toBeNull()
100
+ await act(async () => {
101
+ fireEvent.submit(form)
102
+ })
103
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
104
+ expect(withScopedApiRequestHeadersMock).toHaveBeenCalledTimes(1)
105
+ const headerArg = withScopedApiRequestHeadersMock.mock.calls[0][0]
106
+ expect(headerArg).toEqual({
107
+ [OPTIMISTIC_LOCK_HEADER_NAME]: '2026-05-25T08:42:18.123Z',
108
+ })
109
+ })
110
+
111
+ it('does NOT wrap when optimisticLockUpdatedAt is omitted (no injection headers either)', async () => {
112
+ const onSubmit = jest.fn(async () => undefined)
113
+ const { container } = renderForm({ optimisticLockUpdatedAt: undefined, onSubmit })
114
+ const form = container.querySelector('form') as HTMLFormElement
115
+ await act(async () => {
116
+ fireEvent.submit(form)
117
+ })
118
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
119
+ expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
120
+ })
121
+
122
+ it('does NOT wrap when optimisticLockUpdatedAt is an empty string', async () => {
123
+ const onSubmit = jest.fn(async () => undefined)
124
+ const { container } = renderForm({ optimisticLockUpdatedAt: '', onSubmit })
125
+ const form = container.querySelector('form') as HTMLFormElement
126
+ await act(async () => {
127
+ fireEvent.submit(form)
128
+ })
129
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
130
+ expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('does NOT wrap when optimisticLockUpdatedAt is null', async () => {
134
+ const onSubmit = jest.fn(async () => undefined)
135
+ const { container } = renderForm({ optimisticLockUpdatedAt: null, onSubmit })
136
+ const form = container.querySelector('form') as HTMLFormElement
137
+ await act(async () => {
138
+ fireEvent.submit(form)
139
+ })
140
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
141
+ expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
142
+ })
143
+
144
+ it('surfaces the localized record-modified message on the unified conflict bar (not a raw-token toast) on stale saves', async () => {
145
+ dismissRecordConflict()
146
+ const conflict = Object.assign(new Error('record_modified'), {
147
+ status: 409,
148
+ error: 'record_modified',
149
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
150
+ currentUpdatedAt: '2026-05-25T08:42:19.000Z',
151
+ expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
152
+ })
153
+ const onSubmit = jest.fn(async () => {
154
+ throw conflict
155
+ })
156
+ const { container } = renderForm({ optimisticLockUpdatedAt: '2026-05-25T08:42:18.000Z', onSubmit })
157
+ const form = container.querySelector('form') as HTMLFormElement
158
+
159
+ await act(async () => {
160
+ fireEvent.submit(form)
161
+ })
162
+
163
+ await waitFor(() => {
164
+ const entry = getRecordConflictForTest()
165
+ expect(entry).not.toBeNull()
166
+ expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
167
+ })
168
+ // The conflict bar is the surface — no transient toast for the conflict.
169
+ expect(flashMock).not.toHaveBeenCalledWith('record_modified', 'error')
170
+ expect(flashMock).not.toHaveBeenCalledWith(
171
+ 'This record was modified by someone else. Refresh and try again.',
172
+ 'error',
173
+ )
174
+ })
175
+ })
176
+
177
+ type AutoFormValues = { displayName: string; id?: string; updatedAt?: string | null; updated_at?: string | null }
178
+
179
+ function renderAutoForm(opts: {
180
+ initialValues: Partial<AutoFormValues>
181
+ onSubmit: jest.Mock
182
+ optimisticLockUpdatedAt?: string | null
183
+ disableOptimisticLock?: boolean
184
+ passProp?: boolean
185
+ }) {
186
+ const props: Record<string, unknown> = {
187
+ fields,
188
+ initialValues: opts.initialValues,
189
+ onSubmit: opts.onSubmit,
190
+ }
191
+ if (opts.passProp) props.optimisticLockUpdatedAt = opts.optimisticLockUpdatedAt
192
+ if (opts.disableOptimisticLock !== undefined) props.disableOptimisticLock = opts.disableOptimisticLock
193
+ return renderWithProviders(
194
+ <CrudForm<AutoFormValues> {...(props as any)} />,
195
+ { dict },
196
+ )
197
+ }
198
+
199
+ async function submitAuto(container: HTMLElement) {
200
+ const form = container.querySelector('form') as HTMLFormElement
201
+ expect(form).not.toBeNull()
202
+ await act(async () => { fireEvent.submit(form) })
203
+ }
204
+
205
+ function sentLockHeaderValue(): string | undefined {
206
+ const call = withScopedApiRequestHeadersMock.mock.calls.find(
207
+ (c) => c[0] && Object.prototype.hasOwnProperty.call(c[0], OPTIMISTIC_LOCK_HEADER_NAME),
208
+ )
209
+ return call ? call[0][OPTIMISTIC_LOCK_HEADER_NAME] : undefined
210
+ }
211
+
212
+ describe('CrudForm — optimistic-lock auto-derive from initialValues.updatedAt (no prop)', () => {
213
+ beforeEach(() => {
214
+ flashMock.mockClear()
215
+ withScopedApiRequestHeadersMock.mockClear()
216
+ fetchCustomFieldFormStructureMock.mockResolvedValue({
217
+ fields: [], definitions: [], metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
218
+ })
219
+ })
220
+
221
+ it('edit mode: derives the header from initialValues.updatedAt when the prop is absent', async () => {
222
+ const onSubmit = jest.fn(async () => undefined)
223
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit })
224
+ await submitAuto(container)
225
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
226
+ expect(sentLockHeaderValue()).toBe('2026-05-25T08:00:00.000Z')
227
+ })
228
+
229
+ it('edit mode: falls back to snake_case updated_at', async () => {
230
+ const onSubmit = jest.fn(async () => undefined)
231
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updated_at: '2026-05-25T09:00:00.000Z' }, onSubmit })
232
+ await submitAuto(container)
233
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
234
+ expect(sentLockHeaderValue()).toBe('2026-05-25T09:00:00.000Z')
235
+ })
236
+
237
+ it('edit mode: no updatedAt anywhere → no header attached (no crash)', async () => {
238
+ const onSubmit = jest.fn(async () => undefined)
239
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old' }, onSubmit })
240
+ await submitAuto(container)
241
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
242
+ expect(sentLockHeaderValue()).toBeUndefined()
243
+ })
244
+
245
+ it('genuine create (no id, no updatedAt): never attaches', async () => {
246
+ const onSubmit = jest.fn(async () => undefined)
247
+ const { container } = renderAutoForm({ initialValues: { displayName: 'new' }, onSubmit })
248
+ await submitAuto(container)
249
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
250
+ expect(sentLockHeaderValue()).toBeUndefined()
251
+ })
252
+
253
+ it('attaches from updatedAt even when form values lack an id (edit page tracks the record id outside form values, e.g. catalog product)', async () => {
254
+ // Regression guard: presence of a loaded updatedAt — not CrudForm's
255
+ // create/update heuristic — drives the header. The catalog product edit page
256
+ // keeps productId out of form values, so a previous operation-gated version
257
+ // silently dropped the header here.
258
+ const onSubmit = jest.fn(async () => undefined)
259
+ const { container } = renderAutoForm({ initialValues: { displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit })
260
+ await submitAuto(container)
261
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
262
+ expect(sentLockHeaderValue()).toBe('2026-05-25T08:00:00.000Z')
263
+ })
264
+
265
+ it('explicit optimisticLockUpdatedAt={null} wins over auto-derive (no header)', async () => {
266
+ const onSubmit = jest.fn(async () => undefined)
267
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, passProp: true, optimisticLockUpdatedAt: null })
268
+ await submitAuto(container)
269
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
270
+ expect(sentLockHeaderValue()).toBeUndefined()
271
+ })
272
+
273
+ it('explicit prop value wins over initialValues.updatedAt', async () => {
274
+ const onSubmit = jest.fn(async () => undefined)
275
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, passProp: true, optimisticLockUpdatedAt: '2026-05-25T10:00:00.000Z' })
276
+ await submitAuto(container)
277
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
278
+ expect(sentLockHeaderValue()).toBe('2026-05-25T10:00:00.000Z')
279
+ })
280
+
281
+ it('disableOptimisticLock never attaches even when updatedAt present', async () => {
282
+ const onSubmit = jest.fn(async () => undefined)
283
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, disableOptimisticLock: true })
284
+ await submitAuto(container)
285
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
286
+ expect(sentLockHeaderValue()).toBeUndefined()
287
+ })
288
+ })
@@ -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
+ }