@igstack/app-catalog-frontend-core 6.6.0 → 6.7.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/App.d.ts +29 -3
- package/dist/esm/App.js +4 -2
- package/dist/esm/App.js.map +1 -1
- package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
- package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
- package/dist/esm/appPropsFactory.d.ts +19 -1
- package/dist/esm/appPropsFactory.js +4 -3
- package/dist/esm/appPropsFactory.js.map +1 -1
- package/dist/esm/index.d.ts +15 -2
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internal.d.ts +7 -1
- package/dist/esm/internal.js +0 -2
- package/dist/esm/internal.js.map +1 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +39 -17
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
- package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
- package/dist/esm/modules/auth/AuthContext.js +5 -0
- package/dist/esm/modules/auth/AuthContext.js.map +1 -1
- package/dist/esm/modules/auth/index.d.ts +1 -1
- package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
- package/dist/esm/modules/dismiss/DismissStack.js +82 -0
- package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
- package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
- package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
- package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
- package/dist/esm/modules/extensions/factories.d.ts +18 -0
- package/dist/esm/modules/extensions/factories.js +12 -0
- package/dist/esm/modules/extensions/factories.js.map +1 -0
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
- package/dist/esm/modules/extensions/index.d.ts +79 -0
- package/dist/esm/modules/extensions/index.js +26 -0
- package/dist/esm/modules/extensions/index.js.map +1 -0
- package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
- package/dist/esm/modules/extensions/makeSlot.js +62 -0
- package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
- package/dist/esm/modules/extensions/types.d.ts +46 -0
- package/dist/esm/modules/gallery/Gallery.js +6 -14
- package/dist/esm/modules/gallery/Gallery.js.map +1 -1
- package/dist/esm/routeTree.gen.d.ts +34 -34
- package/dist/esm/routeTree.gen.js +14 -14
- package/dist/esm/routeTree.gen.js.map +1 -1
- package/dist/esm/router.d.ts +23 -0
- package/dist/esm/router.js +5 -0
- package/dist/esm/router.js.map +1 -0
- package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
- package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
- package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
- package/dist/esm/routes/_layout/index.d.ts +1 -1
- package/dist/esm/routes/_layout/login.d.ts +1 -1
- package/dist/esm/routes/_layout/mcp.d.ts +1 -1
- package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
- package/dist/esm/routes/_layout.d.ts +1 -1
- package/dist/esm/routes/_layout.js +13 -2
- package/dist/esm/routes/_layout.js.map +1 -1
- package/dist/esm/routes/auth.callback.d.ts +1 -1
- package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
- package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
- package/dist/esm/ui/components/header/Header.js +0 -1
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/dist/esm/ui/dialog.d.ts +1 -1
- package/dist/esm/ui/dialog.js +9 -0
- package/dist/esm/ui/dialog.js.map +1 -1
- package/dist/esm/ui/table.d.ts +25 -1
- package/dist/esm/ui/table.js +20 -3
- package/dist/esm/ui/table.js.map +1 -1
- package/dist/esm/util/createAcRouter.d.ts +1 -1
- package/package.json +19 -11
- package/src/App.tsx +34 -3
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
- package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
- package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
- package/src/__tests__/publicEntryResolves.test.ts +63 -0
- package/src/appPropsFactory.ts +23 -3
- package/src/index.tsx +32 -2
- package/src/internal.ts +7 -1
- package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +59 -18
- package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
- package/src/modules/auth/AuthContext.tsx +15 -0
- package/src/modules/auth/index.ts +1 -0
- package/src/modules/dismiss/DismissStack.tsx +224 -0
- package/src/modules/extensions/ExtensionsContext.tsx +36 -0
- package/src/modules/extensions/factories.ts +19 -0
- package/src/modules/extensions/features/resourceDetail.ts +74 -0
- package/src/modules/extensions/index.ts +121 -0
- package/src/modules/extensions/makeSlot.tsx +123 -0
- package/src/modules/extensions/types.ts +51 -0
- package/src/modules/gallery/Gallery.tsx +13 -17
- package/src/routeTree.gen.ts +31 -31
- package/src/router.ts +23 -0
- package/src/routes/_layout.tsx +51 -1
- package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
- package/src/ui/dialog.tsx +26 -0
- package/src/ui/table.tsx +46 -3
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
2
|
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
|
3
3
|
import { Search, X } from 'lucide-react'
|
|
4
|
-
import {
|
|
4
|
+
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
5
|
+
import {
|
|
6
|
+
memo,
|
|
7
|
+
useCallback,
|
|
8
|
+
useDeferredValue,
|
|
9
|
+
useEffect,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react'
|
|
5
15
|
import { Badge } from '~/ui/badge'
|
|
6
16
|
import { Input } from '~/ui/input'
|
|
7
17
|
import {
|
|
@@ -21,11 +31,24 @@ import {
|
|
|
21
31
|
SelectValue,
|
|
22
32
|
} from '~/ui/select'
|
|
23
33
|
import { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainText'
|
|
34
|
+
import {
|
|
35
|
+
ResourceSubResourceRowActions,
|
|
36
|
+
usePluginUser,
|
|
37
|
+
useSlotFilled,
|
|
38
|
+
} from '~/modules/extensions/index'
|
|
24
39
|
|
|
25
40
|
interface SubResourcesSectionProps {
|
|
26
41
|
subResources: Resource[]
|
|
27
42
|
/** Slug of the parent, for linking to a child's own page. */
|
|
28
43
|
parentSlug: string
|
|
44
|
+
/**
|
|
45
|
+
* The parent itself, for the row-actions slot's payload.
|
|
46
|
+
*
|
|
47
|
+
* Optional, and the Admin column is skipped without it: a row action is about
|
|
48
|
+
* a child *of something*, and a plugin deciding whether it applies needs the
|
|
49
|
+
* parent to decide against.
|
|
50
|
+
*/
|
|
51
|
+
parent?: Resource
|
|
29
52
|
/**
|
|
30
53
|
* Initial filter text (#38 item C). When the user reached this app by
|
|
31
54
|
* searching a term that matched a sub-resource, seed the sub-resource filter
|
|
@@ -34,6 +57,14 @@ interface SubResourcesSectionProps {
|
|
|
34
57
|
* account is pre-filtered). Only applied when it actually matches a child.
|
|
35
58
|
*/
|
|
36
59
|
initialSearch?: string
|
|
60
|
+
/**
|
|
61
|
+
* What this app calls its children, from the parent's `childrenLabel`.
|
|
62
|
+
*
|
|
63
|
+
* The tab above already uses it, so without it here the tab and the panel it
|
|
64
|
+
* opens disagree — "AWS Accounts" over a heading that says "Sub-Resources".
|
|
65
|
+
* Defaults to the same generic wording the tab falls back to.
|
|
66
|
+
*/
|
|
67
|
+
childrenLabel?: string
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
function getTierBadgeVariant(
|
|
@@ -63,11 +94,242 @@ function getTierDisplayLabel(tierSlug: string): string {
|
|
|
63
94
|
return tierSlug
|
|
64
95
|
}
|
|
65
96
|
|
|
97
|
+
interface SubResourceRowProps {
|
|
98
|
+
resource: Resource
|
|
99
|
+
parentSlug: string
|
|
100
|
+
/**
|
|
101
|
+
* The parent, but ONLY when the Admin column is being rendered — so this one
|
|
102
|
+
* prop carries both "show the cell" and "what to pass it", and the row needs
|
|
103
|
+
* no separate boolean. `undefined` means no Admin cell, which keeps the cells
|
|
104
|
+
* in lockstep with the header and the empty row's `colSpan`, all three
|
|
105
|
+
* derived from `showAdminColumn` in one place above.
|
|
106
|
+
*/
|
|
107
|
+
adminParent?: Resource
|
|
108
|
+
user: ReturnType<typeof usePluginUser>
|
|
109
|
+
isHighlighted: boolean
|
|
110
|
+
/** Set only when the body is virtualized: lets the virtualizer measure the row. */
|
|
111
|
+
measureRef?: (el: HTMLTableRowElement | null) => void
|
|
112
|
+
index?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One row, memoized — which is load-bearing rather than tidiness.
|
|
117
|
+
*
|
|
118
|
+
* Measured on the 667-account table: a filter keystroke that changed no visible
|
|
119
|
+
* row still cost ~400ms and wrote 9 attributes, because every row's element
|
|
120
|
+
* tree was rebuilt and diffed. The browser can build all 33,861 nodes from
|
|
121
|
+
* scratch in ~195ms, so React was spending twice that to change nothing. The
|
|
122
|
+
* work was reconciliation, not the DOM.
|
|
123
|
+
*
|
|
124
|
+
* `memo` makes a keystroke cost one props comparison per row instead. For that
|
|
125
|
+
* to hold, every prop here must be a primitive or a stable reference:
|
|
126
|
+
* `resource` objects come from the parent's `useMemo` filter (same identities),
|
|
127
|
+
* `user` is memoized in `usePluginUser`, and the highlight arrives as a
|
|
128
|
+
* BOOLEAN. Passing `highlightSlug` and comparing inside would change one prop
|
|
129
|
+
* on all 667 rows whenever the highlight moved, re-rendering the whole table to
|
|
130
|
+
* restyle two rows.
|
|
131
|
+
*/
|
|
132
|
+
function SubResourceRowBase({
|
|
133
|
+
resource: sr,
|
|
134
|
+
parentSlug,
|
|
135
|
+
adminParent,
|
|
136
|
+
user,
|
|
137
|
+
isHighlighted,
|
|
138
|
+
measureRef,
|
|
139
|
+
index,
|
|
140
|
+
}: SubResourceRowProps) {
|
|
141
|
+
// Approvers are Person OR Group slugs; the badge resolves both.
|
|
142
|
+
const approvers = [...new Set(sr.approverSlugs ?? [])]
|
|
143
|
+
const accountId = (sr.extra as Record<string, unknown> | null | undefined)
|
|
144
|
+
?.awsAccountId as string | undefined
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<TableRow
|
|
148
|
+
ref={measureRef}
|
|
149
|
+
data-index={index}
|
|
150
|
+
// The row the user asked for, so it reads as "this one" even once the
|
|
151
|
+
// filter is cleared and its siblings come back.
|
|
152
|
+
aria-current={isHighlighted ? 'true' : undefined}
|
|
153
|
+
// `group` so a row-slot contribution can react to the row being hovered
|
|
154
|
+
// or focused (`group-hover:`, `group-focus-within:`). A plugin cannot add
|
|
155
|
+
// this itself — it renders inside the cell, not on the row — and the
|
|
156
|
+
// alternative is every plugin hand-rolling a `tr:hover &` arbitrary
|
|
157
|
+
// variant.
|
|
158
|
+
className={`group ${isHighlighted ? 'bg-primary/[0.06]' : ''}`}
|
|
159
|
+
>
|
|
160
|
+
<TableCell>
|
|
161
|
+
{/* A real link, not a click handler on the row: this is a table of
|
|
162
|
+
resources, each of which has its own page. */}
|
|
163
|
+
<Link
|
|
164
|
+
to="/app/$slug/sub/$subSlug"
|
|
165
|
+
params={{ slug: parentSlug, subSlug: sr.slug }}
|
|
166
|
+
className="font-medium text-sm hover:text-primary hover:underline"
|
|
167
|
+
>
|
|
168
|
+
{sr.displayName}
|
|
169
|
+
</Link>
|
|
170
|
+
{(sr.aliases ?? []).length > 0 && (
|
|
171
|
+
<div className="text-xs text-muted-foreground mt-0.5">
|
|
172
|
+
{(sr.aliases ?? []).join(', ')}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
{sr.description && (
|
|
176
|
+
<div className="text-xs text-muted-foreground mt-0.5">
|
|
177
|
+
{markdownToPlainText(sr.description)}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</TableCell>
|
|
181
|
+
<TableCell>
|
|
182
|
+
{sr.tier && (
|
|
183
|
+
<Badge
|
|
184
|
+
variant={getTierBadgeVariant(sr.tier)}
|
|
185
|
+
className={`text-xs ${getTierBadgeClassName(sr.tier)}`}
|
|
186
|
+
>
|
|
187
|
+
{getTierDisplayLabel(sr.tier)}
|
|
188
|
+
</Badge>
|
|
189
|
+
)}
|
|
190
|
+
</TableCell>
|
|
191
|
+
<TableCell>
|
|
192
|
+
{sr.ownerPersonSlug && <PersonBadge slug={sr.ownerPersonSlug} />}
|
|
193
|
+
</TableCell>
|
|
194
|
+
<TableCell>
|
|
195
|
+
{approvers.length > 0 ? (
|
|
196
|
+
<div className="flex flex-wrap gap-1">
|
|
197
|
+
{approvers.map((slug) => (
|
|
198
|
+
<PersonOrGroupBadge key={slug} slug={slug} />
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
) : (
|
|
202
|
+
<span className="text-muted-foreground">-</span>
|
|
203
|
+
)}
|
|
204
|
+
</TableCell>
|
|
205
|
+
<TableCell>
|
|
206
|
+
{!accountId ? (
|
|
207
|
+
<span className="text-muted-foreground">—</span>
|
|
208
|
+
) : // The account id is what people copy; when the resource also
|
|
209
|
+
// carries its own `appUrl` that is the console deep link for THAT
|
|
210
|
+
// account, so the id doubles as the launch affordance.
|
|
211
|
+
sr.appUrl ? (
|
|
212
|
+
<a
|
|
213
|
+
href={sr.appUrl}
|
|
214
|
+
target="_blank"
|
|
215
|
+
rel="noopener noreferrer"
|
|
216
|
+
className="font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text"
|
|
217
|
+
>
|
|
218
|
+
{accountId}
|
|
219
|
+
</a>
|
|
220
|
+
) : (
|
|
221
|
+
<span className="font-mono text-xs text-muted-foreground select-text">
|
|
222
|
+
{accountId}
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
</TableCell>
|
|
226
|
+
{adminParent !== undefined && (
|
|
227
|
+
// `relative`, so a contribution can position something against the
|
|
228
|
+
// cell — a resting-state glyph under a hover-revealed control, for
|
|
229
|
+
// instance. A plugin cannot add it, because the cell is the core's.
|
|
230
|
+
<TableCell className="relative w-[168px]">
|
|
231
|
+
<ResourceSubResourceRowActions
|
|
232
|
+
resource={sr}
|
|
233
|
+
parent={adminParent}
|
|
234
|
+
user={user}
|
|
235
|
+
/>
|
|
236
|
+
</TableCell>
|
|
237
|
+
)}
|
|
238
|
+
</TableRow>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const SubResourceRow = memo(SubResourceRowBase)
|
|
243
|
+
|
|
244
|
+
/** Stands in for the rows scrolled out of view, so the scrollbar is honest. */
|
|
245
|
+
function SpacerRow({ height, columns }: { height: number; columns: number }) {
|
|
246
|
+
return (
|
|
247
|
+
<tr aria-hidden="true">
|
|
248
|
+
<td colSpan={columns} style={{ height, padding: 0, border: 0 }} />
|
|
249
|
+
</tr>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Past this many rows the body is virtualized: only what is on screen, plus
|
|
255
|
+
* a margin, is mounted.
|
|
256
|
+
*
|
|
257
|
+
* Tab panels mount only while active, so every switch to this tab built the
|
|
258
|
+
* whole table from scratch — measured at ~950 ms for 667 rows and 33,861
|
|
259
|
+
* nodes, every time. `memo` cannot help with that: it saves RE-rendering,
|
|
260
|
+
* and this is mounting. Below the threshold nothing changes, which keeps the
|
|
261
|
+
* common short child list rendering exactly as it always has.
|
|
262
|
+
*
|
|
263
|
+
* Spacer rows rather than absolutely positioned ones, so this stays a real
|
|
264
|
+
* `<table>`: column widths still come from table layout, and the sticky
|
|
265
|
+
* header still resolves against the container that scrolls.
|
|
266
|
+
*/
|
|
267
|
+
const VIRTUALIZE_ABOVE = 60
|
|
268
|
+
|
|
269
|
+
function useRowWindow(
|
|
270
|
+
rows: readonly Resource[],
|
|
271
|
+
scrollRef: React.RefObject<HTMLDivElement | null>,
|
|
272
|
+
) {
|
|
273
|
+
const virtualize = rows.length > VIRTUALIZE_ABOVE
|
|
274
|
+
// Stable on purpose. The virtualizer caches row measurements keyed on this
|
|
275
|
+
// function's IDENTITY, so an inline arrow — a new function every render —
|
|
276
|
+
// invalidated the cache on every render, which recomputed the visible range,
|
|
277
|
+
// which re-rendered, which made a new arrow: "Too many re-renders", and the
|
|
278
|
+
// card blanked the moment this tab opened.
|
|
279
|
+
const getItemKey = useCallback((i: number) => rows[i]?.slug ?? i, [rows])
|
|
280
|
+
// TanStack Virtual hands back functions the React Compiler cannot memoize
|
|
281
|
+
// safely, so it skips this hook. Nothing to fix short of dropping the
|
|
282
|
+
// library; registered in eslint.config.js alongside TanStack Table.
|
|
283
|
+
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual
|
|
284
|
+
const rowVirtualizer = useVirtualizer({
|
|
285
|
+
count: virtualize ? rows.length : 0,
|
|
286
|
+
getScrollElement: () => scrollRef.current,
|
|
287
|
+
estimateSize: () => 88,
|
|
288
|
+
overscan: 8,
|
|
289
|
+
getItemKey,
|
|
290
|
+
// The container's height limit, so the first render already mounts the
|
|
291
|
+
// rows that will be visible instead of painting an empty body for a frame.
|
|
292
|
+
initialRect: { width: 0, height: 400 },
|
|
293
|
+
})
|
|
294
|
+
// No layout, no window. Without layout (jsdom, a print preview) the
|
|
295
|
+
// container measures 0 tall, a virtualizer mounts nothing, and every row
|
|
296
|
+
// becomes unreachable — so render them all instead.
|
|
297
|
+
const [hasLayout, setHasLayout] = useState(true)
|
|
298
|
+
useLayoutEffect(() => {
|
|
299
|
+
const el = scrollRef.current
|
|
300
|
+
if (el) setHasLayout(el.clientHeight > 0)
|
|
301
|
+
}, [scrollRef])
|
|
302
|
+
const windowed = virtualize && hasLayout
|
|
303
|
+
const virtualRows = windowed ? rowVirtualizer.getVirtualItems() : []
|
|
304
|
+
const padTop = virtualRows[0]?.start ?? 0
|
|
305
|
+
const lastRow = virtualRows.at(-1)
|
|
306
|
+
const padBottom = lastRow ? rowVirtualizer.getTotalSize() - lastRow.end : 0
|
|
307
|
+
return {
|
|
308
|
+
windowed,
|
|
309
|
+
virtualRows,
|
|
310
|
+
padTop,
|
|
311
|
+
padBottom,
|
|
312
|
+
measureElement: rowVirtualizer.measureElement,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
66
316
|
export function SubResourcesSection({
|
|
67
317
|
subResources,
|
|
68
318
|
parentSlug,
|
|
319
|
+
parent,
|
|
69
320
|
initialSearch,
|
|
321
|
+
childrenLabel,
|
|
70
322
|
}: SubResourcesSectionProps) {
|
|
323
|
+
// `useSlotFilled`, not "render it and see": the column is layout, so the
|
|
324
|
+
// header has to be decided before any cell renders.
|
|
325
|
+
//
|
|
326
|
+
// ONE flag for the header, the cells and the empty row's colSpan. Deriving it
|
|
327
|
+
// separately per site is how a header appears over a column with no cells —
|
|
328
|
+
// the parent check only guarded the cells at first, and the header rendered
|
|
329
|
+
// anyway.
|
|
330
|
+
const showAdminColumn =
|
|
331
|
+
useSlotFilled('resourceSubResourceRowActions') && parent !== undefined
|
|
332
|
+
const user = usePluginUser()
|
|
71
333
|
// `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row
|
|
72
334
|
// in the search results. It wins over the query-seeded filter below: a query
|
|
73
335
|
// like "project" matches every child, so seeding with it would bury the row
|
|
@@ -121,6 +383,30 @@ export function SubResourcesSection({
|
|
|
121
383
|
const [search, setSearch] = useState(seededSearch)
|
|
122
384
|
const [tierFilter, setTierFilter] = useState<string>('all')
|
|
123
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The input stays bound to `search`; the TABLE filters on this.
|
|
388
|
+
*
|
|
389
|
+
* Rebuilding the row list is the expensive half of a keystroke — on a large
|
|
390
|
+
* entry it mounts and unmounts hundreds of rows — and doing it in the same
|
|
391
|
+
* render as the input makes the caret wait for it. `useDeferredValue` splits
|
|
392
|
+
* them: React commits the typed character immediately and re-renders the
|
|
393
|
+
* table at low priority, interruptibly. Keep typing and the superseded
|
|
394
|
+
* low-priority render is abandoned rather than finished and thrown away, so
|
|
395
|
+
* a burst costs roughly one filter pass instead of one per character.
|
|
396
|
+
*
|
|
397
|
+
* This only works because the row is memoized. Deferring tells React it MAY
|
|
398
|
+
* skip the expensive subtree between keystrokes; `memo` is what lets it
|
|
399
|
+
* actually skip anything.
|
|
400
|
+
*
|
|
401
|
+
* Not a debounce: there is no fixed delay to tune, nothing is thrown away on
|
|
402
|
+
* a fast typist, and the final result is never late — React yields to input
|
|
403
|
+
* instead of waiting on a timer.
|
|
404
|
+
*/
|
|
405
|
+
const deferredSearch = useDeferredValue(search)
|
|
406
|
+
// True while the table is a render behind the box. Used only to dim it, so
|
|
407
|
+
// stale rows read as stale instead of as the answer.
|
|
408
|
+
const isFilterPending = search !== deferredSearch
|
|
409
|
+
|
|
124
410
|
// useState only reads its argument on the first render, so without this the
|
|
125
411
|
// filter kept the query from whenever this panel first mounted — searching
|
|
126
412
|
// again with the panel already open left the old term in the box.
|
|
@@ -143,31 +429,56 @@ export function SubResourcesSection({
|
|
|
143
429
|
result = result.filter((sr) => sr.tier === tierFilter)
|
|
144
430
|
}
|
|
145
431
|
|
|
146
|
-
if (
|
|
147
|
-
const q =
|
|
432
|
+
if (deferredSearch.trim()) {
|
|
433
|
+
const q = deferredSearch.trim().toLowerCase()
|
|
148
434
|
result = result.filter(
|
|
149
435
|
(sr) =>
|
|
150
436
|
sr.displayName.toLowerCase().includes(q) ||
|
|
151
437
|
(sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||
|
|
152
438
|
(sr.description?.toLowerCase().includes(q) ?? false) ||
|
|
439
|
+
// `extra` is nullable on a served resource, and the cast hid that:
|
|
440
|
+
// dereferencing it threw a TypeError the moment anything typed into
|
|
441
|
+
// this box met a child without it, taking the whole table down.
|
|
153
442
|
(
|
|
154
|
-
(sr.extra as Record<string, unknown>)
|
|
155
|
-
|
|
|
156
|
-
| undefined
|
|
443
|
+
(sr.extra as Record<string, unknown> | null | undefined)
|
|
444
|
+
?.awsAccountId as string | undefined
|
|
157
445
|
)?.includes(q) === true,
|
|
158
446
|
)
|
|
159
447
|
}
|
|
160
448
|
|
|
161
449
|
return result
|
|
162
|
-
}, [subResources,
|
|
450
|
+
}, [subResources, deferredSearch, tierFilter, selectedSub])
|
|
451
|
+
|
|
452
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
453
|
+
const { windowed, virtualRows, padTop, padBottom, measureElement } =
|
|
454
|
+
useRowWindow(filtered, scrollRef)
|
|
455
|
+
// Fixed layout for any table big enough to be windowed. With auto layout a
|
|
456
|
+
// column is as wide as its widest MOUNTED cell, so while scrolling a
|
|
457
|
+
// windowed body the columns resized under the cursor — measured at 264px.
|
|
458
|
+
// Keyed on the unfiltered size so a big table keeps one layout while it is
|
|
459
|
+
// filtered below the threshold, rather than jumping as the user types.
|
|
460
|
+
const fixedLayout = subResources.length > VIRTUALIZE_ABOVE
|
|
461
|
+
const columnCount = showAdminColumn ? 6 : 5
|
|
163
462
|
|
|
164
463
|
if (subResources.length === 0) return null
|
|
165
464
|
|
|
166
465
|
return (
|
|
167
466
|
<div className="space-y-3">
|
|
168
467
|
<div className="flex items-center justify-between">
|
|
169
|
-
|
|
170
|
-
|
|
468
|
+
{/* `data-testid`, because the label is now deployment-defined: the test
|
|
469
|
+
kit used to locate this heading by matching the literal string
|
|
470
|
+
"Sub-Resources (n of m)", which silently stopped finding it the
|
|
471
|
+
moment a catalog named its children something else. */}
|
|
472
|
+
<div
|
|
473
|
+
data-testid="sub-resources-heading"
|
|
474
|
+
className="text-sm font-medium"
|
|
475
|
+
>
|
|
476
|
+
{/* Each site keeps the exact wording it had before `childrenLabel`
|
|
477
|
+
existed, so a deployment that names nothing renders identically —
|
|
478
|
+
the heading and the placeholder never agreed, and quietly making
|
|
479
|
+
them agree would change every existing catalog. */}
|
|
480
|
+
{childrenLabel ?? 'Sub-Resources'} ({filtered.length} of{' '}
|
|
481
|
+
{subResources.length})
|
|
171
482
|
</div>
|
|
172
483
|
</div>
|
|
173
484
|
|
|
@@ -194,7 +505,11 @@ export function SubResourcesSection({
|
|
|
194
505
|
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
|
195
506
|
<Input
|
|
196
507
|
ref={searchInputRef}
|
|
197
|
-
|
|
508
|
+
// Verbatim, not lower-cased: the deployment chose the casing and
|
|
509
|
+
// an acronym does not survive it — "AWS Accounts" became
|
|
510
|
+
// "Search aws accounts…". The no-label default stays lowercase
|
|
511
|
+
// because that is the wording this placeholder already had.
|
|
512
|
+
placeholder={`Search ${childrenLabel ?? 'resources'} by name or alias...`}
|
|
198
513
|
value={search}
|
|
199
514
|
onChange={(e) => setSearch(e.target.value)}
|
|
200
515
|
className="pl-9 h-9"
|
|
@@ -218,124 +533,94 @@ export function SubResourcesSection({
|
|
|
218
533
|
)}
|
|
219
534
|
</div>
|
|
220
535
|
|
|
221
|
-
{/* Table
|
|
222
|
-
|
|
223
|
-
|
|
536
|
+
{/* Table.
|
|
537
|
+
The vertical scroll sits on the Table's own wrapper rather than here,
|
|
538
|
+
so the sticky header below pins to the element that actually scrolls —
|
|
539
|
+
see the comment on `stickyHeader`. */}
|
|
540
|
+
{/* Dimmed while the rows are a render behind the filter box, so stale
|
|
541
|
+
rows read as stale rather than as the answer. `aria-busy` says the
|
|
542
|
+
same thing to a screen reader. Opacity only — no spinner and no
|
|
543
|
+
layout change, because on a fast filter this lasts one frame and
|
|
544
|
+
anything heavier would strobe. */}
|
|
545
|
+
<div
|
|
546
|
+
className="rounded-lg border overflow-hidden"
|
|
547
|
+
aria-busy={isFilterPending || undefined}
|
|
548
|
+
>
|
|
549
|
+
<Table
|
|
550
|
+
stickyHeader
|
|
551
|
+
containerRef={scrollRef}
|
|
552
|
+
className={`max-h-[400px] transition-opacity ${
|
|
553
|
+
fixedLayout ? '[&>table]:table-fixed' : ''
|
|
554
|
+
} ${isFilterPending ? 'opacity-60' : ''}`}
|
|
555
|
+
>
|
|
224
556
|
<TableHeader>
|
|
225
557
|
<TableRow>
|
|
226
558
|
<TableHead>Name</TableHead>
|
|
227
559
|
<TableHead className="w-[80px]">Tier</TableHead>
|
|
228
|
-
<TableHead>
|
|
229
|
-
|
|
560
|
+
<TableHead className={fixedLayout ? 'w-[190px]' : undefined}>
|
|
561
|
+
Owner
|
|
562
|
+
</TableHead>
|
|
563
|
+
<TableHead className={fixedLayout ? 'w-[230px]' : undefined}>
|
|
564
|
+
Approvers
|
|
565
|
+
</TableHead>
|
|
230
566
|
<TableHead className="w-[140px]">AWS Account</TableHead>
|
|
567
|
+
{/* Only when something fills it: an empty column with a header and
|
|
568
|
+
no cells is worse than no column, and the open-source build
|
|
569
|
+
registers no plugins at all. */}
|
|
570
|
+
{showAdminColumn && (
|
|
571
|
+
<TableHead className="w-[168px]">Admin</TableHead>
|
|
572
|
+
)}
|
|
231
573
|
</TableRow>
|
|
232
574
|
</TableHeader>
|
|
233
575
|
<TableBody>
|
|
234
576
|
{filtered.length === 0 ? (
|
|
235
577
|
<TableRow>
|
|
236
578
|
<TableCell
|
|
237
|
-
colSpan={5}
|
|
579
|
+
colSpan={showAdminColumn ? 6 : 5}
|
|
238
580
|
className="text-center text-muted-foreground py-8"
|
|
239
581
|
>
|
|
240
582
|
No resources match your filters
|
|
241
583
|
</TableCell>
|
|
242
584
|
</TableRow>
|
|
585
|
+
) : windowed ? (
|
|
586
|
+
<>
|
|
587
|
+
{padTop > 0 && (
|
|
588
|
+
<SpacerRow height={padTop} columns={columnCount} />
|
|
589
|
+
)}
|
|
590
|
+
{virtualRows.map((v) => {
|
|
591
|
+
const sr = filtered[v.index]
|
|
592
|
+
if (!sr) return null
|
|
593
|
+
return (
|
|
594
|
+
<SubResourceRow
|
|
595
|
+
key={sr.slug}
|
|
596
|
+
resource={sr}
|
|
597
|
+
parentSlug={parentSlug}
|
|
598
|
+
adminParent={showAdminColumn ? parent : undefined}
|
|
599
|
+
user={user}
|
|
600
|
+
isHighlighted={sr.slug === highlightSlug}
|
|
601
|
+
measureRef={measureElement}
|
|
602
|
+
index={v.index}
|
|
603
|
+
/>
|
|
604
|
+
)
|
|
605
|
+
})}
|
|
606
|
+
{padBottom > 0 && (
|
|
607
|
+
<SpacerRow height={padBottom} columns={columnCount} />
|
|
608
|
+
)}
|
|
609
|
+
</>
|
|
243
610
|
) : (
|
|
244
|
-
filtered.map((sr) =>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
sr.slug === highlightSlug ? 'bg-primary/[0.06]' : ''
|
|
258
|
-
}
|
|
259
|
-
>
|
|
260
|
-
<TableCell>
|
|
261
|
-
{/* A real link, not a click handler on the row: this is a
|
|
262
|
-
table of resources, each of which has its own page. */}
|
|
263
|
-
<Link
|
|
264
|
-
to="/app/$slug/sub/$subSlug"
|
|
265
|
-
params={{ slug: parentSlug, subSlug: sr.slug }}
|
|
266
|
-
className="font-medium text-sm hover:text-primary hover:underline"
|
|
267
|
-
>
|
|
268
|
-
{sr.displayName}
|
|
269
|
-
</Link>
|
|
270
|
-
{(sr.aliases ?? []).length > 0 && (
|
|
271
|
-
<div className="text-xs text-muted-foreground mt-0.5">
|
|
272
|
-
{(sr.aliases ?? []).join(', ')}
|
|
273
|
-
</div>
|
|
274
|
-
)}
|
|
275
|
-
{sr.description && (
|
|
276
|
-
<div className="text-xs text-muted-foreground mt-0.5">
|
|
277
|
-
{markdownToPlainText(sr.description)}
|
|
278
|
-
</div>
|
|
279
|
-
)}
|
|
280
|
-
</TableCell>
|
|
281
|
-
<TableCell>
|
|
282
|
-
{sr.tier && (
|
|
283
|
-
<Badge
|
|
284
|
-
variant={getTierBadgeVariant(sr.tier)}
|
|
285
|
-
className={`text-xs ${getTierBadgeClassName(sr.tier)}`}
|
|
286
|
-
>
|
|
287
|
-
{getTierDisplayLabel(sr.tier)}
|
|
288
|
-
</Badge>
|
|
289
|
-
)}
|
|
290
|
-
</TableCell>
|
|
291
|
-
<TableCell>
|
|
292
|
-
{sr.ownerPersonSlug && (
|
|
293
|
-
<PersonBadge slug={sr.ownerPersonSlug} />
|
|
294
|
-
)}
|
|
295
|
-
</TableCell>
|
|
296
|
-
<TableCell>
|
|
297
|
-
{approvers.length > 0 ? (
|
|
298
|
-
<div className="flex flex-wrap gap-1">
|
|
299
|
-
{approvers.map((slug) => (
|
|
300
|
-
<PersonOrGroupBadge key={slug} slug={slug} />
|
|
301
|
-
))}
|
|
302
|
-
</div>
|
|
303
|
-
) : (
|
|
304
|
-
<span className="text-muted-foreground">-</span>
|
|
305
|
-
)}
|
|
306
|
-
</TableCell>
|
|
307
|
-
<TableCell>
|
|
308
|
-
{(() => {
|
|
309
|
-
const accountId = (
|
|
310
|
-
sr.extra as Record<string, unknown> | null | undefined
|
|
311
|
-
)?.awsAccountId as string | undefined
|
|
312
|
-
if (!accountId)
|
|
313
|
-
return (
|
|
314
|
-
<span className="text-muted-foreground">—</span>
|
|
315
|
-
)
|
|
316
|
-
// The account id is what people copy; when the resource
|
|
317
|
-
// also carries its own `appUrl` that is the console
|
|
318
|
-
// deep link for THAT account, so the id doubles as the
|
|
319
|
-
// launch affordance.
|
|
320
|
-
return sr.appUrl ? (
|
|
321
|
-
<a
|
|
322
|
-
href={sr.appUrl}
|
|
323
|
-
target="_blank"
|
|
324
|
-
rel="noopener noreferrer"
|
|
325
|
-
className="font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text"
|
|
326
|
-
>
|
|
327
|
-
{accountId}
|
|
328
|
-
</a>
|
|
329
|
-
) : (
|
|
330
|
-
<span className="font-mono text-xs text-muted-foreground select-text">
|
|
331
|
-
{accountId}
|
|
332
|
-
</span>
|
|
333
|
-
)
|
|
334
|
-
})()}
|
|
335
|
-
</TableCell>
|
|
336
|
-
</TableRow>
|
|
337
|
-
)
|
|
338
|
-
})
|
|
611
|
+
filtered.map((sr) => (
|
|
612
|
+
<SubResourceRow
|
|
613
|
+
key={sr.slug}
|
|
614
|
+
resource={sr}
|
|
615
|
+
parentSlug={parentSlug}
|
|
616
|
+
// One prop for "render the Admin cell, with this parent".
|
|
617
|
+
// `showAdminColumn` already implies `parent !== undefined`,
|
|
618
|
+
// so this cannot disagree with the header or the colSpan.
|
|
619
|
+
adminParent={showAdminColumn ? parent : undefined}
|
|
620
|
+
user={user}
|
|
621
|
+
isHighlighted={sr.slug === highlightSlug}
|
|
622
|
+
/>
|
|
623
|
+
))
|
|
339
624
|
)}
|
|
340
625
|
</TableBody>
|
|
341
626
|
</Table>
|