@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Suspense } from 'react'
|
|
2
|
+
import { ErrorBoundary } from 'react-error-boundary'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { useExtensions } from './ExtensionsContext'
|
|
5
|
+
import type { OpaqueRenderer } from './types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reported when a plugin's render throws.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately not swallowed. With a `null` fallback and no report, a crashing
|
|
11
|
+
* plugin is indistinguishable from an unregistered one — the button is simply
|
|
12
|
+
* absent, in production and in tests alike. Routing it through `console.error`
|
|
13
|
+
* is what makes `getGlobalError()` in the test kit able to assert it.
|
|
14
|
+
*/
|
|
15
|
+
function reportSlotError(plugin: string, slot: string, error: unknown): void {
|
|
16
|
+
console.error(`[plugin:${plugin}] slot "${slot}" failed to render`, error)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const call = (fn: OpaqueRenderer, props: unknown): ReactNode =>
|
|
20
|
+
(fn as (p: unknown) => ReactNode)(props)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Invokes a plugin's handler from inside its own component.
|
|
24
|
+
*
|
|
25
|
+
* Not an inline `{call(render, props)}`: that expression is evaluated while the
|
|
26
|
+
* surrounding `Slot` renders, which is BEFORE React mounts the ErrorBoundary
|
|
27
|
+
* around it. A boundary only catches errors thrown by its descendants, so an
|
|
28
|
+
* inline call escaped the boundary entirely and took the host down with it.
|
|
29
|
+
* Putting the invocation in a child component is what moves the throw inside.
|
|
30
|
+
*/
|
|
31
|
+
function Contribution({
|
|
32
|
+
render,
|
|
33
|
+
props,
|
|
34
|
+
}: {
|
|
35
|
+
render: OpaqueRenderer
|
|
36
|
+
props: unknown
|
|
37
|
+
}): ReactNode {
|
|
38
|
+
return call(render, props)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Builds the component the core renders at one leaf slot.
|
|
43
|
+
*
|
|
44
|
+
* Every registered plugin contributing to this slot renders, in array order, so
|
|
45
|
+
* two plugins can occupy the same point. Each is isolated: one plugin throwing
|
|
46
|
+
* removes only its own node.
|
|
47
|
+
*
|
|
48
|
+
* Each contribution also gets its own `Suspense`. Without it a plugin that
|
|
49
|
+
* suspends — any `useSuspenseQuery`, any lazy import — bubbles to the nearest
|
|
50
|
+
* boundary above, which for the detail card is `AppCatalogPage`'s
|
|
51
|
+
* `<Suspense fallback={null}>`; the whole card would then vanish until the
|
|
52
|
+
* plugin's data arrived.
|
|
53
|
+
*/
|
|
54
|
+
export function makeSlot<TProps>(slotName: string) {
|
|
55
|
+
function Slot(props: TProps): ReactNode {
|
|
56
|
+
const plugins = useExtensions()
|
|
57
|
+
|
|
58
|
+
return plugins.map((plugin) => {
|
|
59
|
+
const render = plugin.slots?.[slotName]
|
|
60
|
+
if (!render) return null
|
|
61
|
+
return (
|
|
62
|
+
<ErrorBoundary
|
|
63
|
+
key={plugin.name}
|
|
64
|
+
fallbackRender={() => null}
|
|
65
|
+
onError={(error) => reportSlotError(plugin.name, slotName, error)}
|
|
66
|
+
>
|
|
67
|
+
<Suspense fallback={null}>
|
|
68
|
+
<Contribution render={render} props={props} />
|
|
69
|
+
</Suspense>
|
|
70
|
+
</ErrorBoundary>
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
Slot.displayName = `Slot(${slotName})`
|
|
75
|
+
return Slot
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builds the component the core renders *around* a subtree.
|
|
80
|
+
*
|
|
81
|
+
* Folded right-to-left so the FIRST plugin in the array ends up outermost, which
|
|
82
|
+
* makes nesting order readable from the array itself.
|
|
83
|
+
*
|
|
84
|
+
* Note both fallbacks differ from a leaf slot's: they are the accumulated
|
|
85
|
+
* children, never `null`. A wrapper only decorates — usually by providing
|
|
86
|
+
* context — so neither a broken one nor a suspending one may blank the core
|
|
87
|
+
* subtree it was wrapping.
|
|
88
|
+
*
|
|
89
|
+
* The `Suspense` matters as much as the boundary. Without it a wrapper that
|
|
90
|
+
* suspends bubbles to the nearest boundary above, which for the detail card is
|
|
91
|
+
* `AppCatalogPage`'s `<Suspense fallback={null}>` — so one plugin fetching
|
|
92
|
+
* something would make the entire card disappear until it resolved. Falling
|
|
93
|
+
* back to `acc` instead renders the core subtree immediately, just without the
|
|
94
|
+
* decoration, and it arrives when the plugin is ready.
|
|
95
|
+
*
|
|
96
|
+
* A consequence worth stating: while suspended, descendants see no context from
|
|
97
|
+
* this wrapper. A wrapper whose children cannot cope with that should not
|
|
98
|
+
* suspend — it should render children and fetch inside its own subtree.
|
|
99
|
+
*/
|
|
100
|
+
export function makeWrapper<TProps>(wrapperName: string) {
|
|
101
|
+
function Wrapper(props: TProps & { children: ReactNode }): ReactNode {
|
|
102
|
+
const plugins = useExtensions()
|
|
103
|
+
const { children, ...rest } = props
|
|
104
|
+
|
|
105
|
+
return plugins.reduceRight<ReactNode>((acc, plugin) => {
|
|
106
|
+
const render = plugin.wrappers?.[wrapperName]
|
|
107
|
+
if (!render) return acc
|
|
108
|
+
return (
|
|
109
|
+
<ErrorBoundary
|
|
110
|
+
key={plugin.name}
|
|
111
|
+
fallbackRender={() => acc}
|
|
112
|
+
onError={(error) => reportSlotError(plugin.name, wrapperName, error)}
|
|
113
|
+
>
|
|
114
|
+
<Suspense fallback={acc}>
|
|
115
|
+
<Contribution render={render} props={{ ...rest, children: acc }} />
|
|
116
|
+
</Suspense>
|
|
117
|
+
</ErrorBoundary>
|
|
118
|
+
)
|
|
119
|
+
}, children)
|
|
120
|
+
}
|
|
121
|
+
Wrapper.displayName = `SlotWrapper(${wrapperName})`
|
|
122
|
+
return Wrapper
|
|
123
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The signed-in visitor as a plugin sees them.
|
|
5
|
+
*
|
|
6
|
+
* Structural on purpose. Core's own `User` (see `modules/auth/AuthContext.tsx`)
|
|
7
|
+
* is not exported, and a plugin has no business depending on core's auth
|
|
8
|
+
* internals: it needs an identity and an admin flag, nothing more. Widening this
|
|
9
|
+
* is a deliberate API decision, not a convenience.
|
|
10
|
+
*/
|
|
11
|
+
export interface PluginUser {
|
|
12
|
+
email?: string | null
|
|
13
|
+
isAdmin: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A renderer whose props are deliberately unknowable.
|
|
18
|
+
*
|
|
19
|
+
* This exists only to break a type cycle: the context has to hold plugins, a
|
|
20
|
+
* plugin's slot map is keyed by the composed `SlotSpec`, and `SlotSpec` is
|
|
21
|
+
* composed from the feature files that call `slotFactory` — which would need the
|
|
22
|
+
* context back. `never` in parameter position accepts every function regardless
|
|
23
|
+
* of what it takes (parameters are contravariant under `strictFunctionTypes`), so
|
|
24
|
+
* the registration stays assignable while the PUBLIC types in `index.ts` remain
|
|
25
|
+
* exact. `makeSlot` re-narrows before calling.
|
|
26
|
+
*/
|
|
27
|
+
export type OpaqueRenderer = (props: never) => ReactNode
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the context stores. Consumers never build this by hand — they build an
|
|
31
|
+
* `AcPlugin` (see `index.ts`), which is assignable to it.
|
|
32
|
+
*/
|
|
33
|
+
export interface PluginRegistration {
|
|
34
|
+
name: string
|
|
35
|
+
slots?: Record<string, OpaqueRenderer | undefined>
|
|
36
|
+
wrappers?: Record<string, OpaqueRenderer | undefined>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compile error naming any slot id two features both claim.
|
|
41
|
+
*
|
|
42
|
+
* `SlotSpec` is an intersection of the per-feature specs, and an intersection
|
|
43
|
+
* accepts anything: two features declaring one id would silently intersect their
|
|
44
|
+
* payloads into `A & B` — a slot nothing can satisfy. `extends` would error, but
|
|
45
|
+
* only with a generic "incompatibly declared" message. This puts the offending
|
|
46
|
+
* id in the error text.
|
|
47
|
+
*/
|
|
48
|
+
export type AssertDisjoint<TLeft, TRight> =
|
|
49
|
+
Extract<keyof TLeft, keyof TRight> extends never
|
|
50
|
+
? true
|
|
51
|
+
: { DUPLICATE_SLOT_ID: Extract<keyof TLeft, keyof TRight> }
|
|
@@ -3,6 +3,7 @@ import useEmblaCarousel from 'embla-carousel-react'
|
|
|
3
3
|
import { ChevronLeft, ChevronRight, ImageOff, X } from 'lucide-react'
|
|
4
4
|
import { clamp } from 'radashi'
|
|
5
5
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
6
|
+
import { useDismissable } from '~/modules/dismiss/DismissStack'
|
|
6
7
|
|
|
7
8
|
import { Button } from '~/ui/button'
|
|
8
9
|
import { Spinner } from '~/ui/spinner'
|
|
@@ -129,23 +130,17 @@ export function Gallery({
|
|
|
129
130
|
},
|
|
130
131
|
[scrollNext, images.length],
|
|
131
132
|
)
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
document.addEventListener('keydown', handleEscape, true)
|
|
145
|
-
return () => {
|
|
146
|
-
document.removeEventListener('keydown', handleEscape, true)
|
|
147
|
-
}
|
|
148
|
-
}, [isFullscreen, setFullscreen])
|
|
133
|
+
// Escape leaves fullscreen rather than closing the gallery.
|
|
134
|
+
//
|
|
135
|
+
// This used to be a capture-phase document listener whose whole purpose was
|
|
136
|
+
// to beat Radix to the event, with the dialog above separately cancelling
|
|
137
|
+
// Radix's close by reading this component's fullscreen state through a ref.
|
|
138
|
+
// The stack makes that unnecessary: the fullscreen view is rendered after
|
|
139
|
+
// the dialog that contains it, so it is simply the top layer while it is on.
|
|
140
|
+
const fullscreenRef = useRef<HTMLDivElement>(null)
|
|
141
|
+
useDismissable(fullscreenRef, () => setFullscreen(false), {
|
|
142
|
+
enabled: isFullscreen,
|
|
143
|
+
})
|
|
149
144
|
|
|
150
145
|
// Mouse wheel navigation with debouncing
|
|
151
146
|
useEffect(() => {
|
|
@@ -209,6 +204,7 @@ export function Gallery({
|
|
|
209
204
|
const imageState = imageStates[currentIndex]
|
|
210
205
|
return (
|
|
211
206
|
<div
|
|
207
|
+
ref={fullscreenRef}
|
|
212
208
|
className="fixed inset-0 z-50 bg-background"
|
|
213
209
|
role="dialog"
|
|
214
210
|
aria-label="Fullscreen view"
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
import { Route as rootRouteImport } from './routes/__root'
|
|
12
12
|
import { Route as LayoutRouteImport } from './routes/_layout'
|
|
13
13
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
|
14
|
-
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
|
15
|
-
import { Route as LayoutServiceDesksRouteImport } from './routes/_layout/service-desks'
|
|
16
|
-
import { Route as LayoutMcpRouteImport } from './routes/_layout/mcp'
|
|
17
14
|
import { Route as LayoutLoginRouteImport } from './routes/_layout/login'
|
|
15
|
+
import { Route as LayoutMcpRouteImport } from './routes/_layout/mcp'
|
|
16
|
+
import { Route as LayoutServiceDesksRouteImport } from './routes/_layout/service-desks'
|
|
17
|
+
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
|
18
18
|
import { Route as LayoutAppSlugRouteImport } from './routes/_layout/app.$slug'
|
|
19
19
|
import { Route as LayoutCatalogAppsIndexRouteImport } from './routes/_layout/catalog.apps.index'
|
|
20
20
|
import { Route as LayoutAppSlugSubSubSlugRouteImport } from './routes/_layout/app.$slug_.sub.$subSlug'
|
|
@@ -28,14 +28,9 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
|
|
28
28
|
path: '/',
|
|
29
29
|
getParentRoute: () => LayoutRoute,
|
|
30
30
|
} as any)
|
|
31
|
-
const
|
|
32
|
-
id: '/
|
|
33
|
-
path: '/
|
|
34
|
-
getParentRoute: () => rootRouteImport,
|
|
35
|
-
} as any)
|
|
36
|
-
const LayoutServiceDesksRoute = LayoutServiceDesksRouteImport.update({
|
|
37
|
-
id: '/service-desks',
|
|
38
|
-
path: '/service-desks',
|
|
31
|
+
const LayoutLoginRoute = LayoutLoginRouteImport.update({
|
|
32
|
+
id: '/login',
|
|
33
|
+
path: '/login',
|
|
39
34
|
getParentRoute: () => LayoutRoute,
|
|
40
35
|
} as any)
|
|
41
36
|
const LayoutMcpRoute = LayoutMcpRouteImport.update({
|
|
@@ -43,11 +38,16 @@ const LayoutMcpRoute = LayoutMcpRouteImport.update({
|
|
|
43
38
|
path: '/mcp',
|
|
44
39
|
getParentRoute: () => LayoutRoute,
|
|
45
40
|
} as any)
|
|
46
|
-
const
|
|
47
|
-
id: '/
|
|
48
|
-
path: '/
|
|
41
|
+
const LayoutServiceDesksRoute = LayoutServiceDesksRouteImport.update({
|
|
42
|
+
id: '/service-desks',
|
|
43
|
+
path: '/service-desks',
|
|
49
44
|
getParentRoute: () => LayoutRoute,
|
|
50
45
|
} as any)
|
|
46
|
+
const AuthCallbackRoute = AuthCallbackRouteImport.update({
|
|
47
|
+
id: '/auth/callback',
|
|
48
|
+
path: '/auth/callback',
|
|
49
|
+
getParentRoute: () => rootRouteImport,
|
|
50
|
+
} as any)
|
|
51
51
|
const LayoutAppSlugRoute = LayoutAppSlugRouteImport.update({
|
|
52
52
|
id: '/app/$slug',
|
|
53
53
|
path: '/app/$slug',
|
|
@@ -151,18 +151,11 @@ declare module '@tanstack/react-router' {
|
|
|
151
151
|
preLoaderRoute: typeof LayoutIndexRouteImport
|
|
152
152
|
parentRoute: typeof LayoutRoute
|
|
153
153
|
}
|
|
154
|
-
'/
|
|
155
|
-
id: '/
|
|
156
|
-
path: '/
|
|
157
|
-
fullPath: '/
|
|
158
|
-
preLoaderRoute: typeof
|
|
159
|
-
parentRoute: typeof rootRouteImport
|
|
160
|
-
}
|
|
161
|
-
'/_layout/service-desks': {
|
|
162
|
-
id: '/_layout/service-desks'
|
|
163
|
-
path: '/service-desks'
|
|
164
|
-
fullPath: '/service-desks'
|
|
165
|
-
preLoaderRoute: typeof LayoutServiceDesksRouteImport
|
|
154
|
+
'/_layout/login': {
|
|
155
|
+
id: '/_layout/login'
|
|
156
|
+
path: '/login'
|
|
157
|
+
fullPath: '/login'
|
|
158
|
+
preLoaderRoute: typeof LayoutLoginRouteImport
|
|
166
159
|
parentRoute: typeof LayoutRoute
|
|
167
160
|
}
|
|
168
161
|
'/_layout/mcp': {
|
|
@@ -172,13 +165,20 @@ declare module '@tanstack/react-router' {
|
|
|
172
165
|
preLoaderRoute: typeof LayoutMcpRouteImport
|
|
173
166
|
parentRoute: typeof LayoutRoute
|
|
174
167
|
}
|
|
175
|
-
'/_layout/
|
|
176
|
-
id: '/_layout/
|
|
177
|
-
path: '/
|
|
178
|
-
fullPath: '/
|
|
179
|
-
preLoaderRoute: typeof
|
|
168
|
+
'/_layout/service-desks': {
|
|
169
|
+
id: '/_layout/service-desks'
|
|
170
|
+
path: '/service-desks'
|
|
171
|
+
fullPath: '/service-desks'
|
|
172
|
+
preLoaderRoute: typeof LayoutServiceDesksRouteImport
|
|
180
173
|
parentRoute: typeof LayoutRoute
|
|
181
174
|
}
|
|
175
|
+
'/auth/callback': {
|
|
176
|
+
id: '/auth/callback'
|
|
177
|
+
path: '/auth/callback'
|
|
178
|
+
fullPath: '/auth/callback'
|
|
179
|
+
preLoaderRoute: typeof AuthCallbackRouteImport
|
|
180
|
+
parentRoute: typeof rootRouteImport
|
|
181
|
+
}
|
|
182
182
|
'/_layout/app/$slug': {
|
|
183
183
|
id: '/_layout/app/$slug'
|
|
184
184
|
path: '/app/$slug'
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core's own router, on a subpath of its own — a deliberate quarantine.
|
|
3
|
+
*
|
|
4
|
+
* `createAcRouter` carries `declare module '@tanstack/react-router'` for both
|
|
5
|
+
* `Register` and (via `routeTree.gen.ts`) `FileRoutesByPath`. TypeScript
|
|
6
|
+
* collects module augmentations when it builds the PROGRAM, and `import type`
|
|
7
|
+
* still resolves the specifier — so there is no way to reference anything from
|
|
8
|
+
* that file without admitting its augmentations. Even `await import()` does it;
|
|
9
|
+
* type elision is emit-only.
|
|
10
|
+
*
|
|
11
|
+
* Two declarations of `Register.router` in one program is a hard `TS2717`, and
|
|
12
|
+
* two of `FileRoutesByPath` collapses `createFileRoute` to `path?: never`. So a
|
|
13
|
+
* deployment that composes its own route tree — core routes plus its own, and
|
|
14
|
+
* therefore its own augmentation — must never end up with this file in its
|
|
15
|
+
* program. Keeping it off the main entry's import graph is what makes that
|
|
16
|
+
* possible, and this subpath is the only published door to it.
|
|
17
|
+
*
|
|
18
|
+
* Import it if you use the core's route tree unchanged: that is the example app
|
|
19
|
+
* and the core's own test suites, each a single-`Register` program. Do NOT
|
|
20
|
+
* import it from a deployment that declares its own.
|
|
21
|
+
*/
|
|
22
|
+
export { createAcRouter } from './util/createAcRouter'
|
|
23
|
+
export type { AcRouterInitParams } from './types/types'
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -10,6 +10,36 @@ import { Route as AppDetailRoute } from './_layout/app.$slug'
|
|
|
10
10
|
import { Route as SubResourceDetailRoute } from './_layout/app.$slug_.sub.$subSlug'
|
|
11
11
|
import { Route as CatalogIndexRoute } from './_layout/index'
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The opt-in a deployment's own route sets to get the catalog layout:
|
|
15
|
+
*
|
|
16
|
+
* createFileRoute('/_layout/app/$slug_/whatever')({
|
|
17
|
+
* staticData: { acCatalogLayout: true },
|
|
18
|
+
* component: MyRoute,
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* Narrowed rather than asserted: `staticData` is a loose bag that a consumer
|
|
22
|
+
* fills, so an unexpected shape must read as "not opted in" instead of
|
|
23
|
+
* throwing inside a router-state selector.
|
|
24
|
+
*/
|
|
25
|
+
function wantsCatalogLayout(staticData: unknown): boolean {
|
|
26
|
+
return (
|
|
27
|
+
typeof staticData === 'object' &&
|
|
28
|
+
staticData !== null &&
|
|
29
|
+
'acCatalogLayout' in staticData &&
|
|
30
|
+
staticData.acCatalogLayout === true
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasSlug(params: unknown): params is { slug: string } {
|
|
35
|
+
return (
|
|
36
|
+
typeof params === 'object' &&
|
|
37
|
+
params !== null &&
|
|
38
|
+
'slug' in params &&
|
|
39
|
+
typeof params.slug === 'string'
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
13
43
|
export const Route = createFileRoute('/_layout')({
|
|
14
44
|
component: LayoutComponent,
|
|
15
45
|
})
|
|
@@ -35,12 +65,32 @@ function LayoutComponent() {
|
|
|
35
65
|
const params = (detailMatch ?? subMatch)?.params as
|
|
36
66
|
| { slug?: string; subSlug?: string }
|
|
37
67
|
| undefined
|
|
68
|
+
// A deployment composing its own route tree can put a route inside this
|
|
69
|
+
// layout, and it will not be one of the ids above — so without an opt-in
|
|
70
|
+
// it silently renders outside AppCatalogLayout with no providers, and
|
|
71
|
+
// the first hook that needs the catalog throws. `staticData` is the
|
|
72
|
+
// opt-in: the route declares that it wants the catalog layout, and the
|
|
73
|
+
// core needs to know nothing about it.
|
|
74
|
+
const optedIn = s.matches.some(
|
|
75
|
+
(m) => wantsCatalogLayout(m.staticData) === true,
|
|
76
|
+
)
|
|
77
|
+
// The slug from whichever match carries one, so a deployment's own route
|
|
78
|
+
// under /app/$slug/... opens the card it belongs to rather than dropping
|
|
79
|
+
// the user on the catalog index.
|
|
80
|
+
const slugMatch = [...s.matches].reverse().find((m) => hasSlug(m.params))
|
|
81
|
+
// One narrowing is enough: the predicate on `find` already guarantees the
|
|
82
|
+
// match it returned carries a string slug.
|
|
83
|
+
const consumerSlug = hasSlug(slugMatch?.params)
|
|
84
|
+
? slugMatch.params.slug
|
|
85
|
+
: undefined
|
|
86
|
+
|
|
38
87
|
return {
|
|
39
88
|
isCatalogRoute:
|
|
40
89
|
detailMatch !== undefined ||
|
|
41
90
|
subMatch !== undefined ||
|
|
91
|
+
optedIn ||
|
|
42
92
|
s.matches.some((m) => m.routeId === CatalogIndexRoute.id),
|
|
43
|
-
selectedSlug: params?.slug,
|
|
93
|
+
selectedSlug: params?.slug ?? consumerSlug,
|
|
44
94
|
selectedSubSlug: params?.subSlug,
|
|
45
95
|
}
|
|
46
96
|
},
|
|
@@ -97,7 +97,27 @@ export function Treatment({ error, reset }: ErrorComponentProps) {
|
|
|
97
97
|
)
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* What a thrown value can be relied on to have.
|
|
102
|
+
*
|
|
103
|
+
* `ErrorComponentProps.error` is `unknown`, and rightly so — `throw` accepts
|
|
104
|
+
* any value, so a route can be handed a string or a plain object. Narrowing
|
|
105
|
+
* here rather than asserting `Error` means a non-Error throw renders a useful
|
|
106
|
+
* message instead of "Cannot read properties of undefined".
|
|
107
|
+
*/
|
|
108
|
+
function describeThrown(error: unknown): {
|
|
109
|
+
message: string
|
|
110
|
+
stack: string | undefined
|
|
111
|
+
} {
|
|
112
|
+
if (error instanceof Error) {
|
|
113
|
+
return { message: error.message, stack: error.stack }
|
|
114
|
+
}
|
|
115
|
+
return { message: String(error), stack: undefined }
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
export function DefaultErrorComponent({ error, reset }: ErrorComponentProps) {
|
|
119
|
+
const { message, stack } = describeThrown(error)
|
|
120
|
+
|
|
101
121
|
return (
|
|
102
122
|
<BaseErrorPage>
|
|
103
123
|
<Empty role="alert">
|
|
@@ -107,7 +127,7 @@ export function DefaultErrorComponent({ error, reset }: ErrorComponentProps) {
|
|
|
107
127
|
</EmptyMedia>
|
|
108
128
|
<EmptyTitle>Ooops!</EmptyTitle>
|
|
109
129
|
<EmptyDescription>
|
|
110
|
-
Error inside app-catalog occured: {<i>{
|
|
130
|
+
Error inside app-catalog occured: {<i>{message}</i>}
|
|
111
131
|
</EmptyDescription>
|
|
112
132
|
</EmptyHeader>
|
|
113
133
|
<EmptyContent>
|
|
@@ -118,9 +138,7 @@ export function DefaultErrorComponent({ error, reset }: ErrorComponentProps) {
|
|
|
118
138
|
'mt-8 text-center max-w-[90vw] max-h-[80vh] overflow-auto'
|
|
119
139
|
}
|
|
120
140
|
>
|
|
121
|
-
<pre className={'text-left mt-8 text-sm'}>
|
|
122
|
-
{<i>{error.stack}</i>}
|
|
123
|
-
</pre>
|
|
141
|
+
<pre className={'text-left mt-8 text-sm'}>{<i>{stack}</i>}</pre>
|
|
124
142
|
</div>
|
|
125
143
|
</EmptyContent>
|
|
126
144
|
</Empty>
|
package/src/ui/dialog.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
3
|
+
import { useDismissStack } from '~/modules/dismiss/DismissStack'
|
|
3
4
|
import { XIcon } from 'lucide-react'
|
|
4
5
|
|
|
5
6
|
import { cn } from '~/lib/utils'
|
|
@@ -48,15 +49,40 @@ function DialogContent({
|
|
|
48
49
|
className,
|
|
49
50
|
children,
|
|
50
51
|
showCloseButton = true,
|
|
52
|
+
onEscapeKeyDown,
|
|
51
53
|
...props
|
|
52
54
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
53
55
|
showCloseButton?: boolean
|
|
54
56
|
}) {
|
|
57
|
+
const stack = useDismissStack()
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Bridges Radix's Escape handling into the dismiss stack.
|
|
61
|
+
*
|
|
62
|
+
* Radix listens for Escape with `capture: true`, so it runs before the
|
|
63
|
+
* stack's own document listener and before every component handler. Left
|
|
64
|
+
* alone, a Radix dialog therefore closes itself even when something else is
|
|
65
|
+
* on top of it — which is the bug this whole module exists to fix, just
|
|
66
|
+
* from the other direction.
|
|
67
|
+
*
|
|
68
|
+
* So: ask the stack first. If it had a layer to dismiss, that layer was the
|
|
69
|
+
* topmost one and Radix must stand down, which `preventDefault` tells it.
|
|
70
|
+
* If the stack was empty, nothing registered an opinion and Radix closing
|
|
71
|
+
* this dialog is exactly right — which keeps every existing dialog working
|
|
72
|
+
* whether or not anyone has wired it into the stack.
|
|
73
|
+
*/
|
|
74
|
+
const handleEscapeKeyDown = (event: KeyboardEvent) => {
|
|
75
|
+
onEscapeKeyDown?.(event)
|
|
76
|
+
if (event.defaultPrevented) return
|
|
77
|
+
if (stack.dismissTop()) event.preventDefault()
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
return (
|
|
56
81
|
<DialogPortal data-slot="dialog-portal">
|
|
57
82
|
<DialogOverlay />
|
|
58
83
|
<DialogPrimitive.Content
|
|
59
84
|
data-slot="dialog-content"
|
|
85
|
+
onEscapeKeyDown={handleEscapeKeyDown}
|
|
60
86
|
className={cn(
|
|
61
87
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
|
62
88
|
className,
|
package/src/ui/table.tsx
CHANGED
|
@@ -2,15 +2,58 @@ import * as React from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { cn } from '~/lib/utils'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
interface TableProps extends React.ComponentProps<'table'> {
|
|
6
|
+
/**
|
|
7
|
+
* Keep the header visible while the body scrolls.
|
|
8
|
+
*
|
|
9
|
+
* The scroll has to be on the container BELOW, not on an ancestor, and that is
|
|
10
|
+
* the whole difficulty: this container sets `overflow-x: auto`, which per spec
|
|
11
|
+
* makes `overflow-y: visible` compute to `auto` as well. So it is already a
|
|
12
|
+
* scrollport on both axes, and `position: sticky` on the header resolves
|
|
13
|
+
* against it rather than against whatever ancestor has the height limit.
|
|
14
|
+
* Putting the height limit anywhere above leaves the header pinned to an
|
|
15
|
+
* element that never scrolls vertically — it scrolls away exactly as if
|
|
16
|
+
* nothing had been set.
|
|
17
|
+
*
|
|
18
|
+
* Pass the max height as `className` (e.g. `max-h-[400px]`); it lands on this
|
|
19
|
+
* container, which then both clips and scrolls.
|
|
20
|
+
*/
|
|
21
|
+
stickyHeader?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* The scrolling container, for a caller that virtualizes the body: the
|
|
24
|
+
* scrollport is this element, not the `<table>`, so a ref on the table
|
|
25
|
+
* would measure the wrong thing.
|
|
26
|
+
*/
|
|
27
|
+
containerRef?: React.Ref<HTMLDivElement>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Table({
|
|
31
|
+
className,
|
|
32
|
+
stickyHeader,
|
|
33
|
+
containerRef,
|
|
34
|
+
...props
|
|
35
|
+
}: TableProps) {
|
|
6
36
|
return (
|
|
7
37
|
<div
|
|
38
|
+
ref={containerRef}
|
|
8
39
|
data-slot="table-container"
|
|
9
|
-
className=
|
|
40
|
+
className={cn(
|
|
41
|
+
'relative w-full overflow-x-auto',
|
|
42
|
+
stickyHeader && 'overflow-y-auto',
|
|
43
|
+
stickyHeader && className,
|
|
44
|
+
)}
|
|
10
45
|
>
|
|
11
46
|
<table
|
|
12
47
|
data-slot="table"
|
|
13
|
-
className={cn(
|
|
48
|
+
className={cn(
|
|
49
|
+
'w-full caption-bottom text-sm',
|
|
50
|
+
// The header's own bottom border does not render reliably once the
|
|
51
|
+
// row is sticky, so the separating line comes from an inset shadow on
|
|
52
|
+
// the cells instead. Opaque background, or rows show through it.
|
|
53
|
+
stickyHeader &&
|
|
54
|
+
'[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 [&>thead>tr]:border-b-0 [&>thead_th]:bg-background [&>thead_th]:shadow-[inset_0_-1px_0_var(--border)]',
|
|
55
|
+
!stickyHeader && className,
|
|
56
|
+
)}
|
|
14
57
|
{...props}
|
|
15
58
|
/>
|
|
16
59
|
</div>
|