@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -2
- package/agentic/standalone-guide.md +1 -1
- package/build.mjs +2 -54
- package/dist/ai/AiChat.js +26 -7
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/LoopDisabledBanner.js +1 -1
- package/dist/ai/LoopDisabledBanner.js.map +2 -2
- package/dist/ai/parts/ConfirmationCard.js +4 -4
- package/dist/ai/parts/ConfirmationCard.js.map +2 -2
- package/dist/ai/parts/FieldDiffCard.js +2 -2
- package/dist/ai/parts/FieldDiffCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +3 -3
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
- package/dist/backend/AppShell.js +43 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +14 -0
- package/dist/backend/AuthSessionGuard.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +6 -1
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/CrudForm.js +6 -4
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +20 -18
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/JsonBuilder.js +3 -3
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/NextStepCallout.js +4 -4
- package/dist/backend/NextStepCallout.js.map +1 -1
- package/dist/backend/PerspectiveSidebar.js +1 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +2 -1
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/RowActions.js +2 -2
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +13 -1
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/useGroupOrder.js +14 -21
- package/dist/backend/crud/useGroupOrder.js.map +2 -2
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +3 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/detail/AddressTiles.js +4 -5
- package/dist/backend/detail/AddressTiles.js.map +2 -2
- package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
- package/dist/backend/detail/AttachmentsSection.js +1 -1
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +1 -1
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/detail/TagsSection.js +1 -1
- package/dist/backend/detail/TagsSection.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +1 -1
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/forms/FormHeader.js +1 -2
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +1 -25
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.js +3 -3
- package/dist/backend/icons/lucideRegistry.js.map +2 -2
- package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
- package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
- package/dist/backend/injection/spotIds.js +23 -18
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js.map +1 -1
- package/dist/backend/messages/EmailThreadsPanel.js +1 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/perspectiveState.js +34 -0
- package/dist/backend/perspectiveState.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +3 -3
- package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
- package/dist/backend/schedule/ScheduleGrid.js +3 -3
- package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
- package/dist/backend/schedule/ScheduleToolbar.js +87 -70
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/sidebar/customization-helpers.js +30 -0
- package/dist/backend/sidebar/customization-helpers.js.map +2 -2
- package/dist/backend/utils/apiCall.js +21 -1
- package/dist/backend/utils/apiCall.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +10 -9
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/dist/backend/utils/fetchAllPages.js +57 -0
- package/dist/backend/utils/fetchAllPages.js.map +7 -0
- package/dist/backend/utils/nav.js +5 -2
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
- package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
- package/dist/frontend/LanguageSwitcher.js +2 -1
- package/dist/frontend/LanguageSwitcher.js.map +2 -2
- package/dist/portal/PortalContext.js +1 -1
- package/dist/portal/PortalContext.js.map +2 -2
- package/dist/portal/hooks/portalBridgeStatus.js +25 -0
- package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
- package/dist/portal/hooks/usePortalEventBridge.js +6 -0
- package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
- package/dist/portal/hooks/usePortalNotifications.js +42 -4
- package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
- package/dist/portal/utils/nav.js +2 -3
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/ErrorNotice.js +1 -1
- package/dist/primitives/ErrorNotice.js.map +2 -2
- package/dist/primitives/Notice.js +1 -1
- package/dist/primitives/Notice.js.map +1 -1
- package/dist/primitives/button.js +9 -4
- package/dist/primitives/button.js.map +2 -2
- package/dist/theme/ThemeProvider.js +1 -1
- package/dist/theme/ThemeProvider.js.map +2 -2
- package/dist/theme/index.js +3 -0
- package/dist/theme/index.js.map +2 -2
- package/dist/theme/theme-init-script.js +17 -0
- package/dist/theme/theme-init-script.js.map +7 -0
- package/figma/alert.figma.tsx +35 -0
- package/figma/badge.figma.tsx +33 -0
- package/figma/button.figma.tsx +33 -0
- package/figma/checkbox.figma.tsx +28 -0
- package/figma/drawer.figma.tsx +40 -0
- package/figma/input-variants.figma.tsx +58 -0
- package/figma/radio.figma.tsx +16 -0
- package/figma/select.figma.tsx +24 -0
- package/figma/status-badge.figma.tsx +24 -0
- package/figma/switch.figma.tsx +28 -0
- package/figma/tabs.figma.tsx +22 -0
- package/figma/tag.figma.tsx +27 -0
- package/figma.config.json +6 -0
- package/jest.config.cjs +2 -1
- package/package.json +21 -18
- package/scripts/lucideRegistrySource.cjs +133 -0
- package/src/ai/AiChat.tsx +36 -7
- package/src/ai/LoopDisabledBanner.tsx +1 -1
- package/src/ai/__tests__/AiChat.test.tsx +31 -0
- package/src/ai/parts/ConfirmationCard.tsx +4 -4
- package/src/ai/parts/FieldDiffCard.tsx +2 -2
- package/src/ai/parts/MutationResultCard.tsx +3 -3
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
- package/src/backend/AppShell.tsx +55 -4
- package/src/backend/AuthSessionGuard.tsx +29 -0
- package/src/backend/BackendChromeProvider.tsx +19 -1
- package/src/backend/CrudForm.tsx +18 -4
- package/src/backend/DataTable.tsx +36 -19
- package/src/backend/JsonBuilder.tsx +3 -3
- package/src/backend/NextStepCallout.tsx +4 -4
- package/src/backend/PerspectiveSidebar.tsx +1 -1
- package/src/backend/ProfileDropdown.tsx +1 -0
- package/src/backend/RowActions.tsx +2 -2
- package/src/backend/__tests__/AppShell.test.tsx +65 -7
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
- package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
- package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
- package/src/backend/__tests__/nav-utils.test.ts +139 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
- package/src/backend/crud/useGroupOrder.ts +30 -25
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
- package/src/backend/dashboard/DashboardScreen.tsx +3 -3
- package/src/backend/detail/AddressTiles.tsx +3 -6
- package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/backend/detail/AttachmentsSection.tsx +1 -1
- package/src/backend/detail/NotesSection.tsx +1 -1
- package/src/backend/detail/TagsSection.tsx +1 -1
- package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +21 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
- package/src/backend/forms/FormHeader.tsx +1 -2
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
- package/src/backend/icons/lucideRegistry.ts +2 -2
- package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
- package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
- package/src/backend/injection/spotIds.ts +23 -18
- package/src/backend/inputs/ComboboxInput.tsx +1 -1
- package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
- package/src/backend/perspectiveState.ts +44 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
- package/src/backend/schedule/ScheduleGrid.tsx +3 -3
- package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
- package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
- package/src/backend/sidebar/customization-helpers.ts +47 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
- package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
- package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
- package/src/backend/utils/apiCall.ts +29 -1
- package/src/backend/utils/customFieldDefs.ts +1 -0
- package/src/backend/utils/customFieldForms.ts +15 -11
- package/src/backend/utils/fetchAllPages.ts +93 -0
- package/src/backend/utils/nav.ts +22 -2
- package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
- package/src/frontend/LanguageSwitcher.tsx +1 -0
- package/src/portal/PortalContext.tsx +1 -1
- package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
- package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
- package/src/portal/hooks/portalBridgeStatus.ts +29 -0
- package/src/portal/hooks/usePortalEventBridge.ts +6 -0
- package/src/portal/hooks/usePortalNotifications.ts +55 -12
- package/src/portal/utils/__tests__/nav.test.ts +2 -2
- package/src/portal/utils/nav.ts +3 -4
- package/src/primitives/ErrorNotice.tsx +1 -1
- package/src/primitives/Notice.tsx +5 -5
- package/src/primitives/__tests__/button.test.tsx +70 -0
- package/src/primitives/button.tsx +10 -4
- package/src/theme/ThemeProvider.tsx +1 -2
- package/src/theme/__tests__/theme-init-script.test.ts +70 -0
- package/src/theme/index.ts +1 -0
- 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,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
fetchCustomFieldDefinitionsPayload,
|
|
10
10
|
} from './customFieldDefs'
|
|
11
11
|
import { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'
|
|
12
|
-
import {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
}
|
package/src/backend/utils/nav.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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)
|