@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,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
|
+
})
|
|
@@ -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(
|
|
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?.
|
|
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.
|
|
277
|
-
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
{/*
|
|
275
|
-
|
|
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
|
+
}
|