@igstack/app-catalog-frontend-core 2.0.0-alpha-20260914220838 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +94 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +18 -3
|
File without changes
|
|
@@ -155,7 +155,18 @@ function SubResourcesSection({
|
|
|
155
155
|
/* @__PURE__ */ jsx(TableCell, { children: (() => {
|
|
156
156
|
var _a;
|
|
157
157
|
const accountId = (_a = sr.extra) == null ? void 0 : _a.awsAccountId;
|
|
158
|
-
|
|
158
|
+
if (!accountId)
|
|
159
|
+
return /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "—" });
|
|
160
|
+
return sr.appUrl ? /* @__PURE__ */ jsx(
|
|
161
|
+
"a",
|
|
162
|
+
{
|
|
163
|
+
href: sr.appUrl,
|
|
164
|
+
target: "_blank",
|
|
165
|
+
rel: "noopener noreferrer",
|
|
166
|
+
className: "font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text",
|
|
167
|
+
children: accountId
|
|
168
|
+
}
|
|
169
|
+
) : /* @__PURE__ */ jsx("span", { className: "font-mono text-xs text-muted-foreground select-text", children: accountId });
|
|
159
170
|
})() })
|
|
160
171
|
]
|
|
161
172
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Link, useSearch } from '@tanstack/react-router'\nimport { Search } from 'lucide-react'\nimport { useEffect, useMemo, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge, PersonOrGroupBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainText'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * Initial filter text (#38 item C). When the user reached this app by\n * searching a term that matched a sub-resource, seed the sub-resource filter\n * with that term so the matched child is revealed instead of buried in a\n * long list (e.g. searching \"data\" → open Cloud Console → the matching\n * account is pre-filtered). Only applied when it actually matches a child.\n */\n initialSearch?: string\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n parentSlug,\n initialSearch,\n}: SubResourcesSectionProps) {\n // `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row\n // in the search results. It wins over the query-seeded filter below: a query\n // like \"project\" matches every child, so seeding with it would bury the row\n // that was actually clicked.\n const search_ = useSearch({ strict: false })\n const selectedSubSlug = search_.sub\n const selectedSub = useMemo(\n () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,\n [subResources, selectedSubSlug],\n )\n\n // Seed the filter with the incoming query ONLY if it matches a child, so we\n // reveal the matched sub-resource without hiding everything on a non-match.\n const seededSearch = useMemo(() => {\n const q = initialSearch?.trim().toLowerCase()\n if (!q) return ''\n const hit = subResources.some(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),\n )\n return hit ? initialSearch!.trim() : ''\n }, [initialSearch, subResources])\n const [search, setSearch] = useState(seededSearch)\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n // useState only reads its argument on the first render, so without this the\n // filter kept the query from whenever this panel first mounted — searching\n // again with the panel already open left the old term in the box.\n useEffect(() => setSearch(seededSearch), [seededSearch])\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n if (selectedSub) return [selectedSub]\n\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false) ||\n (\n (sr.extra as Record<string, unknown>).awsAccountId as\n | string\n | undefined\n )?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, search, tierFilter, selectedSub])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Approvers</TableHead>\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={5}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n\n return (\n <TableRow\n key={sr.slug}\n // The row the user asked for, so it reads as \"this one\" even\n // once the filter is cleared and its siblings come back.\n aria-current={\n sr.slug === selectedSubSlug ? 'true' : undefined\n }\n className={\n sr.slug === selectedSubSlug ? 'bg-primary/[0.06]' : ''\n }\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a\n table of resources, each of which has its own page. */}\n <Link\n to=\"/app/$slug/sub/$subSlug\"\n params={{ slug: parentSlug, subSlug: sr.slug }}\n className=\"font-medium text-sm hover:text-primary hover:underline\"\n >\n {sr.displayName}\n </Link>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {markdownToPlainText(sr.description)}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {approvers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {approvers.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {(() => {\n const accountId = (\n sr.extra as Record<string, unknown> | null | undefined\n )?.awsAccountId as string | undefined\n return accountId ? (\n <span className=\"font-mono text-xs text-muted-foreground select-text\">\n {accountId}\n </span>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n })()}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Link, useSearch } from '@tanstack/react-router'\nimport { Search } from 'lucide-react'\nimport { useEffect, useMemo, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge, PersonOrGroupBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainText'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * Initial filter text (#38 item C). When the user reached this app by\n * searching a term that matched a sub-resource, seed the sub-resource filter\n * with that term so the matched child is revealed instead of buried in a\n * long list (e.g. searching \"data\" → open Cloud Console → the matching\n * account is pre-filtered). Only applied when it actually matches a child.\n */\n initialSearch?: string\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n parentSlug,\n initialSearch,\n}: SubResourcesSectionProps) {\n // `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row\n // in the search results. It wins over the query-seeded filter below: a query\n // like \"project\" matches every child, so seeding with it would bury the row\n // that was actually clicked.\n const search_ = useSearch({ strict: false })\n const selectedSubSlug = search_.sub\n const selectedSub = useMemo(\n () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,\n [subResources, selectedSubSlug],\n )\n\n // Seed the filter with the incoming query ONLY if it matches a child, so we\n // reveal the matched sub-resource without hiding everything on a non-match.\n const seededSearch = useMemo(() => {\n const q = initialSearch?.trim().toLowerCase()\n if (!q) return ''\n const hit = subResources.some(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),\n )\n return hit ? initialSearch!.trim() : ''\n }, [initialSearch, subResources])\n const [search, setSearch] = useState(seededSearch)\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n // useState only reads its argument on the first render, so without this the\n // filter kept the query from whenever this panel first mounted — searching\n // again with the panel already open left the old term in the box.\n useEffect(() => setSearch(seededSearch), [seededSearch])\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n if (selectedSub) return [selectedSub]\n\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false) ||\n (\n (sr.extra as Record<string, unknown>).awsAccountId as\n | string\n | undefined\n )?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, search, tierFilter, selectedSub])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Approvers</TableHead>\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={5}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n\n return (\n <TableRow\n key={sr.slug}\n // The row the user asked for, so it reads as \"this one\" even\n // once the filter is cleared and its siblings come back.\n aria-current={\n sr.slug === selectedSubSlug ? 'true' : undefined\n }\n className={\n sr.slug === selectedSubSlug ? 'bg-primary/[0.06]' : ''\n }\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a\n table of resources, each of which has its own page. */}\n <Link\n to=\"/app/$slug/sub/$subSlug\"\n params={{ slug: parentSlug, subSlug: sr.slug }}\n className=\"font-medium text-sm hover:text-primary hover:underline\"\n >\n {sr.displayName}\n </Link>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {markdownToPlainText(sr.description)}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {approvers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {approvers.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {(() => {\n const accountId = (\n sr.extra as Record<string, unknown> | null | undefined\n )?.awsAccountId as string | undefined\n if (!accountId)\n return (\n <span className=\"text-muted-foreground\">—</span>\n )\n // The account id is what people copy; when the resource\n // also carries its own `appUrl` that is the console\n // deep link for THAT account, so the id doubles as the\n // launch affordance.\n return sr.appUrl ? (\n <a\n href={sr.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text\"\n >\n {accountId}\n </a>\n ) : (\n <span className=\"font-mono text-xs text-muted-foreground select-text\">\n {accountId}\n </span>\n )\n })()}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "2.0.0
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"tailwindcss": "^4.1.7",
|
|
114
114
|
"tiptap-markdown": "^0.8.0",
|
|
115
115
|
"tslib": "^2.3.0",
|
|
116
|
-
"@igstack/app-catalog-shared-core": "2.0.0
|
|
116
|
+
"@igstack/app-catalog-shared-core": "2.0.0"
|
|
117
117
|
},
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
"vite-plugin-static-copy": "^3.1.4",
|
|
141
141
|
"vite-plugin-svgr": "^4.2.0",
|
|
142
142
|
"vitest": "^4.1.2",
|
|
143
|
-
"@igstack/app-catalog-backend-core": "2.0.0
|
|
143
|
+
"@igstack/app-catalog-backend-core": "2.0.0"
|
|
144
144
|
},
|
|
145
145
|
"peerDependencies": {
|
|
146
146
|
"react": "19.1.2",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"vite": "^6.3.5",
|
|
150
150
|
"vite-plugin-svgr": "^4.2.0"
|
|
151
151
|
},
|
|
152
|
-
"gitHead": "
|
|
152
|
+
"gitHead": "d8bd5031b4323b32134aed1176b81a5a9a20a8b6",
|
|
153
153
|
"scripts": {
|
|
154
154
|
"build": "vite build",
|
|
155
155
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
// The component reads `?sub=` and links rows to a child's own page; neither is
|
|
7
|
+
// under test here, so stub the router rather than mounting one.
|
|
8
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
9
|
+
useSearch: vi.fn(() => ({})),
|
|
10
|
+
Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
|
|
11
|
+
<a {...rest}>{children}</a>
|
|
12
|
+
),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const { SubResourcesSection } =
|
|
16
|
+
await import('~/modules/appCatalog/ui/components/SubResourcesSection')
|
|
17
|
+
|
|
18
|
+
function sub(slug: string, extra: unknown, appUrl?: string): Resource {
|
|
19
|
+
return {
|
|
20
|
+
id: slug,
|
|
21
|
+
slug,
|
|
22
|
+
displayName: slug,
|
|
23
|
+
parentSlug: 'cloud-console',
|
|
24
|
+
appUrl,
|
|
25
|
+
extra: extra as Resource['extra'],
|
|
26
|
+
} as Resource
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A cloud account row shows its account id; when the entry also carries its own
|
|
30
|
+
// per-account console URL that id must be the link to it — an account-agnostic
|
|
31
|
+
// parent URL is what made the ids un-launchable.
|
|
32
|
+
describe('SubResourcesSection — account id as deep link', () => {
|
|
33
|
+
it('links the account id to the sub-resource own appUrl', () => {
|
|
34
|
+
render(
|
|
35
|
+
<SubResourcesSection
|
|
36
|
+
parentSlug="cloud-console"
|
|
37
|
+
subResources={[
|
|
38
|
+
sub(
|
|
39
|
+
'acct-a',
|
|
40
|
+
{ awsAccountId: '111122223333' },
|
|
41
|
+
'https://portal.example/#/console?account_id=111122223333&role_name=ReadOnly',
|
|
42
|
+
),
|
|
43
|
+
]}
|
|
44
|
+
/>,
|
|
45
|
+
)
|
|
46
|
+
const link = screen.getByRole('link', { name: '111122223333' })
|
|
47
|
+
expect(link).toHaveAttribute(
|
|
48
|
+
'href',
|
|
49
|
+
'https://portal.example/#/console?account_id=111122223333&role_name=ReadOnly',
|
|
50
|
+
)
|
|
51
|
+
expect(link).toHaveAttribute('target', '_blank')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('leaves the account id as plain text when there is no appUrl', () => {
|
|
55
|
+
render(
|
|
56
|
+
<SubResourcesSection
|
|
57
|
+
parentSlug="cloud-console"
|
|
58
|
+
subResources={[sub('acct-b', { awsAccountId: '444455556666' })]}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
expect(screen.getByText('444455556666')).toBeInTheDocument()
|
|
62
|
+
expect(
|
|
63
|
+
screen.queryByRole('link', { name: '444455556666' }),
|
|
64
|
+
).not.toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('gives each row its own target', () => {
|
|
68
|
+
render(
|
|
69
|
+
<SubResourcesSection
|
|
70
|
+
parentSlug="cloud-console"
|
|
71
|
+
subResources={[
|
|
72
|
+
sub(
|
|
73
|
+
'acct-a',
|
|
74
|
+
{ awsAccountId: '111122223333' },
|
|
75
|
+
'https://portal.example/a',
|
|
76
|
+
),
|
|
77
|
+
sub(
|
|
78
|
+
'acct-b',
|
|
79
|
+
{ awsAccountId: '444455556666' },
|
|
80
|
+
'https://portal.example/b',
|
|
81
|
+
),
|
|
82
|
+
]}
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
expect(screen.getByRole('link', { name: '111122223333' })).toHaveAttribute(
|
|
86
|
+
'href',
|
|
87
|
+
'https://portal.example/a',
|
|
88
|
+
)
|
|
89
|
+
expect(screen.getByRole('link', { name: '444455556666' })).toHaveAttribute(
|
|
90
|
+
'href',
|
|
91
|
+
'https://portal.example/b',
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -263,12 +263,27 @@ export function SubResourcesSection({
|
|
|
263
263
|
const accountId = (
|
|
264
264
|
sr.extra as Record<string, unknown> | null | undefined
|
|
265
265
|
)?.awsAccountId as string | undefined
|
|
266
|
-
|
|
266
|
+
if (!accountId)
|
|
267
|
+
return (
|
|
268
|
+
<span className="text-muted-foreground">—</span>
|
|
269
|
+
)
|
|
270
|
+
// The account id is what people copy; when the resource
|
|
271
|
+
// also carries its own `appUrl` that is the console
|
|
272
|
+
// deep link for THAT account, so the id doubles as the
|
|
273
|
+
// launch affordance.
|
|
274
|
+
return sr.appUrl ? (
|
|
275
|
+
<a
|
|
276
|
+
href={sr.appUrl}
|
|
277
|
+
target="_blank"
|
|
278
|
+
rel="noopener noreferrer"
|
|
279
|
+
className="font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text"
|
|
280
|
+
>
|
|
281
|
+
{accountId}
|
|
282
|
+
</a>
|
|
283
|
+
) : (
|
|
267
284
|
<span className="font-mono text-xs text-muted-foreground select-text">
|
|
268
285
|
{accountId}
|
|
269
286
|
</span>
|
|
270
|
-
) : (
|
|
271
|
-
<span className="text-muted-foreground">—</span>
|
|
272
287
|
)
|
|
273
288
|
})()}
|
|
274
289
|
</TableCell>
|