diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 13bec94c98..2674493b8e 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -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; + }, }, }), ], diff --git a/plugins/home-react/report-alpha.api.md b/plugins/home-react/report-alpha.api.md index 9d21b69733..0fc6360e35 100644 --- a/plugins/home-react/report-alpha.api.md +++ b/plugins/home-react/report-alpha.api.md @@ -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; +} + // @alpha export const HomepageWidgetBlueprint: ExtensionBlueprint<{ kind: 'home-widget'; diff --git a/plugins/home-react/src/alpha.ts b/plugins/home-react/src/alpha.ts index 2766ef83a9..1a3432e02e 100644 --- a/plugins/home-react/src/alpha.ts +++ b/plugins/home-react/src/alpha.ts @@ -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'; diff --git a/plugins/home-react/src/alpha/blueprints/HomePageLayoutBlueprint.tsx b/plugins/home-react/src/alpha/blueprints/HomePageLayoutBlueprint.tsx new file mode 100644 index 0000000000..c1d4e94ffb --- /dev/null +++ b/plugins/home-react/src/alpha/blueprints/HomePageLayoutBlueprint.tsx @@ -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), + ); + }, +}); diff --git a/plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx b/plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx index 6e4c6f3c92..e3375f2cb9 100644 --- a/plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx +++ b/plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx @@ -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>, - ): ReactElement => - compatWrapper( - - - , - ); + ): ReactElement => ( + + + + ); yield homePageWidgetDataRef({ component: , diff --git a/plugins/home-react/src/alpha/dataRefs.ts b/plugins/home-react/src/alpha/dataRefs.ts index 00b221994f..2fc6ee30fa 100644 --- a/plugins/home-react/src/alpha/dataRefs.ts +++ b/plugins/home-react/src/alpha/dataRefs.ts @@ -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().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; +} + +/** + * 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', +}); diff --git a/plugins/home/dev/index.tsx b/plugins/home/dev/index.tsx index edbcdc574b..ce292c4ee5 100644 --- a/plugins/home/dev/index.tsx +++ b/plugins/home/dev/index.tsx @@ -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 = [ +const defaultGridConfig: LayoutConfiguration[] = [ { component: 'HomePageToolkit', x: 0, @@ -72,23 +75,30 @@ const defaultGridConfig: NonNullable = [ }, ]; -const homePage = HomepageBlueprint.make({ +const homePageLayout = HomePageLayoutBlueprint.make({ params: { - title: 'Home', - grid: { - config: defaultGridConfig, - }, - render: ({ grid }) => ( - -
} pageTitleOverride="Home"> - -
- {grid} -
- ), + loader: async () => + function CustomHomePageLayout({ widgets }) { + return ( + +
} pageTitleOverride="Home"> + +
+ + + {widgets.map((widget, index) => ( + + {widget.component} + + ))} + + +
+ ); + }, }, }); @@ -163,7 +173,7 @@ const homePageRandomJokeWidget = HomepageWidgetBlueprint.make({ const homeDevModule = createFrontendModule({ pluginId: 'home', extensions: [ - homePage, + homePageLayout, homePageToolkitWidget, homePageStarredEntitiesWidget, homePageRandomJokeWidget, diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index 416b6f6f40..5214736c1c 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -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 - | ExtensionDataRef< - string, - 'core.title', - { - optional: true; - } - >; - inputs: { - widgets: ExtensionInput< - ConfigurableExtensionDataRef, - { - singleton: false; - optional: false; - internal: false; - } - >; - }; - config: {}; - configInput: {}; - dataRefs: never; -}>; - -// @alpha -export interface HomepageBlueprintParams { - grid?: Omit; - render?: (props: HomepageTemplateProps) => ReactElement; - title?: string; -} - -// @public -export type HomepageGridProps = { - children?: ReactNode; - config?: LayoutConfiguration[]; - title?: string; - rowHeight?: number; - breakpoints?: Record; - cols?: Record; - containerPadding?: [number, number] | Record; - containerMargin?: [number, number] | Record; - 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'; diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index 9db31259dd..59e13213f2 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -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 => ( - - )), + loader: async () => { + const LazyDefaultLayout = reactLazy(() => + import('./alpha/DefaultHomePageLayout').then(m => ({ + default: m.DefaultHomePageLayout, + })), + ); + + const DefaultLayoutComponent = (props: HomePageLayoutProps) => ( + + + + ); + + 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 ; + }, }); }, }); @@ -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, diff --git a/plugins/home/src/alpha/DefaultHomePageLayout.tsx b/plugins/home/src/alpha/DefaultHomePageLayout.tsx new file mode 100644 index 0000000000..f8d63a635f --- /dev/null +++ b/plugins/home/src/alpha/DefaultHomePageLayout.tsx @@ -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 `` wrapped in ``. + * This is used when no custom layout extension is installed. + */ +export function DefaultHomePageLayout(props: HomePageLayoutProps) { + const { widgets } = props; + return ( + + + {widgets.map((widget, index) => ( + {widget.component} + ))} + + + ); +} diff --git a/plugins/home/src/alpha/HomepageBlueprint.tsx b/plugins/home/src/alpha/HomepageBlueprint.tsx deleted file mode 100644 index b72ff4d587..0000000000 --- a/plugins/home/src/alpha/HomepageBlueprint.tsx +++ /dev/null @@ -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 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 . The `children` prop is managed by the blueprint. - */ - grid?: Omit; - /** - * Allows supplying a custom renderer for the homepage. Receives the generated widgets as well - * as a 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) => ( - - {widget.get(homePageWidgetDataRef).component} - - )); - - const gridElement = ( - - {widgetElements} - - ); - - const renderedElement = params.render?.({ - widgets: widgetElements, - grid: gridElement, - }) ?? {gridElement}; - - yield coreExtensionData.reactElement( - - {compatWrapper(renderedElement)} - , - ); - - if (params.title) { - yield coreExtensionData.title(params.title); - } - }, -}); - -export type { CustomHomepageGridProps as HomepageGridProps } from '../components';