Skip to content

Commit 8ae94c0

Browse files
committed
add new drawer mount point
1 parent 0ea5d72 commit 8ae94c0

File tree

9 files changed

+464
-6
lines changed

9 files changed

+464
-6
lines changed

app-config.dynamic-plugins.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,16 @@ dynamicPlugins:
265265
mountPoints:
266266
- mountPoint: application/provider
267267
importName: QuickstartDrawerProvider
268+
- mountPoint: application/internal/drawer-state
269+
importName: QuickstartDrawerStateExposer
270+
- mountPoint: application/internal/drawer-content
271+
importName: QuickstartDrawerContent
272+
config:
273+
id: quickstart
274+
- mountPoint: global.header/help
275+
importName: QuickstartButton
276+
config:
277+
priority: 100
268278
red-hat-developer-hub.backstage-plugin-dynamic-home-page:
269279
dynamicRoutes:
270280
- path: /

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

Lines changed: 142 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,148 @@ 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 RHDH automatically managing which drawer is displayed. When a user opens a drawer, any previously open drawer is automatically closed, ensuring only one drawer is visible at a time.
442+
443+
> **Note**: The `application/internal/drawer-state` and `application/internal/drawer-content` mount points are for internal use only and are subject to change. These will be updated with the introduction of the new frontend system.
444+
445+
#### Architecture Overview
446+
447+
The drawer system uses three key mount points:
448+
449+
1. **`application/provider`**: Wraps the application with the plugin's context provider that manages drawer state
450+
2. **`application/internal/drawer-state`**: Exposes minimal drawer state (open/closed, width, close function) to RHDH
451+
3. **`application/internal/drawer-content`**: Provides the actual content to render inside the drawer
452+
453+
#### Configuration Example
454+
455+
Below is a complete example showing how to configure a drawer plugin:
456+
457+
```yaml
458+
# app-config.yaml
459+
dynamicPlugins:
460+
frontend:
461+
<package_name>: # plugin package name
462+
mountPoints:
463+
# 1. Provider: Manages the drawer's internal state
464+
- mountPoint: application/provider
465+
importName: MyDrawerProvider
466+
467+
# 2. State Exposer: Shares drawer state with RHDH
468+
- mountPoint: application/internal/drawer-state
469+
importName: MyDrawerStateExposer
470+
471+
# 3. Content: Defines what renders inside the drawer
472+
- mountPoint: application/internal/drawer-content
473+
importName: MyDrawerContent
474+
config:
475+
id: my-drawer # Unique identifier matching the context id
476+
props:
477+
resizable: true # Enable resize handle (optional, default: false)
478+
```
479+
480+
#### Mount Point Details
481+
482+
##### `application/provider`
483+
484+
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.
485+
486+
##### `application/internal/drawer-state`
487+
488+
The state exposer component reads from the plugin's context and exposes only the minimal state needed by RHDH to render and coordinate the drawer. This uses a callback pattern to avoid shared dependencies between plugins and RHDH.
489+
490+
**Key Points:**
491+
492+
- Component receives `onStateChange` callback from RHDH
493+
- Exposes exactly 5 properties:
494+
- `id`: Unique drawer identifier
495+
- `isDrawerOpen`: Current open/closed state
496+
- `drawerWidth`: Current drawer width in pixels
497+
- `setDrawerWidth`: Function to update drawer width
498+
- `closeDrawer`: Function RHDH calls to close this drawer
499+
- Returns `null` (doesn't render anything, only acts as a bridge)
500+
- RHDH detects state transitions (closed→open, open→closed) automatically
501+
- When a drawer opens, RHDH automatically closes other open drawers by calling their `closeDrawer` function
502+
503+
##### `application/internal/drawer-content`
504+
505+
The content component defines what renders inside the drawer.
506+
507+
**Configuration:**
508+
509+
- `id` (required): Unique identifier that must match the `id` in the provider's context
510+
- `props.resizable` (optional): Boolean enabling a resize handle on the drawer (default: `false`)
511+
512+
#### Automatic Drawer Coordination
513+
514+
RHDH automatically manages drawer visibility through state transition detection:
515+
516+
**When a drawer opens:**
517+
518+
1. Plugin's internal state changes (`isDrawerOpen` becomes `true`)
519+
2. State exposer detects the change and calls `onStateChange`
520+
3. RHDH receives the state update and sets this drawer as active
521+
4. RHDH automatically calls `closeDrawer()` on all other open drawers
522+
5. Only the most recently opened drawer remains visible
523+
524+
**Example Scenario:**
525+
526+
```yaml
527+
# Both plugins configured, but only one drawer visible at a time
528+
red-hat-developer-hub.backstage-plugin-quickstart:
529+
mountPoints:
530+
- mountPoint: application/provider
531+
importName: QuickstartDrawerProvider
532+
- mountPoint: application/internal/drawer-state
533+
importName: QuickstartDrawerStateExposer
534+
- mountPoint: application/internal/drawer-content
535+
importName: QuickstartDrawerContent
536+
config:
537+
id: quickstart
538+
- mountPoint: global.header/help
539+
importName: QuickstartButton
540+
config:
541+
priority: 100
542+
543+
544+
545+
red-hat-developer-hub.backstage-plugin-test-drawer:
546+
mountPoints:
547+
- mountPoint: application/provider
548+
importName: TestDrawerProvider
549+
- mountPoint: application/internal/drawer-state
550+
importName: TestDrawerStateExposer
551+
- mountPoint: application/internal/drawer-content
552+
importName: TestDrawerContent
553+
config:
554+
id: test-drawer
555+
- mountPoint: global.header/help
556+
importName: TestButton
557+
558+
# Flow: User opens Quickstart → Quickstart drawer shows
559+
# User opens Test Drawer → Quickstart auto-closes, Test drawer shows
560+
# User opens Quickstart → test drawer auto-closes, Quickstart shows
561+
```
562+
563+
#### Resizable Drawers
564+
565+
Enable user-resizable drawers with the `resizable` configuration:
566+
567+
```yaml
568+
- mountPoint: application/drawer-content
569+
importName: MyDrawerContent
570+
config:
571+
id: my-drawer
572+
resizable: true # Adds a drag handle on the left edge
573+
```
574+
575+
When `resizable: true`, users can:
576+
577+
- Drag the left edge of the drawer to resize
578+
- Changes are managed by the plugin's `setDrawerWidth` function
579+
- Typically constrained to min/max width limits defined by the plugin
580+
442581
## Customizing and Adding Entity tabs
443582

444583
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:

dynamic-plugins.default.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,12 @@ plugins:
578578
mountPoints:
579579
- mountPoint: application/provider
580580
importName: QuickstartDrawerProvider
581+
- mountPoint: application/internal/drawer-state
582+
importName: QuickstartDrawerStateExposer
583+
- mountPoint: application/internal/drawer-content
584+
importName: QuickstartDrawerContent
585+
config:
586+
id: quickstart
581587
- mountPoint: global.header/help
582588
importName: QuickstartButton
583589
config:

packages/app/config.d.ts

100644100755
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export interface Config {
217217
| string
218218
)[];
219219
};
220+
id?: string;
220221
};
221222
}[];
222223
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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
closeDrawer: () => void;
20+
};
21+
22+
type DrawerStateExposer = {
23+
Component: React.ComponentType<{
24+
onStateChange: (state: DrawerState) => void;
25+
}>;
26+
};
27+
28+
type DrawerContent = {
29+
Component: React.ComponentType;
30+
config?: { id: string; props?: { resizable?: boolean } };
31+
};
32+
33+
export const ApplicationDrawer = () => {
34+
const { mountPoints } = useContext(DynamicRootContext);
35+
36+
// Get drawer content and its configurations
37+
const drawerContents = useMemo(
38+
() =>
39+
(mountPoints['application/internal/drawer-content'] ??
40+
[]) as DrawerContent[],
41+
[mountPoints],
42+
);
43+
44+
// Get drawer states from all state exposers
45+
const drawerStateExposers = useMemo(
46+
() =>
47+
(mountPoints['application/internal/drawer-state'] ??
48+
[]) as DrawerStateExposer[],
49+
[mountPoints],
50+
);
51+
52+
// Store drawer states from all plugins
53+
const drawerStatesRef = useRef<Map<string, DrawerState>>(new Map());
54+
const [activeDrawerId, setActiveDrawerId] = useState<string | null>(null);
55+
56+
const handleDrawerStateChange = useCallback(
57+
(state: DrawerState) => {
58+
const prev = drawerStatesRef.current.get(state.id);
59+
60+
// If drawer just opened, then transition from closed to open
61+
if (!prev?.isDrawerOpen && state.isDrawerOpen) {
62+
setActiveDrawerId(state.id);
63+
}
64+
// If drawer just closed and it was the active one, clear active drawer
65+
else if (
66+
prev?.isDrawerOpen &&
67+
!state.isDrawerOpen &&
68+
state.id === activeDrawerId
69+
) {
70+
setActiveDrawerId(null);
71+
}
72+
73+
drawerStatesRef.current.set(state.id, state);
74+
},
75+
[activeDrawerId],
76+
);
77+
78+
const drawerStates = Array.from(drawerStatesRef.current.values());
79+
80+
const allDrawers = useMemo(
81+
() =>
82+
drawerStates
83+
.map(state => {
84+
const content = drawerContents.find(c => c.config?.id === state.id);
85+
if (!content) return null;
86+
87+
return {
88+
state,
89+
Component: content.Component,
90+
config: content.config,
91+
};
92+
})
93+
.filter(Boolean),
94+
[drawerStates, drawerContents],
95+
);
96+
97+
const activeDrawer =
98+
allDrawers.find(d => d?.state.id === activeDrawerId) || null;
99+
100+
// Close other drawers when one becomes active
101+
useEffect(() => {
102+
if (activeDrawerId) {
103+
drawerStates.forEach(state => {
104+
if (state.id !== activeDrawerId && state.isDrawerOpen) {
105+
state.closeDrawer();
106+
}
107+
});
108+
}
109+
}, [activeDrawerId, drawerStates]);
110+
111+
// Manage CSS classes and variables for layout adjustments
112+
useEffect(() => {
113+
if (activeDrawer) {
114+
const className = 'docked-drawer-open';
115+
const cssVar = '--docked-drawer-width';
116+
117+
document.body.classList.add(className);
118+
document.body.style.setProperty(
119+
cssVar,
120+
`${activeDrawer.state.drawerWidth}px`,
121+
);
122+
123+
return () => {
124+
document.body.classList.remove(className);
125+
document.body.style.removeProperty(cssVar);
126+
};
127+
}
128+
return undefined;
129+
// eslint-disable-next-line react-hooks/exhaustive-deps
130+
}, [activeDrawer?.state.id, activeDrawer?.state.drawerWidth]);
131+
132+
if (drawerContents.length === 0) {
133+
return null;
134+
}
135+
136+
return (
137+
<>
138+
{/* Render the state exposers, they will call handleStateChange */}
139+
{drawerStateExposers.map(({ Component }, index) => (
140+
<Component
141+
// eslint-disable-next-line react/no-array-index-key
142+
key={`drawer-state-${Component.displayName || index}`}
143+
onStateChange={handleDrawerStateChange}
144+
/>
145+
))}
146+
147+
{activeDrawer && (
148+
<ResizableDrawer
149+
isDrawerOpen={activeDrawer.state.isDrawerOpen}
150+
isResizable={activeDrawer.config?.props?.resizable ?? false}
151+
drawerWidth={activeDrawer.state.drawerWidth}
152+
onWidthChange={activeDrawer.state.setDrawerWidth}
153+
>
154+
<activeDrawer.Component />
155+
</ResizableDrawer>
156+
)}
157+
</>
158+
);
159+
};

0 commit comments

Comments
 (0)