@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.
- 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 +120 -15
- 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 +189 -12
- 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.dotPath.test.tsx +312 -0
- 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
|
|
@@ -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
|
-
|
|
1213
|
-
|
|
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
|
|
1258
|
-
|
|
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
|
|
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
|
-
|
|
2548
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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>
|