@igstack/app-catalog-frontend-core 0.3.1-alpha-20260403020019 → 0.3.1-alpha-20260405015231

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 (53) hide show
  1. package/dist/esm/__tests__/integration/mock-backend/MockBackendConfigurer.d.ts +4 -1
  2. package/dist/esm/__tests__/integration/mock-backend/MockDb.d.ts +3 -1
  3. package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +9 -0
  4. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +4 -0
  5. package/dist/esm/__tests__/modules/appCatalog/utils/searchApps.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +4 -1
  7. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +6 -0
  8. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +24 -6
  10. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +7 -57
  11. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +5 -0
  13. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +68 -0
  14. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -0
  15. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +6 -0
  16. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +148 -0
  17. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -0
  18. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.d.ts +6 -0
  19. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +156 -0
  20. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -0
  21. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +47 -8
  22. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  23. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +4 -3
  24. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +4 -0
  26. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +15 -0
  27. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -0
  28. package/dist/esm/modules/appCatalog/utils/searchApps.d.ts +3 -3
  29. package/dist/esm/modules/appCatalog/utils/searchApps.js +24 -4
  30. package/dist/esm/modules/appCatalog/utils/searchApps.js.map +1 -1
  31. package/dist/esm/ui/select.js +138 -0
  32. package/dist/esm/ui/select.js.map +1 -0
  33. package/package.json +3 -3
  34. package/src/__tests__/integration/appCatalog.integration.test.ts +40 -1
  35. package/src/__tests__/integration/harness/given.tsx +1 -1
  36. package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +15 -0
  37. package/src/__tests__/integration/mock-backend/MockDb.ts +12 -0
  38. package/src/__tests__/integration/mock-backend/magazines.ts +5 -9
  39. package/src/__tests__/integration/tools/AppDetailTools.ts +31 -0
  40. package/src/__tests__/integration/tools/CatalogTools.ts +12 -2
  41. package/src/__tests__/modules/appCatalog/ScreenshotPreview.test.tsx +3 -0
  42. package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +94 -0
  43. package/src/__tests__/modules/gallery/EscapeDismissal.integration.test.tsx +3 -0
  44. package/src/modules/appCatalog/context/AppCatalogContext.tsx +12 -0
  45. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +17 -62
  46. package/src/modules/appCatalog/ui/components/AppDetailModal.tsx +26 -4
  47. package/src/modules/appCatalog/ui/components/PersonBadge.tsx +69 -0
  48. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +214 -0
  49. package/src/modules/appCatalog/ui/components/TierVariantsSection.tsx +212 -0
  50. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +71 -7
  51. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +4 -2
  52. package/src/modules/appCatalog/utils/resolveHelpers.ts +26 -0
  53. package/src/modules/appCatalog/utils/searchApps.ts +45 -6
