@open-mercato/ui 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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 +2 -2
- package/AGENTS.md +2 -1
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +212 -85
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +77 -26
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/FlashMessages.js +21 -2
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +64 -41
- package/dist/backend/JsonBuilder.js.map +2 -2
- 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/detail/ActivitiesSection.js +1 -0
- package/dist/backend/detail/ActivitiesSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/detail/RecordNotFoundState.js +10 -5
- package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.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/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/checkbox.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +17 -13
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +320 -97
- package/src/backend/DataTable.tsx +113 -34
- package/src/backend/FlashMessages.tsx +31 -2
- package/src/backend/JsonBuilder.tsx +79 -26
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
- package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- 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/detail/ActivitiesSection.tsx +1 -0
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/detail/RecordNotFoundState.tsx +13 -6
- package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
- 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
- package/src/portal/hooks/useTenantContext.ts +7 -2
- package/src/primitives/__tests__/checkbox.test.tsx +28 -0
- package/src/primitives/alert.tsx +1 -1
- package/src/primitives/checkbox.tsx +2 -1
- package/src/types/css.d.ts +5 -0
|
@@ -0,0 +1,287 @@
|
|
|
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('../confirm-dialog', () => ({
|
|
20
|
+
useConfirmDialog: () => ({
|
|
21
|
+
confirm: jest.fn().mockResolvedValue(true),
|
|
22
|
+
ConfirmDialogElement: null,
|
|
23
|
+
}),
|
|
24
|
+
}))
|
|
25
|
+
jest.mock('../custom-fields/FieldDefinitionsManager', () => {
|
|
26
|
+
const React = require('react')
|
|
27
|
+
return {
|
|
28
|
+
__esModule: true,
|
|
29
|
+
FieldDefinitionsManager: React.forwardRef(() => <div>Field definitions manager</div>),
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
jest.mock('../utils/customFieldForms', () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
buildFormFieldFromCustomFieldDef: (...args: unknown[]) => buildFormFieldFromCustomFieldDefMock(...args),
|
|
35
|
+
buildFormFieldsFromCustomFields: jest.fn(() => []),
|
|
36
|
+
fetchCustomFieldFormStructure: (...args: unknown[]) => fetchCustomFieldFormStructureMock(...args),
|
|
37
|
+
}))
|
|
38
|
+
jest.mock('../utils/apiCall', () => {
|
|
39
|
+
const actual = jest.requireActual('../utils/apiCall')
|
|
40
|
+
return {
|
|
41
|
+
...actual,
|
|
42
|
+
withScopedApiRequestHeaders: (...args: unknown[]) =>
|
|
43
|
+
withScopedApiRequestHeadersMock(
|
|
44
|
+
args[0] as Record<string, string>,
|
|
45
|
+
args[1] as () => Promise<unknown>,
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
jest.mock('../FlashMessages', () => ({
|
|
50
|
+
flash: (...args: unknown[]) => flashMock(...args),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
import * as React from 'react'
|
|
54
|
+
import { act, fireEvent, waitFor } from '@testing-library/react'
|
|
55
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
56
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
57
|
+
import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
|
|
58
|
+
|
|
59
|
+
const dict = {
|
|
60
|
+
'ui.forms.actions.save': 'Save',
|
|
61
|
+
'ui.forms.flash.recordModified': 'This record was modified by someone else. Refresh and try again.',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const fields: CrudField[] = [
|
|
65
|
+
{ id: 'displayName', label: 'Display name', type: 'text' },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
function renderForm(opts: {
|
|
69
|
+
optimisticLockUpdatedAt?: string | null
|
|
70
|
+
onSubmit: jest.Mock
|
|
71
|
+
}) {
|
|
72
|
+
return renderWithProviders(
|
|
73
|
+
<CrudForm<{ displayName: string; id?: string }>
|
|
74
|
+
fields={fields}
|
|
75
|
+
initialValues={{ id: 'rec-1', displayName: 'old' }}
|
|
76
|
+
onSubmit={opts.onSubmit}
|
|
77
|
+
optimisticLockUpdatedAt={opts.optimisticLockUpdatedAt}
|
|
78
|
+
/>,
|
|
79
|
+
{ dict },
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('CrudForm — optimisticLockUpdatedAt prop wiring', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
flashMock.mockClear()
|
|
86
|
+
withScopedApiRequestHeadersMock.mockClear()
|
|
87
|
+
fetchCustomFieldFormStructureMock.mockResolvedValue({
|
|
88
|
+
fields: [],
|
|
89
|
+
definitions: [],
|
|
90
|
+
metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('wraps onSubmit in withScopedApiRequestHeaders carrying the extension header when prop is set', async () => {
|
|
95
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
96
|
+
const { container } = renderForm({ optimisticLockUpdatedAt: '2026-05-25T08:42:18.123Z', onSubmit })
|
|
97
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
98
|
+
expect(form).not.toBeNull()
|
|
99
|
+
await act(async () => {
|
|
100
|
+
fireEvent.submit(form)
|
|
101
|
+
})
|
|
102
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
103
|
+
expect(withScopedApiRequestHeadersMock).toHaveBeenCalledTimes(1)
|
|
104
|
+
const headerArg = withScopedApiRequestHeadersMock.mock.calls[0][0]
|
|
105
|
+
expect(headerArg).toEqual({
|
|
106
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: '2026-05-25T08:42:18.123Z',
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does NOT wrap when optimisticLockUpdatedAt is omitted (no injection headers either)', async () => {
|
|
111
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
112
|
+
const { container } = renderForm({ optimisticLockUpdatedAt: undefined, onSubmit })
|
|
113
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
114
|
+
await act(async () => {
|
|
115
|
+
fireEvent.submit(form)
|
|
116
|
+
})
|
|
117
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
118
|
+
expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does NOT wrap when optimisticLockUpdatedAt is an empty string', async () => {
|
|
122
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
123
|
+
const { container } = renderForm({ optimisticLockUpdatedAt: '', onSubmit })
|
|
124
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
125
|
+
await act(async () => {
|
|
126
|
+
fireEvent.submit(form)
|
|
127
|
+
})
|
|
128
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
129
|
+
expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('does NOT wrap when optimisticLockUpdatedAt is null', async () => {
|
|
133
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
134
|
+
const { container } = renderForm({ optimisticLockUpdatedAt: null, onSubmit })
|
|
135
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
136
|
+
await act(async () => {
|
|
137
|
+
fireEvent.submit(form)
|
|
138
|
+
})
|
|
139
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
140
|
+
expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('surfaces the localized record-modified message on the unified conflict bar (not a raw-token toast) on stale saves', async () => {
|
|
144
|
+
dismissRecordConflict()
|
|
145
|
+
const conflict = Object.assign(new Error('record_modified'), {
|
|
146
|
+
status: 409,
|
|
147
|
+
error: 'record_modified',
|
|
148
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
149
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
150
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
151
|
+
})
|
|
152
|
+
const onSubmit = jest.fn(async () => {
|
|
153
|
+
throw conflict
|
|
154
|
+
})
|
|
155
|
+
const { container } = renderForm({ optimisticLockUpdatedAt: '2026-05-25T08:42:18.000Z', onSubmit })
|
|
156
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
157
|
+
|
|
158
|
+
await act(async () => {
|
|
159
|
+
fireEvent.submit(form)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
const entry = getRecordConflictForTest()
|
|
164
|
+
expect(entry).not.toBeNull()
|
|
165
|
+
expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
|
|
166
|
+
})
|
|
167
|
+
// The conflict bar is the surface — no transient toast for the conflict.
|
|
168
|
+
expect(flashMock).not.toHaveBeenCalledWith('record_modified', 'error')
|
|
169
|
+
expect(flashMock).not.toHaveBeenCalledWith(
|
|
170
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
171
|
+
'error',
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
type AutoFormValues = { displayName: string; id?: string; updatedAt?: string | null; updated_at?: string | null }
|
|
177
|
+
|
|
178
|
+
function renderAutoForm(opts: {
|
|
179
|
+
initialValues: Partial<AutoFormValues>
|
|
180
|
+
onSubmit: jest.Mock
|
|
181
|
+
optimisticLockUpdatedAt?: string | null
|
|
182
|
+
disableOptimisticLock?: boolean
|
|
183
|
+
passProp?: boolean
|
|
184
|
+
}) {
|
|
185
|
+
const props: Record<string, unknown> = {
|
|
186
|
+
fields,
|
|
187
|
+
initialValues: opts.initialValues,
|
|
188
|
+
onSubmit: opts.onSubmit,
|
|
189
|
+
}
|
|
190
|
+
if (opts.passProp) props.optimisticLockUpdatedAt = opts.optimisticLockUpdatedAt
|
|
191
|
+
if (opts.disableOptimisticLock !== undefined) props.disableOptimisticLock = opts.disableOptimisticLock
|
|
192
|
+
return renderWithProviders(
|
|
193
|
+
<CrudForm<AutoFormValues> {...(props as any)} />,
|
|
194
|
+
{ dict },
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function submitAuto(container: HTMLElement) {
|
|
199
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
200
|
+
expect(form).not.toBeNull()
|
|
201
|
+
await act(async () => { fireEvent.submit(form) })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sentLockHeaderValue(): string | undefined {
|
|
205
|
+
const call = withScopedApiRequestHeadersMock.mock.calls.find(
|
|
206
|
+
(c) => c[0] && Object.prototype.hasOwnProperty.call(c[0], OPTIMISTIC_LOCK_HEADER_NAME),
|
|
207
|
+
)
|
|
208
|
+
return call ? call[0][OPTIMISTIC_LOCK_HEADER_NAME] : undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
describe('CrudForm — optimistic-lock auto-derive from initialValues.updatedAt (no prop)', () => {
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
flashMock.mockClear()
|
|
214
|
+
withScopedApiRequestHeadersMock.mockClear()
|
|
215
|
+
fetchCustomFieldFormStructureMock.mockResolvedValue({
|
|
216
|
+
fields: [], definitions: [], metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('edit mode: derives the header from initialValues.updatedAt when the prop is absent', async () => {
|
|
221
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
222
|
+
const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit })
|
|
223
|
+
await submitAuto(container)
|
|
224
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
225
|
+
expect(sentLockHeaderValue()).toBe('2026-05-25T08:00:00.000Z')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('edit mode: falls back to snake_case updated_at', async () => {
|
|
229
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
230
|
+
const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updated_at: '2026-05-25T09:00:00.000Z' }, onSubmit })
|
|
231
|
+
await submitAuto(container)
|
|
232
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
233
|
+
expect(sentLockHeaderValue()).toBe('2026-05-25T09:00:00.000Z')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('edit mode: no updatedAt anywhere → no header attached (no crash)', async () => {
|
|
237
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
238
|
+
const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old' }, onSubmit })
|
|
239
|
+
await submitAuto(container)
|
|
240
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
241
|
+
expect(sentLockHeaderValue()).toBeUndefined()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('genuine create (no id, no updatedAt): never attaches', async () => {
|
|
245
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
246
|
+
const { container } = renderAutoForm({ initialValues: { displayName: 'new' }, onSubmit })
|
|
247
|
+
await submitAuto(container)
|
|
248
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
249
|
+
expect(sentLockHeaderValue()).toBeUndefined()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
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 () => {
|
|
253
|
+
// Regression guard: presence of a loaded updatedAt — not CrudForm's
|
|
254
|
+
// create/update heuristic — drives the header. The catalog product edit page
|
|
255
|
+
// keeps productId out of form values, so a previous operation-gated version
|
|
256
|
+
// silently dropped the header here.
|
|
257
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
258
|
+
const { container } = renderAutoForm({ initialValues: { displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit })
|
|
259
|
+
await submitAuto(container)
|
|
260
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
261
|
+
expect(sentLockHeaderValue()).toBe('2026-05-25T08:00:00.000Z')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('explicit optimisticLockUpdatedAt={null} wins over auto-derive (no header)', async () => {
|
|
265
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
266
|
+
const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, passProp: true, optimisticLockUpdatedAt: null })
|
|
267
|
+
await submitAuto(container)
|
|
268
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
269
|
+
expect(sentLockHeaderValue()).toBeUndefined()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('explicit prop value wins over initialValues.updatedAt', async () => {
|
|
273
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
274
|
+
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' })
|
|
275
|
+
await submitAuto(container)
|
|
276
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
277
|
+
expect(sentLockHeaderValue()).toBe('2026-05-25T10:00:00.000Z')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('disableOptimisticLock never attaches even when updatedAt present', async () => {
|
|
281
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
282
|
+
const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, disableOptimisticLock: true })
|
|
283
|
+
await submitAuto(container)
|
|
284
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
285
|
+
expect(sentLockHeaderValue()).toBeUndefined()
|
|
286
|
+
})
|
|
287
|
+
})
|
|
@@ -12,7 +12,6 @@ jest.mock('next/navigation', () => ({
|
|
|
12
12
|
useSearchParams: () => new URLSearchParams(),
|
|
13
13
|
}))
|
|
14
14
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
15
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
16
15
|
jest.mock('../injection/InjectionSpot', () => ({
|
|
17
16
|
__esModule: true,
|
|
18
17
|
InjectionSpot: () => null,
|
|
@@ -26,7 +25,7 @@ jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
|
26
25
|
|
|
27
26
|
import * as React from 'react'
|
|
28
27
|
import { renderToString } from 'react-dom/server'
|
|
29
|
-
import { act, fireEvent, waitFor } from '@testing-library/react'
|
|
28
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
30
29
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
31
30
|
import { CrudForm, type CrudField } from '../CrudForm'
|
|
32
31
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -80,6 +79,79 @@ describe('CrudForm initialValues', () => {
|
|
|
80
79
|
expect(getInput(container).value).toBe('Bob')
|
|
81
80
|
})
|
|
82
81
|
|
|
82
|
+
it('shows the selected label when select options arrive after the saved value', async () => {
|
|
83
|
+
const makeFields = (options: Array<{ label: string; value: string }>): CrudField[] => [
|
|
84
|
+
{ id: 'teamId', label: 'Team', type: 'select', options },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const { getByRole, rerender } = renderWithProviders(
|
|
88
|
+
<CrudForm
|
|
89
|
+
title="Form"
|
|
90
|
+
fields={makeFields([])}
|
|
91
|
+
initialValues={{ teamId: 'team-1' }}
|
|
92
|
+
onSubmit={() => {}}
|
|
93
|
+
/>,
|
|
94
|
+
{
|
|
95
|
+
dict: {
|
|
96
|
+
'ui.forms.actions.save': 'Save',
|
|
97
|
+
'ui.forms.select.emptyOption': '—',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(getByRole('combobox')).not.toHaveTextContent('Engineering')
|
|
103
|
+
|
|
104
|
+
await act(async () => {
|
|
105
|
+
rerender(
|
|
106
|
+
<CrudForm
|
|
107
|
+
title="Form"
|
|
108
|
+
fields={makeFields([{ value: 'team-1', label: 'Engineering' }])}
|
|
109
|
+
initialValues={{ teamId: 'team-1' }}
|
|
110
|
+
onSubmit={() => {}}
|
|
111
|
+
/>,
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(getByRole('combobox')).toHaveTextContent('Engineering')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('does not submit when a listbox multi-select option is clicked', async () => {
|
|
121
|
+
const onSubmit = jest.fn()
|
|
122
|
+
const fields: CrudField[] = [
|
|
123
|
+
{
|
|
124
|
+
id: 'assignees',
|
|
125
|
+
label: 'Assignees',
|
|
126
|
+
type: 'select',
|
|
127
|
+
multiple: true,
|
|
128
|
+
listbox: true,
|
|
129
|
+
options: [
|
|
130
|
+
{ value: 'alice', label: 'alice' },
|
|
131
|
+
{ value: 'bob', label: 'bob' },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
renderWithProviders(
|
|
137
|
+
<CrudForm title="Form" fields={fields} initialValues={{ assignees: [] }} onSubmit={onSubmit} />,
|
|
138
|
+
{
|
|
139
|
+
dict: {
|
|
140
|
+
'ui.forms.actions.save': 'Save',
|
|
141
|
+
'ui.forms.listbox.searchPlaceholder': 'Search...',
|
|
142
|
+
'ui.forms.listbox.noMatches': 'No matches',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const aliceOption = screen.getByRole('button', { name: 'alice' })
|
|
148
|
+
expect(aliceOption).toHaveAttribute('type', 'button')
|
|
149
|
+
|
|
150
|
+
fireEvent.click(aliceOption)
|
|
151
|
+
|
|
152
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
153
|
+
})
|
|
154
|
+
|
|
83
155
|
it('does not re-invoke loadOptions on parent re-render (#814)', async () => {
|
|
84
156
|
const loader = jest.fn().mockResolvedValue([{ label: 'A', value: 'a' }])
|
|
85
157
|
const baseFields: CrudField[] = [
|
|
@@ -12,7 +12,6 @@ jest.mock('next/navigation', () => ({
|
|
|
12
12
|
useSearchParams: () => new URLSearchParams(),
|
|
13
13
|
}))
|
|
14
14
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
15
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
16
15
|
jest.mock('../injection/InjectionSpot', () => ({
|
|
17
16
|
__esModule: true,
|
|
18
17
|
InjectionSpot: () => null,
|
|
@@ -11,7 +11,6 @@ jest.mock('next/navigation', () => ({
|
|
|
11
11
|
useSearchParams: () => new URLSearchParams(),
|
|
12
12
|
}))
|
|
13
13
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
14
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
15
14
|
jest.mock('../confirm-dialog', () => ({
|
|
16
15
|
useConfirmDialog: () => ({
|
|
17
16
|
confirm: confirmDialogMock,
|
|
@@ -3,6 +3,9 @@ jest.setTimeout(15000)
|
|
|
3
3
|
|
|
4
4
|
const pushMock = jest.fn()
|
|
5
5
|
const confirmDialogMock = jest.fn()
|
|
6
|
+
const triggerEventMock = jest.fn()
|
|
7
|
+
let mockInjectionSpotDataChange: (() => unknown) | null = null
|
|
8
|
+
let mockInjectionSpotDataChangeCalled = false
|
|
6
9
|
|
|
7
10
|
jest.mock('next/navigation', () => ({
|
|
8
11
|
useRouter: () => ({ push: pushMock }),
|
|
@@ -10,7 +13,6 @@ jest.mock('next/navigation', () => ({
|
|
|
10
13
|
useSearchParams: () => new URLSearchParams(),
|
|
11
14
|
}))
|
|
12
15
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
13
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
14
16
|
jest.mock('../confirm-dialog', () => ({
|
|
15
17
|
useConfirmDialog: () => ({
|
|
16
18
|
confirm: confirmDialogMock,
|
|
@@ -19,9 +21,17 @@ jest.mock('../confirm-dialog', () => ({
|
|
|
19
21
|
}))
|
|
20
22
|
jest.mock('../injection/InjectionSpot', () => ({
|
|
21
23
|
__esModule: true,
|
|
22
|
-
InjectionSpot: () =>
|
|
24
|
+
InjectionSpot: ({ onDataChange }: { onDataChange?: (next: unknown) => void }) => {
|
|
25
|
+
const React = require('react') as typeof import('react')
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
if (!mockInjectionSpotDataChange || mockInjectionSpotDataChangeCalled) return
|
|
28
|
+
mockInjectionSpotDataChangeCalled = true
|
|
29
|
+
onDataChange?.(mockInjectionSpotDataChange())
|
|
30
|
+
}, [onDataChange])
|
|
31
|
+
return null
|
|
32
|
+
},
|
|
23
33
|
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
24
|
-
useInjectionSpotEvents: () => ({ triggerEvent:
|
|
34
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
25
35
|
}))
|
|
26
36
|
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
27
37
|
__esModule: true,
|
|
@@ -40,6 +50,10 @@ describe('CrudForm unsaved navigation guard', () => {
|
|
|
40
50
|
pushMock.mockReset()
|
|
41
51
|
confirmDialogMock.mockReset()
|
|
42
52
|
confirmDialogMock.mockResolvedValue(true)
|
|
53
|
+
triggerEventMock.mockReset()
|
|
54
|
+
triggerEventMock.mockImplementation(async (_event: string, data: Record<string, unknown>) => ({ ok: true, data }))
|
|
55
|
+
mockInjectionSpotDataChange = null
|
|
56
|
+
mockInjectionSpotDataChangeCalled = false
|
|
43
57
|
window.history.replaceState({}, '', '/current')
|
|
44
58
|
})
|
|
45
59
|
|
|
@@ -265,4 +279,38 @@ describe('CrudForm unsaved navigation guard', () => {
|
|
|
265
279
|
|
|
266
280
|
anchor.remove()
|
|
267
281
|
})
|
|
282
|
+
|
|
283
|
+
it('does not prompt when initialization normalizes empty values without user edits', async () => {
|
|
284
|
+
mockInjectionSpotDataChange = () => ({ name: undefined, tags: [], meta: { note: null } })
|
|
285
|
+
|
|
286
|
+
renderWithProviders(
|
|
287
|
+
<CrudForm
|
|
288
|
+
title="Form"
|
|
289
|
+
fields={fields}
|
|
290
|
+
initialValues={{ name: '', tags: [], meta: { note: '' } }}
|
|
291
|
+
injectionSpotId="customers.person"
|
|
292
|
+
onSubmit={() => {}}
|
|
293
|
+
/>,
|
|
294
|
+
{
|
|
295
|
+
dict: {
|
|
296
|
+
'ui.forms.actions.save': 'Save',
|
|
297
|
+
'ui.forms.confirmUnsavedChanges': 'You have unsaved changes. Are you sure you want to leave?',
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
await act(async () => {
|
|
303
|
+
await Promise.resolve()
|
|
304
|
+
await Promise.resolve()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
await act(async () => {
|
|
308
|
+
window.history.pushState({}, '', '/backend/customers/people')
|
|
309
|
+
await Promise.resolve()
|
|
310
|
+
await Promise.resolve()
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
expect(confirmDialogMock).not.toHaveBeenCalled()
|
|
314
|
+
expect(window.location.pathname).toBe('/backend/customers/people')
|
|
315
|
+
})
|
|
268
316
|
})
|
|
@@ -7,7 +7,6 @@ jest.mock('next/navigation', () => ({
|
|
|
7
7
|
useSearchParams: () => new URLSearchParams(),
|
|
8
8
|
}))
|
|
9
9
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
10
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
11
10
|
jest.mock('../injection/InjectionSpot', () => ({
|
|
12
11
|
__esModule: true,
|
|
13
12
|
InjectionSpot: () => null,
|
|
@@ -5,7 +5,6 @@ jest.mock('next/navigation', () => ({
|
|
|
5
5
|
useSearchParams: () => new URLSearchParams(),
|
|
6
6
|
}))
|
|
7
7
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
8
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
9
8
|
|
|
10
9
|
import * as React from 'react'
|
|
11
10
|
import { fireEvent, screen } from '@testing-library/react'
|
|
@@ -3,16 +3,25 @@ import { act, screen } from '@testing-library/react'
|
|
|
3
3
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
4
4
|
import { FlashMessages, flash } from '../FlashMessages'
|
|
5
5
|
|
|
6
|
+
function setReferrer(value: string) {
|
|
7
|
+
Object.defineProperty(document, 'referrer', {
|
|
8
|
+
configurable: true,
|
|
9
|
+
value,
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
describe('FlashMessages', () => {
|
|
7
14
|
beforeEach(() => {
|
|
8
15
|
jest.useFakeTimers()
|
|
9
16
|
window.history.replaceState({}, '', 'http://localhost/backend')
|
|
17
|
+
setReferrer('')
|
|
10
18
|
})
|
|
11
19
|
|
|
12
20
|
afterEach(() => {
|
|
13
21
|
jest.runOnlyPendingTimers()
|
|
14
22
|
jest.useRealTimers()
|
|
15
23
|
window.history.replaceState({}, '', 'http://localhost/backend')
|
|
24
|
+
setReferrer('')
|
|
16
25
|
})
|
|
17
26
|
|
|
18
27
|
it('auto-dismisses URL-based flashes after stripping query params', () => {
|
|
@@ -30,6 +39,44 @@ describe('FlashMessages', () => {
|
|
|
30
39
|
expect(screen.queryByText('Saved')).not.toBeInTheDocument()
|
|
31
40
|
})
|
|
32
41
|
|
|
42
|
+
it('honors same-origin referrer flashes', () => {
|
|
43
|
+
setReferrer('http://localhost/backend/checkout/templates')
|
|
44
|
+
window.history.replaceState({}, '', 'http://localhost/backend/checkout/pay-links?flash=Saved&type=success')
|
|
45
|
+
|
|
46
|
+
renderWithProviders(<FlashMessages />)
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText('Saved')).toBeInTheDocument()
|
|
49
|
+
expect(window.location.search).toBe('')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('suppresses cross-origin referrer flashes but still strips the params', () => {
|
|
53
|
+
setReferrer('https://attacker.example.com/phish')
|
|
54
|
+
window.history.replaceState(
|
|
55
|
+
{},
|
|
56
|
+
'',
|
|
57
|
+
'http://localhost/backend/dashboard?flash=Your+account+was+suspended&type=error',
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
renderWithProviders(<FlashMessages />)
|
|
61
|
+
|
|
62
|
+
expect(screen.queryByText('Your account was suspended')).not.toBeInTheDocument()
|
|
63
|
+
expect(window.location.search).toBe('')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to a safe kind when the type param is not an allowed FlashKind', () => {
|
|
67
|
+
window.history.replaceState(
|
|
68
|
+
{},
|
|
69
|
+
'',
|
|
70
|
+
'http://localhost/backend/dashboard?flash=Saved&type=javascript',
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
renderWithProviders(<FlashMessages />)
|
|
74
|
+
|
|
75
|
+
expect(screen.getByText('Saved')).toBeInTheDocument()
|
|
76
|
+
const badge = document.querySelector('[data-slot="alert-icon-badge"]')
|
|
77
|
+
expect(badge?.getAttribute('data-status')).toBe('success')
|
|
78
|
+
})
|
|
79
|
+
|
|
33
80
|
it('auto-dismisses programmatic flashes', () => {
|
|
34
81
|
renderWithProviders(<FlashMessages />)
|
|
35
82
|
|