@open-mercato/ui 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (214) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -2
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +2 -54
  5. package/dist/ai/AiChat.js +26 -7
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/LoopDisabledBanner.js +1 -1
  8. package/dist/ai/LoopDisabledBanner.js.map +2 -2
  9. package/dist/ai/parts/ConfirmationCard.js +4 -4
  10. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  11. package/dist/ai/parts/FieldDiffCard.js +2 -2
  12. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  13. package/dist/ai/parts/MutationResultCard.js +3 -3
  14. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  15. package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
  16. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  17. package/dist/backend/AppShell.js +43 -4
  18. package/dist/backend/AppShell.js.map +2 -2
  19. package/dist/backend/AuthSessionGuard.js +14 -0
  20. package/dist/backend/AuthSessionGuard.js.map +2 -2
  21. package/dist/backend/BackendChromeProvider.js +6 -1
  22. package/dist/backend/BackendChromeProvider.js.map +2 -2
  23. package/dist/backend/CrudForm.js +6 -4
  24. package/dist/backend/CrudForm.js.map +2 -2
  25. package/dist/backend/DataTable.js +20 -18
  26. package/dist/backend/DataTable.js.map +2 -2
  27. package/dist/backend/JsonBuilder.js +3 -3
  28. package/dist/backend/JsonBuilder.js.map +2 -2
  29. package/dist/backend/NextStepCallout.js +4 -4
  30. package/dist/backend/NextStepCallout.js.map +1 -1
  31. package/dist/backend/PerspectiveSidebar.js +1 -1
  32. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  33. package/dist/backend/ProfileDropdown.js +2 -1
  34. package/dist/backend/ProfileDropdown.js.map +2 -2
  35. package/dist/backend/RowActions.js +2 -2
  36. package/dist/backend/RowActions.js.map +2 -2
  37. package/dist/backend/confirm-dialog/ConfirmDialog.js +13 -1
  38. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  39. package/dist/backend/crud/useGroupOrder.js +14 -21
  40. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  41. package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
  42. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
  43. package/dist/backend/dashboard/DashboardScreen.js +3 -3
  44. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  45. package/dist/backend/detail/AddressTiles.js +4 -5
  46. package/dist/backend/detail/AddressTiles.js.map +2 -2
  47. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  48. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  49. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  50. package/dist/backend/detail/AttachmentsSection.js +1 -1
  51. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  52. package/dist/backend/detail/NotesSection.js +1 -1
  53. package/dist/backend/detail/NotesSection.js.map +2 -2
  54. package/dist/backend/detail/TagsSection.js +1 -1
  55. package/dist/backend/detail/TagsSection.js.map +2 -2
  56. package/dist/backend/filters/AdvancedFilterPanel.js +1 -1
  57. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  58. package/dist/backend/forms/FormHeader.js +1 -2
  59. package/dist/backend/forms/FormHeader.js.map +2 -2
  60. package/dist/backend/icons/lucideRegistry.generated.js +1 -25
  61. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  62. package/dist/backend/icons/lucideRegistry.js +3 -3
  63. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  64. package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
  65. package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
  66. package/dist/backend/injection/spotIds.js +23 -18
  67. package/dist/backend/injection/spotIds.js.map +2 -2
  68. package/dist/backend/inputs/ComboboxInput.js.map +1 -1
  69. package/dist/backend/messages/EmailThreadsPanel.js +1 -1
  70. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  71. package/dist/backend/perspectiveState.js +34 -0
  72. package/dist/backend/perspectiveState.js.map +7 -0
  73. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  74. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  75. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  76. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  77. package/dist/backend/schedule/ScheduleToolbar.js +87 -70
  78. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  79. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
  80. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  81. package/dist/backend/sidebar/customization-helpers.js +30 -0
  82. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  83. package/dist/backend/utils/apiCall.js +21 -1
  84. package/dist/backend/utils/apiCall.js.map +2 -2
  85. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  86. package/dist/backend/utils/customFieldForms.js +10 -9
  87. package/dist/backend/utils/customFieldForms.js.map +2 -2
  88. package/dist/backend/utils/fetchAllPages.js +57 -0
  89. package/dist/backend/utils/fetchAllPages.js.map +7 -0
  90. package/dist/backend/utils/nav.js +5 -2
  91. package/dist/backend/utils/nav.js.map +2 -2
  92. package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
  93. package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
  94. package/dist/frontend/LanguageSwitcher.js +2 -1
  95. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  96. package/dist/portal/PortalContext.js +1 -1
  97. package/dist/portal/PortalContext.js.map +2 -2
  98. package/dist/portal/hooks/portalBridgeStatus.js +25 -0
  99. package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
  100. package/dist/portal/hooks/usePortalEventBridge.js +6 -0
  101. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  102. package/dist/portal/hooks/usePortalNotifications.js +42 -4
  103. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  104. package/dist/portal/utils/nav.js +2 -3
  105. package/dist/portal/utils/nav.js.map +2 -2
  106. package/dist/primitives/ErrorNotice.js +1 -1
  107. package/dist/primitives/ErrorNotice.js.map +2 -2
  108. package/dist/primitives/Notice.js +1 -1
  109. package/dist/primitives/Notice.js.map +1 -1
  110. package/dist/primitives/button.js +9 -4
  111. package/dist/primitives/button.js.map +2 -2
  112. package/dist/theme/ThemeProvider.js +1 -1
  113. package/dist/theme/ThemeProvider.js.map +2 -2
  114. package/dist/theme/index.js +3 -0
  115. package/dist/theme/index.js.map +2 -2
  116. package/dist/theme/theme-init-script.js +17 -0
  117. package/dist/theme/theme-init-script.js.map +7 -0
  118. package/figma/alert.figma.tsx +35 -0
  119. package/figma/badge.figma.tsx +33 -0
  120. package/figma/button.figma.tsx +33 -0
  121. package/figma/checkbox.figma.tsx +28 -0
  122. package/figma/drawer.figma.tsx +40 -0
  123. package/figma/input-variants.figma.tsx +58 -0
  124. package/figma/radio.figma.tsx +16 -0
  125. package/figma/select.figma.tsx +24 -0
  126. package/figma/status-badge.figma.tsx +24 -0
  127. package/figma/switch.figma.tsx +28 -0
  128. package/figma/tabs.figma.tsx +22 -0
  129. package/figma/tag.figma.tsx +27 -0
  130. package/figma.config.json +6 -0
  131. package/jest.config.cjs +2 -1
  132. package/package.json +21 -18
  133. package/scripts/lucideRegistrySource.cjs +133 -0
  134. package/src/ai/AiChat.tsx +36 -7
  135. package/src/ai/LoopDisabledBanner.tsx +1 -1
  136. package/src/ai/__tests__/AiChat.test.tsx +31 -0
  137. package/src/ai/parts/ConfirmationCard.tsx +4 -4
  138. package/src/ai/parts/FieldDiffCard.tsx +2 -2
  139. package/src/ai/parts/MutationResultCard.tsx +3 -3
  140. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
  141. package/src/backend/AppShell.tsx +55 -4
  142. package/src/backend/AuthSessionGuard.tsx +29 -0
  143. package/src/backend/BackendChromeProvider.tsx +19 -1
  144. package/src/backend/CrudForm.tsx +18 -4
  145. package/src/backend/DataTable.tsx +36 -19
  146. package/src/backend/JsonBuilder.tsx +3 -3
  147. package/src/backend/NextStepCallout.tsx +4 -4
  148. package/src/backend/PerspectiveSidebar.tsx +1 -1
  149. package/src/backend/ProfileDropdown.tsx +1 -0
  150. package/src/backend/RowActions.tsx +2 -2
  151. package/src/backend/__tests__/AppShell.test.tsx +65 -7
  152. package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
  153. package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
  154. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
  155. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
  156. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
  157. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
  158. package/src/backend/__tests__/nav-utils.test.ts +139 -0
  159. package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
  160. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
  161. package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
  162. package/src/backend/crud/useGroupOrder.ts +30 -25
  163. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
  164. package/src/backend/dashboard/DashboardScreen.tsx +3 -3
  165. package/src/backend/detail/AddressTiles.tsx +3 -6
  166. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  167. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  168. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  169. package/src/backend/detail/NotesSection.tsx +1 -1
  170. package/src/backend/detail/TagsSection.tsx +1 -1
  171. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +21 -0
  172. package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
  173. package/src/backend/forms/FormHeader.tsx +1 -2
  174. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
  175. package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
  176. package/src/backend/icons/lucideRegistry.ts +2 -2
  177. package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
  178. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
  179. package/src/backend/injection/spotIds.ts +23 -18
  180. package/src/backend/inputs/ComboboxInput.tsx +1 -1
  181. package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
  182. package/src/backend/perspectiveState.ts +44 -0
  183. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  184. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  185. package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
  186. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
  187. package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
  188. package/src/backend/sidebar/customization-helpers.ts +47 -0
  189. package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
  190. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
  191. package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
  192. package/src/backend/utils/apiCall.ts +29 -1
  193. package/src/backend/utils/customFieldDefs.ts +1 -0
  194. package/src/backend/utils/customFieldForms.ts +15 -11
  195. package/src/backend/utils/fetchAllPages.ts +93 -0
  196. package/src/backend/utils/nav.ts +22 -2
  197. package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
  198. package/src/frontend/LanguageSwitcher.tsx +1 -0
  199. package/src/portal/PortalContext.tsx +1 -1
  200. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
  201. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
  202. package/src/portal/hooks/portalBridgeStatus.ts +29 -0
  203. package/src/portal/hooks/usePortalEventBridge.ts +6 -0
  204. package/src/portal/hooks/usePortalNotifications.ts +55 -12
  205. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  206. package/src/portal/utils/nav.ts +3 -4
  207. package/src/primitives/ErrorNotice.tsx +1 -1
  208. package/src/primitives/Notice.tsx +5 -5
  209. package/src/primitives/__tests__/button.test.tsx +70 -0
  210. package/src/primitives/button.tsx +10 -4
  211. package/src/theme/ThemeProvider.tsx +1 -2
  212. package/src/theme/__tests__/theme-init-script.test.ts +70 -0
  213. package/src/theme/index.ts +1 -0
  214. package/src/theme/theme-init-script.ts +23 -0
