@open-mercato/ui 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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 +2 -4
- package/agentic/standalone-guide.md +1 -1
- package/build.mjs +54 -2
- package/dist/ai/AiChat.js +2 -21
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/backend/AppShell.js +4 -43
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +0 -14
- package/dist/backend/AuthSessionGuard.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +1 -6
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/CrudForm.js +4 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +18 -20
- 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/ProfileDropdown.js +1 -2
- 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 +1 -13
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/useGroupOrder.js +21 -14
- package/dist/backend/crud/useGroupOrder.js.map +2 -2
- package/dist/backend/detail/AddressTiles.js +5 -4
- 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/forms/FormHeader.js +2 -1
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +25 -1
- 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/injection/spotIds.js +18 -23
- package/dist/backend/injection/spotIds.js.map +2 -2
- 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 +70 -87
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/sidebar/customization-helpers.js +0 -30
- package/dist/backend/sidebar/customization-helpers.js.map +2 -2
- package/dist/backend/utils/api.js +13 -3
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/apiCall.js +1 -21
- package/dist/backend/utils/apiCall.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +9 -10
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/dist/backend/utils/nav.js +2 -5
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/frontend/LanguageSwitcher.js +1 -2
- 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/usePortalEventBridge.js +0 -6
- package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
- package/dist/portal/hooks/usePortalNotifications.js +4 -42
- package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
- package/dist/portal/utils/nav.js +3 -2
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/button.js +4 -9
- package/dist/primitives/button.js.map +2 -2
- package/jest.config.cjs +1 -2
- package/package.json +18 -21
- package/src/ai/AiChat.tsx +1 -30
- package/src/ai/__tests__/AiChat.test.tsx +0 -31
- package/src/backend/AppShell.tsx +4 -55
- package/src/backend/AuthSessionGuard.tsx +0 -29
- package/src/backend/BackendChromeProvider.tsx +1 -19
- package/src/backend/CrudForm.tsx +4 -18
- package/src/backend/DataTable.tsx +19 -36
- package/src/backend/JsonBuilder.tsx +3 -3
- package/src/backend/NextStepCallout.tsx +4 -4
- package/src/backend/ProfileDropdown.tsx +0 -1
- package/src/backend/RowActions.tsx +2 -2
- package/src/backend/__tests__/AppShell.test.tsx +7 -65
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
- package/src/backend/__tests__/nav-utils.test.ts +0 -139
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
- package/src/backend/crud/useGroupOrder.ts +25 -30
- package/src/backend/detail/AddressTiles.tsx +6 -3
- 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 +0 -21
- package/src/backend/forms/FormHeader.tsx +2 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
- package/src/backend/icons/lucideRegistry.ts +2 -2
- package/src/backend/injection/spotIds.ts +18 -23
- package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
- package/src/backend/schedule/ScheduleGrid.tsx +3 -3
- package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
- package/src/backend/sidebar/customization-helpers.ts +0 -47
- package/src/backend/utils/__tests__/api.test.ts +27 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
- package/src/backend/utils/api.ts +20 -3
- package/src/backend/utils/apiCall.ts +1 -29
- package/src/backend/utils/customFieldDefs.ts +0 -1
- package/src/backend/utils/customFieldForms.ts +11 -15
- package/src/backend/utils/nav.ts +2 -22
- package/src/frontend/LanguageSwitcher.tsx +0 -1
- package/src/portal/PortalContext.tsx +1 -1
- package/src/portal/hooks/usePortalEventBridge.ts +0 -6
- package/src/portal/hooks/usePortalNotifications.ts +12 -55
- package/src/portal/utils/__tests__/nav.test.ts +2 -2
- package/src/portal/utils/nav.ts +4 -3
- package/src/primitives/button.tsx +4 -10
- package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
- package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
- package/dist/backend/perspectiveState.js +0 -34
- package/dist/backend/perspectiveState.js.map +0 -7
- package/dist/backend/utils/fetchAllPages.js +0 -57
- package/dist/backend/utils/fetchAllPages.js.map +0 -7
- package/dist/portal/hooks/portalBridgeStatus.js +0 -25
- package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
- package/figma/alert.figma.tsx +0 -35
- package/figma/badge.figma.tsx +0 -33
- package/figma/button.figma.tsx +0 -33
- package/figma/checkbox.figma.tsx +0 -28
- package/figma/drawer.figma.tsx +0 -40
- package/figma/input-variants.figma.tsx +0 -58
- package/figma/radio.figma.tsx +0 -16
- package/figma/select.figma.tsx +0 -24
- package/figma/status-badge.figma.tsx +0 -24
- package/figma/switch.figma.tsx +0 -28
- package/figma/tabs.figma.tsx +0 -22
- package/figma/tag.figma.tsx +0 -27
- package/figma.config.json +0 -6
- package/scripts/lucideRegistrySource.cjs +0 -133
- package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
- package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
- package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
- package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
- package/src/backend/perspectiveState.ts +0 -44
- package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
- package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
- package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
- package/src/backend/utils/fetchAllPages.ts +0 -93
- package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
- package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
- package/src/portal/hooks/portalBridgeStatus.ts +0 -29
- package/src/primitives/__tests__/button.test.tsx +0 -70
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/** @jest-environment node */
|
|
2
|
-
|
|
3
|
-
const apiCall = jest.fn()
|
|
4
|
-
|
|
5
|
-
jest.mock('../apiCall', () => ({
|
|
6
|
-
apiCall: (...args: unknown[]) => apiCall(...args),
|
|
7
|
-
}))
|
|
8
|
-
|
|
9
|
-
jest.mock('../../fields/registry', () => ({
|
|
10
|
-
FieldRegistry: { getInput: () => null },
|
|
11
|
-
loadGeneratedFieldRegistrations: async () => {},
|
|
12
|
-
}))
|
|
13
|
-
|
|
14
|
-
import { buildFormFieldFromCustomFieldDef } from '../customFieldForms'
|
|
15
|
-
|
|
16
|
-
const okPage = (payload: Record<string, unknown>) => ({ ok: true, result: payload })
|
|
17
|
-
|
|
18
|
-
const requestedUrls = () => apiCall.mock.calls.map(([url]) => url as string)
|
|
19
|
-
|
|
20
|
-
const loadOptionsFor = (def: Parameters<typeof buildFormFieldFromCustomFieldDef>[0]) => {
|
|
21
|
-
const field = buildFormFieldFromCustomFieldDef(def)
|
|
22
|
-
const loadOptions = (field as { loadOptions?: (query?: string) => Promise<Array<{ value: string; label: string }>> })
|
|
23
|
-
.loadOptions
|
|
24
|
-
if (!loadOptions) throw new Error('[internal] expected the field to expose loadOptions')
|
|
25
|
-
return loadOptions
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Guards the review finding on #4175: the entries route caps rows per response,
|
|
30
|
-
* so the generic `optionsUrl` loader behind every select/currency/relation and
|
|
31
|
-
* multi-dictionary custom field must page instead of dropping everything past
|
|
32
|
-
* the first page.
|
|
33
|
-
*/
|
|
34
|
-
describe('custom field remote options paging', () => {
|
|
35
|
-
beforeEach(() => {
|
|
36
|
-
apiCall.mockReset()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('walks every page of a dictionary-backed multi-select', async () => {
|
|
40
|
-
apiCall
|
|
41
|
-
.mockResolvedValueOnce(
|
|
42
|
-
okPage({ items: [{ value: 'a', label: 'Alpha' }], total: 2, hasMore: true }),
|
|
43
|
-
)
|
|
44
|
-
.mockResolvedValueOnce(
|
|
45
|
-
okPage({ items: [{ value: 'b', label: 'Beta' }], total: 2, hasMore: false }),
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
const options = await loadOptionsFor({
|
|
49
|
-
key: 'tags',
|
|
50
|
-
kind: 'dictionary',
|
|
51
|
-
multi: true,
|
|
52
|
-
dictionaryId: 'dict-1',
|
|
53
|
-
})()
|
|
54
|
-
|
|
55
|
-
expect(requestedUrls()).toEqual([
|
|
56
|
-
'/api/dictionaries/dict-1/entries',
|
|
57
|
-
'/api/dictionaries/dict-1/entries?offset=1',
|
|
58
|
-
])
|
|
59
|
-
expect(options).toEqual([
|
|
60
|
-
{ value: 'a', label: 'Alpha' },
|
|
61
|
-
{ value: 'b', label: 'Beta' },
|
|
62
|
-
])
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('keeps the typeahead query on every page it requests', async () => {
|
|
66
|
-
apiCall
|
|
67
|
-
.mockResolvedValueOnce(okPage({ items: [{ value: 'a', label: 'Alpha' }], hasMore: true }))
|
|
68
|
-
.mockResolvedValueOnce(okPage({ items: [{ value: 'ab', label: 'Able' }], hasMore: false }))
|
|
69
|
-
|
|
70
|
-
await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })('al')
|
|
71
|
-
|
|
72
|
-
expect(requestedUrls()[0]).toContain('query=al')
|
|
73
|
-
expect(requestedUrls()[1]).toContain('query=al')
|
|
74
|
-
expect(requestedUrls()[1]).toContain('offset=1')
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('issues one request for a route that does not report hasMore', async () => {
|
|
78
|
-
apiCall.mockResolvedValueOnce(okPage({ items: [{ value: 'usd', label: 'USD' }] }))
|
|
79
|
-
|
|
80
|
-
const options = await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })()
|
|
81
|
-
|
|
82
|
-
expect(apiCall).toHaveBeenCalledTimes(1)
|
|
83
|
-
expect(options).toEqual([{ value: 'usd', label: 'USD' }])
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('returns no options when a page fails rather than a partial list', async () => {
|
|
87
|
-
apiCall
|
|
88
|
-
.mockResolvedValueOnce(okPage({ items: [{ value: 'a', label: 'Alpha' }], hasMore: true }))
|
|
89
|
-
.mockResolvedValueOnce({ ok: false, result: { error: 'Forbidden' } })
|
|
90
|
-
|
|
91
|
-
const options = await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })()
|
|
92
|
-
|
|
93
|
-
expect(options).toEqual([])
|
|
94
|
-
})
|
|
95
|
-
})
|
|
@@ -1,103 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,155 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/** @jest-environment jsdom */
|
|
2
|
-
|
|
3
|
-
import * as React from 'react'
|
|
4
|
-
import { render } from '@testing-library/react'
|
|
5
|
-
import { Button, buttonVariants } from '../button'
|
|
6
|
-
|
|
7
|
-
type ButtonVariant = NonNullable<Parameters<typeof buttonVariants>[0]>['variant']
|
|
8
|
-
|
|
9
|
-
function classesOf(variant: ButtonVariant) {
|
|
10
|
-
return buttonVariants({ variant }).split(/\s+/).filter(Boolean)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe('Button destructive variants', () => {
|
|
14
|
-
// The DS policy: `destructive` is the quiet trigger (red text on a calm
|
|
15
|
-
// surface) so a page full of delete buttons does not train users to ignore
|
|
16
|
-
// red; `destructive-solid` is the filled form, reserved for the single
|
|
17
|
-
// point-of-no-return confirmation inside a dialog. These tests pin both
|
|
18
|
-
// halves of that contract — swapping them back is a visual regression that
|
|
19
|
-
// no other test would catch.
|
|
20
|
-
|
|
21
|
-
it('destructive is quiet: destructive border and text on the page surface', () => {
|
|
22
|
-
const classes = classesOf('destructive')
|
|
23
|
-
expect(classes).toContain('border')
|
|
24
|
-
expect(classes).toContain('border-destructive')
|
|
25
|
-
expect(classes).toContain('bg-background')
|
|
26
|
-
expect(classes).toContain('text-destructive')
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('destructive does not fill with the destructive surface', () => {
|
|
30
|
-
const classes = classesOf('destructive')
|
|
31
|
-
expect(classes).not.toContain('bg-destructive')
|
|
32
|
-
expect(classes).not.toContain('text-white')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('destructive-solid fills with the destructive surface and white glyph', () => {
|
|
36
|
-
const classes = classesOf('destructive-solid')
|
|
37
|
-
expect(classes).toContain('bg-destructive')
|
|
38
|
-
expect(classes).toContain('text-white')
|
|
39
|
-
expect(classes).not.toContain('border-destructive')
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('destructive-solid ships no dark background override', () => {
|
|
43
|
-
// `--destructive` is a semantic token that already resolves per theme; a
|
|
44
|
-
// `dark:bg-*` override on top of it is what broke IconButton in #3507.
|
|
45
|
-
const classes = classesOf('destructive-solid')
|
|
46
|
-
expect(classes.some((cls) => cls.startsWith('dark:bg-'))).toBe(false)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('renders the quiet treatment on the element for variant="destructive"', () => {
|
|
50
|
-
const { getByRole } = render(<Button variant="destructive">Delete</Button>)
|
|
51
|
-
const className = getByRole('button').className
|
|
52
|
-
expect(className).toContain('text-destructive')
|
|
53
|
-
expect(className).toContain('border-destructive')
|
|
54
|
-
expect(className.split(/\s+/)).not.toContain('bg-destructive')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('renders the filled treatment on the element for variant="destructive-solid"', () => {
|
|
58
|
-
const { getByRole } = render(<Button variant="destructive-solid">Delete permanently</Button>)
|
|
59
|
-
const classNames = getByRole('button').className.split(/\s+/)
|
|
60
|
-
expect(classNames).toContain('bg-destructive')
|
|
61
|
-
expect(classNames).toContain('text-white')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('defaults to the primary variant when none is given', () => {
|
|
65
|
-
const { getByRole } = render(<Button>Save</Button>)
|
|
66
|
-
const classNames = getByRole('button').className.split(/\s+/)
|
|
67
|
-
expect(classNames).toContain('bg-primary')
|
|
68
|
-
expect(classNames).not.toContain('text-destructive')
|
|
69
|
-
})
|
|
70
|
-
})
|