@open-mercato/ui 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +57 -11
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +20 -8
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/package.json +7 -3
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +99 -9
- package/src/backend/DataTable.tsx +22 -8
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
package/src/backend/AppShell.tsx
CHANGED
|
@@ -24,6 +24,8 @@ import { QueryProvider } from '../theme/QueryProvider'
|
|
|
24
24
|
import { usePathname, useSearchParams } from 'next/navigation'
|
|
25
25
|
import { apiCall } from './utils/apiCall'
|
|
26
26
|
import { LastOperationBanner } from './operations/LastOperationBanner'
|
|
27
|
+
import { RecordConflictBanner } from './conflicts/RecordConflictBanner'
|
|
28
|
+
import { dismissRecordConflict } from './conflicts/store'
|
|
27
29
|
import { ProgressTopBar } from './progress/ProgressTopBar'
|
|
28
30
|
import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
|
|
29
31
|
import { PartialIndexBanner } from './indexes/PartialIndexBanner'
|
|
@@ -447,6 +449,13 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
447
449
|
// section sidebar by default. Set to 'main' to force-show the main nav even
|
|
448
450
|
// when the route is in a section context. Reset on close.
|
|
449
451
|
const [mobileDrawerView, setMobileDrawerView] = React.useState<'auto' | 'main'>('auto')
|
|
452
|
+
// Clear the persistent record-conflict bar when the route changes. The
|
|
453
|
+
// conflict is scoped to the record the user was editing, so navigating to an
|
|
454
|
+
// unrelated page should dismiss it instead of carrying a stale "Record
|
|
455
|
+
// changed" bar across modules.
|
|
456
|
+
React.useEffect(() => {
|
|
457
|
+
dismissRecordConflict()
|
|
458
|
+
}, [pathname])
|
|
450
459
|
React.useEffect(() => {
|
|
451
460
|
if (!mobileOpen) setMobileDrawerView('auto')
|
|
452
461
|
}, [mobileOpen])
|
|
@@ -1349,6 +1358,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1349
1358
|
<PartialIndexBanner />
|
|
1350
1359
|
{canManageUpgradeActions ? <UpgradeActionBanner /> : null}
|
|
1351
1360
|
<LastOperationBanner />
|
|
1361
|
+
<RecordConflictBanner />
|
|
1352
1362
|
<InjectionSpot spotId={BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1353
1363
|
<InjectionSpot
|
|
1354
1364
|
spotId={LEGACY_GLOBAL_MUTATION_INJECTION_SPOT_ID}
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import { FormFooter } from './forms/FormFooter'
|
|
|
35
35
|
import { Button } from '../primitives/button'
|
|
36
36
|
import { IconButton } from '../primitives/icon-button'
|
|
37
37
|
import {
|
|
38
|
+
Check,
|
|
38
39
|
Settings,
|
|
39
40
|
Layers,
|
|
40
41
|
Tag,
|
|
@@ -85,6 +86,8 @@ import { TimePicker } from './inputs/TimePicker'
|
|
|
85
86
|
import { DatePicker } from './inputs/DatePicker'
|
|
86
87
|
import { mapCrudServerErrorToFormErrors, parseServerMessage } from './utils/serverErrors'
|
|
87
88
|
import { withScopedApiRequestHeaders } from './utils/apiCall'
|
|
89
|
+
import { buildOptimisticLockHeader, extractOptimisticLockConflict } from './utils/optimisticLock'
|
|
90
|
+
import { surfaceRecordConflict } from './conflicts'
|
|
88
91
|
import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/validation'
|
|
89
92
|
import type { MDEditorProps as UiWMDEditorProps } from '@uiw/react-md-editor'
|
|
90
93
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
|
|
@@ -307,6 +310,30 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
|
307
310
|
onDelete?: () => Promise<void> | void
|
|
308
311
|
// When true, shows Delete button whenever onDelete is provided, even without an id
|
|
309
312
|
deleteVisible?: boolean
|
|
313
|
+
/**
|
|
314
|
+
* OSS opt-in optimistic locking (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
|
|
315
|
+
*
|
|
316
|
+
* When set to a non-empty ISO-8601 string (typically `record.updatedAt`
|
|
317
|
+
* from the API response), the form auto-injects the
|
|
318
|
+
* `x-om-ext-optimistic-lock-expected-updated-at` extension header on
|
|
319
|
+
* every PUT / PATCH / DELETE issued through the underlying `onSubmit` /
|
|
320
|
+
* `onDelete` callbacks (via `withScopedApiRequestHeaders`). When the
|
|
321
|
+
* server-side guard is opted in for the resource and the timestamp is
|
|
322
|
+
* stale, the API responds with a structured 409 conflict body.
|
|
323
|
+
*
|
|
324
|
+
* Strictly additive: when the prop is absent / null / empty, the form
|
|
325
|
+
* behaves exactly as before (no header injected).
|
|
326
|
+
*/
|
|
327
|
+
optimisticLockUpdatedAt?: string | null
|
|
328
|
+
/**
|
|
329
|
+
* Opt out of optimistic locking entirely for this form. When `true`, the
|
|
330
|
+
* extension header is never attached, regardless of `optimisticLockUpdatedAt`
|
|
331
|
+
* or the value auto-derived from `initialValues.updatedAt`. Use for forms
|
|
332
|
+
* whose locking is owned elsewhere (e.g. sales document sub-resources guarded
|
|
333
|
+
* at the command layer against the parent aggregate) or for entities without
|
|
334
|
+
* an `updated_at` column.
|
|
335
|
+
*/
|
|
336
|
+
disableOptimisticLock?: boolean
|
|
310
337
|
// Legacy field-only grid toggle. Use `groups` for advanced layout.
|
|
311
338
|
twoColumn?: boolean
|
|
312
339
|
title?: string
|
|
@@ -665,6 +692,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
665
692
|
onSubmit,
|
|
666
693
|
onDelete,
|
|
667
694
|
deleteVisible,
|
|
695
|
+
optimisticLockUpdatedAt,
|
|
696
|
+
disableOptimisticLock = false,
|
|
668
697
|
twoColumn = false,
|
|
669
698
|
title,
|
|
670
699
|
backHref,
|
|
@@ -788,6 +817,28 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
788
817
|
)
|
|
789
818
|
|
|
790
819
|
const operation = recordId ? 'update' : 'create'
|
|
820
|
+
|
|
821
|
+
// Resolve the optimistic-lock version used to build the extension header.
|
|
822
|
+
// Explicit `optimisticLockUpdatedAt` (including `null`) always wins. When the
|
|
823
|
+
// prop is absent, auto-derive from the loaded record's `updatedAt`/`updated_at`
|
|
824
|
+
// so every edit CrudForm enforces optimistic locking by default without
|
|
825
|
+
// per-form wiring. Presence of a loaded `updatedAt` — NOT the create/update
|
|
826
|
+
// heuristic — drives this: some edit pages keep the record id outside form
|
|
827
|
+
// values (so `operation` reads as `create`), while a genuine create has no
|
|
828
|
+
// `updatedAt` in `initialValues`. Even a "duplicate"-style create that carries
|
|
829
|
+
// one is harmless — the server ignores the header on inserts (no candidate id).
|
|
830
|
+
// `disableOptimisticLock` always wins; an explicit prop (incl. `null`) wins next.
|
|
831
|
+
const resolvedOptimisticLockUpdatedAt = React.useMemo<string | null | undefined>(() => {
|
|
832
|
+
if (disableOptimisticLock) return null
|
|
833
|
+
if (optimisticLockUpdatedAt !== undefined) return optimisticLockUpdatedAt
|
|
834
|
+
const source = initialValues as Record<string, unknown> | undefined
|
|
835
|
+
const camel = source?.updatedAt
|
|
836
|
+
if (typeof camel === 'string' && camel.length > 0) return camel
|
|
837
|
+
const snake = source?.updated_at
|
|
838
|
+
if (typeof snake === 'string' && snake.length > 0) return snake
|
|
839
|
+
return null
|
|
840
|
+
}, [disableOptimisticLock, optimisticLockUpdatedAt, initialValues])
|
|
841
|
+
|
|
791
842
|
const injectionContext = React.useMemo(() => ({
|
|
792
843
|
formId,
|
|
793
844
|
entityId: primaryEntityId,
|
|
@@ -1265,8 +1316,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1265
1316
|
}
|
|
1266
1317
|
}
|
|
1267
1318
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1319
|
+
const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
|
|
1320
|
+
const mergedDeleteHeaders = {
|
|
1321
|
+
...(injectionRequestHeaders ?? {}),
|
|
1322
|
+
...optimisticLockHeader,
|
|
1323
|
+
}
|
|
1324
|
+
if (Object.keys(mergedDeleteHeaders).length > 0) {
|
|
1325
|
+
await withScopedApiRequestHeaders(mergedDeleteHeaders, async () => {
|
|
1270
1326
|
await onDelete()
|
|
1271
1327
|
})
|
|
1272
1328
|
} else {
|
|
@@ -1310,8 +1366,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1310
1366
|
} catch {
|
|
1311
1367
|
// ignore event dispatch failures
|
|
1312
1368
|
}
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1369
|
+
const optimisticLockConflict = extractOptimisticLockConflict(err)
|
|
1370
|
+
if (optimisticLockConflict) {
|
|
1371
|
+
// Surface on the unified, persistent conflict bar instead of a toast.
|
|
1372
|
+
try { surfaceRecordConflict(err, t) } catch {}
|
|
1373
|
+
} else {
|
|
1374
|
+
const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
|
|
1375
|
+
try { flash(message, 'error') } catch {}
|
|
1376
|
+
}
|
|
1315
1377
|
} finally {
|
|
1316
1378
|
deletingRef.current = false
|
|
1317
1379
|
setPending(false)
|
|
@@ -2631,8 +2693,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2631
2693
|
|
|
2632
2694
|
try {
|
|
2633
2695
|
submitNavigationBypassRef.current = true
|
|
2634
|
-
|
|
2635
|
-
|
|
2696
|
+
const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
|
|
2697
|
+
const mergedSubmitHeaders = {
|
|
2698
|
+
...(injectionRequestHeaders ?? {}),
|
|
2699
|
+
...optimisticLockHeader,
|
|
2700
|
+
}
|
|
2701
|
+
if (Object.keys(mergedSubmitHeaders).length > 0) {
|
|
2702
|
+
await withScopedApiRequestHeaders(mergedSubmitHeaders, async () => {
|
|
2636
2703
|
await onSubmit?.(coreSubmitValues, submitContext)
|
|
2637
2704
|
})
|
|
2638
2705
|
} else {
|
|
@@ -2690,7 +2757,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2690
2757
|
}
|
|
2691
2758
|
}
|
|
2692
2759
|
|
|
2693
|
-
|
|
2760
|
+
const optimisticLockConflict = extractOptimisticLockConflict(err)
|
|
2761
|
+
let displayMessage = optimisticLockConflict
|
|
2762
|
+
? t('ui.forms.flash.recordModified', 'This record was modified by someone else. Refresh and try again.')
|
|
2763
|
+
: typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
|
|
2694
2764
|
if (hasFieldErrors) {
|
|
2695
2765
|
const lowered = displayMessage.toLowerCase()
|
|
2696
2766
|
const highlightedLower = highlightedMessage.toLowerCase()
|
|
@@ -2705,7 +2775,12 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2705
2775
|
displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage
|
|
2706
2776
|
}
|
|
2707
2777
|
displayMessage = parseServerMessage(displayMessage)
|
|
2708
|
-
if (
|
|
2778
|
+
if (optimisticLockConflict) {
|
|
2779
|
+
// Primary surface for the conflict is the persistent, error-styled
|
|
2780
|
+
// RecordConflictBanner (unified across all forms). Keep the inline
|
|
2781
|
+
// form error too, but suppress the redundant transient toast.
|
|
2782
|
+
try { surfaceRecordConflict(err, t) } catch {}
|
|
2783
|
+
} else if (!hasFieldErrors) {
|
|
2709
2784
|
flash(displayMessage, 'error')
|
|
2710
2785
|
}
|
|
2711
2786
|
setFormError(displayMessage)
|
|
@@ -3989,7 +4064,22 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect({
|
|
|
3989
4064
|
className={`w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? 'bg-muted' : ''}`}
|
|
3990
4065
|
>
|
|
3991
4066
|
<span className="inline-flex items-center gap-2">
|
|
3992
|
-
|
|
4067
|
+
{/* Decorative selection indicator — NOT an interactive Checkbox.
|
|
4068
|
+
The enclosing Button owns the click; a real Radix Checkbox here
|
|
4069
|
+
nests a <button> inside a <button> (hydration error) and its
|
|
4070
|
+
Presence/ref lifecycle drives an infinite update loop when the
|
|
4071
|
+
listbox renders selected values. A plain span mirrors the DS
|
|
4072
|
+
checkbox look without any of that. */}
|
|
4073
|
+
<span
|
|
4074
|
+
aria-hidden="true"
|
|
4075
|
+
className={`inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors ${
|
|
4076
|
+
isSel
|
|
4077
|
+
? 'border-accent-indigo bg-accent-indigo text-accent-indigo-foreground'
|
|
4078
|
+
: 'border-input bg-background'
|
|
4079
|
+
}`}
|
|
4080
|
+
>
|
|
4081
|
+
{isSel ? <Check className="size-3.5" aria-hidden="true" /> : null}
|
|
4082
|
+
</span>
|
|
3993
4083
|
<span>{opt.label}</span>
|
|
3994
4084
|
</span>
|
|
3995
4085
|
</Button>
|
|
@@ -31,7 +31,9 @@ import { useAppEvent } from './injection/useAppEvent'
|
|
|
31
31
|
import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
|
|
32
32
|
import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
|
|
33
33
|
import { serializeExport, defaultExportFilename, type PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
|
|
34
|
-
import { apiCall } from './utils/apiCall'
|
|
34
|
+
import { apiCall, withScopedApiRequestHeaders } from './utils/apiCall'
|
|
35
|
+
import { buildOptimisticLockHeader } from './utils/optimisticLock'
|
|
36
|
+
import { surfaceRecordConflict } from './conflicts'
|
|
35
37
|
import { raiseCrudError } from './utils/serverErrors'
|
|
36
38
|
import { PerspectiveSidebar } from './PerspectiveSidebar'
|
|
37
39
|
import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
|
|
@@ -1716,13 +1718,19 @@ export function DataTable<T>({
|
|
|
1716
1718
|
// eslint-disable-next-line no-console
|
|
1717
1719
|
console.debug('[DataTable] perspective payload', payload)
|
|
1718
1720
|
}
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1721
|
+
const existing = input.perspectiveId
|
|
1722
|
+
? perspectiveData?.perspectives.find((p) => p.id === input.perspectiveId) ?? null
|
|
1723
|
+
: null
|
|
1724
|
+
const call = await withScopedApiRequestHeaders(
|
|
1725
|
+
buildOptimisticLockHeader(existing?.updatedAt ?? null),
|
|
1726
|
+
() => apiCall<PerspectiveSaveResponse>(
|
|
1727
|
+
`/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
|
|
1728
|
+
{
|
|
1729
|
+
method: 'POST',
|
|
1730
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1731
|
+
body: JSON.stringify(payload),
|
|
1732
|
+
},
|
|
1733
|
+
),
|
|
1726
1734
|
)
|
|
1727
1735
|
if (call.status === 404) {
|
|
1728
1736
|
throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` to regenerate module routes and restart the dev server.'))
|
|
@@ -1742,6 +1750,12 @@ export function DataTable<T>({
|
|
|
1742
1750
|
applyPerspectiveSettings(data.perspective.settings, data.perspective.id)
|
|
1743
1751
|
}
|
|
1744
1752
|
},
|
|
1753
|
+
onError: (error) => {
|
|
1754
|
+
if (perspectiveTableId) {
|
|
1755
|
+
void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
|
|
1756
|
+
}
|
|
1757
|
+
surfaceRecordConflict(error, t)
|
|
1758
|
+
},
|
|
1745
1759
|
})
|
|
1746
1760
|
|
|
1747
1761
|
const resolveColumnLabel = React.useCallback((column: TableColumn<T, unknown>): string => {
|
|
@@ -7,6 +7,7 @@ import { Checkbox } from '../primitives/checkbox'
|
|
|
7
7
|
import { Spinner } from '../primitives/spinner'
|
|
8
8
|
import { useConfirmDialog } from './confirm-dialog'
|
|
9
9
|
import { flash } from './FlashMessages'
|
|
10
|
+
import { surfaceRecordConflict } from './conflicts'
|
|
10
11
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
11
12
|
import type {
|
|
12
13
|
PerspectiveDto,
|
|
@@ -133,7 +134,8 @@ export function PerspectiveSidebar({
|
|
|
133
134
|
perspectiveId: targetId,
|
|
134
135
|
})
|
|
135
136
|
flash(t('ui.perspectives.autosave.success', 'View saved'), 'success')
|
|
136
|
-
} catch {
|
|
137
|
+
} catch (err: unknown) {
|
|
138
|
+
if (surfaceRecordConflict(err, t)) return
|
|
137
139
|
flash(t('ui.perspectives.autosave.error', 'Failed to save view'), 'error')
|
|
138
140
|
}
|
|
139
141
|
}, 400)
|
|
@@ -199,6 +201,7 @@ export function PerspectiveSidebar({
|
|
|
199
201
|
await onSave({ name: name.trim(), isDefault, applyToRoles: [], setRoleDefault: false, perspectiveId: null })
|
|
200
202
|
resetMode()
|
|
201
203
|
} catch (err: unknown) {
|
|
204
|
+
if (surfaceRecordConflict(err, t)) return
|
|
202
205
|
setError(err instanceof Error ? err.message : 'Failed to save view')
|
|
203
206
|
}
|
|
204
207
|
}
|
|
@@ -237,6 +240,7 @@ export function PerspectiveSidebar({
|
|
|
237
240
|
})
|
|
238
241
|
setRenamingId(null)
|
|
239
242
|
} catch (err: unknown) {
|
|
243
|
+
if (surfaceRecordConflict(err, t)) return
|
|
240
244
|
setError(err instanceof Error ? err.message : 'Failed to rename view')
|
|
241
245
|
}
|
|
242
246
|
}
|
|
@@ -282,6 +286,7 @@ export function PerspectiveSidebar({
|
|
|
282
286
|
const msg = errMessage.toLowerCase()
|
|
283
287
|
const isDuplicate = msg.includes('duplicate key') || msg.includes('unique constraint')
|
|
284
288
|
if (!isDuplicate) {
|
|
289
|
+
if (surfaceRecordConflict(err, t)) return
|
|
285
290
|
setError(errMessage || 'Failed to clone view')
|
|
286
291
|
return
|
|
287
292
|
}
|
|
@@ -289,6 +294,7 @@ export function PerspectiveSidebar({
|
|
|
289
294
|
counter += 1
|
|
290
295
|
}
|
|
291
296
|
}
|
|
297
|
+
if (surfaceRecordConflict(lastErr, t)) return
|
|
292
298
|
setError(lastErr instanceof Error ? lastErr.message : 'Failed to clone view')
|
|
293
299
|
}
|
|
294
300
|
|
|
@@ -333,6 +339,7 @@ export function PerspectiveSidebar({
|
|
|
333
339
|
setSharedIds((prev) => new Set([...prev, mode.perspectiveId]))
|
|
334
340
|
resetMode()
|
|
335
341
|
} catch (err: unknown) {
|
|
342
|
+
if (surfaceRecordConflict(err, t)) return
|
|
336
343
|
setError(err instanceof Error ? err.message : 'Failed to share view')
|
|
337
344
|
}
|
|
338
345
|
}
|
|
@@ -100,7 +100,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
100
100
|
variant="ghost"
|
|
101
101
|
aria-haspopup="menu"
|
|
102
102
|
aria-expanded={open}
|
|
103
|
-
onClick={(e) => { e.stopPropagation(); setOpen(
|
|
103
|
+
onClick={(e) => { e.stopPropagation(); setOpen(true); requestAnimationFrame(updatePosition) }}
|
|
104
104
|
>
|
|
105
105
|
<span aria-hidden="true">⋯</span>
|
|
106
106
|
<span className="sr-only">{t('ui.rowActions.openActions', 'Open actions')}</span>
|
|
@@ -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
|
+
})
|