@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
|
@@ -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('
|
|
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://
|
|
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('
|
|
168
|
-
expect(tableData.map((r) => r.name)).toContain('
|
|
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(), {
|
|
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('
|
|
21
|
+
await ui.catalog.openApp('TaskFlow')
|
|
20
22
|
await waitFor(() => {
|
|
21
|
-
expect(router.state.location.pathname).toBe('/app/
|
|
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('
|
|
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(), {
|
|
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: '
|
|
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('
|
|
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('
|
|
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('
|
|
108
|
+
await ui.catalog.openApp('TaskFlow')
|
|
82
109
|
await waitFor(() => {
|
|
83
|
-
expect(router.state.location.pathname).toBe('/app/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
101
|
-
expect(router.state.location.href).toBe('/app/
|
|
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/
|
|
135
|
-
seedSearch: '
|
|
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('
|
|
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
|
|
9
|
-
// These tests only assert the
|
|
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: {
|
|
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: {
|
|
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 [
|
|
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
|
|
31
|
+
const taskflowLink = screen.getByRole('link', { name: 'TaskFlow' })
|
|
32
32
|
// Internal link must NOT open a new tab.
|
|
33
|
-
expect(
|
|
33
|
+
expect(taskflowLink).not.toHaveAttribute('target', '_blank')
|
|
34
34
|
// It points at the router path (rendered as an href).
|
|
35
|
-
expect(
|
|
35
|
+
expect(taskflowLink).toHaveAttribute('href', '/app/taskflow')
|
|
36
36
|
|
|
37
37
|
const user = userEvent.setup()
|
|
38
|
-
await user.click(
|
|
38
|
+
await user.click(taskflowLink)
|
|
39
39
|
|
|
40
|
-
// In-app navigation: the router path changes and
|
|
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/
|
|
43
|
+
expect(router.state.location.pathname).toBe('/app/taskflow')
|
|
44
44
|
})
|
|
45
45
|
await waitFor(() => {
|
|
46
|
-
expect(ui.app.getOpenTitle()).toContain('
|
|
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
|
-
// `[
|
|
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: '
|
|
16
|
+
slug: 'internal-portals',
|
|
17
17
|
displayName: 'Portals Hub',
|
|
18
18
|
description:
|
|
19
|
-
'See [
|
|
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('
|
|
34
|
-
expect(row!.description).toContain('
|
|
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('[
|
|
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
|
|
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: '
|
|
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
|