@igstack/app-catalog-frontend-core 6.6.0 → 6.7.0

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 (139) hide show
  1. package/dist/esm/App.d.ts +29 -3
  2. package/dist/esm/App.js +4 -2
  3. package/dist/esm/App.js.map +1 -1
  4. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
  5. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
  6. package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
  7. package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
  8. package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
  9. package/dist/esm/appPropsFactory.d.ts +19 -1
  10. package/dist/esm/appPropsFactory.js +4 -3
  11. package/dist/esm/appPropsFactory.js.map +1 -1
  12. package/dist/esm/index.d.ts +15 -2
  13. package/dist/esm/index.js +5 -0
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/internal.d.ts +7 -1
  16. package/dist/esm/internal.js +0 -2
  17. package/dist/esm/internal.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
  19. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
  20. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
  22. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  23. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +39 -17
  24. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
  26. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
  28. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  29. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
  30. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
  31. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  32. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
  33. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
  34. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
  36. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  37. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
  38. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
  39. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  40. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
  41. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  42. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  44. package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
  45. package/dist/esm/modules/auth/AuthContext.js +5 -0
  46. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  47. package/dist/esm/modules/auth/index.d.ts +1 -1
  48. package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
  49. package/dist/esm/modules/dismiss/DismissStack.js +82 -0
  50. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
  51. package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
  52. package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
  53. package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
  54. package/dist/esm/modules/extensions/factories.d.ts +18 -0
  55. package/dist/esm/modules/extensions/factories.js +12 -0
  56. package/dist/esm/modules/extensions/factories.js.map +1 -0
  57. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
  58. package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
  59. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
  60. package/dist/esm/modules/extensions/index.d.ts +79 -0
  61. package/dist/esm/modules/extensions/index.js +26 -0
  62. package/dist/esm/modules/extensions/index.js.map +1 -0
  63. package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
  64. package/dist/esm/modules/extensions/makeSlot.js +62 -0
  65. package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
  66. package/dist/esm/modules/extensions/types.d.ts +46 -0
  67. package/dist/esm/modules/gallery/Gallery.js +6 -14
  68. package/dist/esm/modules/gallery/Gallery.js.map +1 -1
  69. package/dist/esm/routeTree.gen.d.ts +34 -34
  70. package/dist/esm/routeTree.gen.js +14 -14
  71. package/dist/esm/routeTree.gen.js.map +1 -1
  72. package/dist/esm/router.d.ts +23 -0
  73. package/dist/esm/router.js +5 -0
  74. package/dist/esm/router.js.map +1 -0
  75. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  76. package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
  77. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
  78. package/dist/esm/routes/_layout/index.d.ts +1 -1
  79. package/dist/esm/routes/_layout/login.d.ts +1 -1
  80. package/dist/esm/routes/_layout/mcp.d.ts +1 -1
  81. package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
  82. package/dist/esm/routes/_layout.d.ts +1 -1
  83. package/dist/esm/routes/_layout.js +13 -2
  84. package/dist/esm/routes/_layout.js.map +1 -1
  85. package/dist/esm/routes/auth.callback.d.ts +1 -1
  86. package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
  87. package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
  88. package/dist/esm/ui/components/header/Header.js +0 -1
  89. package/dist/esm/ui/components/header/Header.js.map +1 -1
  90. package/dist/esm/ui/dialog.d.ts +1 -1
  91. package/dist/esm/ui/dialog.js +9 -0
  92. package/dist/esm/ui/dialog.js.map +1 -1
  93. package/dist/esm/ui/table.d.ts +25 -1
  94. package/dist/esm/ui/table.js +20 -3
  95. package/dist/esm/ui/table.js.map +1 -1
  96. package/dist/esm/util/createAcRouter.d.ts +1 -1
  97. package/package.json +19 -11
  98. package/src/App.tsx +34 -3
  99. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
  100. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
  101. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
  102. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
  103. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
  104. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
  105. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
  106. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
  107. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
  108. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
  109. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
  110. package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
  111. package/src/__tests__/publicEntryResolves.test.ts +63 -0
  112. package/src/appPropsFactory.ts +23 -3
  113. package/src/index.tsx +32 -2
  114. package/src/internal.ts +7 -1
  115. package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
  116. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
  117. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +59 -18
  118. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
  119. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
  120. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
  121. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
  122. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
  123. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
  124. package/src/modules/auth/AuthContext.tsx +15 -0
  125. package/src/modules/auth/index.ts +1 -0
  126. package/src/modules/dismiss/DismissStack.tsx +224 -0
  127. package/src/modules/extensions/ExtensionsContext.tsx +36 -0
  128. package/src/modules/extensions/factories.ts +19 -0
  129. package/src/modules/extensions/features/resourceDetail.ts +74 -0
  130. package/src/modules/extensions/index.ts +121 -0
  131. package/src/modules/extensions/makeSlot.tsx +123 -0
  132. package/src/modules/extensions/types.ts +51 -0
  133. package/src/modules/gallery/Gallery.tsx +13 -17
  134. package/src/routeTree.gen.ts +31 -31
  135. package/src/router.ts +23 -0
  136. package/src/routes/_layout.tsx +51 -1
  137. package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
  138. package/src/ui/dialog.tsx +26 -0
  139. package/src/ui/table.tsx +46 -3
