@igstack/app-catalog-frontend-core 6.8.4 → 6.9.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/index.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +22 -12
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +16 -1
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
- package/dist/esm/modules/extensions/index.d.ts +19 -3
- package/dist/esm/modules/extensions/index.js +10 -4
- package/dist/esm/modules/extensions/index.js.map +1 -1
- package/dist/esm/modules/extensions/types.d.ts +1 -0
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +41 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +34 -0
- package/src/index.tsx +2 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +60 -36
- package/src/modules/extensions/features/resourceDetail.ts +17 -1
- package/src/modules/extensions/index.ts +37 -2
- package/src/modules/extensions/types.ts +1 -0
package/dist/esm/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type { AreaIcon, AreasSettings, UiSettings } from './types/uiSettings.js'
|
|
|
7
7
|
export { DAY_TO_DAY_AREA_KEY, DAY_TO_DAY_TAG, } from './modules/appCatalog/utils/areaGrouping.js';
|
|
8
8
|
export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa/index.js';
|
|
9
9
|
export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa/index.js';
|
|
10
|
-
export type { AcPlugin, PluginSlots, PluginWrappers, PluginUser, SlotSpec, WrapperSpec, } from './modules/extensions/index.js';
|
|
10
|
+
export type { AcPlugin, PluginAppliesTo, PluginSlots, PluginWrappers, PluginUser, SlotScopeSpec, SlotSpec, WrapperSpec, } from './modules/extensions/index.js';
|
|
11
11
|
export type { Resource } from '@igstack/app-catalog-backend-core';
|
|
12
12
|
export { useCatalogResources } from './modules/appCatalog/context/AppCatalogContext.js';
|
|
13
13
|
/**
|
|
@@ -11,6 +11,10 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from ".
|
|
|
11
11
|
import { markdownToPlainText } from "../../utils/markdownToPlainText.js";
|
|
12
12
|
import { useSlotFilled, usePluginUser } from "../../../extensions/index.js";
|
|
13
13
|
import { ResourceSubResourceRowActions } from "../../../extensions/features/resourceDetail.js";
|
|
14
|
+
function awsAccountIdOf(sr) {
|
|
15
|
+
var _a;
|
|
16
|
+
return (_a = sr.extra) == null ? void 0 : _a.awsAccountId;
|
|
17
|
+
}
|
|
14
18
|
function getTierBadgeVariant(tierSlug) {
|
|
15
19
|
if (tierSlug === "prod" || tierSlug === "production") return "destructive";
|
|
16
20
|
if (tierSlug === "dev" || tierSlug === "staging") return "secondary";
|
|
@@ -37,14 +41,14 @@ function SubResourceRowBase({
|
|
|
37
41
|
resource: sr,
|
|
38
42
|
parentSlug,
|
|
39
43
|
adminParent,
|
|
44
|
+
showAccount,
|
|
40
45
|
user,
|
|
41
46
|
isHighlighted,
|
|
42
47
|
measureRef,
|
|
43
48
|
index
|
|
44
49
|
}) {
|
|
45
|
-
var _a;
|
|
46
50
|
const approvers = [...new Set(sr.approverSlugs ?? [])];
|
|
47
|
-
const accountId = (
|
|
51
|
+
const accountId = awsAccountIdOf(sr);
|
|
48
52
|
return /* @__PURE__ */ jsxs(
|
|
49
53
|
TableRow,
|
|
50
54
|
{
|
|
@@ -76,7 +80,7 @@ function SubResourceRowBase({
|
|
|
76
80
|
) }),
|
|
77
81
|
/* @__PURE__ */ jsx(TableCell, { children: sr.ownerPersonSlug && /* @__PURE__ */ jsx(PersonBadge, { slug: sr.ownerPersonSlug }) }),
|
|
78
82
|
/* @__PURE__ */ jsx(TableCell, { children: approvers.length > 0 ? /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: approvers.map((slug) => /* @__PURE__ */ jsx(PersonOrGroupBadge, { slug }, slug)) }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "-" }) }),
|
|
79
|
-
/* @__PURE__ */ jsx(TableCell, { children: !accountId ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "—" }) : (
|
|
83
|
+
showAccount && /* @__PURE__ */ jsx(TableCell, { children: !accountId ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "—" }) : (
|
|
80
84
|
// The account id is what people copy; when the resource also
|
|
81
85
|
// carries its own `appUrl` that is the console deep link for THAT
|
|
82
86
|
// account, so the id doubles as the launch affordance.
|
|
@@ -153,7 +157,14 @@ function SubResourcesSection({
|
|
|
153
157
|
initialSearch,
|
|
154
158
|
childrenLabel
|
|
155
159
|
}) {
|
|
156
|
-
const showAdminColumn = useSlotFilled(
|
|
160
|
+
const showAdminColumn = useSlotFilled(
|
|
161
|
+
"resourceSubResourceRowActions",
|
|
162
|
+
parent ? { parent } : void 0
|
|
163
|
+
);
|
|
164
|
+
const showAccountColumn = useMemo(
|
|
165
|
+
() => subResources.some((sr) => awsAccountIdOf(sr) !== void 0),
|
|
166
|
+
[subResources]
|
|
167
|
+
);
|
|
157
168
|
const user = usePluginUser();
|
|
158
169
|
const search_ = useSearch({ strict: false });
|
|
159
170
|
const selectedSubSlug = search_.sub;
|
|
@@ -212,11 +223,8 @@ function SubResourcesSection({
|
|
|
212
223
|
const q = deferredSearch.trim().toLowerCase();
|
|
213
224
|
result = result.filter(
|
|
214
225
|
(sr) => {
|
|
215
|
-
var _a, _b
|
|
216
|
-
return sr.displayName.toLowerCase().includes(q) || (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) || (((_a = sr.description) == null ? void 0 : _a.toLowerCase().includes(q)) ?? false) ||
|
|
217
|
-
// dereferencing it threw a TypeError the moment anything typed into
|
|
218
|
-
// this box met a child without it, taking the whole table down.
|
|
219
|
-
((_c = (_b = sr.extra) == null ? void 0 : _b.awsAccountId) == null ? void 0 : _c.includes(q)) === true;
|
|
226
|
+
var _a, _b;
|
|
227
|
+
return sr.displayName.toLowerCase().includes(q) || (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) || (((_a = sr.description) == null ? void 0 : _a.toLowerCase().includes(q)) ?? false) || ((_b = awsAccountIdOf(sr)) == null ? void 0 : _b.includes(q)) === true;
|
|
220
228
|
}
|
|
221
229
|
);
|
|
222
230
|
}
|
|
@@ -225,7 +233,7 @@ function SubResourcesSection({
|
|
|
225
233
|
const scrollRef = useRef(null);
|
|
226
234
|
const { windowed, virtualRows, padTop, padBottom, measureElement } = useRowWindow(filtered, scrollRef);
|
|
227
235
|
const fixedLayout = subResources.length > VIRTUALIZE_ABOVE;
|
|
228
|
-
const columnCount = showAdminColumn ?
|
|
236
|
+
const columnCount = 4 + (showAccountColumn ? 1 : 0) + (showAdminColumn ? 1 : 0);
|
|
229
237
|
if (subResources.length === 0) return null;
|
|
230
238
|
return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
231
239
|
/* @__PURE__ */ jsx("div", { className: "flex items-center justify-between", children: /* @__PURE__ */ jsxs(
|
|
@@ -300,13 +308,13 @@ function SubResourcesSection({
|
|
|
300
308
|
/* @__PURE__ */ jsx(TableHead, { className: "w-[80px]", children: "Tier" }),
|
|
301
309
|
/* @__PURE__ */ jsx(TableHead, { className: fixedLayout ? "w-[190px]" : void 0, children: "Owner" }),
|
|
302
310
|
/* @__PURE__ */ jsx(TableHead, { className: fixedLayout ? "w-[230px]" : void 0, children: "Approvers" }),
|
|
303
|
-
/* @__PURE__ */ jsx(TableHead, { className: "w-[140px]", children: "AWS Account" }),
|
|
311
|
+
showAccountColumn && /* @__PURE__ */ jsx(TableHead, { className: "w-[140px]", children: "AWS Account" }),
|
|
304
312
|
showAdminColumn && /* @__PURE__ */ jsx(TableHead, { className: "w-[168px]", children: "Admin" })
|
|
305
313
|
] }) }),
|
|
306
314
|
/* @__PURE__ */ jsx(TableBody, { children: filtered.length === 0 ? /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
|
|
307
315
|
TableCell,
|
|
308
316
|
{
|
|
309
|
-
colSpan:
|
|
317
|
+
colSpan: columnCount,
|
|
310
318
|
className: "text-center text-muted-foreground py-8",
|
|
311
319
|
children: "No resources match your filters"
|
|
312
320
|
}
|
|
@@ -321,6 +329,7 @@ function SubResourcesSection({
|
|
|
321
329
|
resource: sr,
|
|
322
330
|
parentSlug,
|
|
323
331
|
adminParent: showAdminColumn ? parent : void 0,
|
|
332
|
+
showAccount: showAccountColumn,
|
|
324
333
|
user,
|
|
325
334
|
isHighlighted: sr.slug === highlightSlug,
|
|
326
335
|
measureRef: measureElement,
|
|
@@ -336,6 +345,7 @@ function SubResourcesSection({
|
|
|
336
345
|
resource: sr,
|
|
337
346
|
parentSlug,
|
|
338
347
|
adminParent: showAdminColumn ? parent : void 0,
|
|
348
|
+
showAccount: showAccountColumn,
|
|
339
349
|
user,
|
|
340
350
|
isHighlighted: sr.slug === highlightSlug
|
|
341
351
|
},
|
|
@@ -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 { useVirtualizer } from '@tanstack/react-virtual'\nimport {\n memo,\n useCallback,\n useDeferredValue,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} 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'\nimport {\n ResourceSubResourceRowActions,\n usePluginUser,\n useSlotFilled,\n} from '~/modules/extensions/index'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * The parent itself, for the row-actions slot's payload.\n *\n * Optional, and the Admin column is skipped without it: a row action is about\n * a child *of something*, and a plugin deciding whether it applies needs the\n * parent to decide against.\n */\n parent?: Resource\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 * What this app calls its children, from the parent's `childrenLabel`.\n *\n * The tab above already uses it, so without it here the tab and the panel it\n * opens disagree — \"AWS Accounts\" over a heading that says \"Sub-Resources\".\n * Defaults to the same generic wording the tab falls back to.\n */\n childrenLabel?: 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\ninterface SubResourceRowProps {\n resource: Resource\n parentSlug: string\n /**\n * The parent, but ONLY when the Admin column is being rendered — so this one\n * prop carries both \"show the cell\" and \"what to pass it\", and the row needs\n * no separate boolean. `undefined` means no Admin cell, which keeps the cells\n * in lockstep with the header and the empty row's `colSpan`, all three\n * derived from `showAdminColumn` in one place above.\n */\n adminParent?: Resource\n user: ReturnType<typeof usePluginUser>\n isHighlighted: boolean\n /** Set only when the body is virtualized: lets the virtualizer measure the row. */\n measureRef?: (el: HTMLTableRowElement | null) => void\n index?: number\n}\n\n/**\n * One row, memoized — which is load-bearing rather than tidiness.\n *\n * Measured on the 667-account table: a filter keystroke that changed no visible\n * row still cost ~400ms and wrote 9 attributes, because every row's element\n * tree was rebuilt and diffed. The browser can build all 33,861 nodes from\n * scratch in ~195ms, so React was spending twice that to change nothing. The\n * work was reconciliation, not the DOM.\n *\n * `memo` makes a keystroke cost one props comparison per row instead. For that\n * to hold, every prop here must be a primitive or a stable reference:\n * `resource` objects come from the parent's `useMemo` filter (same identities),\n * `user` is memoized in `usePluginUser`, and the highlight arrives as a\n * BOOLEAN. Passing `highlightSlug` and comparing inside would change one prop\n * on all 667 rows whenever the highlight moved, re-rendering the whole table to\n * restyle two rows.\n */\nfunction SubResourceRowBase({\n resource: sr,\n parentSlug,\n adminParent,\n user,\n isHighlighted,\n measureRef,\n index,\n}: SubResourceRowProps) {\n // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n const accountId = (sr.extra as Record<string, unknown> | null | undefined)\n ?.awsAccountId as string | undefined\n\n return (\n <TableRow\n ref={measureRef}\n data-index={index}\n // The row the user asked for, so it reads as \"this one\" even once the\n // filter is cleared and its siblings come back.\n aria-current={isHighlighted ? 'true' : undefined}\n // `group` so a row-slot contribution can react to the row being hovered\n // or focused (`group-hover:`, `group-focus-within:`). A plugin cannot add\n // this itself — it renders inside the cell, not on the row — and the\n // alternative is every plugin hand-rolling a `tr:hover &` arbitrary\n // variant.\n className={`group ${isHighlighted ? 'bg-primary/[0.06]' : ''}`}\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a table of\n 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 && <PersonBadge slug={sr.ownerPersonSlug} />}\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 {!accountId ? (\n <span className=\"text-muted-foreground\">—</span>\n ) : // The account id is what people copy; when the resource also\n // carries its own `appUrl` that is the console deep link for THAT\n // account, so the id doubles as the launch affordance.\n 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 </TableCell>\n {adminParent !== undefined && (\n // `relative`, so a contribution can position something against the\n // cell — a resting-state glyph under a hover-revealed control, for\n // instance. A plugin cannot add it, because the cell is the core's.\n <TableCell className=\"relative w-[168px]\">\n <ResourceSubResourceRowActions\n resource={sr}\n parent={adminParent}\n user={user}\n />\n </TableCell>\n )}\n </TableRow>\n )\n}\n\nconst SubResourceRow = memo(SubResourceRowBase)\n\n/** Stands in for the rows scrolled out of view, so the scrollbar is honest. */\nfunction SpacerRow({ height, columns }: { height: number; columns: number }) {\n return (\n <tr aria-hidden=\"true\">\n <td colSpan={columns} style={{ height, padding: 0, border: 0 }} />\n </tr>\n )\n}\n\n/**\n * Past this many rows the body is virtualized: only what is on screen, plus\n * a margin, is mounted.\n *\n * Tab panels mount only while active, so every switch to this tab built the\n * whole table from scratch — measured at ~950 ms for 667 rows and 33,861\n * nodes, every time. `memo` cannot help with that: it saves RE-rendering,\n * and this is mounting. Below the threshold nothing changes, which keeps the\n * common short child list rendering exactly as it always has.\n *\n * Spacer rows rather than absolutely positioned ones, so this stays a real\n * `<table>`: column widths still come from table layout, and the sticky\n * header still resolves against the container that scrolls.\n */\nconst VIRTUALIZE_ABOVE = 60\n\nfunction useRowWindow(\n rows: readonly Resource[],\n scrollRef: React.RefObject<HTMLDivElement | null>,\n) {\n const virtualize = rows.length > VIRTUALIZE_ABOVE\n // Stable on purpose. The virtualizer caches row measurements keyed on this\n // function's IDENTITY, so an inline arrow — a new function every render —\n // invalidated the cache on every render, which recomputed the visible range,\n // which re-rendered, which made a new arrow: \"Too many re-renders\", and the\n // card blanked the moment this tab opened.\n const getItemKey = useCallback((i: number) => rows[i]?.slug ?? i, [rows])\n // TanStack Virtual hands back functions the React Compiler cannot memoize\n // safely, so it skips this hook. Nothing to fix short of dropping the\n // library; registered in eslint.config.js alongside TanStack Table.\n // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual\n const rowVirtualizer = useVirtualizer({\n count: virtualize ? rows.length : 0,\n getScrollElement: () => scrollRef.current,\n estimateSize: () => 88,\n overscan: 8,\n getItemKey,\n // The container's height limit, so the first render already mounts the\n // rows that will be visible instead of painting an empty body for a frame.\n initialRect: { width: 0, height: 400 },\n })\n // No layout, no window. Without layout (jsdom, a print preview) the\n // container measures 0 tall, a virtualizer mounts nothing, and every row\n // becomes unreachable — so render them all instead.\n const [hasLayout, setHasLayout] = useState(true)\n useLayoutEffect(() => {\n const el = scrollRef.current\n if (el) setHasLayout(el.clientHeight > 0)\n }, [scrollRef])\n const windowed = virtualize && hasLayout\n const virtualRows = windowed ? rowVirtualizer.getVirtualItems() : []\n const padTop = virtualRows[0]?.start ?? 0\n const lastRow = virtualRows.at(-1)\n const padBottom = lastRow ? rowVirtualizer.getTotalSize() - lastRow.end : 0\n return {\n windowed,\n virtualRows,\n padTop,\n padBottom,\n measureElement: rowVirtualizer.measureElement,\n }\n}\n\nexport function SubResourcesSection({\n subResources,\n parentSlug,\n parent,\n initialSearch,\n childrenLabel,\n}: SubResourcesSectionProps) {\n // `useSlotFilled`, not \"render it and see\": the column is layout, so the\n // header has to be decided before any cell renders.\n //\n // ONE flag for the header, the cells and the empty row's colSpan. Deriving it\n // separately per site is how a header appears over a column with no cells —\n // the parent check only guarded the cells at first, and the header rendered\n // anyway.\n const showAdminColumn =\n useSlotFilled('resourceSubResourceRowActions') && parent !== undefined\n const user = usePluginUser()\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 focusSearchAfterClearRef = useRef(false)\n useEffect(() => {\n if (!selectedSub && focusSearchAfterClearRef.current) {\n focusSearchAfterClearRef.current = false\n searchInputRef.current?.focus()\n }\n }, [selectedSub])\n\n const clearSelection = () => {\n focusSearchAfterClearRef.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 /**\n * The input stays bound to `search`; the TABLE filters on this.\n *\n * Rebuilding the row list is the expensive half of a keystroke — on a large\n * entry it mounts and unmounts hundreds of rows — and doing it in the same\n * render as the input makes the caret wait for it. `useDeferredValue` splits\n * them: React commits the typed character immediately and re-renders the\n * table at low priority, interruptibly. Keep typing and the superseded\n * low-priority render is abandoned rather than finished and thrown away, so\n * a burst costs roughly one filter pass instead of one per character.\n *\n * This only works because the row is memoized. Deferring tells React it MAY\n * skip the expensive subtree between keystrokes; `memo` is what lets it\n * actually skip anything.\n *\n * Not a debounce: there is no fixed delay to tune, nothing is thrown away on\n * a fast typist, and the final result is never late — React yields to input\n * instead of waiting on a timer.\n */\n const deferredSearch = useDeferredValue(search)\n // True while the table is a render behind the box. Used only to dim it, so\n // stale rows read as stale instead of as the answer.\n const isFilterPending = search !== deferredSearch\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 (deferredSearch.trim()) {\n const q = deferredSearch.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 // `extra` is nullable on a served resource, and the cast hid that:\n // dereferencing it threw a TypeError the moment anything typed into\n // this box met a child without it, taking the whole table down.\n (\n (sr.extra as Record<string, unknown> | null | undefined)\n ?.awsAccountId as string | undefined\n )?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, deferredSearch, tierFilter, selectedSub])\n\n const scrollRef = useRef<HTMLDivElement>(null)\n const { windowed, virtualRows, padTop, padBottom, measureElement } =\n useRowWindow(filtered, scrollRef)\n // Fixed layout for any table big enough to be windowed. With auto layout a\n // column is as wide as its widest MOUNTED cell, so while scrolling a\n // windowed body the columns resized under the cursor — measured at 264px.\n // Keyed on the unfiltered size so a big table keeps one layout while it is\n // filtered below the threshold, rather than jumping as the user types.\n const fixedLayout = subResources.length > VIRTUALIZE_ABOVE\n const columnCount = showAdminColumn ? 6 : 5\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 {/* `data-testid`, because the label is now deployment-defined: the test\n kit used to locate this heading by matching the literal string\n \"Sub-Resources (n of m)\", which silently stopped finding it the\n moment a catalog named its children something else. */}\n <div\n data-testid=\"sub-resources-heading\"\n className=\"text-sm font-medium\"\n >\n {/* Each site keeps the exact wording it had before `childrenLabel`\n existed, so a deployment that names nothing renders identically —\n the heading and the placeholder never agreed, and quietly making\n them agree would change every existing catalog. */}\n {childrenLabel ?? 'Sub-Resources'} ({filtered.length} of{' '}\n {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 // Verbatim, not lower-cased: the deployment chose the casing and\n // an acronym does not survive it — \"AWS Accounts\" became\n // \"Search aws accounts…\". The no-label default stays lowercase\n // because that is the wording this placeholder already had.\n placeholder={`Search ${childrenLabel ?? '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 The vertical scroll sits on the Table's own wrapper rather than here,\n so the sticky header below pins to the element that actually scrolls —\n see the comment on `stickyHeader`. */}\n {/* Dimmed while the rows are a render behind the filter box, so stale\n rows read as stale rather than as the answer. `aria-busy` says the\n same thing to a screen reader. Opacity only — no spinner and no\n layout change, because on a fast filter this lasts one frame and\n anything heavier would strobe. */}\n <div\n className=\"rounded-lg border overflow-hidden\"\n aria-busy={isFilterPending || undefined}\n >\n <Table\n stickyHeader\n containerRef={scrollRef}\n className={`max-h-[400px] transition-opacity ${\n fixedLayout ? '[&>table]:table-fixed' : ''\n } ${isFilterPending ? 'opacity-60' : ''}`}\n >\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead className={fixedLayout ? 'w-[190px]' : undefined}>\n Owner\n </TableHead>\n <TableHead className={fixedLayout ? 'w-[230px]' : undefined}>\n Approvers\n </TableHead>\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n {/* Only when something fills it: an empty column with a header and\n no cells is worse than no column, and the open-source build\n registers no plugins at all. */}\n {showAdminColumn && (\n <TableHead className=\"w-[168px]\">Admin</TableHead>\n )}\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={showAdminColumn ? 6 : 5}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : windowed ? (\n <>\n {padTop > 0 && (\n <SpacerRow height={padTop} columns={columnCount} />\n )}\n {virtualRows.map((v) => {\n const sr = filtered[v.index]\n if (!sr) return null\n return (\n <SubResourceRow\n key={sr.slug}\n resource={sr}\n parentSlug={parentSlug}\n adminParent={showAdminColumn ? parent : undefined}\n user={user}\n isHighlighted={sr.slug === highlightSlug}\n measureRef={measureElement}\n index={v.index}\n />\n )\n })}\n {padBottom > 0 && (\n <SpacerRow height={padBottom} columns={columnCount} />\n )}\n </>\n ) : (\n filtered.map((sr) => (\n <SubResourceRow\n key={sr.slug}\n resource={sr}\n parentSlug={parentSlug}\n // One prop for \"render the Admin cell, with this parent\".\n // `showAdminColumn` already implies `parent !== undefined`,\n // so this cannot disagree with the header or the colSpan.\n adminParent={showAdminColumn ? parent : undefined}\n user={user}\n isHighlighted={sr.slug === highlightSlug}\n />\n ))\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":["_a"],"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, useNavigate, useSearch } from '@tanstack/react-router'\nimport { Search, X } from 'lucide-react'\nimport { useVirtualizer } from '@tanstack/react-virtual'\nimport {\n memo,\n useCallback,\n useDeferredValue,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} 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'\nimport {\n ResourceSubResourceRowActions,\n usePluginUser,\n useSlotFilled,\n} from '~/modules/extensions/index'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * The parent itself, for the row-actions slot's payload.\n *\n * Optional, and the Admin column is skipped without it: a row action is about\n * a child *of something*, and a plugin deciding whether it applies needs the\n * parent to decide against.\n */\n parent?: Resource\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 * What this app calls its children, from the parent's `childrenLabel`.\n *\n * The tab above already uses it, so without it here the tab and the panel it\n * opens disagree — \"AWS Accounts\" over a heading that says \"Sub-Resources\".\n * Defaults to the same generic wording the tab falls back to.\n */\n childrenLabel?: string\n}\n\n/**\n * The AWS account a child stands for, if it is one. `extra` is nullable on a\n * served resource, so the optional chain is load-bearing: dereferencing it threw\n * the moment anything typed into the filter met a child without it.\n */\nfunction awsAccountIdOf(sr: Resource): string | undefined {\n return (sr.extra as Record<string, unknown> | null | undefined)\n ?.awsAccountId as string | undefined\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\ninterface SubResourceRowProps {\n resource: Resource\n parentSlug: string\n /**\n * The parent, but ONLY when the Admin column is being rendered — so this one\n * prop carries both \"show the cell\" and \"what to pass it\", and the row needs\n * no separate boolean. `undefined` means no Admin cell, which keeps the cells\n * in lockstep with the header and the empty row's `colSpan`, all three\n * derived from `showAdminColumn` in one place above.\n */\n adminParent?: Resource\n /** Whether the AWS Account column is rendered — `showAccountColumn` above. */\n showAccount: boolean\n user: ReturnType<typeof usePluginUser>\n isHighlighted: boolean\n /** Set only when the body is virtualized: lets the virtualizer measure the row. */\n measureRef?: (el: HTMLTableRowElement | null) => void\n index?: number\n}\n\n/**\n * One row, memoized — which is load-bearing rather than tidiness.\n *\n * Measured on the 667-account table: a filter keystroke that changed no visible\n * row still cost ~400ms and wrote 9 attributes, because every row's element\n * tree was rebuilt and diffed. The browser can build all 33,861 nodes from\n * scratch in ~195ms, so React was spending twice that to change nothing. The\n * work was reconciliation, not the DOM.\n *\n * `memo` makes a keystroke cost one props comparison per row instead. For that\n * to hold, every prop here must be a primitive or a stable reference:\n * `resource` objects come from the parent's `useMemo` filter (same identities),\n * `user` is memoized in `usePluginUser`, and the highlight arrives as a\n * BOOLEAN. Passing `highlightSlug` and comparing inside would change one prop\n * on all 667 rows whenever the highlight moved, re-rendering the whole table to\n * restyle two rows.\n */\nfunction SubResourceRowBase({\n resource: sr,\n parentSlug,\n adminParent,\n showAccount,\n user,\n isHighlighted,\n measureRef,\n index,\n}: SubResourceRowProps) {\n // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n const accountId = awsAccountIdOf(sr)\n\n return (\n <TableRow\n ref={measureRef}\n data-index={index}\n // The row the user asked for, so it reads as \"this one\" even once the\n // filter is cleared and its siblings come back.\n aria-current={isHighlighted ? 'true' : undefined}\n // `group` so a row-slot contribution can react to the row being hovered\n // or focused (`group-hover:`, `group-focus-within:`). A plugin cannot add\n // this itself — it renders inside the cell, not on the row — and the\n // alternative is every plugin hand-rolling a `tr:hover &` arbitrary\n // variant.\n className={`group ${isHighlighted ? 'bg-primary/[0.06]' : ''}`}\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a table of\n 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 && <PersonBadge slug={sr.ownerPersonSlug} />}\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 {showAccount && (\n <TableCell>\n {!accountId ? (\n <span className=\"text-muted-foreground\">—</span>\n ) : // The account id is what people copy; when the resource also\n // carries its own `appUrl` that is the console deep link for THAT\n // account, so the id doubles as the launch affordance.\n 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 </TableCell>\n )}\n {adminParent !== undefined && (\n // `relative`, so a contribution can position something against the\n // cell — a resting-state glyph under a hover-revealed control, for\n // instance. A plugin cannot add it, because the cell is the core's.\n <TableCell className=\"relative w-[168px]\">\n <ResourceSubResourceRowActions\n resource={sr}\n parent={adminParent}\n user={user}\n />\n </TableCell>\n )}\n </TableRow>\n )\n}\n\nconst SubResourceRow = memo(SubResourceRowBase)\n\n/** Stands in for the rows scrolled out of view, so the scrollbar is honest. */\nfunction SpacerRow({ height, columns }: { height: number; columns: number }) {\n return (\n <tr aria-hidden=\"true\">\n <td colSpan={columns} style={{ height, padding: 0, border: 0 }} />\n </tr>\n )\n}\n\n/**\n * Past this many rows the body is virtualized: only what is on screen, plus\n * a margin, is mounted.\n *\n * Tab panels mount only while active, so every switch to this tab built the\n * whole table from scratch — measured at ~950 ms for 667 rows and 33,861\n * nodes, every time. `memo` cannot help with that: it saves RE-rendering,\n * and this is mounting. Below the threshold nothing changes, which keeps the\n * common short child list rendering exactly as it always has.\n *\n * Spacer rows rather than absolutely positioned ones, so this stays a real\n * `<table>`: column widths still come from table layout, and the sticky\n * header still resolves against the container that scrolls.\n */\nconst VIRTUALIZE_ABOVE = 60\n\nfunction useRowWindow(\n rows: readonly Resource[],\n scrollRef: React.RefObject<HTMLDivElement | null>,\n) {\n const virtualize = rows.length > VIRTUALIZE_ABOVE\n // Stable on purpose. The virtualizer caches row measurements keyed on this\n // function's IDENTITY, so an inline arrow — a new function every render —\n // invalidated the cache on every render, which recomputed the visible range,\n // which re-rendered, which made a new arrow: \"Too many re-renders\", and the\n // card blanked the moment this tab opened.\n const getItemKey = useCallback((i: number) => rows[i]?.slug ?? i, [rows])\n // TanStack Virtual hands back functions the React Compiler cannot memoize\n // safely, so it skips this hook. Nothing to fix short of dropping the\n // library; registered in eslint.config.js alongside TanStack Table.\n // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual\n const rowVirtualizer = useVirtualizer({\n count: virtualize ? rows.length : 0,\n getScrollElement: () => scrollRef.current,\n estimateSize: () => 88,\n overscan: 8,\n getItemKey,\n // The container's height limit, so the first render already mounts the\n // rows that will be visible instead of painting an empty body for a frame.\n initialRect: { width: 0, height: 400 },\n })\n // No layout, no window. Without layout (jsdom, a print preview) the\n // container measures 0 tall, a virtualizer mounts nothing, and every row\n // becomes unreachable — so render them all instead.\n const [hasLayout, setHasLayout] = useState(true)\n useLayoutEffect(() => {\n const el = scrollRef.current\n if (el) setHasLayout(el.clientHeight > 0)\n }, [scrollRef])\n const windowed = virtualize && hasLayout\n const virtualRows = windowed ? rowVirtualizer.getVirtualItems() : []\n const padTop = virtualRows[0]?.start ?? 0\n const lastRow = virtualRows.at(-1)\n const padBottom = lastRow ? rowVirtualizer.getTotalSize() - lastRow.end : 0\n return {\n windowed,\n virtualRows,\n padTop,\n padBottom,\n measureElement: rowVirtualizer.measureElement,\n }\n}\n\nexport function SubResourcesSection({\n subResources,\n parentSlug,\n parent,\n initialSearch,\n childrenLabel,\n}: SubResourcesSectionProps) {\n // `useSlotFilled`, not \"render it and see\": the column is layout, so the\n // header has to be decided before any cell renders.\n //\n // ONE flag for the header, the cells and the empty row's colSpan. Deriving it\n // separately per site is how a header appears over a column with no cells —\n // the parent check only guarded the cells at first, and the header rendered\n // anyway. The scope lets a plugin that serves only some parents keep the\n // column off every other parent's table; no parent means nothing to decide.\n const showAdminColumn = useSlotFilled(\n 'resourceSubResourceRowActions',\n parent ? { parent } : undefined,\n )\n // Same rule from the data side: only a family of AWS accounts has an account\n // id to show. Decided on the UNFILTERED list, so typing a filter that happens\n // to hide every account does not make the column jump.\n const showAccountColumn = useMemo(\n () => subResources.some((sr) => awsAccountIdOf(sr) !== undefined),\n [subResources],\n )\n const user = usePluginUser()\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 focusSearchAfterClearRef = useRef(false)\n useEffect(() => {\n if (!selectedSub && focusSearchAfterClearRef.current) {\n focusSearchAfterClearRef.current = false\n searchInputRef.current?.focus()\n }\n }, [selectedSub])\n\n const clearSelection = () => {\n focusSearchAfterClearRef.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 /**\n * The input stays bound to `search`; the TABLE filters on this.\n *\n * Rebuilding the row list is the expensive half of a keystroke — on a large\n * entry it mounts and unmounts hundreds of rows — and doing it in the same\n * render as the input makes the caret wait for it. `useDeferredValue` splits\n * them: React commits the typed character immediately and re-renders the\n * table at low priority, interruptibly. Keep typing and the superseded\n * low-priority render is abandoned rather than finished and thrown away, so\n * a burst costs roughly one filter pass instead of one per character.\n *\n * This only works because the row is memoized. Deferring tells React it MAY\n * skip the expensive subtree between keystrokes; `memo` is what lets it\n * actually skip anything.\n *\n * Not a debounce: there is no fixed delay to tune, nothing is thrown away on\n * a fast typist, and the final result is never late — React yields to input\n * instead of waiting on a timer.\n */\n const deferredSearch = useDeferredValue(search)\n // True while the table is a render behind the box. Used only to dim it, so\n // stale rows read as stale instead of as the answer.\n const isFilterPending = search !== deferredSearch\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 (deferredSearch.trim()) {\n const q = deferredSearch.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 awsAccountIdOf(sr)?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, deferredSearch, tierFilter, selectedSub])\n\n const scrollRef = useRef<HTMLDivElement>(null)\n const { windowed, virtualRows, padTop, padBottom, measureElement } =\n useRowWindow(filtered, scrollRef)\n // Fixed layout for any table big enough to be windowed. With auto layout a\n // column is as wide as its widest MOUNTED cell, so while scrolling a\n // windowed body the columns resized under the cursor — measured at 264px.\n // Keyed on the unfiltered size so a big table keeps one layout while it is\n // filtered below the threshold, rather than jumping as the user types.\n const fixedLayout = subResources.length > VIRTUALIZE_ABOVE\n // Name, Tier, Owner, Approvers, then the two optional columns.\n const columnCount =\n 4 + (showAccountColumn ? 1 : 0) + (showAdminColumn ? 1 : 0)\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 {/* `data-testid`, because the label is now deployment-defined: the test\n kit used to locate this heading by matching the literal string\n \"Sub-Resources (n of m)\", which silently stopped finding it the\n moment a catalog named its children something else. */}\n <div\n data-testid=\"sub-resources-heading\"\n className=\"text-sm font-medium\"\n >\n {/* Each site keeps the exact wording it had before `childrenLabel`\n existed, so a deployment that names nothing renders identically —\n the heading and the placeholder never agreed, and quietly making\n them agree would change every existing catalog. */}\n {childrenLabel ?? 'Sub-Resources'} ({filtered.length} of{' '}\n {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 // Verbatim, not lower-cased: the deployment chose the casing and\n // an acronym does not survive it — \"AWS Accounts\" became\n // \"Search aws accounts…\". The no-label default stays lowercase\n // because that is the wording this placeholder already had.\n placeholder={`Search ${childrenLabel ?? '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 The vertical scroll sits on the Table's own wrapper rather than here,\n so the sticky header below pins to the element that actually scrolls —\n see the comment on `stickyHeader`. */}\n {/* Dimmed while the rows are a render behind the filter box, so stale\n rows read as stale rather than as the answer. `aria-busy` says the\n same thing to a screen reader. Opacity only — no spinner and no\n layout change, because on a fast filter this lasts one frame and\n anything heavier would strobe. */}\n <div\n className=\"rounded-lg border overflow-hidden\"\n aria-busy={isFilterPending || undefined}\n >\n <Table\n stickyHeader\n containerRef={scrollRef}\n className={`max-h-[400px] transition-opacity ${\n fixedLayout ? '[&>table]:table-fixed' : ''\n } ${isFilterPending ? 'opacity-60' : ''}`}\n >\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead className={fixedLayout ? 'w-[190px]' : undefined}>\n Owner\n </TableHead>\n <TableHead className={fixedLayout ? 'w-[230px]' : undefined}>\n Approvers\n </TableHead>\n {showAccountColumn && (\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n )}\n {/* Only when something fills it: an empty column with a header and\n no cells is worse than no column, and the open-source build\n registers no plugins at all. */}\n {showAdminColumn && (\n <TableHead className=\"w-[168px]\">Admin</TableHead>\n )}\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={columnCount}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : windowed ? (\n <>\n {padTop > 0 && (\n <SpacerRow height={padTop} columns={columnCount} />\n )}\n {virtualRows.map((v) => {\n const sr = filtered[v.index]\n if (!sr) return null\n return (\n <SubResourceRow\n key={sr.slug}\n resource={sr}\n parentSlug={parentSlug}\n adminParent={showAdminColumn ? parent : undefined}\n showAccount={showAccountColumn}\n user={user}\n isHighlighted={sr.slug === highlightSlug}\n measureRef={measureElement}\n index={v.index}\n />\n )\n })}\n {padBottom > 0 && (\n <SpacerRow height={padBottom} columns={columnCount} />\n )}\n </>\n ) : (\n filtered.map((sr) => (\n <SubResourceRow\n key={sr.slug}\n resource={sr}\n parentSlug={parentSlug}\n // One prop for \"render the Admin cell, with this parent\".\n // `showAdminColumn` already implies `parent !== undefined`,\n // so this cannot disagree with the header or the colSpan.\n adminParent={showAdminColumn ? parent : undefined}\n showAccount={showAccountColumn}\n user={user}\n isHighlighted={sr.slug === highlightSlug}\n />\n ))\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":["_a"],"mappings":"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"}
|
|
@@ -35,7 +35,9 @@ export interface ResourceDetailSlots {
|
|
|
35
35
|
* family has children (hundreds, in a large catalog) — keep the cell cheap and
|
|
36
36
|
* defer anything expensive until it is interacted with. And the column only
|
|
37
37
|
* appears when a plugin actually fills it, so a build with no plugin renders
|
|
38
|
-
* the table exactly as before.
|
|
38
|
+
* the table exactly as before. A plugin that serves only some parents says so
|
|
39
|
+
* in `appliesTo` (see `ResourceDetailSlotScopes`), so every other parent's
|
|
40
|
+
* table gets no column rather than a column of empty cells.
|
|
39
41
|
*/
|
|
40
42
|
resourceSubResourceRowActions: {
|
|
41
43
|
/** The child this row is showing. */
|
|
@@ -45,6 +47,19 @@ export interface ResourceDetailSlots {
|
|
|
45
47
|
user: PluginUser | null;
|
|
46
48
|
};
|
|
47
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* What the core knows when it decides a slot's LAYOUT, before any row renders.
|
|
52
|
+
*
|
|
53
|
+
* The input to a plugin's `appliesTo` predicate for that slot. A contribution
|
|
54
|
+
* renders as a component, so the core cannot ask it whether it would render
|
|
55
|
+
* nothing; the plugin has to say up front which scopes it serves.
|
|
56
|
+
*/
|
|
57
|
+
export interface ResourceDetailSlotScopes {
|
|
58
|
+
resourceSubResourceRowActions: {
|
|
59
|
+
/** The entry whose sub-resources table is being laid out. */
|
|
60
|
+
parent: Resource;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
48
63
|
/**
|
|
49
64
|
* Wrappers on the resource detail card.
|
|
50
65
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resourceDetail.js","sources":["../../../../../src/modules/extensions/features/resourceDetail.ts"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { slotFactory, wrapperFactory } from '../factories'\nimport type { PluginUser } from '../types'\n\n/**\n * Leaf slots on the resource detail card.\n *\n * Payloads are additive-only. Adding a field is invisible to existing plugins;\n * renaming or removing one breaks them at compile time on their next core bump,\n * which is the intended moment for that to surface.\n */\nexport interface ResourceDetailSlots {\n /**\n * Actions on the detail card's header band.\n *\n * The band and not a tab panel: tab panels mount only while active and\n * Overview is first, so a slot inside a tab is invisible until a second click.\n */\n resourceDetailAccessActions: {\n /** The resource on screen — a top-level app OR one of its sub-resources. */\n resource: Resource\n /** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */\n parent?: Resource\n /** Children of `parent ?? resource`: the family's sub-resources. */\n subResources: Resource[]\n user: PluginUser | null\n }\n\n /**\n * One cell per row of the sub-resources table, in a column of its own.\n *\n * Called once per rendered row, which is the opposite shape to\n * `resourceDetailAccessActions` — that one is called once and handed the whole\n * list. Use this when the action belongs to a single child.\n *\n * Two things to know before filling it. The table renders EVERY row with no\n * virtualisation, so a contribution here is mounted as many times as the\n * family has children (hundreds, in a large catalog) — keep the cell cheap and\n * defer anything expensive until it is interacted with. And the column only\n * appears when a plugin actually fills it, so a build with no plugin renders\n * the table exactly as before.\n */\n resourceSubResourceRowActions: {\n /** The child this row is showing. */\n resource: Resource\n /** The entry the row hangs off. Always present here, unlike the header slot. */\n parent: Resource\n user: PluginUser | null\n }\n}\n\n/**\n * Wrappers on the resource detail card.\n *\n * `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`\n * subtree, so anything a plugin provides here is scoped to the resource on\n * screen and is discarded when the card navigates. That is what lets two slots\n * share per-resource state without the plugin writing any cleanup.\n */\nexport interface ResourceDetailWrappers {\n resourceDetailProvider: {\n resource: Resource\n parent?: Resource\n }\n}\n\nconst mkSlot = slotFactory<ResourceDetailSlots>()\nconst mkWrapper = wrapperFactory<ResourceDetailWrappers>()\n\nexport const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')\nexport const ResourceSubResourceRowActions = mkSlot(\n 'resourceSubResourceRowActions',\n)\nexport const ResourceDetailProvider = mkWrapper('resourceDetailProvider')\n"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"resourceDetail.js","sources":["../../../../../src/modules/extensions/features/resourceDetail.ts"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { slotFactory, wrapperFactory } from '../factories'\nimport type { PluginUser } from '../types'\n\n/**\n * Leaf slots on the resource detail card.\n *\n * Payloads are additive-only. Adding a field is invisible to existing plugins;\n * renaming or removing one breaks them at compile time on their next core bump,\n * which is the intended moment for that to surface.\n */\nexport interface ResourceDetailSlots {\n /**\n * Actions on the detail card's header band.\n *\n * The band and not a tab panel: tab panels mount only while active and\n * Overview is first, so a slot inside a tab is invisible until a second click.\n */\n resourceDetailAccessActions: {\n /** The resource on screen — a top-level app OR one of its sub-resources. */\n resource: Resource\n /** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */\n parent?: Resource\n /** Children of `parent ?? resource`: the family's sub-resources. */\n subResources: Resource[]\n user: PluginUser | null\n }\n\n /**\n * One cell per row of the sub-resources table, in a column of its own.\n *\n * Called once per rendered row, which is the opposite shape to\n * `resourceDetailAccessActions` — that one is called once and handed the whole\n * list. Use this when the action belongs to a single child.\n *\n * Two things to know before filling it. The table renders EVERY row with no\n * virtualisation, so a contribution here is mounted as many times as the\n * family has children (hundreds, in a large catalog) — keep the cell cheap and\n * defer anything expensive until it is interacted with. And the column only\n * appears when a plugin actually fills it, so a build with no plugin renders\n * the table exactly as before. A plugin that serves only some parents says so\n * in `appliesTo` (see `ResourceDetailSlotScopes`), so every other parent's\n * table gets no column rather than a column of empty cells.\n */\n resourceSubResourceRowActions: {\n /** The child this row is showing. */\n resource: Resource\n /** The entry the row hangs off. Always present here, unlike the header slot. */\n parent: Resource\n user: PluginUser | null\n }\n}\n\n/**\n * What the core knows when it decides a slot's LAYOUT, before any row renders.\n *\n * The input to a plugin's `appliesTo` predicate for that slot. A contribution\n * renders as a component, so the core cannot ask it whether it would render\n * nothing; the plugin has to say up front which scopes it serves.\n */\nexport interface ResourceDetailSlotScopes {\n resourceSubResourceRowActions: {\n /** The entry whose sub-resources table is being laid out. */\n parent: Resource\n }\n}\n\n/**\n * Wrappers on the resource detail card.\n *\n * `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`\n * subtree, so anything a plugin provides here is scoped to the resource on\n * screen and is discarded when the card navigates. That is what lets two slots\n * share per-resource state without the plugin writing any cleanup.\n */\nexport interface ResourceDetailWrappers {\n resourceDetailProvider: {\n resource: Resource\n parent?: Resource\n }\n}\n\nconst mkSlot = slotFactory<ResourceDetailSlots>()\nconst mkWrapper = wrapperFactory<ResourceDetailWrappers>()\n\nexport const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')\nexport const ResourceSubResourceRowActions = mkSlot(\n 'resourceSubResourceRowActions',\n)\nexport const ResourceDetailProvider = mkWrapper('resourceDetailProvider')\n"],"names":[],"mappings":";AAkFA,MAAM,SAAS,YAAA;AACf,MAAM,YAAY,eAAA;AAEX,MAAM,8BAA8B,OAAO,6BAA6B;AACxE,MAAM,gCAAgC;AAAA,EAC3C;AACF;AACO,MAAM,yBAAyB,UAAU,wBAAwB;"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
|
-
import { ResourceDetailSlots, ResourceDetailWrappers } from './features/resourceDetail.js';
|
|
2
|
+
import { ResourceDetailSlotScopes, ResourceDetailSlots, ResourceDetailWrappers } from './features/resourceDetail.js';
|
|
3
3
|
import { PluginUser } from './types.js';
|
|
4
4
|
export { ExtensionsContext, useExtensions } from './ExtensionsContext.js';
|
|
5
5
|
export { slotFactory, wrapperFactory } from './factories.js';
|
|
@@ -7,7 +7,7 @@ export { makeSlot, makeWrapper } from './makeSlot.js';
|
|
|
7
7
|
export type { PluginUser, PluginRegistration } from './types.js';
|
|
8
8
|
export type { AssertDisjoint } from './types.js';
|
|
9
9
|
export { ResourceDetailAccessActions, ResourceSubResourceRowActions, ResourceDetailProvider, } from './features/resourceDetail.js';
|
|
10
|
-
export type { ResourceDetailSlots, ResourceDetailWrappers, } from './features/resourceDetail.js';
|
|
10
|
+
export type { ResourceDetailSlotScopes, ResourceDetailSlots, ResourceDetailWrappers, } from './features/resourceDetail.js';
|
|
11
11
|
/**
|
|
12
12
|
* Every leaf slot in the core, composed from the per-feature specs.
|
|
13
13
|
*
|
|
@@ -17,6 +17,8 @@ export type { ResourceDetailSlots, ResourceDetailWrappers, } from './features/re
|
|
|
17
17
|
export type SlotSpec = ResourceDetailSlots;
|
|
18
18
|
/** Every wrapper slot in the core. */
|
|
19
19
|
export type WrapperSpec = ResourceDetailWrappers;
|
|
20
|
+
/** The layout scope of every slot whose layout depends on more than presence. */
|
|
21
|
+
export type SlotScopeSpec = ResourceDetailSlotScopes;
|
|
20
22
|
/**
|
|
21
23
|
* A plugin's leaf-slot handlers. Each receives exactly its slot's payload.
|
|
22
24
|
*
|
|
@@ -33,6 +35,15 @@ export type PluginWrappers = {
|
|
|
33
35
|
children: ReactNode;
|
|
34
36
|
}) => ReactNode;
|
|
35
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Which layout scopes a plugin's slot contribution serves. Absent = all of them.
|
|
40
|
+
*
|
|
41
|
+
* Only slots listed in `SlotScopeSpec` take a predicate: those are the slots
|
|
42
|
+
* whose layout the core decides before rendering any contribution.
|
|
43
|
+
*/
|
|
44
|
+
export type PluginAppliesTo = {
|
|
45
|
+
[N in keyof SlotScopeSpec]?: (scope: SlotScopeSpec[N]) => boolean;
|
|
46
|
+
};
|
|
36
47
|
/**
|
|
37
48
|
* One deployment-specific feature's contribution to the core's UI.
|
|
38
49
|
*
|
|
@@ -49,6 +60,7 @@ export interface AcPlugin {
|
|
|
49
60
|
name: string;
|
|
50
61
|
slots?: PluginSlots;
|
|
51
62
|
wrappers?: PluginWrappers;
|
|
63
|
+
appliesTo?: PluginAppliesTo;
|
|
52
64
|
}
|
|
53
65
|
/**
|
|
54
66
|
* Whether any registered plugin fills this slot.
|
|
@@ -62,8 +74,12 @@ export interface AcPlugin {
|
|
|
62
74
|
* Use it ONLY for layout. A slot must never be the only route to something the
|
|
63
75
|
* core itself needs, so branching behaviour on a plugin's presence is a design
|
|
64
76
|
* smell even where branching layout is not.
|
|
77
|
+
*
|
|
78
|
+
* A slot in `SlotScopeSpec` also takes the scope being laid out, and counts only
|
|
79
|
+
* plugins whose `appliesTo` accepts it. An `undefined` scope means there is
|
|
80
|
+
* nothing to decide against, so the slot reads as unfilled.
|
|
65
81
|
*/
|
|
66
|
-
export declare function useSlotFilled(slot: keyof
|
|
82
|
+
export declare function useSlotFilled<TSlot extends keyof SlotSpec>(slot: TSlot, ...scope: TSlot extends keyof SlotScopeSpec ? [scope: SlotScopeSpec[TSlot] | undefined] : []): boolean;
|
|
67
83
|
/**
|
|
68
84
|
* The signed-in user, reduced to what slots are given.
|
|
69
85
|
*
|
|
@@ -2,10 +2,16 @@ import { useMemo } from "react";
|
|
|
2
2
|
import { useOptionalUser } from "../auth/AuthContext.js";
|
|
3
3
|
import { useExtensions } from "./ExtensionsContext.js";
|
|
4
4
|
import { ExtensionsContext } from "./ExtensionsContext.js";
|
|
5
|
-
function useSlotFilled(slot) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
function useSlotFilled(slot, ...scope) {
|
|
6
|
+
const plugins = useExtensions();
|
|
7
|
+
const scoped = scope.length > 0;
|
|
8
|
+
const value = scope[0];
|
|
9
|
+
if (scoped && value === void 0) return false;
|
|
10
|
+
return plugins.some((plugin) => {
|
|
11
|
+
var _a, _b;
|
|
12
|
+
if (!((_a = plugin.slots) == null ? void 0 : _a[slot])) return false;
|
|
13
|
+
const applies = (_b = plugin.appliesTo) == null ? void 0 : _b[slot];
|
|
14
|
+
return !scoped || !applies || applies(value);
|
|
9
15
|
});
|
|
10
16
|
}
|
|
11
17
|
function usePluginUser() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../../../../src/modules/extensions/index.ts"],"sourcesContent":["import { useMemo } from 'react'\nimport type { ReactNode } from 'react'\n// Relative, not `~`: this file is on the published entry's graph and therefore\n// in every consumer's program, and `~` is the CORE's alias — a consumer only\n// resolves it if it has mapped it, which the example app has not.\nimport { useOptionalUser } from '../auth/AuthContext'\nimport type {\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\nimport type { AssertDisjoint, PluginUser } from './types'\n\nimport { useExtensions } from './ExtensionsContext'\n\nexport { ExtensionsContext, useExtensions } from './ExtensionsContext'\nexport { slotFactory, wrapperFactory } from './factories'\nexport { makeSlot, makeWrapper } from './makeSlot'\nexport type { PluginUser, PluginRegistration } from './types'\nexport type { AssertDisjoint } from './types'\nexport {\n ResourceDetailAccessActions,\n ResourceSubResourceRowActions,\n ResourceDetailProvider,\n} from './features/resourceDetail'\nexport type {\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\n\n/**\n * Every leaf slot in the core, composed from the per-feature specs.\n *\n * Adding a feature means a new `features/<name>.ts` plus one member here and one\n * line in the guard below. No existing feature file is touched.\n */\nexport type SlotSpec = ResourceDetailSlots\n\n/** Every wrapper slot in the core. */\nexport type WrapperSpec = ResourceDetailWrappers\n\n// Guard: with a second feature this becomes\n// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the\n// duplicated id spelled out in the error. With one feature it is trivially true,\n// and is kept so the next feature inherits the check rather than inventing it.\nconst _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true\nvoid _slotIdsAreDisjoint\n\n/**\n * A plugin's leaf-slot handlers. Each receives exactly its slot's payload.\n *\n * Registering an unknown slot, or reading a field the slot does not pass, is a\n * compile error — which is the whole point of composing `SlotSpec` rather than\n * typing handlers as `(props: unknown)`.\n */\nexport type PluginSlots = {\n [N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode\n}\n\n/** A plugin's wrapper handlers. `children` is injected by the core. */\nexport type PluginWrappers = {\n [N in keyof WrapperSpec]?: (\n props: WrapperSpec[N] & { children: ReactNode },\n ) => ReactNode\n}\n\n/**\n * One deployment-specific feature's contribution to the core's UI.\n *\n * Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map\n * holds one value per key, so a second plugin at the same slot would silently\n * replace the first and two wrappers around one subtree would be impossible.\n *\n * The array must be a module-level constant. The core iterates it in render\n * order, so rebuilding it per render would change hook call order inside the\n * plugins and violate the rules of hooks.\n */\nexport interface AcPlugin {\n /** Identity, used in error reports and test assertions. */\n name: string\n slots?: PluginSlots\n wrappers?: PluginWrappers\n}\n\n/**\n * Whether any registered plugin fills this slot.\n *\n * For the cases where rendering the slot is not enough and the core has to\n * change its own layout around it — a table column, say, which would otherwise\n * be an empty column with a header and no cells in every build that registers\n * nothing. Rendering an empty slot costs nothing; reserving space for one is\n * what this is for.\n *\n * Use it ONLY for layout. A slot must never be the only route to something the\n * core itself needs, so branching behaviour on a plugin's presence is a design\n * smell even where branching layout is not.\n */\nexport function useSlotFilled(slot: keyof
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/modules/extensions/index.ts"],"sourcesContent":["import { useMemo } from 'react'\nimport type { ReactNode } from 'react'\n// Relative, not `~`: this file is on the published entry's graph and therefore\n// in every consumer's program, and `~` is the CORE's alias — a consumer only\n// resolves it if it has mapped it, which the example app has not.\nimport { useOptionalUser } from '../auth/AuthContext'\nimport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\nimport type { AssertDisjoint, PluginUser } from './types'\n\nimport { useExtensions } from './ExtensionsContext'\n\nexport { ExtensionsContext, useExtensions } from './ExtensionsContext'\nexport { slotFactory, wrapperFactory } from './factories'\nexport { makeSlot, makeWrapper } from './makeSlot'\nexport type { PluginUser, PluginRegistration } from './types'\nexport type { AssertDisjoint } from './types'\nexport {\n ResourceDetailAccessActions,\n ResourceSubResourceRowActions,\n ResourceDetailProvider,\n} from './features/resourceDetail'\nexport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\n\n/**\n * Every leaf slot in the core, composed from the per-feature specs.\n *\n * Adding a feature means a new `features/<name>.ts` plus one member here and one\n * line in the guard below. No existing feature file is touched.\n */\nexport type SlotSpec = ResourceDetailSlots\n\n/** Every wrapper slot in the core. */\nexport type WrapperSpec = ResourceDetailWrappers\n\n/** The layout scope of every slot whose layout depends on more than presence. */\nexport type SlotScopeSpec = ResourceDetailSlotScopes\n\n// Guard: with a second feature this becomes\n// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the\n// duplicated id spelled out in the error. With one feature it is trivially true,\n// and is kept so the next feature inherits the check rather than inventing it.\nconst _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true\nvoid _slotIdsAreDisjoint\n\n/**\n * A plugin's leaf-slot handlers. Each receives exactly its slot's payload.\n *\n * Registering an unknown slot, or reading a field the slot does not pass, is a\n * compile error — which is the whole point of composing `SlotSpec` rather than\n * typing handlers as `(props: unknown)`.\n */\nexport type PluginSlots = {\n [N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode\n}\n\n/** A plugin's wrapper handlers. `children` is injected by the core. */\nexport type PluginWrappers = {\n [N in keyof WrapperSpec]?: (\n props: WrapperSpec[N] & { children: ReactNode },\n ) => ReactNode\n}\n\n/**\n * Which layout scopes a plugin's slot contribution serves. Absent = all of them.\n *\n * Only slots listed in `SlotScopeSpec` take a predicate: those are the slots\n * whose layout the core decides before rendering any contribution.\n */\nexport type PluginAppliesTo = {\n [N in keyof SlotScopeSpec]?: (scope: SlotScopeSpec[N]) => boolean\n}\n\n/**\n * One deployment-specific feature's contribution to the core's UI.\n *\n * Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map\n * holds one value per key, so a second plugin at the same slot would silently\n * replace the first and two wrappers around one subtree would be impossible.\n *\n * The array must be a module-level constant. The core iterates it in render\n * order, so rebuilding it per render would change hook call order inside the\n * plugins and violate the rules of hooks.\n */\nexport interface AcPlugin {\n /** Identity, used in error reports and test assertions. */\n name: string\n slots?: PluginSlots\n wrappers?: PluginWrappers\n appliesTo?: PluginAppliesTo\n}\n\n/**\n * Whether any registered plugin fills this slot.\n *\n * For the cases where rendering the slot is not enough and the core has to\n * change its own layout around it — a table column, say, which would otherwise\n * be an empty column with a header and no cells in every build that registers\n * nothing. Rendering an empty slot costs nothing; reserving space for one is\n * what this is for.\n *\n * Use it ONLY for layout. A slot must never be the only route to something the\n * core itself needs, so branching behaviour on a plugin's presence is a design\n * smell even where branching layout is not.\n *\n * A slot in `SlotScopeSpec` also takes the scope being laid out, and counts only\n * plugins whose `appliesTo` accepts it. An `undefined` scope means there is\n * nothing to decide against, so the slot reads as unfilled.\n */\nexport function useSlotFilled<TSlot extends keyof SlotSpec>(\n slot: TSlot,\n ...scope: TSlot extends keyof SlotScopeSpec\n ? [scope: SlotScopeSpec[TSlot] | undefined]\n : []\n): boolean {\n const plugins = useExtensions()\n const scoped = scope.length > 0\n const value = scope[0]\n if (scoped && value === undefined) return false\n return plugins.some((plugin) => {\n if (!plugin.slots?.[slot]) return false\n const applies = plugin.appliesTo?.[slot] as\n | ((s: typeof value) => boolean)\n | undefined\n return !scoped || !applies || applies(value)\n })\n}\n\n/**\n * The signed-in user, reduced to what slots are given.\n *\n * One place, so two slots cannot disagree about the shape — and deliberately a\n * projection rather than the real user object: the core's `User` is free to grow\n * fields, and handing a plugin the whole thing would make every one of them part\n * of the published slot contract.\n *\n * Returns `null` with no `AuthProvider` above it. The detail card renders inside\n * the catalog panel, which some suites mount outside the provider, and throwing\n * there would make a plugin's presence break unrelated tests.\n */\nexport function usePluginUser(): PluginUser | null {\n const user = useOptionalUser()\n return useMemo(\n // `?? false`, because admin is a capability: absent must read as \"not an\n // admin\", never as undefined leaking into a plugin's own check.\n () => (user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null),\n [user],\n )\n}\n"],"names":[],"mappings":";;;;AAoHO,SAAS,cACd,SACG,OAGM;AACT,QAAM,UAAU,cAAA;AAChB,QAAM,SAAS,MAAM,SAAS;AAC9B,QAAM,QAAQ,MAAM,CAAC;AACrB,MAAI,UAAU,UAAU,OAAW,QAAO;AAC1C,SAAO,QAAQ,KAAK,CAAC,WAAW;;AAC9B,QAAI,GAAC,YAAO,UAAP,mBAAe,OAAO,QAAO;AAClC,UAAM,WAAU,YAAO,cAAP,mBAAmB;AAGnC,WAAO,CAAC,UAAU,CAAC,WAAW,QAAQ,KAAK;AAAA,EAC7C,CAAC;AACH;AAcO,SAAS,gBAAmC;AACjD,QAAM,OAAO,gBAAA;AACb,SAAO;AAAA;AAAA;AAAA,IAGL,MAAO,OAAO,EAAE,OAAO,KAAK,OAAO,SAAS,KAAK,WAAW,MAAA,IAAU;AAAA,IACtE,CAAC,IAAI;AAAA,EAAA;AAET;"}
|
|
@@ -31,6 +31,7 @@ export interface PluginRegistration {
|
|
|
31
31
|
name: string;
|
|
32
32
|
slots?: Record<string, OpaqueRenderer | undefined>;
|
|
33
33
|
wrappers?: Record<string, OpaqueRenderer | undefined>;
|
|
34
|
+
appliesTo?: Record<string, ((scope: never) => boolean) | undefined>;
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
36
37
|
* Compile error naming any slot id two features both claim.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.0",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"tailwindcss": "^4.1.7",
|
|
119
119
|
"tiptap-markdown": "^0.8.0",
|
|
120
120
|
"tslib": "^2.3.0",
|
|
121
|
-
"@igstack/app-catalog-shared-core": "6.
|
|
121
|
+
"@igstack/app-catalog-shared-core": "6.9.0"
|
|
122
122
|
},
|
|
123
123
|
"devDependencies": {
|
|
124
124
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"vite-plugin-static-copy": "^3.1.4",
|
|
147
147
|
"vite-plugin-svgr": "^4.2.0",
|
|
148
148
|
"vitest": "^4.1.2",
|
|
149
|
-
"@igstack/app-catalog-backend-core": "6.
|
|
149
|
+
"@igstack/app-catalog-backend-core": "6.9.0"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
152
|
"@tanstack/react-router": "1.170.41",
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
"vite": "^6.3.5",
|
|
157
157
|
"vite-plugin-svgr": "^4.2.0"
|
|
158
158
|
},
|
|
159
|
-
"gitHead": "
|
|
159
|
+
"gitHead": "6b0730c9d2d396517940db7d847080f4737df2fd",
|
|
160
160
|
"scripts": {
|
|
161
161
|
"build": "vite build",
|
|
162
162
|
"build:lenient": "vite build --mode lenient",
|
package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx
CHANGED
|
@@ -60,8 +60,9 @@ describe('the sub-resources Admin column', () => {
|
|
|
60
60
|
expect(
|
|
61
61
|
screen.queryByRole('columnheader', { name: 'Admin' }),
|
|
62
62
|
).not.toBeInTheDocument()
|
|
63
|
-
//
|
|
64
|
-
|
|
63
|
+
// Name, Tier, Owner, Approvers — these children carry no account id, so
|
|
64
|
+
// the AWS Account column is absent too.
|
|
65
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(4)
|
|
65
66
|
})
|
|
66
67
|
|
|
67
68
|
it('appears, with one cell per row, once a plugin fills it', () => {
|
|
@@ -70,7 +71,7 @@ describe('the sub-resources Admin column', () => {
|
|
|
70
71
|
expect(
|
|
71
72
|
screen.getByRole('columnheader', { name: 'Admin' }),
|
|
72
73
|
).toBeInTheDocument()
|
|
73
|
-
expect(screen.getAllByRole('columnheader')).toHaveLength(
|
|
74
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(5)
|
|
74
75
|
|
|
75
76
|
// Called once PER ROW and handed that row — the opposite shape to the
|
|
76
77
|
// header slot, which is called once with the whole list.
|
|
@@ -96,7 +97,43 @@ describe('the sub-resources Admin column', () => {
|
|
|
96
97
|
)
|
|
97
98
|
|
|
98
99
|
const empty = within(container).getByText(/No resources match your filters/)
|
|
99
|
-
expect(empty).toHaveAttribute('colspan', '
|
|
100
|
+
expect(empty).toHaveAttribute('colspan', '5')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('is absent on a parent the plugin says it does not serve', () => {
|
|
104
|
+
// A contribution that returns null for this parent would still leave a
|
|
105
|
+
// header over a column of empty cells; `appliesTo` is how the plugin keeps
|
|
106
|
+
// the column off tables it has nothing for.
|
|
107
|
+
mount([
|
|
108
|
+
{
|
|
109
|
+
...rowActionsPlugin,
|
|
110
|
+
appliesTo: {
|
|
111
|
+
resourceSubResourceRowActions: ({ parent: p }) => p.slug === 'other',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
screen.queryByRole('columnheader', { name: 'Admin' }),
|
|
118
|
+
).not.toBeInTheDocument()
|
|
119
|
+
expect(
|
|
120
|
+
screen.queryByRole('button', { name: /Provision/ }),
|
|
121
|
+
).not.toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('appears on a parent the plugin says it serves', () => {
|
|
125
|
+
mount([
|
|
126
|
+
{
|
|
127
|
+
...rowActionsPlugin,
|
|
128
|
+
appliesTo: {
|
|
129
|
+
resourceSubResourceRowActions: ({ parent: p }) => p.slug === 'cloud',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
screen.getByRole('columnheader', { name: 'Admin' }),
|
|
136
|
+
).toBeInTheDocument()
|
|
100
137
|
})
|
|
101
138
|
|
|
102
139
|
it('skips the column when the parent is not known', () => {
|
|
@@ -96,6 +96,40 @@ describe('SubResourcesSection — account id as deep link', () => {
|
|
|
96
96
|
})
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
+
// The AWS Account column belongs to families of AWS accounts. Anything else —
|
|
100
|
+
// workflow namespaces, data projects — has nothing to put in it, and a column
|
|
101
|
+
// of dashes under that header reads as missing data.
|
|
102
|
+
describe('SubResourcesSection — AWS Account column', () => {
|
|
103
|
+
it('is absent when no child carries an account id', () => {
|
|
104
|
+
render(
|
|
105
|
+
<SubResourcesSection
|
|
106
|
+
parentSlug="workflows"
|
|
107
|
+
subResources={[sub('ns-a', null), sub('ns-b', { region: 'x' })]}
|
|
108
|
+
/>,
|
|
109
|
+
)
|
|
110
|
+
expect(
|
|
111
|
+
screen.queryByRole('columnheader', { name: 'AWS Account' }),
|
|
112
|
+
).not.toBeInTheDocument()
|
|
113
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(4)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('is present when at least one child carries an account id', () => {
|
|
117
|
+
render(
|
|
118
|
+
<SubResourcesSection
|
|
119
|
+
parentSlug="cloud-console"
|
|
120
|
+
subResources={[
|
|
121
|
+
sub('acct-a', { awsAccountId: '111122223333' }),
|
|
122
|
+
sub('other', null),
|
|
123
|
+
]}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
expect(
|
|
127
|
+
screen.getByRole('columnheader', { name: 'AWS Account' }),
|
|
128
|
+
).toBeInTheDocument()
|
|
129
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(5)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
99
133
|
// A `?sub=` deep link singles out one row; the table must say so and offer a
|
|
100
134
|
// way back to the full list without editing the URL.
|
|
101
135
|
describe('SubResourcesSection — clearing the ?sub= selection', () => {
|
package/src/index.tsx
CHANGED
|
@@ -19,9 +19,11 @@ export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa/index'
|
|
|
19
19
|
// have to depend on backend-core directly just to type a handler.
|
|
20
20
|
export type {
|
|
21
21
|
AcPlugin,
|
|
22
|
+
PluginAppliesTo,
|
|
22
23
|
PluginSlots,
|
|
23
24
|
PluginWrappers,
|
|
24
25
|
PluginUser,
|
|
26
|
+
SlotScopeSpec,
|
|
25
27
|
SlotSpec,
|
|
26
28
|
WrapperSpec,
|
|
27
29
|
} from './modules/extensions/index'
|
|
@@ -67,6 +67,16 @@ interface SubResourcesSectionProps {
|
|
|
67
67
|
childrenLabel?: string
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The AWS account a child stands for, if it is one. `extra` is nullable on a
|
|
72
|
+
* served resource, so the optional chain is load-bearing: dereferencing it threw
|
|
73
|
+
* the moment anything typed into the filter met a child without it.
|
|
74
|
+
*/
|
|
75
|
+
function awsAccountIdOf(sr: Resource): string | undefined {
|
|
76
|
+
return (sr.extra as Record<string, unknown> | null | undefined)
|
|
77
|
+
?.awsAccountId as string | undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
function getTierBadgeVariant(
|
|
71
81
|
tierSlug: string,
|
|
72
82
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
@@ -105,6 +115,8 @@ interface SubResourceRowProps {
|
|
|
105
115
|
* derived from `showAdminColumn` in one place above.
|
|
106
116
|
*/
|
|
107
117
|
adminParent?: Resource
|
|
118
|
+
/** Whether the AWS Account column is rendered — `showAccountColumn` above. */
|
|
119
|
+
showAccount: boolean
|
|
108
120
|
user: ReturnType<typeof usePluginUser>
|
|
109
121
|
isHighlighted: boolean
|
|
110
122
|
/** Set only when the body is virtualized: lets the virtualizer measure the row. */
|
|
@@ -133,6 +145,7 @@ function SubResourceRowBase({
|
|
|
133
145
|
resource: sr,
|
|
134
146
|
parentSlug,
|
|
135
147
|
adminParent,
|
|
148
|
+
showAccount,
|
|
136
149
|
user,
|
|
137
150
|
isHighlighted,
|
|
138
151
|
measureRef,
|
|
@@ -140,8 +153,7 @@ function SubResourceRowBase({
|
|
|
140
153
|
}: SubResourceRowProps) {
|
|
141
154
|
// Approvers are Person OR Group slugs; the badge resolves both.
|
|
142
155
|
const approvers = [...new Set(sr.approverSlugs ?? [])]
|
|
143
|
-
const accountId = (sr
|
|
144
|
-
?.awsAccountId as string | undefined
|
|
156
|
+
const accountId = awsAccountIdOf(sr)
|
|
145
157
|
|
|
146
158
|
return (
|
|
147
159
|
<TableRow
|
|
@@ -202,27 +214,29 @@ function SubResourceRowBase({
|
|
|
202
214
|
<span className="text-muted-foreground">-</span>
|
|
203
215
|
)}
|
|
204
216
|
</TableCell>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
217
|
+
{showAccount && (
|
|
218
|
+
<TableCell>
|
|
219
|
+
{!accountId ? (
|
|
220
|
+
<span className="text-muted-foreground">—</span>
|
|
221
|
+
) : // The account id is what people copy; when the resource also
|
|
222
|
+
// carries its own `appUrl` that is the console deep link for THAT
|
|
223
|
+
// account, so the id doubles as the launch affordance.
|
|
224
|
+
sr.appUrl ? (
|
|
225
|
+
<a
|
|
226
|
+
href={sr.appUrl}
|
|
227
|
+
target="_blank"
|
|
228
|
+
rel="noopener noreferrer"
|
|
229
|
+
className="font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text"
|
|
230
|
+
>
|
|
231
|
+
{accountId}
|
|
232
|
+
</a>
|
|
233
|
+
) : (
|
|
234
|
+
<span className="font-mono text-xs text-muted-foreground select-text">
|
|
235
|
+
{accountId}
|
|
236
|
+
</span>
|
|
237
|
+
)}
|
|
238
|
+
</TableCell>
|
|
239
|
+
)}
|
|
226
240
|
{adminParent !== undefined && (
|
|
227
241
|
// `relative`, so a contribution can position something against the
|
|
228
242
|
// cell — a resting-state glyph under a hover-revealed control, for
|
|
@@ -326,9 +340,19 @@ export function SubResourcesSection({
|
|
|
326
340
|
// ONE flag for the header, the cells and the empty row's colSpan. Deriving it
|
|
327
341
|
// separately per site is how a header appears over a column with no cells —
|
|
328
342
|
// the parent check only guarded the cells at first, and the header rendered
|
|
329
|
-
// anyway.
|
|
330
|
-
|
|
331
|
-
|
|
343
|
+
// anyway. The scope lets a plugin that serves only some parents keep the
|
|
344
|
+
// column off every other parent's table; no parent means nothing to decide.
|
|
345
|
+
const showAdminColumn = useSlotFilled(
|
|
346
|
+
'resourceSubResourceRowActions',
|
|
347
|
+
parent ? { parent } : undefined,
|
|
348
|
+
)
|
|
349
|
+
// Same rule from the data side: only a family of AWS accounts has an account
|
|
350
|
+
// id to show. Decided on the UNFILTERED list, so typing a filter that happens
|
|
351
|
+
// to hide every account does not make the column jump.
|
|
352
|
+
const showAccountColumn = useMemo(
|
|
353
|
+
() => subResources.some((sr) => awsAccountIdOf(sr) !== undefined),
|
|
354
|
+
[subResources],
|
|
355
|
+
)
|
|
332
356
|
const user = usePluginUser()
|
|
333
357
|
// `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row
|
|
334
358
|
// in the search results. It wins over the query-seeded filter below: a query
|
|
@@ -436,13 +460,7 @@ export function SubResourcesSection({
|
|
|
436
460
|
sr.displayName.toLowerCase().includes(q) ||
|
|
437
461
|
(sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||
|
|
438
462
|
(sr.description?.toLowerCase().includes(q) ?? false) ||
|
|
439
|
-
|
|
440
|
-
// dereferencing it threw a TypeError the moment anything typed into
|
|
441
|
-
// this box met a child without it, taking the whole table down.
|
|
442
|
-
(
|
|
443
|
-
(sr.extra as Record<string, unknown> | null | undefined)
|
|
444
|
-
?.awsAccountId as string | undefined
|
|
445
|
-
)?.includes(q) === true,
|
|
463
|
+
awsAccountIdOf(sr)?.includes(q) === true,
|
|
446
464
|
)
|
|
447
465
|
}
|
|
448
466
|
|
|
@@ -458,7 +476,9 @@ export function SubResourcesSection({
|
|
|
458
476
|
// Keyed on the unfiltered size so a big table keeps one layout while it is
|
|
459
477
|
// filtered below the threshold, rather than jumping as the user types.
|
|
460
478
|
const fixedLayout = subResources.length > VIRTUALIZE_ABOVE
|
|
461
|
-
|
|
479
|
+
// Name, Tier, Owner, Approvers, then the two optional columns.
|
|
480
|
+
const columnCount =
|
|
481
|
+
4 + (showAccountColumn ? 1 : 0) + (showAdminColumn ? 1 : 0)
|
|
462
482
|
|
|
463
483
|
if (subResources.length === 0) return null
|
|
464
484
|
|
|
@@ -563,7 +583,9 @@ export function SubResourcesSection({
|
|
|
563
583
|
<TableHead className={fixedLayout ? 'w-[230px]' : undefined}>
|
|
564
584
|
Approvers
|
|
565
585
|
</TableHead>
|
|
566
|
-
|
|
586
|
+
{showAccountColumn && (
|
|
587
|
+
<TableHead className="w-[140px]">AWS Account</TableHead>
|
|
588
|
+
)}
|
|
567
589
|
{/* Only when something fills it: an empty column with a header and
|
|
568
590
|
no cells is worse than no column, and the open-source build
|
|
569
591
|
registers no plugins at all. */}
|
|
@@ -576,7 +598,7 @@ export function SubResourcesSection({
|
|
|
576
598
|
{filtered.length === 0 ? (
|
|
577
599
|
<TableRow>
|
|
578
600
|
<TableCell
|
|
579
|
-
colSpan={
|
|
601
|
+
colSpan={columnCount}
|
|
580
602
|
className="text-center text-muted-foreground py-8"
|
|
581
603
|
>
|
|
582
604
|
No resources match your filters
|
|
@@ -596,6 +618,7 @@ export function SubResourcesSection({
|
|
|
596
618
|
resource={sr}
|
|
597
619
|
parentSlug={parentSlug}
|
|
598
620
|
adminParent={showAdminColumn ? parent : undefined}
|
|
621
|
+
showAccount={showAccountColumn}
|
|
599
622
|
user={user}
|
|
600
623
|
isHighlighted={sr.slug === highlightSlug}
|
|
601
624
|
measureRef={measureElement}
|
|
@@ -617,6 +640,7 @@ export function SubResourcesSection({
|
|
|
617
640
|
// `showAdminColumn` already implies `parent !== undefined`,
|
|
618
641
|
// so this cannot disagree with the header or the colSpan.
|
|
619
642
|
adminParent={showAdminColumn ? parent : undefined}
|
|
643
|
+
showAccount={showAccountColumn}
|
|
620
644
|
user={user}
|
|
621
645
|
isHighlighted={sr.slug === highlightSlug}
|
|
622
646
|
/>
|
|
@@ -38,7 +38,9 @@ export interface ResourceDetailSlots {
|
|
|
38
38
|
* family has children (hundreds, in a large catalog) — keep the cell cheap and
|
|
39
39
|
* defer anything expensive until it is interacted with. And the column only
|
|
40
40
|
* appears when a plugin actually fills it, so a build with no plugin renders
|
|
41
|
-
* the table exactly as before.
|
|
41
|
+
* the table exactly as before. A plugin that serves only some parents says so
|
|
42
|
+
* in `appliesTo` (see `ResourceDetailSlotScopes`), so every other parent's
|
|
43
|
+
* table gets no column rather than a column of empty cells.
|
|
42
44
|
*/
|
|
43
45
|
resourceSubResourceRowActions: {
|
|
44
46
|
/** The child this row is showing. */
|
|
@@ -49,6 +51,20 @@ export interface ResourceDetailSlots {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* What the core knows when it decides a slot's LAYOUT, before any row renders.
|
|
56
|
+
*
|
|
57
|
+
* The input to a plugin's `appliesTo` predicate for that slot. A contribution
|
|
58
|
+
* renders as a component, so the core cannot ask it whether it would render
|
|
59
|
+
* nothing; the plugin has to say up front which scopes it serves.
|
|
60
|
+
*/
|
|
61
|
+
export interface ResourceDetailSlotScopes {
|
|
62
|
+
resourceSubResourceRowActions: {
|
|
63
|
+
/** The entry whose sub-resources table is being laid out. */
|
|
64
|
+
parent: Resource
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
/**
|
|
53
69
|
* Wrappers on the resource detail card.
|
|
54
70
|
*
|
|
@@ -5,6 +5,7 @@ import type { ReactNode } from 'react'
|
|
|
5
5
|
// resolves it if it has mapped it, which the example app has not.
|
|
6
6
|
import { useOptionalUser } from '../auth/AuthContext'
|
|
7
7
|
import type {
|
|
8
|
+
ResourceDetailSlotScopes,
|
|
8
9
|
ResourceDetailSlots,
|
|
9
10
|
ResourceDetailWrappers,
|
|
10
11
|
} from './features/resourceDetail'
|
|
@@ -23,6 +24,7 @@ export {
|
|
|
23
24
|
ResourceDetailProvider,
|
|
24
25
|
} from './features/resourceDetail'
|
|
25
26
|
export type {
|
|
27
|
+
ResourceDetailSlotScopes,
|
|
26
28
|
ResourceDetailSlots,
|
|
27
29
|
ResourceDetailWrappers,
|
|
28
30
|
} from './features/resourceDetail'
|
|
@@ -38,6 +40,9 @@ export type SlotSpec = ResourceDetailSlots
|
|
|
38
40
|
/** Every wrapper slot in the core. */
|
|
39
41
|
export type WrapperSpec = ResourceDetailWrappers
|
|
40
42
|
|
|
43
|
+
/** The layout scope of every slot whose layout depends on more than presence. */
|
|
44
|
+
export type SlotScopeSpec = ResourceDetailSlotScopes
|
|
45
|
+
|
|
41
46
|
// Guard: with a second feature this becomes
|
|
42
47
|
// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the
|
|
43
48
|
// duplicated id spelled out in the error. With one feature it is trivially true,
|
|
@@ -63,6 +68,16 @@ export type PluginWrappers = {
|
|
|
63
68
|
) => ReactNode
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Which layout scopes a plugin's slot contribution serves. Absent = all of them.
|
|
73
|
+
*
|
|
74
|
+
* Only slots listed in `SlotScopeSpec` take a predicate: those are the slots
|
|
75
|
+
* whose layout the core decides before rendering any contribution.
|
|
76
|
+
*/
|
|
77
|
+
export type PluginAppliesTo = {
|
|
78
|
+
[N in keyof SlotScopeSpec]?: (scope: SlotScopeSpec[N]) => boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
/**
|
|
67
82
|
* One deployment-specific feature's contribution to the core's UI.
|
|
68
83
|
*
|
|
@@ -79,6 +94,7 @@ export interface AcPlugin {
|
|
|
79
94
|
name: string
|
|
80
95
|
slots?: PluginSlots
|
|
81
96
|
wrappers?: PluginWrappers
|
|
97
|
+
appliesTo?: PluginAppliesTo
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/**
|
|
@@ -93,9 +109,28 @@ export interface AcPlugin {
|
|
|
93
109
|
* Use it ONLY for layout. A slot must never be the only route to something the
|
|
94
110
|
* core itself needs, so branching behaviour on a plugin's presence is a design
|
|
95
111
|
* smell even where branching layout is not.
|
|
112
|
+
*
|
|
113
|
+
* A slot in `SlotScopeSpec` also takes the scope being laid out, and counts only
|
|
114
|
+
* plugins whose `appliesTo` accepts it. An `undefined` scope means there is
|
|
115
|
+
* nothing to decide against, so the slot reads as unfilled.
|
|
96
116
|
*/
|
|
97
|
-
export function useSlotFilled
|
|
98
|
-
|
|
117
|
+
export function useSlotFilled<TSlot extends keyof SlotSpec>(
|
|
118
|
+
slot: TSlot,
|
|
119
|
+
...scope: TSlot extends keyof SlotScopeSpec
|
|
120
|
+
? [scope: SlotScopeSpec[TSlot] | undefined]
|
|
121
|
+
: []
|
|
122
|
+
): boolean {
|
|
123
|
+
const plugins = useExtensions()
|
|
124
|
+
const scoped = scope.length > 0
|
|
125
|
+
const value = scope[0]
|
|
126
|
+
if (scoped && value === undefined) return false
|
|
127
|
+
return plugins.some((plugin) => {
|
|
128
|
+
if (!plugin.slots?.[slot]) return false
|
|
129
|
+
const applies = plugin.appliesTo?.[slot] as
|
|
130
|
+
| ((s: typeof value) => boolean)
|
|
131
|
+
| undefined
|
|
132
|
+
return !scoped || !applies || applies(value)
|
|
133
|
+
})
|
|
99
134
|
}
|
|
100
135
|
|
|
101
136
|
/**
|