@igstack/app-catalog-frontend-core 6.2.3-alpha-20260928205740 → 6.2.3-alpha-20260928222933
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/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +24 -16
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +163 -0
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +23 -2
|
File without changes
|
|
@@ -13,10 +13,11 @@ function AppDetailPanel({
|
|
|
13
13
|
}) {
|
|
14
14
|
const { approvalMethods } = useAppCatalogContext();
|
|
15
15
|
const dialogRef = useRef(null);
|
|
16
|
+
const shownSlug = (subResource == null ? void 0 : subResource.slug) ?? app.slug;
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
const dialog = dialogRef.current;
|
|
18
19
|
if (dialog && !dialog.contains(document.activeElement)) dialog.focus();
|
|
19
|
-
}, []);
|
|
20
|
+
}, [shownSlug]);
|
|
20
21
|
return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10", children: [
|
|
21
22
|
/* @__PURE__ */ jsx(
|
|
22
23
|
"button",
|
|
@@ -47,22 +48,29 @@ function AppDetailPanel({
|
|
|
47
48
|
children: /* @__PURE__ */ jsx(X, { className: "size-4" })
|
|
48
49
|
}
|
|
49
50
|
),
|
|
50
|
-
/* @__PURE__ */ jsx(
|
|
51
|
-
|
|
52
|
-
{
|
|
53
|
-
subResource,
|
|
54
|
-
parent: app,
|
|
55
|
-
approvalMethods,
|
|
56
|
-
onBack: onBackToParent ?? onClose
|
|
57
|
-
}
|
|
58
|
-
) : /* @__PURE__ */ jsx(
|
|
59
|
-
AppDetails,
|
|
51
|
+
/* @__PURE__ */ jsx(
|
|
52
|
+
"div",
|
|
60
53
|
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
className: "max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7",
|
|
55
|
+
children: subResource ? /* @__PURE__ */ jsx(
|
|
56
|
+
SubResourceDetailPanel,
|
|
57
|
+
{
|
|
58
|
+
subResource,
|
|
59
|
+
parent: app,
|
|
60
|
+
approvalMethods,
|
|
61
|
+
onBack: onBackToParent ?? onClose
|
|
62
|
+
}
|
|
63
|
+
) : /* @__PURE__ */ jsx(
|
|
64
|
+
AppDetails,
|
|
65
|
+
{
|
|
66
|
+
app,
|
|
67
|
+
onAppClick,
|
|
68
|
+
onClosePanel: onClose
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
shownSlug
|
|
73
|
+
)
|
|
66
74
|
]
|
|
67
75
|
}
|
|
68
76
|
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { useEffect, useRef } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'\nimport { AppDetails } from '../detail/AppDetails'\n\n/**\n * Centered detail card over the catalog (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the catalog backdrop.\n *\n * When a sub-resource is selected (`?sub=<slug>` — e.g. the user clicked a\n * matched sub-resource row in the search results) the card shows that\n * sub-resource's own detail, with its two-step access chain and a back link to\n * the parent.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function AppDetailPanel({\n app,\n subResource,\n onClose,\n onAppClick,\n onBackToParent,\n}: {\n app: Resource\n /** Selected child of `app`, when the URL carries `?sub=<slug>`. */\n subResource?: Resource | null\n onClose: () => void\n onAppClick?: (app: Resource) => void\n /** Clears `?sub=` and returns to the parent's detail. */\n onBackToParent?: () => void\n}) {\n const { approvalMethods } = useAppCatalogContext()\n const dialogRef = useRef<HTMLDivElement>(null)\n\n // Focus the dialog on mount so Esc hotkeys fire immediately, even when\n // the card was opened by a mouse click (which leaves focus on the grid row).\n //\n // Unless something inside already took the caret — the Quick Jump field does,\n // and it has the better claim: a child's effect runs before its parent's, so\n // without this guard the card would take focus straight back off it.\n useEffect(() => {\n const dialog = dialogRef.current\n if (dialog && !dialog.contains(document.activeElement)) dialog.focus()\n }, [])\n\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n ref={dialogRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${subResource?.displayName ?? app.displayName} details`}\n tabIndex={-1}\n className=\"relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none\"\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n <div
|
|
1
|
+
{"version":3,"file":"AppDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { useEffect, useRef } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'\nimport { AppDetails } from '../detail/AppDetails'\n\n/**\n * Centered detail card over the catalog (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the catalog backdrop.\n *\n * When a sub-resource is selected (`?sub=<slug>` — e.g. the user clicked a\n * matched sub-resource row in the search results) the card shows that\n * sub-resource's own detail, with its two-step access chain and a back link to\n * the parent.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function AppDetailPanel({\n app,\n subResource,\n onClose,\n onAppClick,\n onBackToParent,\n}: {\n app: Resource\n /** Selected child of `app`, when the URL carries `?sub=<slug>`. */\n subResource?: Resource | null\n onClose: () => void\n onAppClick?: (app: Resource) => void\n /** Clears `?sub=` and returns to the parent's detail. */\n onBackToParent?: () => void\n}) {\n const { approvalMethods } = useAppCatalogContext()\n const dialogRef = useRef<HTMLDivElement>(null)\n\n // Focus the dialog on mount so Esc hotkeys fire immediately, even when\n // the card was opened by a mouse click (which leaves focus on the grid row).\n //\n // Unless something inside already took the caret — the Quick Jump field does,\n // and it has the better claim: a child's effect runs before its parent's, so\n // without this guard the card would take focus straight back off it.\n //\n // Runs again whenever the card navigates to another resource, keyed the same\n // way the body below is: that navigation destroys whatever was focused (the\n // \"View replacement\" button, a prerequisite's parent), and without this the\n // caret would land on <body> — where the next Tab walks the catalog grid\n // behind the scrim, since the card has no focus trap.\n const shownSlug = subResource?.slug ?? app.slug\n useEffect(() => {\n const dialog = dialogRef.current\n if (dialog && !dialog.contains(document.activeElement)) dialog.focus()\n }, [shownSlug])\n\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n ref={dialogRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${subResource?.displayName ?? app.displayName} details`}\n tabIndex={-1}\n className=\"relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none\"\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n {/* Keyed by the resource on screen, so the body remounts when the card\n navigates from one app to another (\"View replacement\", an access\n prerequisite's parent) instead of updating in place. Everything\n per-app inside it is component state with no reset of its own — the\n roles table's expansion, the icon/screenshot error flags, a draft\n source edit — and it used to arrive at the next app still carrying\n the previous one's, plus the previous scroll offset.\n\n The key is here and not on the outer dialog on purpose: that one\n would restart the open animation and re-run the mount-focus effect\n on every in-card navigation. */}\n <div\n key={shownSlug}\n className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\"\n >\n {subResource ? (\n <SubResourceDetailPanel\n subResource={subResource}\n parent={app}\n approvalMethods={approvalMethods}\n onBack={onBackToParent ?? onClose}\n />\n ) : (\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n )}\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;;;AAsBO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAQG;AACD,QAAM,EAAE,gBAAA,IAAoB,qBAAA;AAC5B,QAAM,YAAY,OAAuB,IAAI;AAc7C,QAAM,aAAY,2CAAa,SAAQ,IAAI;AAC3C,YAAU,MAAM;AACd,UAAM,SAAS,UAAU;AACzB,QAAI,UAAU,CAAC,OAAO,SAAS,SAAS,aAAa,UAAU,MAAA;AAAA,EACjE,GAAG,CAAC,SAAS,CAAC;AAEd,SACE,qBAAC,OAAA,EAAI,WAAU,yFAEb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,SAAS;AAAA,QACT,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAMZ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,IAAG,2CAAa,gBAAe,IAAI,WAAW;AAAA,QAC1D,UAAU;AAAA,QACV,WAAU;AAAA,QAEV,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,cAAW;AAAA,cACX,SAAS;AAAA,cACT,WAAU;AAAA,cAEV,UAAA,oBAAC,GAAA,EAAE,WAAU,SAAA,CAAS;AAAA,YAAA;AAAA,UAAA;AAAA,UAaxB;AAAA,YAAC;AAAA,YAAA;AAAA,cAEC,WAAU;AAAA,cAET,UAAA,cACC;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC;AAAA,kBACA,QAAQ;AAAA,kBACR;AAAA,kBACA,QAAQ,kBAAkB;AAAA,gBAAA;AAAA,cAAA,IAG5B;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC;AAAA,kBACA;AAAA,kBACA,cAAc;AAAA,gBAAA;AAAA,cAAA;AAAA,YAChB;AAAA,YAfG;AAAA,UAAA;AAAA,QAiBP;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "6.2.3-alpha-
|
|
3
|
+
"version": "6.2.3-alpha-20260928222933",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"tailwindcss": "^4.1.7",
|
|
113
113
|
"tiptap-markdown": "^0.8.0",
|
|
114
114
|
"tslib": "^2.3.0",
|
|
115
|
-
"@igstack/app-catalog-shared-core": "6.2.3-alpha-
|
|
115
|
+
"@igstack/app-catalog-shared-core": "6.2.3-alpha-20260928222933"
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
118
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"vite-plugin-static-copy": "^3.1.4",
|
|
140
140
|
"vite-plugin-svgr": "^4.2.0",
|
|
141
141
|
"vitest": "^4.1.2",
|
|
142
|
-
"@igstack/app-catalog-backend-core": "6.2.3-alpha-
|
|
142
|
+
"@igstack/app-catalog-backend-core": "6.2.3-alpha-20260928222933"
|
|
143
143
|
},
|
|
144
144
|
"peerDependencies": {
|
|
145
145
|
"react": "19.1.2",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"vite": "^6.3.5",
|
|
149
149
|
"vite-plugin-svgr": "^4.2.0"
|
|
150
150
|
},
|
|
151
|
-
"gitHead": "
|
|
151
|
+
"gitHead": "746cb8b8ea315692017815bdc6de92738f70f6b0",
|
|
152
152
|
"scripts": {
|
|
153
153
|
"build": "vite build",
|
|
154
154
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
// The panel drags the whole detail data layer in; none of it decides whether
|
|
7
|
+
// per-app state resets, so the hooks and the fetching children are stubbed --
|
|
8
|
+
// but AppDetails itself is REAL, because the chain under test is
|
|
9
|
+
// AppDetailPanel -> AppDetails -> AccessRequestSection -> the roles table.
|
|
10
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
11
|
+
useSearch: vi.fn(() => ({})),
|
|
12
|
+
useNavigate: vi.fn(() => vi.fn()),
|
|
13
|
+
useRouter: vi.fn(() => ({
|
|
14
|
+
state: { location: { pathname: '/', search: {} } },
|
|
15
|
+
})),
|
|
16
|
+
Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
|
|
17
|
+
<a {...rest}>{children}</a>
|
|
18
|
+
),
|
|
19
|
+
}))
|
|
20
|
+
vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
|
|
21
|
+
useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
|
|
22
|
+
}))
|
|
23
|
+
vi.mock('~/modules/appCatalog/hooks/useAppClickHistory', () => ({
|
|
24
|
+
useAppClickHistory: vi.fn(() => ({ recordClick: vi.fn() })),
|
|
25
|
+
}))
|
|
26
|
+
vi.mock('~/modules/appCatalog/hooks/useUpdateApp', () => ({
|
|
27
|
+
useUpdateApp: vi.fn(() => ({ mutate: vi.fn() })),
|
|
28
|
+
}))
|
|
29
|
+
vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
|
|
30
|
+
useAppCatalogFilters: vi.fn(() => ({
|
|
31
|
+
state: { searchValue: '' },
|
|
32
|
+
actions: {},
|
|
33
|
+
})),
|
|
34
|
+
}))
|
|
35
|
+
vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
|
|
36
|
+
vi.mock('~/modules/appCatalog/ui/detail/CommentsSection', () => ({
|
|
37
|
+
CommentsSection: () => <div />,
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
const { AppDetailPanel } =
|
|
41
|
+
await import('~/modules/appCatalog/ui/catalog/AppDetailPanel')
|
|
42
|
+
|
|
43
|
+
function appWithRoles(slug: string, count: number): Resource {
|
|
44
|
+
return {
|
|
45
|
+
id: slug,
|
|
46
|
+
slug,
|
|
47
|
+
displayName: slug,
|
|
48
|
+
description: `${slug} description.`,
|
|
49
|
+
accessRequest: {
|
|
50
|
+
approvalMethodSlug: 'service',
|
|
51
|
+
roles: Array.from({ length: count }, (_, i) => ({
|
|
52
|
+
displayName: `${slug} role ${i + 1}`,
|
|
53
|
+
})),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function roleRowCount() {
|
|
59
|
+
return screen
|
|
60
|
+
.getByRole('table')
|
|
61
|
+
.querySelector('tbody')
|
|
62
|
+
?.querySelectorAll('tr').length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Two flows reach a different app without closing the card: "View replacement"
|
|
66
|
+
// on a deprecated entry, and an access prerequisite's parent. Both swap the
|
|
67
|
+
// `app` prop, and nothing in the chain down to the roles table was keyed by it,
|
|
68
|
+
// so the table arrived at the new app still expanded -- pushing that app's
|
|
69
|
+
// approvers and post-approval steps below the fold, which is exactly what
|
|
70
|
+
// truncating at five rows exists to prevent.
|
|
71
|
+
describe('AppDetailPanel — navigating from one app to another', () => {
|
|
72
|
+
it("collapses the new app's roles table", () => {
|
|
73
|
+
const { rerender } = render(
|
|
74
|
+
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
fireEvent.click(
|
|
78
|
+
screen.getByRole('button', { name: /Show all \(8\) roles/ }),
|
|
79
|
+
)
|
|
80
|
+
expect(
|
|
81
|
+
screen.getByRole('button', { name: 'Show fewer roles' }),
|
|
82
|
+
).toBeInTheDocument()
|
|
83
|
+
|
|
84
|
+
rerender(
|
|
85
|
+
<AppDetailPanel app={appWithRoles('alation', 7)} onClose={vi.fn()} />,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const toggle = screen.getByRole('button', { name: /Show all \(7\) roles/ })
|
|
89
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
|
90
|
+
// 5 roles + the expand row.
|
|
91
|
+
expect(roleRowCount()).toBe(6)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// The other half of the fix: a key that changes on anything but the resource
|
|
95
|
+
// would throw the reader's own expansion away mid-read. Re-rendering the same
|
|
96
|
+
// app must leave the table exactly as they left it.
|
|
97
|
+
it('keeps the expansion when the same app re-renders', () => {
|
|
98
|
+
const { rerender } = render(
|
|
99
|
+
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
fireEvent.click(
|
|
103
|
+
screen.getByRole('button', { name: /Show all \(8\) roles/ }),
|
|
104
|
+
)
|
|
105
|
+
// A fresh object for the same app, which is what a refetch hands down: an
|
|
106
|
+
// identity-based key would throw the reader's own expansion away here.
|
|
107
|
+
rerender(
|
|
108
|
+
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
expect(
|
|
112
|
+
screen.getByRole('button', { name: 'Show fewer roles' }),
|
|
113
|
+
).toHaveAttribute('aria-expanded', 'true')
|
|
114
|
+
// 8 roles + the collapse row.
|
|
115
|
+
expect(roleRowCount()).toBe(9)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// The card body remounts, the dialog around it must not: keying the dialog too
|
|
120
|
+
// would restart its open animation and re-run its mount-focus effect on every
|
|
121
|
+
// in-card navigation.
|
|
122
|
+
describe('AppDetailPanel — what the remount leaves alone', () => {
|
|
123
|
+
it('keeps the same dialog element across an app switch', () => {
|
|
124
|
+
const { rerender } = render(
|
|
125
|
+
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
126
|
+
)
|
|
127
|
+
const dialog = screen.getByRole('dialog')
|
|
128
|
+
|
|
129
|
+
rerender(
|
|
130
|
+
<AppDetailPanel app={appWithRoles('alation', 7)} onClose={vi.fn()} />,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(screen.getByRole('dialog')).toBe(dialog)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// The navigation destroys whatever was focused inside the card, and the card
|
|
138
|
+
// has no focus trap -- so a caret left on <body> means the next Tab walks the
|
|
139
|
+
// catalog grid behind the scrim instead of the card in front of it.
|
|
140
|
+
describe('AppDetailPanel — focus after an app switch', () => {
|
|
141
|
+
it('takes the caret back into the card', () => {
|
|
142
|
+
const { rerender } = render(
|
|
143
|
+
<AppDetailPanel app={appWithRoles('labvantage', 8)} onClose={vi.fn()} />,
|
|
144
|
+
)
|
|
145
|
+
// Stands in for the control that was activated to navigate ("View
|
|
146
|
+
// replacement", a prerequisite's parent): focused, and inside the body
|
|
147
|
+
// that is about to be replaced.
|
|
148
|
+
const toggle = screen.getByRole('button', {
|
|
149
|
+
name: /Show all \(8\) roles/,
|
|
150
|
+
})
|
|
151
|
+
toggle.focus()
|
|
152
|
+
expect(toggle).toHaveFocus()
|
|
153
|
+
|
|
154
|
+
rerender(
|
|
155
|
+
<AppDetailPanel app={appWithRoles('alation', 7)} onClose={vi.fn()} />,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
expect(document.activeElement).not.toBe(document.body)
|
|
159
|
+
expect(screen.getByRole('dialog')).toContainElement(
|
|
160
|
+
document.activeElement as HTMLElement,
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -44,10 +44,17 @@ export function AppDetailPanel({
|
|
|
44
44
|
// Unless something inside already took the caret — the Quick Jump field does,
|
|
45
45
|
// and it has the better claim: a child's effect runs before its parent's, so
|
|
46
46
|
// without this guard the card would take focus straight back off it.
|
|
47
|
+
//
|
|
48
|
+
// Runs again whenever the card navigates to another resource, keyed the same
|
|
49
|
+
// way the body below is: that navigation destroys whatever was focused (the
|
|
50
|
+
// "View replacement" button, a prerequisite's parent), and without this the
|
|
51
|
+
// caret would land on <body> — where the next Tab walks the catalog grid
|
|
52
|
+
// behind the scrim, since the card has no focus trap.
|
|
53
|
+
const shownSlug = subResource?.slug ?? app.slug
|
|
47
54
|
useEffect(() => {
|
|
48
55
|
const dialog = dialogRef.current
|
|
49
56
|
if (dialog && !dialog.contains(document.activeElement)) dialog.focus()
|
|
50
|
-
}, [])
|
|
57
|
+
}, [shownSlug])
|
|
51
58
|
|
|
52
59
|
return (
|
|
53
60
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
|
|
@@ -78,7 +85,21 @@ export function AppDetailPanel({
|
|
|
78
85
|
>
|
|
79
86
|
<X className="size-4" />
|
|
80
87
|
</button>
|
|
81
|
-
|
|
88
|
+
{/* Keyed by the resource on screen, so the body remounts when the card
|
|
89
|
+
navigates from one app to another ("View replacement", an access
|
|
90
|
+
prerequisite's parent) instead of updating in place. Everything
|
|
91
|
+
per-app inside it is component state with no reset of its own — the
|
|
92
|
+
roles table's expansion, the icon/screenshot error flags, a draft
|
|
93
|
+
source edit — and it used to arrive at the next app still carrying
|
|
94
|
+
the previous one's, plus the previous scroll offset.
|
|
95
|
+
|
|
96
|
+
The key is here and not on the outer dialog on purpose: that one
|
|
97
|
+
would restart the open animation and re-run the mount-focus effect
|
|
98
|
+
on every in-card navigation. */}
|
|
99
|
+
<div
|
|
100
|
+
key={shownSlug}
|
|
101
|
+
className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7"
|
|
102
|
+
>
|
|
82
103
|
{subResource ? (
|
|
83
104
|
<SubResourceDetailPanel
|
|
84
105
|
subResource={subResource}
|