Skip to content

Commit 7e2010f

Browse files
committed
add new drawer mount point
1 parent b04d365 commit 7e2010f

File tree

7 files changed

+391
-6
lines changed

7 files changed

+391
-6
lines changed

docs/dynamic-plugins/frontend-plugin-wiring.md

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,6 @@ plugins:
168168
Up to 3 levels of nested menu items are supported.
169169
170170
- <menu_item_name> - A unique name in the main sidebar navigation. This can represent either a standalone menu item or a parent menu item. If it represents a plugin menu item, the name must match the corresponding path in `dynamicRoutes`. For example, if `dynamicRoutes` defines `path: /my-plugin`, the `menu_item_name` must be `my-plugin`.
171-
172171
- Handling Complex Paths:
173172
- For simple paths like `path: /my-plugin`, the `menu_item_name` should be `my-plugin`.
174173
- For more complex paths, such as multi-segment paths like `path: /metrics/users/info`, the `menu_item_name` should represent the full path in dot notation (e.g., `metrics.users.info`).
@@ -334,13 +333,11 @@ Each mount point supports additional configuration:
334333
- `if` - Used only in `*/cards` type which renders visible content. This is passed to `<EntitySwitch.Case if={<here>}`.
335334

336335
The following conditions are available:
337-
338336
- `allOf`: All conditions must be met
339337
- `anyOf`: At least one condition must be met
340338
- `oneOf`: Only one condition must be met
341339

342340
Conditions can be:
343-
344341
- `isKind`: Accepts a string or a list of string with entity kinds. For example `isKind: component` will render the component only for entity of `kind: Component`.
345342
- `isType`: Accepts a string or a list of string with entity types. For example `isType: service` will render the component only for entities of `spec.type: 'service'`.
346343
- `hasAnnotation`: Accepts a string or a list of string with annotation keys. For example `hasAnnotation: my-annotation` will render the component only for entities that have `metadata.annotations['my-annotation']` defined.
@@ -439,6 +436,113 @@ dynamicPlugins:
439436
440437
Users can configure multiple application providers by adding entries to the `mountPoints` field.
441438

