@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
|
@@ -31,6 +31,7 @@ import { formatRelativeTime } from '../../utils/formatRelativeTime'
|
|
|
31
31
|
import { TierVariantsSection } from '../components/TierVariantsSection'
|
|
32
32
|
import { SubResourcesSection } from '../components/SubResourcesSection'
|
|
33
33
|
import { getChildResources } from '../../utils/resolveHelpers'
|
|
34
|
+
import { ResourceDetailAccessActions } from '~/modules/extensions'
|
|
34
35
|
import { FeedbackSection } from './FeedbackSection'
|
|
35
36
|
import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
|
|
36
37
|
import { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'
|
|
@@ -116,11 +117,9 @@ function AppScreenshot({ app }: { app: Resource }) {
|
|
|
116
117
|
export function AppDetails({
|
|
117
118
|
app,
|
|
118
119
|
onAppClick,
|
|
119
|
-
onClosePanel,
|
|
120
120
|
}: {
|
|
121
121
|
app: Resource
|
|
122
122
|
onAppClick?: (app: Resource) => void
|
|
123
|
-
onClosePanel: () => void
|
|
124
123
|
}) {
|
|
125
124
|
const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)
|
|
126
125
|
const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)
|
|
@@ -175,6 +174,31 @@ export function AppDetails({
|
|
|
175
174
|
[allResources, app.slug],
|
|
176
175
|
)
|
|
177
176
|
|
|
177
|
+
// `app` is whatever resource the card shows, a nested one included, so the
|
|
178
|
+
// parent has to be looked up rather than assumed absent. A plugin uses its
|
|
179
|
+
// presence to tell a sub-resource view from a top-level one.
|
|
180
|
+
const parent = React.useMemo(
|
|
181
|
+
() =>
|
|
182
|
+
app.parentSlug
|
|
183
|
+
? allResources.find((r) => r.slug === app.parentSlug)
|
|
184
|
+
: undefined,
|
|
185
|
+
[allResources, app.parentSlug],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// What a plugin slot in the band receives. Memoised so a plugin that
|
|
189
|
+
// memoises on its props is not defeated by a new object every render.
|
|
190
|
+
const accessActionsCtx = React.useMemo(
|
|
191
|
+
() => ({
|
|
192
|
+
resource: app,
|
|
193
|
+
parent,
|
|
194
|
+
subResources: parent
|
|
195
|
+
? getChildResources(allResources, parent.slug)
|
|
196
|
+
: children,
|
|
197
|
+
user: user ? { email: user.email, isAdmin } : null,
|
|
198
|
+
}),
|
|
199
|
+
[app, parent, allResources, children, user, isAdmin],
|
|
200
|
+
)
|
|
201
|
+
|
|
178
202
|
// Enter: open screenshot gallery
|
|
179
203
|
useHotkeys(
|
|
180
204
|
'enter',
|
|
@@ -198,21 +222,6 @@ export function AppDetails({
|
|
|
198
222
|
[app, isGalleryOpen],
|
|
199
223
|
)
|
|
200
224
|
|
|
201
|
-
// Esc: close the details panel (only when gallery is NOT open)
|
|
202
|
-
//
|
|
203
|
-
// From inside a text field too: the Quick Jump field holds the caret from the
|
|
204
|
-
// moment the card opens, so the default "ignore form tags" left the card with
|
|
205
|
-
// no keyboard way out. A field that wants Esc for itself stops the event —
|
|
206
|
-
// InlineEditableField does, so Esc there cancels the edit and nothing more.
|
|
207
|
-
useHotkeys(
|
|
208
|
-
'escape',
|
|
209
|
-
() => {
|
|
210
|
-
onClosePanel()
|
|
211
|
-
},
|
|
212
|
-
{ enabled: !isGalleryOpen, enableOnFormTags: ['input'] },
|
|
213
|
-
[isGalleryOpen, onClosePanel],
|
|
214
|
-
)
|
|
215
|
-
|
|
216
225
|
const handleScreenshotClick = (index: number) => {
|
|
217
226
|
setGalleryInitialIndex(index)
|
|
218
227
|
setIsGalleryOpen(true)
|
|
@@ -346,7 +355,9 @@ export function AppDetails({
|
|
|
346
355
|
<SubResourcesSection
|
|
347
356
|
subResources={children}
|
|
348
357
|
parentSlug={app.slug}
|
|
358
|
+
parent={app}
|
|
349
359
|
initialSearch={filterState.searchValue}
|
|
360
|
+
childrenLabel={app.childrenLabel}
|
|
350
361
|
/>
|
|
351
362
|
</div>
|
|
352
363
|
)}
|
|
@@ -650,15 +661,24 @@ export function AppDetails({
|
|
|
650
661
|
card to find. Up here it is present on every tab, which is the whole
|
|
651
662
|
reason the band does not scroll away.
|
|
652
663
|
`mr-10` keeps it clear of the dialog's own close button at top-right. */}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
664
|
+
{/* Plugin actions sit beside "Suggest a change", sharing its reasoning:
|
|
665
|
+
the band is mounted on every tab and never scrolls away. A slot in a
|
|
666
|
+
tab panel would be invisible until a second click, and one inside
|
|
667
|
+
SubResourceDetailPanel's `hasAnyAccess` gate would vanish for exactly
|
|
668
|
+
the resources that need help most.
|
|
669
|
+
Renders nothing when no plugin contributes, so the band keeps its
|
|
670
|
+
current shape in the open-source build. */}
|
|
671
|
+
<div className="ml-auto mr-10 flex items-center gap-2">
|
|
672
|
+
<ResourceDetailAccessActions {...accessActionsCtx} />
|
|
673
|
+
<Button
|
|
674
|
+
type="button"
|
|
675
|
+
size="sm"
|
|
676
|
+
variant="outline"
|
|
677
|
+
onClick={() => setComposeRequests((n) => n + 1)}
|
|
678
|
+
>
|
|
679
|
+
✎ Suggest a change
|
|
680
|
+
</Button>
|
|
681
|
+
</div>
|
|
662
682
|
</div>
|
|
663
683
|
|
|
664
684
|
{app.nicknames && app.nicknames.length > 0 && (
|
|
@@ -116,6 +116,26 @@ export function DetailTabs({
|
|
|
116
116
|
setUserChose(true)
|
|
117
117
|
setUrlTab(requestTab)
|
|
118
118
|
}, [requestTab, requestNonce, setUrlTab])
|
|
119
|
+
/**
|
|
120
|
+
* Releasing the force must not move the view.
|
|
121
|
+
*
|
|
122
|
+
* Arriving with `?sub=` forces the sub-resources tab, but only through
|
|
123
|
+
* `forcedTab` — the tab's own state is still the default. Clearing the
|
|
124
|
+
* selection drops `?sub=`, the force goes with it, and the strip fell back to
|
|
125
|
+
* that stale default: the reader was moved to Overview while looking at the
|
|
126
|
+
* table they had just cleared a filter on. So when the force is released
|
|
127
|
+
* before the user picked anything, the forced tab becomes the real choice.
|
|
128
|
+
*
|
|
129
|
+
* Adjusted during render rather than in an effect, the pattern React
|
|
130
|
+
* documents for responding to a prop change: an effect would paint one frame
|
|
131
|
+
* of Overview first, mounting that panel only to tear it down again.
|
|
132
|
+
*/
|
|
133
|
+
const [lastForced, setLastForced] = React.useState(forcedTab)
|
|
134
|
+
if (forcedTab !== lastForced) {
|
|
135
|
+
setLastForced(forcedTab)
|
|
136
|
+
if (lastForced && !forcedTab && !userChose) setUrlTab(lastForced)
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
const asked = userChose || !forcedTab ? urlTab : forcedTab
|
|
120
140
|
const requested = RETIRED[asked] ?? asked
|
|
121
141
|
const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')
|
|
@@ -68,6 +68,22 @@ export function AppCatalogPage({
|
|
|
68
68
|
[resources],
|
|
69
69
|
)
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* What the grid actually shows, memoized because its IDENTITY matters.
|
|
73
|
+
*
|
|
74
|
+
* Filtering inline in the JSX produced a new array on every render, and the
|
|
75
|
+
* grid keys four `useMemo`s off this prop (the slug index, "new this week",
|
|
76
|
+
* the browse list and its area grouping). A fresh identity invalidated all
|
|
77
|
+
* four on every keystroke — recomputed even while searching, when the
|
|
78
|
+
* discovery spine they feed is not on screen. The search box lives in a
|
|
79
|
+
* context above this component, so every keystroke re-renders here.
|
|
80
|
+
*/
|
|
81
|
+
const visibleRootResources = useMemo(
|
|
82
|
+
() =>
|
|
83
|
+
rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),
|
|
84
|
+
[rootResources, filterState.showDeprecated],
|
|
85
|
+
)
|
|
86
|
+
|
|
71
87
|
// Dev-only skew warning: data arrived (resources > 0) but nothing is top-level
|
|
72
88
|
// (rootResources === 0). That fingerprints a frontend/backend-core version skew
|
|
73
89
|
// or a stale service worker — NOT a normal empty search/filter (those still have
|
|
@@ -187,9 +203,7 @@ export function AppCatalogPage({
|
|
|
187
203
|
// against the bottom edge or under the floating support bubble.
|
|
188
204
|
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24">
|
|
189
205
|
<AppCatalogGrid
|
|
190
|
-
apps={
|
|
191
|
-
(a) => filterState.showDeprecated || !a.deprecated,
|
|
192
|
-
)}
|
|
206
|
+
apps={visibleRootResources}
|
|
193
207
|
// All resources incl. children — so search matches sub-resources
|
|
194
208
|
// (e.g. a cloud account) and surfaces their parent.
|
|
195
209
|
allResources={resources}
|
|
@@ -131,3 +131,18 @@ export function useUser() {
|
|
|
131
131
|
const { user } = useAuth()
|
|
132
132
|
return user
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The current user, or null when there is no `AuthProvider` above.
|
|
137
|
+
*
|
|
138
|
+
* `useUser()` throws without a provider, which is correct for UI that only ever
|
|
139
|
+
* renders inside the app but wrong for anything a test may mount bare. Several
|
|
140
|
+
* components are rendered bare on purpose (`AppDetailPanel`, `AppCatalogGrid`),
|
|
141
|
+
* so assembling a plugin slot payload with `useUser()` there turns every one of
|
|
142
|
+
* those tests red — "useAuth must be used within AuthProvider".
|
|
143
|
+
*
|
|
144
|
+
* Signed-out and no-provider are the same answer for a slot payload: null.
|
|
145
|
+
*/
|
|
146
|
+
export function useOptionalUser() {
|
|
147
|
+
return use(AuthContext)?.user ?? null
|
|
148
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createContext, use, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One Escape key, one answer: dismiss the layer on top.
|
|
5
|
+
*
|
|
6
|
+
* Before this existed, Escape was handled in six independent places, and each
|
|
7
|
+
* one had to know about the others to avoid double-firing — the detail card
|
|
8
|
+
* bound Escape only `enabled: !isGalleryOpen`, the gallery installed its own
|
|
9
|
+
* capture-phase listener specifically to beat Radix to the event, and the
|
|
10
|
+
* screenshot dialog cancelled Radix's own Escape by reading the gallery's
|
|
11
|
+
* fullscreen state through a ref. Every new overlay had to learn that dance,
|
|
12
|
+
* and getting it wrong stayed invisible until someone pressed Escape in the
|
|
13
|
+
* wrong order. It was wrong: a dialog opened over the card had no say, so
|
|
14
|
+
* Escape closed the card out from under it and navigated away.
|
|
15
|
+
*
|
|
16
|
+
* A layer now says only "I am dismissable, and this element is me". The stack
|
|
17
|
+
* decides who Escape belongs to, and no layer needs to know another exists.
|
|
18
|
+
*
|
|
19
|
+
* "On top" is DOCUMENT ORDER, the last enabled layer in the document. Two
|
|
20
|
+
* cheaper keys were tried first and both broke:
|
|
21
|
+
*
|
|
22
|
+
* - Registration order. React runs a child's effects before its parent's, so
|
|
23
|
+
* an overlay nested inside another registers FIRST and the container looks
|
|
24
|
+
* newer: Escape in the screenshot viewer closed the card under it.
|
|
25
|
+
* - Activation time. On a cold deep link the dialog's route resolves before
|
|
26
|
+
* the catalog arrives, so the card it sits over mounts LATER and looks
|
|
27
|
+
* newer: Escape closed the card first, and the card took focus back off the
|
|
28
|
+
* dialog's first field.
|
|
29
|
+
*
|
|
30
|
+
* Document order has neither problem because it is what "on top" means on
|
|
31
|
+
* screen: a portal appends to the end of `<body>`, a child route renders after
|
|
32
|
+
* its parent's page, and a nested layer is a descendant — and a descendant
|
|
33
|
+
* follows its ancestor in document order. None of it depends on when anything
|
|
34
|
+
* mounted.
|
|
35
|
+
*
|
|
36
|
+
* `useDismissable` works with or without a provider above it, on purpose. A
|
|
37
|
+
* hook that silently did nothing outside a provider would be the same class of
|
|
38
|
+
* bug this module exists to delete — every render site, including tests and
|
|
39
|
+
* stories, would have to remember a wrapper, and forgetting it would look like
|
|
40
|
+
* working code with dead Escape handling. So an app-wide stack is the default
|
|
41
|
+
* and `DismissStackProvider` is only for scoping.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
interface DismissEntry {
|
|
45
|
+
/** The layer's root element; its position in the document is its rank. */
|
|
46
|
+
layer: { readonly current: HTMLElement | null }
|
|
47
|
+
/** Called when Escape belongs to this layer. */
|
|
48
|
+
onDismiss: () => void
|
|
49
|
+
/**
|
|
50
|
+
* A layer can stay registered and opt out — a collapsed panel, a closed
|
|
51
|
+
* popover. Skipped when choosing the top rather than unregistering.
|
|
52
|
+
*/
|
|
53
|
+
enabled: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DismissStackApi {
|
|
57
|
+
/** Adds an entry and returns its unregister function. */
|
|
58
|
+
register: (entry: DismissEntry) => () => void
|
|
59
|
+
/** Dismisses the top layer; reports whether there was one. */
|
|
60
|
+
dismissTop: () => boolean
|
|
61
|
+
/**
|
|
62
|
+
* The top layer's element, or null when nothing is open. For a layer that
|
|
63
|
+
* takes focus on mount: it should only do so while it is on top, or it
|
|
64
|
+
* steals the caret from whatever was opened over it.
|
|
65
|
+
*/
|
|
66
|
+
topElement: () => HTMLElement | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isAfter(a: HTMLElement, b: HTMLElement): boolean {
|
|
70
|
+
// A descendant reports FOLLOWING as well as CONTAINED_BY, so this one test
|
|
71
|
+
// covers both "rendered later" and "nested inside".
|
|
72
|
+
return (b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createStack(): DismissStackApi {
|
|
76
|
+
const entries: DismissEntry[] = []
|
|
77
|
+
|
|
78
|
+
const top = (): DismissEntry | undefined => {
|
|
79
|
+
let best: DismissEntry | undefined
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const el = entry.layer.current
|
|
82
|
+
// Not in the document yet, or already gone: not something on screen.
|
|
83
|
+
if (!entry.enabled || !el?.isConnected) continue
|
|
84
|
+
const bestEl = best?.layer.current
|
|
85
|
+
if (!bestEl || isAfter(el, bestEl)) best = entry
|
|
86
|
+
}
|
|
87
|
+
return best
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dismissTop = () => {
|
|
91
|
+
const entry = top()
|
|
92
|
+
if (!entry) return false
|
|
93
|
+
entry.onDismiss()
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const topElement = () => top()?.layer.current ?? null
|
|
98
|
+
|
|
99
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
100
|
+
if (event.key !== 'Escape') return
|
|
101
|
+
// Something closer to the event already dealt with it — a text field
|
|
102
|
+
// cancelling an edit, say. Honoured rather than overridden: the element the
|
|
103
|
+
// user is typing in is more specific than any layer containing it.
|
|
104
|
+
//
|
|
105
|
+
// This check is the reason the listener below is on the BUBBLE phase. In
|
|
106
|
+
// capture it would run before every component handler, so `defaultPrevented`
|
|
107
|
+
// would always be false and a field could never keep Escape for itself.
|
|
108
|
+
if (event.defaultPrevented) return
|
|
109
|
+
if (!dismissTop()) return
|
|
110
|
+
// Claim it, so a handler further out does not also act.
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
event.stopPropagation()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const register = (entry: DismissEntry) => {
|
|
116
|
+
if (entries.length === 0) {
|
|
117
|
+
// Bubble, on the document: every component handler and React's own
|
|
118
|
+
// delegated handlers run first, so the innermost thing that wants Escape
|
|
119
|
+
// gets it. Radix is the one exception — it listens with `capture: true`,
|
|
120
|
+
// so it runs BEFORE this. `DialogContent` bridges that by asking the
|
|
121
|
+
// stack from inside Radix's own escape callback.
|
|
122
|
+
document.addEventListener('keydown', onKeyDown)
|
|
123
|
+
}
|
|
124
|
+
entries.push(entry)
|
|
125
|
+
return () => {
|
|
126
|
+
const i = entries.indexOf(entry)
|
|
127
|
+
if (i !== -1) entries.splice(i, 1)
|
|
128
|
+
if (entries.length === 0) {
|
|
129
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// When a layer closes, the caret it held goes with it and lands on
|
|
133
|
+
// <body>, where the next Tab walks whatever is behind every overlay and
|
|
134
|
+
// 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.
|
|
138
|
+
queueMicrotask(() => {
|
|
139
|
+
if (document.activeElement && document.activeElement !== document.body)
|
|
140
|
+
return
|
|
141
|
+
topElement()?.focus()
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { register, dismissTop, topElement }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The app-wide stack, used whenever no provider scopes one.
|
|
151
|
+
*
|
|
152
|
+
* Module-level so two sibling overlays coordinate even if neither sits under a
|
|
153
|
+
* provider — which is the whole point. Created lazily so importing this module
|
|
154
|
+
* never touches `document`, keeping it safe in a non-DOM environment.
|
|
155
|
+
*/
|
|
156
|
+
let appStack: DismissStackApi | null = null
|
|
157
|
+
function getAppStack(): DismissStackApi {
|
|
158
|
+
appStack ??= createStack()
|
|
159
|
+
return appStack
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const DismissStackContext = createContext<DismissStackApi | null>(null)
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Scopes a stack to a subtree.
|
|
166
|
+
*
|
|
167
|
+
* Only needed when overlays inside must not compete with overlays outside —
|
|
168
|
+
* two independently mounted apps on one page, for instance. The app does not
|
|
169
|
+
* need it for ordinary nesting; the default stack already handles that.
|
|
170
|
+
*/
|
|
171
|
+
export function DismissStackProvider({
|
|
172
|
+
children,
|
|
173
|
+
}: {
|
|
174
|
+
children: React.ReactNode
|
|
175
|
+
}) {
|
|
176
|
+
const api = useMemo(() => createStack(), [])
|
|
177
|
+
return <DismissStackContext value={api}>{children}</DismissStackContext>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Declare this layer dismissable by Escape.
|
|
182
|
+
*
|
|
183
|
+
* ```tsx
|
|
184
|
+
* const ref = useRef<HTMLDivElement>(null)
|
|
185
|
+
* useDismissable(ref, onClose) // always dismissable
|
|
186
|
+
* useDismissable(ref, close, { enabled: isOpen }) // only while open
|
|
187
|
+
* return <div ref={ref} role="dialog">…</div>
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* `ref` is the layer's root element, and it is required rather than optional
|
|
191
|
+
* because it IS the ordering: the last enabled layer in the document owns
|
|
192
|
+
* Escape. Call the hook once per layer, unconditionally; nothing else
|
|
193
|
+
* coordinates with any other layer, and adding one above an existing layer
|
|
194
|
+
* needs no change to it.
|
|
195
|
+
*/
|
|
196
|
+
export function useDismissable(
|
|
197
|
+
layer: { readonly current: HTMLElement | null },
|
|
198
|
+
onDismiss: () => void,
|
|
199
|
+
{ enabled = true }: { enabled?: boolean } = {},
|
|
200
|
+
): void {
|
|
201
|
+
const scoped = use(DismissStackContext)
|
|
202
|
+
const stack = scoped ?? getAppStack()
|
|
203
|
+
|
|
204
|
+
// A stable entry whose fields are refreshed in place. Handing `register` a
|
|
205
|
+
// new object whenever the callback identity changed would churn the stack on
|
|
206
|
+
// every render for no benefit.
|
|
207
|
+
const entryRef = useRef<DismissEntry>({ layer, onDismiss, enabled })
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
entryRef.current.layer = layer
|
|
210
|
+
entryRef.current.onDismiss = onDismiss
|
|
211
|
+
entryRef.current.enabled = enabled
|
|
212
|
+
}, [layer, onDismiss, enabled])
|
|
213
|
+
|
|
214
|
+
useEffect(() => stack.register(entryRef.current), [stack])
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The stack itself, for a component that owns its own key handling or focus
|
|
219
|
+
* and wants stack ordering instead of inventing its own.
|
|
220
|
+
*/
|
|
221
|
+
export function useDismissStack(): DismissStackApi {
|
|
222
|
+
const scoped = use(DismissStackContext)
|
|
223
|
+
return scoped ?? getAppStack()
|
|
224
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createContext, use } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import type { PluginRegistration } from './types'
|
|
4
|
+
|
|
5
|
+
const ExtensionsInternalContext = createContext<
|
|
6
|
+
readonly PluginRegistration[] | undefined
|
|
7
|
+
>(undefined)
|
|
8
|
+
|
|
9
|
+
interface ExtensionsContextProps {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
value?: readonly PluginRegistration[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ExtensionsContext({ children, value }: ExtensionsContextProps) {
|
|
15
|
+
return (
|
|
16
|
+
<ExtensionsInternalContext value={value}>
|
|
17
|
+
{children}
|
|
18
|
+
</ExtensionsInternalContext>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Registered plugins, or an empty list.
|
|
24
|
+
*
|
|
25
|
+
* Empty is the open-source default and has to stay a first-class state: every
|
|
26
|
+
* slot renders nothing and every wrapper passes its children through, so the UI
|
|
27
|
+
* is identical to one with no slots at all. Slots are additive — a slot must
|
|
28
|
+
* never be the only route to something the core itself needs.
|
|
29
|
+
*/
|
|
30
|
+
export function useExtensions(): readonly PluginRegistration[] {
|
|
31
|
+
return use(ExtensionsInternalContext) ?? EMPTY
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Module-level constant: a fresh `[]` per call would be a new identity every
|
|
35
|
+
// render and defeat memoisation in anything downstream of it.
|
|
36
|
+
const EMPTY: readonly PluginRegistration[] = []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { makeSlot, makeWrapper } from './makeSlot'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Binds one feature's slot spec, so each slot names itself exactly once and the
|
|
5
|
+
* name is checked against that feature's own spec.
|
|
6
|
+
*
|
|
7
|
+
* const mkSlot = slotFactory<AppCatalogSlots>()
|
|
8
|
+
* export const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')
|
|
9
|
+
* // ^ typo here is a compile error
|
|
10
|
+
*/
|
|
11
|
+
export function slotFactory<TSpec>() {
|
|
12
|
+
return <TName extends keyof TSpec & string>(name: TName) =>
|
|
13
|
+
makeSlot<TSpec[TName]>(name)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function wrapperFactory<TSpec>() {
|
|
17
|
+
return <TName extends keyof TSpec & string>(name: TName) =>
|
|
18
|
+
makeWrapper<TSpec[TName]>(name)
|
|
19
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { slotFactory, wrapperFactory } from '../factories'
|
|
3
|
+
import type { PluginUser } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Leaf slots on the resource detail card.
|
|
7
|
+
*
|
|
8
|
+
* Payloads are additive-only. Adding a field is invisible to existing plugins;
|
|
9
|
+
* renaming or removing one breaks them at compile time on their next core bump,
|
|
10
|
+
* which is the intended moment for that to surface.
|
|
11
|
+
*/
|
|
12
|
+
export interface ResourceDetailSlots {
|
|
13
|
+
/**
|
|
14
|
+
* Actions on the detail card's header band.
|
|
15
|
+
*
|
|
16
|
+
* The band and not a tab panel: tab panels mount only while active and
|
|
17
|
+
* Overview is first, so a slot inside a tab is invisible until a second click.
|
|
18
|
+
*/
|
|
19
|
+
resourceDetailAccessActions: {
|
|
20
|
+
/** The resource on screen — a top-level app OR one of its sub-resources. */
|
|
21
|
+
resource: Resource
|
|
22
|
+
/** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */
|
|
23
|
+
parent?: Resource
|
|
24
|
+
/** Children of `parent ?? resource`: the family's sub-resources. */
|
|
25
|
+
subResources: Resource[]
|
|
26
|
+
user: PluginUser | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One cell per row of the sub-resources table, in a column of its own.
|
|
31
|
+
*
|
|
32
|
+
* Called once per rendered row, which is the opposite shape to
|
|
33
|
+
* `resourceDetailAccessActions` — that one is called once and handed the whole
|
|
34
|
+
* list. Use this when the action belongs to a single child.
|
|
35
|
+
*
|
|
36
|
+
* Two things to know before filling it. The table renders EVERY row with no
|
|
37
|
+
* virtualisation, so a contribution here is mounted as many times as the
|
|
38
|
+
* family has children (hundreds, in a large catalog) — keep the cell cheap and
|
|
39
|
+
* defer anything expensive until it is interacted with. And the column only
|
|
40
|
+
* appears when a plugin actually fills it, so a build with no plugin renders
|
|
41
|
+
* the table exactly as before.
|
|
42
|
+
*/
|
|
43
|
+
resourceSubResourceRowActions: {
|
|
44
|
+
/** The child this row is showing. */
|
|
45
|
+
resource: Resource
|
|
46
|
+
/** The entry the row hangs off. Always present here, unlike the header slot. */
|
|
47
|
+
parent: Resource
|
|
48
|
+
user: PluginUser | null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wrappers on the resource detail card.
|
|
54
|
+
*
|
|
55
|
+
* `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`
|
|
56
|
+
* subtree, so anything a plugin provides here is scoped to the resource on
|
|
57
|
+
* screen and is discarded when the card navigates. That is what lets two slots
|
|
58
|
+
* share per-resource state without the plugin writing any cleanup.
|
|
59
|
+
*/
|
|
60
|
+
export interface ResourceDetailWrappers {
|
|
61
|
+
resourceDetailProvider: {
|
|
62
|
+
resource: Resource
|
|
63
|
+
parent?: Resource
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const mkSlot = slotFactory<ResourceDetailSlots>()
|
|
68
|
+
const mkWrapper = wrapperFactory<ResourceDetailWrappers>()
|
|
69
|
+
|
|
70
|
+
export const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')
|
|
71
|
+
export const ResourceSubResourceRowActions = mkSlot(
|
|
72
|
+
'resourceSubResourceRowActions',
|
|
73
|
+
)
|
|
74
|
+
export const ResourceDetailProvider = mkWrapper('resourceDetailProvider')
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
// Relative, not `~`: this file is on the published entry's graph and therefore
|
|
4
|
+
// in every consumer's program, and `~` is the CORE's alias — a consumer only
|
|
5
|
+
// resolves it if it has mapped it, which the example app has not.
|
|
6
|
+
import { useOptionalUser } from '../auth/AuthContext'
|
|
7
|
+
import type {
|
|
8
|
+
ResourceDetailSlots,
|
|
9
|
+
ResourceDetailWrappers,
|
|
10
|
+
} from './features/resourceDetail'
|
|
11
|
+
import type { AssertDisjoint, PluginUser } from './types'
|
|
12
|
+
|
|
13
|
+
import { useExtensions } from './ExtensionsContext'
|
|
14
|
+
|
|
15
|
+
export { ExtensionsContext, useExtensions } from './ExtensionsContext'
|
|
16
|
+
export { slotFactory, wrapperFactory } from './factories'
|
|
17
|
+
export { makeSlot, makeWrapper } from './makeSlot'
|
|
18
|
+
export type { PluginUser, PluginRegistration } from './types'
|
|
19
|
+
export type { AssertDisjoint } from './types'
|
|
20
|
+
export {
|
|
21
|
+
ResourceDetailAccessActions,
|
|
22
|
+
ResourceSubResourceRowActions,
|
|
23
|
+
ResourceDetailProvider,
|
|
24
|
+
} from './features/resourceDetail'
|
|
25
|
+
export type {
|
|
26
|
+
ResourceDetailSlots,
|
|
27
|
+
ResourceDetailWrappers,
|
|
28
|
+
} from './features/resourceDetail'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every leaf slot in the core, composed from the per-feature specs.
|
|
32
|
+
*
|
|
33
|
+
* Adding a feature means a new `features/<name>.ts` plus one member here and one
|
|
34
|
+
* line in the guard below. No existing feature file is touched.
|
|
35
|
+
*/
|
|
36
|
+
export type SlotSpec = ResourceDetailSlots
|
|
37
|
+
|
|
38
|
+
/** Every wrapper slot in the core. */
|
|
39
|
+
export type WrapperSpec = ResourceDetailWrappers
|
|
40
|
+
|
|
41
|
+
// Guard: with a second feature this becomes
|
|
42
|
+
// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the
|
|
43
|
+
// duplicated id spelled out in the error. With one feature it is trivially true,
|
|
44
|
+
// and is kept so the next feature inherits the check rather than inventing it.
|
|
45
|
+
const _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true
|
|
46
|
+
void _slotIdsAreDisjoint
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A plugin's leaf-slot handlers. Each receives exactly its slot's payload.
|
|
50
|
+
*
|
|
51
|
+
* Registering an unknown slot, or reading a field the slot does not pass, is a
|
|
52
|
+
* compile error — which is the whole point of composing `SlotSpec` rather than
|
|
53
|
+
* typing handlers as `(props: unknown)`.
|
|
54
|
+
*/
|
|
55
|
+
export type PluginSlots = {
|
|
56
|
+
[N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A plugin's wrapper handlers. `children` is injected by the core. */
|
|
60
|
+
export type PluginWrappers = {
|
|
61
|
+
[N in keyof WrapperSpec]?: (
|
|
62
|
+
props: WrapperSpec[N] & { children: ReactNode },
|
|
63
|
+
) => ReactNode
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One deployment-specific feature's contribution to the core's UI.
|
|
68
|
+
*
|
|
69
|
+
* Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map
|
|
70
|
+
* holds one value per key, so a second plugin at the same slot would silently
|
|
71
|
+
* replace the first and two wrappers around one subtree would be impossible.
|
|
72
|
+
*
|
|
73
|
+
* The array must be a module-level constant. The core iterates it in render
|
|
74
|
+
* order, so rebuilding it per render would change hook call order inside the
|
|
75
|
+
* plugins and violate the rules of hooks.
|
|
76
|
+
*/
|
|
77
|
+
export interface AcPlugin {
|
|
78
|
+
/** Identity, used in error reports and test assertions. */
|
|
79
|
+
name: string
|
|
80
|
+
slots?: PluginSlots
|
|
81
|
+
wrappers?: PluginWrappers
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether any registered plugin fills this slot.
|
|
86
|
+
*
|
|
87
|
+
* For the cases where rendering the slot is not enough and the core has to
|
|
88
|
+
* change its own layout around it — a table column, say, which would otherwise
|
|
89
|
+
* be an empty column with a header and no cells in every build that registers
|
|
90
|
+
* nothing. Rendering an empty slot costs nothing; reserving space for one is
|
|
91
|
+
* what this is for.
|
|
92
|
+
*
|
|
93
|
+
* Use it ONLY for layout. A slot must never be the only route to something the
|
|
94
|
+
* core itself needs, so branching behaviour on a plugin's presence is a design
|
|
95
|
+
* smell even where branching layout is not.
|
|
96
|
+
*/
|
|
97
|
+
export function useSlotFilled(slot: keyof SlotSpec): boolean {
|
|
98
|
+
return useExtensions().some((plugin) => Boolean(plugin.slots?.[slot]))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The signed-in user, reduced to what slots are given.
|
|
103
|
+
*
|
|
104
|
+
* One place, so two slots cannot disagree about the shape — and deliberately a
|
|
105
|
+
* projection rather than the real user object: the core's `User` is free to grow
|
|
106
|
+
* fields, and handing a plugin the whole thing would make every one of them part
|
|
107
|
+
* of the published slot contract.
|
|
108
|
+
*
|
|
109
|
+
* Returns `null` with no `AuthProvider` above it. The detail card renders inside
|
|
110
|
+
* the catalog panel, which some suites mount outside the provider, and throwing
|
|
111
|
+
* there would make a plugin's presence break unrelated tests.
|
|
112
|
+
*/
|
|
113
|
+
export function usePluginUser(): PluginUser | null {
|
|
114
|
+
const user = useOptionalUser()
|
|
115
|
+
return useMemo(
|
|
116
|
+
// `?? false`, because admin is a capability: absent must read as "not an
|
|
117
|
+
// admin", never as undefined leaking into a plugin's own check.
|
|
118
|
+
() => (user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null),
|
|
119
|
+
[user],
|
|
120
|
+
)
|
|
121
|
+
}
|