@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
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { fireEvent, waitFor } from '@testing-library/react'
2
+ import { fireEvent, screen, waitFor } from '@testing-library/react'
3
3
  import '@testing-library/jest-dom/vitest'
4
4
 
5
5
  import { given } from './harness/given'
@@ -18,7 +18,7 @@ describe('App Catalog Integration', () => {
18
18
  it('navigate screenshots then escape back to list view', async () => {
19
19
  const { ui } = await given(magazine.full())
20
20
 
21
- await ui.catalog.openApp('Jira')
21
+ await ui.catalog.openApp('TaskFlow')
22
22
  await ui.app.screenshots.open()
23
23
 
24
24
  await waitFor(() => {
@@ -45,7 +45,7 @@ describe('App Catalog Integration', () => {
45
45
  const approvalMethod = backendCfg.withApprovalMethod({
46
46
  type: 'service',
47
47
  displayName: 'Help Desk',
48
- config: { url: 'https://helpdesk.example.com' },
48
+ config: { url: 'https://support.example.com' },
49
49
  })
50
50
  backendCfg.withApp({
51
51
  displayName: 'My Custom App',
@@ -148,6 +148,36 @@ describe('App Catalog Integration', () => {
148
148
  expect(ui.catalog.isDetailPanelOpen()).toBe(false)
149
149
  })
150
150
 
151
+ // Detail card UX (#45): the detail panel shows a prominent primary "Open" button
152
+ // with the destination URL visible alongside it.
153
+ it('shows a prominent primary Open button with URL in the app detail card', async () => {
154
+ const { ui } = await given(
155
+ magazine.custom(({ backendCfg }) => {
156
+ backendCfg.withApp({
157
+ displayName: 'Website App',
158
+ description: 'Has a website',
159
+ appUrl: 'https://tools.example.com/launch',
160
+ })
161
+ backendCfg.withApp({
162
+ displayName: 'No URL App',
163
+ description: 'No url',
164
+ })
165
+ }),
166
+ )
167
+
168
+ await ui.catalog.openApp('Website App')
169
+ const btn = ui.app.getOpenButton()
170
+ expect(btn).not.toBeNull()
171
+ expect(btn!.getAttribute('href')).toBe('https://tools.example.com/launch')
172
+ expect(btn!.getAttribute('target')).toBe('_blank')
173
+ // The URL is shown in the button (stripped of protocol).
174
+ expect(btn!.textContent).toContain('tools.example.com/launch')
175
+
176
+ // An app without appUrl shows no Open button.
177
+ await ui.catalog.openApp('No URL App')
178
+ expect(ui.app.getOpenButton()).toBeNull()
179
+ })
180
+
151
181
  // Test 5: Returning user — cached data + no onboarding + backend down
152
182
  it('returning user sees cached apps even when backend is unavailable', async () => {
153
183
  suppressConsole([/TRPC Error/, /Failed to fetch/])
@@ -164,8 +194,8 @@ describe('App Catalog Integration', () => {
164
194
 
165
195
  const tableData = ui.catalog.getTableData()
166
196
  expect(tableData.length).toBe(5)
167
- expect(tableData.map((r) => r.name)).toContain('Jira')
168
- expect(tableData.map((r) => r.name)).toContain('Slack')
197
+ expect(tableData.map((r) => r.name)).toContain('TaskFlow')
198
+ expect(tableData.map((r) => r.name)).toContain('TeamChat')
169
199
  })
170
200
 
171
201
  // Test 3: Network Error — Connection Reset
@@ -298,4 +328,233 @@ describe('App Catalog Integration', () => {
298
328
  /(?<![\d])0(?![\d]).*clear filters/i,
299
329
  )
300
330
  })
331
+
332
+ it('highlights matched query text in search result app names (#57)', async () => {
333
+ const { ui } = await given(
334
+ magazine.custom(({ backendCfg }) => {
335
+ backendCfg.withApp({ displayName: 'Kubernetes Platform' })
336
+ backendCfg.withApp({ displayName: 'Other Tool' })
337
+ }),
338
+ )
339
+
340
+ await ui.catalog.search('kube')
341
+
342
+ await waitFor(() => {
343
+ const marks = document.querySelectorAll('mark')
344
+ const markTexts = [...marks].map((m) => String(m.textContent))
345
+ expect(markTexts.some((t) => t.toLowerCase().includes('kube'))).toBe(true)
346
+ })
347
+ })
348
+
349
+ it('clear (×) button appears when search has text and clears on click (#54)', async () => {
350
+ const { ui } = await given(magazine.full())
351
+
352
+ // Before typing: no clear button
353
+ expect(
354
+ document.querySelector('[aria-label="Clear search"]'),
355
+ ).not.toBeInTheDocument()
356
+
357
+ // After typing: clear button appears
358
+ await ui.catalog.search('taskflow')
359
+ await waitFor(() => {
360
+ expect(
361
+ document.querySelector('[aria-label="Clear search"]'),
362
+ ).toBeInTheDocument()
363
+ })
364
+
365
+ // Click × — input clears, button disappears
366
+ fireEvent.click(document.querySelector('[aria-label="Clear search"]')!)
367
+ await waitFor(() => {
368
+ expect(ui.catalog.getSearchInput().value).toBe('')
369
+ expect(
370
+ document.querySelector('[aria-label="Clear search"]'),
371
+ ).not.toBeInTheDocument()
372
+ })
373
+ })
374
+
375
+ it('shows expandable subresource rows under parent in search results', async () => {
376
+ const { ui } = await given(
377
+ magazine.custom(({ backendCfg }) => {
378
+ const app = backendCfg.withApp({
379
+ slug: 'aws-console',
380
+ displayName: 'AWS Console',
381
+ description: 'Cloud management console',
382
+ })
383
+ for (let i = 1; i <= 7; i++) {
384
+ backendCfg.withSubResource({
385
+ appSlug: app.slug,
386
+ slug: `acct-data-${i}`,
387
+ displayName: `Data Account ${i}`,
388
+ })
389
+ }
390
+ }),
391
+ )
392
+ await ui.catalog.search('Data Account')
393
+ await waitFor(() => {
394
+ const subRows = ui.catalog.getSubResourceRows()
395
+ expect(subRows).not.toBeNull()
396
+ expect(subRows!.visible).toBe(5)
397
+ expect(subRows!.total).toBe(7)
398
+ expect(subRows!.hasExpandRow).toBe(true)
399
+ })
400
+ })
401
+
402
+ it('expand "...N more" row reveals all subresources', async () => {
403
+ const { ui } = await given(
404
+ magazine.custom(({ backendCfg }) => {
405
+ const app = backendCfg.withApp({
406
+ slug: 'aws-console',
407
+ displayName: 'AWS Console',
408
+ })
409
+ for (let i = 1; i <= 7; i++) {
410
+ backendCfg.withSubResource({
411
+ appSlug: app.slug,
412
+ slug: `acct-data-${i}`,
413
+ displayName: `Data Account ${i}`,
414
+ })
415
+ }
416
+ }),
417
+ )
418
+ await ui.catalog.search('Data Account')
419
+ await waitFor(() => {
420
+ expect(ui.catalog.getSubResourceRows()).not.toBeNull()
421
+ })
422
+ await ui.catalog.expandSubResources()
423
+ const subRows = ui.catalog.getSubResourceRows()
424
+ expect(subRows!.visible).toBe(7)
425
+ expect(subRows!.hasExpandRow).toBe(false)
426
+ })
427
+
428
+ it('clicking subresource row shows its detail with two-step access', async () => {
429
+ const { ui } = await given(
430
+ magazine.custom(({ backendCfg }) => {
431
+ const parentMethod = backendCfg.withApprovalMethod({
432
+ type: 'service',
433
+ displayName: 'Support Portal',
434
+ config: { url: 'https://support.example.com' },
435
+ })
436
+ const subMethod = backendCfg.withApprovalMethod({
437
+ type: 'custom',
438
+ displayName: 'Account Owner',
439
+ })
440
+ const app = backendCfg.withApp({
441
+ slug: 'aws-console',
442
+ displayName: 'AWS Console',
443
+ accessRequest: {
444
+ approvalMethodSlug: parentMethod.slug,
445
+ comments: 'Submit a support ticket to request access',
446
+ },
447
+ })
448
+ backendCfg.withSubResource({
449
+ appSlug: app.slug,
450
+ slug: 'acct-data-prod',
451
+ displayName: 'Data Account Prod',
452
+ accessRequest: {
453
+ approvalMethodSlug: subMethod.slug,
454
+ comments: 'Contact account owner for account-level permissions',
455
+ },
456
+ })
457
+ }),
458
+ )
459
+ await ui.catalog.search('Data Account')
460
+ await waitFor(() => {
461
+ expect(ui.catalog.getSubResourceRows()).not.toBeNull()
462
+ })
463
+ await ui.catalog.clickSubResource('Data Account Prod')
464
+ await waitFor(() => {
465
+ expect(ui.app.getSubResourceDetail()).not.toBeNull()
466
+ })
467
+ const detail = ui.app.getSubResourceDetail()
468
+ expect(detail).not.toBeNull()
469
+ expect(detail!.subResourceName).toBe('Data Account Prod')
470
+ expect(detail!.hasStep1).toBe(true)
471
+ expect(detail!.hasStep2).toBe(true)
472
+ expect(detail!.backButtonLabel).toBe('Back to AWS Console')
473
+ })
474
+
475
+ it('back button from subresource detail returns to parent view', async () => {
476
+ const { ui } = await given(
477
+ magazine.custom(({ backendCfg }) => {
478
+ const method = backendCfg.withApprovalMethod({
479
+ type: 'service',
480
+ displayName: 'Support Portal',
481
+ config: { url: 'https://support.example.com' },
482
+ })
483
+ const app = backendCfg.withApp({
484
+ slug: 'aws-console',
485
+ displayName: 'AWS Console',
486
+ accessRequest: { approvalMethodSlug: method.slug },
487
+ })
488
+ backendCfg.withSubResource({
489
+ appSlug: app.slug,
490
+ slug: 'acct-data-prod',
491
+ displayName: 'Data Account Prod',
492
+ })
493
+ }),
494
+ )
495
+ await ui.catalog.search('Data Account')
496
+ await waitFor(() => {
497
+ expect(ui.catalog.getSubResourceRows()).not.toBeNull()
498
+ })
499
+ await ui.catalog.clickSubResource('Data Account Prod')
500
+ await waitFor(() => {
501
+ expect(ui.app.getSubResourceDetail()).not.toBeNull()
502
+ })
503
+ await ui.app.clickBackToParent()
504
+ // After going back, the sub detail should be gone and parent detail shown
505
+ expect(ui.app.getSubResourceDetail()).toBeNull()
506
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
507
+ })
508
+
509
+ it('shows Step 1 / Step 2 badges for two-step access apps (#56)', async () => {
510
+ const { ui } = await given(
511
+ magazine.custom(({ backendCfg }) => {
512
+ const method = backendCfg.withApprovalMethod({
513
+ type: 'service',
514
+ displayName: 'Natero Bot',
515
+ config: { url: 'https://natero.example.com' },
516
+ })
517
+ backendCfg.withApp({
518
+ displayName: 'Two-Step App',
519
+ accessRequest: {
520
+ approvalMethodSlug: method.slug,
521
+ requestPrompt: 'Give me access to Two-Step App',
522
+ postApprovalInstructions: 'Contact the owner to complete setup.',
523
+ },
524
+ })
525
+ backendCfg.withApp({ displayName: 'Other App' })
526
+ }),
527
+ )
528
+ await ui.catalog.openApp('Two-Step App')
529
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
530
+ expect(screen.getByText('Step 1')).toBeInTheDocument()
531
+ expect(screen.getByText('Step 2')).toBeInTheDocument()
532
+ expect(
533
+ screen.getByText('Contact the owner to complete setup.'),
534
+ ).toBeInTheDocument()
535
+ })
536
+
537
+ it('hides Step labels for single-step apps (#56)', async () => {
538
+ const { ui } = await given(
539
+ magazine.custom(({ backendCfg }) => {
540
+ const method = backendCfg.withApprovalMethod({
541
+ type: 'service',
542
+ displayName: 'Natero Bot',
543
+ config: { url: 'https://natero.example.com' },
544
+ })
545
+ backendCfg.withApp({
546
+ displayName: 'Single-Step App',
547
+ accessRequest: {
548
+ approvalMethodSlug: method.slug,
549
+ requestPrompt: 'Give me access',
550
+ },
551
+ })
552
+ backendCfg.withApp({ displayName: 'Other App' })
553
+ }),
554
+ )
555
+ await ui.catalog.openApp('Single-Step App')
556
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
557
+ expect(screen.queryByText('Step 1')).not.toBeInTheDocument()
558
+ expect(screen.queryByText('Step 2')).not.toBeInTheDocument()
559
+ })
301
560
  })
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { fireEvent, waitFor } from '@testing-library/react'
2
+ import { fireEvent, screen, waitFor } from '@testing-library/react'
3
3
  import '@testing-library/jest-dom/vitest'
4
4
 
5
5
  import { given } from './harness/given'
@@ -7,7 +7,9 @@ import { magazine } from './mock-backend/magazines'
7
7
 
8
8
  describe('App deep-link routing', () => {
9
9
  it('opens the app detail when navigating directly to /app/<slug>', async () => {
10
- const { ui } = await given(magazine.full(), { initialRoute: '/app/jira' })
10
+ const { ui } = await given(magazine.full(), {
11
+ initialRoute: '/app/taskflow',
12
+ })
11
13
  await waitFor(() => {
12
14
  expect(ui.catalog.isDetailPanelOpen()).toBe(true)
13
15
  })
@@ -16,9 +18,9 @@ describe('App deep-link routing', () => {
16
18
 
17
19
  it('navigates to /app/<slug> when selecting an app, and pushes history', async () => {
18
20
  const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
19
- await ui.catalog.openApp('Jira')
21
+ await ui.catalog.openApp('TaskFlow')
20
22
  await waitFor(() => {
21
- expect(router.state.location.pathname).toBe('/app/jira')
23
+ expect(router.state.location.pathname).toBe('/app/taskflow')
22
24
  })
23
25
  // push (not replace): catalog '/' is still in history, Back returns to it
24
26
  expect(router.history.canGoBack()).toBe(true)
@@ -36,9 +38,32 @@ describe('App deep-link routing', () => {
36
38
 
37
39
  it('returns to / when the detail panel is closed', async () => {
38
40
  const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
39
- await ui.catalog.openApp('Jira')
41
+ await ui.catalog.openApp('TaskFlow')
42
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
43
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
44
+ await waitFor(() => {
45
+ expect(ui.catalog.isDetailPanelOpen()).toBe(false)
46
+ })
47
+ expect(router.state.location.pathname).toBe('/')
48
+ })
49
+
50
+ it('Esc closes card opened by mouse click (#53 bug 1)', async () => {
51
+ const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
52
+ // Open via click (mouse, not keyboard nav)
53
+ await ui.catalog.openApp('TaskFlow')
40
54
  await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
55
+ // Esc should close — no "dead click" needed
41
56
  fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
57
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(false))
58
+ expect(router.state.location.pathname).toBe('/')
59
+ })
60
+
61
+ it('closes the detail panel when the × close button is clicked (#59)', async () => {
62
+ const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
63
+ await ui.catalog.openApp('TaskFlow')
64
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
65
+ const closeBtn = screen.getByRole('button', { name: /^Close$/i })
66
+ fireEvent.click(closeBtn)
42
67
  await waitFor(() => {
43
68
  expect(ui.catalog.isDetailPanelOpen()).toBe(false)
44
69
  })
@@ -46,7 +71,9 @@ describe('App deep-link routing', () => {
46
71
  })
47
72
 
48
73
  it('ignores legacy ?app= query param (no detail opens)', async () => {
49
- const { ui } = await given(magazine.full(), { initialRoute: '/?app=jira' })
74
+ const { ui } = await given(magazine.full(), {
75
+ initialRoute: '/?app=taskflow',
76
+ })
50
77
  await waitFor(() => {
51
78
  expect(ui.catalog.getTableData().length).toBeGreaterThan(0)
52
79
  })
@@ -64,7 +91,7 @@ describe('App deep-link routing', () => {
64
91
  // jsdom userEvent.type + useDeferredValue drops characters, orthogonal here.
65
92
  const { ui, router } = await given(magazine.full(), {
66
93
  initialRoute: '/',
67
- seedSearch: 'jira',
94
+ seedSearch: 'taskflow',
68
95
  })
69
96
 
70
97
  // Stays on the launcher home — NO auto-navigation to the detail route.
@@ -72,33 +99,33 @@ describe('App deep-link routing', () => {
72
99
  expect(ui.catalog.getTableData().length).toBeGreaterThan(0)
73
100
  })
74
101
  expect(router.state.location.pathname).toBe('/')
75
- expect(ui.catalog.getTableData().map((r) => r.name)).toContain('Jira')
102
+ expect(ui.catalog.getTableData().map((r) => r.name)).toContain('TaskFlow')
76
103
  // Search text preserved (sessionStorage-backed), no ?q leak.
77
- expect(ui.catalog.getSearchInput().value).toBe('jira')
104
+ expect(ui.catalog.getSearchInput().value).toBe('taskflow')
78
105
  expect((router.state.location.search as { q?: string }).q).toBeUndefined()
79
106
 
80
107
  // Opting in (click) opens the detail.
81
- await ui.catalog.openApp('Jira')
108
+ await ui.catalog.openApp('TaskFlow')
82
109
  await waitFor(() => {
83
- expect(router.state.location.pathname).toBe('/app/jira')
110
+ expect(router.state.location.pathname).toBe('/app/taskflow')
84
111
  })
85
112
  })
86
113
 
87
114
  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
115
+ // Old shared links may still carry `?q=` (e.g. /app/taskflow?q=taskflow). `q` is
89
116
  // still declared in the route search schema (so it validates), but the
90
117
  // 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
118
+ // /app/taskflow on load. We no longer read `q` from the URL, so a bare deep-link
92
119
  // doesn't repopulate the input from the query string.
93
120
  const { ui, router } = await given(magazine.full(), {
94
- initialRoute: '/app/jira?q=jira',
121
+ initialRoute: '/app/taskflow?q=taskflow',
95
122
  })
96
123
 
97
124
  await waitFor(() => {
98
125
  expect(ui.catalog.isDetailPanelOpen()).toBe(true)
99
126
  })
100
- expect(router.state.location.pathname).toBe('/app/jira')
101
- expect(router.state.location.href).toBe('/app/jira')
127
+ expect(router.state.location.pathname).toBe('/app/taskflow')
128
+ expect(router.state.location.href).toBe('/app/taskflow')
102
129
  expect((router.state.location.search as { q?: string }).q).toBeUndefined()
103
130
  })
104
131
 
@@ -131,14 +158,14 @@ describe('App deep-link routing', () => {
131
158
  // store as a returning user would have (typed a search, then navigated), and
132
159
  // assert the detail route restores it with no `q` in the URL.
133
160
  const { ui, router } = await given(magazine.full(), {
134
- initialRoute: '/app/jira',
135
- seedSearch: 'jira',
161
+ initialRoute: '/app/taskflow',
162
+ seedSearch: 'taskflow',
136
163
  })
137
164
 
138
165
  await waitFor(() => {
139
166
  expect(ui.catalog.isDetailPanelOpen()).toBe(true)
140
167
  })
141
- expect(ui.catalog.getSearchInput().value).toBe('jira')
168
+ expect(ui.catalog.getSearchInput().value).toBe('taskflow')
142
169
  expect((router.state.location.search as { q?: string }).q).toBeUndefined()
143
170
  })
144
171
  })
@@ -5,8 +5,9 @@ import '@testing-library/jest-dom/vitest'
5
5
  import { given } from './harness/given'
6
6
  import { magazine } from './mock-backend/magazines'
7
7
 
8
- // The backend owns the freshness semantic and serves { lastCheckedAt, isStale }.
9
- // These tests only assert the frontend renders that semantic — no date math here.
8
+ // The backend owns the freshness semantic and serves
9
+ // { lastCheckedAt, lastContentChangeAt, isStale }. These tests only assert the
10
+ // frontend renders that semantic — no date math here.
10
11
  const DAY = 24 * 60 * 60 * 1000
11
12
  const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString()
12
13
 
@@ -17,7 +18,11 @@ describe('App freshness line', () => {
17
18
  backendCfg.withApp({
18
19
  slug: 'fresh-app',
19
20
  displayName: 'Fresh App',
20
- freshness: { lastCheckedAt: iso(2 * DAY), isStale: false },
21
+ freshness: {
22
+ lastCheckedAt: iso(2 * DAY),
23
+ lastContentChangeAt: null,
24
+ isStale: false,
25
+ },
21
26
  })
22
27
  }),
23
28
  { initialRoute: '/app/fresh-app' },
@@ -35,7 +40,11 @@ describe('App freshness line', () => {
35
40
  backendCfg.withApp({
36
41
  slug: 'stale-app',
37
42
  displayName: 'Stale App',
38
- freshness: { lastCheckedAt: iso(30 * DAY), isStale: true },
43
+ freshness: {
44
+ lastCheckedAt: iso(30 * DAY),
45
+ lastContentChangeAt: null,
46
+ isStale: true,
47
+ },
39
48
  })
40
49
  }),
41
50
  { initialRoute: '/app/stale-app' },
@@ -45,6 +54,67 @@ describe('App freshness line', () => {
45
54
  expect(screen.getByText(/may be out of date/i)).toBeInTheDocument()
46
55
  })
47
56
 
57
+ it('shows "Added" date before Sources when createdAt is present (#55)', async () => {
58
+ const { ui } = await given(
59
+ magazine.full(({ backendCfg }) => {
60
+ backendCfg.withApp({
61
+ slug: 'dated-app',
62
+ displayName: 'Dated App',
63
+ createdAt: iso(30 * DAY),
64
+ })
65
+ }),
66
+ { initialRoute: '/app/dated-app' },
67
+ )
68
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
69
+ expect(screen.getByText(/Added/i)).toBeInTheDocument()
70
+ })
71
+
72
+ it('shows the last CONTENT change, not the last check', async () => {
73
+ // The reported symptom: an app read 22h ago showed "Updated 22 hours ago"
74
+ // while its data had not changed in months. "Updated" must mean changed.
75
+ const { ui } = await given(
76
+ magazine.full(({ backendCfg }) => {
77
+ backendCfg.withApp({
78
+ slug: 'checked-often-app',
79
+ displayName: 'Checked Often App',
80
+ freshness: {
81
+ lastCheckedAt: iso(DAY / 24), // read an hour ago
82
+ lastContentChangeAt: iso(120 * DAY), // unchanged for months
83
+ isStale: false,
84
+ },
85
+ })
86
+ }),
87
+ { initialRoute: '/app/checked-often-app' },
88
+ )
89
+
90
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
91
+ const line = screen.getByText(/Updated/i)
92
+ expect(line).toHaveTextContent(/months ago/i)
93
+ expect(line).not.toHaveTextContent(/hour/i)
94
+ })
95
+
96
+ it('falls back to the last check for entries with no recorded change', async () => {
97
+ // Rows scanned before lastContentChangeAt existed: better to show the check
98
+ // date than to drop the line entirely.
99
+ const { ui } = await given(
100
+ magazine.full(({ backendCfg }) => {
101
+ backendCfg.withApp({
102
+ slug: 'unchanged-app',
103
+ displayName: 'Unchanged App',
104
+ freshness: {
105
+ lastCheckedAt: iso(3 * DAY),
106
+ lastContentChangeAt: null,
107
+ isStale: false,
108
+ },
109
+ })
110
+ }),
111
+ { initialRoute: '/app/unchanged-app' },
112
+ )
113
+
114
+ await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
115
+ expect(screen.getByText(/Updated/i)).toHaveTextContent(/3 days ago/i)
116
+ })
117
+
48
118
  it('shows no freshness line when the app was never scanned', async () => {
49
119
  const { ui } = await given(
50
120
  magazine.full(({ backendCfg }) => {
@@ -16,7 +16,7 @@ describe('Cross-reference links between app entries (#25)', () => {
16
16
  slug: 'portals-hub',
17
17
  displayName: 'Portals Hub',
18
18
  description:
19
- 'See the [Jira](/app/jira) entry and the external [docs](https://example.com/docs). Also [Ghost](/app/no-such-app) which does not exist.',
19
+ 'See the [TaskFlow](/app/taskflow) entry and the external [docs](https://example.com/docs). Also [Ghost](/app/no-such-app) which does not exist.',
20
20
  })
21
21
  })
22
22
 
@@ -28,22 +28,22 @@ describe('Cross-reference links between app entries (#25)', () => {
28
28
  expect(ui.catalog.isDetailPanelOpen()).toBe(true)
29
29
  })
30
30
 
31
- const jiraLink = screen.getByRole('link', { name: 'Jira' })
31
+ const taskflowLink = screen.getByRole('link', { name: 'TaskFlow' })
32
32
  // Internal link must NOT open a new tab.
33
- expect(jiraLink).not.toHaveAttribute('target', '_blank')
33
+ expect(taskflowLink).not.toHaveAttribute('target', '_blank')
34
34
  // It points at the router path (rendered as an href).
35
- expect(jiraLink).toHaveAttribute('href', '/app/jira')
35
+ expect(taskflowLink).toHaveAttribute('href', '/app/taskflow')
36
36
 
37
37
  const user = userEvent.setup()
38
- await user.click(jiraLink)
38
+ await user.click(taskflowLink)
39
39
 
40
- // In-app navigation: the router path changes and Jira's detail opens,
40
+ // In-app navigation: the router path changes and TaskFlow's detail opens,
41
41
  // without a full page reload / new tab.
42
42
  await waitFor(() => {
43
- expect(router.state.location.pathname).toBe('/app/jira')
43
+ expect(router.state.location.pathname).toBe('/app/taskflow')
44
44
  })
45
45
  await waitFor(() => {
46
- expect(ui.app.getOpenTitle()).toContain('Jira')
46
+ expect(ui.app.getOpenTitle()).toContain('TaskFlow')
47
47
  })
48
48
  })
49
49
 
@@ -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
- // `[Signatera Portal](/app/signatera-portal)`. The detail panel renders them
8
+ // `[GenomicsApp](/app/genomics-app)`. 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: 'natera-portals-ish',
16
+ slug: 'internal-portals',
17
17
  displayName: 'Portals Hub',
18
18
  description:
19
- 'See [Signatera Portal](/app/signatera-portal) and [Prospera Portal](/app/prospera-portal).',
19
+ 'See [GenomicsApp](/app/genomics-app) and [DataApp](/app/data-app).',
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('Signatera Portal')
34
- expect(row!.description).toContain('Prospera Portal')
33
+ expect(row!.description).toContain('GenomicsApp')
34
+ expect(row!.description).toContain('DataApp')
35
35
  // …and NEVER leaks the raw markdown link syntax.
36
36
  expect(row!.description).not.toContain('](/app/')
37
- expect(row!.description).not.toContain('[Signatera Portal]')
37
+ expect(row!.description).not.toContain('[GenomicsApp]')
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 Signatera Portal and/)).toBeInTheDocument()
43
+ expect(screen.getByText(/See GenomicsApp 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: 'Signatera Portal' })).toBeNull()
47
+ expect(screen.queryByRole('link', { name: 'GenomicsApp' })).toBeNull()
48
48
  })
49
49
  })
@@ -79,6 +79,8 @@ export class MockBackendConfigurer {
79
79
  parentSlug: appSlug,
80
80
  tier: overrides.tier,
81
81
  ownerPersonSlug: overrides.ownerPersonSlug,
82
+ accessRequest: overrides.accessRequest,
83
+ description: overrides.description,
82
84
  }
83
85
  this.db.upsertResource(resource)
84
86
  return resource