@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,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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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('closes the panel', () => {
56
- const onClosePanel = vi.fn()
57
- render(<AppDetails app={app} onClosePanel={onClosePanel} />)
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(onClosePanel).toHaveBeenCalled()
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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 = { sub: 'parent-two', tab: undefined as string | undefined }
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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} onClosePanel={vi.fn()} />)
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()