@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.
Files changed (56) hide show
  1. package/dist/esm/__tests__/integration/mock-backend/magazines.d.ts +1 -1
  2. package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +16 -0
  3. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +12 -0
  4. package/dist/esm/api/infra/trpc.d.ts +24 -0
  5. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +5 -2
  6. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +1 -1
  7. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +9 -0
  9. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +90 -0
  10. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -0
  11. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +1 -1
  12. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  13. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +7 -1
  14. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +114 -34
  15. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +8 -8
  17. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +31 -4
  19. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  20. package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.js +41 -20
  21. package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
  23. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
  24. package/dist/esm/routeTree.gen.d.ts +2 -0
  25. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -0
  26. package/dist/esm/routes/_layout/app._slug.js +2 -1
  27. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  28. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +1 -0
  29. package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
  30. package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
  31. package/package.json +5 -7
  32. package/src/__tests__/integration/appCatalog.integration.test.ts +264 -5
  33. package/src/__tests__/integration/appDeepLink.integration.test.ts +46 -19
  34. package/src/__tests__/integration/appFreshness.integration.test.ts +74 -4
  35. package/src/__tests__/integration/crossRefLink.integration.test.ts +8 -8
  36. package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
  37. package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +2 -0
  38. package/src/__tests__/integration/mock-backend/magazines.ts +24 -24
  39. package/src/__tests__/integration/replacementLink.integration.test.ts +2 -2
  40. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +2 -2
  41. package/src/__tests__/integration/serviceDesks.integration.test.ts +31 -24
  42. package/src/__tests__/integration/tools/AppDetailTools.ts +57 -0
  43. package/src/__tests__/integration/tools/CatalogTools.ts +77 -4
  44. package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +6 -6
  45. package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +1 -1
  46. package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +11 -7
  47. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +1 -1
  48. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +124 -0
  49. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +1 -1
  50. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +140 -36
  51. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +12 -8
  52. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +49 -8
  53. package/src/modules/appCatalog/ui/pages/ServiceDesksPage.tsx +37 -9
  54. package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
  55. package/src/routes/_layout/app.$slug.tsx +1 -0
  56. 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 helpdesk = backendCfg.withApprovalMethod({
36
- slug: 'it-helpdesk',
35
+ const supportPortal = backendCfg.withApprovalMethod({
36
+ slug: 'it-support',
37
37
  type: 'service',
38
- displayName: 'IT Help Desk',
38
+ displayName: 'Support Portal',
39
39
  config: {
40
- url: 'https://helpdesk.example.com',
41
- description: 'IT Infrastructure support desk',
40
+ url: 'https://support.example.com',
41
+ description: 'General IT support desk',
42
42
  },
43
43
  })
44
44
  backendCfg.withApprovalMethod({
45
- slug: 'ops-helpdesk',
45
+ slug: 'ops-support',
46
46
  type: 'service',
47
- displayName: 'Ops Helpdesk',
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: 'jira',
95
- displayName: 'Jira',
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-jira-1', 'ss-jira-2', 'ss-jira-3'],
99
- appUrl: 'https://jira.example.com',
98
+ screenshotIds: ['ss-taskflow-1', 'ss-taskflow-2', 'ss-taskflow-3'],
99
+ appUrl: 'https://taskflow.example.com',
100
100
  accessRequest: {
101
- approvalMethodSlug: helpdesk.slug,
101
+ approvalMethodSlug: supportPortal.slug,
102
102
  comments: 'Submit a ticket',
103
103
  },
104
104
  })
105
105
 
106
106
  backendCfg.withApp({
107
- slug: 'slack',
108
- displayName: 'Slack',
107
+ slug: 'teamchat',
108
+ displayName: 'TeamChat',
109
109
  description: 'Team messaging and collaboration',
110
110
  tags: ['category:communication', 'team:platform'],
111
- screenshotIds: ['ss-slack-1'],
112
- appUrl: 'https://slack.example.com',
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-helpdesk',
186
+ slug: 'it-support',
187
187
  type: 'service',
188
- displayName: 'IT Help Desk',
189
- config: { url: 'https://helpdesk.example.com' },
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: 'jira',
207
- displayName: 'Jira',
206
+ slug: 'taskflow',
207
+ displayName: 'TaskFlow',
208
208
  description: 'Project tracking and issue management',
209
209
  tags: ['category:project-management'],
210
- screenshotIds: ['screenshot-jira-1', 'screenshot-jira-2'],
211
- appUrl: 'https://jira.example.com',
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 (Jira), 1 tag, 1 approval method — accepts optional post-configurer */
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/jira',
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('Jira')
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: 'Jira',
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('Jira')
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 (IT Help Desk, Ops Helpdesk) and
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('IT Help Desk')
41
- expect(names).toContain('Ops Helpdesk')
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 a link to its portal opening in a new tab.
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('IT Help Desk'))!
50
- const link = within(itRow).getByRole('link')
51
- expect(link).toHaveAttribute('href', 'https://helpdesk.example.com')
52
- expect(link).toHaveAttribute('target', '_blank')
53
- // The link shows the full URL without the scheme, not generic "Open" text.
54
- expect(link).toHaveTextContent('helpdesk.example.com')
55
- expect(link).not.toHaveTextContent('Open')
56
- expect(link).not.toHaveTextContent('https://')
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
- // IT Help Desk has a description; it renders in the same name cell as subtext.
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('IT Help Desk'))!
77
+ .find((r) => r.textContent.includes('Support Portal'))!
72
78
  const nameCell = within(itRow).getAllByRole('cell')[0]
73
- expect(nameCell).toHaveTextContent('IT Infrastructure support desk')
79
+ expect(nameCell).toHaveTextContent('General IT support desk')
74
80
 
75
- // Ops Helpdesk has no description — its name cell shows only the name.
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 Helpdesk'))!
79
- const uxNameCell = within(uxRow).getAllByRole('cell')[0]!
80
- expect(uxNameCell.textContent.trim()).toBe('Ops Helpdesk')
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 Helpdesk'))
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 Helpdesk')
96
- expect(names).not.toContain('IT Help Desk')
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/quicksight highlighted
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/jira' })
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 = this.getSearchInput()
49
- await this.user.clear(input)
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 `[Signatera Portal](/app/signatera-portal)`
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
- markdownToPlainText('See [Signatera Portal](/app/signatera-portal) too'),
13
- ).toBe('See Signatera Portal too')
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
- '[Signatera Portal](/app/signatera-portal) and [Prospera Portal](/app/prospera-portal)',
25
+ '[Team Chat](/app/team-chat) and [Data Hub](/app/data-hub)',
26
26
  ),
27
- ).toBe('Signatera Portal and Prospera Portal')
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-helpdesk' },
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: 'jira',
29
- displayName: 'Jira',
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({ slug: 'slack', displayName: 'Slack', description: 'Messaging' }),
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, 'jira')
49
+ const results = searchResources(apps, 'taskflow')
46
50
  expect(results).toHaveLength(1)
47
- expect(results[0]!.slug).toBe('jira')
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-biomarkers-ici-dev',
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 biomarkers')
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 IT Helpdesk)
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 "biom" → open AWS Console → the biomarker
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