@igstack/app-catalog-frontend-core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/esm/__tests__/integration/harness/given.d.ts +5 -1
  2. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +1 -1
  3. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +1 -13
  4. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  5. package/dist/esm/modules/appCatalog/ui/components/MarkdownText.d.ts +18 -0
  6. package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js +26 -0
  7. package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js.map +1 -0
  8. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +1 -1
  9. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +1 -1
  10. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +1 -1
  11. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +3 -1
  12. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +5 -2
  13. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.d.ts +3 -1
  15. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +17 -12
  16. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  17. package/dist/esm/routeTree.gen.d.ts +18 -4
  18. package/dist/esm/routeTree.gen.js +11 -4
  19. package/dist/esm/routeTree.gen.js.map +1 -1
  20. package/dist/esm/routes/_layout/app.$slug.d.ts +3 -0
  21. package/dist/esm/routes/_layout/app._slug.js +21 -0
  22. package/dist/esm/routes/_layout/app._slug.js.map +1 -0
  23. package/dist/esm/routes/_layout/index.d.ts +0 -1
  24. package/dist/esm/routes/_layout/index.js +0 -1
  25. package/dist/esm/routes/_layout/index.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/__tests__/integration/appDeepLink.integration.test.ts +55 -0
  28. package/src/__tests__/integration/harness/given.tsx +7 -3
  29. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +1 -18
  30. package/src/modules/appCatalog/ui/components/MarkdownText.tsx +42 -0
  31. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +10 -3
  32. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +18 -15
  33. package/src/routeTree.gen.ts +21 -2
  34. package/src/routes/_layout/app.$slug.tsx +23 -0
  35. package/src/routes/_layout/index.tsx +0 -1
  36. package/dist/esm/__tests__/modules/gallery/EscapeDismissal.integration.test.d.ts +0 -0
  37. package/dist/esm/modules/appCatalog/ui/components/AppDetailModal.d.ts +0 -7
  38. package/src/__tests__/modules/appCatalog/ScreenshotPreview.test.tsx +0 -63
  39. package/src/__tests__/modules/gallery/EscapeDismissal.integration.test.tsx +0 -118
  40. package/src/modules/appCatalog/ui/components/AppDetailModal.tsx +0 -365
  41. /package/dist/esm/__tests__/{modules/appCatalog/ScreenshotPreview.test.d.ts → integration/appDeepLink.integration.test.d.ts} +0 -0
@@ -1,4 +1,5 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { useNavigate } from '@tanstack/react-router'
2
3
  import { X } from 'lucide-react'
3
4
  import { useDeferredValue, useEffect, useMemo, useState } from 'react'
4
5
  import { Button } from '~/ui/button'
