diff --git a/app-config.dynamic-plugins.yaml b/app-config.dynamic-plugins.yaml index 07ba45ef6e..dc84144f52 100644 --- a/app-config.dynamic-plugins.yaml +++ b/app-config.dynamic-plugins.yaml @@ -265,6 +265,16 @@ dynamicPlugins: mountPoints: - mountPoint: application/provider importName: QuickstartDrawerProvider + - mountPoint: application/internal/drawer-state + importName: QuickstartDrawerStateExposer + - mountPoint: application/internal/drawer-content + importName: QuickstartDrawerContent + config: + id: quickstart + - mountPoint: global.header/help + importName: QuickstartButton + config: + priority: 100 red-hat-developer-hub.backstage-plugin-dynamic-home-page: dynamicRoutes: - path: / diff --git a/docs/dynamic-plugins/frontend-plugin-wiring.md b/docs/dynamic-plugins/frontend-plugin-wiring.md index d35b080a39..76bf463dd6 100644 --- a/docs/dynamic-plugins/frontend-plugin-wiring.md +++ b/docs/dynamic-plugins/frontend-plugin-wiring.md @@ -168,7 +168,6 @@ plugins: Up to 3 levels of nested menu items are supported. - - 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`. - - Handling Complex Paths: - For simple paths like `path: /my-plugin`, the `menu_item_name` should be `my-plugin`. - 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: - `if` - Used only in `*/cards` type which renders visible content. This is passed to `}`. The following conditions are available: - - `allOf`: All conditions must be met - `anyOf`: At least one condition must be met - `oneOf`: Only one condition must be met Conditions can be: - - `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`. - `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'`. - `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: Users can configure multiple application providers by adding entries to the `mountPoints` field. +### Adding application drawers + +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. + +> **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. + +#### Architecture Overview + +The drawer system uses three key mount points: + +1. **`application/provider`**: Wraps the application with the plugin's context provider that manages drawer state +2. **`application/internal/drawer-state`**: Exposes minimal drawer state (open/closed, width, close function) to RHDH +3. **`application/internal/drawer-content`**: Provides the actual content to render inside the drawer + +#### Configuration Example + +Below is a complete example showing how to configure a drawer plugin: + +```yaml +# app-config.yaml +dynamicPlugins: + frontend: + : # plugin package name + mountPoints: + # 1. Provider: Manages the drawer's internal state + - mountPoint: application/provider + importName: MyDrawerProvider + + # 2. State Exposer: Shares drawer state with RHDH + - mountPoint: application/internal/drawer-state + importName: MyDrawerStateExposer + + # 3. Content: Defines what renders inside the drawer + - mountPoint: application/internal/drawer-content + importName: MyDrawerContent + config: + id: my-drawer # Unique identifier matching the context id + props: + resizable: true # Enable resize handle (optional, default: false) +``` + +#### Mount Point Details + +##### `application/provider` + +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. + +##### `application/internal/drawer-state` + +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. + +**Key Points:** + +- Component receives `onStateChange` callback from RHDH +- Exposes exactly 5 properties: + - `id`: Unique drawer identifier + - `isDrawerOpen`: Current open/closed state + - `drawerWidth`: Current drawer width in pixels + - `setDrawerWidth`: Function to update drawer width + - `closeDrawer`: Function RHDH calls to close this drawer +- Returns `null` (doesn't render anything, only acts as a bridge) +- RHDH detects state transitions (closed→open, open→closed) automatically +- When a drawer opens, RHDH automatically closes other open drawers by calling their `closeDrawer` function + +##### `application/internal/drawer-content` + +The content component defines what renders inside the drawer. + +**Configuration:** + +- `id` (required): Unique identifier that must match the `id` in the provider's context +- `props.resizable` (optional): Boolean enabling a resize handle on the drawer (default: `false`) + +#### Automatic Drawer Coordination + +RHDH automatically manages drawer visibility through state transition detection: + +**When a drawer opens:** + +1. Plugin's internal state changes (`isDrawerOpen` becomes `true`) +2. State exposer detects the change and calls `onStateChange` +3. RHDH receives the state update and sets this drawer as active +4. RHDH automatically calls `closeDrawer()` on all other open drawers +5. Only the most recently opened drawer remains visible + +**Example Scenario:** + +```yaml +# Both plugins configured, but only one drawer visible at a time +red-hat-developer-hub.backstage-plugin-quickstart: + mountPoints: + - mountPoint: application/provider + importName: QuickstartDrawerProvider + - mountPoint: application/internal/drawer-state + importName: QuickstartDrawerStateExposer + - mountPoint: application/internal/drawer-content + importName: QuickstartDrawerContent + config: + id: quickstart + - mountPoint: global.header/help + importName: QuickstartButton + config: + priority: 100 + + + +red-hat-developer-hub.backstage-plugin-test-drawer: + mountPoints: + - mountPoint: application/provider + importName: TestDrawerProvider + - mountPoint: application/internal/drawer-state + importName: TestDrawerStateExposer + - mountPoint: application/internal/drawer-content + importName: TestDrawerContent + config: + id: test-drawer + - mountPoint: global.header/help + importName: TestButton + +# Flow: User opens Quickstart → Quickstart drawer shows +# User opens Test Drawer → Quickstart auto-closes, Test drawer shows +# User opens Quickstart → test drawer auto-closes, Quickstart shows +``` + +#### Resizable Drawers + +Enable user-resizable drawers with the `resizable` configuration: + +```yaml +- mountPoint: application/drawer-content + importName: MyDrawerContent + config: + id: my-drawer + resizable: true # Adds a drag handle on the left edge +``` + +When `resizable: true`, users can: + +- Drag the left edge of the drawer to resize +- Changes are managed by the plugin's `setDrawerWidth` function +- Typically constrained to min/max width limits defined by the plugin + ## Customizing and Adding Entity tabs 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: diff --git a/dynamic-plugins.default.yaml b/dynamic-plugins.default.yaml index ad1c59be2c..6900ba8faa 100644 --- a/dynamic-plugins.default.yaml +++ b/dynamic-plugins.default.yaml @@ -578,6 +578,12 @@ plugins: mountPoints: - mountPoint: application/provider importName: QuickstartDrawerProvider + - mountPoint: application/internal/drawer-state + importName: QuickstartDrawerStateExposer + - mountPoint: application/internal/drawer-content + importName: QuickstartDrawerContent + config: + id: quickstart - mountPoint: global.header/help importName: QuickstartButton config: diff --git a/packages/app/config.d.ts b/packages/app/config.d.ts old mode 100644 new mode 100755 index 78eb4828d9..0ddc6b278a --- a/packages/app/config.d.ts +++ b/packages/app/config.d.ts @@ -217,6 +217,7 @@ export interface Config { | string )[]; }; + id?: string; }; }[]; appIcons?: { diff --git a/packages/app/src/components/AppBase/AppBase.tsx b/packages/app/src/components/AppBase/AppBase.tsx old mode 100644 new mode 100755 index a73e3ec109..375368647b --- a/packages/app/src/components/AppBase/AppBase.tsx +++ b/packages/app/src/components/AppBase/AppBase.tsx @@ -27,6 +27,7 @@ import { entityPage } from '../catalog/EntityPage'; import { CustomCatalogFilters } from '../catalog/filters/CustomCatalogFilters'; import { LearningPaths } from '../learningPaths/LearningPathsPage'; import { Root } from '../Root'; +import { ApplicationDrawer } from '../Root/ApplicationDrawer'; import { ApplicationListener } from '../Root/ApplicationListener'; import { ApplicationProvider } from '../Root/ApplicationProvider'; import ConfigUpdater from '../Root/ConfigUpdater'; @@ -150,6 +151,7 @@ const AppBase = () => { )} + diff --git a/packages/app/src/components/Root/ApplicationDrawer.tsx b/packages/app/src/components/Root/ApplicationDrawer.tsx new file mode 100755 index 0000000000..f7a200ac95 --- /dev/null +++ b/packages/app/src/components/Root/ApplicationDrawer.tsx @@ -0,0 +1,169 @@ +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import DynamicRootContext from '@red-hat-developer-hub/plugin-utils'; + +import { ResizableDrawer } from './ResizableDrawer'; + +type DrawerState = { + id: string; + isDrawerOpen: boolean; + drawerWidth: number; + setDrawerWidth: (width: number) => void; + closeDrawer: () => void; +}; + +type DrawerStateExposer = { + Component: React.ComponentType<{ + onStateChange: (state: DrawerState) => void; + }>; +}; + +type DrawerContent = { + Component: React.ComponentType; + config?: { id: string; props?: { resizable?: boolean } }; +}; + +export const ApplicationDrawer = () => { + const { mountPoints } = useContext(DynamicRootContext); + + // Get drawer content and its configurations + const drawerContents = useMemo( + () => + (mountPoints['application/internal/drawer-content'] ?? + []) as DrawerContent[], + [mountPoints], + ); + + // Get drawer states from all state exposers + const drawerStateExposers = useMemo( + () => + (mountPoints['application/internal/drawer-state'] ?? + []) as DrawerStateExposer[], + [mountPoints], + ); + + // Store drawer states from all plugins + const drawerStatesRef = useRef>(new Map()); + const [, forceUpdate] = useState({}); + const [activeDrawerId, setActiveDrawerId] = useState(null); + + const handleDrawerStateChange = useCallback( + (state: DrawerState) => { + const prev = drawerStatesRef.current.get(state.id); + const hasChanged = + !prev || + prev.isDrawerOpen !== state.isDrawerOpen || + prev.drawerWidth !== state.drawerWidth || + prev.setDrawerWidth !== state.setDrawerWidth; + + // If drawer just opened, then transition from closed to open + if (!prev?.isDrawerOpen && state.isDrawerOpen) { + setActiveDrawerId(state.id); + } + // If drawer just closed and it was the active one, clear active drawer + else if ( + prev?.isDrawerOpen && + !state.isDrawerOpen && + state.id === activeDrawerId + ) { + setActiveDrawerId(null); + } + + drawerStatesRef.current.set(state.id, state); + + if (hasChanged) { + forceUpdate({}); + } + }, + [activeDrawerId], + ); + + const drawerStates = Array.from(drawerStatesRef.current.values()); + + const allDrawers = useMemo( + () => + drawerStates + .map(state => { + const content = drawerContents.find(c => c.config?.id === state.id); + if (!content) return null; + + return { + state, + Component: content.Component, + config: content.config, + }; + }) + .filter(Boolean), + [drawerStates, drawerContents], + ); + + const activeDrawer = + allDrawers.find(d => d?.state.id === activeDrawerId) || null; + + // Close other drawers when one becomes active + useEffect(() => { + if (activeDrawerId) { + drawerStates.forEach(state => { + if (state.id !== activeDrawerId && state.isDrawerOpen) { + state.closeDrawer(); + } + }); + } + }, [activeDrawerId, drawerStates]); + + // Manage CSS classes and variables for layout adjustments + useEffect(() => { + if (activeDrawer) { + const className = 'docked-drawer-open'; + const cssVar = '--docked-drawer-width'; + + document.body.classList.add(className); + document.body.style.setProperty( + cssVar, + `${activeDrawer.state.drawerWidth}px`, + ); + + return () => { + document.body.classList.remove(className); + document.body.style.removeProperty(cssVar); + }; + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeDrawer?.state.id, activeDrawer?.state.drawerWidth]); + + if (drawerContents.length === 0) { + return null; + } + + return ( + <> + {/* Render the state exposers, they will call handleStateChange */} + {drawerStateExposers.map(({ Component }, index) => ( + + ))} + + {activeDrawer && ( + + + + )} + + ); +}; diff --git a/packages/app/src/components/Root/ResizableDrawer.tsx b/packages/app/src/components/Root/ResizableDrawer.tsx new file mode 100755 index 0000000000..0f1f1fdf0a --- /dev/null +++ b/packages/app/src/components/Root/ResizableDrawer.tsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; +import { styled } from '@mui/material/styles'; +import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; + +const Handle = styled('div')(({ theme }) => ({ + width: 6, + cursor: 'col-resize', + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + zIndex: 1201, + backgroundColor: theme.palette.divider, +})); + +export type ResizableDrawerProps = { + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + initialWidth?: number; + isDrawerOpen: boolean; + drawerWidth?: number; + onWidthChange?: (width: number) => void; + isResizable?: boolean; + [key: string]: any; +}; + +export const ResizableDrawer = (props: ResizableDrawerProps) => { + const { + children, + minWidth = 400, + maxWidth = 800, + initialWidth = 400, + isDrawerOpen, + isResizable = false, + drawerWidth: externalDrawerWidth, + onWidthChange, + ...drawerProps + } = props; + + // Ensure width is never below minWidth + const clampedInitialWidth = Math.max( + externalDrawerWidth || initialWidth, + minWidth, + ); + + const [width, setWidth] = useState(clampedInitialWidth); + const resizingRef = useRef(false); + + // Sync with external drawerWidth when it changes + useEffect(() => { + if (externalDrawerWidth !== undefined) { + const clampedWidth = Math.max(externalDrawerWidth, minWidth); + if (clampedWidth !== width) { + setWidth(clampedWidth); + // If the external width was below min, update the parent + if (externalDrawerWidth < minWidth && onWidthChange && isResizable) { + onWidthChange(clampedWidth); + } + } + } + }, [externalDrawerWidth, width, minWidth, onWidthChange, isResizable]); + + const onMouseDown = () => { + resizingRef.current = true; + }; + + const onMouseMove = useCallback( + (e: MouseEvent) => { + if (!resizingRef.current) return; + // For right-anchored drawer, calculate width from the right edge + const newWidth = globalThis.innerWidth - e.clientX; + + if (newWidth >= minWidth && newWidth <= maxWidth) { + setWidth(newWidth); + if (onWidthChange) { + onWidthChange(newWidth); + } + } + }, + [maxWidth, minWidth, onWidthChange], + ); + + const onMouseUp = () => { + resizingRef.current = false; + }; + + useEffect(() => { + if (isResizable) { + globalThis.addEventListener('mousemove', onMouseMove); + globalThis.addEventListener('mouseup', onMouseUp); + return () => { + globalThis.removeEventListener('mousemove', onMouseMove); + globalThis.removeEventListener('mouseup', onMouseUp); + }; + } + return () => {}; + }, [onMouseMove, isResizable]); + + // Ensure anchor is always 'right' and not overridden by drawerProps + const { anchor: _, ...restDrawerProps } = drawerProps; + + return ( + { + const themeConfig = theme as ThemeConfig; + return ( + themeConfig.palette?.rhdh?.general?.sidebarBackgroundColor || + theme.palette.background.paper + ); + }, + justifyContent: 'space-between', + }, + // Only apply header offset when global header exists + 'body:has(#global-header) &': { + '& .v5-MuiDrawer-paper': { + top: '64px !important', + height: 'calc(100vh - 64px) !important', + }, + }, + }} + variant="persistent" + open={isDrawerOpen} + > + + {children} + {isResizable && } + + + ); +}; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx old mode 100644 new mode 100755 index 6c84df8d37..51d130ceab --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -161,10 +161,10 @@ const SidebarLayout = styled(Box, { flexGrow: 1, }, - // When quickstart drawer is open, adjust the content size - 'body.quickstart-drawer-open #rhdh-sidebar-layout&': { + // When drawer is docked, adjust the content size + 'body.docked-drawer-open #rhdh-sidebar-layout&': { '> div > main': { - marginRight: `calc(var(--quickstart-drawer-width, 500px) + ${(theme as ThemeConfig).palette?.rhdh?.general.pageInset})`, + marginRight: `calc(var(--docked-drawer-width, 500px) + ${(theme as ThemeConfig).palette?.rhdh?.general.pageInset})`, transition: 'margin-right 0.3s ease', }, }, diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index f5afff0ac3..8a65236852 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -47,6 +47,7 @@ export type ResolvedDynamicRoute = { }; export type MountPointConfigBase = { + id?: string; layout?: Record; props?: Record; };