From 90956a61bdc85eca613184e78e66a5d12dfc5f2d Mon Sep 17 00:00:00 2001 From: Adam Kunicki Date: Mon, 22 Sep 2025 10:00:11 -0700 Subject: [PATCH] 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 --- .changeset/tangy-wasps-invent.md | 6 + docs/getting-started/homepage.md | 78 ++++++- packages/app-next/package.json | 1 + packages/app-next/src/App.tsx | 5 +- plugins/home-react/package.json | 1 + plugins/home-react/report-alpha.api.md | 88 +++++++- plugins/home-react/src/alpha.ts | 17 ++ .../blueprints/HomepageWidgetBlueprint.tsx | 128 +++++++++++ plugins/home-react/src/alpha/dataRefs.ts | 26 +++ plugins/home-react/src/extensions.tsx | 2 +- plugins/home-react/src/translation.ts | 3 + plugins/home/README.md | 165 ++++++++++++-- plugins/home/dev/index.tsx | 213 +++++++++++++++++- plugins/home/package.json | 2 + plugins/home/report-alpha.api.md | 107 ++++++++- plugins/home/report.api.md | 2 +- plugins/home/src/alpha.test.ts | 111 +++++++++ plugins/home/src/alpha.tsx | 61 ++++- plugins/home/src/alpha/HomepageBlueprint.tsx | 115 ++++++++++ plugins/home/src/api/VisitsApi.ts | 7 +- plugins/home/src/translation.ts | 3 + yarn.lock | 4 + 22 files changed, 1085 insertions(+), 60 deletions(-) create mode 100644 .changeset/tangy-wasps-invent.md create mode 100644 plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx create mode 100644 plugins/home-react/src/alpha/dataRefs.ts create mode 100644 plugins/home/src/alpha.test.ts create mode 100644 plugins/home/src/alpha/HomepageBlueprint.tsx diff --git a/.changeset/tangy-wasps-invent.md b/.changeset/tangy-wasps-invent.md new file mode 100644 index 0000000000..6b07726df0 --- /dev/null +++ b/.changeset/tangy-wasps-invent.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-home-react': patch +'@backstage/plugin-home': patch +--- + +Support new frontend system in the homepage plugin diff --git a/docs/getting-started/homepage.md b/docs/getting-started/homepage.md index 7fac59f728..8faedd67eb 100644 --- a/docs/getting-started/homepage.md +++ b/docs/getting-started/homepage.md @@ -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 diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 411142e8d4..b974969f6c 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -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:^", diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 3503805394..df63ccd5c5 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -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, diff --git a/plugins/home-react/package.json b/plugins/home-react/package.json index 298b690841..a5df160e73 100644 --- a/plugins/home-react/package.json +++ b/plugins/home-react/package.json @@ -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:^", diff --git a/plugins/home-react/report-alpha.api.md b/plugins/home-react/report-alpha.api.md index fad4c5a2bc..1689a91f73 100644 --- a/plugins/home-react/report-alpha.api.md +++ b/plugins/home-react/report-alpha.api.md @@ -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 + | 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; + components: () => Promise; + 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', + {} +>; ``` diff --git a/plugins/home-react/src/alpha.ts b/plugins/home-react/src/alpha.ts index 3a08699f66..b72ba1972b 100644 --- a/plugins/home-react/src/alpha.ts +++ b/plugins/home-react/src/alpha.ts @@ -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'; diff --git a/plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx b/plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx new file mode 100644 index 0000000000..553a6b3249 --- /dev/null +++ b/plugins/home-react/src/alpha/blueprints/HomepageWidgetBlueprint.tsx @@ -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; + /** + * 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; +} + +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>) => ( + + ), + })), + ); + + const Widget = ( + props: CardExtensionProps>, + ): ReactElement => + compatWrapper( + + + , + ); + + yield coreExtensionData.reactElement( + , + ); + + yield widgetMetadataRef({ + name: params.name, + title: params.title, + description: params.description, + layout: params.layout, + settings: params.settings, + }); + }, +}); diff --git a/plugins/home-react/src/alpha/dataRefs.ts b/plugins/home-react/src/alpha/dataRefs.ts new file mode 100644 index 0000000000..5c15f9b527 --- /dev/null +++ b/plugins/home-react/src/alpha/dataRefs.ts @@ -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().with({ + id: 'title', +}); diff --git a/plugins/home-react/src/extensions.tsx b/plugins/home-react/src/extensions.tsx index 7795e20c73..699a3f9c9f 100644 --- a/plugins/home-react/src/extensions.tsx +++ b/plugins/home-react/src/extensions.tsx @@ -121,7 +121,7 @@ type CardExtensionComponentProps = CardExtensionProps & overrideTitle?: string; }; -function CardExtension(props: CardExtensionComponentProps) { +export function CardExtension(props: CardExtensionComponentProps) { const { Renderer, Content, diff --git a/plugins/home-react/src/translation.ts b/plugins/home-react/src/translation.ts index 8de5bb1ff1..1a8fd9f1bf 100644 --- a/plugins/home-react/src/translation.ts +++ b/plugins/home-react/src/translation.ts @@ -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({ diff --git a/plugins/home/README.md b/plugins/home/README.md index 242e66ff40..10bebe6195 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -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 }) => ( + +
+ {grid} + + ), + }, +}); +``` + +#### 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: { diff --git a/plugins/home/dev/index.tsx b/plugins/home/dev/index.tsx index 341253a49e..edbcdc574b 100644 --- a/plugins/home/dev/index.tsx +++ b/plugins/home/dev/index.tsx @@ -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: , - 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 = [ + { + 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 }) => ( + +
} pageTitleOverride="Home"> + +
+ {grid} +
+ ), + }, +}); + +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: , + }, + ], + }, + }, +}); + +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); diff --git a/plugins/home/package.json b/plugins/home/package.json index 8121f340c0..f8e2162fbd 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -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", diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index 99e9b07463..7de07f408c 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -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; @@ -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; + }, + 'core.nav-item.target', + {} + >; + inputs: {}; + params: { + title: string; + icon: IconComponent; + routeRef: RouteRef; + }; + }>; '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 + | ExtensionDataRef< + string, + 'title', + { + optional: true; + } + >; + inputs: { + widgets: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: false; + optional: 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', { @@ -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) ``` diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md index 7b15ecd8a7..17870ed481 100644 --- a/plugins/home/report.api.md +++ b/plugins/home/report.api.md @@ -344,7 +344,7 @@ export type VisitsApiQueryParams = { }>; }; -// @public (undocumented) +// @public export const visitsApiRef: ApiRef; // @public diff --git a/plugins/home/src/alpha.test.ts b/plugins/home/src/alpha.test.ts new file mode 100644 index 0000000000..caa7d45cf7 --- /dev/null +++ b/plugins/home/src/alpha.test.ts @@ -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(); +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(); + }); + }); +}); diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index 6cad71d643..35a734c98d 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -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().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: , }, @@ -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'; diff --git a/plugins/home/src/alpha/HomepageBlueprint.tsx b/plugins/home/src/alpha/HomepageBlueprint.tsx new file mode 100644 index 0000000000..befc5b74fd --- /dev/null +++ b/plugins/home/src/alpha/HomepageBlueprint.tsx @@ -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 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, titleExtensionDataRef.optional()], + inputs: { + widgets: createExtensionInput([coreExtensionData.reactElement]), + }, + *factory(params: HomepageBlueprintParams = {}, { inputs, node }) { + const widgetOutputs = inputs.widgets ?? []; + const widgetElements = widgetOutputs.map((widget, index) => ( + + {widget.get(coreExtensionData.reactElement)} + + )); + + const gridElement = ( + + {widgetElements} + + ); + + const renderedElement = params.render?.({ + widgets: widgetElements, + grid: gridElement, + }) ?? {gridElement}; + + yield coreExtensionData.reactElement( + + {compatWrapper(renderedElement)} + , + ); + + if (params.title) { + yield titleExtensionDataRef(params.title); + } + }, +}); + +export type { CustomHomepageGridProps as HomepageGridProps } from '../components'; diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index 5cbd65fab3..3345128e08 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -146,7 +146,12 @@ export interface VisitsApi { ): Promise> | Record; } -/** @public */ +/** + * API reference for the visits tracking service. + * Provides functionality to track and retrieve user page visit history. + * + * @public + */ export const visitsApiRef = createApiRef({ id: 'homepage.visits', }); diff --git a/plugins/home/src/translation.ts b/plugins/home/src/translation.ts index 6e8ad2c511..480e2850b9 100644 --- a/plugins/home/src/translation.ts +++ b/plugins/home/src/translation.ts @@ -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({ diff --git a/yarn.lock b/yarn.lock index a057a759bf..1e15e4020b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"