@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.
Files changed (139) hide show
  1. package/dist/esm/App.d.ts +29 -3
  2. package/dist/esm/App.js +4 -2
  3. package/dist/esm/App.js.map +1 -1
  4. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
  5. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
  6. package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
  7. package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
  8. package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
  9. package/dist/esm/appPropsFactory.d.ts +19 -1
  10. package/dist/esm/appPropsFactory.js +4 -3
  11. package/dist/esm/appPropsFactory.js.map +1 -1
  12. package/dist/esm/index.d.ts +15 -2
  13. package/dist/esm/index.js +5 -0
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/internal.d.ts +7 -1
  16. package/dist/esm/internal.js +0 -2
  17. package/dist/esm/internal.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
  19. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
  20. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
  22. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  23. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +39 -17
  24. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
  26. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
  28. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  29. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
  30. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
  31. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  32. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
  33. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
  34. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
  36. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  37. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
  38. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
  39. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  40. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
  41. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  42. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  44. package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
  45. package/dist/esm/modules/auth/AuthContext.js +5 -0
  46. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  47. package/dist/esm/modules/auth/index.d.ts +1 -1
  48. package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
  49. package/dist/esm/modules/dismiss/DismissStack.js +82 -0
  50. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
  51. package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
  52. package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
  53. package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
  54. package/dist/esm/modules/extensions/factories.d.ts +18 -0
  55. package/dist/esm/modules/extensions/factories.js +12 -0
  56. package/dist/esm/modules/extensions/factories.js.map +1 -0
  57. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
  58. package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
  59. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
  60. package/dist/esm/modules/extensions/index.d.ts +79 -0
  61. package/dist/esm/modules/extensions/index.js +26 -0
  62. package/dist/esm/modules/extensions/index.js.map +1 -0
  63. package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
  64. package/dist/esm/modules/extensions/makeSlot.js +62 -0
  65. package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
  66. package/dist/esm/modules/extensions/types.d.ts +46 -0
  67. package/dist/esm/modules/gallery/Gallery.js +6 -14
  68. package/dist/esm/modules/gallery/Gallery.js.map +1 -1
  69. package/dist/esm/routeTree.gen.d.ts +34 -34
  70. package/dist/esm/routeTree.gen.js +14 -14
  71. package/dist/esm/routeTree.gen.js.map +1 -1
  72. package/dist/esm/router.d.ts +23 -0
  73. package/dist/esm/router.js +5 -0
  74. package/dist/esm/router.js.map +1 -0
  75. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  76. package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
  77. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
  78. package/dist/esm/routes/_layout/index.d.ts +1 -1
  79. package/dist/esm/routes/_layout/login.d.ts +1 -1
  80. package/dist/esm/routes/_layout/mcp.d.ts +1 -1
  81. package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
  82. package/dist/esm/routes/_layout.d.ts +1 -1
  83. package/dist/esm/routes/_layout.js +13 -2
  84. package/dist/esm/routes/_layout.js.map +1 -1
  85. package/dist/esm/routes/auth.callback.d.ts +1 -1
  86. package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
  87. package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
  88. package/dist/esm/ui/components/header/Header.js +0 -1
  89. package/dist/esm/ui/components/header/Header.js.map +1 -1
  90. package/dist/esm/ui/dialog.d.ts +1 -1
  91. package/dist/esm/ui/dialog.js +9 -0
  92. package/dist/esm/ui/dialog.js.map +1 -1
  93. package/dist/esm/ui/table.d.ts +25 -1
  94. package/dist/esm/ui/table.js +20 -3
  95. package/dist/esm/ui/table.js.map +1 -1
  96. package/dist/esm/util/createAcRouter.d.ts +1 -1
  97. package/package.json +19 -11
  98. package/src/App.tsx +34 -3
  99. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
  100. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
  101. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
  102. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
  103. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
  104. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
  105. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
  106. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
  107. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
  108. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
  109. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
  110. package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
  111. package/src/__tests__/publicEntryResolves.test.ts +63 -0
  112. package/src/appPropsFactory.ts +23 -3
  113. package/src/index.tsx +32 -2
  114. package/src/internal.ts +7 -1
  115. package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
  116. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
  117. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +59 -18
  118. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
  119. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
  120. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
  121. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
  122. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
  123. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
  124. package/src/modules/auth/AuthContext.tsx +15 -0
  125. package/src/modules/auth/index.ts +1 -0
  126. package/src/modules/dismiss/DismissStack.tsx +224 -0
  127. package/src/modules/extensions/ExtensionsContext.tsx +36 -0
  128. package/src/modules/extensions/factories.ts +19 -0
  129. package/src/modules/extensions/features/resourceDetail.ts +74 -0
  130. package/src/modules/extensions/index.ts +121 -0
  131. package/src/modules/extensions/makeSlot.tsx +123 -0
  132. package/src/modules/extensions/types.ts +51 -0
  133. package/src/modules/gallery/Gallery.tsx +13 -17
  134. package/src/routeTree.gen.ts +31 -31
  135. package/src/router.ts +23 -0
  136. package/src/routes/_layout.tsx +51 -1
  137. package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
  138. package/src/ui/dialog.tsx +26 -0
  139. 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 { useEffect, useMemo, useRef, useState } from 'react'
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 (search.trim()) {
147
- const q = search.trim().toLowerCase()
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>).awsAccountId as
155
- | string
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, search, tierFilter, selectedSub])
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
- <div className="text-sm font-medium">
170
- Sub-Resources ({filtered.length} of {subResources.length})
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
- placeholder="Search resources by name or alias..."
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
- <div className="rounded-lg border max-h-[400px] overflow-auto">
223
- <Table>
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>Owner</TableHead>
229
- <TableHead>Approvers</TableHead>
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
- // Approvers are Person OR Group slugs; the badge resolves both.
246
- const approvers = [...new Set(sr.approverSlugs ?? [])]
247
-
248
- return (
249
- <TableRow
250
- key={sr.slug}
251
- // The row the user asked for, so it reads as "this one" even
252
- // once the filter is cleared and its siblings come back.
253
- aria-current={
254
- sr.slug === highlightSlug ? 'true' : undefined
255
- }
256
- className={
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>