@igstack/app-catalog-frontend-core 0.10.1 → 0.12.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/__tests__/integration/harness/given.d.ts +1 -0
- package/dist/esm/__tests__/integration/skewWarning.integration.test.d.ts +0 -0
- package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +5 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +15 -1
- package/dist/esm/__tests__/modules/appCatalog/utils/resolveHelpers.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.d.ts +40 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +40 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +61 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +9 -2
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js +1 -1
- package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +95 -31
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +9 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +73 -18
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js +31 -0
- package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +16 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +40 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +389 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
- package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
- package/dist/esm/routeTree.gen.d.ts +1 -1
- package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
- package/dist/esm/routes/_layout/app._slug.js +4 -3
- package/dist/esm/routes/_layout/app._slug.js.map +1 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
- package/dist/esm/routes/_layout/index.js +2 -1
- package/dist/esm/routes/_layout/index.js.map +1 -1
- package/dist/esm/routes/_layout/service-desks.js +2 -1
- package/dist/esm/routes/_layout/service-desks.js.map +1 -1
- package/dist/esm/types/uiSettings.d.ts +15 -0
- package/dist/esm/ui/components/header/Header.js +1 -1
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/dist/index.css +73 -66
- package/package.json +4 -4
- package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
- package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
- package/src/__tests__/integration/appFreshness.integration.test.ts +3 -3
- package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
- package/src/__tests__/integration/harness/given.tsx +9 -1
- package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
- package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
- package/src/__tests__/integration/setup/testSetup.ts +1 -0
- package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
- package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +62 -11
- package/src/__tests__/integration/tools/GalleryTools.ts +7 -1
- package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
- package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
- package/src/index.css +73 -66
- package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
- package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
- package/src/modules/appCatalog/ui/components/PersonBadge.tsx +103 -30
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
- package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +90 -23
- package/src/modules/appCatalog/ui/launcher/AttributionFooter.tsx +43 -0
- package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +52 -0
- package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +547 -0
- package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
- package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
- package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
- package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
- package/src/routes/_layout/app.$slug.tsx +7 -4
- package/src/routes/_layout/catalog.apps.index.tsx +4 -1
- package/src/routes/_layout/index.tsx +5 -1
- package/src/routes/_layout/service-desks.tsx +4 -1
- package/src/types/uiSettings.ts +15 -0
- package/src/ui/components/header/Header.tsx +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -134,8 +134,8 @@
|
|
|
134
134
|
"vite-plugin-static-copy": "^3.1.4",
|
|
135
135
|
"vite-plugin-svgr": "^4.2.0",
|
|
136
136
|
"vitest": "^4.1.2",
|
|
137
|
-
"@igstack/app-catalog-backend-core": "0.
|
|
138
|
-
"@igstack/app-catalog-shared-core": "0.
|
|
137
|
+
"@igstack/app-catalog-backend-core": "0.12.0",
|
|
138
|
+
"@igstack/app-catalog-shared-core": "0.12.0"
|
|
139
139
|
},
|
|
140
140
|
"peerDependencies": {
|
|
141
141
|
"react": "19.1.2",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"vite": "^6.3.5",
|
|
145
145
|
"vite-plugin-svgr": "^4.2.0"
|
|
146
146
|
},
|
|
147
|
-
"gitHead": "
|
|
147
|
+
"gitHead": "34de03d23f3923a85c8a3b4d0bc6ff2d83c799d3",
|
|
148
148
|
"scripts": {
|
|
149
149
|
"build": "vite build",
|
|
150
150
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -71,6 +71,83 @@ describe('App Catalog Integration', () => {
|
|
|
71
71
|
expect(appData.description).toBe('A test application')
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
+
// Access UX (#31): "no access required" must be stated, not implied by a
|
|
75
|
+
// blank section. Previously a noAccessRequired/custom method rendered nothing.
|
|
76
|
+
it('states "open to everyone" for a no-access-required app', async () => {
|
|
77
|
+
const { ui } = await given(
|
|
78
|
+
magazine.custom(({ backendCfg }) => {
|
|
79
|
+
const method = backendCfg.withApprovalMethod({
|
|
80
|
+
type: 'noAccessRequired',
|
|
81
|
+
displayName: 'No Approval Needed',
|
|
82
|
+
config: {},
|
|
83
|
+
})
|
|
84
|
+
backendCfg.withApp({
|
|
85
|
+
displayName: 'Open App',
|
|
86
|
+
description: 'Anyone can use it',
|
|
87
|
+
accessRequest: { approvalMethodSlug: method.slug },
|
|
88
|
+
})
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
await ui.catalog.openApp('Open App')
|
|
93
|
+
const access = ui.app.getAccessText()
|
|
94
|
+
expect(access).not.toBeNull()
|
|
95
|
+
expect(access!.toLowerCase()).toContain('no request')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Access UX (#31): an undocumented method must route the user to the owner,
|
|
99
|
+
// never leave a blank/dead access section.
|
|
100
|
+
it('offers a fallback when the access process is undocumented', async () => {
|
|
101
|
+
const { ui } = await given(
|
|
102
|
+
magazine.custom(({ backendCfg }) => {
|
|
103
|
+
const method = backendCfg.withApprovalMethod({
|
|
104
|
+
type: 'unknown',
|
|
105
|
+
displayName: 'Unknown',
|
|
106
|
+
config: {},
|
|
107
|
+
})
|
|
108
|
+
backendCfg.withApp({
|
|
109
|
+
displayName: 'Mystery App',
|
|
110
|
+
description: 'Access process not written down',
|
|
111
|
+
accessRequest: { approvalMethodSlug: method.slug },
|
|
112
|
+
})
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
await ui.catalog.openApp('Mystery App')
|
|
117
|
+
const access = ui.app.getAccessText()
|
|
118
|
+
expect(access).not.toBeNull()
|
|
119
|
+
expect(access!.toLowerCase()).toContain('not')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Access UX (#31): each row has a secondary "open in new tab" launch action
|
|
123
|
+
// (the primary click opens detail). The launch link points at the app URL and
|
|
124
|
+
// must not also open the detail panel.
|
|
125
|
+
it('offers a secondary launch link on rows that has the app url', async () => {
|
|
126
|
+
const { ui } = await given(
|
|
127
|
+
magazine.custom(({ backendCfg }) => {
|
|
128
|
+
backendCfg.withApp({
|
|
129
|
+
displayName: 'Launchable App',
|
|
130
|
+
description: 'Has a url',
|
|
131
|
+
appUrl: 'https://launch.example.com',
|
|
132
|
+
})
|
|
133
|
+
// Second app so the single-result auto-open doesn't fire.
|
|
134
|
+
backendCfg.withApp({
|
|
135
|
+
displayName: 'Other App',
|
|
136
|
+
description: 'No url here',
|
|
137
|
+
})
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const link = ui.catalog.getLaunchLink('Launchable App')
|
|
142
|
+
expect(link).not.toBeNull()
|
|
143
|
+
expect(link!.getAttribute('href')).toBe('https://launch.example.com')
|
|
144
|
+
expect(link!.getAttribute('target')).toBe('_blank')
|
|
145
|
+
// The row without a URL has no launch link.
|
|
146
|
+
expect(ui.catalog.getLaunchLink('Other App')).toBeNull()
|
|
147
|
+
// Launch link is a secondary action — it does not open the detail panel.
|
|
148
|
+
expect(ui.catalog.isDetailPanelOpen()).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
|
|
74
151
|
// Test 5: Returning user — cached data + no onboarding + backend down
|
|
75
152
|
it('returning user sees cached apps even when backend is unavailable', async () => {
|
|
76
153
|
suppressConsole([/TRPC Error/, /Failed to fetch/])
|
|
@@ -152,6 +229,35 @@ describe('App Catalog Integration', () => {
|
|
|
152
229
|
expect(subResources!.names).toContain('acct-staging')
|
|
153
230
|
})
|
|
154
231
|
|
|
232
|
+
it('surfaces the parent app when searching for a sub-resource name (#38)', async () => {
|
|
233
|
+
const { ui } = await given(
|
|
234
|
+
magazine.custom(({ backendCfg }) => {
|
|
235
|
+
const app = backendCfg.withApp({
|
|
236
|
+
slug: 'aws-console',
|
|
237
|
+
displayName: 'AWS Console',
|
|
238
|
+
description: 'Cloud management console',
|
|
239
|
+
})
|
|
240
|
+
backendCfg.withSubResource({
|
|
241
|
+
appSlug: app.slug,
|
|
242
|
+
displayName: 'acct-payments-prod',
|
|
243
|
+
tier: 'prod',
|
|
244
|
+
})
|
|
245
|
+
backendCfg.withApp({
|
|
246
|
+
slug: 'unrelated',
|
|
247
|
+
displayName: 'Unrelated Tool',
|
|
248
|
+
description: 'Nothing to do with the query',
|
|
249
|
+
})
|
|
250
|
+
}),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
// Query matches only a sub-resource name; the parent app must appear in the
|
|
254
|
+
// launcher search-morph results (cross-sub-resource search preserved).
|
|
255
|
+
await ui.catalog.search('acct-payments-prod')
|
|
256
|
+
const names = ui.catalog.getTableData().map((r) => r.name)
|
|
257
|
+
expect(names).toContain('AWS Console')
|
|
258
|
+
expect(names).not.toContain('Unrelated Tool')
|
|
259
|
+
})
|
|
260
|
+
|
|
155
261
|
// Test 4: Malformed Response — HTML Instead of JSON
|
|
156
262
|
it('shows error when backend returns HTML instead of JSON', async () => {
|
|
157
263
|
suppressConsole([
|
|
@@ -53,30 +53,53 @@ describe('App deep-link routing', () => {
|
|
|
53
53
|
expect(ui.catalog.isDetailPanelOpen()).toBe(false)
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
it('
|
|
57
|
-
//
|
|
58
|
-
// that app's detail
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
56
|
+
it('a single search match stays in the launcher morph — no auto-jump to /app (#38)', async () => {
|
|
57
|
+
// Regression (#38): a search narrowing to exactly one app used to auto-open
|
|
58
|
+
// that app's detail route, which — with the launcher — yanked the user out
|
|
59
|
+
// of the search-morph into the old grid+panel (reported: typing "biom"
|
|
60
|
+
// jumped straight to an app-detail page). The morph now shows the single
|
|
61
|
+
// match as a selectable row; the user opts in (click / ↵) to open it.
|
|
62
|
+
//
|
|
63
|
+
// Seed via sessionStorage (returning user who typed before this render):
|
|
64
|
+
// jsdom userEvent.type + useDeferredValue drops characters, orthogonal here.
|
|
65
|
+
const { ui, router } = await given(magazine.full(), {
|
|
66
|
+
initialRoute: '/',
|
|
67
|
+
seedSearch: 'jira',
|
|
68
|
+
})
|
|
65
69
|
|
|
66
|
-
|
|
70
|
+
// Stays on the launcher home — NO auto-navigation to the detail route.
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
expect(ui.catalog.getTableData().length).toBeGreaterThan(0)
|
|
73
|
+
})
|
|
74
|
+
expect(router.state.location.pathname).toBe('/')
|
|
75
|
+
expect(ui.catalog.getTableData().map((r) => r.name)).toContain('Jira')
|
|
76
|
+
// Search text preserved (sessionStorage-backed), no ?q leak.
|
|
77
|
+
expect(ui.catalog.getSearchInput().value).toBe('jira')
|
|
78
|
+
expect((router.state.location.search as { q?: string }).q).toBeUndefined()
|
|
67
79
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
// written, so on the per-route remount the input reset to '' and lost focus.
|
|
80
|
+
// Opting in (click) opens the detail.
|
|
81
|
+
await ui.catalog.openApp('Jira')
|
|
71
82
|
await waitFor(() => {
|
|
72
83
|
expect(router.state.location.pathname).toBe('/app/jira')
|
|
73
84
|
})
|
|
85
|
+
})
|
|
74
86
|
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
it('strips a legacy ?q= from the URL on the app detail route (#27)', async () => {
|
|
88
|
+
// Old shared links may still carry `?q=` (e.g. /app/jira?q=jira). `q` is
|
|
89
|
+
// still declared in the route search schema (so it validates), but the
|
|
90
|
+
// route's `stripSearchParams(['q'])` middleware rewrites the URL to a clean
|
|
91
|
+
// /app/jira on load. We no longer read `q` from the URL, so a bare deep-link
|
|
92
|
+
// doesn't repopulate the input from the query string.
|
|
93
|
+
const { ui, router } = await given(magazine.full(), {
|
|
94
|
+
initialRoute: '/app/jira?q=jira',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(ui.catalog.isDetailPanelOpen()).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
expect(router.state.location.pathname).toBe('/app/jira')
|
|
101
|
+
expect(router.state.location.href).toBe('/app/jira')
|
|
102
|
+
expect((router.state.location.search as { q?: string }).q).toBeUndefined()
|
|
80
103
|
})
|
|
81
104
|
|
|
82
105
|
it('redirects /app/<alias> to the canonical /app/<slug> (replace, no back entry)', async () => {
|
|
@@ -102,17 +125,20 @@ describe('App deep-link routing', () => {
|
|
|
102
125
|
expect(router.history.canGoBack()).toBe(false)
|
|
103
126
|
})
|
|
104
127
|
|
|
105
|
-
it('
|
|
106
|
-
// The
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
it('restores the search input from sessionStorage on the app detail route (#10 read path, #27)', async () => {
|
|
129
|
+
// The #10 guarantee - landing on /app/<slug> repopulates the search input -
|
|
130
|
+
// now sources the query from sessionStorage instead of `?q=` (#27). Seed the
|
|
131
|
+
// store as a returning user would have (typed a search, then navigated), and
|
|
132
|
+
// assert the detail route restores it with no `q` in the URL.
|
|
133
|
+
const { ui, router } = await given(magazine.full(), {
|
|
134
|
+
initialRoute: '/app/jira',
|
|
135
|
+
seedSearch: 'jira',
|
|
111
136
|
})
|
|
112
137
|
|
|
113
138
|
await waitFor(() => {
|
|
114
139
|
expect(ui.catalog.isDetailPanelOpen()).toBe(true)
|
|
115
140
|
})
|
|
116
141
|
expect(ui.catalog.getSearchInput().value).toBe('jira')
|
|
142
|
+
expect((router.state.location.search as { q?: string }).q).toBeUndefined()
|
|
117
143
|
})
|
|
118
144
|
})
|
|
@@ -11,7 +11,7 @@ const DAY = 24 * 60 * 60 * 1000
|
|
|
11
11
|
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString()
|
|
12
12
|
|
|
13
13
|
describe('App freshness line', () => {
|
|
14
|
-
it('shows
|
|
14
|
+
it('shows an "Updated" freshness line when the app has freshness', async () => {
|
|
15
15
|
const { ui } = await given(
|
|
16
16
|
magazine.full(({ backendCfg }) => {
|
|
17
17
|
backendCfg.withApp({
|
|
@@ -24,7 +24,7 @@ describe('App freshness line', () => {
|
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
|
|
27
|
-
expect(screen.getByText(/
|
|
27
|
+
expect(screen.getByText(/Updated/i)).toBeInTheDocument()
|
|
28
28
|
// fresh → no stale note
|
|
29
29
|
expect(screen.queryByText(/may be out of date/i)).toBeNull()
|
|
30
30
|
})
|
|
@@ -58,6 +58,6 @@ describe('App freshness line', () => {
|
|
|
58
58
|
)
|
|
59
59
|
|
|
60
60
|
await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
|
|
61
|
-
expect(screen.queryByText(/
|
|
61
|
+
expect(screen.queryByText(/Updated/i)).toBeNull()
|
|
62
62
|
})
|
|
63
63
|
})
|
|
@@ -5,7 +5,7 @@ import { given } from './harness/given'
|
|
|
5
5
|
import { magazine } from './mock-backend/magazines'
|
|
6
6
|
|
|
7
7
|
// #25 regression: descriptions now carry markdown cross-reference links like
|
|
8
|
-
// `[
|
|
8
|
+
// `[Signatera Portal](/app/signatera-portal)`. The detail panel renders them
|
|
9
9
|
// as real links, but the COMPACT surfaces (grid list preview / table row) show
|
|
10
10
|
// `description` as plain text, so the raw markdown syntax leaked to users.
|
|
11
11
|
// In clamped previews we render the visible text only — no bracket/paren
|
|
@@ -13,10 +13,10 @@ import { magazine } from './mock-backend/magazines'
|
|
|
13
13
|
describe('Cross-reference links do not leak raw markdown in the list view (#25)', () => {
|
|
14
14
|
const withCrossRefApp = magazine.full(({ backendCfg }) => {
|
|
15
15
|
backendCfg.withApp({
|
|
16
|
-
slug: '
|
|
16
|
+
slug: 'natera-portals-ish',
|
|
17
17
|
displayName: 'Portals Hub',
|
|
18
18
|
description:
|
|
19
|
-
'See [
|
|
19
|
+
'See [Signatera Portal](/app/signatera-portal) and [Prospera Portal](/app/prospera-portal).',
|
|
20
20
|
})
|
|
21
21
|
})
|
|
22
22
|
|
|
@@ -30,20 +30,20 @@ describe('Cross-reference links do not leak raw markdown in the list view (#25)'
|
|
|
30
30
|
expect(row).toBeDefined()
|
|
31
31
|
|
|
32
32
|
// The clamped preview shows the visible text…
|
|
33
|
-
expect(row!.description).toContain('
|
|
34
|
-
expect(row!.description).toContain('Portal
|
|
33
|
+
expect(row!.description).toContain('Signatera Portal')
|
|
34
|
+
expect(row!.description).toContain('Prospera Portal')
|
|
35
35
|
// …and NEVER leaks the raw markdown link syntax.
|
|
36
36
|
expect(row!.description).not.toContain('](/app/')
|
|
37
|
-
expect(row!.description).not.toContain('[
|
|
37
|
+
expect(row!.description).not.toContain('[Signatera Portal]')
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
it('does not render an interactive link inside the clamped list preview', async () => {
|
|
41
41
|
await given(withCrossRefApp)
|
|
42
42
|
await waitFor(() => {
|
|
43
|
-
expect(screen.getByText(/See
|
|
43
|
+
expect(screen.getByText(/See Signatera Portal and/)).toBeInTheDocument()
|
|
44
44
|
})
|
|
45
45
|
// A clamped preview must not carry a navigational link that could break the
|
|
46
46
|
// clamp/layout — the interactive cross-links live in the detail panel.
|
|
47
|
-
expect(screen.queryByRole('link', { name: '
|
|
47
|
+
expect(screen.queryByRole('link', { name: 'Signatera Portal' })).toBeNull()
|
|
48
48
|
})
|
|
49
49
|
})
|
|
@@ -25,6 +25,7 @@ import { MockBackendVerifier } from './MockBackendVerifier'
|
|
|
25
25
|
import { getGlobalError } from '../tools/ErrorTools'
|
|
26
26
|
import type { GlobalError } from '../tools/ErrorTools'
|
|
27
27
|
import { browserState } from '../tools/BrowserState'
|
|
28
|
+
import { SEARCH_STORAGE_KEY } from '~/modules/appCatalog/hooks/useSessionSyncedState'
|
|
28
29
|
import { CatalogTools } from '../tools/CatalogTools'
|
|
29
30
|
import { AppDetailTools } from '../tools/AppDetailTools'
|
|
30
31
|
import { GalleryTools } from '../tools/GalleryTools'
|
|
@@ -60,7 +61,7 @@ export async function cleanupTestResources(): Promise<void> {
|
|
|
60
61
|
|
|
61
62
|
export async function given(
|
|
62
63
|
magazine: Magazine,
|
|
63
|
-
opts: { initialRoute?: string } = {},
|
|
64
|
+
opts: { initialRoute?: string; seedSearch?: string } = {},
|
|
64
65
|
): Promise<GivenResult> {
|
|
65
66
|
// Clean up any previous resources
|
|
66
67
|
await cleanupTestResources()
|
|
@@ -92,6 +93,13 @@ export async function given(
|
|
|
92
93
|
localStorage.setItem(key, value)
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
// Seed the persisted catalog search (#27): simulates a returning user who had
|
|
97
|
+
// typed a query before navigating. Backs the same sessionStorage key the
|
|
98
|
+
// provider reads, so the search input repopulates without any `?q=` in the URL.
|
|
99
|
+
if (opts.seedSearch !== undefined) {
|
|
100
|
+
sessionStorage.setItem(SEARCH_STORAGE_KEY, opts.seedSearch)
|
|
101
|
+
}
|
|
102
|
+
|
|
95
103
|
// 5. Build network catalog from backend service, then apply overrides
|
|
96
104
|
const catalog = makeNetworkReplyWithCatalog(service)
|
|
97
105
|
for (const override of networkCfg.overrides) {
|
|
@@ -14,8 +14,11 @@ describe('Deprecated app "View replacement" link (#12)', () => {
|
|
|
14
14
|
// Deep-link straight onto the deprecated Old Tool with an active search of
|
|
15
15
|
// "old tool" — which does NOT match "New Tool", so the replacement is
|
|
16
16
|
// absent from the filtered list (the exact condition that broke the link).
|
|
17
|
+
// The search is seeded via sessionStorage (#27) — it no longer rides in the
|
|
18
|
+
// URL as `?q=`.
|
|
17
19
|
const { ui, router } = await given(magazine.full(), {
|
|
18
|
-
initialRoute: '/app/old-tool
|
|
20
|
+
initialRoute: '/app/old-tool',
|
|
21
|
+
seedSearch: 'old tool',
|
|
19
22
|
})
|
|
20
23
|
|
|
21
24
|
await waitFor(() => {
|
|
@@ -41,7 +44,8 @@ describe('Deprecated app "View replacement" link (#12)', () => {
|
|
|
41
44
|
// Hard navigation: /app/<slug> should render the app regardless of a
|
|
42
45
|
// non-matching active search (browser-URL-like behavior).
|
|
43
46
|
const { ui } = await given(magazine.full(), {
|
|
44
|
-
initialRoute: '/app/jira
|
|
47
|
+
initialRoute: '/app/jira',
|
|
48
|
+
seedSearch: 'zzz-no-match-zzz',
|
|
45
49
|
})
|
|
46
50
|
|
|
47
51
|
await waitFor(() => {
|
|
@@ -5,42 +5,47 @@ import '@testing-library/jest-dom/vitest'
|
|
|
5
5
|
import { given } from './harness/given'
|
|
6
6
|
import { magazine } from './mock-backend/magazines'
|
|
7
7
|
|
|
8
|
-
// #11: when a search returns 0 non-deprecated results, fall back to
|
|
9
|
-
// deprecated
|
|
10
|
-
// "Show Deprecated Apps" toggle
|
|
11
|
-
//
|
|
8
|
+
// #11: when a search returns 0 non-deprecated results, fall back to showing
|
|
9
|
+
// deprecated matches and surface a notice. In the launcher search-morph (#38)
|
|
10
|
+
// there is no separate "Show Deprecated Apps" toggle (a grid-era control) —
|
|
11
|
+
// the morph searches the full set and only labels the deprecated-only case.
|
|
12
|
+
// `magazine.full()` includes a deprecated app "Old Tool" (slug old-tool) that
|
|
13
|
+
// is the only match for "old tool".
|
|
12
14
|
describe('Search fallback to deprecated apps (#11)', () => {
|
|
13
|
-
it('shows deprecated matches + notice
|
|
14
|
-
// Seed the query via
|
|
15
|
-
// userEvent typing race
|
|
15
|
+
it('shows deprecated matches + notice when 0 active results', async () => {
|
|
16
|
+
// Seed the query via sessionStorage to avoid the jsdom useDeferredValue +
|
|
17
|
+
// userEvent typing race (#27 removed `?q=` from the URL). "old tool" matches
|
|
18
|
+
// ONLY the deprecated "Old Tool".
|
|
16
19
|
const { ui } = await given(magazine.full(), {
|
|
17
|
-
initialRoute: '
|
|
20
|
+
initialRoute: '/',
|
|
21
|
+
seedSearch: 'old tool',
|
|
18
22
|
})
|
|
19
23
|
|
|
20
|
-
// The fallback surfaces the deprecated match
|
|
21
|
-
// "Show Deprecated Apps" toggle is auto-enabled.
|
|
24
|
+
// The fallback surfaces the deprecated match and shows the notice.
|
|
22
25
|
await waitFor(() => {
|
|
23
26
|
expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(true)
|
|
24
27
|
})
|
|
25
28
|
expect(ui.catalog.getTableData().map((r) => r.name)).toContain('Old Tool')
|
|
26
|
-
expect(ui.catalog.isShowDeprecatedChecked()).toBe(true)
|
|
27
29
|
})
|
|
28
30
|
|
|
29
|
-
it('does not
|
|
30
|
-
const { ui } = await given(magazine.full(), {
|
|
31
|
+
it('does not show the fallback notice when the query has an active match', async () => {
|
|
32
|
+
const { ui } = await given(magazine.full(), {
|
|
33
|
+
initialRoute: '/',
|
|
34
|
+
seedSearch: 'Jira',
|
|
35
|
+
})
|
|
31
36
|
|
|
32
37
|
await waitFor(() => {
|
|
33
38
|
const names = ui.catalog.getTableData().map((r) => r.name)
|
|
34
39
|
expect(names).toContain('Jira')
|
|
35
40
|
})
|
|
36
|
-
// Active match exists → no fallback
|
|
41
|
+
// Active match exists → no deprecated-fallback notice.
|
|
37
42
|
expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(false)
|
|
38
|
-
expect(ui.catalog.isShowDeprecatedChecked()).toBe(false)
|
|
39
43
|
})
|
|
40
44
|
|
|
41
45
|
it('keeps the normal empty state when nothing matches at all', async () => {
|
|
42
46
|
const { ui } = await given(magazine.full(), {
|
|
43
|
-
initialRoute: '
|
|
47
|
+
initialRoute: '/',
|
|
48
|
+
seedSearch: 'zzz-nothing-matches-zzz',
|
|
44
49
|
})
|
|
45
50
|
|
|
46
51
|
await waitFor(() => {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { waitFor } from '@testing-library/react'
|
|
3
|
+
import '@testing-library/jest-dom/vitest'
|
|
4
|
+
|
|
5
|
+
import { given } from './harness/given'
|
|
6
|
+
import { magazine } from './mock-backend/magazines'
|
|
7
|
+
|
|
8
|
+
// The dev-only skew warning fires when resources loaded but 0 are top-level
|
|
9
|
+
// (fingerprint of a frontend/backend-core version skew or stale service worker).
|
|
10
|
+
describe('empty-catalog skew warning (dev-only)', () => {
|
|
11
|
+
let warnSpy: ReturnType<typeof vi.spyOn>
|
|
12
|
+
const origDev = import.meta.env.DEV
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// Force DEV so the guarded warning path runs under the test runner.
|
|
16
|
+
;(import.meta.env as { DEV: boolean }).DEV = true
|
|
17
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
18
|
+
})
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
warnSpy.mockRestore()
|
|
21
|
+
;(import.meta.env as { DEV: boolean }).DEV = origDev
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('warns when resources load but none are top-level (skew fingerprint)', async () => {
|
|
25
|
+
// Skew fixture: one resource, and it is a CHILD (parentSlug set) → rootResources === 0.
|
|
26
|
+
const skew = magazine.custom(({ backendCfg }) => {
|
|
27
|
+
backendCfg.withApp({
|
|
28
|
+
slug: 'child-only',
|
|
29
|
+
displayName: 'Child Only',
|
|
30
|
+
parentSlug: 'ghost-parent',
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
const { ui } = await given(skew)
|
|
34
|
+
await waitFor(() => {
|
|
35
|
+
expect(
|
|
36
|
+
warnSpy.mock.calls.some((c: unknown[]) =>
|
|
37
|
+
String(c[0]).includes('0 are top-level'),
|
|
38
|
+
),
|
|
39
|
+
).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
// sanity: catalog rendered the empty state, not a crash
|
|
42
|
+
expect(ui.catalog).toBeDefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does NOT warn for a normal populated catalog', async () => {
|
|
46
|
+
const { ui } = await given(magazine.full())
|
|
47
|
+
await waitFor(() => {
|
|
48
|
+
expect(ui.catalog.getTableData().length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
expect(
|
|
51
|
+
warnSpy.mock.calls.some((c: unknown[]) =>
|
|
52
|
+
String(c[0]).includes('0 are top-level'),
|
|
53
|
+
),
|
|
54
|
+
).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -113,6 +113,19 @@ export class AppDetailTools {
|
|
|
113
113
|
return { total, visible, names }
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Read the "How to get access" section text from the detail panel.
|
|
118
|
+
* Returns null if the section is not present.
|
|
119
|
+
*/
|
|
120
|
+
getAccessText(): string | null {
|
|
121
|
+
const panel = this.getPanel()
|
|
122
|
+
const heading = this.findHeading(panel, 'How to get access')
|
|
123
|
+
if (!heading) return null
|
|
124
|
+
// The section is the heading plus its following content wrapper.
|
|
125
|
+
const container = heading.parentElement ?? heading
|
|
126
|
+
return container.textContent.trim()
|
|
127
|
+
}
|
|
128
|
+
|
|
116
129
|
screenshots = {
|
|
117
130
|
/**
|
|
118
131
|
* Click the screenshot preview to open the gallery modal.
|
|
@@ -11,24 +11,33 @@ export class CatalogTools {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Click an app row by display name.
|
|
14
|
+
* Works against both the table/grid view and the launcher home (#38), which
|
|
15
|
+
* renders app rows as buttons rather than a <table>.
|
|
14
16
|
* Throws with list of visible apps if name not found.
|
|
15
17
|
*/
|
|
16
18
|
async openApp(name: string): Promise<void> {
|
|
17
19
|
const table = this.getCatalogTable()
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
if (table) {
|
|
21
|
+
const rows = within(table).getAllByRole('row')
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
const nameEl = row.querySelector('.font-medium')
|
|
24
|
+
if (nameEl?.textContent.trim() === name) {
|
|
25
|
+
await this.user.click(row)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
// Launcher home: rows are buttons titled "View <name>".
|
|
31
|
+
const btn = screen.queryByTitle(`View ${name}`)
|
|
32
|
+
if (btn) {
|
|
33
|
+
await this.user.click(btn)
|
|
25
34
|
return
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
const visibleNames = this.getTableData().map((r) => r.name)
|
|
30
39
|
throw new Error(
|
|
31
|
-
`App "${name}" not found
|
|
40
|
+
`App "${name}" not found. Visible apps: [${visibleNames.join(', ')}]`,
|
|
32
41
|
)
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -63,7 +72,10 @@ export class CatalogTools {
|
|
|
63
72
|
`Cannot read table — global error on page: ${bodyText.slice(0, 500)}`,
|
|
64
73
|
)
|
|
65
74
|
}
|
|
66
|
-
|
|
75
|
+
// Launcher home (#38): no <table>; read the app row buttons instead.
|
|
76
|
+
const launcherRows = this.getLauncherRows()
|
|
77
|
+
if (launcherRows.length > 0) return launcherRows
|
|
78
|
+
throw new Error('No table or launcher rows found on page')
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
const rows = within(table).getAllByRole('row')
|
|
@@ -86,6 +98,18 @@ export class CatalogTools {
|
|
|
86
98
|
return result
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The secondary "open in new tab" launch link for a row, by app name.
|
|
103
|
+
* Returns null if the row has no launch link.
|
|
104
|
+
*/
|
|
105
|
+
getLaunchLink(name: string): HTMLAnchorElement | null {
|
|
106
|
+
// The launch link's aria-label starts with "Open <name> in a new tab" and
|
|
107
|
+
// may append the destination URL, e.g. "… in a new tab (foo.example.com)".
|
|
108
|
+
return screen.queryByLabelText<HTMLAnchorElement>(
|
|
109
|
+
new RegExp(`^Open ${name} in a new tab`),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
/**
|
|
90
114
|
* Whether the right detail panel is currently visible.
|
|
91
115
|
*/
|
|
@@ -107,9 +131,12 @@ export class CatalogTools {
|
|
|
107
131
|
return !!screen.queryByText(/showing deprecated matches/i)
|
|
108
132
|
}
|
|
109
133
|
|
|
110
|
-
/** Whether the "No apps found" empty state is visible. */
|
|
134
|
+
/** Whether the "No apps found" empty state or search-morph "No results for" is visible. */
|
|
111
135
|
isEmptyStateVisible(): boolean {
|
|
112
|
-
return
|
|
136
|
+
return (
|
|
137
|
+
!!screen.queryByText(/No apps found/i) ||
|
|
138
|
+
!!screen.queryByText(/No results for/i)
|
|
139
|
+
)
|
|
113
140
|
}
|
|
114
141
|
|
|
115
142
|
/**
|
|
@@ -119,6 +146,30 @@ export class CatalogTools {
|
|
|
119
146
|
return !!screen.queryByText('Welcome to App Catalog')
|
|
120
147
|
}
|
|
121
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Read app rows from the launcher home (#38): buttons titled "View <name>".
|
|
151
|
+
* Name is the first `.font-semibold`/`.font-bold` span; description the
|
|
152
|
+
* `.text-muted-foreground` span. Deduped by name (an app can appear both in
|
|
153
|
+
* "Your apps" and "Browse all").
|
|
154
|
+
*/
|
|
155
|
+
private getLauncherRows(): TableRow[] {
|
|
156
|
+
const buttons = Array.from(
|
|
157
|
+
document.querySelectorAll<HTMLElement>('button[title^="View "]'),
|
|
158
|
+
)
|
|
159
|
+
const seen = new Set<string>()
|
|
160
|
+
const result: TableRow[] = []
|
|
161
|
+
for (const btn of buttons) {
|
|
162
|
+
const name = (btn.getAttribute('title') ?? '')
|
|
163
|
+
.replace(/^View\s+/, '')
|
|
164
|
+
.trim()
|
|
165
|
+
if (!name || seen.has(name)) continue
|
|
166
|
+
seen.add(name)
|
|
167
|
+
const descEl = btn.querySelector('.text-muted-foreground')
|
|
168
|
+
result.push({ name, description: descEl?.textContent.trim() ?? '' })
|
|
169
|
+
}
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
122
173
|
/**
|
|
123
174
|
* Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).
|
|
124
175
|
*/
|