Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 107 additions & 3 deletions docs/dynamic-plugins/frontend-plugin-wiring.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ plugins:
Up to 3 levels of nested menu items are supported.

- <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`.

- 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`).
Expand Down Expand Up @@ -334,13 +333,11 @@ Each mount point supports additional configuration:
- `if` - Used only in `*/cards` type which renders visible content. This is passed to `<EntitySwitch.Case if={<here>}`.

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.
Expand Down Expand Up @@ -439,6 +436,113 @@ 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 the system automatically managing which drawer is displayed based on priority and open state.

#### 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/drawer-state`**: Exposes the drawer state (open/closed, width) to RHDH without creating dependencies
3. **`application/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:
<package_name>: # 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/drawer-state
importName: MyDrawerStateExposer

# 3. Content: Defines what renders inside the drawer
- mountPoint: application/drawer-content
importName: MyDrawerContent
config:
id: my-drawer # Unique identifier matching the context id
priority: 100 # Higher priority renders first (optional, default: 0)
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/drawer-state`

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.

**Key Points:**

- Component receives `onStateChange` callback from RHDH
- Only exposes 4 properties: `id`, `isDrawerOpen`, `drawerWidth`, `setDrawerWidth`
- Does not expose other methods like `toggleDrawer`, `openDrawer`, etc.
- Returns `null` (doesn't render anything, only acts a bridge)

##### `application/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
- `priority` (optional): Number determining render order when multiple drawers are open (higher = rendered first)
- `resizable` (optional): Boolean enabling a resize handle on the drawer

#### Priority System

When multiple drawers are open simultaneously, RHDH renders only the highest priority drawer:

```yaml
# Lightspeed drawer (priority: 100) takes precedence when both are open
red-hat-developer-hub.backstage-plugin-lightspeed:
mountPoints:
- mountPoint: application/drawer-content
importName: LightspeedChatContainer
config:
id: lightspeed
priority: 100 # Highest priority

red-hat-developer-hub.backstage-plugin-quickstart:
mountPoints:
- mountPoint: application/drawer-content
importName: QuickstartDrawerContent
config:
id: quickstart
priority: 0 # Lower priority
```

#### 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:
Expand Down
2 changes: 2 additions & 0 deletions packages/app/config.d.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export interface Config {
| string
)[];
};
id?: string;
priority?: number;
};
}[];
appIcons?: {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/AppBase/AppBase.tsx
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -150,6 +151,7 @@ const AppBase = () => {
)}
</FlatRoutes>
</Root>
<ApplicationDrawer />
</ApplicationProvider>
</AppRouter>
</AppProvider>
Expand Down
135 changes: 135 additions & 0 deletions packages/app/src/components/Root/ApplicationDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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;
};

type DrawerStateExposer = {
Component: React.ComponentType<{
onStateChange: (state: DrawerState) => void;
}>;
};

type DrawerContent = {
Component: React.ComponentType;
config?: { id: string; priority?: number; props?: { resizable?: boolean } };
};

export const ApplicationDrawer = () => {
const { mountPoints } = useContext(DynamicRootContext);

// Get drawer content and its configurations
const drawerContents = useMemo(
() => (mountPoints['application/drawer-content'] ?? []) as DrawerContent[],
[mountPoints],
);

// Get drawer states from all state exposers
const drawerStateExposers = useMemo(
() =>
(mountPoints['application/drawer-state'] ?? []) as DrawerStateExposer[],
[mountPoints],
);

// Store drawer states from all plugins
const drawerStatesRef = useRef<Map<string, DrawerState>>(new Map());
const [, forceUpdate] = useState({});

// Callback that plugins will call when state changes
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;

drawerStatesRef.current.set(state.id, state);

if (hasChanged) {
forceUpdate({});
}
}, []);

// Get all drawer states
const drawerStates = Array.from(drawerStatesRef.current.values());

// Find the highest priority open drawer
const activeDrawer = drawerStates
.filter(state => state.isDrawerOpen)
.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)
.sort((a, b) => (b?.config?.priority ?? 0) - (a?.config?.priority ?? 0))[0];

// 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) => (
<Component
// eslint-disable-next-line react/no-array-index-key
key={`drawer-state-${Component.displayName || index}`}
onStateChange={handleDrawerStateChange}
/>
))}

{activeDrawer && (
<ResizableDrawer
isDrawerOpen={activeDrawer.state.isDrawerOpen}
isResizable={activeDrawer.config?.props?.resizable ?? false}
drawerWidth={activeDrawer.state.drawerWidth}
onWidthChange={activeDrawer.state.setDrawerWidth}
>
<activeDrawer.Component />
</ResizableDrawer>
)}
</>
);
};
Loading
Loading