refactor(home): restructure extension architecture to match catalog pattern

Reorganize the home plugin's new frontend system extensions to follow
the same pattern as the catalog plugin's EntityContentLayoutBlueprint.
Key changes:
- Widgets now attach directly to page:home instead of through an
  intermediate HomepageBlueprint extension
- Add HomePageLayoutBlueprint in home-react for custom layout creation,
  with a built-in default layout as fallback
- Remove compatWrapper from HomepageWidgetBlueprint (consumer
  responsibility, reduced need with NFS support in old system)
- Remove HomepageCompositionRoot usage (old system artifact)
- Delete HomepageBlueprint (replaced by HomePageLayoutBlueprint)
- Update app-next example and dev harness to use new pattern
Signed-off-by: Adam Kunicki <kunickiaj@gmail.com>
This commit is contained in:
Adam Kunicki
2026-02-07 04:50:04 -08:00
parent 06e0a2cb22
commit 03fc07adb7
11 changed files with 289 additions and 265 deletions
+10 -14
View File
@@ -20,11 +20,8 @@ import notFoundErrorPage from './examples/notFoundErrorPageExtension';
import userSettingsPlugin from '@backstage/plugin-user-settings/alpha';
import homePlugin from '@backstage/plugin-home/alpha';
import {
coreExtensionData,
createExtension,
createFrontendModule,
} from '@backstage/frontend-plugin-api';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { HomePageLayoutBlueprint } from '@backstage/plugin-home-react/alpha';
import {
techdocsPlugin,
TechDocsIndexPage,
@@ -99,15 +96,14 @@ const convertedTechdocsPlugin = convertLegacyPlugin(techdocsPlugin, {
const customHomePageModule = createFrontendModule({
pluginId: 'home',
extensions: [
createExtension({
name: 'my-home-page',
attachTo: { id: 'page:home', input: 'props' },
output: [coreExtensionData.reactElement, coreExtensionData.title],
factory() {
return [
coreExtensionData.reactElement(homePage),
coreExtensionData.title('just a title'),
];
HomePageLayoutBlueprint.make({
params: {
loader: async () =>
// This is a legacy-style home page that renders static content.
// In production, you'd use the widgets prop to render dynamic widgets.
function LegacyHomePageLayout() {
return homePage;
},
},
}),
],
+39
View File
@@ -6,6 +6,7 @@
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 { ReactElement } from 'react';
import { RJSFSchema } from '@rjsf/utils';
import { TranslationRef } from '@backstage/frontend-plugin-api';
@@ -39,6 +40,44 @@ export type ComponentParts = {
ContextProvider?: (props: any) => JSX.Element;
};
// @alpha
export const HomePageLayoutBlueprint: ExtensionBlueprint<{
kind: 'home-page-layout';
params: HomePageLayoutBlueprintParams;
output: ExtensionDataRef<
(props: HomePageLayoutProps) => JSX_2.Element,
'home.layout.component',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
component: ConfigurableExtensionDataRef<
(props: HomePageLayoutProps) => JSX_2.Element,
'home.layout.component',
{}
>;
};
}>;
// @alpha
export interface HomePageLayoutBlueprintParams {
loader: () => Promise<(props: HomePageLayoutProps) => JSX_2.Element>;
}
// @alpha
export const homePageLayoutComponentDataRef: ConfigurableExtensionDataRef<
(props: HomePageLayoutProps) => JSX_2.Element,
'home.layout.component',
{}
>;
// @alpha
export interface HomePageLayoutProps {
widgets: Array<HomePageWidgetData>;
}
// @alpha
export const HomepageWidgetBlueprint: ExtensionBlueprint<{
kind: 'home-widget';
+6
View File
@@ -28,8 +28,14 @@ export {
HomepageWidgetBlueprint,
type HomepageWidgetBlueprintParams,
} from './alpha/blueprints/HomepageWidgetBlueprint';
export {
HomePageLayoutBlueprint,
type HomePageLayoutBlueprintParams,
} from './alpha/blueprints/HomePageLayoutBlueprint';
export {
homePageWidgetDataRef,
type HomePageWidgetData,
homePageLayoutComponentDataRef,
type HomePageLayoutProps,
} from './alpha/dataRefs';
export type { ComponentParts, CardLayout, CardSettings } from './extensions';
@@ -0,0 +1,64 @@
/*
* 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 { JSX } from 'react';
import {
createExtensionBlueprint,
ExtensionBoundary,
} from '@backstage/frontend-plugin-api';
import {
homePageLayoutComponentDataRef,
HomePageLayoutProps,
} from '../dataRefs';
/**
* Parameters for creating a home page layout extension.
*
* @alpha
*/
export interface HomePageLayoutBlueprintParams {
/**
* Async loader that returns the layout component.
* The component receives the collected widgets and renders them.
*/
loader: () => Promise<(props: HomePageLayoutProps) => JSX.Element>;
}
/**
* Blueprint for creating custom home page layouts.
*
* A layout receives the list of installed widgets and is responsible for
* arranging them on the home page. This follows the same pattern as
* `EntityContentLayoutBlueprint` in the catalog plugin.
*
* If no layout extension is installed, the home page uses a built-in default.
* Users can install their own layout to customize how widgets are arranged.
*
* @alpha
*/
export const HomePageLayoutBlueprint = createExtensionBlueprint({
kind: 'home-page-layout',
attachTo: { id: 'page:home', input: 'layouts' },
output: [homePageLayoutComponentDataRef],
dataRefs: {
component: homePageLayoutComponentDataRef,
},
*factory({ loader }: HomePageLayoutBlueprintParams, { node }) {
yield homePageLayoutComponentDataRef(
ExtensionBoundary.lazyComponent(node, loader),
);
},
});
@@ -15,7 +15,6 @@
*/
import { lazy, ReactElement } from 'react';
import { compatWrapper } from '@backstage/core-compat-api';
import {
createExtensionBlueprint,
ExtensionBoundary,
@@ -96,12 +95,11 @@ export const HomepageWidgetBlueprint = createExtensionBlueprint({
const Widget = (
props: CardExtensionProps<Record<string, unknown>>,
): ReactElement =>
compatWrapper(
<ExtensionBoundary node={node}>
<LazyCard {...props} />
</ExtensionBoundary>,
);
): ReactElement => (
<ExtensionBoundary node={node}>
<LazyCard {...props} />
</ExtensionBoundary>
);
yield homePageWidgetDataRef({
component: <Widget {...(params.componentProps ?? {})} />,
+28 -1
View File
@@ -15,7 +15,7 @@
*/
import { createExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ReactElement } from 'react';
import { JSX, ReactElement } from 'react';
import type { CardLayout, CardSettings } from '../extensions';
/**
@@ -64,3 +64,30 @@ export const homePageWidgetDataRef =
createExtensionDataRef<HomePageWidgetData>().with({
id: 'home.widget.data',
});
/**
* Props provided to a home page layout component.
*
* @alpha
*/
export interface HomePageLayoutProps {
/**
* The list of widget elements and metadata to render on the home page.
*/
widgets: Array<HomePageWidgetData>;
}
/**
* Extension data ref for home page layout components.
*
* A layout receives the collected widgets and is responsible for arranging
* them on the home page. This follows the same pattern as
* EntityContentLayoutBlueprint in the catalog plugin.
*
* @alpha
*/
export const homePageLayoutComponentDataRef = createExtensionDataRef<
(props: HomePageLayoutProps) => JSX.Element
>().with({
id: 'home.layout.component',
});
+33 -23
View File
@@ -21,17 +21,20 @@ 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 { Fragment } from 'react';
import {
ApiBlueprint,
createFrontendModule,
} from '@backstage/frontend-plugin-api';
import { HomepageWidgetBlueprint } from '@backstage/plugin-home-react/alpha';
import {
HomepageWidgetBlueprint,
HomePageLayoutBlueprint,
} from '@backstage/plugin-home-react/alpha';
import { HeaderWorldClock, WelcomeTitle, type ClockConfig } from '../src';
import homePlugin, {
HomepageBlueprint,
type HomepageGridProps,
} from '../src/alpha';
import homePlugin from '../src/alpha';
import { CustomHomepageGrid } from '../src/components';
import type { LayoutConfiguration } from '../src/components/CustomHomepage/types';
const clockConfigs: ClockConfig[] = [
{ label: 'NYC', timeZone: 'America/New_York' },
@@ -46,7 +49,7 @@ const timeFormat: Intl.DateTimeFormatOptions = {
hour12: false,
};
const defaultGridConfig: NonNullable<HomepageGridProps['config']> = [
const defaultGridConfig: LayoutConfiguration[] = [
{
component: 'HomePageToolkit',
x: 0,
@@ -72,23 +75,30 @@ const defaultGridConfig: NonNullable<HomepageGridProps['config']> = [
},
];
const homePage = HomepageBlueprint.make({
const homePageLayout = HomePageLayoutBlueprint.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>
),
loader: async () =>
function CustomHomePageLayout({ widgets }) {
return (
<Page themeId="home">
<Header title={<WelcomeTitle />} pageTitleOverride="Home">
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>
</Header>
<Content>
<CustomHomepageGrid config={defaultGridConfig}>
{widgets.map((widget, index) => (
<Fragment key={widget.name ?? index}>
{widget.component}
</Fragment>
))}
</CustomHomepageGrid>
</Content>
</Page>
);
},
},
});
@@ -163,7 +173,7 @@ const homePageRandomJokeWidget = HomepageWidgetBlueprint.make({
const homeDevModule = createFrontendModule({
pluginId: 'home',
extensions: [
homePage,
homePageLayout,
homePageToolkitWidget,
homePageStarredEntitiesWidget,
homePageRandomJokeWidget,
+23 -80
View File
@@ -7,18 +7,16 @@ 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 { HomePageLayoutProps } from '@backstage/plugin-home-react/alpha';
import { HomePageWidgetData } from '@backstage/plugin-home-react/alpha';
import { IconComponent } from '@backstage/frontend-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';
@@ -97,24 +95,27 @@ const _default: OverridableFrontendPlugin<
}
>;
inputs: {
props: ExtensionInput<
| ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>,
widgets: ExtensionInput<
ConfigurableExtensionDataRef<
HomePageWidgetData,
'home.widget.data',
{}
>,
{
singleton: true;
optional: true;
singleton: false;
optional: false;
internal: false;
}
>;
layouts: ExtensionInput<
ConfigurableExtensionDataRef<
(props: HomePageLayoutProps) => JSX_2.Element,
'home.layout.component',
{}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
@@ -132,69 +133,10 @@ const _default: OverridableFrontendPlugin<
>;
export default _default;
// @alpha
export const HomepageBlueprint: ExtensionBlueprint<{
kind: 'home-page';
params: HomepageBlueprintParams;
output:
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>;
inputs: {
widgets: ExtensionInput<
ConfigurableExtensionDataRef<HomePageWidgetData, 'home.widget.data', {}>,
{
singleton: false;
optional: false;
internal: 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',
{
readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!';
readonly 'addWidgetDialog.title': 'Add new widget to dashboard';
readonly 'customHomepageButtons.cancel': 'Cancel';
readonly 'customHomepageButtons.clearAll': 'Clear all';
@@ -203,10 +145,10 @@ export const homeTranslationRef: TranslationRef<
readonly 'customHomepageButtons.addWidget': 'Add widget';
readonly 'customHomepageButtons.save': 'Save';
readonly 'customHomepage.noWidgets': "No widgets added. Start by clicking the 'Add widget' button.";
readonly 'widgetSettingsOverlay.cancelButtonTitle': 'Cancel';
readonly 'widgetSettingsOverlay.editSettingsTooptip': 'Edit settings';
readonly 'widgetSettingsOverlay.deleteWidgetTooltip': 'Delete widget';
readonly 'widgetSettingsOverlay.submitButtonTitle': 'Submit';
readonly 'widgetSettingsOverlay.cancelButtonTitle': 'Cancel';
readonly 'starredEntityListItem.removeFavoriteEntityTitle': 'Remove entity from favorites';
readonly 'visitList.empty.title': 'There are no visits to show yet.';
readonly 'visitList.empty.description': 'Once you start using Backstage, your visits will appear here as a quick link to carry on where you left off.';
@@ -214,6 +156,7 @@ export const homeTranslationRef: TranslationRef<
readonly 'quickStart.title': 'Onboarding';
readonly 'quickStart.description': 'Get started with Backstage';
readonly 'quickStart.learnMoreLinkTitle': 'Learn more';
readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!';
readonly 'visitedByType.action.viewMore': 'View more';
readonly 'visitedByType.action.viewLess': 'View less';
readonly 'featuredDocsCard.empty.title': 'No documents to show';
+42 -25
View File
@@ -24,8 +24,8 @@
* @packageDocumentation
*/
import { lazy as reactLazy } from 'react';
import {
coreExtensionData,
createExtensionInput,
PageBlueprint,
NavItemBlueprint,
@@ -36,37 +36,60 @@ import {
storageApiRef,
errorApiRef,
ApiBlueprint,
ExtensionBoundary,
} from '@backstage/frontend-plugin-api';
import { VisitListener } from './components/';
import { visitsApiRef, VisitsStorageApi, VisitsWebStorageApi } from './api';
import HomeIcon from '@material-ui/icons/Home';
import {
homePageWidgetDataRef,
homePageLayoutComponentDataRef,
HomePageLayoutBlueprint,
type HomePageLayoutProps,
} from '@backstage/plugin-home-react/alpha';
const rootRouteRef = createRouteRef();
const homePage = PageBlueprint.makeWithOverrides({
inputs: {
props: createExtensionInput(
[
coreExtensionData.reactElement.optional(),
coreExtensionData.title.optional(),
],
{
singleton: true,
optional: true,
},
),
widgets: createExtensionInput([homePageWidgetDataRef]),
layouts: createExtensionInput([HomePageLayoutBlueprint.dataRefs.component]),
},
factory: (originalFactory, { inputs }) => {
factory(originalFactory, { node, inputs }) {
return originalFactory({
path: '/home',
routeRef: rootRouteRef,
loader: () =>
import('./components/').then(m => (
<m.HomepageCompositionRoot
children={inputs.props?.get(coreExtensionData.reactElement)}
title={inputs.props?.get(coreExtensionData.title)}
/>
)),
loader: async () => {
const LazyDefaultLayout = reactLazy(() =>
import('./alpha/DefaultHomePageLayout').then(m => ({
default: m.DefaultHomePageLayout,
})),
);
const DefaultLayoutComponent = (props: HomePageLayoutProps) => (
<ExtensionBoundary node={node}>
<LazyDefaultLayout {...props} />
</ExtensionBoundary>
);
const layouts = [
...inputs.layouts.map(layout => ({
Component: layout.get(homePageLayoutComponentDataRef),
})),
{
Component: DefaultLayoutComponent,
},
];
const widgets = inputs.widgets.map(widget =>
widget.get(homePageWidgetDataRef),
);
// Use the first installed layout, or fall back to the default
const layout = layouts[0];
return <layout.Component widgets={widgets} />;
},
});
},
});
@@ -126,12 +149,6 @@ export default createFrontendPlugin({
});
export { homeTranslationRef } from './translation';
export {
HomepageBlueprint,
type HomepageBlueprintParams,
type HomepageTemplateProps,
type HomepageGridProps,
} from './alpha/HomepageBlueprint';
export {
type LayoutConfiguration,
type Breakpoint,
@@ -0,0 +1,39 @@
/*
* 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 { Fragment } from 'react';
import { Content } from '@backstage/core-components';
import type { HomePageLayoutProps } from '@backstage/plugin-home-react/alpha';
import { CustomHomepageGrid } from '../components';
/**
* The default home page layout component.
*
* Renders widgets inside a `<CustomHomepageGrid/>` wrapped in `<Content/>`.
* This is used when no custom layout extension is installed.
*/
export function DefaultHomePageLayout(props: HomePageLayoutProps) {
const { widgets } = props;
return (
<Content>
<CustomHomepageGrid>
{widgets.map((widget, index) => (
<Fragment key={widget.name ?? index}>{widget.component}</Fragment>
))}
</CustomHomepageGrid>
</Content>
);
}
@@ -1,115 +0,0 @@
/*
* 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 { homePageWidgetDataRef } 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, coreExtensionData.title.optional()],
inputs: {
widgets: createExtensionInput([homePageWidgetDataRef]),
},
*factory(params: HomepageBlueprintParams = {}, { inputs, node }) {
const widgetOutputs = inputs.widgets ?? [];
const widgetElements = widgetOutputs.map((widget, index) => (
<Fragment key={index}>
{widget.get(homePageWidgetDataRef).component}
</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 coreExtensionData.title(params.title);
}
},
});
export type { CustomHomepageGridProps as HomepageGridProps } from '../components';