@igstack/app-catalog-frontend-core 6.8.3 → 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.
Files changed (26) hide show
  1. package/dist/esm/index.d.ts +1 -1
  2. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +0 -1
  3. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  4. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +22 -12
  5. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  6. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +0 -1
  7. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  8. package/dist/esm/modules/dismiss/DismissStack.d.ts +9 -0
  9. package/dist/esm/modules/dismiss/DismissStack.js +26 -7
  10. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -1
  11. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +16 -1
  12. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
  13. package/dist/esm/modules/extensions/index.d.ts +19 -3
  14. package/dist/esm/modules/extensions/index.js +10 -4
  15. package/dist/esm/modules/extensions/index.js.map +1 -1
  16. package/dist/esm/modules/extensions/types.d.ts +1 -0
  17. package/package.json +4 -4
  18. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +41 -4
  19. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +34 -0
  20. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +91 -1
  21. package/src/index.tsx +2 -0
  22. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +60 -36
  23. package/src/modules/dismiss/DismissStack.tsx +52 -9
  24. package/src/modules/extensions/features/resourceDetail.ts +17 -1
  25. package/src/modules/extensions/index.ts +37 -2
  26. package/src/modules/extensions/types.ts +1 -0
@@ -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
- // Five columns, the same as before the slot existed.
64
- expect(screen.getAllByRole('columnheader')).toHaveLength(5)
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(6)
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', '6')
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', () => {
@@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest'
2
2
  import { render, screen } from '@testing-library/react'
3
3
  import userEvent from '@testing-library/user-event'
4
4
  import { describe, expect, it, vi } from 'vitest'
5
- import { useRef, useState } from 'react'
5
+ import { useEffect, useRef, useState } from 'react'
6
6
  import {
7
7
  DismissStackProvider,
8
8
  useDismissable,
@@ -327,4 +327,94 @@ describe('the dismiss stack', () => {
327
327
 
328
328
  expect(document.activeElement).toBe(screen.getByTestId('layer-card'))
329
329
  })
330
+
331
+ /** A layer that takes the caret on mount, as the detail card does. */
332
+ function GrabbingLayer({
333
+ name,
334
+ onDismiss,
335
+ children,
336
+ }: {
337
+ name: string
338
+ onDismiss: () => void
339
+ children?: React.ReactNode
340
+ }) {
341
+ const ref = useRef<HTMLDivElement>(null)
342
+ useEffect(() => ref.current?.focus(), [])
343
+ useDismissable(ref, onDismiss)
344
+ return (
345
+ <div ref={ref} tabIndex={-1} data-testid={`layer-${name}`}>
346
+ {children}
347
+ </div>
348
+ )
349
+ }
350
+
351
+ it('gives the caret back to the field that opened the last layer, where it was', async () => {
352
+ // Enter on a search result opens the card; Escape has to land the reader
353
+ // back in the query, caret and all, so a wrong pick is one keystroke to fix.
354
+ function Host() {
355
+ const [open, setOpen] = useState(false)
356
+ return (
357
+ <>
358
+ <input
359
+ aria-label="search"
360
+ onKeyDown={(e) => {
361
+ if (e.key === 'Enter') setOpen(true)
362
+ }}
363
+ />
364
+ {open && (
365
+ <GrabbingLayer name="card" onDismiss={() => setOpen(false)} />
366
+ )}
367
+ </>
368
+ )
369
+ }
370
+ render(<Host />)
371
+ const input = screen.getByLabelText<HTMLInputElement>('search')
372
+
373
+ await userEvent.type(input, 'Master Control{ArrowLeft}{Enter}')
374
+ expect(screen.getByTestId('layer-card')).toHaveFocus()
375
+
376
+ await esc()
377
+ await new Promise((r) => setTimeout(r, 0))
378
+
379
+ expect(input).toHaveFocus()
380
+ expect([input.selectionStart, input.selectionEnd]).toEqual([13, 13])
381
+ })
382
+
383
+ it('does not hand the caret to an opener still covered by a layer', async () => {
384
+ // A dialog opened from inside the card returns to its button in the card;
385
+ // only the card closing reaches the field behind it.
386
+ function Host() {
387
+ const [card, setCard] = useState(false)
388
+ const [dialog, setDialog] = useState(false)
389
+ return (
390
+ <>
391
+ <button type="button" onClick={() => setCard(true)}>
392
+ open card
393
+ </button>
394
+ {card && (
395
+ <GrabbingLayer name="card" onDismiss={() => setCard(false)}>
396
+ <button type="button" onClick={() => setDialog(true)}>
397
+ open dialog
398
+ </button>
399
+ </GrabbingLayer>
400
+ )}
401
+ {dialog && (
402
+ <GrabbingLayer name="dialog" onDismiss={() => setDialog(false)} />
403
+ )}
404
+ </>
405
+ )
406
+ }
407
+ render(<Host />)
408
+ const tick = () => new Promise((r) => setTimeout(r, 0))
409
+
410
+ await userEvent.click(screen.getByRole('button', { name: 'open card' }))
411
+ await userEvent.click(screen.getByRole('button', { name: 'open dialog' }))
412
+ await esc()
413
+ await tick()
414
+ expect(screen.getByRole('button', { name: 'open dialog' })).toHaveFocus()
415
+
416
+ await esc()
417
+ await tick()
418
+ expect(screen.getByRole('button', { name: 'open card' })).toHaveFocus()
419
+ })
330
420
  })
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.extra as Record<string, unknown> | null | undefined)
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
- <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>
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
- const showAdminColumn =
331
- useSlotFilled('resourceSubResourceRowActions') && parent !== undefined
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
- // `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.
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
- const columnCount = showAdminColumn ? 6 : 5
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
- <TableHead className="w-[140px]">AWS Account</TableHead>
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={showAdminColumn ? 6 : 5}
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
  />
