@igstack/app-catalog-frontend-core 0.16.0 → 0.17.1-alpha-20260821043201
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/mock-backend/magazines.d.ts +1 -1
- package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +16 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +12 -0
- package/dist/esm/api/infra/trpc.d.ts +24 -0
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +5 -2
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +90 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +7 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +114 -34
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +8 -8
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +31 -4
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.js +41 -20
- package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.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/routeTree.gen.d.ts +2 -0
- package/dist/esm/routes/_layout/app.$slug.d.ts +1 -0
- package/dist/esm/routes/_layout/app._slug.js +2 -1
- package/dist/esm/routes/_layout/app._slug.js.map +1 -1
- package/dist/esm/routes/_layout/catalog.apps.index.d.ts +1 -0
- package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
- package/package.json +5 -7
- package/src/__tests__/integration/appCatalog.integration.test.ts +264 -5
- package/src/__tests__/integration/appDeepLink.integration.test.ts +46 -19
- package/src/__tests__/integration/appFreshness.integration.test.ts +74 -4
- package/src/__tests__/integration/crossRefLink.integration.test.ts +8 -8
- package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
- package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +2 -0
- package/src/__tests__/integration/mock-backend/magazines.ts +24 -24
- package/src/__tests__/integration/replacementLink.integration.test.ts +2 -2
- package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +2 -2
- package/src/__tests__/integration/serviceDesks.integration.test.ts +31 -24
- package/src/__tests__/integration/tools/AppDetailTools.ts +57 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +77 -4
- package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +6 -6
- package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +1 -1
- package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +11 -7
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +1 -1
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +124 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +1 -1
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +140 -36
- package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +12 -8
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +49 -8
- package/src/modules/appCatalog/ui/pages/ServiceDesksPage.tsx +37 -9
- package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
- package/src/routes/_layout/app.$slug.tsx +1 -0
- package/src/routes/_layout/catalog.apps.index.tsx +1 -0
|
@@ -32,19 +32,19 @@ function fullMagazine(
|
|
|
32
32
|
const { backendCfg, browserStateCfg } = ctx
|
|
33
33
|
|
|
34
34
|
// Approval methods — all 3 types
|
|
35
|
-
const
|
|
36
|
-
slug: 'it-
|
|
35
|
+
const supportPortal = backendCfg.withApprovalMethod({
|
|
36
|
+
slug: 'it-support',
|
|
37
37
|
type: 'service',
|
|
38
|
-
displayName: '
|
|
38
|
+
displayName: 'Support Portal',
|
|
39
39
|
config: {
|
|
40
|
-
url: 'https://
|
|
41
|
-
description: 'IT
|
|
40
|
+
url: 'https://support.example.com',
|
|
41
|
+
description: 'General IT support desk',
|
|
42
42
|
},
|
|
43
43
|
})
|
|
44
44
|
backendCfg.withApprovalMethod({
|
|
45
|
-
slug: 'ops-
|
|
45
|
+
slug: 'ops-support',
|
|
46
46
|
type: 'service',
|
|
47
|
-
displayName: 'Ops
|
|
47
|
+
displayName: 'Ops Portal',
|
|
48
48
|
config: { url: 'https://opsdesk.example.com' },
|
|
49
49
|
})
|
|
50
50
|
const managerApproval = backendCfg.withApprovalMethod({
|
|
@@ -91,25 +91,25 @@ function fullMagazine(
|
|
|
91
91
|
|
|
92
92
|
// Apps
|
|
93
93
|
backendCfg.withApp({
|
|
94
|
-
slug: '
|
|
95
|
-
displayName: '
|
|
94
|
+
slug: 'taskflow',
|
|
95
|
+
displayName: 'TaskFlow',
|
|
96
96
|
description: 'Project tracking and issue management',
|
|
97
97
|
tags: ['category:project-management', 'team:engineering'],
|
|
98
|
-
screenshotIds: ['ss-
|
|
99
|
-
appUrl: 'https://
|
|
98
|
+
screenshotIds: ['ss-taskflow-1', 'ss-taskflow-2', 'ss-taskflow-3'],
|
|
99
|
+
appUrl: 'https://taskflow.example.com',
|
|
100
100
|
accessRequest: {
|
|
101
|
-
approvalMethodSlug:
|
|
101
|
+
approvalMethodSlug: supportPortal.slug,
|
|
102
102
|
comments: 'Submit a ticket',
|
|
103
103
|
},
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
backendCfg.withApp({
|
|
107
|
-
slug: '
|
|
108
|
-
displayName: '
|
|
107
|
+
slug: 'teamchat',
|
|
108
|
+
displayName: 'TeamChat',
|
|
109
109
|
description: 'Team messaging and collaboration',
|
|
110
110
|
tags: ['category:communication', 'team:platform'],
|
|
111
|
-
screenshotIds: ['ss-
|
|
112
|
-
appUrl: 'https://
|
|
111
|
+
screenshotIds: ['ss-teamchat-1'],
|
|
112
|
+
appUrl: 'https://teamchat.example.com',
|
|
113
113
|
})
|
|
114
114
|
|
|
115
115
|
const newTool = backendCfg.withApp({
|
|
@@ -183,10 +183,10 @@ function singleMagazine(postConfigure?: Magazine): Magazine {
|
|
|
183
183
|
const { backendCfg } = ctx
|
|
184
184
|
|
|
185
185
|
const method = backendCfg.withApprovalMethod({
|
|
186
|
-
slug: 'it-
|
|
186
|
+
slug: 'it-support',
|
|
187
187
|
type: 'service',
|
|
188
|
-
displayName: '
|
|
189
|
-
config: { url: 'https://
|
|
188
|
+
displayName: 'Support Portal',
|
|
189
|
+
config: { url: 'https://support.example.com' },
|
|
190
190
|
})
|
|
191
191
|
|
|
192
192
|
backendCfg.withTag({
|
|
@@ -203,12 +203,12 @@ function singleMagazine(postConfigure?: Magazine): Magazine {
|
|
|
203
203
|
})
|
|
204
204
|
|
|
205
205
|
backendCfg.withApp({
|
|
206
|
-
slug: '
|
|
207
|
-
displayName: '
|
|
206
|
+
slug: 'taskflow',
|
|
207
|
+
displayName: 'TaskFlow',
|
|
208
208
|
description: 'Project tracking and issue management',
|
|
209
209
|
tags: ['category:project-management'],
|
|
210
|
-
screenshotIds: ['screenshot-
|
|
211
|
-
appUrl: 'https://
|
|
210
|
+
screenshotIds: ['screenshot-taskflow-1', 'screenshot-taskflow-2'],
|
|
211
|
+
appUrl: 'https://taskflow.example.com',
|
|
212
212
|
accessRequest: {
|
|
213
213
|
approvalMethodSlug: method.slug,
|
|
214
214
|
comments: 'Submit a ticket to IT',
|
|
@@ -227,7 +227,7 @@ export const magazine = {
|
|
|
227
227
|
/** No apps, no tags, no approval methods */
|
|
228
228
|
empty: (() => {}) satisfies Magazine,
|
|
229
229
|
|
|
230
|
-
/** 1 app (
|
|
230
|
+
/** 1 app (TaskFlow), 1 tag, 1 approval method — accepts optional post-configurer */
|
|
231
231
|
single: (postConfigure?: Magazine) => singleMagazine(postConfigure),
|
|
232
232
|
|
|
233
233
|
/** Full catalog, first-time user — accepts optional post-configurer */
|
|
@@ -44,13 +44,13 @@ describe('Deprecated app "View replacement" link (#12)', () => {
|
|
|
44
44
|
// Hard navigation: /app/<slug> should render the app regardless of a
|
|
45
45
|
// non-matching active search (browser-URL-like behavior).
|
|
46
46
|
const { ui } = await given(magazine.full(), {
|
|
47
|
-
initialRoute: '/app/
|
|
47
|
+
initialRoute: '/app/taskflow',
|
|
48
48
|
seedSearch: 'zzz-no-match-zzz',
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
await waitFor(() => {
|
|
52
52
|
expect(ui.catalog.isDetailPanelOpen()).toBe(true)
|
|
53
53
|
})
|
|
54
|
-
expect(ui.app.getOpenTitle()).toContain('
|
|
54
|
+
expect(ui.app.getOpenTitle()).toContain('TaskFlow')
|
|
55
55
|
})
|
|
56
56
|
})
|
|
@@ -31,12 +31,12 @@ describe('Search fallback to deprecated apps (#11)', () => {
|
|
|
31
31
|
it('does not show the fallback notice when the query has an active match', async () => {
|
|
32
32
|
const { ui } = await given(magazine.full(), {
|
|
33
33
|
initialRoute: '/',
|
|
34
|
-
seedSearch: '
|
|
34
|
+
seedSearch: 'TaskFlow',
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
await waitFor(() => {
|
|
38
38
|
const names = ui.catalog.getTableData().map((r) => r.name)
|
|
39
|
-
expect(names).toContain('
|
|
39
|
+
expect(names).toContain('TaskFlow')
|
|
40
40
|
})
|
|
41
41
|
// Active match exists → no deprecated-fallback notice.
|
|
42
42
|
expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(false)
|
|
@@ -8,7 +8,7 @@ import { magazine } from './mock-backend/magazines'
|
|
|
8
8
|
|
|
9
9
|
// #9: a header toggle (Apps | Service Desks) + a /service-desks route showing a
|
|
10
10
|
// searchable table of all type:'service' approval methods with open links.
|
|
11
|
-
// magazine.full() seeds two service desks (
|
|
11
|
+
// magazine.full() seeds two service desks (Support Portal, Ops Portal) and
|
|
12
12
|
// two custom methods (Manager Approval, Self-Service) which must NOT appear.
|
|
13
13
|
|
|
14
14
|
function serviceDeskTable(): HTMLElement {
|
|
@@ -37,23 +37,29 @@ describe('Service Desks view (#9)', () => {
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
const names = deskNames()
|
|
40
|
-
expect(names).toContain('
|
|
41
|
-
expect(names).toContain('Ops
|
|
40
|
+
expect(names).toContain('Support Portal')
|
|
41
|
+
expect(names).toContain('Ops Portal')
|
|
42
42
|
// custom-type methods are not service desks
|
|
43
43
|
expect(names).not.toContain('Manager Approval')
|
|
44
44
|
expect(names).not.toContain('Self-Service')
|
|
45
45
|
|
|
46
|
-
// Each service desk row has
|
|
46
|
+
// Each service desk row has links to its portal opening in a new tab.
|
|
47
|
+
// The name itself is now also a link, plus the URL is shown under the name.
|
|
47
48
|
const itRow = within(serviceDeskTable())
|
|
48
49
|
.getAllByRole('row')
|
|
49
|
-
.find((r) => r.textContent.includes('
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
.find((r) => r.textContent.includes('Support Portal'))!
|
|
51
|
+
const links = within(itRow).getAllByRole('link')
|
|
52
|
+
// All links in the row point to the same portal URL
|
|
53
|
+
for (const link of links) {
|
|
54
|
+
expect(link).toHaveAttribute('href', 'https://support.example.com')
|
|
55
|
+
expect(link).toHaveAttribute('target', '_blank')
|
|
56
|
+
}
|
|
57
|
+
// At least one link shows the URL without the scheme
|
|
58
|
+
const urlLink = links.find((l) =>
|
|
59
|
+
String(l.textContent).includes('support.example.com'),
|
|
60
|
+
)
|
|
61
|
+
expect(urlLink).toBeTruthy()
|
|
62
|
+
expect(urlLink).not.toHaveTextContent('https://')
|
|
57
63
|
})
|
|
58
64
|
|
|
59
65
|
it('shows the service desk description as subtext when present', async () => {
|
|
@@ -65,19 +71,20 @@ describe('Service Desks view (#9)', () => {
|
|
|
65
71
|
expect(screen.getByLabelText('Search service desks')).toBeInTheDocument()
|
|
66
72
|
})
|
|
67
73
|
|
|
68
|
-
//
|
|
74
|
+
// Support Portal has a description; it renders in the same name cell as subtext.
|
|
69
75
|
const itRow = within(serviceDeskTable())
|
|
70
76
|
.getAllByRole('row')
|
|
71
|
-
.find((r) => r.textContent.includes('
|
|
77
|
+
.find((r) => r.textContent.includes('Support Portal'))!
|
|
72
78
|
const nameCell = within(itRow).getAllByRole('cell')[0]
|
|
73
|
-
expect(nameCell).toHaveTextContent('IT
|
|
79
|
+
expect(nameCell).toHaveTextContent('General IT support desk')
|
|
74
80
|
|
|
75
|
-
// Ops
|
|
81
|
+
// Ops Portal has no description — its name element shows only the name
|
|
82
|
+
// (the cell also contains the URL link now, so check the name element).
|
|
76
83
|
const uxRow = within(serviceDeskTable())
|
|
77
84
|
.getAllByRole('row')
|
|
78
|
-
.find((r) => r.textContent.includes('Ops
|
|
79
|
-
const
|
|
80
|
-
expect(
|
|
85
|
+
.find((r) => r.textContent.includes('Ops Portal'))!
|
|
86
|
+
const uxNameEl = within(uxRow).getByTestId('service-desk-name')
|
|
87
|
+
expect(uxNameEl.textContent.trim()).toBe('Ops Portal')
|
|
81
88
|
})
|
|
82
89
|
|
|
83
90
|
it('filters the desks by search', async () => {
|
|
@@ -86,14 +93,14 @@ describe('Service Desks view (#9)', () => {
|
|
|
86
93
|
initialRoute: '/service-desks',
|
|
87
94
|
})
|
|
88
95
|
|
|
89
|
-
await waitFor(() => expect(deskNames()).toContain('Ops
|
|
96
|
+
await waitFor(() => expect(deskNames()).toContain('Ops Portal'))
|
|
90
97
|
|
|
91
98
|
await user.type(screen.getByLabelText('Search service desks'), 'Ops')
|
|
92
99
|
|
|
93
100
|
await waitFor(() => {
|
|
94
101
|
const names = deskNames()
|
|
95
|
-
expect(names).toContain('Ops
|
|
96
|
-
expect(names).not.toContain('
|
|
102
|
+
expect(names).toContain('Ops Portal')
|
|
103
|
+
expect(names).not.toContain('Support Portal')
|
|
97
104
|
})
|
|
98
105
|
})
|
|
99
106
|
|
|
@@ -126,14 +133,14 @@ describe('Service Desks view (#9)', () => {
|
|
|
126
133
|
|
|
127
134
|
// #23: the header "Apps" tab must stay active while viewing an app-detail route
|
|
128
135
|
// (/app/<slug>) — the detail panel is part of the Apps view. Previously the Apps
|
|
129
|
-
// link was active only on the exact "/" route, so /app/
|
|
136
|
+
// link was active only on the exact "/" route, so /app/taskflow highlighted
|
|
130
137
|
// neither tab.
|
|
131
138
|
describe('ViewToggle active tab (#23)', () => {
|
|
132
139
|
const appsTab = () => screen.getByRole('link', { name: 'Apps' })
|
|
133
140
|
const deskTab = () => screen.getByRole('link', { name: 'Service Desks' })
|
|
134
141
|
|
|
135
142
|
it('activates the Apps tab on an app-detail route (/app/$slug)', async () => {
|
|
136
|
-
await given(magazine.full(), { initialRoute: '/app/
|
|
143
|
+
await given(magazine.full(), { initialRoute: '/app/taskflow' })
|
|
137
144
|
|
|
138
145
|
await waitFor(() =>
|
|
139
146
|
expect(appsTab()).toHaveAttribute('aria-current', 'page'),
|
|
@@ -152,6 +152,63 @@ export class AppDetailTools {
|
|
|
152
152
|
return panel.querySelector('.text-2xl')?.textContent.trim() ?? ''
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The prominent primary "Open" button in the app detail card.
|
|
157
|
+
* Returns the anchor element when the app has an appUrl, null otherwise.
|
|
158
|
+
*/
|
|
159
|
+
getOpenButton(): HTMLAnchorElement | null {
|
|
160
|
+
const panel = this.getPanel()
|
|
161
|
+
return (
|
|
162
|
+
panel.querySelector<HTMLAnchorElement>('[aria-label^="Open "]') ?? null
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get sub-resource detail panel data when a sub-resource is selected.
|
|
168
|
+
* Returns null if the sub-resource detail panel is not shown.
|
|
169
|
+
*/
|
|
170
|
+
getSubResourceDetail(): {
|
|
171
|
+
subResourceName: string
|
|
172
|
+
hasStep1: boolean
|
|
173
|
+
hasStep2: boolean
|
|
174
|
+
backButtonLabel: string | null
|
|
175
|
+
} | null {
|
|
176
|
+
const panel = document.querySelector<HTMLElement>(
|
|
177
|
+
'[aria-label^="Back to "]',
|
|
178
|
+
)
|
|
179
|
+
if (!panel) return null
|
|
180
|
+
|
|
181
|
+
const backButtonEl = document.querySelector<HTMLElement>(
|
|
182
|
+
'[aria-label^="Back to "]',
|
|
183
|
+
)
|
|
184
|
+
const backButtonLabel = backButtonEl?.getAttribute('aria-label') ?? null
|
|
185
|
+
|
|
186
|
+
// Sub-resource name: h2 in the detail panel area
|
|
187
|
+
const h2 = document.querySelector('h2.text-xl')
|
|
188
|
+
const subResourceName = (h2?.textContent ?? '').trim()
|
|
189
|
+
|
|
190
|
+
// Step badges: circles with "1" and "2" text
|
|
191
|
+
const stepBadges = Array.from(
|
|
192
|
+
document.querySelectorAll<HTMLElement>('span.rounded-full'),
|
|
193
|
+
)
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
195
|
+
const getText = (el: HTMLElement) => (el.textContent ?? '').trim()
|
|
196
|
+
const hasStep1 = stepBadges.some((el) => getText(el) === '1')
|
|
197
|
+
const hasStep2 = stepBadges.some((el) => getText(el) === '2')
|
|
198
|
+
|
|
199
|
+
return { subResourceName, hasStep1, hasStep2, backButtonLabel }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async clickBackToParent(): Promise<void> {
|
|
203
|
+
const backButton = document.querySelector<HTMLButtonElement>(
|
|
204
|
+
'[aria-label^="Back to "]',
|
|
205
|
+
)
|
|
206
|
+
if (!backButton) {
|
|
207
|
+
throw new Error('Back-to-parent button not found in detail panel')
|
|
208
|
+
}
|
|
209
|
+
await this.user.click(backButton)
|
|
210
|
+
}
|
|
211
|
+
|
|
155
212
|
private getPanel(): HTMLElement {
|
|
156
213
|
const closeButton = screen.queryByLabelText('Close details panel')
|
|
157
214
|
if (!closeButton) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { screen, within } from '@testing-library/react'
|
|
1
|
+
import { fireEvent, screen, within } from '@testing-library/react'
|
|
2
2
|
import userEvent from '@testing-library/user-event'
|
|
3
3
|
|
|
4
4
|
export interface TableRow {
|
|
@@ -45,9 +45,8 @@ export class CatalogTools {
|
|
|
45
45
|
* Type into the search input.
|
|
46
46
|
*/
|
|
47
47
|
async search(text: string): Promise<void> {
|
|
48
|
-
const input =
|
|
49
|
-
|
|
50
|
-
await this.user.type(input, text)
|
|
48
|
+
const input = screen.getByLabelText('Search apps')
|
|
49
|
+
fireEvent.change(input, { target: { value: text } })
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
/** The catalog search input. */
|
|
@@ -170,6 +169,80 @@ export class CatalogTools {
|
|
|
170
169
|
return result
|
|
171
170
|
}
|
|
172
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Get expandable subresource rows shown under parents in search results.
|
|
174
|
+
* Returns null if no sub-rows are visible.
|
|
175
|
+
*/
|
|
176
|
+
getSubResourceRows(): {
|
|
177
|
+
visible: number
|
|
178
|
+
total: number
|
|
179
|
+
hasExpandRow: boolean
|
|
180
|
+
names: string[]
|
|
181
|
+
} | null {
|
|
182
|
+
const subButtons = Array.from(
|
|
183
|
+
document.querySelectorAll<HTMLButtonElement>(
|
|
184
|
+
'button[class*="border-l-2"]',
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
if (subButtons.length === 0) return null
|
|
188
|
+
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
190
|
+
const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()
|
|
191
|
+
const isExpandRow = (el: HTMLButtonElement) => getText(el).startsWith('...')
|
|
192
|
+
|
|
193
|
+
const expandButton = subButtons.find(isExpandRow)
|
|
194
|
+
const hasExpandRow = !!expandButton
|
|
195
|
+
|
|
196
|
+
let hiddenCount = 0
|
|
197
|
+
if (expandButton) {
|
|
198
|
+
const match = /\d+/.exec(getText(expandButton))
|
|
199
|
+
hiddenCount = match?.[0] ? parseInt(match[0], 10) : 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const visibleSubs = subButtons.filter((b) => !isExpandRow(b))
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
visible: visibleSubs.length,
|
|
206
|
+
total: visibleSubs.length + hiddenCount,
|
|
207
|
+
hasExpandRow,
|
|
208
|
+
names: visibleSubs.map((b) => getText(b)),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async clickSubResource(displayName: string): Promise<void> {
|
|
213
|
+
const subButtons = Array.from(
|
|
214
|
+
document.querySelectorAll<HTMLButtonElement>(
|
|
215
|
+
'button[class*="border-l-2"]',
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
219
|
+
const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()
|
|
220
|
+
const target = subButtons.find(
|
|
221
|
+
(b) =>
|
|
222
|
+
getText(b) === displayName && !getText(b).trimStart().startsWith('...'),
|
|
223
|
+
)
|
|
224
|
+
if (!target) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Sub-resource "${displayName}" not found in search result rows`,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
await this.user.click(target)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async expandSubResources(): Promise<void> {
|
|
233
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
234
|
+
const getText = (el: HTMLButtonElement) => (el.textContent ?? '').trim()
|
|
235
|
+
const expandButtons = Array.from(
|
|
236
|
+
document.querySelectorAll<HTMLButtonElement>(
|
|
237
|
+
'button[class*="border-l-2"]',
|
|
238
|
+
),
|
|
239
|
+
).filter((b) => getText(b).trimStart().startsWith('...'))
|
|
240
|
+
if (expandButtons.length === 0) {
|
|
241
|
+
throw new Error('No expand ("...N more") button found')
|
|
242
|
+
}
|
|
243
|
+
await this.user.click(expandButtons[0]!)
|
|
244
|
+
}
|
|
245
|
+
|
|
173
246
|
/**
|
|
174
247
|
* Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).
|
|
175
248
|
*/
|
|
@@ -3,14 +3,14 @@ import { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainT
|
|
|
3
3
|
|
|
4
4
|
// #25 regression: markdown-bearing text fields (descriptions) are rendered raw
|
|
5
5
|
// in compact/clamped surfaces (grid list preview, table row, filter combobox,
|
|
6
|
-
// sub-resources). Link syntax like `[
|
|
6
|
+
// sub-resources). Link syntax like `[Team Chat](/app/team-chat)`
|
|
7
7
|
// leaked as literal text. In clamped previews we strip markdown to its visible
|
|
8
8
|
// text so nothing leaks and `line-clamp` / search highlighting keep working.
|
|
9
9
|
describe('markdownToPlainText', () => {
|
|
10
10
|
it('reduces an internal link to just its text', () => {
|
|
11
|
-
expect(
|
|
12
|
-
|
|
13
|
-
)
|
|
11
|
+
expect(markdownToPlainText('See [Team Chat](/app/team-chat) too')).toBe(
|
|
12
|
+
'See Team Chat too',
|
|
13
|
+
)
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
it('reduces an external link to just its text', () => {
|
|
@@ -22,9 +22,9 @@ describe('markdownToPlainText', () => {
|
|
|
22
22
|
it('handles multiple links in one string', () => {
|
|
23
23
|
expect(
|
|
24
24
|
markdownToPlainText(
|
|
25
|
-
'[
|
|
25
|
+
'[Team Chat](/app/team-chat) and [Data Hub](/app/data-hub)',
|
|
26
26
|
),
|
|
27
|
-
).toBe('
|
|
27
|
+
).toBe('Team Chat and Data Hub')
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
it('strips image syntax to its alt text', () => {
|
|
@@ -39,7 +39,7 @@ describe('getAccessPrerequisiteChain (#38 two-step access)', () => {
|
|
|
39
39
|
it('returns the access-bearing parent for a nested resource', () => {
|
|
40
40
|
const parent = r({
|
|
41
41
|
slug: 'aws-console',
|
|
42
|
-
accessRequest: { approvalMethodSlug: 'it-
|
|
42
|
+
accessRequest: { approvalMethodSlug: 'it-support' },
|
|
43
43
|
})
|
|
44
44
|
const child = r({ slug: 'acct-prod', parentSlug: 'aws-console' })
|
|
45
45
|
const chain = getAccessPrerequisiteChain([parent, child], child)
|
|
@@ -25,8 +25,8 @@ function makeChildResource(
|
|
|
25
25
|
describe('searchResources', () => {
|
|
26
26
|
const apps: Resource[] = [
|
|
27
27
|
makeApp({
|
|
28
|
-
slug: '
|
|
29
|
-
displayName: '
|
|
28
|
+
slug: 'taskflow',
|
|
29
|
+
displayName: 'TaskFlow',
|
|
30
30
|
description: 'Issue tracker',
|
|
31
31
|
}),
|
|
32
32
|
makeApp({
|
|
@@ -34,7 +34,11 @@ describe('searchResources', () => {
|
|
|
34
34
|
displayName: 'AWS Console',
|
|
35
35
|
description: 'Cloud management',
|
|
36
36
|
}),
|
|
37
|
-
makeApp({
|
|
37
|
+
makeApp({
|
|
38
|
+
slug: 'teamchat',
|
|
39
|
+
displayName: 'TeamChat',
|
|
40
|
+
description: 'Messaging',
|
|
41
|
+
}),
|
|
38
42
|
]
|
|
39
43
|
|
|
40
44
|
it('returns all root apps when query is empty', () => {
|
|
@@ -42,16 +46,16 @@ describe('searchResources', () => {
|
|
|
42
46
|
})
|
|
43
47
|
|
|
44
48
|
it('finds app by displayName', () => {
|
|
45
|
-
const results = searchResources(apps, '
|
|
49
|
+
const results = searchResources(apps, 'taskflow')
|
|
46
50
|
expect(results).toHaveLength(1)
|
|
47
|
-
expect(results[0]!.slug).toBe('
|
|
51
|
+
expect(results[0]!.slug).toBe('taskflow')
|
|
48
52
|
})
|
|
49
53
|
|
|
50
54
|
describe('child resource search', () => {
|
|
51
55
|
const childResources: Resource[] = [
|
|
52
56
|
makeChildResource({
|
|
53
57
|
slug: 'aws-acme-pipelines-dev',
|
|
54
|
-
displayName: 'acme-pipelines-
|
|
58
|
+
displayName: 'acme-pipelines-data-analytics-dev',
|
|
55
59
|
parentSlug: 'aws-console',
|
|
56
60
|
aliases: ['000000000001'],
|
|
57
61
|
}),
|
|
@@ -66,7 +70,7 @@ describe('searchResources', () => {
|
|
|
66
70
|
const allResources = [...apps, ...childResources]
|
|
67
71
|
|
|
68
72
|
it('finds app by child resource displayName', () => {
|
|
69
|
-
const results = searchResources(allResources, 'pipelines
|
|
73
|
+
const results = searchResources(allResources, 'pipelines data-analytics')
|
|
70
74
|
expect(results).toHaveLength(1)
|
|
71
75
|
expect(results[0]!.slug).toBe('aws-console')
|
|
72
76
|
})
|
|
@@ -9,7 +9,7 @@ import { getAccessPrerequisiteChain } from '../../utils/resolveHelpers'
|
|
|
9
9
|
* the user must obtain the parent's access FIRST. That chain is invisible
|
|
10
10
|
* otherwise, so surface it explicitly as ordered steps:
|
|
11
11
|
*
|
|
12
|
-
* Step 1 — get access to <parent> (e.g. AWS Console via
|
|
12
|
+
* Step 1 — get access to <parent> (e.g. AWS Console via Support Portal)
|
|
13
13
|
* Step 2 — get access to THIS resource (contact its owner / access contacts)
|
|
14
14
|
*
|
|
15
15
|
* Renders nothing for root resources or children with no access-bearing parent.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppApprovalMethod,
|
|
3
|
+
Resource,
|
|
4
|
+
} from '@igstack/app-catalog-backend-core'
|
|
5
|
+
import { ArrowLeft } from 'lucide-react'
|
|
6
|
+
import { Button } from '~/ui/button'
|
|
7
|
+
import { Badge } from '~/ui/badge'
|
|
8
|
+
import { AccessRequestSection } from './AccessRequestSection'
|
|
9
|
+
|
|
10
|
+
interface SubResourceDetailPanelProps {
|
|
11
|
+
subResource: Resource
|
|
12
|
+
parent: Resource
|
|
13
|
+
approvalMethods: AppApprovalMethod[]
|
|
14
|
+
onBack: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function StepBadge({ step }: { step: number }) {
|
|
18
|
+
return (
|
|
19
|
+
<span className="inline-flex items-center justify-center size-6 rounded-full bg-primary text-primary-foreground text-xs font-semibold shrink-0">
|
|
20
|
+
{step}
|
|
21
|
+
</span>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SubResourceDetailPanel({
|
|
26
|
+
subResource,
|
|
27
|
+
parent,
|
|
28
|
+
approvalMethods,
|
|
29
|
+
onBack,
|
|
30
|
+
}: SubResourceDetailPanelProps) {
|
|
31
|
+
const hasParentAccess = !!parent.accessRequest
|
|
32
|
+
const hasSubAccess = !!subResource.accessRequest
|
|
33
|
+
const hasTwoStepAccess = hasParentAccess && hasSubAccess
|
|
34
|
+
const hasAnyAccess = hasParentAccess || hasSubAccess
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex h-full flex-col p-6">
|
|
38
|
+
{/* Back navigation */}
|
|
39
|
+
<div className="mb-4">
|
|
40
|
+
<Button
|
|
41
|
+
variant="ghost"
|
|
42
|
+
size="sm"
|
|
43
|
+
onClick={onBack}
|
|
44
|
+
className="gap-1.5 text-muted-foreground hover:text-foreground -ml-2"
|
|
45
|
+
aria-label={`Back to ${parent.displayName}`}
|
|
46
|
+
>
|
|
47
|
+
<ArrowLeft className="size-4" />
|
|
48
|
+
<span>{parent.displayName}</span>
|
|
49
|
+
</Button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{/* Sub-resource title */}
|
|
53
|
+
<div className="border-b pb-4">
|
|
54
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
55
|
+
<h2 className="text-xl font-semibold">{subResource.displayName}</h2>
|
|
56
|
+
{subResource.tier && (
|
|
57
|
+
<Badge variant="outline" className="text-xs">
|
|
58
|
+
{subResource.tier}
|
|
59
|
+
</Badge>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
{subResource.description && (
|
|
63
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
64
|
+
{subResource.description}
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
{(subResource.aliases ?? []).length > 0 && (
|
|
68
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
69
|
+
Also known as: {(subResource.aliases ?? []).join(', ')}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Two-step access section */}
|
|
75
|
+
{hasAnyAccess && (
|
|
76
|
+
<div className="mt-6 space-y-6">
|
|
77
|
+
<h3 className="text-sm font-medium">How to get access</h3>
|
|
78
|
+
|
|
79
|
+
{hasTwoStepAccess ? (
|
|
80
|
+
<>
|
|
81
|
+
{/* Step 1: Parent access */}
|
|
82
|
+
<div className="space-y-2">
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
<StepBadge step={1} />
|
|
85
|
+
<span className="text-sm font-medium text-muted-foreground">
|
|
86
|
+
{parent.displayName}
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="pl-8">
|
|
90
|
+
<AccessRequestSection
|
|
91
|
+
app={parent}
|
|
92
|
+
approvalMethods={approvalMethods}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Step 2: Sub-resource access */}
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<StepBadge step={2} />
|
|
101
|
+
<span className="text-sm font-medium text-muted-foreground">
|
|
102
|
+
{subResource.displayName}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="pl-8">
|
|
106
|
+
<AccessRequestSection
|
|
107
|
+
app={subResource}
|
|
108
|
+
approvalMethods={approvalMethods}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
/* Single-step: only one of them has access info */
|
|
115
|
+
<AccessRequestSection
|
|
116
|
+
app={hasParentAccess ? parent : subResource}
|
|
117
|
+
approvalMethods={approvalMethods}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -29,7 +29,7 @@ interface SubResourcesSectionProps {
|
|
|
29
29
|
* Initial filter text (#38 item C). When the user reached this app by
|
|
30
30
|
* searching a term that matched a sub-resource, seed the sub-resource filter
|
|
31
31
|
* with that term so the matched child is revealed instead of buried in a
|
|
32
|
-
* long list (e.g. searching "
|
|
32
|
+
* long list (e.g. searching "data" → open Cloud Console → the matching
|
|
33
33
|
* account is pre-filtered). Only applied when it actually matches a child.
|
|
34
34
|
*/
|
|
35
35
|
initialSearch?: string
|