@@ -0,0 +1,330 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { useRef, useState } from 'react'
6
+ import {
7
+ DismissStackProvider,
8
+ useDismissable,
9
+ } from '~/modules/dismiss/DismissStack'
10
+
11
+ /**
12
+ * The contract every overlay in the app now depends on, so it is worth pinning
13
+ * precisely. Each case here corresponds to a way the old hand-rolled Escape
14
+ * handling got it wrong.
15
+ */
16
+
17
+ function Layer({
18
+ name,
19
+ onDismiss,
20
+ enabled,
21
+ children,
22
+ }: {
23
+ name: string
24
+ onDismiss: () => void
25
+ enabled?: boolean
26
+ children?: React.ReactNode
27
+ }) {
28
+ const ref = useRef<HTMLDivElement>(null)
29
+ useDismissable(
30
+ ref,
31
+ onDismiss,
32
+ enabled === undefined ? undefined : { enabled },
33
+ )
34
+ return (
35
+ <div ref={ref} data-testid={`layer-${name}`}>
36
+ {name}
37
+ {children}
38
+ </div>
39
+ )
40
+ }
41
+
42
+ const esc = () => userEvent.keyboard('{Escape}')
43
+
44
+ describe('the dismiss stack', () => {
45
+ it('gives Escape to the layer registered last', async () => {
46
+ const outer = vi.fn()
47
+ const inner = vi.fn()
48
+ render(
49
+ <>
50
+ <Layer name="outer" onDismiss={outer} />
51
+ <Layer name="inner" onDismiss={inner} />
52
+ </>,
53
+ )
54
+
55
+ await esc()
56
+
57
+ // The whole point: the thing opened last is the thing Escape takes away.
58
+ expect(inner).toHaveBeenCalledTimes(1)
59
+ expect(outer).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('dismisses one layer per keypress, never two at once', async () => {
63
+ // The reported bug: one Escape closed the dialog AND the card under it,
64
+ // landing the user on the home page instead of back on the table.
65
+ const outer = vi.fn()
66
+ const inner = vi.fn()
67
+ render(
68
+ <>
69
+ <Layer name="outer" onDismiss={outer} />
70
+ <Layer name="inner" onDismiss={inner} />
71
+ </>,
72
+ )
73
+
74
+ await esc()
75
+ expect(inner).toHaveBeenCalledTimes(1)
76
+ expect(outer).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('falls back to the layer below once the top one unmounts', async () => {
80
+ function Host() {
81
+ const [innerOpen, setInnerOpen] = useState(true)
82
+ return (
83
+ <>
84
+ <Layer name="outer" onDismiss={outer} />
85
+ {innerOpen && (
86
+ <Layer name="inner" onDismiss={() => setInnerOpen(false)} />
87
+ )}
88
+ </>
89
+ )
90
+ }
91
+ const outer = vi.fn()
92
+ render(<Host />)
93
+
94
+ await esc()
95
+ expect(screen.queryByTestId('layer-inner')).not.toBeInTheDocument()
96
+ expect(outer).not.toHaveBeenCalled()
97
+
98
+ // Escape again, and the layer underneath gets it — "previous", one step at
99
+ // a time, which is what makes nesting work without anyone coordinating.
100
+ await esc()
101
+ expect(outer).toHaveBeenCalledTimes(1)
102
+ })
103
+
104
+ it('skips a disabled layer and asks the one beneath it', async () => {
105
+ const outer = vi.fn()
106
+ const closed = vi.fn()
107
+ render(
108
+ <>
109
+ <Layer name="outer" onDismiss={outer} />
110
+ <Layer name="closed" onDismiss={closed} enabled={false} />
111
+ </>,
112
+ )
113
+
114
+ await esc()
115
+
116
+ // A registered-but-closed popover must not swallow Escape. Staying
117
+ // registered while disabled is what keeps stack order stable across a
118
+ // toggle, so this case has to work.
119
+ expect(closed).not.toHaveBeenCalled()
120
+ expect(outer).toHaveBeenCalledTimes(1)
121
+ })
122
+
123
+ it('does not move a layer to the top just because it re-rendered', async () => {
124
+ // The subtle one. If the hook re-registered whenever the callback identity
125
+ // changed, the last component to RE-RENDER would win Escape instead of the
126
+ // last one to OPEN — so an unrelated state change elsewhere would silently
127
+ // redirect Escape.
128
+ const outer = vi.fn()
129
+ const inner = vi.fn()
130
+ function Host() {
131
+ const [n, setN] = useState(0)
132
+ return (
133
+ <>
134
+ {/* A fresh closure every render, on the BOTTOM layer. */}
135
+ <Layer name="outer" onDismiss={() => outer(n)} />
136
+ <Layer name="inner" onDismiss={() => inner(n)} />
137
+ <button type="button" onClick={() => setN((v) => v + 1)}>
138
+ bump
139
+ </button>
140
+ </>
141
+ )
142
+ }
143
+ render(<Host />)
144
+
145
+ await userEvent.click(screen.getByRole('button', { name: 'bump' }))
146
+ await esc()
147
+
148
+ expect(inner).toHaveBeenCalledTimes(1)
149
+ expect(outer).not.toHaveBeenCalled()
150
+ // And the refreshed closure is the one that ran, not the stale one from
151
+ // the first render.
152
+ expect(inner).toHaveBeenCalledWith(1)
153
+ })
154
+
155
+ it('leaves Escape alone when a closer handler already claimed it', async () => {
156
+ // An input that uses Escape for itself — cancelling an edit — is more
157
+ // specific than any layer containing it, so the stack honours
158
+ // preventDefault instead of overriding it.
159
+ const layer = vi.fn()
160
+ render(
161
+ <Layer name="outer" onDismiss={layer}>
162
+ <input
163
+ aria-label="field"
164
+ onKeyDown={(e) => {
165
+ if (e.key === 'Escape') e.preventDefault()
166
+ }}
167
+ />
168
+ </Layer>,
169
+ )
170
+
171
+ await userEvent.click(screen.getByLabelText('field'))
172
+ await esc()
173
+
174
+ expect(layer).not.toHaveBeenCalled()
175
+ })
176
+
177
+ it('does nothing when no layer is registered', async () => {
178
+ // Escape has to stay available to whatever else wants it — the search
179
+ // results list clears the query on Escape when no overlay is open.
180
+ const onKeyDown = vi.fn()
181
+ render(<div onKeyDown={onKeyDown}>no layers</div>)
182
+
183
+ await esc()
184
+
185
+ expect(onKeyDown).not.toHaveBeenCalled() // nothing focused inside
186
+ })
187
+
188
+ it('works without a provider, and scopes to one when given', async () => {
189
+ // The hook deliberately works unwrapped: a hook that silently did nothing
190
+ // outside a provider is the same class of bug this module deletes.
191
+ const bare = vi.fn()
192
+ const { unmount } = render(<Layer name="bare" onDismiss={bare} />)
193
+ await esc()
194
+ expect(bare).toHaveBeenCalledTimes(1)
195
+ unmount()
196
+
197
+ const scoped = vi.fn()
198
+ render(
199
+ <DismissStackProvider>
200
+ <Layer name="scoped" onDismiss={scoped} />
201
+ </DismissStackProvider>,
202
+ )
203
+ await esc()
204
+ expect(scoped).toHaveBeenCalledTimes(1)
205
+ })
206
+
207
+ it('removes its key listener once the last layer unmounts', async () => {
208
+ const add = vi.spyOn(document, 'addEventListener')
209
+ const remove = vi.spyOn(document, 'removeEventListener')
210
+ const onDismiss = vi.fn()
211
+
212
+ const { unmount } = render(<Layer name="only" onDismiss={onDismiss} />)
213
+ // Bubble phase, deliberately: a capture listener runs before every
214
+ // component handler, which would stop a field from keeping Escape for
215
+ // itself.
216
+ expect(add).toHaveBeenCalledWith('keydown', expect.any(Function))
217
+
218
+ unmount()
219
+ expect(remove).toHaveBeenCalledWith('keydown', expect.any(Function))
220
+
221
+ // And really gone: an app with nothing open has no global key handler.
222
+ await esc()
223
+ expect(onDismiss).not.toHaveBeenCalled()
224
+
225
+ add.mockRestore()
226
+ remove.mockRestore()
227
+ })
228
+
229
+ it('gives Escape to a nested layer that opens later, not its container', async () => {
230
+ // The regression this ordering exists for. React runs a CHILD's effects
231
+ // before its parent's, so an overlay nested inside another registers
232
+ // FIRST — and with registration-order LIFO the container looked like the
233
+ // newer layer. Pressing Escape in the screenshot viewer therefore closed
234
+ // the card underneath it and left the catalog index.
235
+ const closeCard = vi.fn()
236
+ function Host() {
237
+ const [viewerOpen, setViewerOpen] = useState(false)
238
+ return (
239
+ <Layer name="card" onDismiss={closeCard}>
240
+ {/* Mounted from the start and merely disabled, exactly as the
241
+ screenshot dialog is, so it registers before its container. */}
242
+ <Layer
243
+ name="viewer"
244
+ onDismiss={() => setViewerOpen(false)}
245
+ enabled={viewerOpen}
246
+ />
247
+ <button type="button" onClick={() => setViewerOpen(true)}>
248
+ open viewer
249
+ </button>
250
+ </Layer>
251
+ )
252
+ }
253
+ render(<Host />)
254
+
255
+ await userEvent.click(screen.getByRole('button', { name: 'open viewer' }))
256
+ await esc()
257
+
258
+ // The viewer closed; the card is still there.
259
+ expect(closeCard).not.toHaveBeenCalled()
260
+
261
+ // And now the card gets it.
262
+ await esc()
263
+ expect(closeCard).toHaveBeenCalledTimes(1)
264
+ })
265
+
266
+ it('ranks by position on screen, not by when a layer mounted', async () => {
267
+ // The cold-deep-link shape. The dialog's route resolves first; the card
268
+ // it sits over waits for the catalog and mounts LATER, while still being
269
+ // rendered EARLIER in the document. Ranking by mount or activation time
270
+ // made the card "newer", so Escape closed it first.
271
+ const closeCard = vi.fn()
272
+ const closeDialog = vi.fn()
273
+ function Host() {
274
+ const [catalogLoaded, setCatalogLoaded] = useState(false)
275
+ return (
276
+ <>
277
+ {catalogLoaded && <Layer name="card" onDismiss={closeCard} />}
278
+ <Layer name="dialog" onDismiss={closeDialog} />
279
+ <button type="button" onClick={() => setCatalogLoaded(true)}>
280
+ catalog arrives
281
+ </button>
282
+ </>
283
+ )
284
+ }
285
+ render(<Host />)
286
+ await userEvent.click(
287
+ screen.getByRole('button', { name: 'catalog arrives' }),
288
+ )
289
+
290
+ await esc()
291
+
292
+ expect(closeDialog).toHaveBeenCalledTimes(1)
293
+ expect(closeCard).not.toHaveBeenCalled()
294
+ })
295
+
296
+ it('hands focus to the layer beneath when the top one closes', async () => {
297
+ // Otherwise the caret falls to <body> and the next Tab walks whatever is
298
+ // behind every overlay.
299
+ function FocusableLayer({
300
+ name,
301
+ onDismiss,
302
+ }: {
303
+ name: string
304
+ onDismiss: () => void
305
+ }) {
306
+ const ref = useRef<HTMLDivElement>(null)
307
+ useDismissable(ref, onDismiss)
308
+ return <div ref={ref} tabIndex={-1} data-testid={`layer-${name}`} />
309
+ }
310
+ function Host() {
311
+ const [open, setOpen] = useState(true)
312
+ return (
313
+ <>
314
+ <FocusableLayer name="card" onDismiss={() => {}} />
315
+ {open && (
316
+ <FocusableLayer name="dialog" onDismiss={() => setOpen(false)} />
317
+ )}
318
+ </>
319
+ )
320
+ }
321
+ render(<Host />)
322
+ screen.getByTestId('layer-dialog').focus()
323
+
324
+ await esc()
325
+ // The handback is queued after the unmount, so let it run.
326
+ await new Promise((r) => setTimeout(r, 0))
327
+
328
+ expect(document.activeElement).toBe(screen.getByTestId('layer-card'))
329
+ })
330
+ })
@@ -0,0 +1,252 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import type { ReactNode } from 'react'
5
+ import type { Resource } from '@igstack/app-catalog-backend-core'
6
+ import {
7
+ ExtensionsContext,
8
+ ResourceDetailAccessActions,
9
+ ResourceDetailProvider,
10
+ } from '~/modules/extensions'
11
+ import type { AcPlugin } from '~/modules/extensions'
12
+
13
+ const resource = { slug: 'tracker', displayName: 'Tracker' } as Resource
14
+ const ctx = { resource, subResources: [], user: null }
15
+
16
+ function mount(plugins: AcPlugin[] | undefined, children: ReactNode) {
17
+ return render(
18
+ <ExtensionsContext value={plugins}>{children}</ExtensionsContext>,
19
+ )
20
+ }
21
+
22
+ describe('leaf slot', () => {
23
+ it('renders nothing when no plugin is registered', () => {
24
+ // The open-source default. Slots are additive: with none registered the
25
+ // output must be indistinguishable from having no slot at all.
26
+ const { container } = mount(
27
+ undefined,
28
+ <ResourceDetailAccessActions {...ctx} />,
29
+ )
30
+ expect(container).toBeEmptyDOMElement()
31
+ })
32
+
33
+ it('renders every contributor, in array order', () => {
34
+ const plugins: AcPlugin[] = [
35
+ {
36
+ name: 'first',
37
+ slots: { resourceDetailAccessActions: () => <b>one</b> },
38
+ },
39
+ {
40
+ name: 'second',
41
+ slots: { resourceDetailAccessActions: () => <b>two</b> },
42
+ },
43
+ ]
44
+ mount(plugins, <ResourceDetailAccessActions {...ctx} />)
45
+ // Both render — the case a keyed map could not express, since the second
46
+ // registration would have overwritten the first.
47
+ expect(screen.getAllByText(/one|two/).map((n) => n.textContent)).toEqual([
48
+ 'one',
49
+ 'two',
50
+ ])
51
+ })
52
+
53
+ it('hands the payload to the handler', () => {
54
+ const seen = vi.fn()
55
+ mount(
56
+ [
57
+ {
58
+ name: 'spy',
59
+ slots: {
60
+ resourceDetailAccessActions: (props) => {
61
+ seen(props.resource.slug)
62
+ return null
63
+ },
64
+ },
65
+ },
66
+ ],
67
+ <ResourceDetailAccessActions {...ctx} />,
68
+ )
69
+ expect(seen).toHaveBeenCalledWith('tracker')
70
+ })
71
+
72
+ it('contains a crashing plugin and reports it', () => {
73
+ // A crash must not reach the host, and must not be silent either: with a
74
+ // null fallback and no report it is indistinguishable from "not registered".
75
+ const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
76
+ const plugins: AcPlugin[] = [
77
+ {
78
+ name: 'boom',
79
+ slots: {
80
+ resourceDetailAccessActions: () => {
81
+ throw new Error('plugin exploded')
82
+ },
83
+ },
84
+ },
85
+ {
86
+ name: 'ok',
87
+ slots: { resourceDetailAccessActions: () => <b>survivor</b> },
88
+ },
89
+ ]
90
+ mount(plugins, <ResourceDetailAccessActions {...ctx} />)
91
+
92
+ expect(screen.getByText('survivor')).toBeInTheDocument()
93
+ expect(
94
+ onError.mock.calls.some((call) =>
95
+ call.some(
96
+ (arg) => typeof arg === 'string' && arg.includes('[plugin:boom]'),
97
+ ),
98
+ ),
99
+ ).toBe(true)
100
+ onError.mockRestore()
101
+ })
102
+ })
103
+
104
+ describe('wrapper slot', () => {
105
+ it('passes children through untouched when no plugin is registered', () => {
106
+ mount(
107
+ undefined,
108
+ <ResourceDetailProvider resource={resource}>
109
+ <b>core content</b>
110
+ </ResourceDetailProvider>,
111
+ )
112
+ expect(screen.getByText('core content')).toBeInTheDocument()
113
+ })
114
+
115
+ it('nests wrappers with the first plugin outermost', () => {
116
+ const plugins: AcPlugin[] = [
117
+ {
118
+ name: 'outer',
119
+ wrappers: {
120
+ resourceDetailProvider: ({ children }) => (
121
+ <div data-testid="outer">{children}</div>
122
+ ),
123
+ },
124
+ },
125
+ {
126
+ name: 'inner',
127
+ wrappers: {
128
+ resourceDetailProvider: ({ children }) => (
129
+ <div data-testid="inner">{children}</div>
130
+ ),
131
+ },
132
+ },
133
+ ]
134
+ mount(
135
+ plugins,
136
+ <ResourceDetailProvider resource={resource}>
137
+ <b>leaf</b>
138
+ </ResourceDetailProvider>,
139
+ )
140
+ // Array order reads as nesting order: first declared ends up outermost.
141
+ expect(
142
+ screen.getByTestId('outer').querySelector('[data-testid="inner"]'),
143
+ ).not.toBeNull()
144
+ })
145
+
146
+ it('keeps core content when a wrapper crashes', () => {
147
+ // The fallback difference that matters: a leaf disappears, but a wrapper
148
+ // only decorated the subtree, so the subtree must survive its failure.
149
+ const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
150
+ mount(
151
+ [
152
+ {
153
+ name: 'broken-wrapper',
154
+ wrappers: {
155
+ resourceDetailProvider: () => {
156
+ throw new Error('wrapper exploded')
157
+ },
158
+ },
159
+ },
160
+ ],
161
+ <ResourceDetailProvider resource={resource}>
162
+ <b>core content</b>
163
+ </ResourceDetailProvider>,
164
+ )
165
+ expect(screen.getByText('core content')).toBeInTheDocument()
166
+ onError.mockRestore()
167
+ })
168
+ })
169
+
170
+ describe('a suspending contribution', () => {
171
+ /** Suspends once, then resolves — a stand-in for any lazy import or query. */
172
+ function makeSuspender(label: string) {
173
+ let resolved = false
174
+ let release!: () => void
175
+ const ready = new Promise<void>((r) => {
176
+ release = () => {
177
+ resolved = true
178
+ r()
179
+ }
180
+ })
181
+ // Renders its children, so the wrapper case below is a faithful wrapper
182
+ // rather than something that merely replaces the subtree.
183
+ function Suspender({ children }: { children?: ReactNode }) {
184
+ if (!resolved) throw ready
185
+ return (
186
+ <b>
187
+ {label}
188
+ {children}
189
+ </b>
190
+ )
191
+ }
192
+ return { Suspender, release }
193
+ }
194
+
195
+ it('does not blank the host while a leaf slot suspends', async () => {
196
+ const { Suspender, release } = makeSuspender('arrived')
197
+ const plugins: AcPlugin[] = [
198
+ {
199
+ name: 'slow',
200
+ slots: { resourceDetailAccessActions: () => <Suspender /> },
201
+ },
202
+ ]
203
+
204
+ mount(
205
+ plugins,
206
+ <div>
207
+ <span>host</span>
208
+ <ResourceDetailAccessActions {...ctx} />
209
+ </div>,
210
+ )
211
+
212
+ // Contained: the host is still there, only the plugin's region is empty.
213
+ expect(screen.getByText('host')).toBeInTheDocument()
214
+ expect(screen.queryByText('arrived')).not.toBeInTheDocument()
215
+
216
+ release()
217
+ expect(await screen.findByText('arrived')).toBeInTheDocument()
218
+ expect(screen.getByText('host')).toBeInTheDocument()
219
+ })
220
+
221
+ it('keeps a wrapper’s children visible while the wrapper suspends', async () => {
222
+ // The asymmetry that matters: a wrapper only decorates, so suspending must
223
+ // not take the core subtree it was wrapping off the screen. Without its own
224
+ // Suspense this bubbles to the detail card's `fallback={null}` and the whole
225
+ // card disappears until the plugin resolves.
226
+ const { Suspender, release } = makeSuspender('decorated')
227
+ const plugins: AcPlugin[] = [
228
+ {
229
+ name: 'slow-wrapper',
230
+ wrappers: {
231
+ resourceDetailProvider: ({ children }) => (
232
+ <Suspender>{children}</Suspender>
233
+ ),
234
+ },
235
+ },
236
+ ]
237
+
238
+ mount(
239
+ plugins,
240
+ <ResourceDetailProvider resource={resource}>
241
+ <span>core subtree</span>
242
+ </ResourceDetailProvider>,
243
+ )
244
+
245
+ expect(screen.getByText('core subtree')).toBeInTheDocument()
246
+
247
+ release()
248
+ expect(await screen.findByText('decorated')).toBeInTheDocument()
249
+ // Still there once the wrapper arrives — now actually wrapped by it.
250
+ expect(screen.getByText('core subtree')).toBeInTheDocument()
251
+ })
252
+ })
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const srcDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
7
+
8
+ /**
9
+ * Re-exporting from a bare directory silently strips the types for consumers.
10
+ *
11
+ * `export type { X } from './modules/foo'` emits `from './modules/foo.js'` into
12
+ * `dist/esm/index.d.ts`, but the build writes `dist/esm/modules/foo/index.d.ts`
13
+ * — so the specifier resolves to nothing. Because every consumer runs with
14
+ * `skipLibCheck`, the unresolved import raises no error anywhere: the types
15
+ * quietly degrade to `any`, and a consumer's own typecheck passes while
16
+ * accepting absolutely anything.
17
+ *
18
+ * Measured, not theoretical: `keyof SlotSpec` accepted an arbitrary string from
19
+ * a consuming app, and `PwaAutoUpdateOptions` had been shipping as `any` for
20
+ * however long the export had existed. Writing `./modules/foo/index` fixes it.
21
+ *
22
+ * This checks the source rather than the build output so it runs without one.
23
+ */
24
+ const ENTRY_POINTS = ['index.tsx', 'internal.ts']
25
+
26
+ function relativeSpecifiers(source: string): string[] {
27
+ return [...source.matchAll(/from\s+'(\.[^']+)'/g)]
28
+ .map((match) => match[1])
29
+ .filter((specifier): specifier is string => specifier !== undefined)
30
+ }
31
+
32
+ describe.each(ENTRY_POINTS)('public entry %s', (entry) => {
33
+ const source = readFileSync(resolve(srcDir, entry), 'utf8')
34
+
35
+ it('re-exports no bare directory', () => {
36
+ const offenders = relativeSpecifiers(source).filter((specifier) => {
37
+ const target = resolve(srcDir, specifier)
38
+ return existsSync(target) && statSync(target).isDirectory()
39
+ })
40
+
41
+ // Thrown rather than passed to expect() as a message: the repo's
42
+ // vitest/valid-expect rule allows only one argument, and a bare
43
+ // "expected [x] to equal []" would not explain why it matters.
44
+ if (offenders.length > 0) {
45
+ throw new Error(
46
+ `Bare directory re-export(s) in src/${entry}: ${offenders.join(', ')}.\n` +
47
+ 'Emitted as "<dir>.js", which has no .d.ts, so every type behind it\n' +
48
+ 'becomes `any` for consumers without any error. Append "/index".',
49
+ )
50
+ }
51
+ expect(offenders).toEqual([])
52
+ })
53
+
54
+ it('resolves every relative specifier to a real module', () => {
55
+ const unresolved = relativeSpecifiers(source).filter((specifier) => {
56
+ const base = resolve(srcDir, specifier.replace(/\.js$/, ''))
57
+ return !['', '.ts', '.tsx', '.d.ts'].some((ext) =>
58
+ existsSync(`${base}${ext}`),
59
+ )
60
+ })
61
+ expect(unresolved).toEqual([])
62
+ })
63
+ })
@@ -2,13 +2,33 @@ import { createBrowserHistory } from '@tanstack/react-router'
2
2
  import { createTRPCClient, httpBatchLink } from '@trpc/client'
3
3
  import { AcDb } from './userDb/AcDb'
4
4
  import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
5
+ import type { RegisteredRouter } from '@tanstack/react-router'
5
6
  import type { AppProps } from './App'
6
7
  import type { AcPlugin } from './modules/pluginCore/types'
8
+ import type { AcRouterInitParams } from './types/types'
7
9
  import { createQueryClient } from './api/infra/createQueryClient'
8
- import { createAcRouter } from './util/createAcRouter'
10
+
11
+ export interface AppPropsFactoryOptions {
12
+ /**
13
+ * How to build the router.
14
+ *
15
+ * Injected rather than imported, because the ROUTE TREE belongs to the
16
+ * deployment. A deployment that composes its own tree declares its own
17
+ * `declare module … Register`, and importing the core's `createAcRouter`
18
+ * here would put the core's competing augmentation into that program — a
19
+ * hard `TS2717`, and `import type` would not help: augmentations are
20
+ * collected when the program is built.
21
+ *
22
+ * Using the core's tree unchanged? Pass `createAcRouter` from
23
+ * `@igstack/app-catalog-frontend-core/router`.
24
+ */
25
+ createRouter: (init: AcRouterInitParams) => RegisteredRouter
26
+ }
9
27
 
10
28
  // registerSW();
11
- export function appPropsFactory(): AppProps {
29
+ export function appPropsFactory({
30
+ createRouter,
31
+ }: AppPropsFactoryOptions): AppProps {
12
32
  const trpcClient = createTRPCClient<TRPCRouter>({
13
33
  links: [
14
34
  httpBatchLink({
@@ -22,7 +42,7 @@ export function appPropsFactory(): AppProps {
22
42
  const plugins: AcPlugin[] = [
23
43
  // Future plugins can be added here
24
44
  ]
25
- const router = createAcRouter({
45
+ const router = createRouter({
26
46
  history: createBrowserHistory(),
27
47
  context: {
28
48
  queryClient,