@open-mercato/ui 0.6.4-develop.4371.1.8f3030407e → 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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +2 -1
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +212 -85
  6. package/dist/backend/CrudForm.js.map +3 -3
  7. package/dist/backend/DataTable.js +77 -26
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/FlashMessages.js +21 -2
  10. package/dist/backend/FlashMessages.js.map +2 -2
  11. package/dist/backend/JsonBuilder.js +64 -41
  12. package/dist/backend/JsonBuilder.js.map +2 -2
  13. package/dist/backend/PerspectiveSidebar.js +8 -1
  14. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  15. package/dist/backend/RowActions.js +1 -1
  16. package/dist/backend/RowActions.js.map +2 -2
  17. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  18. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  19. package/dist/backend/conflicts/index.js +32 -0
  20. package/dist/backend/conflicts/index.js.map +7 -0
  21. package/dist/backend/conflicts/store.js +50 -0
  22. package/dist/backend/conflicts/store.js.map +7 -0
  23. package/dist/backend/detail/ActivitiesSection.js +1 -0
  24. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  25. package/dist/backend/detail/InlineEditors.js +2 -51
  26. package/dist/backend/detail/InlineEditors.js.map +3 -3
  27. package/dist/backend/detail/RecordNotFoundState.js +10 -5
  28. package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
  29. package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
  30. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  31. package/dist/backend/filters/FilteredEmptyResults.js +12 -4
  32. package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
  33. package/dist/backend/filters/ListEmptyState.js +44 -0
  34. package/dist/backend/filters/ListEmptyState.js.map +7 -0
  35. package/dist/backend/filters/SearchEmptyResults.js +28 -0
  36. package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js +8 -0
  38. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  39. package/dist/backend/injection/useGuardedMutation.js +8 -1
  40. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  41. package/dist/backend/inputs/MarkdownField.js +20 -0
  42. package/dist/backend/inputs/MarkdownField.js.map +7 -0
  43. package/dist/backend/inputs/MdxEditorImpl.js +98 -0
  44. package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
  45. package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
  46. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  47. package/dist/backend/markdown/markdownToPlainText.js +29 -0
  48. package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
  49. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  50. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  51. package/dist/backend/messages/index.js +4 -0
  52. package/dist/backend/messages/index.js.map +2 -2
  53. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  54. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  55. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  56. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  57. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  58. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  59. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  60. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  61. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  62. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  63. package/dist/backend/utils/optimisticLock.js +34 -0
  64. package/dist/backend/utils/optimisticLock.js.map +7 -0
  65. package/dist/portal/hooks/useTenantContext.js +1 -1
  66. package/dist/portal/hooks/useTenantContext.js.map +2 -2
  67. package/dist/primitives/alert.js.map +1 -1
  68. package/dist/primitives/checkbox.js +2 -1
  69. package/dist/primitives/checkbox.js.map +2 -2
  70. package/dist/types/css.d.js +1 -0
  71. package/dist/types/css.d.js.map +7 -0
  72. package/package.json +17 -13
  73. package/src/backend/AppShell.tsx +10 -0
  74. package/src/backend/CrudForm.tsx +320 -97
  75. package/src/backend/DataTable.tsx +113 -34
  76. package/src/backend/FlashMessages.tsx +31 -2
  77. package/src/backend/JsonBuilder.tsx +79 -26
  78. package/src/backend/PerspectiveSidebar.tsx +8 -1
  79. package/src/backend/RowActions.tsx +1 -1
  80. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
  81. package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
  82. package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
  83. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
  84. package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
  85. package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
  86. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
  87. package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
  88. package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
  89. package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
  90. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
  91. package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
  92. package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
  93. package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
  94. package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
  95. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
  96. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  97. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  98. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  99. package/src/backend/conflicts/index.ts +49 -0
  100. package/src/backend/conflicts/store.ts +94 -0
  101. package/src/backend/detail/ActivitiesSection.tsx +1 -0
  102. package/src/backend/detail/InlineEditors.tsx +14 -76
  103. package/src/backend/detail/RecordNotFoundState.tsx +13 -6
  104. package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
  105. package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
  106. package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
  107. package/src/backend/filters/ListEmptyState.tsx +69 -0
  108. package/src/backend/filters/SearchEmptyResults.tsx +42 -0
  109. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
  110. package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
  111. package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
  112. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  113. package/src/backend/injection/useGuardedMutation.ts +16 -1
  114. package/src/backend/inputs/MarkdownField.tsx +34 -0
  115. package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
  116. package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
  117. package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
  118. package/src/backend/markdown/markdownToPlainText.ts +41 -0
  119. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  120. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  121. package/src/backend/messages/index.ts +12 -0
  122. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  123. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  124. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  125. package/src/backend/notifications/useNotificationsSse.ts +6 -0
  126. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  127. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  128. package/src/backend/utils/optimisticLock.ts +70 -0
  129. package/src/portal/hooks/useTenantContext.ts +7 -2
  130. package/src/primitives/__tests__/checkbox.test.tsx +28 -0
  131. package/src/primitives/alert.tsx +1 -1
  132. package/src/primitives/checkbox.tsx +2 -1
  133. 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 }> }>(preferencesApiPath)
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
- apiCall(preferencesApiPath, {
699
- method: 'PUT',
700
- headers: { 'content-type': 'application/json' },
701
- body: JSON.stringify(preferencesPayload),
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
- // The variant entity is the canonical layout; the preferences sync is what the
708
- // AppShell sidebar actually reads. A failed sync would leave the saved variant
709
- // not reflected live, so surface it as a save error rather than flashing success.
710
- setError(formatVariantApiError(preferencesCall, t))
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; tenantId: string; name: string }; error?: string }>(`/api/directory/organizations/lookup?slug=${encodeURIComponent(orgSlug)}`)
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: data.organization.tenantId ?? undefined,
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
+ })
@@ -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'