feat(home): add new frontend system support

Migrates home plugin to support the new frontend system architecture by
introducing extension blueprints for composable homepage functionality.
Key changes:
- Add CustomHomepageWidgetBlueprint for creating installable homepage widgets
- Add CustomHomepageBlueprint for composing pages from widget extensions
- Introduce titleExtensionDataRef for NFS title handling
This attempts to bring  the home plugin up to par with other core plugins that have migrated
to the new frontend system
Signed-off-by: Adam Kunicki <kunickiaj@gmail.com>
This commit is contained in:
Adam Kunicki
2025-09-22 10:00:11 -07:00
parent 2218061089
commit 90956a61bd
22 changed files with 1085 additions and 60 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-home-react': patch
'@backstage/plugin-home': patch
---
Support new frontend system in the homepage plugin
+77 -1
View File
@@ -24,7 +24,83 @@ Before we begin, make sure
Now, let's get started by installing the home plugin and creating a simple homepage for your Backstage app.
### Setup homepage
## Setup Methods
There are two ways to set up the home plugin, depending on which frontend system your Backstage app uses:
1. **New Frontend System (Recommended)** - For apps using the new plugin system with extensions and blueprints
2. **Legacy Frontend System** - For existing apps using the legacy plugin architecture
### New Frontend System Setup
If your Backstage app uses the [new frontend system](../frontend-system/index.md), follow these steps:
#### 1. Install the plugin
```bash title="From your Backstage root directory"
yarn --cwd packages/app add @backstage/plugin-home
```
#### 2. Add the plugin to your app configuration
Update your `packages/app/src/app.tsx` to include the home plugin:
```tsx title="packages/app/src/app.tsx"
import homePlugin from '@backstage/plugin-home/alpha';
const app = createApp({
features: [
// ... other plugins
homePlugin,
],
});
```
#### 3. Configure the homepage as your root route
By default, the homepage will be available at `/home`. To make it your app's landing page at `/`, add this configuration to your `app-config.yaml`:
```yaml title="app-config.yaml"
app:
extensions:
- page:home:
config:
path: /
```
The plugin will automatically add a "Home" navigation item to your sidebar and provide a basic homepage layout.
#### 4. Optional: Enable visit tracking
Visit tracking is an optional feature that allows users to see their recently visited and most visited pages on the homepage. This feature is **disabled by default** to give you control over what data is collected and stored.
Visit tracking requires a storage implementation to persist user data:
- **With UserSettings storage** (recommended): If you have the [UserSettings plugin](https://backstage.io/docs/features/software-catalog/external-integrations/#user-settings) configured with persistent storage, visit data will be stored there and synchronized across devices.
- **Fallback to local storage**: If no persistent storage is available, the plugin will automatically fall back to browser local storage, which stores data locally per device.
To enable visit tracking, add this configuration to your `app-config.yaml`:
```yaml title="app-config.yaml"
app:
extensions:
- api:home/visits: true
- app-root-element:home/visit-listener: true
```
#### 5. Customizing your homepage
The New Frontend System provides powerful customization options:
**Custom Homepage Layouts**: Use the `HomepageBlueprint` to create custom homepage layouts with your own design and widget arrangements.
**Adding Homepage Widgets**: Register custom widgets using the `HomepageWidgetBlueprint` from the `@backstage/plugin-home-react/alpha` package.
For detailed instructions on creating custom layouts, registering widgets, and advanced configuration options, see the [Home plugin documentation](https://github.com/backstage/backstage/tree/master/plugins/home#readme).
### Legacy Frontend System Setup
If your Backstage app uses the legacy frontend system, follow these steps:
#### 1. Install the plugin
+1
View File
@@ -60,6 +60,7 @@
"@backstage/plugin-catalog-unprocessed-entities": "workspace:^",
"@backstage/plugin-devtools": "workspace:^",
"@backstage/plugin-home": "workspace:^",
"@backstage/plugin-home-react": "workspace:^",
"@backstage/plugin-kubernetes": "workspace:^",
"@backstage/plugin-kubernetes-cluster": "workspace:^",
"@backstage/plugin-notifications": "workspace:^",
+2 -3
View File
@@ -18,9 +18,8 @@ import { createApp } from '@backstage/frontend-defaults';
import { pagesPlugin } from './examples/pagesPlugin';
import notFoundErrorPage from './examples/notFoundErrorPageExtension';
import userSettingsPlugin from '@backstage/plugin-user-settings/alpha';
import homePlugin, {
titleExtensionDataRef,
} from '@backstage/plugin-home/alpha';
import homePlugin from '@backstage/plugin-home/alpha';
import { titleExtensionDataRef } from '@backstage/plugin-home-react/alpha';
import {
coreExtensionData,
+1
View File
@@ -55,6 +55,7 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/core-compat-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
+87 -1
View File
@@ -3,9 +3,77 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { RJSFSchema } from '@rjsf/utils';
import { TranslationRef } from '@backstage/frontend-plugin-api';
import { UiSchema } from '@rjsf/utils';
// @public (undocumented)
export type CardLayout = {
width?: {
minColumns?: number;
maxColumns?: number;
defaultColumns?: number;
};
height?: {
minRows?: number;
maxRows?: number;
defaultRows?: number;
};
};
// @public (undocumented)
export type CardSettings = {
schema?: RJSFSchema;
uiSchema?: UiSchema;
};
// @public (undocumented)
export type ComponentParts = {
Content: (props?: any) => JSX.Element;
Actions?: () => JSX.Element;
Settings?: () => JSX.Element;
ContextProvider?: (props: any) => JSX.Element;
};
// @alpha
export const HomepageWidgetBlueprint: ExtensionBlueprint<{
kind: 'home-widget';
params: HomepageWidgetBlueprintParams;
output:
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
{
name?: string;
title?: string;
description?: string;
layout?: CardLayout;
settings?: CardSettings;
},
'home.widget.metadata',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: never;
}>;
// @alpha (undocumented)
export interface HomepageWidgetBlueprintParams {
componentProps?: Record<string, unknown>;
components: () => Promise<ComponentParts>;
description?: string;
layout?: CardLayout;
name?: string;
settings?: CardSettings;
title?: string;
}
// @alpha
export const homeReactTranslationRef: TranslationRef<
'home-react',
{
@@ -15,5 +83,23 @@ export const homeReactTranslationRef: TranslationRef<
}
>;
// (No @packageDocumentation comment for this package)
// @alpha
export const titleExtensionDataRef: ConfigurableExtensionDataRef<
string,
'title',
{}
>;
// @alpha
export const widgetMetadataRef: ConfigurableExtensionDataRef<
{
name?: string;
title?: string;
description?: string;
layout?: CardLayout;
settings?: CardSettings;
},
'home.widget.metadata',
{}
>;
```
+17
View File
@@ -13,4 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* React components and utilities for the home plugin's new frontend system.
*
* @remarks
* This package provides React components, blueprints, and utilities for building
* customizable home pages with the new Backstage frontend system.
*
* @packageDocumentation
*/
export { homeReactTranslationRef } from './translation';
export { titleExtensionDataRef } from './alpha/dataRefs';
export {
HomepageWidgetBlueprint,
widgetMetadataRef,
type HomepageWidgetBlueprintParams,
} from './alpha/blueprints/HomepageWidgetBlueprint';
export type { ComponentParts, CardLayout, CardSettings } from './extensions';
@@ -0,0 +1,128 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { lazy, ReactElement } from 'react';
import { compatWrapper } from '@backstage/core-compat-api';
import {
coreExtensionData,
createExtensionBlueprint,
createExtensionDataRef,
ExtensionBoundary,
} from '@backstage/frontend-plugin-api';
import {
CardExtension,
CardExtensionProps,
CardLayout,
CardSettings,
ComponentParts,
} from '../../extensions';
/** @alpha */
export interface HomepageWidgetBlueprintParams {
/**
* Optional name for the widget. If not provided, the extension will use only its kind
* in the extension ID.
*/
name?: string;
/**
* Optional title displayed for the widget, used as the default card heading.
*/
title?: string;
/**
* Optional description shown in the widget catalog when adding new cards.
*/
description?: string;
/**
* Component parts rendered within the card.
*/
components: () => Promise<ComponentParts>;
/**
* Layout hints used by the customizable grid.
*/
layout?: CardLayout;
/**
* Schema used to configure widget settings.
*/
settings?: CardSettings;
/**
* Default props forwarded to the rendered widget component.
*/
componentProps?: Record<string, unknown>;
}
const DEFAULT_WIDGET_ATTACH_POINT = {
id: 'page:home',
input: 'widgets',
} as const;
/**
* Extension data ref for widget metadata.
* @alpha
*/
export const widgetMetadataRef = createExtensionDataRef<{
name?: string;
title?: string;
description?: string;
layout?: CardLayout;
settings?: CardSettings;
}>().with({ id: 'home.widget.metadata' });
/**
* Creates widgets that can be installed into the home page grid.
*
* @alpha
*/
export const HomepageWidgetBlueprint = createExtensionBlueprint({
kind: 'home-widget',
attachTo: DEFAULT_WIDGET_ATTACH_POINT,
output: [coreExtensionData.reactElement, widgetMetadataRef],
*factory(params: HomepageWidgetBlueprintParams, { node }) {
const isCustomizable = params.settings?.schema !== undefined;
const LazyCard = lazy(() =>
params.components().then(parts => ({
default: (props: CardExtensionProps<Record<string, unknown>>) => (
<CardExtension
{...props}
{...parts}
title={props.title || params.title}
isCustomizable={isCustomizable}
/>
),
})),
);
const Widget = (
props: CardExtensionProps<Record<string, unknown>>,
): ReactElement =>
compatWrapper(
<ExtensionBoundary node={node}>
<LazyCard {...props} />
</ExtensionBoundary>,
);
yield coreExtensionData.reactElement(
<Widget {...(params.componentProps ?? {})} />,
);
yield widgetMetadataRef({
name: params.name,
title: params.title,
description: params.description,
layout: params.layout,
settings: params.settings,
});
},
});
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExtensionDataRef } from '@backstage/frontend-plugin-api';
/**
* Title data supplied to the home page extension when composing via the new frontend system.
*
* @alpha
*/
export const titleExtensionDataRef = createExtensionDataRef<string>().with({
id: 'title',
});
+1 -1
View File
@@ -121,7 +121,7 @@ type CardExtensionComponentProps<T> = CardExtensionProps<T> &
overrideTitle?: string;
};
function CardExtension<T>(props: CardExtensionComponentProps<T>) {
export function CardExtension<T>(props: CardExtensionComponentProps<T>) {
const {
Renderer,
Content,
+3
View File
@@ -16,6 +16,9 @@
import { createTranslationRef } from '@backstage/frontend-plugin-api';
/**
* Translation reference for the home-react plugin.
* Contains localized text strings for home page components and settings modals.
*
* @alpha
*/
export const homeReactTranslationRef = createTranslationRef({
+144 -21
View File
@@ -4,7 +4,7 @@ The Home plugin introduces a system for composing a Home Page for Backstage in o
For App Integrators, the system is designed to be composable to give total freedom in designing a Home Page that suits the needs of the organization. From the perspective of a Component Developer who wishes to contribute with building blocks to be included in Home Pages, there's a convenient interface for bundling the different parts and exporting them with both error boundary and lazy loading handled under the surface.
## Getting started
## Installation
If you have a standalone app (you didn't clone this repo), then do
@@ -13,6 +13,144 @@ If you have a standalone app (you didn't clone this repo), then do
yarn --cwd packages/app add @backstage/plugin-home
```
## Getting started
The home plugin supports both the new frontend system and the legacy system.
### New Frontend System
If you're using Backstage's new frontend system, add the plugin to your app:
```ts
// packages/app/src/App.tsx
import homePlugin from '@backstage/plugin-home/alpha';
const app = createApp({
features: [
// ... other plugins
homePlugin,
// ... other plugins
],
});
```
The plugin will automatically provide:
- A homepage at `/home` with customizable widget grid
- A "Home" navigation item in the sidebar
#### Creating Custom Homepage Layouts
Use the `HomepageBlueprint` to create custom homepage layouts:
```ts
import { HomepageBlueprint } from '@backstage/plugin-home/alpha';
import { Content, Header, Page } from '@backstage/core-components';
const myHomePage = HomepageBlueprint.make({
params: {
title: 'My Custom Home',
render: ({ grid }) => (
<Page themeId="home">
<Header title="Welcome" />
<Content>{grid}</Content>
</Page>
),
},
});
```
#### Visit Tracking (Optional)
Visit tracking is an **optional feature** that must be explicitly enabled. When enabled, it provides intelligent storage fallbacks:
**Enabling Visit Tracking:**
Add the following to your `app-config.yaml`:
```yaml
app:
extensions:
# Enable visit tracking API (disabled by default)
- api:home/visits: true
# Enable visit listener (disabled by default)
- app-root-element:home/visit-listener: true
```
**Storage Strategy (when enabled):**
1. **Custom Storage API**: If you have `storageApiRef` configured (like database-backed `UserSettingsStorage`), visit data uses your custom storage
2. **Browser Local Storage Fallback**: If no custom storage is configured, automatically falls back to browser local storage
**Note**: Visit tracking extensions are disabled by default to give users control over data collection and storage.
## Creating Homepage Widgets
Homepage widgets are React components that can be added to customizable home pages. The **key difference** between the new frontend system and legacy system is how these widget components are **registered and exported**:
- **New Frontend System**: Use `HomepageWidgetBlueprint` to register widgets as extensions
- **Legacy System**: Use `createCardExtension` to export widgets as card components
### New Frontend System
Create widgets using the `HomepageWidgetBlueprint`:
```ts
import { HomepageWidgetBlueprint } from '@backstage/plugin-home-react/alpha';
const myWidget = HomepageWidgetBlueprint.make({
name: 'my-widget',
params: {
name: 'MyWidget',
title: 'My Custom Widget',
description: 'A custom widget for the homepage',
components: () =>
import('./MyWidgetComponent').then(m => ({
Content: m.Content,
})),
layout: {
height: { minRows: 4 },
width: { minColumns: 3 },
},
settings: {
schema: {
title: 'Widget Settings',
type: 'object',
properties: {
color: {
title: 'Color',
type: 'string',
default: 'blue',
enum: ['blue', 'red', 'green'],
},
},
},
},
},
});
```
> **Example**: See [dev/index.tsx](dev/index.tsx) for a comprehensive example of creating multiple homepage widgets and layouts using the new frontend system.
### Legacy System - Widget Registration
In the legacy system, use the `createCardExtension` helper to create homepage widgets:
```tsx
import { createCardExtension } from '@backstage/plugin-home-react';
export const MyWidget = homePlugin.provide(
createCardExtension<{ defaultCategory?: 'programming' | 'any' }>({
title: 'My Custom Widget',
components: () => import('./homePageComponents/MyWidget'),
}),
);
```
The `createCardExtension` provides error boundary and lazy loading, and accepts generics for custom props that App Integrators can configure.
## Legacy System Setup
### Setting up the Home Page
1. Create a Home Page Component that will be used for composition.
@@ -40,28 +178,13 @@ import { homePage } from './components/home/HomePage';
// ...
```
### Creating Components
### Creating Components (Legacy)
The Home Page can be composed with regular React components, so there's no magic in creating components to be used for composition 🪄 🎩 . However, in order to assure that your component fits into a diverse set of Home Pages, there's an extension creator for this purpose, that creates a Card-based layout, for consistency between components (read more about extensions [here](https://backstage.io/docs/plugins/composability#extensions)). The extension creator requires two fields: `title` and `components`. The `components` field is expected to be an asynchronous import that should at least contain a `Content` field. Additionally, you can optionally provide `settings`, `actions` and `contextProvider` as well. These parts will be combined to create a card, where the `content`, `actions` and `settings` will be wrapped within the `contextProvider` in order to be able to access to context and effectively communicate with one another.
In the legacy system, homepage components can be regular React components or wrapped with `createCardExtension` for additional features like error boundaries and lazy loading. Components created with `createCardExtension` are exported as card components that can be composed into homepage layouts.
Finally, the `createCardExtension` also accepts a generic, such that Component Developers can indicate to App Integrators what custom props their component will accept, such as the example below where the default category of the random jokes can be set.
### Composing a Home Page (Legacy)
```tsx
import { createCardExtension } from '@backstage/plugin-home-react';
export const RandomJokeHomePageComponent = homePlugin.provide(
createCardExtension<{ defaultCategory?: 'programming' | 'any' }>({
title: 'Random Joke',
components: () => import('./homePageComponents/RandomJoke'),
}),
);
```
In summary: it is not necessary to use the `createCardExtension` extension creator to register a home page component, although it is convenient since it provides error boundary and lazy loading, and it also may hook into other functionality in the future.
### Composing a Home Page
Composing a Home Page is no different from creating a regular React Component, i.e. the App Integrator is free to include whatever content they like. However, there are components developed with the Home Page in mind, as described in the previous section. If created by the `createCardExtension` extension creator, they are rendered like so
In the legacy system, composing a Home Page is done by creating regular React components. Components created with `createCardExtension` are rendered like so:
```tsx
import Grid from '@material-ui/core/Grid';
@@ -293,7 +416,7 @@ export const apis: AnyApiFactory[] = [
VisitsStorageApi.create({ storageApi, identityApi }),
}),
// Or a localStorage data implementation, relies on WebStorage implementation of storageApi
// Or a local storage data implementation, relies on WebStorage implementation of storageApi
createApiFactory({
api: visitsApiRef,
deps: {
+202 -11
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2021 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,14 +13,205 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createDevApp } from '@backstage/dev-utils';
import { homePlugin, HomepageCompositionRoot } from '../src/plugin';
createDevApp()
.registerPlugin(homePlugin)
.addPage({
element: <HomepageCompositionRoot />,
title: 'Root Page',
path: '/',
})
.render();
import { Content, Header, Page } from '@backstage/core-components';
import { createApp } from '@backstage/frontend-defaults';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import catalogPlugin from '@backstage/plugin-catalog/alpha';
import HomeIcon from '@material-ui/icons/Home';
import ReactDOM from 'react-dom/client';
import {
ApiBlueprint,
createFrontendModule,
} from '@backstage/frontend-plugin-api';
import { HomepageWidgetBlueprint } from '@backstage/plugin-home-react/alpha';
import { HeaderWorldClock, WelcomeTitle, type ClockConfig } from '../src';
import homePlugin, {
HomepageBlueprint,
type HomepageGridProps,
} from '../src/alpha';
const clockConfigs: ClockConfig[] = [
{ label: 'NYC', timeZone: 'America/New_York' },
{ label: 'UTC', timeZone: 'UTC' },
{ label: 'STO', timeZone: 'Europe/Stockholm' },
{ label: 'TYO', timeZone: 'Asia/Tokyo' },
];
const timeFormat: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
const defaultGridConfig: NonNullable<HomepageGridProps['config']> = [
{
component: 'HomePageToolkit',
x: 0,
y: 0,
width: 12,
height: 4,
movable: false,
resizable: false,
},
{
component: 'HomePageStarredEntities',
x: 0,
y: 4,
width: 6,
height: 5,
},
{
component: 'HomePageRandomJoke',
x: 6,
y: 4,
width: 6,
height: 5,
},
];
const homePage = HomepageBlueprint.make({
params: {
title: 'Home',
grid: {
config: defaultGridConfig,
},
render: ({ grid }) => (
<Page themeId="home">
<Header title={<WelcomeTitle />} pageTitleOverride="Home">
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>
</Header>
<Content>{grid}</Content>
</Page>
),
},
});
const homePageToolkitWidget = HomepageWidgetBlueprint.make({
name: 'home-toolkit',
params: {
name: 'HomePageToolkit',
title: 'Toolkit',
components: () =>
import('../src/homePageComponents/Toolkit').then(m => ({
Content: m.Content,
ContextProvider: m.ContextProvider,
})),
componentProps: {
tools: [
{
url: 'https://backstage.io',
label: 'Backstage Homepage',
icon: <HomeIcon />,
},
],
},
},
});
const homePageStarredEntitiesWidget = HomepageWidgetBlueprint.make({
name: 'home-starred-entities',
params: {
name: 'HomePageStarredEntities',
title: 'Your Starred Entities',
components: () =>
import('../src/homePageComponents/StarredEntities').then(m => ({
Content: m.Content,
})),
},
});
const homePageRandomJokeWidget = HomepageWidgetBlueprint.make({
name: 'home-random-joke',
params: {
name: 'HomePageRandomJoke',
title: 'Random Joke',
description: 'Shows a random programming joke',
components: () =>
import('../src/homePageComponents/RandomJoke').then(m => ({
Content: m.Content,
Settings: m.Settings,
Actions: m.Actions,
ContextProvider: m.ContextProvider,
})),
layout: {
height: { minRows: 4 },
width: { minColumns: 3 },
},
settings: {
schema: {
title: 'Random Joke settings',
type: 'object',
properties: {
defaultCategory: {
title: 'Category',
type: 'string',
enum: ['any', 'programming', 'dad'],
default: 'any',
},
},
},
},
},
});
const homeDevModule = createFrontendModule({
pluginId: 'home',
extensions: [
homePage,
homePageToolkitWidget,
homePageStarredEntitiesWidget,
homePageRandomJokeWidget,
],
});
const entities = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'example',
annotations: {
'backstage.io/managed-by-location': 'file:/path/to/catalog-info.yaml',
},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'guest',
},
},
];
const catalogApi = catalogApiMock({ entities });
const catalogPluginOverrides = createFrontendModule({
pluginId: 'catalog',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: catalogApiRef,
deps: {},
factory: () => catalogApi,
}),
}),
],
});
const app = createApp({
features: [
catalogPlugin,
catalogPluginOverrides,
homePlugin, // Load the home plugin
homeDevModule, // Load the widgets and homepage content
],
});
const root = app.createRoot();
ReactDOM.createRoot(document.getElementById('root')!).render(root);
+2
View File
@@ -83,6 +83,8 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.0.0",
+99 -8
View File
@@ -7,16 +7,24 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { CSSProperties } from 'react';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
// @public
export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
// @alpha
const _default: OverridableFrontendPlugin<
{
root: RouteRef<undefined>;
@@ -49,6 +57,27 @@ const _default: OverridableFrontendPlugin<
element: JSX.Element;
};
}>;
'nav-item:home': OverridableExtensionDefinition<{
kind: 'nav-item';
name: undefined;
config: {};
configInput: {};
output: ExtensionDataRef<
{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
{}
>;
inputs: {};
params: {
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
};
}>;
'page:home': OverridableExtensionDefinition<{
config: {
path: string | undefined;
@@ -102,7 +131,64 @@ const _default: OverridableFrontendPlugin<
>;
export default _default;
// @alpha (undocumented)
// @alpha
export const HomepageBlueprint: ExtensionBlueprint<{
kind: 'home-page';
params: HomepageBlueprintParams;
output:
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'title',
{
optional: true;
}
>;
inputs: {
widgets: ExtensionInput<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{
singleton: false;
optional: false;
}
>;
};
config: {};
configInput: {};
dataRefs: never;
}>;
// @alpha
export interface HomepageBlueprintParams {
grid?: Omit<HomepageGridProps, 'children'>;
render?: (props: HomepageTemplateProps) => ReactElement;
title?: string;
}
// @public
export type HomepageGridProps = {
children?: ReactNode;
config?: LayoutConfiguration[];
title?: string;
rowHeight?: number;
breakpoints?: Record<Breakpoint, number>;
cols?: Record<Breakpoint, number>;
containerPadding?: [number, number] | Record<Breakpoint, [number, number]>;
containerMargin?: [number, number] | Record<Breakpoint, [number, number]>;
maxRows?: number;
style?: CSSProperties;
compactType?: 'vertical' | 'horizontal' | null;
allowOverlap?: boolean;
preventCollision?: boolean;
};
// @alpha
export interface HomepageTemplateProps {
grid: ReactElement;
widgets: ReactNode[];
}
// @alpha
export const homeTranslationRef: TranslationRef<
'home',
{
@@ -135,12 +221,17 @@ export const homeTranslationRef: TranslationRef<
}
>;
// @alpha (undocumented)
export const titleExtensionDataRef: ConfigurableExtensionDataRef<
string,
'title',
{}
>;
// @public
export type LayoutConfiguration = {
component: ReactElement | string;
x: number;
y: number;
width: number;
height: number;
movable?: boolean;
deletable?: boolean;
resizable?: boolean;
};
// (No @packageDocumentation comment for this package)
```
+1 -1
View File
@@ -344,7 +344,7 @@ export type VisitsApiQueryParams = {
}>;
};
// @public (undocumented)
// @public
export const visitsApiRef: ApiRef<VisitsApi>;
// @public
+111
View File
@@ -0,0 +1,111 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockApis } from '@backstage/test-utils';
import homePlugin from './alpha';
import { VisitsStorageApi, VisitsWebStorageApi } from './api';
// Mock localStorage for testing
const mockLocalStorage = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
value: {
getItem: (key: string) => mockLocalStorage.get(key) || null,
setItem: (key: string, value: string) => mockLocalStorage.set(key, value),
removeItem: (key: string) => mockLocalStorage.delete(key),
clear: () => mockLocalStorage.clear(),
},
});
describe('Home Plugin Alpha', () => {
beforeEach(() => {
mockLocalStorage.clear();
});
describe('Core Extensions (Always Enabled)', () => {
it('should export core home page extension', () => {
expect(homePlugin.getExtension('page:home')).toBeDefined();
});
it('should export navigation item extension', () => {
expect(homePlugin.getExtension('nav-item:home')).toBeDefined();
});
});
describe('Optional Extensions (Disabled by Default)', () => {
it('should export visit tracking API extension', () => {
const visitTrackingExtension = homePlugin.getExtension('api:home/visits');
expect(visitTrackingExtension).toBeDefined();
});
it('should export visit listener extension', () => {
const visitListenerExtension = homePlugin.getExtension(
'app-root-element:home/visit-listener',
);
expect(visitListenerExtension).toBeDefined();
});
});
describe('API Implementation Classes', () => {
it('should create VisitsStorageApi with custom storage', () => {
const mockStorageApi = mockApis.storage();
const mockIdentityApi = mockApis.identity({
userEntityRef: 'user:default/testuser',
});
const visitsApi = VisitsStorageApi.create({
storageApi: mockStorageApi,
identityApi: mockIdentityApi,
});
expect(visitsApi).toBeInstanceOf(VisitsStorageApi);
});
it('should create localStorage fallback API when no custom storage is available', () => {
const mockIdentityApi = mockApis.identity({
userEntityRef: 'user:default/testuser',
});
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
// Test that VisitsWebStorageApi can be created without custom storage
const visitsApi = VisitsWebStorageApi.create({
identityApi: mockIdentityApi,
errorApi: mockErrorApi,
});
// VisitsWebStorageApi.create returns a VisitsStorageApi instance
expect(visitsApi).toBeInstanceOf(VisitsStorageApi);
});
});
describe('Plugin Structure', () => {
it('should have correct plugin metadata', () => {
expect(homePlugin.id).toBe('home');
expect(homePlugin.routes.root).toBeDefined();
});
it('should include all extensions in the correct order', () => {
// Core extensions (always enabled)
expect(homePlugin.getExtension('page:home')).toBeDefined();
expect(homePlugin.getExtension('nav-item:home')).toBeDefined();
// Optional extensions (disabled by default)
expect(homePlugin.getExtension('api:home/visits')).toBeDefined();
expect(
homePlugin.getExtension('app-root-element:home/visit-listener'),
).toBeDefined();
});
});
});
+49 -12
View File
@@ -14,30 +14,36 @@
* limitations under the License.
*/
/**
* The home plugin for Backstage's new frontend system.
*
* @remarks
* This package provides the new frontend system implementation of the home plugin,
* which offers customizable home pages with widget support and optional visit tracking.
*
* @packageDocumentation
*/
import {
coreExtensionData,
createExtensionDataRef,
createExtensionInput,
PageBlueprint,
NavItemBlueprint,
createFrontendPlugin,
createRouteRef,
AppRootElementBlueprint,
identityApiRef,
storageApiRef,
errorApiRef,
ApiBlueprint,
} from '@backstage/frontend-plugin-api';
import { VisitListener } from './components/';
import { visitsApiRef, VisitsStorageApi } from './api';
import { visitsApiRef, VisitsStorageApi, VisitsWebStorageApi } from './api';
import { titleExtensionDataRef } from '@backstage/plugin-home-react/alpha';
import HomeIcon from '@material-ui/icons/Home';
const rootRouteRef = createRouteRef();
/**
* @alpha
*/
export const titleExtensionDataRef = createExtensionDataRef<string>().with({
id: 'title',
});
const homePage = PageBlueprint.makeWithOverrides({
inputs: {
props: createExtensionInput(
@@ -68,6 +74,7 @@ const homePage = PageBlueprint.makeWithOverrides({
const visitListenerAppRootElement = AppRootElementBlueprint.make({
name: 'visit-listener',
disabled: true,
params: {
element: <VisitListener />,
},
@@ -75,28 +82,58 @@ const visitListenerAppRootElement = AppRootElementBlueprint.make({
const visitsApi = ApiBlueprint.make({
name: 'visits',
disabled: true,
params: defineParams =>
defineParams({
api: visitsApiRef,
deps: {
storageApi: storageApiRef,
identityApi: identityApiRef,
errorApi: errorApiRef,
},
factory: ({ storageApi, identityApi, errorApi }) => {
// Smart fallback: use custom storage API if available, otherwise localStorage
if (storageApi) {
return VisitsStorageApi.create({ storageApi, identityApi });
}
return VisitsWebStorageApi.create({ identityApi, errorApi });
},
factory: ({ storageApi, identityApi }) =>
VisitsStorageApi.create({ storageApi, identityApi }),
}),
});
const homeNavItem = NavItemBlueprint.make({
params: {
title: 'Home',
routeRef: rootRouteRef,
icon: HomeIcon,
},
});
/**
* Home plugin for the new frontend system.
*
* Provides core homepage functionality with optional visit tracking extensions.
* Visit tracking extensions are disabled by default and can be enabled via app-config.yaml.
*
* @alpha
*/
export default createFrontendPlugin({
pluginId: 'home',
info: { packageJson: () => import('../package.json') },
extensions: [homePage, visitsApi, visitListenerAppRootElement],
extensions: [homePage, homeNavItem, visitsApi, visitListenerAppRootElement],
routes: {
root: rootRouteRef,
},
});
export { homeTranslationRef } from './translation';
export {
HomepageBlueprint,
type HomepageBlueprintParams,
type HomepageTemplateProps,
type HomepageGridProps,
} from './alpha/HomepageBlueprint';
export {
type LayoutConfiguration,
type Breakpoint,
} from './components/CustomHomepage/types';
@@ -0,0 +1,115 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { compatWrapper } from '@backstage/core-compat-api';
import { Content } from '@backstage/core-components';
import {
coreExtensionData,
createExtensionBlueprint,
createExtensionInput,
ExtensionBoundary,
} from '@backstage/frontend-plugin-api';
import { Fragment, type ReactElement, type ReactNode } from 'react';
import { CustomHomepageGrid } from '../components';
import type { CustomHomepageGridProps } from '../components';
import { titleExtensionDataRef } from '@backstage/plugin-home-react/alpha';
/**
* Arguments provided to the homepage renderer.
*
* @alpha
*/
export interface HomepageTemplateProps {
/**
* React elements built from the installed homepage widgets.
*/
widgets: ReactNode[];
/**
* A <CustomHomepageGrid/> element that renders the widgets using the provided props.
*/
grid: ReactElement;
}
/**
* Parameters for creating a homepage extension.
*
* @alpha
*/
export interface HomepageBlueprintParams {
/**
* Optional title used by the home page when rendered through the new frontend system.
*/
title?: string;
/**
* Props forwarded to <CustomHomepageGrid/>. The `children` prop is managed by the blueprint.
*/
grid?: Omit<CustomHomepageGridProps, 'children'>;
/**
* Allows supplying a custom renderer for the homepage. Receives the generated widgets as well
* as a <CustomHomepageGrid/> element configured with the provided props.
*/
render?: (props: HomepageTemplateProps) => ReactElement;
}
const DEFAULT_ATTACH_POINT = Object.freeze({
id: 'page:home',
input: 'props',
});
/**
* Blueprint that composes a home page based on installed widgets.
*
* @alpha
*/
export const HomepageBlueprint = createExtensionBlueprint({
kind: 'home-page',
attachTo: DEFAULT_ATTACH_POINT,
output: [coreExtensionData.reactElement, titleExtensionDataRef.optional()],
inputs: {
widgets: createExtensionInput([coreExtensionData.reactElement]),
},
*factory(params: HomepageBlueprintParams = {}, { inputs, node }) {
const widgetOutputs = inputs.widgets ?? [];
const widgetElements = widgetOutputs.map((widget, index) => (
<Fragment key={index}>
{widget.get(coreExtensionData.reactElement)}
</Fragment>
));
const gridElement = (
<CustomHomepageGrid {...(params.grid ?? {})}>
{widgetElements}
</CustomHomepageGrid>
);
const renderedElement = params.render?.({
widgets: widgetElements,
grid: gridElement,
}) ?? <Content>{gridElement}</Content>;
yield coreExtensionData.reactElement(
<ExtensionBoundary node={node}>
{compatWrapper(renderedElement)}
</ExtensionBoundary>,
);
if (params.title) {
yield titleExtensionDataRef(params.title);
}
},
});
export type { CustomHomepageGridProps as HomepageGridProps } from '../components';
+6 -1
View File
@@ -146,7 +146,12 @@ export interface VisitsApi {
): Promise<Record<string, any>> | Record<string, any>;
}
/** @public */
/**
* API reference for the visits tracking service.
* Provides functionality to track and retrieve user page visit history.
*
* @public
*/
export const visitsApiRef = createApiRef<VisitsApi>({
id: 'homepage.visits',
});
+3
View File
@@ -16,6 +16,9 @@
import { createTranslationRef } from '@backstage/frontend-plugin-api';
/**
* Translation reference for the home plugin.
* Contains localized text strings for home page components and widgets.
*
* @alpha
*/
export const homeTranslationRef = createTranslationRef({
+4
View File
@@ -5897,6 +5897,7 @@ __metadata:
resolution: "@backstage/plugin-home-react@workspace:plugins/home-react"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-compat-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
@@ -5931,7 +5932,9 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/frontend-defaults": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"
"@backstage/plugin-home-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
@@ -30991,6 +30994,7 @@ __metadata:
"@backstage/plugin-catalog-unprocessed-entities": "workspace:^"
"@backstage/plugin-devtools": "workspace:^"
"@backstage/plugin-home": "workspace:^"
"@backstage/plugin-home-react": "workspace:^"
"@backstage/plugin-kubernetes": "workspace:^"
"@backstage/plugin-kubernetes-cluster": "workspace:^"
"@backstage/plugin-notifications": "workspace:^"