@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
package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import { render, screen, within } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
6
|
+
import { ExtensionsContext } from '~/modules/extensions'
|
|
7
|
+
import type { AcPlugin } from '~/modules/extensions'
|
|
8
|
+
import { SubResourcesSection } from '~/modules/appCatalog/ui/components/SubResourcesSection'
|
|
9
|
+
|
|
10
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
11
|
+
Link: ({ children }: { children: React.ReactNode }) => (
|
|
12
|
+
<a href="#">{children}</a>
|
|
13
|
+
),
|
|
14
|
+
useNavigate: () => vi.fn(),
|
|
15
|
+
useSearch: () => ({}),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
vi.mock('~/modules/appCatalog/ui/components/PersonBadge', () => ({
|
|
19
|
+
PersonBadge: () => null,
|
|
20
|
+
PersonOrGroupBadge: () => null,
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
const parent = { slug: 'cloud', displayName: 'Cloud' } as Resource
|
|
24
|
+
const children = [
|
|
25
|
+
{ slug: 'acct-a', displayName: 'account-a' },
|
|
26
|
+
{ slug: 'acct-b', displayName: 'account-b' },
|
|
27
|
+
] as Resource[]
|
|
28
|
+
|
|
29
|
+
function mount(plugins?: AcPlugin[]) {
|
|
30
|
+
return render(
|
|
31
|
+
<ExtensionsContext value={plugins}>
|
|
32
|
+
<SubResourcesSection
|
|
33
|
+
subResources={children}
|
|
34
|
+
parentSlug={parent.slug}
|
|
35
|
+
parent={parent}
|
|
36
|
+
/>
|
|
37
|
+
</ExtensionsContext>,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rowActionsPlugin: AcPlugin = {
|
|
42
|
+
name: 'admin-actions',
|
|
43
|
+
slots: {
|
|
44
|
+
resourceSubResourceRowActions: ({ resource }) => (
|
|
45
|
+
<button type="button">Provision {resource.displayName}</button>
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The column is layout, so unlike an ordinary slot it cannot be "rendered empty"
|
|
52
|
+
* — a header with no cells under it reads as a broken table, and the
|
|
53
|
+
* open-source build registers no plugins at all. These two tests are the parity
|
|
54
|
+
* invariant: nothing registered has to look exactly like no slot existing.
|
|
55
|
+
*/
|
|
56
|
+
describe('the sub-resources Admin column', () => {
|
|
57
|
+
it('is absent entirely when no plugin fills the row slot', () => {
|
|
58
|
+
mount(undefined)
|
|
59
|
+
|
|
60
|
+
expect(
|
|
61
|
+
screen.queryByRole('columnheader', { name: 'Admin' }),
|
|
62
|
+
).not.toBeInTheDocument()
|
|
63
|
+
// Five columns, the same as before the slot existed.
|
|
64
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(5)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('appears, with one cell per row, once a plugin fills it', () => {
|
|
68
|
+
mount([rowActionsPlugin])
|
|
69
|
+
|
|
70
|
+
expect(
|
|
71
|
+
screen.getByRole('columnheader', { name: 'Admin' }),
|
|
72
|
+
).toBeInTheDocument()
|
|
73
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(6)
|
|
74
|
+
|
|
75
|
+
// Called once PER ROW and handed that row — the opposite shape to the
|
|
76
|
+
// header slot, which is called once with the whole list.
|
|
77
|
+
expect(
|
|
78
|
+
screen.getByRole('button', { name: 'Provision account-a' }),
|
|
79
|
+
).toBeInTheDocument()
|
|
80
|
+
expect(
|
|
81
|
+
screen.getByRole('button', { name: 'Provision account-b' }),
|
|
82
|
+
).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps the empty-state row spanning the full width', async () => {
|
|
86
|
+
// A colSpan that does not match the column count leaves a stray cell and a
|
|
87
|
+
// visibly broken border, and it only shows up when a filter matches nothing.
|
|
88
|
+
//
|
|
89
|
+
// Typed, not seeded via `initialSearch`: that prop is deliberately ignored
|
|
90
|
+
// unless it matches a child, so passing a non-matching term there shows
|
|
91
|
+
// every row and the empty state never appears.
|
|
92
|
+
const { container } = mount([rowActionsPlugin])
|
|
93
|
+
await userEvent.type(
|
|
94
|
+
screen.getByPlaceholderText(/Search resources/i),
|
|
95
|
+
'nothing-matches-this',
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const empty = within(container).getByText(/No resources match your filters/)
|
|
99
|
+
expect(empty).toHaveAttribute('colspan', '6')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('skips the column when the parent is not known', () => {
|
|
103
|
+
// The slot's payload promises a parent. Without one there is nothing to
|
|
104
|
+
// decide against, so the column is left out rather than passed a hole.
|
|
105
|
+
render(
|
|
106
|
+
<ExtensionsContext value={[rowActionsPlugin]}>
|
|
107
|
+
<SubResourcesSection subResources={children} parentSlug={parent.slug} />
|
|
108
|
+
</ExtensionsContext>,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(
|
|
112
|
+
screen.queryByRole('columnheader', { name: 'Admin' }),
|
|
113
|
+
).not.toBeInTheDocument()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
6
|
+
import { ExtensionsContext } from '~/modules/extensions'
|
|
7
|
+
import type { AcPlugin } from '~/modules/extensions'
|
|
8
|
+
import { SubResourcesSection } from '~/modules/appCatalog/ui/components/SubResourcesSection'
|
|
9
|
+
|
|
10
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
11
|
+
Link: ({ children }: { children: React.ReactNode }) => (
|
|
12
|
+
<a href="#">{children}</a>
|
|
13
|
+
),
|
|
14
|
+
useNavigate: () => vi.fn(),
|
|
15
|
+
useSearch: () => ({}),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
vi.mock('~/modules/appCatalog/ui/components/PersonBadge', () => ({
|
|
19
|
+
PersonBadge: () => null,
|
|
20
|
+
PersonOrGroupBadge: () => null,
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
const parent = { slug: 'cloud', displayName: 'Cloud' } as Resource
|
|
24
|
+
const children = [
|
|
25
|
+
{ slug: 'acct-a', displayName: 'alpha-account' },
|
|
26
|
+
{ slug: 'acct-b', displayName: 'alpha-backup' },
|
|
27
|
+
{ slug: 'acct-c', displayName: 'zeta-account' },
|
|
28
|
+
] as Resource[]
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Renders per row, counted through the public plugin API.
|
|
32
|
+
*
|
|
33
|
+
* The row-actions slot is invoked once per row render, so its call count IS the
|
|
34
|
+
* row's render count — no internal instrumentation and nothing to keep in sync
|
|
35
|
+
* with the component's structure.
|
|
36
|
+
*/
|
|
37
|
+
let renders: Record<string, number> = {}
|
|
38
|
+
|
|
39
|
+
const countingPlugin: AcPlugin = {
|
|
40
|
+
name: 'render-counter',
|
|
41
|
+
slots: {
|
|
42
|
+
resourceSubResourceRowActions: ({ resource }) => {
|
|
43
|
+
renders[resource.slug] = (renders[resource.slug] ?? 0) + 1
|
|
44
|
+
return <span>cell</span>
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mount() {
|
|
50
|
+
return render(
|
|
51
|
+
<ExtensionsContext value={[countingPlugin]}>
|
|
52
|
+
<SubResourcesSection
|
|
53
|
+
subResources={children}
|
|
54
|
+
parentSlug={parent.slug}
|
|
55
|
+
parent={parent}
|
|
56
|
+
/>
|
|
57
|
+
</ExtensionsContext>,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const filterBox = () => screen.getByPlaceholderText(/Search resources/i)
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The rows are memoized, and on a large table that is a correctness-adjacent
|
|
65
|
+
* property rather than a nicety: measured on the 667-account catalog entry, a
|
|
66
|
+
* filter keystroke that changed no visible row cost ~400ms and wrote 9
|
|
67
|
+
* attributes, because every row's tree was rebuilt and diffed. Memoizing the
|
|
68
|
+
* row took the same keystroke to ~33ms.
|
|
69
|
+
*
|
|
70
|
+
* These tests pin the two things that make it work, both easy to undo by
|
|
71
|
+
* accident: props must stay referentially stable across a filter change, and
|
|
72
|
+
* the highlight must arrive as a boolean rather than as the slug (which would
|
|
73
|
+
* change a prop on EVERY row whenever the highlight moved).
|
|
74
|
+
*/
|
|
75
|
+
describe('sub-resource rows are memoized', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
renders = {}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('renders each row once on mount', () => {
|
|
81
|
+
mount()
|
|
82
|
+
|
|
83
|
+
// The baseline the next test depends on. Without it, "no re-render" could
|
|
84
|
+
// pass simply because nothing ever rendered.
|
|
85
|
+
expect(renders).toEqual({ 'acct-a': 1, 'acct-b': 1, 'acct-c': 1 })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not re-render a row when filtering leaves it on screen', async () => {
|
|
89
|
+
mount()
|
|
90
|
+
expect(renders['acct-a']).toBe(1)
|
|
91
|
+
|
|
92
|
+
// 'alpha' keeps rows a and b and drops c. The two survivors are the same
|
|
93
|
+
// objects from the same array, so their props are unchanged and memo should
|
|
94
|
+
// skip them entirely.
|
|
95
|
+
await userEvent.type(filterBox(), 'alpha')
|
|
96
|
+
|
|
97
|
+
expect(screen.getByText('alpha-account')).toBeInTheDocument()
|
|
98
|
+
expect(screen.getByText('alpha-backup')).toBeInTheDocument()
|
|
99
|
+
expect(screen.queryByText('zeta-account')).not.toBeInTheDocument()
|
|
100
|
+
|
|
101
|
+
// Five keystrokes, five re-renders of the section. The surviving rows must
|
|
102
|
+
// not have re-rendered even once.
|
|
103
|
+
expect(renders['acct-a']).toBe(1)
|
|
104
|
+
expect(renders['acct-b']).toBe(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('re-renders a row that comes back after the filter is cleared', async () => {
|
|
108
|
+
mount()
|
|
109
|
+
await userEvent.type(filterBox(), 'alpha')
|
|
110
|
+
expect(renders['acct-c']).toBe(1)
|
|
111
|
+
|
|
112
|
+
await userEvent.clear(filterBox())
|
|
113
|
+
|
|
114
|
+
expect(screen.getByText('zeta-account')).toBeInTheDocument()
|
|
115
|
+
// Unmounted while filtered out, so it genuinely mounts again. This is the
|
|
116
|
+
// positive control: memo must not be so sticky that a row that should
|
|
117
|
+
// reappear does not.
|
|
118
|
+
expect(renders['acct-c']).toBe(2)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -49,14 +49,14 @@ const app = {
|
|
|
49
49
|
// visit. That is what these assert now.
|
|
50
50
|
describe('AppDetails — Background', () => {
|
|
51
51
|
it('opens on the description, not on the history', () => {
|
|
52
|
-
render(<AppDetails app={app}
|
|
52
|
+
render(<AppDetails app={app} />)
|
|
53
53
|
|
|
54
54
|
expect(screen.getByRole('heading', { name: 'Description' })).toBeVisible()
|
|
55
55
|
expect(screen.queryByRole('heading', { name: 'Background' })).toBeNull()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it('renders the history under Documentation', async () => {
|
|
59
|
-
render(<AppDetails app={app}
|
|
59
|
+
render(<AppDetails app={app} />)
|
|
60
60
|
|
|
61
61
|
fireEvent.click(screen.getByRole('tab', { name: /documentation/i }))
|
|
62
62
|
|
|
@@ -71,9 +71,7 @@ describe('AppDetails — Background', () => {
|
|
|
71
71
|
// panel either way — a test that cannot fail is worse than no test.
|
|
72
72
|
it('leaves no empty section when the entry has no history recorded', () => {
|
|
73
73
|
const { background: _omitted, ...withoutBackground } = app
|
|
74
|
-
render(
|
|
75
|
-
<AppDetails app={withoutBackground as Resource} onClosePanel={vi.fn()} />,
|
|
76
|
-
)
|
|
74
|
+
render(<AppDetails app={withoutBackground as Resource} />)
|
|
77
75
|
|
|
78
76
|
fireEvent.click(screen.getByRole('tab', { name: /documentation/i }))
|
|
79
77
|
|
|
@@ -67,7 +67,7 @@ beforeEach(() => {
|
|
|
67
67
|
// address" — and a surface that looks pressable but is not would be worse.
|
|
68
68
|
describe('the door', () => {
|
|
69
69
|
it('is a link to the app when there is an address', () => {
|
|
70
|
-
render(<AppDetails app={withUrl}
|
|
70
|
+
render(<AppDetails app={withUrl} />)
|
|
71
71
|
|
|
72
72
|
const door = screen.getByRole('link', { name: /^Open Thing in a new tab/ })
|
|
73
73
|
expect(door).toHaveAttribute('href', 'https://thing.example.com/portal')
|
|
@@ -79,7 +79,7 @@ describe('the door', () => {
|
|
|
79
79
|
})
|
|
80
80
|
|
|
81
81
|
it('says so plainly when the entry has no address, and offers nothing to press', () => {
|
|
82
|
-
render(<AppDetails app={withoutUrl}
|
|
82
|
+
render(<AppDetails app={withoutUrl} />)
|
|
83
83
|
|
|
84
84
|
expect(screen.getByText('No URL for this app.')).toBeVisible()
|
|
85
85
|
expect(screen.queryByRole('link', { name: /in a new tab/ })).toBeNull()
|
|
@@ -90,7 +90,7 @@ describe('the tab strip', () => {
|
|
|
90
90
|
// The reason the active tab lives in the url at all: a card is linkable
|
|
91
91
|
// mid-conversation, and a maintainer's reply on the notes tab is addressable.
|
|
92
92
|
it('writes the selected tab to the url', () => {
|
|
93
|
-
render(<AppDetails app={withUrl}
|
|
93
|
+
render(<AppDetails app={withUrl} />)
|
|
94
94
|
|
|
95
95
|
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
96
96
|
|
|
@@ -108,7 +108,7 @@ describe('the tab strip', () => {
|
|
|
108
108
|
* NOT the default belongs in the url.
|
|
109
109
|
*/
|
|
110
110
|
it('keeps the default tab out of the url', () => {
|
|
111
|
-
render(<AppDetails app={withUrl}
|
|
111
|
+
render(<AppDetails app={withUrl} />)
|
|
112
112
|
|
|
113
113
|
// Go somewhere else, then come back to the first tab.
|
|
114
114
|
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
@@ -128,7 +128,7 @@ describe('the tab strip', () => {
|
|
|
128
128
|
|
|
129
129
|
it('opens on the tab named in the url', () => {
|
|
130
130
|
search.tab = 'documentation'
|
|
131
|
-
render(<AppDetails app={withUrl}
|
|
131
|
+
render(<AppDetails app={withUrl} />)
|
|
132
132
|
|
|
133
133
|
expect(screen.getByRole('tab', { name: 'Documentation' })).toHaveAttribute(
|
|
134
134
|
'aria-selected',
|
|
@@ -140,7 +140,7 @@ describe('the tab strip', () => {
|
|
|
140
140
|
// keep resolving rather than dropping the reader on the first tab.
|
|
141
141
|
it('still honours a retired tab id from an older link', () => {
|
|
142
142
|
search.tab = 'metadata'
|
|
143
|
-
render(<AppDetails app={withUrl}
|
|
143
|
+
render(<AppDetails app={withUrl} />)
|
|
144
144
|
|
|
145
145
|
expect(screen.getByRole('tab', { name: 'Documentation' })).toHaveAttribute(
|
|
146
146
|
'aria-selected',
|
|
@@ -152,7 +152,7 @@ describe('the tab strip', () => {
|
|
|
152
152
|
// rendering a card with no panel at all.
|
|
153
153
|
it('falls back to the first tab when the url names one this entry lacks', () => {
|
|
154
154
|
search.tab = 'resources'
|
|
155
|
-
render(<AppDetails app={withUrl}
|
|
155
|
+
render(<AppDetails app={withUrl} />)
|
|
156
156
|
|
|
157
157
|
expect(screen.queryByRole('tab', { name: /resources/i })).toBeNull()
|
|
158
158
|
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
|
@@ -164,7 +164,7 @@ describe('the tab strip', () => {
|
|
|
164
164
|
|
|
165
165
|
// Per the tablist pattern: one stop in the tab order, arrows move within.
|
|
166
166
|
it('moves between tabs with the arrow keys', () => {
|
|
167
|
-
render(<AppDetails app={withUrl}
|
|
167
|
+
render(<AppDetails app={withUrl} />)
|
|
168
168
|
|
|
169
169
|
const overview = screen.getByRole('tab', { name: 'Overview' })
|
|
170
170
|
expect(overview).toHaveAttribute('tabindex', '0')
|
|
@@ -180,7 +180,7 @@ describe('the tab strip', () => {
|
|
|
180
180
|
})
|
|
181
181
|
|
|
182
182
|
it('wraps from the first tab to the last', () => {
|
|
183
|
-
render(<AppDetails app={withUrl}
|
|
183
|
+
render(<AppDetails app={withUrl} />)
|
|
184
184
|
|
|
185
185
|
fireEvent.keyDown(screen.getByRole('tab', { name: 'Overview' }), {
|
|
186
186
|
key: 'ArrowLeft',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
2
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { useRef } from 'react'
|
|
3
4
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
5
|
import '@testing-library/jest-dom/vitest'
|
|
5
6
|
|
|
@@ -33,6 +34,24 @@ vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
|
33
34
|
}))
|
|
34
35
|
|
|
35
36
|
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
37
|
+
const { useDismissable } = await import('~/modules/dismiss/DismissStack')
|
|
38
|
+
|
|
39
|
+
/** Stands in for the card: the layer AppDetails is rendered inside. */
|
|
40
|
+
function CardLayer({
|
|
41
|
+
onClose,
|
|
42
|
+
children,
|
|
43
|
+
}: {
|
|
44
|
+
onClose: () => void
|
|
45
|
+
children: React.ReactNode
|
|
46
|
+
}) {
|
|
47
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
48
|
+
useDismissable(ref, onClose)
|
|
49
|
+
return (
|
|
50
|
+
<div ref={ref} role="dialog" aria-label="card">
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
36
55
|
|
|
37
56
|
const app = {
|
|
38
57
|
id: 'tracker',
|
|
@@ -51,17 +70,24 @@ const app = {
|
|
|
51
70
|
// The card takes focus on open so Esc closes it. The Quick Jump field takes that
|
|
52
71
|
// focus instead — so Esc has to keep working from inside the field, or the
|
|
53
72
|
// feature costs the keyboard its way out.
|
|
73
|
+
//
|
|
74
|
+
// Escape itself belongs to the card's layer, not to AppDetails. What this pins
|
|
75
|
+
// is that the field does not CLAIM it: a field that called preventDefault on
|
|
76
|
+
// Escape would keep it from the layer, and the card would stop closing.
|
|
54
77
|
describe('AppDetails — Esc from the quick jump field', () => {
|
|
55
|
-
it('
|
|
56
|
-
const
|
|
57
|
-
render(
|
|
78
|
+
it('reaches the layer the details are rendered in', () => {
|
|
79
|
+
const onClose = vi.fn()
|
|
80
|
+
render(
|
|
81
|
+
<CardLayer onClose={onClose}>
|
|
82
|
+
<AppDetails app={app} />
|
|
83
|
+
</CardLayer>,
|
|
84
|
+
)
|
|
58
85
|
|
|
59
|
-
// `code` as well as `key`: the hotkey library matches on the physical code.
|
|
60
86
|
fireEvent.keyDown(screen.getByLabelText('case id'), {
|
|
61
87
|
key: 'Escape',
|
|
62
88
|
code: 'Escape',
|
|
63
89
|
})
|
|
64
90
|
|
|
65
|
-
expect(
|
|
91
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
66
92
|
})
|
|
67
93
|
})
|
|
@@ -55,7 +55,7 @@ const app = {
|
|
|
55
55
|
// that would have silently regressed if this test had simply been deleted.
|
|
56
56
|
describe('AppDetails — section order', () => {
|
|
57
57
|
it('opens on the description, with the access instructions not yet mounted', () => {
|
|
58
|
-
render(<AppDetails app={app}
|
|
58
|
+
render(<AppDetails app={app} />)
|
|
59
59
|
|
|
60
60
|
expect(screen.getByRole('heading', { name: 'Description' })).toBeVisible()
|
|
61
61
|
expect(
|
|
@@ -64,7 +64,7 @@ describe('AppDetails — section order', () => {
|
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
it('reaches the access instructions from its own tab', () => {
|
|
67
|
-
render(<AppDetails app={app}
|
|
67
|
+
render(<AppDetails app={app} />)
|
|
68
68
|
|
|
69
69
|
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
70
70
|
|
|
@@ -76,7 +76,7 @@ describe('AppDetails — section order', () => {
|
|
|
76
76
|
// approval method registered there is nothing to name, and claiming a route
|
|
77
77
|
// we cannot describe is the failure the label exists to prevent.
|
|
78
78
|
it('names the access route in the tab', () => {
|
|
79
|
-
render(<AppDetails app={app}
|
|
79
|
+
render(<AppDetails app={app} />)
|
|
80
80
|
|
|
81
81
|
expect(
|
|
82
82
|
screen.getByRole('tab', { name: /access — not documented/i }),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
2
|
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
3
4
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
5
|
import '@testing-library/jest-dom/vitest'
|
|
5
6
|
|
|
@@ -7,7 +8,10 @@ import '@testing-library/jest-dom/vitest'
|
|
|
7
8
|
// (to single out the row), so it is set on the shared router mock rather than
|
|
8
9
|
// passed in. Everything else in the data layer is stubbed; the chain under test
|
|
9
10
|
// is AppDetails -> DetailTabs -> SubResourcesSection.
|
|
10
|
-
const search = {
|
|
11
|
+
const search = {
|
|
12
|
+
sub: 'parent-two' as string | undefined,
|
|
13
|
+
tab: undefined as string | undefined,
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
vi.mock('@tanstack/react-router', () => ({
|
|
13
17
|
useSearch: vi.fn(() => search),
|
|
@@ -74,7 +78,7 @@ const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
|
74
78
|
// piece of information their click carried.
|
|
75
79
|
describe('AppDetails — arriving with ?sub=', () => {
|
|
76
80
|
it('opens on the children tab, not on Overview', () => {
|
|
77
|
-
render(<AppDetails app={parent}
|
|
81
|
+
render(<AppDetails app={parent} />)
|
|
78
82
|
|
|
79
83
|
expect(screen.getByRole('tab', { name: /accounts/i })).toHaveAttribute(
|
|
80
84
|
'aria-selected',
|
|
@@ -87,7 +91,7 @@ describe('AppDetails — arriving with ?sub=', () => {
|
|
|
87
91
|
})
|
|
88
92
|
|
|
89
93
|
it('singles out the matched child rather than listing its siblings', () => {
|
|
90
|
-
render(<AppDetails app={parent}
|
|
94
|
+
render(<AppDetails app={parent} />)
|
|
91
95
|
|
|
92
96
|
expect(screen.getByText(/Showing: second-child/)).toBeInTheDocument()
|
|
93
97
|
expect(screen.queryByText('first-child')).toBeNull()
|
|
@@ -98,7 +102,7 @@ describe('AppDetails — arriving with ?sub=', () => {
|
|
|
98
102
|
it('wins over a ?tab= that disagrees', () => {
|
|
99
103
|
search.tab = 'metadata'
|
|
100
104
|
try {
|
|
101
|
-
render(<AppDetails app={parent}
|
|
105
|
+
render(<AppDetails app={parent} />)
|
|
102
106
|
expect(screen.getByRole('tab', { name: /accounts/i })).toHaveAttribute(
|
|
103
107
|
'aria-selected',
|
|
104
108
|
'true',
|
|
@@ -109,7 +113,7 @@ describe('AppDetails — arriving with ?sub=', () => {
|
|
|
109
113
|
})
|
|
110
114
|
|
|
111
115
|
it('names the tab from childrenLabel and counts the children', () => {
|
|
112
|
-
render(<AppDetails app={parent}
|
|
116
|
+
render(<AppDetails app={parent} />)
|
|
113
117
|
|
|
114
118
|
const tab = screen.getByRole('tab', { name: /accounts/i })
|
|
115
119
|
expect(tab).toHaveTextContent('Accounts')
|
|
@@ -118,8 +122,46 @@ describe('AppDetails — arriving with ?sub=', () => {
|
|
|
118
122
|
|
|
119
123
|
it('falls back to a generic label when the parent does not name its children', () => {
|
|
120
124
|
const { childrenLabel: _omitted, ...unnamed } = parent
|
|
121
|
-
render(<AppDetails app={unnamed as Resource}
|
|
125
|
+
render(<AppDetails app={unnamed as Resource} />)
|
|
122
126
|
|
|
123
127
|
expect(screen.getByRole('tab', { name: /resources/i })).toBeVisible()
|
|
124
128
|
})
|
|
129
|
+
|
|
130
|
+
// The tab and the panel it opens have to agree. They used to be independent:
|
|
131
|
+
// the tab read childrenLabel while the panel hard-coded "Sub-Resources", so
|
|
132
|
+
// naming the children renamed the tab and left the heading under it
|
|
133
|
+
// contradicting the tab the user just clicked.
|
|
134
|
+
it('uses childrenLabel for the panel heading and the filter, not just the tab', async () => {
|
|
135
|
+
render(<AppDetails app={parent} />)
|
|
136
|
+
await userEvent.click(screen.getByRole('tab', { name: /accounts/i }))
|
|
137
|
+
|
|
138
|
+
expect(await screen.findByText(/^Accounts \(\d+ of \d+\)$/)).toBeVisible()
|
|
139
|
+
expect(screen.queryByText(/Sub-Resources/)).not.toBeInTheDocument()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('names the children in the filter placeholder too', async () => {
|
|
143
|
+
// The shared mock pins `?sub=`, which swaps the search box for a dismissible
|
|
144
|
+
// "Showing: …" badge — so the placeholder only exists with no row singled out.
|
|
145
|
+
search.sub = undefined
|
|
146
|
+
try {
|
|
147
|
+
render(<AppDetails app={parent} />)
|
|
148
|
+
await userEvent.click(screen.getByRole('tab', { name: /accounts/i }))
|
|
149
|
+
|
|
150
|
+
expect(
|
|
151
|
+
await screen.findByPlaceholderText(/Search Accounts by name or alias/i),
|
|
152
|
+
).toBeVisible()
|
|
153
|
+
} finally {
|
|
154
|
+
search.sub = 'parent-two'
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('keeps the generic heading when the parent does not name its children', async () => {
|
|
159
|
+
const { childrenLabel: _omitted, ...unnamed } = parent
|
|
160
|
+
render(<AppDetails app={unnamed as Resource} />)
|
|
161
|
+
await userEvent.click(screen.getByRole('tab', { name: /resources/i }))
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
await screen.findByText(/^Sub-Resources \(\d+ of \d+\)$/),
|
|
165
|
+
).toBeVisible()
|
|
166
|
+
})
|
|
125
167
|
})
|
|
@@ -55,7 +55,6 @@ describe('AppDetails — tags', () => {
|
|
|
55
55
|
'jira',
|
|
56
56
|
'placement:day-to-day',
|
|
57
57
|
])}
|
|
58
|
-
onClosePanel={vi.fn()}
|
|
59
58
|
/>,
|
|
60
59
|
)
|
|
61
60
|
|
|
@@ -65,12 +64,7 @@ describe('AppDetails — tags', () => {
|
|
|
65
64
|
})
|
|
66
65
|
|
|
67
66
|
it('drops the whole section when every tag is namespaced', () => {
|
|
68
|
-
render(
|
|
69
|
-
<AppDetails
|
|
70
|
-
app={app(['category:internal', 'team:platform'])}
|
|
71
|
-
onClosePanel={vi.fn()}
|
|
72
|
-
/>,
|
|
73
|
-
)
|
|
67
|
+
render(<AppDetails app={app(['category:internal', 'team:platform'])} />)
|
|
74
68
|
|
|
75
69
|
// An empty "Tags" heading is worse than no heading: it reads as missing data.
|
|
76
70
|
expect(screen.queryByRole('heading', { name: 'Tags' })).toBeNull()
|