@@ -13,25 +14,22 @@ import {
13
14
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
14
15
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
15
16
  import { useAppCounts } from '../../hooks/useAppCounts'
16
- import { useUrlSyncedState } from '../../hooks/useUrlSyncedState'
17
17
  import { searchResources } from '../../utils/searchApps'
18
18
  import { OnboardingCard } from '../components/OnboardingCard'
19
19
  import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
20
20
  import { FilterBar } from '../filters/FilterBar'
21
21
  import { AppCatalogGrid } from '../grid/AppCatalogGrid'
22
22
 
23
- export function AppCatalogPage() {
23
+ export function AppCatalogPage({
24
+ selectedSlug,
25
+ }: { selectedSlug?: string } = {}) {
24
26
  const { resources, isLoadingApps, tagsDefinitions } = useAppCatalogContext()
25
27
  const { state: filterState, actions } = useAppCatalogFilters()
26
28
  const { getTopApps } = useAppClickHistory()
29
+ const navigate = useNavigate()
27
30
 
28
- // URL-synced state
29
- const [selectedAppSlug, setSelectedAppSlug] = useUrlSyncedState<
30
- string | undefined
31
- >({
32
- key: 'app',
33
- defaultValue: undefined,
34
- })
31
+ // Selected app comes from the route path (/app/<slug>); undefined = nothing open
32
+ const selectedAppSlug = selectedSlug
35
33
 
36
34
  // Search value from context (URL-synced in AppCatalogFiltersContext)
37
35
  const searchValue = filterState.searchValue
@@ -108,19 +106,23 @@ export function AppCatalogPage() {
108
106
 
109
107
  // Auto-open details when only 1 result
110
108
  useEffect(() => {
111
- if (filteredApps.length === 1 && filteredApps[0]) {
112
- setSelectedAppSlug(filteredApps[0].slug)
109
+ if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {
110
+ void navigate({
111
+ to: '/app/$slug',
112
+ params: { slug: filteredApps[0].slug },
113
+ replace: true,
114
+ })
113
115
  }
114
- }, [filteredApps, setSelectedAppSlug])
116
+ }, [filteredApps, selectedAppSlug, navigate])
115
117
 
116
118
  const handleAppClick = (app: Resource) => {
117
- setSelectedAppSlug(app.slug)
119
+ void navigate({ to: '/app/$slug', params: { slug: app.slug } })
118
120
  }
119
121
 
120
122
  const handleClearFilters = () => {
121
123
  setSearchValue('')
122
124
  actions.clearAllFilters()
123
- setSelectedAppSlug(undefined)
125
+ void navigate({ to: '/' })
124
126
  }
125
127
 
126
128
  // Calculate total apps count (respecting showDeprecated setting)
@@ -174,7 +176,7 @@ export function AppCatalogPage() {
174
176
  variant="outline"
175
177
  onClick={() => {
176
178
  setSearchValue('')
177
- setSelectedAppSlug(undefined)
179
+ void navigate({ to: '/' })
178
180
  }}
179
181
  className="gap-2"
180
182
  >
@@ -190,6 +192,7 @@ export function AppCatalogPage() {
190
192
  selectedAppSlug={selectedAppSlug}
191
193
  groupingDefinition={groupingDefinition}
192
194
  onAppClick={handleAppClick}
195
+ onClosePanel={() => void navigate({ to: '/' })}
193
196
  hasSearch={!!deferredSearchValue}
194
197
  searchQuery={deferredSearchValue}
195
198
  totalAppsCount={totalAppsCount}
@@ -13,6 +13,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
13
13
  import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
14
14
  import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
15
15
  import { Route as LayoutLoginRouteImport } from './routes/_layout/login'
16
+ import { Route as LayoutAppSlugRouteImport } from './routes/_layout/app.$slug'
16
17
  import { Route as LayoutCatalogAppsIndexRouteImport } from './routes/_layout/catalog.apps.index'
17
18
 
18
19
  const LayoutRoute = LayoutRouteImport.update({
@@ -34,6 +35,11 @@ const LayoutLoginRoute = LayoutLoginRouteImport.update({
34
35
  path: '/login',
35
36
  getParentRoute: () => LayoutRoute,
36
37
  } as any)
38
+ const LayoutAppSlugRoute = LayoutAppSlugRouteImport.update({
39
+ id: '/app/$slug',
40
+ path: '/app/$slug',
41
+ getParentRoute: () => LayoutRoute,
42
+ } as any)
37
43
  const LayoutCatalogAppsIndexRoute = LayoutCatalogAppsIndexRouteImport.update({
38
44
  id: '/catalog/apps/',
39
45
  path: '/catalog/apps/',
@@ -44,12 +50,14 @@ export interface FileRoutesByFullPath {
44
50
  '/': typeof LayoutIndexRoute
45
51
  '/login': typeof LayoutLoginRoute
46
52
  '/auth/callback': typeof AuthCallbackRoute
53
+ '/app/$slug': typeof LayoutAppSlugRoute
47
54
  '/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
48
55
  }
49
56
  export interface FileRoutesByTo {
50
57
  '/login': typeof LayoutLoginRoute
51
58
  '/auth/callback': typeof AuthCallbackRoute
52
59
  '/': typeof LayoutIndexRoute
60
+ '/app/$slug': typeof LayoutAppSlugRoute
53
61
  '/catalog/apps': typeof LayoutCatalogAppsIndexRoute
54
62
  }
55
63
  export interface FileRoutesById {
@@ -58,19 +66,21 @@ export interface FileRoutesById {
58
66
  '/_layout/login': typeof LayoutLoginRoute
59
67
  '/auth/callback': typeof AuthCallbackRoute
60
68
  '/_layout/': typeof LayoutIndexRoute
69
+ '/_layout/app/$slug': typeof LayoutAppSlugRoute
61
70
  '/_layout/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
62
71
  }
63
72
  export interface FileRouteTypes {
64
73
  fileRoutesByFullPath: FileRoutesByFullPath
65
- fullPaths: '/' | '/login' | '/auth/callback' | '/catalog/apps/'
74
+ fullPaths: '/' | '/login' | '/auth/callback' | '/app/$slug' | '/catalog/apps/'
66
75
  fileRoutesByTo: FileRoutesByTo
67
- to: '/login' | '/auth/callback' | '/' | '/catalog/apps'
76
+ to: '/login' | '/auth/callback' | '/' | '/app/$slug' | '/catalog/apps'
68
77
  id:
69
78
  | '__root__'
70
79
  | '/_layout'
71
80
  | '/_layout/login'
72
81
  | '/auth/callback'
73
82
  | '/_layout/'
83
+ | '/_layout/app/$slug'
74
84
  | '/_layout/catalog/apps/'
75
85
  fileRoutesById: FileRoutesById
76
86
  }
@@ -109,6 +119,13 @@ declare module '@tanstack/react-router' {
109
119
  preLoaderRoute: typeof LayoutLoginRouteImport
110
120
  parentRoute: typeof LayoutRoute
111
121
  }
122
+ '/_layout/app/$slug': {
123
+ id: '/_layout/app/$slug'
124
+ path: '/app/$slug'
125
+ fullPath: '/app/$slug'
126
+ preLoaderRoute: typeof LayoutAppSlugRouteImport
127
+ parentRoute: typeof LayoutRoute
128
+ }
112
129
  '/_layout/catalog/apps/': {
113
130
  id: '/_layout/catalog/apps/'
114
131
  path: '/catalog/apps'
@@ -122,12 +139,14 @@ declare module '@tanstack/react-router' {
122
139
  interface LayoutRouteChildren {
123
140
  LayoutLoginRoute: typeof LayoutLoginRoute
124
141
  LayoutIndexRoute: typeof LayoutIndexRoute
142
+ LayoutAppSlugRoute: typeof LayoutAppSlugRoute
125
143
  LayoutCatalogAppsIndexRoute: typeof LayoutCatalogAppsIndexRoute
126
144
  }
127
145
 
128
146
  const LayoutRouteChildren: LayoutRouteChildren = {
129
147
  LayoutLoginRoute: LayoutLoginRoute,
130
148
  LayoutIndexRoute: LayoutIndexRoute,
149
+ LayoutAppSlugRoute: LayoutAppSlugRoute,
131
150
  LayoutCatalogAppsIndexRoute: LayoutCatalogAppsIndexRoute,
132
151
  }
133
152
 
@@ -0,0 +1,23 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'
3
+ import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
4
+ import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
5
+
6
+ export const Route = createFileRoute('/_layout/app/$slug')({
7
+ component: RouteComponent,
8
+ async loader() {
9
+ const appCatalogLoader = await appCatalogRouteLoader()
10
+ return { appCatalogLoader }
11
+ },
12
+ })
13
+
14
+ function RouteComponent() {
15
+ const { queryClient, trpcClient } = Route.useRouteContext()
16
+ const { slug } = Route.useParams()
17
+
18
+ return (
19
+ <AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>
20
+ <AppCatalogPage selectedSlug={slug} />
21
+ </AppCatalogLayout>
22
+ )
23
+ }
@@ -5,7 +5,6 @@ import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayou
5
5
  import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
6
6
 
7
7
  const searchSchema = z.object({
8
- app: z.string().optional(),
9
8
  filterTag: z.string().optional(),
10
9
  })
11
10
 
@@ -1,7 +0,0 @@
1
- import { Resource } from '@igstack/app-catalog-backend-core';
2
- export interface AppDetailModalProps {
3
- app: Resource;
4
- isOpen: boolean;
5
- onClose: () => void;
6
- }
7
- export declare function AppDetailModal({ app, isOpen, onClose }: AppDetailModalProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,63 +0,0 @@
1
- import { render, screen } from '@testing-library/react'
2
- import { describe, expect, it } from 'vitest'
3
- import '@testing-library/jest-dom/vitest'
4
-
5
- import type { Resource } from '@igstack/app-catalog-backend-core'
6
- import { AppDetailModal } from '~/modules/appCatalog/ui/components/AppDetailModal'
7
- import { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
8
- import type { AppCatalogContextIface } from '~/modules/appCatalog/context/AppCatalogContext'
9
-
10
- // Minimal context value — ScreenshotPreview doesn't use context directly,
11
- // but AccessSection (sibling in AppDetailModal) does.
12
- const minimalContext: AppCatalogContextIface = {
13
- resources: [],
14
- isLoadingApps: false,
15
- tagsDefinitions: [],
16
- approvalMethods: [],
17
- persons: [],
18
- groups: [],
19
- }
20
-
21
- function renderWithContext(app: Resource) {
22
- return render(
23
- <AppCatalogContext value={minimalContext}>
24
- <AppDetailModal app={app} isOpen={true} onClose={() => {}} />
25
- </AppCatalogContext>,
26
- )
27
- }
28
-
29
- function makeApp(screenshotIds: string[]): Resource {
30
- return {
31
- id: 'app-1',
32
- slug: 'test-app',
33
- displayName: 'Test App',
34
- screenshotIds,
35
- }
36
- }
37
-
38
- // Task 2.2 — unit test for thumbnail size parameter
39
- // Requirements: 1.1, 1.3
40
- describe('ScreenshotPreview thumbnail URLs', () => {
41
- it('appends ?size=600 to each thumbnail src', () => {
42
- const ids = ['abc123', 'def456', 'ghi789']
43
- renderWithContext(makeApp(ids))
44
-
45
- const imgs = screen.getAllByRole('img', { name: /screenshot/i })
46
- for (const [i, id] of ids.entries()) {
47
- expect(imgs[i]).toHaveAttribute('src', `/api/screenshots/${id}?size=600`)
48
- }
49
- })
50
-
51
- it('renders one thumbnail per screenshot ID', () => {
52
- const ids = ['id-1', 'id-2', 'id-3']
53
- renderWithContext(makeApp(ids))
54
-
55
- const imgs = screen.getAllByRole('img', { name: /screenshot/i })
56
- expect(imgs).toHaveLength(ids.length)
57
- })
58
-
59
- it('shows placeholder when screenshotIds is empty', () => {
60
- renderWithContext(makeApp([]))
61
- expect(screen.getByText(/no screenshots available/i)).toBeInTheDocument()
62
- })
63
- })
@@ -1,118 +0,0 @@
1
- import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
2
- import { describe, expect, it, vi } from 'vitest'
3
- import '@testing-library/jest-dom/vitest'
4
-
5
- import type { Resource } from '@igstack/app-catalog-backend-core'
6
- import { AppDetailModal } from '~/modules/appCatalog/ui/components/AppDetailModal'
7
- import { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
8
- import type { AppCatalogContextIface } from '~/modules/appCatalog/context/AppCatalogContext'
9
-
10
- // Task 3.3 — integration test for full Escape dismissal chain
11
- // Requirements: 6.7
12
- // Chain: Fullscreen_View → Gallery_Dialog → App_Card
13
- // Each Escape press dismisses only the innermost active layer.
14
-
15
- const minimalContext: AppCatalogContextIface = {
16
- resources: [],
17
- isLoadingApps: false,
18
- tagsDefinitions: [],
19
- approvalMethods: [],
20
- persons: [],
21
- groups: [],
22
- }
23
-
24
- function makeApp(): Resource {
25
- return {
26
- id: 'app-1',
27
- slug: 'test-app',
28
- displayName: 'Test App',
29
- screenshotIds: ['screenshot-1', 'screenshot-2'],
30
- }
31
- }
32
-
33
- function pressEscape() {
34
- fireEvent.keyDown(document, {
35
- key: 'Escape',
36
- bubbles: true,
37
- cancelable: true,
38
- })
39
- }
40
-
41
- describe('Escape key — layered dismissal chain (integration)', () => {
42
- it('Escape dismisses layers one at a time: Fullscreen → Gallery → App Card', async () => {
43
- const onClose = vi.fn()
44
- const app = makeApp()
45
-
46
- render(
47
- <AppCatalogContext value={minimalContext}>
48
- <AppDetailModal app={app} isOpen={true} onClose={onClose} />
49
- </AppCatalogContext>,
50
- )
51
-
52
- // App card is open — modal content should be visible
53
- expect(screen.getByText('Test App')).toBeInTheDocument()
54
-
55
- // Step 1: Open the gallery by clicking a thumbnail
56
- const thumbnail = screen.getAllByRole('button', {
57
- name: /screenshot 1/i,
58
- })[0]
59
- await act(async () => {
60
- fireEvent.click(thumbnail!)
61
- })
62
-
63
- // Gallery dialog should now be open (Radix renders into a portal)
64
- await waitFor(() => {
65
- expect(document.querySelector('[role="dialog"]')).toBeInTheDocument()
66
- })
67
-
68
- // Step 2: Enter fullscreen by clicking the first active gallery image
69
- // Gallery images have alt "Test App screenshot" (without index number)
70
- const galleryImgs = document.querySelectorAll(
71
- '[role="dialog"] img[alt="Test App screenshot"]',
72
- )
73
- expect(galleryImgs.length).toBeGreaterThan(0)
74
- await act(async () => {
75
- fireEvent.click(galleryImgs[0]!)
76
- })
77
-
78
- // Fullscreen overlay should be visible
79
- await waitFor(() => {
80
- expect(
81
- document.querySelector('[aria-label="Fullscreen view"]'),
82
- ).toBeInTheDocument()
83
- })
84
-
85
- // --- First Escape: exits fullscreen, gallery stays open, app card stays open ---
86
- await act(async () => {
87
- pressEscape()
88
- })
89
-
90
- await waitFor(() => {
91
- expect(
92
- document.querySelector('[aria-label="Fullscreen view"]'),
93
- ).not.toBeInTheDocument()
94
- })
95
- // Gallery dialog still present
96
- expect(document.querySelector('[role="dialog"]')).toBeInTheDocument()
97
- // App card still open (onClose not called)
98
- expect(onClose).not.toHaveBeenCalled()
99
-
100
- // --- Second Escape: closes gallery dialog, app card stays open ---
101
- await act(async () => {
102
- pressEscape()
103
- })
104
-
105
- await waitFor(() => {
106
- expect(document.querySelector('[role="dialog"]')).not.toBeInTheDocument()
107
- })
108
- // App card still open (onClose not called yet)
109
- expect(onClose).not.toHaveBeenCalled()
110
-
111
- // --- Third Escape: closes app card ---
112
- await act(async () => {
113
- pressEscape()
114
- })
115
-
116
- expect(onClose).toHaveBeenCalledTimes(1)
117
- })
118
- })