@igstack/app-catalog-frontend-core 6.6.0 → 6.8.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 (141) 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/catalog/AppDetailPanel.widen.test.d.ts +0 -0
  5. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
  6. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
  7. package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
  8. package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
  9. package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
  10. package/dist/esm/appPropsFactory.d.ts +19 -1
  11. package/dist/esm/appPropsFactory.js +4 -3
  12. package/dist/esm/appPropsFactory.js.map +1 -1
  13. package/dist/esm/index.d.ts +15 -2
  14. package/dist/esm/index.js +5 -0
  15. package/dist/esm/index.js.map +1 -1
  16. package/dist/esm/internal.d.ts +7 -1
  17. package/dist/esm/internal.js +0 -2
  18. package/dist/esm/internal.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
  20. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
  21. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
  23. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +65 -29
  25. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  26. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
  27. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  28. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
  29. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  30. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
  31. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
  32. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  33. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
  34. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
  35. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  36. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
  37. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  38. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
  39. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
  40. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  41. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
  42. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
  44. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  45. package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
  46. package/dist/esm/modules/auth/AuthContext.js +5 -0
  47. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  48. package/dist/esm/modules/auth/index.d.ts +1 -1
  49. package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
  50. package/dist/esm/modules/dismiss/DismissStack.js +82 -0
  51. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
  52. package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
  53. package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
  54. package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
  55. package/dist/esm/modules/extensions/factories.d.ts +18 -0
  56. package/dist/esm/modules/extensions/factories.js +12 -0
  57. package/dist/esm/modules/extensions/factories.js.map +1 -0
  58. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
  59. package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
  60. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
  61. package/dist/esm/modules/extensions/index.d.ts +79 -0
  62. package/dist/esm/modules/extensions/index.js +26 -0
  63. package/dist/esm/modules/extensions/index.js.map +1 -0
  64. package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
  65. package/dist/esm/modules/extensions/makeSlot.js +62 -0
  66. package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
  67. package/dist/esm/modules/extensions/types.d.ts +46 -0
  68. package/dist/esm/modules/gallery/Gallery.js +6 -14
  69. package/dist/esm/modules/gallery/Gallery.js.map +1 -1
  70. package/dist/esm/routeTree.gen.d.ts +34 -34
  71. package/dist/esm/routeTree.gen.js +14 -14
  72. package/dist/esm/routeTree.gen.js.map +1 -1
  73. package/dist/esm/router.d.ts +23 -0
  74. package/dist/esm/router.js +5 -0
  75. package/dist/esm/router.js.map +1 -0
  76. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  77. package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
  78. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
  79. package/dist/esm/routes/_layout/index.d.ts +1 -1
  80. package/dist/esm/routes/_layout/login.d.ts +1 -1
  81. package/dist/esm/routes/_layout/mcp.d.ts +1 -1
  82. package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
  83. package/dist/esm/routes/_layout.d.ts +1 -1
  84. package/dist/esm/routes/_layout.js +13 -2
  85. package/dist/esm/routes/_layout.js.map +1 -1
  86. package/dist/esm/routes/auth.callback.d.ts +1 -1
  87. package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
  88. package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
  89. package/dist/esm/ui/components/header/Header.js +0 -1
  90. package/dist/esm/ui/components/header/Header.js.map +1 -1
  91. package/dist/esm/ui/dialog.d.ts +1 -1
  92. package/dist/esm/ui/dialog.js +9 -0
  93. package/dist/esm/ui/dialog.js.map +1 -1
  94. package/dist/esm/ui/table.d.ts +25 -1
  95. package/dist/esm/ui/table.js +20 -3
  96. package/dist/esm/ui/table.js.map +1 -1
  97. package/dist/esm/util/createAcRouter.d.ts +1 -1
  98. package/package.json +19 -11
  99. package/src/App.tsx +34 -3
  100. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
  101. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
  102. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.widen.test.tsx +53 -0
  103. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
  104. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
  105. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
  106. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
  107. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
  108. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
  109. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
  110. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
  111. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
  112. package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
  113. package/src/__tests__/publicEntryResolves.test.ts +63 -0
  114. package/src/appPropsFactory.ts +23 -3
  115. package/src/index.tsx +32 -2
  116. package/src/internal.ts +7 -1
  117. package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
  118. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
  119. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +89 -28
  120. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
  121. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
  122. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
  123. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
  124. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
  125. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
  126. package/src/modules/auth/AuthContext.tsx +15 -0
  127. package/src/modules/auth/index.ts +1 -0
  128. package/src/modules/dismiss/DismissStack.tsx +224 -0
  129. package/src/modules/extensions/ExtensionsContext.tsx +36 -0
  130. package/src/modules/extensions/factories.ts +19 -0
  131. package/src/modules/extensions/features/resourceDetail.ts +74 -0
  132. package/src/modules/extensions/index.ts +121 -0
  133. package/src/modules/extensions/makeSlot.tsx +123 -0
  134. package/src/modules/extensions/types.ts +51 -0
  135. package/src/modules/gallery/Gallery.tsx +13 -17
  136. package/src/routeTree.gen.ts +31 -31
  137. package/src/router.ts +23 -0
  138. package/src/routes/_layout.tsx +51 -1
  139. package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
  140. package/src/ui/dialog.tsx +26 -0
  141. package/src/ui/table.tsx +46 -3
