@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,94 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type {
3
+ AppForCatalog,
4
+ SubResource,
5
+ } from '@igstack/app-catalog-backend-core'
6
+ import { searchApps } from '~/modules/appCatalog/utils/searchApps'
7
+
8
+ function makeApp(
9
+ overrides: Partial<AppForCatalog> & { slug: string },
10
+ ): AppForCatalog {
11
+ return {
12
+ id: overrides.slug,
13
+ displayName: overrides.slug,
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ function makeSubResource(
19
+ overrides: Partial<SubResource> & { slug: string; appSlug: string },
20
+ ): SubResource {
21
+ return {
22
+ displayName: overrides.slug,
23
+ aliases: [],
24
+ accessMaintainerGroupSlugs: [],
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ describe('searchApps', () => {
30
+ const apps: AppForCatalog[] = [
31
+ makeApp({
32
+ slug: 'jira',
33
+ displayName: 'Jira',
34
+ description: 'Issue tracker',
35
+ }),
36
+ makeApp({
37
+ slug: 'aws-console',
38
+ displayName: 'AWS Console',
39
+ description: 'Cloud management',
40
+ }),
41
+ makeApp({ slug: 'slack', displayName: 'Slack', description: 'Messaging' }),
42
+ ]
43
+
44
+ it('returns all apps when query is empty', () => {
45
+ expect(searchApps(apps, '')).toHaveLength(3)
46
+ })
47
+
48
+ it('finds app by displayName', () => {
49
+ const results = searchApps(apps, 'jira')
50
+ expect(results).toHaveLength(1)
51
+ expect(results[0]!.slug).toBe('jira')
52
+ })
53
+
54
+ describe('sub-resource search', () => {
55
+ const subResources: SubResource[] = [
56
+ makeSubResource({
57
+ slug: 'aws-natera-pipelines-dev',
58
+ displayName: 'natera-pipelines-biomarkers-ici-dev',
59
+ appSlug: 'aws-console',
60
+ aliases: ['043902793406'],
61
+ }),
62
+ makeSubResource({
63
+ slug: 'aws-natera-infosec-dev',
64
+ displayName: 'natera-infosec-dev',
65
+ appSlug: 'aws-console',
66
+ aliases: [],
67
+ }),
68
+ ]
69
+
70
+ it('finds app by sub-resource displayName', () => {
71
+ const results = searchApps(apps, 'pipelines biomarkers', subResources)
72
+ expect(results).toHaveLength(1)
73
+ expect(results[0]!.slug).toBe('aws-console')
74
+ })
75
+
76
+ it('finds app by sub-resource alias (account ID)', () => {
77
+ const results = searchApps(apps, '043902793406', subResources)
78
+ expect(results).toHaveLength(1)
79
+ expect(results[0]!.slug).toBe('aws-console')
80
+ })
81
+
82
+ it('does not match sub-resources when no subResources provided', () => {
83
+ const results = searchApps(apps, '043902793406')
84
+ expect(results).toHaveLength(0)
85
+ })
86
+
87
+ it('direct app match ranks higher than sub-resource match', () => {
88
+ const results = searchApps(apps, 'aws', subResources)
89
+ // 'aws-console' matches by displayName, should appear
90
+ expect(results.length).toBeGreaterThanOrEqual(1)
91
+ expect(results[0]!.slug).toBe('aws-console')
92
+ })
93
+ })
94
+ })
@@ -17,6 +17,9 @@ const minimalContext: AppCatalogContextIface = {
17
17
  isLoadingApps: false,
18
18
  tagsDefinitions: [],
19
19
  approvalMethods: [],
20
+ persons: [],
21
+ groups: [],
22
+ subResources: [],
20
23
  }
21
24
 
22
25
  function makeApp(): AppForCatalog {
@@ -2,7 +2,10 @@ import type {
2
2
  AppApprovalMethod,
3
3
  AppForCatalog,
4
4
  AppVersionInfo,
5
+ Group,
5
6
  GroupingTagDefinition,
7
+ Person,
8
+ SubResource,
6
9
  } from '@igstack/app-catalog-backend-core'
7
10
  import { useQuery } from '@tanstack/react-query'
8
11
  import type { ReactNode } from 'react'
@@ -15,6 +18,9 @@ export interface AppCatalogContextIface {
15
18
  isLoadingApps: boolean
16
19
  tagsDefinitions: GroupingTagDefinition[]
17
20
  approvalMethods: AppApprovalMethod[]
21
+ persons: Person[]
22
+ groups: Group[]
23
+ subResources?: SubResource[]
18
24
  versions?: AppVersionInfo
19
25
  }
20
26
 
@@ -38,6 +44,9 @@ export function AppCatalogProvider({ children }: AppCatalogProviderProps) {
38
44
  isLoadingApps,
39
45
  tagsDefinitions: data?.tagsDefinitions ?? [],
40
46
  approvalMethods: data?.approvalMethods ?? [],
47
+ persons: data?.persons ?? [],
48
+ groups: data?.groups ?? [],
49
+ subResources: data?.subResources ?? [],
41
50
  versions: {
42
51
  ...data?.versions,
43
52
  ...(uiSettings.frontendBuildId && {
@@ -54,6 +63,9 @@ export function AppCatalogProvider({ children }: AppCatalogProviderProps) {
54
63
  data?.approvalMethods,
55
64
  data?.apps,
56
65
  data?.tagsDefinitions,
66
+ data?.persons,
67
+ data?.groups,
68
+ data?.subResources,
57
69
  data?.versions,
58
70
  uiSettings.frontendBuildId,
59
71
  isLoadingApps,
@@ -5,7 +5,6 @@ import type {
5
5
  import { Bot, Check, Copy, ExternalLink, Settings, Users } from 'lucide-react'
6
6
  import { useCallback, useEffect, useRef, useState } from 'react'
7
7
  import ReactMarkdown from 'react-markdown'
8
- import { Badge } from '~/ui/badge'
9
8
  import { Button } from '~/ui/button'
10
9
  import {
11
10
  Accordion,
@@ -21,6 +20,7 @@ import {
21
20
  TableHeader,
22
21
  TableRow,
23
22
  } from '~/ui/table'
23
+ import { PersonBadge } from './PersonBadge'
24
24
 
25
25
  // Constants
26
26
  const COPY_FEEDBACK_DURATION = 2000
@@ -49,13 +49,17 @@ const MarkdownLink = ({
49
49
  )
50
50
 
51
51
  // Helper function for approval method icons
52
- function getApprovalMethodIcon(type: 'service' | 'personTeam' | 'custom') {
52
+ function getApprovalMethodIcon(
53
+ type: 'service' | 'personTeam' | 'custom' | 'noAccessRequired' | 'unknown',
54
+ ) {
53
55
  switch (type) {
54
56
  case 'service':
55
57
  return <Bot className="size-5 text-primary" />
56
58
  case 'personTeam':
57
59
  return <Users className="size-5 text-primary" />
58
60
  case 'custom':
61
+ case 'noAccessRequired':
62
+ case 'unknown':
59
63
  return <Settings className="size-5 text-primary" />
60
64
  }
61
65
  }
@@ -154,7 +158,7 @@ export function AccessRequestSection({
154
158
  const { copiedId, copyToClipboard } = useCopyToClipboard()
155
159
  const accessRequest = app.accessRequest
156
160
  const approvalMethod = approvalMethods.find(
157
- (m) => m.slug === accessRequest?.approvalMethodId,
161
+ (m) => m.slug === accessRequest?.approvalMethodSlug,
158
162
  )
159
163
 
160
164
  const handleCopyPrompt = useCallback(() => {
@@ -163,13 +167,6 @@ export function AccessRequestSection({
163
167
  }
164
168
  }, [accessRequest?.requestPrompt, copyToClipboard])
165
169
 
166
- const handleCopyApproverEmail = useCallback(
167
- (email: string, index: number) => {
168
- copyToClipboard(email, `approver-${index}`)
169
- },
170
- [copyToClipboard],
171
- )
172
-
173
170
  // Early return if no access request
174
171
  if (!accessRequest) return null
175
172
 
@@ -273,59 +270,17 @@ export function AccessRequestSection({
273
270
  )}
274
271
 
275
272
  {/* Approvers */}
276
- {accessRequest.approvers && accessRequest.approvers.length > 0 && (
277
- <div>
278
- <h4 className="mb-2 text-sm font-medium">Approvers</h4>
279
- <div className="flex flex-wrap gap-2">
280
- {accessRequest.approvers.map((approver, idx) => {
281
- const approverId = `approver-${idx}`
282
- const isCopied = copiedId === approverId
283
-
284
- return (
285
- <Badge
286
- key={`${approver.displayName}-${idx}`}
287
- variant="outline"
288
- className="font-normal inline-flex items-center gap-1"
289
- >
290
- {approver.displayName}
291
- {approver.contact && (
292
- <>
293
- <span className="text-xs opacity-70">
294
- ({approver.contact})
295
- </span>
296
- <button
297
- type="button"
298
- onClick={(e) => {
299
- e.stopPropagation()
300
- handleCopyApproverEmail(approver.contact!, idx)
301
- }}
302
- className="inline-flex items-center justify-center hover:bg-accent rounded p-0.5 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1"
303
- aria-label={`Copy ${approver.displayName}'s email`}
304
- title={isCopied ? 'Copied!' : 'Copy email'}
305
- >
306
- {isCopied ? (
307
- <>
308
- <Check className="h-3 w-3 text-green-600" />
309
- <span
310
- className="sr-only"
311
- role="status"
312
- aria-live="polite"
313
- >
314
- Email copied to clipboard
315
- </span>
316
- </>
317
- ) : (
318
- <Copy className="h-3 w-3 opacity-50 hover:opacity-100" />
319
- )}
320
- </button>
321
- </>
322
- )}
323
- </Badge>
324
- )
325
- })}
273
+ {accessRequest.approverPersonSlugs &&
274
+ accessRequest.approverPersonSlugs.length > 0 && (
275
+ <div>
276
+ <h4 className="mb-2 text-sm font-medium">Approvers</h4>
277
+ <div className="flex flex-wrap gap-2">
278
+ {accessRequest.approverPersonSlugs.map((slug) => (
279
+ <PersonBadge key={slug} slug={slug} />
280
+ ))}
281
+ </div>
326
282
  </div>
327
- </div>
328
- )}
283
+ )}
329
284
 
330
285
  {/* Documentation URLs */}
331
286
  {accessRequest.urls && accessRequest.urls.length > 0 && (
@@ -1,6 +1,6 @@
1
1
  import type { AppForCatalog } from '@igstack/app-catalog-backend-core'
2
2
  import { AppWindowIcon, ExternalLinkIcon, XIcon } from 'lucide-react'
3
- import React, { useEffect, useState } from 'react'
3
+ import React, { useEffect, useMemo, useState } from 'react'
4
4
  import { Badge } from '~/ui/badge'
5
5
  import { Button } from '~/ui/button'
6
6
  import { ScrollArea } from '~/ui/scroll-area'
@@ -17,6 +17,9 @@ import {
17
17
  TableRow,
18
18
  } from '~/ui/table'
19
19
  import { ScreenshotGallery } from './ScreenshotGallery'
20
+ import { TierVariantsSection } from './TierVariantsSection'
21
+ import { SubResourcesSection } from './SubResourcesSection'
22
+ import { getSubResourcesForApp } from '~/modules/appCatalog/utils/resolveHelpers'
20
23
 
21
24
  export interface AppDetailModalProps {
22
25
  app: AppForCatalog
@@ -129,7 +132,7 @@ function AccessSection({ app }: { app: AppForCatalog }) {
129
132
  }
130
133
 
131
134
  const approvalMethod = approvalMethods.find(
132
- (m) => accessRequest.approvalMethodId === m.slug,
135
+ (m) => accessRequest.approvalMethodSlug === m.slug,
133
136
  )
134
137
  if (approvalMethod?.type !== 'service') {
135
138
  return 'not service'
@@ -183,6 +186,25 @@ function AccessSection({ app }: { app: AppForCatalog }) {
183
186
  )
184
187
  }
185
188
 
189
+ function TiersAndSubResources({ app }: { app: AppForCatalog }) {
190
+ const { subResources } = useAppCatalogContext()
191
+ const appSubResources = useMemo(
192
+ () => getSubResourcesForApp(subResources ?? [], app.slug),
193
+ [subResources, app.slug],
194
+ )
195
+
196
+ return (
197
+ <>
198
+ {app.tiers && app.tiers.length > 0 && (
199
+ <TierVariantsSection tiers={app.tiers} />
200
+ )}
201
+ {appSubResources.length > 0 && (
202
+ <SubResourcesSection subResources={appSubResources} />
203
+ )}
204
+ </>
205
+ )
206
+ }
207
+
186
208
  export function AppDetailModal({ app, isOpen, onClose }: AppDetailModalProps) {
187
209
  // Close on Escape key — but only if no inner layer (gallery) is handling it
188
210
  useEffect(() => {
@@ -271,8 +293,8 @@ export function AppDetailModal({ app, isOpen, onClose }: AppDetailModalProps) {
271
293
  {/* Access Section */}
272
294
  <AccessSection app={app} />
273
295
 
274
- {/* Approval Details Section - TODO: Update to use new approval system */}
275
- {/* {app.accessRequest && <AccessRequestSection accessRequest={app.accessRequest} />} */}
296
+ {/* Tier Variants and Sub-Resources */}
297
+ <TiersAndSubResources app={app} />
276
298
 
277
299
  {/* Tags */}
278
300
  {app.tags && app.tags.length > 0 && (
@@ -0,0 +1,69 @@
1
+ import { Check, Copy, User } from 'lucide-react'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { Badge } from '~/ui/badge'
4
+ import { useAppCatalogContext } from '~/modules/appCatalog'
5
+ import { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'
6
+
7
+ interface PersonBadgeProps {
8
+ slug: string
9
+ }
10
+
11
+ export function PersonBadge({ slug }: PersonBadgeProps) {
12
+ const { persons } = useAppCatalogContext()
13
+ const person = getPersonBySlug(persons, slug)
14
+
15
+ const displayName = person
16
+ ? `${person.firstName} ${person.lastName}`.trim() || slug
17
+ : slug
18
+
19
+ const email = person?.email
20
+
21
+ return (
22
+ <Badge
23
+ variant="outline"
24
+ className="font-normal inline-flex items-center gap-1"
25
+ title={email ? `${displayName} (${email})` : displayName}
26
+ >
27
+ <User className="size-3" />
28
+ {displayName}
29
+ {email && <CopyEmailButton email={email} />}
30
+ </Badge>
31
+ )
32
+ }
33
+
34
+ function CopyEmailButton({ email }: { email: string }) {
35
+ const [copied, setCopied] = useState(false)
36
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null)
37
+
38
+ useEffect(() => {
39
+ return () => {
40
+ if (timeoutRef.current) clearTimeout(timeoutRef.current)
41
+ }
42
+ }, [])
43
+
44
+ const handleCopy = useCallback(
45
+ (e: React.MouseEvent) => {
46
+ e.stopPropagation()
47
+ navigator.clipboard.writeText(email)
48
+ setCopied(true)
49
+ if (timeoutRef.current) clearTimeout(timeoutRef.current)
50
+ timeoutRef.current = setTimeout(() => setCopied(false), 2000)
51
+ },
52
+ [email],
53
+ )
54
+
55
+ return (
56
+ <button
57
+ onClick={handleCopy}
58
+ className="ml-0.5 hover:text-primary transition-colors"
59
+ title={copied ? 'Copied!' : `Copy ${email}`}
60
+ type="button"
61
+ >
62
+ {copied ? (
63
+ <Check className="size-3 text-green-600" />
64
+ ) : (
65
+ <Copy className="size-3" />
66
+ )}
67
+ </button>
68
+ )
69
+ }
@@ -0,0 +1,214 @@
1
+ import type { SubResource } from '@igstack/app-catalog-backend-core'
2
+ import { Search } from 'lucide-react'
3
+ import { useMemo, useState } from 'react'
4
+ import { Badge } from '~/ui/badge'
5
+ import { Input } from '~/ui/input'
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from '~/ui/table'
14
+ import { PersonBadge } from './PersonBadge'
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '~/ui/select'
22
+ import { useAppCatalogContext } from '~/modules/appCatalog'
23
+ import { getGroupBySlug } from '~/modules/appCatalog/utils/resolveHelpers'
24
+
25
+ interface SubResourcesSectionProps {
26
+ subResources: SubResource[]
27
+ }
28
+
29
+ function getTierBadgeVariant(
30
+ tierSlug: string,
31
+ ): 'default' | 'secondary' | 'destructive' | 'outline' {
32
+ if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'
33
+ if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'
34
+ if (tierSlug === 'preprod') return 'outline'
35
+ if (tierSlug === 'sandbox') return 'outline'
36
+ return 'outline'
37
+ }
38
+
39
+ function getTierBadgeClassName(tierSlug: string): string {
40
+ if (tierSlug === 'preprod')
41
+ return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'
42
+ if (tierSlug === 'sandbox')
43
+ return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'
44
+ return ''
45
+ }
46
+
47
+ function getTierDisplayLabel(tierSlug: string): string {
48
+ if (tierSlug === 'preprod') return 'Pre-Prod'
49
+ if (tierSlug === 'sandbox') return 'Sandbox'
50
+ if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'
51
+ if (tierSlug === 'dev') return 'Dev'
52
+ if (tierSlug === 'staging') return 'Staging'
53
+ return tierSlug
54
+ }
55
+
56
+ export function SubResourcesSection({
57
+ subResources,
58
+ }: SubResourcesSectionProps) {
59
+ const { groups } = useAppCatalogContext()
60
+ const [search, setSearch] = useState('')
61
+ const [tierFilter, setTierFilter] = useState<string>('all')
62
+
63
+ const uniqueTiers = useMemo(() => {
64
+ const tiers = new Set<string>()
65
+ for (const sr of subResources) {
66
+ if (sr.tierSlug) tiers.add(sr.tierSlug)
67
+ }
68
+ return [...tiers].sort()
69
+ }, [subResources])
70
+
71
+ const filtered = useMemo(() => {
72
+ let result = subResources
73
+
74
+ if (tierFilter !== 'all') {
75
+ result = result.filter((sr) => sr.tierSlug === tierFilter)
76
+ }
77
+
78
+ if (search.trim()) {
79
+ const q = search.trim().toLowerCase()
80
+ result = result.filter(
81
+ (sr) =>
82
+ sr.displayName.toLowerCase().includes(q) ||
83
+ sr.aliases.some((a) => a.toLowerCase().includes(q)) ||
84
+ (sr.description?.toLowerCase().includes(q) ?? false),
85
+ )
86
+ }
87
+
88
+ return result
89
+ }, [subResources, search, tierFilter])
90
+
91
+ if (subResources.length === 0) return null
92
+
93
+ return (
94
+ <div className="space-y-3">
95
+ <div className="flex items-center justify-between">
96
+ <div className="text-sm font-medium">
97
+ Sub-Resources ({filtered.length} of {subResources.length})
98
+ </div>
99
+ </div>
100
+
101
+ {/* Filters */}
102
+ <div className="flex gap-2">
103
+ <div className="relative flex-1">
104
+ <Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
105
+ <Input
106
+ placeholder="Search resources by name or alias..."
107
+ value={search}
108
+ onChange={(e) => setSearch(e.target.value)}
109
+ className="pl-9 h-9"
110
+ />
111
+ </div>
112
+ {uniqueTiers.length > 1 && (
113
+ <Select value={tierFilter} onValueChange={setTierFilter}>
114
+ <SelectTrigger className="w-[130px] h-9">
115
+ <SelectValue placeholder="All tiers" />
116
+ </SelectTrigger>
117
+ <SelectContent>
118
+ <SelectItem value="all">All tiers</SelectItem>
119
+ {uniqueTiers.map((tier) => (
120
+ <SelectItem key={tier} value={tier}>
121
+ {tier}
122
+ </SelectItem>
123
+ ))}
124
+ </SelectContent>
125
+ </Select>
126
+ )}
127
+ </div>
128
+
129
+ {/* Table */}
130
+ <div className="rounded-lg border max-h-[400px] overflow-auto">
131
+ <Table>
132
+ <TableHeader>
133
+ <TableRow>
134
+ <TableHead>Name</TableHead>
135
+ <TableHead className="w-[80px]">Tier</TableHead>
136
+ <TableHead>Owner</TableHead>
137
+ <TableHead>Access Contacts</TableHead>
138
+ </TableRow>
139
+ </TableHeader>
140
+ <TableBody>
141
+ {filtered.length === 0 ? (
142
+ <TableRow>
143
+ <TableCell
144
+ colSpan={4}
145
+ className="text-center text-muted-foreground py-8"
146
+ >
147
+ No resources match your filters
148
+ </TableCell>
149
+ </TableRow>
150
+ ) : (
151
+ filtered.map((sr) => {
152
+ // Resolve maintainer group members
153
+ const maintainerMembers = sr.accessMaintainerGroupSlugs.flatMap(
154
+ (groupSlug) => {
155
+ const group = getGroupBySlug(groups, groupSlug)
156
+ return group?.memberSlugs ?? []
157
+ },
158
+ )
159
+ // Deduplicate
160
+ const uniqueMaintainers = [...new Set(maintainerMembers)]
161
+
162
+ return (
163
+ <TableRow key={sr.slug}>
164
+ <TableCell>
165
+ <div className="font-medium text-sm">
166
+ {sr.displayName}
167
+ </div>
168
+ {sr.aliases.length > 0 && (
169
+ <div className="text-xs text-muted-foreground mt-0.5">
170
+ {sr.aliases.join(', ')}
171
+ </div>
172
+ )}
173
+ {sr.description && (
174
+ <div className="text-xs text-muted-foreground mt-0.5">
175
+ {sr.description}
176
+ </div>
177
+ )}
178
+ </TableCell>
179
+ <TableCell>
180
+ {sr.tierSlug && (
181
+ <Badge
182
+ variant={getTierBadgeVariant(sr.tierSlug)}
183
+ className={`text-xs ${getTierBadgeClassName(sr.tierSlug)}`}
184
+ >
185
+ {getTierDisplayLabel(sr.tierSlug)}
186
+ </Badge>
187
+ )}
188
+ </TableCell>
189
+ <TableCell>
190
+ {sr.ownerPersonSlug && (
191
+ <PersonBadge slug={sr.ownerPersonSlug} />
192
+ )}
193
+ </TableCell>
194
+ <TableCell>
195
+ {uniqueMaintainers.length > 0 ? (
196
+ <div className="flex flex-wrap gap-1">
197
+ {uniqueMaintainers.map((personSlug) => (
198
+ <PersonBadge key={personSlug} slug={personSlug} />
199
+ ))}
200
+ </div>
201
+ ) : (
202
+ <span className="text-muted-foreground">-</span>
203
+ )}
204
+ </TableCell>
205
+ </TableRow>
206
+ )
207
+ })
208
+ )}
209
+ </TableBody>
210
+ </Table>
211
+ </div>
212
+ </div>
213
+ )
214
+ }