@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.
- package/dist/esm/App.d.ts +29 -3
- package/dist/esm/App.js +4 -2
- package/dist/esm/App.js.map +1 -1
- package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
- package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
- package/dist/esm/appPropsFactory.d.ts +19 -1
- package/dist/esm/appPropsFactory.js +4 -3
- package/dist/esm/appPropsFactory.js.map +1 -1
- package/dist/esm/index.d.ts +15 -2
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internal.d.ts +7 -1
- package/dist/esm/internal.js +0 -2
- package/dist/esm/internal.js.map +1 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +39 -17
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
- package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
- package/dist/esm/modules/auth/AuthContext.js +5 -0
- package/dist/esm/modules/auth/AuthContext.js.map +1 -1
- package/dist/esm/modules/auth/index.d.ts +1 -1
- package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
- package/dist/esm/modules/dismiss/DismissStack.js +82 -0
- package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
- package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
- package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
- package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
- package/dist/esm/modules/extensions/factories.d.ts +18 -0
- package/dist/esm/modules/extensions/factories.js +12 -0
- package/dist/esm/modules/extensions/factories.js.map +1 -0
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
- package/dist/esm/modules/extensions/index.d.ts +79 -0
- package/dist/esm/modules/extensions/index.js +26 -0
- package/dist/esm/modules/extensions/index.js.map +1 -0
- package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
- package/dist/esm/modules/extensions/makeSlot.js +62 -0
- package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
- package/dist/esm/modules/extensions/types.d.ts +46 -0
- package/dist/esm/modules/gallery/Gallery.js +6 -14
- package/dist/esm/modules/gallery/Gallery.js.map +1 -1
- package/dist/esm/routeTree.gen.d.ts +34 -34
- package/dist/esm/routeTree.gen.js +14 -14
- package/dist/esm/routeTree.gen.js.map +1 -1
- package/dist/esm/router.d.ts +23 -0
- package/dist/esm/router.js +5 -0
- package/dist/esm/router.js.map +1 -0
- package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
- package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
- package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
- package/dist/esm/routes/_layout/index.d.ts +1 -1
- package/dist/esm/routes/_layout/login.d.ts +1 -1
- package/dist/esm/routes/_layout/mcp.d.ts +1 -1
- package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
- package/dist/esm/routes/_layout.d.ts +1 -1
- package/dist/esm/routes/_layout.js +13 -2
- package/dist/esm/routes/_layout.js.map +1 -1
- package/dist/esm/routes/auth.callback.d.ts +1 -1
- package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
- package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
- package/dist/esm/ui/components/header/Header.js +0 -1
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/dist/esm/ui/dialog.d.ts +1 -1
- package/dist/esm/ui/dialog.js +9 -0
- package/dist/esm/ui/dialog.js.map +1 -1
- package/dist/esm/ui/table.d.ts +25 -1
- package/dist/esm/ui/table.js +20 -3
- package/dist/esm/ui/table.js.map +1 -1
- package/dist/esm/util/createAcRouter.d.ts +1 -1
- package/package.json +19 -11
- package/src/App.tsx +34 -3
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
- package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
- package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
- package/src/__tests__/publicEntryResolves.test.ts +63 -0
- package/src/appPropsFactory.ts +23 -3
- package/src/index.tsx +32 -2
- package/src/internal.ts +7 -1
- package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +59 -18
- package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
- package/src/modules/auth/AuthContext.tsx +15 -0
- package/src/modules/auth/index.ts +1 -0
- package/src/modules/dismiss/DismissStack.tsx +224 -0
- package/src/modules/extensions/ExtensionsContext.tsx +36 -0
- package/src/modules/extensions/factories.ts +19 -0
- package/src/modules/extensions/features/resourceDetail.ts +74 -0
- package/src/modules/extensions/index.ts +121 -0
- package/src/modules/extensions/makeSlot.tsx +123 -0
- package/src/modules/extensions/types.ts +51 -0
- package/src/modules/gallery/Gallery.tsx +13 -17
- package/src/routeTree.gen.ts +31 -31
- package/src/router.ts +23 -0
- package/src/routes/_layout.tsx +51 -1
- package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
- package/src/ui/dialog.tsx +26 -0
- 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
|
+
})
|
package/src/appPropsFactory.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
45
|
+
const router = createRouter({
|
|
26
46
|
history: createBrowserHistory(),
|
|
27
47
|
context: {
|
|
28
48
|
queryClient,
|