package/src/App.tsx CHANGED
@@ -5,17 +5,44 @@ import type { QueryClient } from '@tanstack/react-query'
5
5
  import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
6
6
  import type { TRPCClient } from '@trpc/client'
7
7
  import type { AcDb } from './userDb/AcDb'
8
- import type { createAcRouter } from './util/createAcRouter'
8
+ import type { RegisteredRouter } from '@tanstack/react-router'
9
9
  import { TRPCProvider } from './api/infra/trpc'
10
10
  import type { UiSettings } from './types/uiSettings'
11
11
  import { UiSettingsContext } from './context/UiSettingsContext'
12
+ import { ExtensionsContext } from './modules/extensions'
13
+ import type { AcPlugin } from './modules/extensions'
12
14
 
13
15
  export interface AppProps {
14
- router: ReturnType<typeof createAcRouter>
16
+ /**
17
+ * `RegisteredRouter`, not `ReturnType<typeof createAcRouter>`.
18
+ *
19
+ * That was a type-only import, and a type-only import still pulls the
20
+ * referenced file's `declare module` into the program — so naming
21
+ * `createAcRouter` here put the core's `Register` augmentation into every
22
+ * consumer, and a deployment composing its own route tree could not declare
23
+ * its own without `TS2717`.
24
+ *
25
+ * This widens nothing: `RegisteredRouter` resolves per program against
26
+ * whatever `Register` that program declared, so the core's own program gets
27
+ * the core's router and a deployment gets its own, each exactly typed. A
28
+ * router built from a different route tree is still rejected here.
29
+ *
30
+ * The one blind spot, guarded by a type test on the consumer side: with NO
31
+ * `Register` anywhere in a program this silently degrades to `AnyRouter`.
32
+ */
33
+ router: RegisteredRouter
15
34
  queryClient: QueryClient
16
35
  trpcClient: TRPCClient<TRPCRouter>
17
36
  db: AcDb
18
37
  uiSettings?: UiSettings
38
+ /**
39
+ * Deployment-specific UI, rendered at the slots the core declares. Optional:
40
+ * with none registered every slot renders nothing and the UI is identical to
41
+ * one with no slots at all.
42
+ *
43
+ * Must be a module-level constant — see `AcPlugin`.
44
+ */
45
+ extensions?: readonly AcPlugin[]
19
46
  }
20
47
 
