@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.
- package/dist/esm/__tests__/integration/mock-backend/MockBackendConfigurer.d.ts +4 -1
- package/dist/esm/__tests__/integration/mock-backend/MockDb.d.ts +3 -1
- package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +9 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +4 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/searchApps.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +4 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +6 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +24 -6
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +7 -57
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +5 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +68 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +148 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +156 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +47 -8
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +4 -3
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +4 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +15 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -0
- package/dist/esm/modules/appCatalog/utils/searchApps.d.ts +3 -3
- package/dist/esm/modules/appCatalog/utils/searchApps.js +24 -4
- package/dist/esm/modules/appCatalog/utils/searchApps.js.map +1 -1
- package/dist/esm/ui/select.js +138 -0
- package/dist/esm/ui/select.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/integration/appCatalog.integration.test.ts +40 -1
- package/src/__tests__/integration/harness/given.tsx +1 -1
- package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +15 -0
- package/src/__tests__/integration/mock-backend/MockDb.ts +12 -0
- package/src/__tests__/integration/mock-backend/magazines.ts +5 -9
- package/src/__tests__/integration/tools/AppDetailTools.ts +31 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +12 -2
- package/src/__tests__/modules/appCatalog/ScreenshotPreview.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +94 -0
- package/src/__tests__/modules/gallery/EscapeDismissal.integration.test.tsx +3 -0
- package/src/modules/appCatalog/context/AppCatalogContext.tsx +12 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +17 -62
- package/src/modules/appCatalog/ui/components/AppDetailModal.tsx +26 -4
- package/src/modules/appCatalog/ui/components/PersonBadge.tsx +69 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +214 -0
- package/src/modules/appCatalog/ui/components/TierVariantsSection.tsx +212 -0
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +71 -7
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +4 -2
- package/src/modules/appCatalog/utils/resolveHelpers.ts +26 -0
- 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
|
-
<
|
|
750
|
-
<
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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 } =
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
195
|
+
else if (match.field === 'subResource') score = 13
|
|
196
|
+
else score = 14 // description
|
|
158
197
|
}
|
|
159
198
|
|
|
160
199
|
scoreMap.set(app.id, score)
|