@@ -0,0 +1,212 @@
1
+ import type {
2
+ AppAccessRequest,
3
+ AppApprovalMethod,
4
+ AppTierVariant,
5
+ } from '@igstack/app-catalog-backend-core'
6
+ import { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'
7
+ import { useState } from 'react'
8
+ import ReactMarkdown from 'react-markdown'
9
+ import { Badge } from '~/ui/badge'
10
+ import {
11
+ Table,
12
+ TableBody,
13
+ TableCell,
14
+ TableHead,
15
+ TableHeader,
16
+ TableRow,
17
+ } from '~/ui/table'
18
+ import { useAppCatalogContext } from '~/modules/appCatalog'
19
+ import { PersonBadge } from './PersonBadge'
20
+
21
+ interface TierVariantsSectionProps {
22
+ tiers: AppTierVariant[]
23
+ }
24
+
25
+ function getTierBadgeVariant(
26
+ tierSlug: string,
27
+ ): 'default' | 'secondary' | 'destructive' | 'outline' {
28
+ if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'
29
+ if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'
30
+ if (tierSlug === 'preprod') return 'outline'
31
+ if (tierSlug === 'sandbox') return 'outline'
32
+ return 'outline'
33
+ }
34
+
35
+ function getTierBadgeClassName(tierSlug: string): string {
36
+ if (tierSlug === 'preprod')
37
+ return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'
38
+ if (tierSlug === 'sandbox')
39
+ return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'
40
+ return ''
41
+ }
42
+
43
+ function getTierDisplayLabel(tierSlug: string): string {
44
+ if (tierSlug === 'preprod') return 'Pre-Prod'
45
+ if (tierSlug === 'sandbox') return 'Sandbox'
46
+ if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'
47
+ if (tierSlug === 'dev') return 'Dev'
48
+ if (tierSlug === 'staging') return 'Staging'
49
+ return tierSlug
50
+ }
51
+
52
+ function getAccessIcon(type: string): React.ReactNode {
53
+ switch (type) {
54
+ case 'service':
55
+ return <Bot className="size-4 text-primary shrink-0" />
56
+ case 'personTeam':
57
+ return <Users className="size-4 text-primary shrink-0" />
58
+ default:
59
+ return <Settings className="size-4 text-primary shrink-0" />
60
+ }
61
+ }
62
+
63
+ /** Compact inline access detail for a tier row */
64
+ function TierAccessDetail({
65
+ accessRequest,
66
+ methods,
67
+ }: {
68
+ accessRequest: AppAccessRequest
69
+ methods: AppApprovalMethod[]
70
+ }) {
71
+ const [expanded, setExpanded] = useState(false)
72
+ const method = methods.find(
73
+ (m) => m.slug === accessRequest.approvalMethodSlug,
74
+ )
75
+
76
+ const hasExtra =
77
+ accessRequest.comments ||
78
+ accessRequest.urls?.length ||
79
+ accessRequest.approverPersonSlugs?.length ||
80
+ accessRequest.postApprovalInstructions
81
+
82
+ return (
83
+ <div className="space-y-1">
84
+ <div className="flex items-center gap-1.5">
85
+ {method && getAccessIcon(method.type)}
86
+ {method?.type === 'service' && method.config.url ? (
87
+ <a
88
+ href={method.config.url}
89
+ target="_blank"
90
+ rel="noopener noreferrer"
91
+ className="text-sm text-primary hover:underline inline-flex items-center gap-1"
92
+ >
93
+ {method.displayName}
94
+ <ExternalLinkIcon className="size-3" />
95
+ </a>
96
+ ) : (
97
+ <span className="text-sm">
98
+ {method?.displayName ?? accessRequest.approvalMethodSlug}
99
+ </span>
100
+ )}
101
+ {hasExtra && (
102
+ <button
103
+ type="button"
104
+ onClick={() => setExpanded(!expanded)}
105
+ className="text-xs text-muted-foreground hover:text-primary ml-1"
106
+ >
107
+ {expanded ? 'less' : 'more...'}
108
+ </button>
109
+ )}
110
+ </div>
111
+ {expanded && (
112
+ <div className="pl-5 space-y-1.5 text-xs">
113
+ {accessRequest.comments && (
114
+ <div className="text-muted-foreground prose prose-xs max-w-none">
115
+ <ReactMarkdown>{accessRequest.comments}</ReactMarkdown>
116
+ </div>
117
+ )}
118
+ {accessRequest.urls && accessRequest.urls.length > 0 && (
119
+ <div className="flex flex-col gap-0.5">
120
+ {accessRequest.urls.map((urlObj, idx) => (
121
+ <a
122
+ key={`${urlObj.url}-${idx}`}
123
+ href={urlObj.url}
124
+ target="_blank"
125
+ rel="noopener noreferrer"
126
+ className="text-primary hover:underline inline-flex items-center gap-1"
127
+ >
128
+ {urlObj.label || urlObj.url.replace(/^https?:\/\//, '')}
129
+ <ExternalLinkIcon className="size-3" />
130
+ </a>
131
+ ))}
132
+ </div>
133
+ )}
134
+ {accessRequest.approverPersonSlugs &&
135
+ accessRequest.approverPersonSlugs.length > 0 && (
136
+ <div className="flex flex-wrap gap-1">
137
+ {accessRequest.approverPersonSlugs.map((slug) => (
138
+ <PersonBadge key={slug} slug={slug} />
139
+ ))}
140
+ </div>
141
+ )}
142
+ </div>
143
+ )}
144
+ </div>
145
+ )
146
+ }
147
+
148
+ export function TierVariantsSection({ tiers }: TierVariantsSectionProps) {
149
+ const { approvalMethods } = useAppCatalogContext()
150
+
151
+ if (tiers.length === 0) return null
152
+
153
+ return (
154
+ <div className="space-y-2">
155
+ <div className="text-sm font-medium">Environment Tiers</div>
156
+ <div className="rounded-lg border">
157
+ <Table>
158
+ <TableHeader>
159
+ <TableRow>
160
+ <TableHead className="w-[100px]">Tier</TableHead>
161
+ <TableHead>Name</TableHead>
162
+ <TableHead>URL</TableHead>
163
+ <TableHead>Access</TableHead>
164
+ </TableRow>
165
+ </TableHeader>
166
+ <TableBody>
167
+ {tiers.map((tier) => (
168
+ <TableRow key={tier.tierSlug}>
169
+ <TableCell>
170
+ <Badge
171
+ variant={getTierBadgeVariant(tier.tierSlug)}
172
+ className={getTierBadgeClassName(tier.tierSlug)}
173
+ >
174
+ {getTierDisplayLabel(tier.tierSlug)}
175
+ </Badge>
176
+ </TableCell>
177
+ <TableCell className="font-medium">
178
+ {tier.displayName ?? tier.tierSlug}
179
+ </TableCell>
180
+ <TableCell>
181
+ {tier.appUrl ? (
182
+ <a
183
+ href={tier.appUrl}
184
+ target="_blank"
185
+ rel="noopener noreferrer"
186
+ className="text-sm text-primary hover:underline inline-flex items-center gap-1"
187
+ >
188
+ {tier.appUrl.replace(/^https?:\/\//, '')}
189
+ <ExternalLinkIcon className="size-3" />
190
+ </a>
191
+ ) : (
192
+ <span className="text-muted-foreground">-</span>
193
+ )}
194
+ </TableCell>
195
+ <TableCell>
196
+ {tier.accessRequest ? (
197
+ <TierAccessDetail
198
+ accessRequest={tier.accessRequest}
199
+ methods={approvalMethods}
200
+ />
201
+ ) : (
202
+ <span className="text-muted-foreground">Same as app</span>
203
+ )}
204
+ </TableCell>
205
+ </TableRow>
206
+ ))}
207
+ </TableBody>
208
+ </Table>
209
+ </div>
210
+ </div>
211
+ )
212
+ }
@@ -37,6 +37,9 @@ import { useAppCatalogContext } from '../../context/AppCatalogContext'
37
37
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
38
38
  import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation'
39
39
  import { highlightText } from '../../utils/searchApps'
40
+ import { TierVariantsSection } from '../components/TierVariantsSection'
41
+ import { SubResourcesSection } from '../components/SubResourcesSection'
42
+ import { getSubResourcesForApp } from '../../utils/resolveHelpers'
40
43
 
41
44
  export interface AppCatalogGridProps {
42
45
  apps: AppForCatalog[]
@@ -169,6 +172,29 @@ function AppScreenshot({ app }: { app: AppForCatalog }) {
169
172
  )
170
173
  }
171
174
 
175
+ function TiersAndSubResourcesPanel({ app }: { app: AppForCatalog }) {
176
+ const { subResources } = useAppCatalogContext()
177
+ const appSubResources = React.useMemo(
178
+ () => getSubResourcesForApp(subResources ?? [], app.slug),
179
+ [subResources, app.slug],
180
+ )
181
+
182
+ return (
183
+ <>
184
+ {app.tiers && app.tiers.length > 0 && (
185
+ <div className="mt-6">
186
+ <TierVariantsSection tiers={app.tiers} />
187
+ </div>
188
+ )}
189
+ {appSubResources.length > 0 && (
190
+ <div className="mt-6">
191
+ <SubResourcesSection subResources={appSubResources} />
192
+ </div>
193
+ )}
194
+ </>
195
+ )
196
+ }
197
+
172
198
  function AppDetails({
173
199
  app,
174
200
  onAppClick,
@@ -405,6 +431,9 @@ function AppDetails({
405
431
  {/* Access Request Section */}
406
432
  <AccessRequestSection app={app} approvalMethods={approvalMethods} />
407
433
 
434
+ {/* Tier Variants and Sub-Resources */}
435
+ <TiersAndSubResourcesPanel app={app} />
436
+
408
437
  {/* Links */}
409
438
  {app.links && app.links.length > 0 && (
410
439
  <div className="mt-4">
@@ -691,6 +720,33 @@ export function AppCatalogGrid({
691
720
  onAppClick,
692
721
  })
693
722
 
723
+ // Build a map of appSlug -> matched sub-resource displayName for search annotation
724
+ const { subResources: allSubResources } = useAppCatalogContext()
725
+ const matchedSubResourceMap = React.useMemo(() => {
726
+ const map = new Map<string, string>()
727
+ if (!searchQuery?.trim() || !allSubResources?.length) return map
728
+ const queryTerms = searchQuery
729
+ .trim()
730
+ .toLowerCase()
731
+ .split(/\s+/)
732
+ .filter(Boolean)
733
+ const allTermsMatch = (text: string): boolean =>
734
+ queryTerms.every((term) => text.includes(term))
735
+
736
+ for (const sr of allSubResources) {
737
+ if (map.has(sr.appSlug)) continue
738
+ const nameMatch = allTermsMatch(sr.displayName.toLowerCase())
739
+ const aliasMatch = sr.aliases.some((a) => allTermsMatch(a.toLowerCase()))
740
+ const descMatch = sr.description
741
+ ? allTermsMatch(sr.description.toLowerCase())
742
+ : false
743
+ if (nameMatch || aliasMatch || descMatch) {
744
+ map.set(sr.appSlug, sr.displayName)
745
+ }
746
+ }
747
+ return map
748
+ }, [searchQuery, allSubResources])
749
+
694
750
  // Define columns
695
751
  const columns = React.useMemo<ColumnDef<AppForCatalog>[]>(
696
752
  () => [
@@ -746,16 +802,24 @@ export function AppCatalogGrid({
746
802
  id: 'description',
747
803
  header: 'Description',
748
804
  cell: ({ row }) => (
749
- <span className="text-sm text-muted-foreground line-clamp-2">
750
- <HighlightedText
751
- text={row.original.description || '—'}
752
- searchQuery={searchQuery}
753
- />
754
- </span>
805
+ <div>
806
+ <span className="text-sm text-muted-foreground line-clamp-2">
807
+ <HighlightedText
808
+ text={row.original.description || '—'}
809
+ searchQuery={searchQuery}
810
+ />
811
+ </span>
812
+ {matchedSubResourceMap.get(row.original.slug) && (
813
+ <div className="text-xs text-primary mt-0.5">
814
+ Matched sub-resource:{' '}
815
+ {matchedSubResourceMap.get(row.original.slug)}
816
+ </div>
817
+ )}
818
+ </div>
755
819
  ),
756
820
  },
757
821
  ],
758
- [searchQuery],
822
+ [searchQuery, matchedSubResourceMap],
759
823
  )
760
824
 
761
825
  // Create a single table instance with all apps
@@ -21,7 +21,8 @@ import { FilterBar } from '../filters/FilterBar'
21
21
  import { AppCatalogGrid } from '../grid/AppCatalogGrid'
22
22
 
23
23
  export function AppCatalogPage() {
24
- const { apps, isLoadingApps, tagsDefinitions } = useAppCatalogContext()
24
+ const { apps, isLoadingApps, tagsDefinitions, subResources } =
25
+ useAppCatalogContext()
25
26
  const { state: filterState, actions } = useAppCatalogFilters()
26
27
  const { getTopApps } = useAppClickHistory()
27
28
 
@@ -75,7 +76,7 @@ export function AppCatalogPage() {
75
76
  }
76
77
 
77
78
  // Step 3: Apply search (using deferred value)
78
- result = searchApps(result, deferredSearchValue)
79
+ result = searchApps(result, deferredSearchValue, subResources)
79
80
 
80
81
  return result
81
82
  }, [
@@ -85,6 +86,7 @@ export function AppCatalogPage() {
85
86
  filterState.tagFilters,
86
87
  filterState.showDeprecated,
87
88
  topAppSlugs,
89
+ subResources,
88
90
  ])
89
91
 
90
92
  // Calculate counts for FilterBar
@@ -0,0 +1,26 @@
1
+ import type {
2
+ Group,
3
+ Person,
4
+ SubResource,
5
+ } from '@igstack/app-catalog-backend-core'
6
+
7
+ export function getPersonBySlug(
8
+ persons: Person[],
9
+ slug: string,
10
+ ): Person | undefined {
11
+ return persons.find((p) => p.slug === slug)
12
+ }
13
+
14
+ export function getGroupBySlug(
15
+ groups: Group[],
16
+ slug: string,
17
+ ): Group | undefined {
18
+ return groups.find((g) => g.slug === slug)
19
+ }
20
+
21
+ export function getSubResourcesForApp(
22
+ subResources: SubResource[],
23
+ appSlug: string,
24
+ ): SubResource[] {
25
+ return subResources.filter((sr) => sr.appSlug === appSlug)
26
+ }
@@ -1,4 +1,7 @@
1
- import type { AppForCatalog } from '@igstack/app-catalog-backend-core'
1
+ import type {
2
+ AppForCatalog,
3
+ SubResource,
4
+ } from '@igstack/app-catalog-backend-core'
2
5
 
3
6
  export interface SearchMatch {
4
7
  /** Field where the match occurred */
@@ -10,6 +13,7 @@ export interface SearchMatch {
10
13
  | 'tags'
11
14
  | 'teams'
12
15
  | 'description'
16
+ | 'subResource'
13
17
  /** Type of match */
14
18
  type: 'exact' | 'prefix' | 'contains'
15
19
  }
@@ -44,6 +48,7 @@ export interface SearchResult {
44
48
  export function searchApps(
45
49
  apps: AppForCatalog[],
46
50
  searchQuery: string,
51
+ subResources?: SubResource[],
47
52
  ): AppForCatalog[] {
48
53
  const normalizedQuery = searchQuery.trim().toLowerCase()
49
54
 
@@ -51,6 +56,23 @@ export function searchApps(
51
56
  return apps
52
57
  }
53
58
 
59
+ // Split query into terms for multi-word matching (AND logic)
60
+ const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
61
+
62
+ // Helper: all terms appear in the text (order-independent)
63
+ const allTermsMatch = (text: string): boolean =>
64
+ queryTerms.every((term) => text.includes(term))
65
+
66
+ // Build sub-resource lookup: appSlug -> SubResource[]
67
+ const subResourcesByApp = new Map<string, SubResource[]>()
68
+ if (subResources) {
69
+ for (const sr of subResources) {
70
+ const list = subResourcesByApp.get(sr.appSlug) ?? []
71
+ list.push(sr)
72
+ subResourcesByApp.set(sr.appSlug, list)
73
+ }
74
+ }
75
+
54
76
  // Filter and score apps
55
77
  const scoredApps = apps
56
78
  .map((app): SearchResult | null => {
@@ -111,16 +133,32 @@ export function searchApps(
111
133
  return { app, match: { field: 'tags', type: 'contains' } }
112
134
  }
113
135
 
114
- // Check teams
115
- if (teams.includes(normalizedQuery)) {
136
+ // Check teams (multi-word)
137
+ if (allTermsMatch(teams)) {
116
138
  return { app, match: { field: 'teams', type: 'contains' } }
117
139
  }
118
140
 
119
- // Check description
120
- if (description.includes(normalizedQuery)) {
141
+ // Check description (multi-word)
142
+ if (allTermsMatch(description)) {
121
143
  return { app, match: { field: 'description', type: 'contains' } }
122
144
  }
123
145
 
146
+ // Check sub-resources (name, aliases, description) — supports multi-word queries
147
+ const appSubResources = subResourcesByApp.get(app.slug)
148
+ if (appSubResources) {
149
+ const subMatch = appSubResources.some(
150
+ (sr) =>
151
+ allTermsMatch(sr.displayName.toLowerCase()) ||
152
+ sr.aliases.some((a) => allTermsMatch(a.toLowerCase())) ||
153
+ (sr.description
154
+ ? allTermsMatch(sr.description.toLowerCase())
155
+ : false),
156
+ )
157
+ if (subMatch) {
158
+ return { app, match: { field: 'subResource', type: 'contains' } }
159
+ }
160
+ }
161
+
124
162
  // No match found
125
163
  return null
126
164
  })
@@ -154,7 +192,8 @@ export function searchApps(
154
192
  else if (match.field === 'nicknames') score = 10
155
193
  else if (match.field === 'tags') score = 11
156
194
  else if (match.field === 'teams') score = 12
157
- else score = 13 // description
195
+ else if (match.field === 'subResource') score = 13
196
+ else score = 14 // description
158
197
  }
159
198
 
160
199
  scoreMap.set(app.id, score)