@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.
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +0 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +22 -12
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +0 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/dismiss/DismissStack.d.ts +9 -0
- package/dist/esm/modules/dismiss/DismissStack.js +26 -7
- package/dist/esm/modules/dismiss/DismissStack.js.map +1 -1
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +16 -1
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
- package/dist/esm/modules/extensions/index.d.ts +19 -3
- package/dist/esm/modules/extensions/index.js +10 -4
- package/dist/esm/modules/extensions/index.js.map +1 -1
- package/dist/esm/modules/extensions/types.d.ts +1 -0
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +41 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +34 -0
- package/src/__tests__/modules/dismiss/DismissStack.test.tsx +91 -1
- package/src/index.tsx +2 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +60 -36
- package/src/modules/dismiss/DismissStack.tsx +52 -9
- package/src/modules/extensions/features/resourceDetail.ts +17 -1
- package/src/modules/extensions/index.ts +37 -2
- package/src/modules/extensions/types.ts +1 -0
package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx
CHANGED
|
@@ -60,8 +60,9 @@ describe('the sub-resources Admin column', () => {
|
|
|
60
60
|
expect(
|
|
61
61
|
screen.queryByRole('columnheader', { name: 'Admin' }),
|
|
62
62
|
).not.toBeInTheDocument()
|
|
63
|
-
//
|
|
64
|
-
|
|
63
|
+
// Name, Tier, Owner, Approvers — these children carry no account id, so
|
|
64
|
+
// the AWS Account column is absent too.
|
|
65
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(4)
|
|
65
66
|
})
|
|
66
67
|
|
|
67
68
|
it('appears, with one cell per row, once a plugin fills it', () => {
|
|
@@ -70,7 +71,7 @@ describe('the sub-resources Admin column', () => {
|
|
|
70
71
|
expect(
|
|
71
72
|
screen.getByRole('columnheader', { name: 'Admin' }),
|
|
72
73
|
).toBeInTheDocument()
|
|
73
|
-
expect(screen.getAllByRole('columnheader')).toHaveLength(
|
|
74
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(5)
|
|
74
75
|
|
|
75
76
|
// Called once PER ROW and handed that row — the opposite shape to the
|
|
76
77
|
// header slot, which is called once with the whole list.
|
|
@@ -96,7 +97,43 @@ describe('the sub-resources Admin column', () => {
|
|
|
96
97
|
)
|
|
97
98
|
|
|
98
99
|
const empty = within(container).getByText(/No resources match your filters/)
|
|
99
|
-
expect(empty).toHaveAttribute('colspan', '
|
|
100
|
+
expect(empty).toHaveAttribute('colspan', '5')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('is absent on a parent the plugin says it does not serve', () => {
|
|
104
|
+
// A contribution that returns null for this parent would still leave a
|
|
105
|
+
// header over a column of empty cells; `appliesTo` is how the plugin keeps
|
|
106
|
+
// the column off tables it has nothing for.
|
|
107
|
+
mount([
|
|
108
|
+
{
|
|
109
|
+
...rowActionsPlugin,
|
|
110
|
+
appliesTo: {
|
|
111
|
+
resourceSubResourceRowActions: ({ parent: p }) => p.slug === 'other',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
screen.queryByRole('columnheader', { name: 'Admin' }),
|
|
118
|
+
).not.toBeInTheDocument()
|
|
119
|
+
expect(
|
|
120
|
+
screen.queryByRole('button', { name: /Provision/ }),
|
|
121
|
+
).not.toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('appears on a parent the plugin says it serves', () => {
|
|
125
|
+
mount([
|
|
126
|
+
{
|
|
127
|
+
...rowActionsPlugin,
|
|
128
|
+
appliesTo: {
|
|
129
|
+
resourceSubResourceRowActions: ({ parent: p }) => p.slug === 'cloud',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
screen.getByRole('columnheader', { name: 'Admin' }),
|
|
136
|
+
).toBeInTheDocument()
|
|
100
137
|
})
|
|
101
138
|
|
|
102
139
|
it('skips the column when the parent is not known', () => {
|
|
@@ -96,6 +96,40 @@ describe('SubResourcesSection — account id as deep link', () => {
|
|
|
96
96
|
})
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
+
// The AWS Account column belongs to families of AWS accounts. Anything else —
|
|
100
|
+
// workflow namespaces, data projects — has nothing to put in it, and a column
|
|
101
|
+
// of dashes under that header reads as missing data.
|
|
102
|
+
describe('SubResourcesSection — AWS Account column', () => {
|
|
103
|
+
it('is absent when no child carries an account id', () => {
|
|
104
|
+
render(
|
|
105
|
+
<SubResourcesSection
|
|
106
|
+
parentSlug="workflows"
|
|
107
|
+
subResources={[sub('ns-a', null), sub('ns-b', { region: 'x' })]}
|
|
108
|
+
/>,
|
|
109
|
+
)
|
|
110
|
+
expect(
|
|
111
|
+
screen.queryByRole('columnheader', { name: 'AWS Account' }),
|
|
112
|
+
).not.toBeInTheDocument()
|
|
113
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(4)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('is present when at least one child carries an account id', () => {
|
|
117
|
+
render(
|
|
118
|
+
<SubResourcesSection
|
|
119
|
+
parentSlug="cloud-console"
|
|
120
|
+
subResources={[
|
|
121
|
+
sub('acct-a', { awsAccountId: '111122223333' }),
|
|
122
|
+
sub('other', null),
|
|
123
|
+
]}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
expect(
|
|
127
|
+
screen.getByRole('columnheader', { name: 'AWS Account' }),
|
|
128
|
+
).toBeInTheDocument()
|
|
129
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(5)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
99
133
|
// A `?sub=` deep link singles out one row; the table must say so and offer a
|
|
100
134
|
// way back to the full list without editing the URL.
|
|
101
135
|
describe('SubResourcesSection — clearing the ?sub= selection', () => {
|
|
@@ -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
|
|
144
|
-
?.awsAccountId as string | undefined
|
|
156
|
+
const accountId = awsAccountIdOf(sr)
|
|
145
157
|
|
|
146
158
|
return (
|
|
147
159
|
<TableRow
|
|
@@ -202,27 +214,29 @@ function SubResourceRowBase({
|
|
|
202
214
|
<span className="text-muted-foreground">-</span>
|
|
203
215
|
)}
|
|
204
216
|
</TableCell>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
217
|
+
{showAccount && (
|
|
218
|
+
<TableCell>
|
|
219
|
+
{!accountId ? (
|
|
220
|
+
<span className="text-muted-foreground">—</span>
|
|
221
|
+
) : // The account id is what people copy; when the resource also
|
|
222
|
+
// carries its own `appUrl` that is the console deep link for THAT
|
|
223
|
+
// account, so the id doubles as the launch affordance.
|
|
224
|
+
sr.appUrl ? (
|
|
225
|
+
<a
|
|
226
|
+
href={sr.appUrl}
|
|
227
|
+
target="_blank"
|
|
228
|
+
rel="noopener noreferrer"
|
|
229
|
+
className="font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text"
|
|
230
|
+
>
|
|
231
|
+
{accountId}
|
|
232
|
+
</a>
|
|
233
|
+
) : (
|
|
234
|
+
<span className="font-mono text-xs text-muted-foreground select-text">
|
|
235
|
+
{accountId}
|
|
236
|
+
</span>
|
|
237
|
+
)}
|
|
238
|
+
</TableCell>
|
|
239
|
+
)}
|
|
226
240
|
{adminParent !== undefined && (
|
|
227
241
|
// `relative`, so a contribution can position something against the
|
|
228
242
|
// cell — a resting-state glyph under a hover-revealed control, for
|
|
@@ -326,9 +340,19 @@ export function SubResourcesSection({
|
|
|
326
340
|
// ONE flag for the header, the cells and the empty row's colSpan. Deriving it
|
|
327
341
|
// separately per site is how a header appears over a column with no cells —
|
|
328
342
|
// the parent check only guarded the cells at first, and the header rendered
|
|
329
|
-
// anyway.
|
|
330
|
-
|
|
331
|
-
|
|
343
|
+
// anyway. The scope lets a plugin that serves only some parents keep the
|
|
344
|
+
// column off every other parent's table; no parent means nothing to decide.
|
|
345
|
+
const showAdminColumn = useSlotFilled(
|
|
346
|
+
'resourceSubResourceRowActions',
|
|
347
|
+
parent ? { parent } : undefined,
|
|
348
|
+
)
|
|
349
|
+
// Same rule from the data side: only a family of AWS accounts has an account
|
|
350
|
+
// id to show. Decided on the UNFILTERED list, so typing a filter that happens
|
|
351
|
+
// to hide every account does not make the column jump.
|
|
352
|
+
const showAccountColumn = useMemo(
|
|
353
|
+
() => subResources.some((sr) => awsAccountIdOf(sr) !== undefined),
|
|
354
|
+
[subResources],
|
|
355
|
+
)
|
|
332
356
|
const user = usePluginUser()
|
|
333
357
|
// `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row
|
|
334
358
|
// in the search results. It wins over the query-seeded filter below: a query
|
|
@@ -436,13 +460,7 @@ export function SubResourcesSection({
|
|
|
436
460
|
sr.displayName.toLowerCase().includes(q) ||
|
|
437
461
|
(sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||
|
|
438
462
|
(sr.description?.toLowerCase().includes(q) ?? false) ||
|
|
439
|
-
|
|
440
|
-
// dereferencing it threw a TypeError the moment anything typed into
|
|
441
|
-
// this box met a child without it, taking the whole table down.
|
|
442
|
-
(
|
|
443
|
-
(sr.extra as Record<string, unknown> | null | undefined)
|
|
444
|
-
?.awsAccountId as string | undefined
|
|
445
|
-
)?.includes(q) === true,
|
|
463
|
+
awsAccountIdOf(sr)?.includes(q) === true,
|
|
446
464
|
)
|
|
447
465
|
}
|
|
448
466
|
|
|
@@ -458,7 +476,9 @@ export function SubResourcesSection({
|
|
|
458
476
|
// Keyed on the unfiltered size so a big table keeps one layout while it is
|
|
459
477
|
// filtered below the threshold, rather than jumping as the user types.
|
|
460
478
|
const fixedLayout = subResources.length > VIRTUALIZE_ABOVE
|
|
461
|
-
|
|
479
|
+
// Name, Tier, Owner, Approvers, then the two optional columns.
|
|
480
|
+
const columnCount =
|
|
481
|
+
4 + (showAccountColumn ? 1 : 0) + (showAdminColumn ? 1 : 0)
|
|
462
482
|
|
|
463
483
|
if (subResources.length === 0) return null
|
|
464
484
|
|
|
@@ -563,7 +583,9 @@ export function SubResourcesSection({
|
|
|
563
583
|
<TableHead className={fixedLayout ? 'w-[230px]' : undefined}>
|
|
564
584
|
Approvers
|
|
565
585
|
</TableHead>
|
|
566
|
-
|
|
586
|
+
{showAccountColumn && (
|
|
587
|
+
<TableHead className="w-[140px]">AWS Account</TableHead>
|
|
588
|
+
)}
|
|
567
589
|
{/* Only when something fills it: an empty column with a header and
|
|
568
590
|
no cells is worse than no column, and the open-source build
|
|
569
591
|
registers no plugins at all. */}
|
|
@@ -576,7 +598,7 @@ export function SubResourcesSection({
|
|
|
576
598
|
{filtered.length === 0 ? (
|
|
577
599
|
<TableRow>
|
|
578
600
|
<TableCell
|
|
579
|
-
colSpan={
|
|
601
|
+
colSpan={columnCount}
|
|
580
602
|
className="text-center text-muted-foreground py-8"
|
|
581
603
|
>
|
|
582
604
|
No resources match your filters
|
|
@@ -596,6 +618,7 @@ export function SubResourcesSection({
|
|
|
596
618
|
resource={sr}
|
|
597
619
|
parentSlug={parentSlug}
|
|
598
620
|
adminParent={showAdminColumn ? parent : undefined}
|
|
621
|
+
showAccount={showAccountColumn}
|
|
599
622
|
user={user}
|
|
600
623
|
isHighlighted={sr.slug === highlightSlug}
|
|
601
624
|
measureRef={measureElement}
|
|
@@ -617,6 +640,7 @@ export function SubResourcesSection({
|
|
|
617
640
|
// `showAdminColumn` already implies `parent !== undefined`,
|
|
618
641
|
// so this cannot disagree with the header or the colSpan.
|
|
619
642
|
adminParent={showAdminColumn ? parent : undefined}
|
|
643
|
+
showAccount={showAccountColumn}
|
|
620
644
|
user={user}
|
|
621
645
|
isHighlighted={sr.slug === highlightSlug}
|
|
622
646
|
/>
|
|
@@ -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
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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()
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
117
|
+
export function useSlotFilled<TSlot extends keyof SlotSpec>(
|
|
118
|
+
slot: TSlot,
|
|
119
|
+
...scope: TSlot extends keyof SlotScopeSpec
|
|
120
|
+
? [scope: SlotScopeSpec[TSlot] | undefined]
|
|
121
|
+
: []
|
|
122
|
+
): boolean {
|
|
123
|
+
const plugins = useExtensions()
|
|
124
|
+
const scoped = scope.length > 0
|
|
125
|
+
const value = scope[0]
|
|
126
|
+
if (scoped && value === undefined) return false
|
|
127
|
+
return plugins.some((plugin) => {
|
|
128
|
+
if (!plugin.slots?.[slot]) return false
|
|
129
|
+
const applies = plugin.appliesTo?.[slot] as
|
|
130
|
+
| ((s: typeof value) => boolean)
|
|
131
|
+
| undefined
|
|
132
|
+
return !scoped || !applies || applies(value)
|
|
133
|
+
})
|
|
99
134
|
}
|
|
100
135
|
|
|
101
136
|
/**
|