@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.
Files changed (167) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -4
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +54 -2
  5. package/dist/ai/AiChat.js +2 -21
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/backend/AppShell.js +4 -43
  8. package/dist/backend/AppShell.js.map +2 -2
  9. package/dist/backend/AuthSessionGuard.js +0 -14
  10. package/dist/backend/AuthSessionGuard.js.map +2 -2
  11. package/dist/backend/BackendChromeProvider.js +1 -6
  12. package/dist/backend/BackendChromeProvider.js.map +2 -2
  13. package/dist/backend/CrudForm.js +4 -6
  14. package/dist/backend/CrudForm.js.map +2 -2
  15. package/dist/backend/DataTable.js +18 -20
  16. package/dist/backend/DataTable.js.map +2 -2
  17. package/dist/backend/JsonBuilder.js +3 -3
  18. package/dist/backend/JsonBuilder.js.map +2 -2
  19. package/dist/backend/NextStepCallout.js +4 -4
  20. package/dist/backend/NextStepCallout.js.map +1 -1
  21. package/dist/backend/ProfileDropdown.js +1 -2
  22. package/dist/backend/ProfileDropdown.js.map +2 -2
  23. package/dist/backend/RowActions.js +2 -2
  24. package/dist/backend/RowActions.js.map +2 -2
  25. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -13
  26. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  27. package/dist/backend/crud/useGroupOrder.js +21 -14
  28. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  29. package/dist/backend/detail/AddressTiles.js +5 -4
  30. package/dist/backend/detail/AddressTiles.js.map +2 -2
  31. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  32. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  33. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  34. package/dist/backend/detail/AttachmentsSection.js +1 -1
  35. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  36. package/dist/backend/detail/NotesSection.js +1 -1
  37. package/dist/backend/detail/NotesSection.js.map +2 -2
  38. package/dist/backend/detail/TagsSection.js +1 -1
  39. package/dist/backend/detail/TagsSection.js.map +2 -2
  40. package/dist/backend/forms/FormHeader.js +2 -1
  41. package/dist/backend/forms/FormHeader.js.map +2 -2
  42. package/dist/backend/icons/lucideRegistry.generated.js +25 -1
  43. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  44. package/dist/backend/icons/lucideRegistry.js +3 -3
  45. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  46. package/dist/backend/injection/spotIds.js +18 -23
  47. package/dist/backend/injection/spotIds.js.map +2 -2
  48. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  49. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  50. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  51. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  52. package/dist/backend/schedule/ScheduleToolbar.js +70 -87
  53. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  54. package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
  55. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  56. package/dist/backend/sidebar/customization-helpers.js +0 -30
  57. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  58. package/dist/backend/utils/api.js +13 -3
  59. package/dist/backend/utils/api.js.map +2 -2
  60. package/dist/backend/utils/apiCall.js +1 -21
  61. package/dist/backend/utils/apiCall.js.map +2 -2
  62. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  63. package/dist/backend/utils/customFieldForms.js +9 -10
  64. package/dist/backend/utils/customFieldForms.js.map +2 -2
  65. package/dist/backend/utils/nav.js +2 -5
  66. package/dist/backend/utils/nav.js.map +2 -2
  67. package/dist/frontend/LanguageSwitcher.js +1 -2
  68. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  69. package/dist/portal/PortalContext.js +1 -1
  70. package/dist/portal/PortalContext.js.map +2 -2
  71. package/dist/portal/hooks/usePortalEventBridge.js +0 -6
  72. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  73. package/dist/portal/hooks/usePortalNotifications.js +4 -42
  74. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  75. package/dist/portal/utils/nav.js +3 -2
  76. package/dist/portal/utils/nav.js.map +2 -2
  77. package/dist/primitives/button.js +4 -9
  78. package/dist/primitives/button.js.map +2 -2
  79. package/jest.config.cjs +1 -2
  80. package/package.json +18 -21
  81. package/src/ai/AiChat.tsx +1 -30
  82. package/src/ai/__tests__/AiChat.test.tsx +0 -31
  83. package/src/backend/AppShell.tsx +4 -55
  84. package/src/backend/AuthSessionGuard.tsx +0 -29
  85. package/src/backend/BackendChromeProvider.tsx +1 -19
  86. package/src/backend/CrudForm.tsx +4 -18
  87. package/src/backend/DataTable.tsx +19 -36
  88. package/src/backend/JsonBuilder.tsx +3 -3
  89. package/src/backend/NextStepCallout.tsx +4 -4
  90. package/src/backend/ProfileDropdown.tsx +0 -1
  91. package/src/backend/RowActions.tsx +2 -2
  92. package/src/backend/__tests__/AppShell.test.tsx +7 -65
  93. package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
  94. package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
  95. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
  96. package/src/backend/__tests__/nav-utils.test.ts +0 -139
  97. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
  98. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
  99. package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
  100. package/src/backend/crud/useGroupOrder.ts +25 -30
  101. package/src/backend/detail/AddressTiles.tsx +6 -3
  102. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  103. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  104. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  105. package/src/backend/detail/NotesSection.tsx +1 -1
  106. package/src/backend/detail/TagsSection.tsx +1 -1
  107. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +0 -21
  108. package/src/backend/forms/FormHeader.tsx +2 -1
  109. package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
  110. package/src/backend/icons/lucideRegistry.ts +2 -2
  111. package/src/backend/injection/spotIds.ts +18 -23
  112. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  113. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  114. package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
  115. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
  116. package/src/backend/sidebar/customization-helpers.ts +0 -47
  117. package/src/backend/utils/__tests__/api.test.ts +27 -0
  118. package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
  119. package/src/backend/utils/api.ts +20 -3
  120. package/src/backend/utils/apiCall.ts +1 -29
  121. package/src/backend/utils/customFieldDefs.ts +0 -1
  122. package/src/backend/utils/customFieldForms.ts +11 -15
  123. package/src/backend/utils/nav.ts +2 -22
  124. package/src/frontend/LanguageSwitcher.tsx +0 -1
  125. package/src/portal/PortalContext.tsx +1 -1
  126. package/src/portal/hooks/usePortalEventBridge.ts +0 -6
  127. package/src/portal/hooks/usePortalNotifications.ts +12 -55
  128. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  129. package/src/portal/utils/nav.ts +4 -3
  130. package/src/primitives/button.tsx +4 -10
  131. package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
  132. package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
  133. package/dist/backend/perspectiveState.js +0 -34
  134. package/dist/backend/perspectiveState.js.map +0 -7
  135. package/dist/backend/utils/fetchAllPages.js +0 -57
  136. package/dist/backend/utils/fetchAllPages.js.map +0 -7
  137. package/dist/portal/hooks/portalBridgeStatus.js +0 -25
  138. package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
  139. package/figma/alert.figma.tsx +0 -35
  140. package/figma/badge.figma.tsx +0 -33
  141. package/figma/button.figma.tsx +0 -33
  142. package/figma/checkbox.figma.tsx +0 -28
  143. package/figma/drawer.figma.tsx +0 -40
  144. package/figma/input-variants.figma.tsx +0 -58
  145. package/figma/radio.figma.tsx +0 -16
  146. package/figma/select.figma.tsx +0 -24
  147. package/figma/status-badge.figma.tsx +0 -24
  148. package/figma/switch.figma.tsx +0 -28
  149. package/figma/tabs.figma.tsx +0 -22
  150. package/figma/tag.figma.tsx +0 -27
  151. package/figma.config.json +0 -6
  152. package/scripts/lucideRegistrySource.cjs +0 -133
  153. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
  154. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
  155. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
  156. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
  157. package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
  158. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
  159. package/src/backend/perspectiveState.ts +0 -44
  160. package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
  161. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
  162. package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
  163. package/src/backend/utils/fetchAllPages.ts +0 -93
  164. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
  165. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
  166. package/src/portal/hooks/portalBridgeStatus.ts +0 -29
  167. 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
- })