@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
@@ -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}
@@ -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
@@ -407,6 +434,62 @@ function readByDotPath(source: Record<string, unknown> | undefined, path: string
407
434
  return current
408
435
  }
409
436
 
437
+ // Keys that would mutate the prototype chain if used as dot-path segments.
438
+ // Guard against them so a declared field id such as `__proto__.polluted` can
439
+ // never reach into Object.prototype (prototype-pollution hardening).
440
+ const PROTO_POLLUTING_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
441
+
442
+ function isProtoPollutingKey(key: string): boolean {
443
+ return PROTO_POLLUTING_KEYS.has(key)
444
+ }
445
+
446
+ function writeByDotPath(target: Record<string, unknown>, path: string, value: unknown): void {
447
+ const segments = path.split('.').filter(Boolean)
448
+ if (segments.length === 0) return
449
+ if (segments.some(isProtoPollutingKey)) return
450
+ let current: Record<string, unknown> = target
451
+ for (let index = 0; index < segments.length - 1; index++) {
452
+ const segment = segments[index]
453
+ const existing = current[segment]
454
+ const isSafeObject =
455
+ !!existing &&
456
+ typeof existing === 'object' &&
457
+ !Array.isArray(existing) &&
458
+ (Object.getPrototypeOf(existing) === Object.prototype || Object.getPrototypeOf(existing) === null)
459
+
460
+ if (!isSafeObject) {
461
+ const created = Object.create(null) as Record<string, unknown>
462
+ current[segment] = created
463
+ current = created
464
+ } else {
465
+ current = existing as Record<string, unknown>
466
+ }
467
+ }
468
+ const finalSegment = segments[segments.length - 1]
469
+ if (isProtoPollutingKey(finalSegment)) return
470
+ current[finalSegment] = value
471
+ }
472
+
473
+ // Collapse flat dot-path keys (e.g. `metadata.category`) declared as base form
474
+ // fields into the nested object the schema expects (`{ metadata: { category } }`).
475
+ // The flat keys are preserved alongside the nested projection so that forms whose
476
+ // schema declares the flat keys directly keep working — CrudForm's non-strict
477
+ // schema.safeParse retains whichever representation the schema declares and strips
478
+ // the other. See issue #2503 ("Latent framework gap").
479
+ function collapseDotPathFields(
480
+ source: Record<string, unknown>,
481
+ dotPathFieldIds: ReadonlySet<string>,
482
+ ): Record<string, unknown> {
483
+ if (!dotPathFieldIds.size) return source
484
+ let result: Record<string, unknown> | null = null
485
+ for (const fieldId of dotPathFieldIds) {
486
+ if (!Object.prototype.hasOwnProperty.call(source, fieldId)) continue
487
+ if (!result) result = { ...source }
488
+ writeByDotPath(result, fieldId, source[fieldId])
489
+ }
490
+ return result ?? source
491
+ }
492
+
410
493
  function readInitialCustomFieldValue(
411
494
  source: Record<string, unknown> | undefined,
412
495
  key: string,
@@ -609,6 +692,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
609
692
  onSubmit,
610
693
  onDelete,
611
694
  deleteVisible,
695
+ optimisticLockUpdatedAt,
696
+ disableOptimisticLock = false,
612
697
  twoColumn = false,
613
698
  title,
614
699
  backHref,
@@ -732,6 +817,28 @@ export function CrudForm<TValues extends Record<string, unknown>>({
732
817
  )
733
818
 
734
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
+
735
842
  const injectionContext = React.useMemo(() => ({
736
843
  formId,
737
844
  entityId: primaryEntityId,
@@ -1209,8 +1316,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1209
1316
  }
1210
1317
  }
1211
1318
 
1212
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1213
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
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 () => {
1214
1326
  await onDelete()
1215
1327
  })
1216
1328
  } else {
@@ -1254,8 +1366,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1254
1366
  } catch {
1255
1367
  // ignore event dispatch failures
1256
1368
  }
1257
- const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
1258
- try { flash(message, 'error') } catch {}
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
+ }
1259
1377
  } finally {
1260
1378
  deletingRef.current = false
1261
1379
  setPending(false)
@@ -1660,6 +1778,25 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1660
1778
  return new globalThis.Map(allFields.map((f) => [f.id, f]))
1661
1779
  }, [allFields])
1662
1780
 