439+
### Adding application drawers
440+
441+
The application drawer system allows plugins to create persistent side drawers that can be opened and closed independently. Multiple drawer plugins can coexist, with the system automatically managing which drawer is displayed based on priority and open state.
442+
443+
#### Architecture Overview
444+
445+
The drawer system uses three key mount points:
446+
447+
1. **`application/provider`**: Wraps the application with the plugin's context provider that manages drawer state
448+
2. **`application/drawer-state`**: Exposes the drawer state (open/closed, width) to RHDH without creating dependencies
449+
3. **`application/drawer-content`**: Provides the actual content to render inside the drawer
450+
451+
#### Configuration Example
452+
453+
Below is a complete example showing how to configure a drawer plugin:
454+
455+
```yaml
456+
# app-config.yaml
457+
dynamicPlugins:
458+
frontend:
459+
<package_name>: # plugin package name
460+
mountPoints:
461+
# 1. Provider: Manages the drawer's internal state
462+
- mountPoint: application/provider
463+
importName: MyDrawerProvider
464+
465+
# 2. State Exposer: Shares drawer state with RHDH
466+
- mountPoint: application/drawer-state
467+
importName: MyDrawerStateExposer
468+
469+
# 3. Content: Defines what renders inside the drawer
470+
- mountPoint: application/drawer-content
471+
importName: MyDrawerContent
472+
config:
473+
id: my-drawer # Unique identifier matching the context id
474+
priority: 100 # Higher priority renders first (optional, default: 0)
475+
resizable: true # Enable resize handle (optional, default: false)
476+
```
477+
478+
#### Mount Point Details
479+
480+
##### `application/provider`
481+
482+
The provider component wraps the application and manages the drawer's full internal state (open/closed, width, toggle methods, etc.). This is a standard React context provider.
483+
484+
##### `application/drawer-state`
485+
486+
The state exposer component reads from the plugin's context and exposes only the minimal state needed by RHDH to render the drawer. This uses a callback pattern to avoid shared dependencies between plugins and RHDH.
487+
488+
**Key Points:**
489+
490+
- Component receives `onStateChange` callback from RHDH
491+
- Only exposes 4 properties: `id`, `isDrawerOpen`, `drawerWidth`, `setDrawerWidth`
492+
- Does not expose other methods like `toggleDrawer`, `openDrawer`, etc.
493+
- Returns `null` (doesn't render anything, only acts a bridge)
494+
495+
##### `application/drawer-content`
496+
497+
The content component defines what renders inside the drawer.
498+
499+
**Configuration:**
500+
501+
- `id` (required): Unique identifier that must match the `id` in the provider's context
502+
- `priority` (optional): Number determining render order when multiple drawers are open (higher = rendered first)
503+
- `resizable` (optional): Boolean enabling a resize handle on the drawer
504+
505+
#### Priority System
506+
507+
When multiple drawers are open simultaneously, RHDH renders only the highest priority drawer:
508+
509+
```yaml
510+
# Lightspeed drawer (priority: 100) takes precedence when both are open
511+
red-hat-developer-hub.backstage-plugin-lightspeed:
512+
mountPoints:
513+
- mountPoint: application/drawer-content
514+
importName: LightspeedChatContainer
515+
config:
516+
id: lightspeed
517+
priority: 100 # Highest priority
518+
519+
red-hat-developer-hub.backstage-plugin-quickstart:
520+
mountPoints:
521+
- mountPoint: application/drawer-content
522+
importName: QuickstartDrawerContent
523+
config:
524+
id: quickstart
525+
priority: 0 # Lower priority
526+
```
527+
528+
#### Resizable Drawers
529+
530+
Enable user-resizable drawers with the `resizable` configuration:
531+
532+
```yaml
533+
- mountPoint: application/drawer-content
534+
importName: MyDrawerContent
535+
config:
536+
id: my-drawer
537+
resizable: true # Adds a drag handle on the left edge
538+
```
539+
540+
When `resizable: true`, users can:
541+
542+
- Drag the left edge of the drawer to resize
543+
- Changes are managed by the plugin's `setDrawerWidth` function
544+
- Typically constrained to min/max width limits defined by the plugin
545+
442546
## Customizing and Adding Entity tabs
443547

444548
Out of the box the frontend system provides an opinionated set of tabs for catalog entity views. This set of tabs can be further customized and extended as needed via the `entityTabs` configuration:

packages/app/config.d.ts

100644100755
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ export interface Config {
217217
| string
218218
)[];
219219
};
220+
id?: string;
221+
priority?: number;
220222
};
221223
}[];
222224
appIcons?: {

packages/app/src/components/AppBase/AppBase.tsx

100644100755
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { entityPage } from '../catalog/EntityPage';
2727
import { CustomCatalogFilters } from '../catalog/filters/CustomCatalogFilters';
2828
import { LearningPaths } from '../learningPaths/LearningPathsPage';
2929
import { Root } from '../Root';
30+
import { ApplicationDrawer } from '../Root/ApplicationDrawer';
3031
import { ApplicationListener } from '../Root/ApplicationListener';
3132
import { ApplicationProvider } from '../Root/ApplicationProvider';
3233
import ConfigUpdater from '../Root/ConfigUpdater';
@@ -150,6 +151,7 @@ const AppBase = () => {
150151
)}
151152
</FlatRoutes>
152153
</Root>
154+
<ApplicationDrawer />
153155
</ApplicationProvider>
154156
</AppRouter>
155157
</AppProvider>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {
2+
useCallback,
3+
useContext,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
10+
import DynamicRootContext from '@red-hat-developer-hub/plugin-utils';
11+
12+
import { ResizableDrawer } from './ResizableDrawer';
13+
14+
type DrawerState = {
15+
id: string;
16+
isDrawerOpen: boolean;
17+
drawerWidth: number;
18+
setDrawerWidth: (width: number) => void;
19+
};
20+
21+
type DrawerStateExposer = {
22+
Component: React.ComponentType<{
23+
onStateChange: (state: DrawerState) => void;
24+
}>;
25+
};
26+
27+
type DrawerContent = {
28+
Component: React.ComponentType;
29+
config?: { id: string; priority?: number; props?: { resizable?: boolean } };
30+
};
31+
32+
export const ApplicationDrawer = () => {
33+
const { mountPoints } = useContext(DynamicRootContext);
34+
35+
// Get drawer content and its configurations
36+
const drawerContents = useMemo(
37+
() => (mountPoints['application/drawer-content'] ?? []) as DrawerContent[],
38+
[mountPoints],
39+
);
40+
41+
// Get drawer states from all state exposers
42+
const drawerStateExposers = useMemo(
43+
() =>
44+
(mountPoints['application/drawer-state'] ?? []) as DrawerStateExposer[],
45+
[mountPoints],
46+
);
47+
48+
// Store drawer states from all plugins
49+
const drawerStatesRef = useRef<Map<string, DrawerState>>(new Map());
50+
const [, forceUpdate] = useState({});
51+
52+
// Callback that plugins will call when state changes
53+
const handleDrawerStateChange = useCallback((state: DrawerState) => {
54+
const prev = drawerStatesRef.current.get(state.id);
55+
const hasChanged =
56+
!prev ||
57+
prev.isDrawerOpen !== state.isDrawerOpen ||
58+
prev.drawerWidth !== state.drawerWidth ||
59+
prev.setDrawerWidth !== state.setDrawerWidth;
60+
61+
drawerStatesRef.current.set(state.id, state);
62+
63+
if (hasChanged) {
64+
forceUpdate({});
65+
}
66+
}, []);
67+
68+
// Get all drawer states
69+
const drawerStates = Array.from(drawerStatesRef.current.values());
70+
71+
// Find the highest priority open drawer
72+
const activeDrawer = drawerStates
73+
.filter(state => state.isDrawerOpen)
74+
.map(state => {
75+
const content = drawerContents.find(c => c.config?.id === state.id);
76+
if (!content) return null;
77+
78+
return {
79+
state,
80+
Component: content.Component,
81+
config: content.config,
82+
};
83+
})
84+
.filter(Boolean)
85+
.sort((a, b) => (b?.config?.priority ?? 0) - (a?.config?.priority ?? 0))[0];
86+
87+
// Manage CSS classes and variables for layout adjustments
88+
useEffect(() => {
89+
if (activeDrawer) {
90+
const className = 'docked-drawer-open';
91+
const cssVar = '--docked-drawer-width';
92+
93+
document.body.classList.add(className);
94+
document.body.style.setProperty(
95+
cssVar,
96+
`${activeDrawer.state.drawerWidth}px`,
97+
);
98+
99+
return () => {
100+
document.body.classList.remove(className);
101+
document.body.style.removeProperty(cssVar);
102+
};
103+
}
104+
return undefined;
105+
// eslint-disable-next-line react-hooks/exhaustive-deps
106+
}, [activeDrawer?.state?.id, activeDrawer?.state?.drawerWidth]);
107+
108+
if (drawerContents.length === 0) {
109+
return null;
110+
}
111+
112+
return (
113+
<>
114+
{/* Render the state exposers, they will call handleStateChange */}
115+
{drawerStateExposers.map(({ Component }, index) => (
116+
<Component
117+
key={`drawer-state-${index}`}
118+
onStateChange={handleDrawerStateChange}
119+
/>
120+
))}
121+
122+
123+
{activeDrawer && (
124+
<ResizableDrawer
125+
isDrawerOpen={activeDrawer.state.isDrawerOpen}
126+
isResizable={activeDrawer.config?.props?.resizable ?? false}
127+
drawerWidth={activeDrawer.state.drawerWidth}
128+
onWidthChange={activeDrawer.state.setDrawerWidth}
129+
>
130+
<activeDrawer.Component />
131+
</ResizableDrawer>
132+
)}
133+
</>
134+
);
135+
};

0 commit comments

Comments
 (0)