@@ -1,4 +1,4 @@
1
- import { createContext, use, useEffect, useMemo, useRef } from 'react'
1
+ import { createContext, use, useEffect, useMemo, useRef, useState } from 'react'
2
2
 
3
3
  /**
4
4
  * One Escape key, one answer: dismiss the layer on top.
@@ -51,6 +51,27 @@ interface DismissEntry {
51
51
  * popover. Skipped when choosing the top rather than unregistering.
52
52
  */
53
53
  enabled: boolean
54
+ /**
55
+ * What had the caret before this layer opened — read on its first render,
56
+ * ahead of the layer's own mount focus — and the caret's place in it when
57
+ * it is a text field.
58
+ */
59
+ opener?: { element: Element | null; selection: [number, number] | null }
60
+ }
61
+
62
+ function readOpener(): DismissEntry['opener'] {
63
+ const element = document.activeElement
64
+ const field =
65
+ element instanceof HTMLInputElement ||
66
+ element instanceof HTMLTextAreaElement
67
+ ? element
68
+ : null
69
+ const start = field?.selectionStart
70
+ const end = field?.selectionEnd
71
+ return {
72
+ element,
73
+ selection: start != null && end != null ? [start, end] : null,
74
+ }
54
75
  }
55
76
 
56
77
  export interface DismissStackApi {
@@ -125,20 +146,38 @@ function createStack(): DismissStackApi {
125
146
  return () => {
126
147
  const i = entries.indexOf(entry)
127
148
  if (i !== -1) entries.splice(i, 1)
128
- if (entries.length === 0) {
149
+ if (entries.length === 0)
129
150
  document.removeEventListener('keydown', onKeyDown)
130
- return
131
- }
132
151
  // When a layer closes, the caret it held goes with it and lands on
133
152
  // <body>, where the next Tab walks whatever is behind every overlay and
134
153
  // Escape still works only because this listener is on the document.
135
- // Hand it to the layer that is now on top. Only from <body>: if
136
- // something already restored focus — Radix returns it to the trigger —
137
- // that choice is more specific than this one and stands.
154
+ // Hand it back to whatever opened the layer — the search field, caret
155
+ // where it was — unless a layer still covers that; then to the layer now
156
+ // on top. Only from <body>: if something already restored focus — Radix
157
+ // returns it to the trigger — that choice is more specific and stands.
138
158
  queueMicrotask(() => {
139
159
  if (document.activeElement && document.activeElement !== document.body)
140
160
  return
141
- topElement()?.focus()
161
+ const onTop = topElement()
162
+ const opener = entry.opener?.element
163
+ if (
164
+ opener instanceof HTMLElement &&
165
+ opener !== document.body &&
166
+ opener.isConnected &&
167
+ (!onTop || onTop.contains(opener))
168
+ ) {
169
+ opener.focus()
170
+ const selection = entry.opener?.selection
171
+ if (
172
+ selection &&
173
+ (opener instanceof HTMLInputElement ||
174
+ opener instanceof HTMLTextAreaElement)
175
+ )
176
+ opener.setSelectionRange(...selection)
177
+ // A hidden, inert or disabled opener refuses focus; fall through.
178
+ if (document.activeElement === opener) return
179
+ }
180
+ onTop?.focus()
142
181
  })
143
182
  }
144
183
  }
@@ -205,11 +244,15 @@ export function useDismissable(
205
244
  // new object whenever the callback identity changed would churn the stack on
206
245
  // every render for no benefit.
207
246
  const entryRef = useRef<DismissEntry>({ layer, onDismiss, enabled })
247
+ // Read during the first render, not in an effect: by the time effects run,
248
+ // the layer has usually taken the caret for itself.
249
+ const [opener] = useState(readOpener)
208
250
  useEffect(() => {
209
251
  entryRef.current.layer = layer
210
252
  entryRef.current.onDismiss = onDismiss
211
253
  entryRef.current.enabled = enabled
212
- }, [layer, onDismiss, enabled])
254
+ entryRef.current.opener = opener
255
+ }, [layer, onDismiss, enabled, opener])
213
256
 
214
257
  useEffect(() => stack.register(entryRef.current), [stack])
215
258
  }
@@ -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(slot: keyof SlotSpec): boolean {
98
- return useExtensions().some((plugin) => Boolean(plugin.slots?.[slot]))
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
  /**
@@ -34,6 +34,7 @@ export interface PluginRegistration {
34
34
  name: string
35
35
  slots?: Record<string, OpaqueRenderer | undefined>
36
36
  wrappers?: Record<string, OpaqueRenderer | undefined>
37
+ appliesTo?: Record<string, ((scope: never) => boolean) | undefined>
37
38
  }
38
39
 
39
40
  /**