@@ -0,0 +1,103 @@
1
+ /** @jest-environment node */
2
+
3
+ const apiCall = jest.fn()
4
+
5
+ jest.mock('../apiCall', () => ({
6
+ apiCall: (...args: unknown[]) => apiCall(...args),
7
+ }))
8
+
9
+ import { fetchAllOffsetPages, MAX_OFFSET_PAGES } from '../fetchAllPages'
10
+
11
+ const okPage = (payload: Record<string, unknown>) => ({ ok: true, result: payload })
12
+
13
+ const requestedUrls = () => apiCall.mock.calls.map(([url]) => url as string)
14
+
15
+ describe('fetchAllOffsetPages', () => {
16
+ beforeEach(() => {
17
+ apiCall.mockReset()
18
+ })
19
+
20
+ it('issues a single untouched request when the route does not report hasMore', async () => {
21
+ apiCall.mockResolvedValueOnce(okPage({ items: [{ value: 'a' }, { value: 'b' }] }))
22
+
23
+ const result = await fetchAllOffsetPages('/api/options')
24
+
25
+ expect(requestedUrls()).toEqual(['/api/options'])
26
+ expect(result.ok).toBe(true)
27
+ expect(result.pageCount).toBe(1)
28
+ expect(result.items).toEqual([{ value: 'a' }, { value: 'b' }])
29
+ })
30
+
31
+ it('walks hasMore, advancing the offset by the rows already collected', async () => {
32
+ apiCall
33
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }, { value: 'b' }], hasMore: true }))
34
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'c' }], hasMore: false }))
35
+
36
+ const result = await fetchAllOffsetPages('/api/options')
37
+
38
+ expect(requestedUrls()).toEqual(['/api/options', '/api/options?offset=2'])
39
+ expect(result.items).toHaveLength(3)
40
+ expect(result.pageCount).toBe(2)
41
+ })
42
+
43
+ it('preserves the caller query string when it appends the offset', async () => {
44
+ apiCall
45
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
46
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'b' }], hasMore: false }))
47
+
48
+ await fetchAllOffsetPages('/api/options?query=ab&q=ab')
49
+
50
+ expect(requestedUrls()[1]).toBe('/api/options?query=ab&q=ab&offset=1')
51
+ })
52
+
53
+ it('continues from an offset the caller already put on the url', async () => {
54
+ apiCall
55
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
56
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'b' }], hasMore: false }))
57
+
58
+ await fetchAllOffsetPages('/api/options?offset=10')
59
+
60
+ expect(requestedUrls()).toEqual(['/api/options?offset=10', '/api/options?offset=11'])
61
+ })
62
+
63
+ it('exposes the first payload so callers can read page-invariant metadata', async () => {
64
+ apiCall
65
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true, sortMode: 'value_desc' }))
66
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'b' }], hasMore: false, sortMode: 'value_desc' }))
67
+
68
+ const result = await fetchAllOffsetPages('/api/options')
69
+
70
+ expect(result.firstPayload.sortMode).toBe('value_desc')
71
+ })
72
+
73
+ it('stops on an empty page so a route reporting hasMore forever cannot spin', async () => {
74
+ apiCall
75
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
76
+ .mockResolvedValueOnce(okPage({ items: [], hasMore: true }))
77
+
78
+ const result = await fetchAllOffsetPages('/api/options')
79
+
80
+ expect(apiCall).toHaveBeenCalledTimes(2)
81
+ expect(result.items).toHaveLength(1)
82
+ })
83
+
84
+ it('caps the walk at the page ceiling', async () => {
85
+ apiCall.mockResolvedValue(okPage({ items: [{ value: 'a' }], hasMore: true }))
86
+
87
+ const result = await fetchAllOffsetPages('/api/options')
88
+
89
+ expect(apiCall).toHaveBeenCalledTimes(MAX_OFFSET_PAGES)
90
+ expect(result.pageCount).toBe(MAX_OFFSET_PAGES)
91
+ })
92
+
93
+ it('reports a failed page instead of returning a silently truncated list', async () => {
94
+ apiCall
95
+ .mockResolvedValueOnce(okPage({ items: [{ value: 'a' }], hasMore: true }))
96
+ .mockResolvedValueOnce({ ok: false, result: { error: 'Forbidden' } })
97
+
98
+ const result = await fetchAllOffsetPages('/api/options')
99
+
100
+ expect(result.ok).toBe(false)
101
+ expect(result.errorPayload).toEqual({ error: 'Forbidden' })
102
+ })
103
+ })
@@ -19,6 +19,27 @@ export type ApiCallResult<TReturn> = {
19
19
 
20
20
  const scopedRequestHeaders = createScopedHeaderStack()
21
21
 
22
+ function isAbortError(error: unknown): boolean {
23
+ return typeof error === 'object'
24
+ && error !== null
25
+ && (error as { name?: unknown }).name === 'AbortError'
26
+ }
27
+
28
+ function createAbortError(): Error {
29
+ if (typeof DOMException === 'function') {
30
+ return new DOMException('[internal] The operation was aborted.', 'AbortError')
31
+ }
32
+ const error = new Error('[internal] The operation was aborted.')
33
+ error.name = 'AbortError'
34
+ return error
35
+ }
36
+
37
+ function resolveAbortSignal(input: RequestInfo | URL, init?: RequestInit): AbortSignal | null {
38
+ if (init?.signal) return init.signal
39
+ if (typeof Request !== 'undefined' && input instanceof Request) return input.signal ?? null
40
+ return null
41
+ }
42
+
22
43
  function mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {
23
44
  if (!base) return extra
24
45
  if (typeof Headers !== 'undefined' && base instanceof Headers) {
@@ -63,9 +84,16 @@ export async function apiCall<TReturn = Record<string, unknown>>(
63
84
  : response
64
85
  if (parser) result = await parser(source)
65
86
  else result = await readJsonSafe<TReturn>(source, fallback)
66
- } catch {
87
+ } catch (err) {
88
+ if (isAbortError(err)) throw err
67
89
  result = fallback
68
90
  }
91
+ // `readJsonSafe` swallows the abort that cancels an in-flight body read, so a
92
+ // request aborted after its response headers arrived would otherwise look like
93
+ // a successful call that returned an empty payload.
94
+ if (result == null && resolveAbortSignal(input, init)?.aborted) {
95
+ throw createAbortError()
96
+ }
69
97
  return {
70
98
  ok: response.ok,
71
99
  status: response.status,
@@ -39,6 +39,7 @@ export type CustomFieldDefDto = {
39
39
  dictionaryId?: string
40
40
  dictionaryInlineCreate?: boolean
41
41
  defaultValue?: string | number | boolean | null
42
+ relatedEntityId?: string
42
43
  }
43
44
 
44
45
  export type CustomFieldsetGroupDto = {
@@ -9,7 +9,7 @@ import {
9
9
  fetchCustomFieldDefinitionsPayload,
10
10
  } from './customFieldDefs'
11
11
  import { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'
12
- import { apiCall } from './apiCall'
12
+ import { fetchAllOffsetPages } from './fetchAllPages'
13
13
  import { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'
14
14
  import { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'
15
15
 
@@ -42,18 +42,22 @@ function buildOptionsUrl(base: string, query?: string): string {
42
42
  }
43
43
  }
44
44
 
45
- type OptionsResponse = { items?: unknown[] }
46
-
47
45
  async function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {
48
46
  try {
49
- const call = await apiCall<OptionsResponse>(url, undefined, { fallback: { items: [] } })
50
- if (!call.ok) return []
51
- const payload = call.result ?? { items: [] }
52
- const items = Array.isArray(payload?.items) ? payload.items : []
53
- return items.map((it: any) => ({
54
- value: String(it?.value ?? it),
55
- label: String(it?.label ?? it?.value ?? it),
56
- }))
47
+ // Options routes bound how many rows one response carries (dictionary
48
+ // entries at 500), so walk every page instead of reading a single
49
+ // response a stored value past the first page must stay selectable and
50
+ // resolvable to its label. Routes without `hasMore` still resolve in one
51
+ // request.
52
+ const pages = await fetchAllOffsetPages(url)
53
+ if (!pages.ok) return []
54
+ return pages.items.map((item) => {
55
+ const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : null
56
+ return {
57
+ value: String(record?.value ?? item),
58
+ label: String(record?.label ?? record?.value ?? item),
59
+ }
60
+ })
57
61
  } catch {
58
62
  return []
59
63
  }
@@ -0,0 +1,93 @@
1
+ "use client"
2
+
3
+ import { apiCall } from './apiCall'
4
+
5
+ // Offset-paged list routes cap how many rows one response may carry, so a
6
+ // caller that needs the whole collection has to walk the pages. Bound the walk
7
+ // so a route reporting hasMore forever cannot hang the caller; at the 500-row
8
+ // dictionary ceiling this still covers 25k rows.
9
+ export const MAX_OFFSET_PAGES = 50
10
+
11
+ export type OffsetPagedPayload = Record<string, unknown>
12
+
13
+ export type OffsetPagedResult = {
14
+ ok: boolean
15
+ items: unknown[]
16
+ pageCount: number
17
+ firstPayload: OffsetPagedPayload
18
+ errorPayload: OffsetPagedPayload | null
19
+ }
20
+
21
+ const ABSOLUTE_URL_PATTERN = /^([a-z][a-z\d+\-.]*:)?\/\//i
22
+
23
+ type ParsedUrl = { url: URL; absolute: boolean }
24
+
25
+ function parseUrl(value: string): ParsedUrl | null {
26
+ try {
27
+ const absolute = ABSOLUTE_URL_PATTERN.test(value)
28
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
29
+ return { url: absolute ? new URL(value) : new URL(value, origin), absolute }
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ function formatUrl(parsed: ParsedUrl): string {
36
+ return parsed.absolute ? parsed.url.toString() : `${parsed.url.pathname}${parsed.url.search}`
37
+ }
38
+
39
+ function readOffset(value: string): number {
40
+ const parsed = parseUrl(value)
41
+ if (!parsed) return 0
42
+ const offset = Number.parseInt(parsed.url.searchParams.get('offset') ?? '', 10)
43
+ return Number.isFinite(offset) && offset > 0 ? offset : 0
44
+ }
45
+
46
+ function withOffset(value: string, offset: number): string {
47
+ const parsed = parseUrl(value)
48
+ if (!parsed) {
49
+ const separator = value.includes('?') ? '&' : '?'
50
+ return `${value}${separator}offset=${offset}`
51
+ }
52
+ parsed.url.searchParams.set('offset', String(offset))
53
+ return formatUrl(parsed)
54
+ }
55
+
56
+ /**
57
+ * Walk every page of an offset-paged list route and return the assembled items.
58
+ *
59
+ * A route that does not report `hasMore` resolves after a single request, so
60
+ * this is a drop-in replacement for a one-shot `apiCall` against any list
61
+ * endpoint. Ordering is preserved as the route returned it, page after page —
62
+ * callers that need a global order across page boundaries must re-sort.
63
+ */
64
+ export async function fetchAllOffsetPages(
65
+ url: string,
66
+ options: { maxPages?: number } = {},
67
+ ): Promise<OffsetPagedResult> {
68
+ const maxPages = options.maxPages ?? MAX_OFFSET_PAGES
69
+ const baseOffset = readOffset(url)
70
+ const items: unknown[] = []
71
+ let firstPayload: OffsetPagedPayload = {}
72
+ let pageCount = 0
73
+
74
+ while (pageCount < maxPages) {
75
+ // The first request goes out untouched so a route that caches only its
76
+ // default page still serves that page from cache.
77
+ const pageUrl = pageCount === 0 ? url : withOffset(url, baseOffset + items.length)
78
+ const call = await apiCall<OffsetPagedPayload>(pageUrl, undefined, { fallback: {} })
79
+ const payload = call.result ?? {}
80
+ if (!call.ok) {
81
+ return { ok: false, items, pageCount, firstPayload, errorPayload: payload }
82
+ }
83
+ pageCount += 1
84
+ if (pageCount === 1) firstPayload = payload
85
+ const pageItems = Array.isArray(payload.items) ? payload.items : []
86
+ items.push(...pageItems)
87
+ // An empty page also stops the walk: without it a route that keeps
88
+ // reporting hasMore while returning nothing would spin to the page cap.
89
+ if (payload.hasMore !== true || pageItems.length === 0) break
90
+ }
91
+
92
+ return { ok: true, items, pageCount, firstPayload, errorPayload: null }
93
+ }
@@ -100,6 +100,26 @@ export type SettingsSectionItem = {
100
100
  children?: SettingsSectionItem[]
101
101
  }
102
102
 
103
+ /**
104
+ * Slug of a rendered group label, used as the pre-#4843 settings section id.
105
+ *
106
+ * `sectionOrder` used to be keyed by these slugs, which made ordering depend on the active locale.
107
+ * Kept only as a lookup fallback so third-party callers passing a legacy map keep their weights.
108
+ *
109
+ * @deprecated Key `sectionOrder` by the untranslated group id (`AdminNavItem.groupId`) instead.
110
+ */
111
+ function legacySettingsSectionSlug(groupLabel: string): string {
112
+ return groupLabel.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
113
+ }
114
+
115
+ /**
116
+ * Groups the settings-context nav entries into ordered sections.
117
+ *
118
+ * `sectionOrder` is keyed by the untranslated group id (`AdminNavItem.groupId`, i.e. the page's
119
+ * `pageGroupKey` when it declares one) — the same convention the main sidebar's `defaultGroupOrder`
120
+ * follows. Keying off the rendered label instead made every non-English deployment miss its weights
121
+ * and fall back to the catch-all bucket (#4843).
122
+ */
103
123
  export function buildSettingsSections(
104
124
  entries: AdminNavItem[],
105
125
  sectionOrder: Record<string, number>
@@ -123,8 +143,8 @@ export function buildSettingsSections(
123
143
  }
124
144
 
125
145
  for (const item of settingsItems) {
126
- const sectionId = item.group.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
127
- const order = sectionOrder[sectionId] ?? 999
146
+ const sectionId = item.groupId
147
+ const order = sectionOrder[sectionId] ?? sectionOrder[legacySettingsSectionSlug(item.group)] ?? 999
128
148
 
129
149
  if (!sectionMap.has(sectionId)) {
130
150
  sectionMap.set(sectionId, {
@@ -192,7 +192,7 @@ export function VersionHistoryPanel({
192
192
  ) : (
193
193
  <div className="space-y-3">
194
194
  {shouldAutoCheck && !permissions.isLoading && !permissions.canViewTenant && permissions.currentUserId ? (
195
- <Alert variant="info">
195
+ <Alert status="information">
196
196
  <AlertDescription>
197
197
  {t('audit_logs.hint.view_self_only', 'Showing only your own changes. Contact an administrator for broader access.')}
198
198
  </AlertDescription>
@@ -24,6 +24,7 @@ export function LanguageSwitcher() {
24
24
  pl: t('common.languages.polish', 'Polski'),
25
25
  es: t('common.languages.spanish', 'Español'),
26
26
  de: t('common.languages.german', 'Deutsch'),
27
+ ko: t('common.languages.korean', '한국어'),
27
28
  }
28
29
 
29
30
  async function setLocale(locale: Locale) {
@@ -91,7 +91,7 @@ export function PortalProvider({ orgSlug, children, initialAuth, initialTenant }
91
91
  },
92
92
  roles: [],
93
93
  resolvedFeatures: initialAuth.resolvedFeatures,
94
- isPortalAdmin: initialAuth.resolvedFeatures.some((f) => f === 'portal.*'),
94
+ isPortalAdmin: initialAuth.isPortalAdmin === true,
95
95
  loading: false,
96
96
  error: null,
97
97
  }
@@ -0,0 +1,66 @@
1
+ /** @jest-environment jsdom */
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { usePortalEventBridge } from '../usePortalEventBridge'
4
+ import {
5
+ clearPortalBridgeHealth,
6
+ PORTAL_BRIDGE_STATUS_DOM_NAME,
7
+ readPortalBridgeHealth,
8
+ type PortalBridgeStatusDetail,
9
+ } from '../portalBridgeStatus'
10
+
11
+ class EventSourceMock {
12
+ static instances: EventSourceMock[] = []
13
+
14
+ onopen: ((event: Event) => void) | null = null
15
+ onmessage: ((event: MessageEvent) => void) | null = null
16
+ onerror: ((event: Event) => void) | null = null
17
+ close = jest.fn()
18
+
19
+ constructor() {
20
+ EventSourceMock.instances.push(this)
21
+ }
22
+ }
23
+
24
+ describe('usePortalEventBridge health lifecycle', () => {
25
+ const originalEventSource = globalThis.window?.EventSource
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks()
29
+ EventSourceMock.instances = []
30
+ clearPortalBridgeHealth()
31
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = EventSourceMock as unknown as typeof EventSource
32
+ })
33
+
34
+ afterEach(() => {
35
+ clearPortalBridgeHealth()
36
+ if (typeof originalEventSource === 'undefined') {
37
+ delete (window as unknown as { EventSource?: typeof EventSource }).EventSource
38
+ } else {
39
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = originalEventSource
40
+ }
41
+ })
42
+
43
+ it('publishes healthy on open and unhealthy when the bridge unmounts', () => {
44
+ const statuses: boolean[] = []
45
+ const handleStatus = (event: Event) => {
46
+ statuses.push((event as CustomEvent<PortalBridgeStatusDetail>).detail.healthy)
47
+ }
48
+ window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatus)
49
+
50
+ const { unmount } = renderHook(() => usePortalEventBridge())
51
+ const source = EventSourceMock.instances[0]
52
+ expect(source).toBeDefined()
53
+
54
+ act(() => {
55
+ source.onopen?.(new Event('open'))
56
+ })
57
+ expect(readPortalBridgeHealth()).toBe(true)
58
+
59
+ unmount()
60
+
61
+ expect(source.close).toHaveBeenCalledTimes(1)
62
+ expect(readPortalBridgeHealth()).toBe(false)
63
+ expect(statuses).toEqual([true, false])
64
+ window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatus)
65
+ })
66
+ })
@@ -0,0 +1,155 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { renderHook, act, waitFor } from '@testing-library/react'
4
+ import { usePortalNotifications } from '../usePortalNotifications'
5
+ import {
6
+ clearPortalBridgeHealth,
7
+ publishPortalBridgeHealth,
8
+ } from '../portalBridgeStatus'
9
+
10
+ const apiCallMock = jest.fn()
11
+
12
+ jest.mock('../../../backend/utils/apiCall', () => ({
13
+ apiCall: (...args: unknown[]) => apiCallMock(...args),
14
+ }))
15
+
16
+ describe('usePortalNotifications strategy', () => {
17
+ const originalEventSource = globalThis.window?.EventSource
18
+ let setIntervalSpy: jest.SpyInstance
19
+
20
+ beforeEach(() => {
21
+ jest.clearAllMocks()
22
+ apiCallMock.mockResolvedValue({
23
+ ok: true,
24
+ result: { ok: true, items: [], unreadCount: 0 },
25
+ })
26
+ setIntervalSpy = jest.spyOn(global, 'setInterval')
27
+ clearPortalBridgeHealth()
28
+ })
29
+
30
+ afterEach(() => {
31
+ setIntervalSpy.mockRestore()
32
+ if (typeof originalEventSource === 'undefined') {
33
+ delete (window as unknown as { EventSource?: typeof EventSource }).EventSource
34
+ } else {
35
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = originalEventSource
36
+ }
37
+ })
38
+
39
+ it('keeps polling until an available EventSource bridge reports healthy', async () => {
40
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
41
+ return {} as EventSource
42
+ } as unknown as typeof EventSource
43
+
44
+ const { result } = renderHook(() => usePortalNotifications())
45
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
46
+
47
+ expect(apiCallMock).toHaveBeenCalled()
48
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
49
+ })
50
+
51
+ it('uses SSE strategy without polling when the bridge is explicitly healthy', async () => {
52
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
53
+ return {} as EventSource
54
+ } as unknown as typeof EventSource
55
+ publishPortalBridgeHealth(true)
56
+
57
+ const { result } = renderHook(() => usePortalNotifications())
58
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
59
+
60
+ expect(apiCallMock).toHaveBeenCalled()
61
+ expect(setIntervalSpy).not.toHaveBeenCalledWith(expect.any(Function), 8000)
62
+ })
63
+
64
+ it('falls back to polling strategy when EventSource is unavailable', async () => {
65
+ delete (window as unknown as { EventSource?: typeof EventSource }).EventSource
66
+
67
+ const { result } = renderHook(() => usePortalNotifications())
68
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
69
+
70
+ expect(apiCallMock).toHaveBeenCalled()
71
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
72
+ })
73
+
74
+ it('activates polling when SSE is explicitly marked unhealthy', async () => {
75
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
76
+ return {} as EventSource
77
+ } as unknown as typeof EventSource
78
+
79
+ publishPortalBridgeHealth(false)
80
+
81
+ const { result } = renderHook(() => usePortalNotifications())
82
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
83
+
84
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
85
+ })
86
+
87
+ it('switches to polling fallback dynamically when status becomes unhealthy', async () => {
88
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
89
+ return {} as EventSource
90
+ } as unknown as typeof EventSource
91
+ publishPortalBridgeHealth(true)
92
+
93
+ const { result } = renderHook(() => usePortalNotifications())
94
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
95
+ expect(apiCallMock).toHaveBeenCalledTimes(2)
96
+
97
+ expect(setIntervalSpy).not.toHaveBeenCalledWith(expect.any(Function), 8000)
98
+
99
+ act(() => {
100
+ publishPortalBridgeHealth(false)
101
+ })
102
+
103
+ expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 8000)
104
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(4))
105
+ })
106
+
107
+ it('performs one reconciliation for an unhealthy-to-reconnected sequence', async () => {
108
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
109
+ return {} as EventSource
110
+ } as unknown as typeof EventSource
111
+ publishPortalBridgeHealth(true)
112
+
113
+ const { result } = renderHook(() => usePortalNotifications())
114
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
115
+ expect(apiCallMock).toHaveBeenCalledTimes(2)
116
+
117
+ act(() => {
118
+ publishPortalBridgeHealth(false)
119
+ })
120
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(4))
121
+
122
+ act(() => {
123
+ publishPortalBridgeHealth(true)
124
+ window.dispatchEvent(
125
+ new CustomEvent('om:portal-event', {
126
+ detail: {
127
+ id: 'om:portal-bridge:reconnected',
128
+ payload: {},
129
+ },
130
+ })
131
+ )
132
+ })
133
+
134
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(6))
135
+ })
136
+
137
+ it('reconciles notifications when the portal window regains focus', async () => {
138
+ ;(window as unknown as { EventSource?: typeof EventSource }).EventSource = function EventSourceMock() {
139
+ return {} as EventSource
140
+ } as unknown as typeof EventSource
141
+
142
+ const { result } = renderHook(() => usePortalNotifications())
143
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
144
+ expect(apiCallMock).toHaveBeenCalledTimes(2)
145
+
146
+ await act(async () => {
147
+ window.dispatchEvent(new Event('focus'))
148
+ await new Promise<void>((resolve) => {
149
+ setTimeout(resolve, 0)
150
+ })
151
+ })
152
+
153
+ await waitFor(() => expect(apiCallMock).toHaveBeenCalledTimes(4))
154
+ })
155
+ })
@@ -0,0 +1,29 @@
1
+ export const PORTAL_BRIDGE_STATUS_DOM_NAME = 'om:portal-bridge:status'
2
+
3
+ export type PortalBridgeStatusDetail = {
4
+ healthy: boolean
5
+ }
6
+
7
+ type PortalBridgeWindow = Window & {
8
+ __portalBridgeHealthy?: boolean
9
+ }
10
+
11
+ export function readPortalBridgeHealth(): boolean | undefined {
12
+ if (typeof window === 'undefined') return undefined
13
+ return (window as PortalBridgeWindow).__portalBridgeHealthy
14
+ }
15
+
16
+ export function publishPortalBridgeHealth(healthy: boolean): void {
17
+ if (typeof window === 'undefined') return
18
+ ;(window as PortalBridgeWindow).__portalBridgeHealthy = healthy
19
+ window.dispatchEvent(
20
+ new CustomEvent<PortalBridgeStatusDetail>(PORTAL_BRIDGE_STATUS_DOM_NAME, {
21
+ detail: { healthy },
22
+ }),
23
+ )
24
+ }
25
+
26
+ export function clearPortalBridgeHealth(): void {
27
+ if (typeof window === 'undefined') return
28
+ delete (window as PortalBridgeWindow).__portalBridgeHealthy
29
+ }
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
3
3
  import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
4
4
  import { PORTAL_EVENT_DOM_NAME } from './usePortalAppEvent'
5
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
6
+ import { publishPortalBridgeHealth } from './portalBridgeStatus'
6
7
 
7
8
  const logger = createLogger('ui').child({ component: 'PortalEventBridge' })
8
9
 
@@ -62,6 +63,7 @@ export function usePortalEventBridge(): void {
62
63
  if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)
63
64
  heartbeatTimer.current = setTimeout(() => {
64
65
  logger.warn('Heartbeat timeout — reconnecting')
66
+ publishPortalBridgeHealth(false)
65
67
  disconnect()
66
68
  scheduleReconnect()
67
69
  }, HEARTBEAT_TIMEOUT)
@@ -80,6 +82,7 @@ export function usePortalEventBridge(): void {
80
82
  hasEverConnected.current = true
81
83
  reconnectPending.current = false
82
84
  reconnectAttempts.current = 0
85
+ publishPortalBridgeHealth(true)
83
86
  resetHeartbeatTimer()
84
87
  if (shouldEmitReconnect) {
85
88
  window.dispatchEvent(
@@ -116,6 +119,7 @@ export function usePortalEventBridge(): void {
116
119
  if (hasEverConnected.current) {
117
120
  reconnectPending.current = true
118
121
  }
122
+ publishPortalBridgeHealth(false)
119
123
  disconnect()
120
124
  if (mounted) scheduleReconnect()
121
125
  }
@@ -123,6 +127,7 @@ export function usePortalEventBridge(): void {
123
127
  if (hasEverConnected.current) {
124
128
  reconnectPending.current = true
125
129
  }
130
+ publishPortalBridgeHealth(false)
126
131
  if (mounted) scheduleReconnect()
127
132
  }
128
133
  }
@@ -155,6 +160,7 @@ export function usePortalEventBridge(): void {
155
160
 
156
161
  return () => {
157
162
  mounted = false
163
+ publishPortalBridgeHealth(false)
158
164
  disconnect()
159
165
  if (reconnectTimer.current) {
160
166
  clearTimeout(reconnectTimer.current)