@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
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
SelectTrigger,
|
|
22
22
|
SelectValue,
|
|
23
23
|
} from '../../primitives/select'
|
|
24
|
-
import { apiCall } from '../utils/apiCall'
|
|
24
|
+
import { apiCall, withScopedApiRequestHeaders } from '../utils/apiCall'
|
|
25
|
+
import { buildOptimisticLockHeader } from '../utils/optimisticLock'
|
|
26
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
25
27
|
import { flash } from '../FlashMessages'
|
|
26
28
|
import { Page, PageBody } from '../Page'
|
|
27
29
|
import { useBackendChrome } from '../BackendChromeProvider'
|
|
@@ -231,6 +233,9 @@ export function SidebarCustomizationEditor({
|
|
|
231
233
|
const [addDialogError, setAddDialogError] = React.useState<string | null>(null)
|
|
232
234
|
const baseSnapshotRef = React.useRef<SidebarGroup[] | null>(null)
|
|
233
235
|
const hasInitializedRef = React.useRef(false)
|
|
236
|
+
// Tracks the user-scope sidebar-preference version so concurrent edits from another
|
|
237
|
+
// tab are caught by the server's optimistic-lock guard (409 → conflict bar).
|
|
238
|
+
const preferencesUpdatedAtRef = React.useRef<string | null>(null)
|
|
234
239
|
|
|
235
240
|
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
236
241
|
formId: string
|
|
@@ -285,11 +290,12 @@ export function SidebarCustomizationEditor({
|
|
|
285
290
|
}, [variantsApiPath])
|
|
286
291
|
|
|
287
292
|
const loadRolesPayload = React.useCallback(async (): Promise<{ canApplyToRoles: boolean; roles: RoleTarget[] }> => {
|
|
288
|
-
const call = await apiCall<{ canApplyToRoles?: boolean; roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }
|
|
293
|
+
const call = await apiCall<{ canApplyToRoles?: boolean; roles?: Array<{ id?: string; name?: string; hasPreference?: boolean }>; updatedAt?: string | null }>(preferencesApiPath)
|
|
289
294
|
if (!call.ok) {
|
|
290
295
|
return { canApplyToRoles: false, roles: [] }
|
|
291
296
|
}
|
|
292
297
|
const data = call.result ?? null
|
|
298
|
+
preferencesUpdatedAtRef.current = typeof data?.updatedAt === 'string' ? data.updatedAt : null
|
|
293
299
|
const can = data?.canApplyToRoles === true
|
|
294
300
|
const roles = Array.isArray(data?.roles)
|
|
295
301
|
? (data!.roles as Array<{ id?: string; name?: string; hasPreference?: boolean }>)
|
|
@@ -695,19 +701,37 @@ export function SidebarCustomizationEditor({
|
|
|
695
701
|
}
|
|
696
702
|
const preferencesCall = await runMutation({
|
|
697
703
|
operation: () =>
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
704
|
+
withScopedApiRequestHeaders(
|
|
705
|
+
buildOptimisticLockHeader(preferencesUpdatedAtRef.current),
|
|
706
|
+
() =>
|
|
707
|
+
apiCall<{ updatedAt?: string | null }>(preferencesApiPath, {
|
|
708
|
+
method: 'PUT',
|
|
709
|
+
headers: { 'content-type': 'application/json' },
|
|
710
|
+
body: JSON.stringify(preferencesPayload),
|
|
711
|
+
}),
|
|
712
|
+
),
|
|
703
713
|
context: buildMutationContext('savePreferences', selectedVariantId),
|
|
704
714
|
mutationPayload: preferencesPayload,
|
|
705
715
|
})
|
|
716
|
+
if (preferencesCall.ok) {
|
|
717
|
+
preferencesUpdatedAtRef.current =
|
|
718
|
+
typeof preferencesCall.result?.updatedAt === 'string' ? preferencesCall.result.updatedAt : null
|
|
719
|
+
}
|
|
706
720
|
if (!preferencesCall.ok) {
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
721
|
+
// A stale save (another tab changed the sidebar customization first) returns a
|
|
722
|
+
// 409 with `code: 'optimistic_lock_conflict'`. apiCall does not throw, so surface
|
|
723
|
+
// the conflict explicitly on the shared, persistent conflict bar instead of a
|
|
724
|
+
// generic save error.
|
|
725
|
+
const surfaced = surfaceRecordConflict(
|
|
726
|
+
{ status: preferencesCall.status, body: preferencesCall.result },
|
|
727
|
+
t,
|
|
728
|
+
)
|
|
729
|
+
if (!surfaced) {
|
|
730
|
+
// The variant entity is the canonical layout; the preferences sync is what the
|
|
731
|
+
// AppShell sidebar actually reads. A failed sync would leave the saved variant
|
|
732
|
+
// not reflected live, so surface it as a save error rather than flashing success.
|
|
733
|
+
setError(formatVariantApiError(preferencesCall, t))
|
|
734
|
+
}
|
|
711
735
|
return
|
|
712
736
|
}
|
|
713
737
|
try { window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT)) } catch { /* no listener attached — fine, AppShell will refresh on next navigation */ }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '../optimisticLock'
|
|
2
|
+
import {
|
|
3
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
4
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
5
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
6
|
+
|
|
7
|
+
describe('buildOptimisticLockHeader', () => {
|
|
8
|
+
it('returns the extension header when updatedAt is a non-empty string', () => {
|
|
9
|
+
expect(buildOptimisticLockHeader('2026-05-25T08:42:18.123Z')).toEqual({
|
|
10
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: '2026-05-25T08:42:18.123Z',
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('trims surrounding whitespace', () => {
|
|
15
|
+
expect(buildOptimisticLockHeader(' 2026-05-25T08:42:18.123Z ')).toEqual({
|
|
16
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: '2026-05-25T08:42:18.123Z',
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns an empty object for null / undefined / empty / whitespace input', () => {
|
|
21
|
+
expect(buildOptimisticLockHeader(null)).toEqual({})
|
|
22
|
+
expect(buildOptimisticLockHeader(undefined)).toEqual({})
|
|
23
|
+
expect(buildOptimisticLockHeader('')).toEqual({})
|
|
24
|
+
expect(buildOptimisticLockHeader(' ')).toEqual({})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns an empty object for non-string input (defensive)', () => {
|
|
28
|
+
// @ts-expect-error intentional misuse
|
|
29
|
+
expect(buildOptimisticLockHeader(12345)).toEqual({})
|
|
30
|
+
// @ts-expect-error intentional misuse
|
|
31
|
+
expect(buildOptimisticLockHeader({ updatedAt: '2026' })).toEqual({})
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('extractOptimisticLockConflict', () => {
|
|
36
|
+
it('returns the conflict body when the error has status 409 + optimistic-lock code + both timestamps', () => {
|
|
37
|
+
const err = {
|
|
38
|
+
status: 409,
|
|
39
|
+
body: {
|
|
40
|
+
error: 'record_modified',
|
|
41
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
42
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
43
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
expect(extractOptimisticLockConflict(err)).toEqual({
|
|
47
|
+
error: 'record_modified',
|
|
48
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
49
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
50
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns the conflict body when raiseCrudError attached the response fields directly to the error', () => {
|
|
55
|
+
const err = {
|
|
56
|
+
status: 409,
|
|
57
|
+
error: 'record_modified',
|
|
58
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
59
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
60
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
61
|
+
}
|
|
62
|
+
expect(extractOptimisticLockConflict(err)).toEqual({
|
|
63
|
+
error: 'record_modified',
|
|
64
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
65
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
66
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns null for non-409 errors', () => {
|
|
71
|
+
expect(
|
|
72
|
+
extractOptimisticLockConflict({
|
|
73
|
+
status: 422,
|
|
74
|
+
body: { error: 'validation_failed', code: OPTIMISTIC_LOCK_CONFLICT_CODE },
|
|
75
|
+
}),
|
|
76
|
+
).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns null for 409 errors that are not optimistic-lock conflicts', () => {
|
|
80
|
+
expect(
|
|
81
|
+
extractOptimisticLockConflict({
|
|
82
|
+
status: 409,
|
|
83
|
+
body: { error: 'duplicate', code: 'duplicate_resource' },
|
|
84
|
+
}),
|
|
85
|
+
).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns null when the conflict body is missing required timestamp fields', () => {
|
|
89
|
+
expect(
|
|
90
|
+
extractOptimisticLockConflict({
|
|
91
|
+
status: 409,
|
|
92
|
+
body: { error: 'record_modified', code: OPTIMISTIC_LOCK_CONFLICT_CODE },
|
|
93
|
+
}),
|
|
94
|
+
).toBeNull()
|
|
95
|
+
expect(
|
|
96
|
+
extractOptimisticLockConflict({
|
|
97
|
+
status: 409,
|
|
98
|
+
body: {
|
|
99
|
+
error: 'record_modified',
|
|
100
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
101
|
+
currentUpdatedAt: 123,
|
|
102
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns null for non-object inputs', () => {
|
|
109
|
+
expect(extractOptimisticLockConflict(null)).toBeNull()
|
|
110
|
+
expect(extractOptimisticLockConflict(undefined)).toBeNull()
|
|
111
|
+
expect(extractOptimisticLockConflict('boom')).toBeNull()
|
|
112
|
+
expect(extractOptimisticLockConflict(409)).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('falls back to the canonical error token when body.error is missing', () => {
|
|
116
|
+
const result = extractOptimisticLockConflict({
|
|
117
|
+
status: 409,
|
|
118
|
+
body: {
|
|
119
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
120
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
121
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
expect(result?.error).toBe('record_modified')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side helpers for the OSS opt-in optimistic-locking guard
|
|
3
|
+
* (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
|
|
4
|
+
*
|
|
5
|
+
* These are deliberately small and dependency-light so they can be wired
|
|
6
|
+
* into any backend page without touching the shared CrudForm / useGuardedMutation
|
|
7
|
+
* components. A future PR may pull them into CrudForm directly once the
|
|
8
|
+
* reference rollout is broader.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
12
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
13
|
+
type OptimisticLockConflictBody,
|
|
14
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the extension-header bag for an `apiCall` request. Pass into
|
|
18
|
+
* `withScopedApiRequestHeaders(buildOptimisticLockHeader(updatedAt), ...)`
|
|
19
|
+
* when issuing a `PUT`/`PATCH`/`DELETE` for a record that exposes
|
|
20
|
+
* `updatedAt`. Returns an empty object when the input is missing/empty so
|
|
21
|
+
* the call site stays unconditional:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* await withScopedApiRequestHeaders(
|
|
25
|
+
* buildOptimisticLockHeader(record.updatedAt),
|
|
26
|
+
* () => updateCrud('customers/companies', id, { ... }),
|
|
27
|
+
* )
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function buildOptimisticLockHeader(
|
|
31
|
+
updatedAt: string | null | undefined,
|
|
32
|
+
): Record<string, string> {
|
|
33
|
+
if (typeof updatedAt !== 'string') return {}
|
|
34
|
+
const trimmed = updatedAt.trim()
|
|
35
|
+
if (!trimmed) return {}
|
|
36
|
+
return { [OPTIMISTIC_LOCK_HEADER_NAME]: trimmed }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detect whether an error thrown by `raiseCrudError`/`apiCallOrThrow` is an
|
|
41
|
+
* optimistic-lock conflict (HTTP 409 with `code: 'optimistic_lock_conflict'`).
|
|
42
|
+
*
|
|
43
|
+
* Returns the typed conflict body when matched so the caller can show a
|
|
44
|
+
* UI that includes the server's `currentUpdatedAt`, or `null` otherwise.
|
|
45
|
+
*/
|
|
46
|
+
export function extractOptimisticLockConflict(
|
|
47
|
+
err: unknown,
|
|
48
|
+
): OptimisticLockConflictBody | null {
|
|
49
|
+
if (!err || typeof err !== 'object') return null
|
|
50
|
+
const candidate = err as Record<string, unknown>
|
|
51
|
+
const status = candidate.status
|
|
52
|
+
if (status !== 409) return null
|
|
53
|
+
const body = candidate.body && typeof candidate.body === 'object'
|
|
54
|
+
? candidate.body
|
|
55
|
+
: candidate
|
|
56
|
+
if (!body || typeof body !== 'object') return null
|
|
57
|
+
const bodyRecord = body as Record<string, unknown>
|
|
58
|
+
if (bodyRecord.code !== OPTIMISTIC_LOCK_CONFLICT_CODE) return null
|
|
59
|
+
const currentUpdatedAt = bodyRecord.currentUpdatedAt
|
|
60
|
+
const expectedUpdatedAt = bodyRecord.expectedUpdatedAt
|
|
61
|
+
if (typeof currentUpdatedAt !== 'string' || typeof expectedUpdatedAt !== 'string') return null
|
|
62
|
+
return {
|
|
63
|
+
error: typeof bodyRecord.error === 'string'
|
|
64
|
+
? (bodyRecord.error as OptimisticLockConflictBody['error'])
|
|
65
|
+
: 'record_modified',
|
|
66
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
67
|
+
currentUpdatedAt,
|
|
68
|
+
expectedUpdatedAt,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -3,6 +3,11 @@ import { useEffect, useState } from 'react'
|
|
|
3
3
|
import { apiCall } from '../../backend/utils/apiCall'
|
|
4
4
|
|
|
5
5
|
type OrgContext = {
|
|
6
|
+
/**
|
|
7
|
+
* @deprecated The public org lookup no longer returns a tenant id; portal
|
|
8
|
+
* flows send `organizationId` and the server resolves the tenant. Always
|
|
9
|
+
* `undefined` — kept for backward compatibility with existing consumers.
|
|
10
|
+
*/
|
|
6
11
|
tenantId: string | undefined
|
|
7
12
|
organizationId: string | undefined
|
|
8
13
|
organizationName: string | undefined
|
|
@@ -49,7 +54,7 @@ export function useTenantContext(orgSlug: string): OrgContext {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
try {
|
|
52
|
-
const { ok, result: data } = await apiCall<{ ok: boolean; organization: { id: string;
|
|
57
|
+
const { ok, result: data } = await apiCall<{ ok: boolean; organization: { id: string; name: string }; error?: string }>(`/api/directory/organizations/lookup?slug=${encodeURIComponent(orgSlug)}`)
|
|
53
58
|
if (cancelled) return
|
|
54
59
|
|
|
55
60
|
if (!ok || !data?.ok || !data.organization) {
|
|
@@ -62,7 +67,7 @@ export function useTenantContext(orgSlug: string): OrgContext {
|
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
setState({
|
|
65
|
-
tenantId:
|
|
70
|
+
tenantId: undefined,
|
|
66
71
|
organizationId: data.organization.id,
|
|
67
72
|
organizationName: data.organization.name,
|
|
68
73
|
loading: false,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
5
|
+
import { Checkbox } from '../checkbox'
|
|
6
|
+
|
|
7
|
+
describe('Checkbox', () => {
|
|
8
|
+
it('does not submit a parent form by default', () => {
|
|
9
|
+
const onSubmit = jest.fn((event: React.FormEvent<HTMLFormElement>) => {
|
|
10
|
+
event.preventDefault()
|
|
11
|
+
})
|
|
12
|
+
const onCheckedChange = jest.fn()
|
|
13
|
+
|
|
14
|
+
render(
|
|
15
|
+
<form onSubmit={onSubmit}>
|
|
16
|
+
<Checkbox aria-label="Assignee alice" onCheckedChange={onCheckedChange} />
|
|
17
|
+
</form>,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const checkbox = screen.getByRole('checkbox', { name: 'Assignee alice' })
|
|
21
|
+
expect(checkbox).toHaveAttribute('type', 'button')
|
|
22
|
+
|
|
23
|
+
fireEvent.click(checkbox)
|
|
24
|
+
|
|
25
|
+
expect(onCheckedChange).toHaveBeenCalled()
|
|
26
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
27
|
+
})
|
|
28
|
+
})
|
package/src/primitives/alert.tsx
CHANGED
|
@@ -133,7 +133,7 @@ const defaultStatusIcons: Record<AlertStatus, React.ComponentType<{ className?:
|
|
|
133
133
|
feature: Rocket,
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
export type AlertProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'role'> &
|
|
136
|
+
export type AlertProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'role' | 'style'> &
|
|
137
137
|
Omit<VariantProps<typeof alertVariants>, 'status' | 'style' | 'size'> & {
|
|
138
138
|
status?: AlertStatus
|
|
139
139
|
style?: AlertStyle
|
|
@@ -31,11 +31,12 @@ export type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimit
|
|
|
31
31
|
const Checkbox = React.forwardRef<
|
|
32
32
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
33
33
|
CheckboxProps
|
|
34
|
-
>(({ className, size, ...props }, ref) => {
|
|
34
|
+
>(({ className, size, type = "button", ...props }, ref) => {
|
|
35
35
|
const iconClass = indicatorIconBySize[size ?? "sm"]
|
|
36
36
|
return (
|
|
37
37
|
<CheckboxPrimitive.Root
|
|
38
38
|
ref={ref}
|
|
39
|
+
type={type}
|
|
39
40
|
className={cn(checkboxVariants({ size, className }))}
|
|
40
41
|
{...props}
|
|
41
42
|
>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Ambient declaration so side-effect CSS imports from third-party packages
|
|
2
|
+
// (e.g. `import '@mdxeditor/editor/style.css'`) type-check. Packages that consume
|
|
3
|
+
// @open-mercato/ui as source type-check this file's `.tsx` too, so MdxEditorImpl.tsx
|
|
4
|
+
// pulls this declaration into their program via a `/// <reference path>` directive.
|
|
5
|
+
declare module '*.css'
|