1781
+ // Declared base fields whose id is a dot-path (e.g. `metadata.category`).
1782
+ // Injected and custom (cf) fields already get dot-path/cf-path hydration and
1783
+ // are excluded here so their dedicated handling is preserved. CrudForm hydrates
1784
+ // these flat keys from nested initial values on load and projects them back into
1785
+ // nested objects before schema.safeParse on submit. See issue #2503.
1786
+ const dotPathBaseFieldIds = React.useMemo(() => {
1787
+ const ids = new Set<string>()
1788
+ const cfFieldIds = new Set(cfFields.map((field) => field.id))
1789
+ for (const field of allFields) {
1790
+ const fieldId = field.id
1791
+ if (!fieldId.includes('.')) continue
1792
+ if (injectedFieldIdSet.has(fieldId)) continue
1793
+ if (fieldId.startsWith('cf_') || fieldId.startsWith('cf:')) continue
1794
+ if (cfFieldIds.has(fieldId)) continue
1795
+ ids.add(fieldId)
1796
+ }
1797
+ return ids
1798
+ }, [allFields, injectedFieldIdSet, cfFields])
1799
+
1663
1800
  const validateFieldOnBlur = React.useCallback(async (
1664
1801
  fieldId: string,
1665
1802
  sourceValues?: CrudFormValues<TValues>,
@@ -1732,7 +1869,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1732
1869
  for (const injectedId of injectedFieldIdSet) {
1733
1870
  delete coreValues[injectedId]
1734
1871
  }
1735
- const result = schema.safeParse(coreValues)
1872
+ const result = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
1736
1873
  if (!result.success) {
1737
1874
  const schemaFieldErrors: Record<string, string> = {}
1738
1875
  result.error.issues.forEach((issue) => {
@@ -1772,6 +1909,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1772
1909
  }, [
1773
1910
  cfDefinitions,
1774
1911
  customEntity,
1912
+ dotPathBaseFieldIds,
1775
1913
  fieldById,
1776
1914
  formReadOnly,
1777
1915
  hiddenBaseFieldIds,
@@ -2177,6 +2315,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2177
2315
  initialValues,
2178
2316
  injectedFieldIds: injectedFieldDefinitions.map((definition) => definition.id),
2179
2317
  customFieldMappings: cfDefinitions.map((definition) => definition.key),
2318
+ dotPathBaseFieldIds: Array.from(dotPathBaseFieldIds),
2180
2319
  })
2181
2320
  if (appliedInitialValuesSnapshotRef.current === snapshot) return
2182
2321
  appliedInitialValuesSnapshotRef.current = snapshot
@@ -2199,6 +2338,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2199
2338
  ;(merged as Record<string, unknown>)[targetId] = extracted
2200
2339
  }
2201
2340
  }
2341
+ for (const fieldId of dotPathBaseFieldIds) {
2342
+ if (merged[fieldId] !== undefined) continue
2343
+ const extracted = readByDotPath(initialRecord, fieldId)
2344
+ if (extracted !== undefined) {
2345
+ ;(merged as Record<string, unknown>)[fieldId] = extracted
2346
+ }
2347
+ }
2202
2348
  mergedValues = merged
2203
2349
  return mergedValues
2204
2350
  })
@@ -2229,6 +2375,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2229
2375
  }, [
2230
2376
  cfDefinitions,
2231
2377
  customEntity,
2378
+ dotPathBaseFieldIds,
2232
2379
  extendedInjectionEventsEnabled,
2233
2380
  initialValues,
2234
2381
  injectedFieldDefinitions,
@@ -2426,7 +2573,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2426
2573
 
2427
2574
  let parsedValues: TValues
2428
2575
  if (schema) {
2429
- const res = schema.safeParse(coreValues)
2576
+ const res = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
2430
2577
  if (!res.success) {
2431
2578
  const fieldErrors: Record<string, string> = {}
2432
2579
  res.error.issues.forEach((issue) => {
@@ -2456,7 +2603,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2456
2603
  for (const injectedId of injectedFieldIdSet) {
2457
2604
  delete projectedCoreValues[injectedId]
2458
2605
  }
2459
- coreSubmitValues = schema ? schema.parse(projectedCoreValues) : (projectedCoreValues as TValues)
2606
+ coreSubmitValues = schema
2607
+ ? schema.parse(collapseDotPathFields(projectedCoreValues, dotPathBaseFieldIds))
2608
+ : (projectedCoreValues as TValues)
2460
2609
  if (result.applyToForm) {
2461
2610
  setValues(result.data as CrudFormValues<TValues>)
2462
2611
  }
@@ -2544,8 +2693,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2544
2693
 
2545
2694
  try {
2546
2695
  submitNavigationBypassRef.current = true
2547
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
2548
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
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 () => {
2549
2703
  await onSubmit?.(coreSubmitValues, submitContext)
2550
2704
  })
2551
2705
  } else {
@@ -2603,7 +2757,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2603
2757
  }
2604
2758
  }
2605
2759
 
2606
- let displayMessage = typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
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() : ''
2607
2764
  if (hasFieldErrors) {
2608
2765
  const lowered = displayMessage.toLowerCase()
2609
2766
  const highlightedLower = highlightedMessage.toLowerCase()
@@ -2618,7 +2775,12 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2618
2775
  displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage
2619
2776
  }
2620
2777
  displayMessage = parseServerMessage(displayMessage)
2621
- if (!hasFieldErrors) {
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) {
2622
2784
  flash(displayMessage, 'error')
2623
2785
  }
2624
2786
  setFormError(displayMessage)
@@ -3902,7 +4064,22 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect({
3902
4064
  className={`w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? 'bg-muted' : ''}`}
3903
4065
  >
3904
4066
  <span className="inline-flex items-center gap-2">
3905
- <Checkbox checked={isSel} disabled tabIndex={-1} className="pointer-events-none" />
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>
3906
4083
  <span>{opt.label}</span>
3907
4084
  </span>
3908
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 call = await apiCall<PerspectiveSaveResponse>(
1720
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1721
- {
1722
- method: 'POST',
1723
- headers: { 'Content-Type': 'application/json' },
1724
- body: JSON.stringify(payload),
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((v) => !v); requestAnimationFrame(updatePosition) }}
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>