21
48
  export function App({
@@ -24,13 +51,17 @@ export function App({
24
51
  trpcClient,
25
52
  db,
26
53
  uiSettings,
54
+ extensions,
27
55
  }: AppProps) {
28
56
  return (
29
57
  <QueryClientProvider client={queryClient}>
30
58
  <TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>
31
59
  <DbProvider db={db}>
32
60
  <UiSettingsContext value={uiSettings}>
33
- <RouterProvider router={router} />
61
+ {/* Above the router, so a slot anywhere in the tree can read it. */}
62
+ <ExtensionsContext value={extensions}>
63
+ <RouterProvider router={router} />
64
+ </ExtensionsContext>
34
65
  </UiSettingsContext>
35
66
  </DbProvider>
36
67
  </TRPCProvider>
@@ -32,7 +32,10 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
32
32
  actions: {},
33
33
  })),
34
34
  }))
35
- vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
35
+ vi.mock('~/modules/auth', () => ({
36
+ useUser: vi.fn(() => null),
37
+ useOptionalUser: vi.fn(() => null),
38
+ }))
36
39
  vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
37
40
  FeedbackSection: () => <div />,
38
41
  }))
@@ -17,7 +17,7 @@ vi.mock('~/modules/appCatalog/ui/components/SubResourceDetailPanel', () => ({
17
17
  SubResourceDetailPanel: () => null,
18
18
  }))
19
19
  vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
20
- useAppCatalogContext: vi.fn(() => ({ approvalMethods: [] })),
20
+ useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
21
21
  }))
22
22
 
23
23
  const { AppDetailPanel } =
@@ -0,0 +1,53 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import '@testing-library/jest-dom/vitest'
6
+
7
+ vi.mock('~/modules/appCatalog/ui/detail/AppDetails', () => ({
8
+ AppDetails: () => <p>body</p>,
9
+ }))
10
+ vi.mock('~/modules/appCatalog/ui/components/SubResourceDetailPanel', () => ({
11
+ SubResourceDetailPanel: () => null,
12
+ }))
13
+ vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
14
+ useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
15
+ }))
16
+
17
+ const { AppDetailPanel } =
18
+ await import('~/modules/appCatalog/ui/catalog/AppDetailPanel')
19
+
20
+ const app = { slug: 'tracker', displayName: 'Tracker' } as Resource
21
+
22
+ describe('AppDetailPanel — widen button', () => {
23
+ // jsdom has no layout, so the measured width is checked on the live site;
24
+ // here only the toggle and its cap.
25
+ it('toggles the card between its capped width and the full width', async () => {
26
+ const user = userEvent.setup()
27
+ render(<AppDetailPanel app={app} onClose={vi.fn()} />)
28
+ const dialog = screen.getByRole('dialog')
29
+ expect(dialog).not.toHaveClass('max-w-none')
30
+
31
+ await user.click(screen.getByRole('button', { name: 'Widen card' }))
32
+ expect(dialog).toHaveClass('max-w-none')
33
+
34
+ await user.click(screen.getByRole('button', { name: 'Restore width' }))
35
+ expect(dialog).not.toHaveClass('max-w-none')
36
+ expect(screen.getByRole('button', { name: 'Widen card' })).toBeVisible()
37
+ })
38
+
39
+ it('works from the keyboard and leaves Escape closing the card', async () => {
40
+ const user = userEvent.setup()
41
+ const onClose = vi.fn()
42
+ render(<AppDetailPanel app={app} onClose={onClose} />)
43
+
44
+ screen.getByRole('button', { name: 'Widen card' }).focus()
45
+ await user.keyboard('{Enter}')
46
+ expect(screen.getByRole('dialog')).toHaveClass('max-w-none')
47
+
48
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Restore width' }), {
49
+ key: 'Escape',
50
+ })
51
+ expect(onClose).toHaveBeenCalledTimes(1)
52
+ })
53
+ })
@@ -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 }),