@igstack/app-catalog-frontend-core 6.1.0-alpha-20260924231444 → 6.1.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/modules/appCatalog/ui/components/SubResourcesSection.js +6 -45
- 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 +2 -48
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +14 -60
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useSearch,
|
|
3
|
-
import {
|
|
4
|
-
import { useMemo, useState, useEffect
|
|
2
|
+
import { useSearch, Link } from "@tanstack/react-router";
|
|
3
|
+
import { Search } from "lucide-react";
|
|
4
|
+
import { useMemo, useState, useEffect } from "react";
|
|
5
5
|
import { Badge } from "../../../../ui/badge.js";
|
|
6
6
|
import { Input } from "../../../../ui/input.js";
|
|
7
7
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "../../../../ui/table.js";
|
|
@@ -41,27 +41,6 @@ function SubResourcesSection({
|
|
|
41
41
|
() => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,
|
|
42
42
|
[subResources, selectedSubSlug]
|
|
43
43
|
);
|
|
44
|
-
const navigate = useNavigate();
|
|
45
|
-
const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug);
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
if (selectedSubSlug) setHighlightSlug(selectedSubSlug);
|
|
48
|
-
}, [selectedSubSlug]);
|
|
49
|
-
const searchInputRef = useRef(null);
|
|
50
|
-
const focusSearchAfterClear = useRef(false);
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
var _a;
|
|
53
|
-
if (!selectedSub && focusSearchAfterClear.current) {
|
|
54
|
-
focusSearchAfterClear.current = false;
|
|
55
|
-
(_a = searchInputRef.current) == null ? void 0 : _a.focus();
|
|
56
|
-
}
|
|
57
|
-
}, [selectedSub]);
|
|
58
|
-
const clearSelection = () => {
|
|
59
|
-
focusSearchAfterClear.current = true;
|
|
60
|
-
void navigate({
|
|
61
|
-
to: ".",
|
|
62
|
-
search: (prev) => ({ ...prev, sub: void 0 })
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
44
|
const seededSearch = useMemo(() => {
|
|
66
45
|
const q = initialSearch == null ? void 0 : initialSearch.trim().toLowerCase();
|
|
67
46
|
if (!q) return "";
|
|
@@ -107,29 +86,11 @@ function SubResourcesSection({
|
|
|
107
86
|
")"
|
|
108
87
|
] }) }),
|
|
109
88
|
/* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
|
|
110
|
-
|
|
111
|
-
// `?sub=` wins over the search box, so show it where the box would be
|
|
112
|
-
// and make it dismissible — otherwise the only way out is the URL.
|
|
113
|
-
/* @__PURE__ */ jsx("div", { className: "flex flex-1 items-center", children: /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "h-9 gap-1 pl-3 pr-1 text-sm", children: [
|
|
114
|
-
"Showing: ",
|
|
115
|
-
selectedSub.displayName,
|
|
116
|
-
/* @__PURE__ */ jsx(
|
|
117
|
-
"button",
|
|
118
|
-
{
|
|
119
|
-
type: "button",
|
|
120
|
-
"aria-label": "Clear sub-resource filter",
|
|
121
|
-
onClick: clearSelection,
|
|
122
|
-
className: "rounded-sm p-1 hover:bg-muted-foreground/20",
|
|
123
|
-
children: /* @__PURE__ */ jsx(X, { className: "size-4" })
|
|
124
|
-
}
|
|
125
|
-
)
|
|
126
|
-
] }) })
|
|
127
|
-
) : /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
|
|
89
|
+
/* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
|
|
128
90
|
/* @__PURE__ */ jsx(Search, { className: "absolute left-2.5 top-2.5 size-4 text-muted-foreground" }),
|
|
129
91
|
/* @__PURE__ */ jsx(
|
|
130
92
|
Input,
|
|
131
93
|
{
|
|
132
|
-
ref: searchInputRef,
|
|
133
94
|
placeholder: "Search resources by name or alias...",
|
|
134
95
|
value: search,
|
|
135
96
|
onChange: (e) => setSearch(e.target.value),
|
|
@@ -165,8 +126,8 @@ function SubResourcesSection({
|
|
|
165
126
|
return /* @__PURE__ */ jsxs(
|
|
166
127
|
TableRow,
|
|
167
128
|
{
|
|
168
|
-
"aria-current": sr.slug ===
|
|
169
|
-
className: sr.slug ===
|
|
129
|
+
"aria-current": sr.slug === selectedSubSlug ? "true" : void 0,
|
|
130
|
+
className: sr.slug === selectedSubSlug ? "bg-primary/[0.06]" : "",
|
|
170
131
|
children: [
|
|
171
132
|
/* @__PURE__ */ jsxs(TableCell, { children: [
|
|
172
133
|
/* @__PURE__ */ jsx(
|
|
@@ -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, useNavigate, useSearch } from '@tanstack/react-router'\nimport { Search, X } from 'lucide-react'\nimport { useEffect, useMemo, useRef, 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 const navigate = useNavigate()\n\n // The row to mark as \"this one\". It outlives `?sub=` so clearing the filter\n // brings the siblings back without losing the user's place.\n const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug)\n useEffect(() => {\n if (selectedSubSlug) setHighlightSlug(selectedSubSlug)\n }, [selectedSubSlug])\n\n // The search input only mounts once the selection is gone, so focus it then.\n const searchInputRef = useRef<HTMLInputElement>(null)\n const focusSearchAfterClear = useRef(false)\n useEffect(() => {\n if (!selectedSub && focusSearchAfterClear.current) {\n focusSearchAfterClear.current = false\n searchInputRef.current?.focus()\n }\n }, [selectedSub])\n\n const clearSelection = () => {\n focusSearchAfterClear.current = true\n // A navigation, not a replace, so Back restores the single-row view.\n void navigate({\n to: '.',\n search: (prev) => ({ ...prev, sub: undefined }),\n })\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 {selectedSub ? (\n // `?sub=` wins over the search box, so show it where the box would be\n // and make it dismissible — otherwise the only way out is the URL.\n <div className=\"flex flex-1 items-center\">\n <Badge variant=\"secondary\" className=\"h-9 gap-1 pl-3 pr-1 text-sm\">\n Showing: {selectedSub.displayName}\n <button\n type=\"button\"\n aria-label=\"Clear sub-resource filter\"\n onClick={clearSelection}\n className=\"rounded-sm p-1 hover:bg-muted-foreground/20\"\n >\n <X className=\"size-4\" />\n </button>\n </Badge>\n </div>\n ) : (\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n ref={searchInputRef}\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 )}\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 === highlightSlug ? 'true' : undefined\n }\n className={\n sr.slug === highlightSlug ? '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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"}
|
|
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": "6.1.0
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"tailwindcss": "^4.1.7",
|
|
113
113
|
"tiptap-markdown": "^0.8.0",
|
|
114
114
|
"tslib": "^2.3.0",
|
|
115
|
-
"@igstack/app-catalog-shared-core": "6.1.0
|
|
115
|
+
"@igstack/app-catalog-shared-core": "6.1.0"
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
118
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"vite-plugin-static-copy": "^3.1.4",
|
|
140
140
|
"vite-plugin-svgr": "^4.2.0",
|
|
141
141
|
"vitest": "^4.1.2",
|
|
142
|
-
"@igstack/app-catalog-backend-core": "6.1.0
|
|
142
|
+
"@igstack/app-catalog-backend-core": "6.1.0"
|
|
143
143
|
},
|
|
144
144
|
"peerDependencies": {
|
|
145
145
|
"react": "19.1.2",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"vite": "^6.3.5",
|
|
149
149
|
"vite-plugin-svgr": "^4.2.0"
|
|
150
150
|
},
|
|
151
|
-
"gitHead": "
|
|
151
|
+
"gitHead": "df0fdd456ac388d853ec0e04619e4d7741bcebcb",
|
|
152
152
|
"scripts": {
|
|
153
153
|
"build": "vite build",
|
|
154
154
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import {
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import '@testing-library/jest-dom/vitest'
|
|
5
5
|
|
|
6
6
|
// The component reads `?sub=` and links rows to a child's own page; neither is
|
|
7
7
|
// under test here, so stub the router rather than mounting one.
|
|
8
|
-
const routerSearch: { sub?: string } = {}
|
|
9
|
-
const navigate = vi.fn()
|
|
10
8
|
vi.mock('@tanstack/react-router', () => ({
|
|
11
|
-
useSearch: vi.fn(() =>
|
|
12
|
-
useNavigate: vi.fn(() => navigate),
|
|
9
|
+
useSearch: vi.fn(() => ({})),
|
|
13
10
|
Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
|
|
14
11
|
<a {...rest}>{children}</a>
|
|
15
12
|
),
|
|
@@ -95,46 +92,3 @@ describe('SubResourcesSection — account id as deep link', () => {
|
|
|
95
92
|
)
|
|
96
93
|
})
|
|
97
94
|
})
|
|
98
|
-
|
|
99
|
-
// A `?sub=` deep link singles out one row; the table must say so and offer a
|
|
100
|
-
// way back to the full list without editing the URL.
|
|
101
|
-
describe('SubResourcesSection — clearing the ?sub= selection', () => {
|
|
102
|
-
const rows = [sub('a', {}), sub('b', {}), sub('c', {})]
|
|
103
|
-
|
|
104
|
-
it('shows the selection as a dismissible filter that restores every row', () => {
|
|
105
|
-
routerSearch.sub = 'b'
|
|
106
|
-
navigate.mockClear()
|
|
107
|
-
const { rerender } = render(
|
|
108
|
-
<SubResourcesSection parentSlug="p" subResources={rows} />,
|
|
109
|
-
)
|
|
110
|
-
expect(screen.getByText('Sub-Resources (1 of 3)')).toBeInTheDocument()
|
|
111
|
-
|
|
112
|
-
fireEvent.click(
|
|
113
|
-
screen.getByRole('button', { name: 'Clear sub-resource filter' }),
|
|
114
|
-
)
|
|
115
|
-
const { search } = navigate.mock.calls[0]![0] as {
|
|
116
|
-
search: (prev: object) => object
|
|
117
|
-
}
|
|
118
|
-
expect(search({ sub: 'b', q: 'x' })).toEqual({ sub: undefined, q: 'x' })
|
|
119
|
-
|
|
120
|
-
// The router drops `sub` from the URL, which re-renders the section.
|
|
121
|
-
routerSearch.sub = undefined
|
|
122
|
-
rerender(<SubResourcesSection parentSlug="p" subResources={rows} />)
|
|
123
|
-
expect(screen.getByText('Sub-Resources (3 of 3)')).toBeInTheDocument()
|
|
124
|
-
expect(
|
|
125
|
-
screen.getByPlaceholderText('Search resources by name or alias...'),
|
|
126
|
-
).toHaveFocus()
|
|
127
|
-
// The user keeps their place: the row they came for is still marked.
|
|
128
|
-
expect(
|
|
129
|
-
screen.getByText('b', { selector: 'a' }).closest('tr'),
|
|
130
|
-
).toHaveAttribute('aria-current', 'true')
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('shows no clear control without a selection', () => {
|
|
134
|
-
routerSearch.sub = undefined
|
|
135
|
-
render(<SubResourcesSection parentSlug="p" subResources={rows} />)
|
|
136
|
-
expect(
|
|
137
|
-
screen.queryByRole('button', { name: 'Clear sub-resource filter' }),
|
|
138
|
-
).not.toBeInTheDocument()
|
|
139
|
-
})
|
|
140
|
-
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import { Link,
|
|
3
|
-
import { Search
|
|
4
|
-
import { useEffect, useMemo,
|
|
2
|
+
import { Link, useSearch } from '@tanstack/react-router'
|
|
3
|
+
import { Search } from 'lucide-react'
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
5
5
|
import { Badge } from '~/ui/badge'
|
|
6
6
|
import { Input } from '~/ui/input'
|
|
7
7
|
import {
|
|
@@ -78,33 +78,6 @@ export function SubResourcesSection({
|
|
|
78
78
|
() => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,
|
|
79
79
|
[subResources, selectedSubSlug],
|
|
80
80
|
)
|
|
81
|
-
const navigate = useNavigate()
|
|
82
|
-
|
|
83
|
-
// The row to mark as "this one". It outlives `?sub=` so clearing the filter
|
|
84
|
-
// brings the siblings back without losing the user's place.
|
|
85
|
-
const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug)
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
if (selectedSubSlug) setHighlightSlug(selectedSubSlug)
|
|
88
|
-
}, [selectedSubSlug])
|
|
89
|
-
|
|
90
|
-
// The search input only mounts once the selection is gone, so focus it then.
|
|
91
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
92
|
-
const focusSearchAfterClear = useRef(false)
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (!selectedSub && focusSearchAfterClear.current) {
|
|
95
|
-
focusSearchAfterClear.current = false
|
|
96
|
-
searchInputRef.current?.focus()
|
|
97
|
-
}
|
|
98
|
-
}, [selectedSub])
|
|
99
|
-
|
|
100
|
-
const clearSelection = () => {
|
|
101
|
-
focusSearchAfterClear.current = true
|
|
102
|
-
// A navigation, not a replace, so Back restores the single-row view.
|
|
103
|
-
void navigate({
|
|
104
|
-
to: '.',
|
|
105
|
-
search: (prev) => ({ ...prev, sub: undefined }),
|
|
106
|
-
})
|
|
107
|
-
}
|
|
108
81
|
|
|
109
82
|
// Seed the filter with the incoming query ONLY if it matches a child, so we
|
|
110
83
|
// reveal the matched sub-resource without hiding everything on a non-match.
|
|
@@ -173,34 +146,15 @@ export function SubResourcesSection({
|
|
|
173
146
|
|
|
174
147
|
{/* Filters */}
|
|
175
148
|
<div className="flex gap-2">
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
onClick={clearSelection}
|
|
186
|
-
className="rounded-sm p-1 hover:bg-muted-foreground/20"
|
|
187
|
-
>
|
|
188
|
-
<X className="size-4" />
|
|
189
|
-
</button>
|
|
190
|
-
</Badge>
|
|
191
|
-
</div>
|
|
192
|
-
) : (
|
|
193
|
-
<div className="relative flex-1">
|
|
194
|
-
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
|
195
|
-
<Input
|
|
196
|
-
ref={searchInputRef}
|
|
197
|
-
placeholder="Search resources by name or alias..."
|
|
198
|
-
value={search}
|
|
199
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
200
|
-
className="pl-9 h-9"
|
|
201
|
-
/>
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
149
|
+
<div className="relative flex-1">
|
|
150
|
+
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
|
151
|
+
<Input
|
|
152
|
+
placeholder="Search resources by name or alias..."
|
|
153
|
+
value={search}
|
|
154
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
155
|
+
className="pl-9 h-9"
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
204
158
|
{uniqueTiers.length > 1 && (
|
|
205
159
|
<Select value={tierFilter} onValueChange={setTierFilter}>
|
|
206
160
|
<SelectTrigger className="w-[130px] h-9">
|
|
@@ -251,10 +205,10 @@ export function SubResourcesSection({
|
|
|
251
205
|
// The row the user asked for, so it reads as "this one" even
|
|
252
206
|
// once the filter is cleared and its siblings come back.
|
|
253
207
|
aria-current={
|
|
254
|
-
sr.slug ===
|
|
208
|
+
sr.slug === selectedSubSlug ? 'true' : undefined
|
|
255
209
|
}
|
|
256
210
|
className={
|
|
257
|
-
sr.slug ===
|
|
211
|
+
sr.slug === selectedSubSlug ? 'bg-primary/[0.06]' : ''
|
|
258
212
|
}
|
|
259
213
|
>
|
|
260
214
|
<TableCell>
|