diff --git a/workspaces/homepage/.changeset/home-plugin-overrides.md b/workspaces/homepage/.changeset/home-plugin-overrides.md
new file mode 100644
index 00000000000..4b53a090a2f
--- /dev/null
+++ b/workspaces/homepage/.changeset/home-plugin-overrides.md
@@ -0,0 +1,5 @@
+---
+'@red-hat-developer-hub/backstage-plugin-homepage': minor
+---
+
+Give the homepage NFS plugin its own configurable page (`page:homepage`) so it works without community `@backstage/plugin-home`, and apply persona-based `homepage.defaultWidgets` filtering only on that page via homepage-backend. When `homepageHomeModule` is installed, the same widgets are also registered on community `page:home` (without the RH layout / homepage-backend filtering). Community `page:home` and `page:homepage` can be enabled or disabled independently via app-config.
diff --git a/workspaces/homepage/app-config.yaml b/workspaces/homepage/app-config.yaml
index b5b2195e5bd..e12cc8bd732 100644
--- a/workspaces/homepage/app-config.yaml
+++ b/workspaces/homepage/app-config.yaml
@@ -10,10 +10,17 @@ app:
# Disable the nav items that we're manually rendering in packages/app/src/modules/nav/Sidebar.tsx
- api:home/visits: true
- app-root-element:home/visit-listener: true
+ # Community home page (optional). Disable when using page:homepage alone.
- page:home:
+ config:
+ path: /home
+ title: Home
+ # Homepage-owned page (configurable path). Disable with page:homepage: false.
+ - page:homepage:
config:
path: /
- - home-page-layout:home/dynamic-homepage-layout:
+ title: Dynamic Homepage
+ - home-page-layout:homepage/dynamic-homepage-layout:
config:
customizable: true # false for read-only homepage layout
widgetLayout:
diff --git a/workspaces/homepage/e2e-tests/homepageCustomizable.test.ts b/workspaces/homepage/e2e-tests/homepageCustomizable.test.ts
index b460c06fc51..f7be900ad37 100644
--- a/workspaces/homepage/e2e-tests/homepageCustomizable.test.ts
+++ b/workspaces/homepage/e2e-tests/homepageCustomizable.test.ts
@@ -137,11 +137,11 @@ test.describe.serial('Dynamic Home Page Customization', () => {
test.describe('Persona-Based Homepages', () => {
test('Groups filters default widgets by persona', async ({ browser }) => {
- // The `if: groups:` condition in `homepage.defaultWidgets` is a legacy-only
- // feature — NFS does not implement group-based widget filtering.
+ // NFS applies `homepage.defaultWidgets` persona filtering in HomePageLayout,
+ // but this suite exercises the legacy `/customizable` mount-point page.
test.skip(
process.env.APP_MODE === 'nfs',
- '`if: groups:` filtering is not supported in NFS mode',
+ 'Persona e2e still targets the legacy /customizable route',
);
const loginUrl = '/customizable';
diff --git a/workspaces/homepage/packages/app/knip-report.md b/workspaces/homepage/packages/app/knip-report.md
index 4bb9eaf1b71..045762352c4 100644
--- a/workspaces/homepage/packages/app/knip-report.md
+++ b/workspaces/homepage/packages/app/knip-report.md
@@ -1,6 +1,6 @@
# Knip report
-## Unused dependencies (17)
+## Unused dependencies (16)
| Name | Location | Severity |
| :----------------------------------------------- | :---------------- | :------- |
@@ -18,7 +18,6 @@
| @backstage/plugin-techdocs | package.json:43:6 | error |
| @backstage/plugin-catalog | package.json:33:6 | error |
| @backstage/plugin-signals | package.json:42:6 | error |
-| @backstage/plugin-home | package.json:36:6 | error |
| @backstage/plugin-org | package.json:39:6 | error |
| react-router | package.json:53:6 | error |
diff --git a/workspaces/homepage/packages/app/src/App.tsx b/workspaces/homepage/packages/app/src/App.tsx
index 8eb7bbffc89..3a60352a2fa 100644
--- a/workspaces/homepage/packages/app/src/App.tsx
+++ b/workspaces/homepage/packages/app/src/App.tsx
@@ -18,7 +18,8 @@ import { createApp } from '@backstage/frontend-defaults';
import { navModule } from './modules/nav';
import { signInModule } from './modules/signIn';
import {
- homePageModule,
+ homepagePlugin,
+ homepageHomeModule,
homepageTranslationsModule,
} from '@red-hat-developer-hub/backstage-plugin-homepage';
import { rhdhThemeModule } from '@red-hat-developer-hub/backstage-plugin-theme/alpha';
@@ -28,7 +29,8 @@ export default createApp({
rhdhThemeModule,
navModule,
signInModule,
- homePageModule,
+ homepagePlugin,
+ homepageHomeModule,
homepageTranslationsModule,
],
});
diff --git a/workspaces/homepage/packages/app/src/modules/nav/Sidebar.tsx b/workspaces/homepage/packages/app/src/modules/nav/Sidebar.tsx
index eec1e8b7393..bc5b149eb81 100644
--- a/workspaces/homepage/packages/app/src/modules/nav/Sidebar.tsx
+++ b/workspaces/homepage/packages/app/src/modules/nav/Sidebar.tsx
@@ -48,6 +48,7 @@ export const SidebarContent = NavContentBlueprint.make({
}>
{nav.take('page:home')}
+ {nav.take('page:homepage')}
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
diff --git a/workspaces/homepage/plugins/homepage/README.md b/workspaces/homepage/plugins/homepage/README.md
index f817d96b976..37dda12c511 100644
--- a/workspaces/homepage/plugins/homepage/README.md
+++ b/workspaces/homepage/plugins/homepage/README.md
@@ -6,76 +6,68 @@ The plugin supports both the **New Frontend System (NFS)** and the **legacy** dy
## New Frontend System
-If you're using Backstage's new frontend system, add the plugin to your app:
+The homepage package is its **own** frontend plugin (`pluginId: homepage`) with its own page (`page:homepage`). It works **without** community `@backstage/plugin-home`.
+
+Widgets/layout attach to `page:homepage` on the homepage plugin. Persona-based defaults (`homepage.defaultWidgets` / homepage-backend) are applied only by that layout. When `homepageHomeModule` is installed, the same widgets are mirrored onto community `page:home` (NFS allows only one `attachTo` per extension), but community home keeps the upstream layout and does not call homepage-backend.
```tsx
// packages/app/src/App.tsx
import { createApp } from '@backstage/frontend-defaults';
import {
- homePageModule,
+ homepagePlugin,
+ homepageHomeModule, // optional: only if community home is also installed
homepageTranslationsModule,
} from '@red-hat-developer-hub/backstage-plugin-homepage';
export default createApp({
features: [
- // ... other plugins (nav, signIn, etc.)
- homePageModule,
+ homepagePlugin,
homepageTranslationsModule,
+ // homepageHomeModule, // optional when using community home alongside
],
});
```
-The plugin will automatically provide:
-
-- A homepage at `/home` (or the path configured via `page:home`)
-- Default widgets: Onboarding, Entity Catalog, Templates, Quick Access, Search, Recently Visited, Top Visited, and more
-- Customizable or read-only layout based on configuration, default layout being customizable
-
### Configuration
-Add the following to your `app-config.yaml`:
-
```yaml
app:
extensions:
- # Register the home page route (default: /)
- - page:home:
+ # Disable community home when using homepage alone (avoids two home pages)
+ - page:home: false
+
+ # Homepage-owned route (configurable)
+ - page:homepage:
config:
- path: /
- # Enable visit tracking (optional)
- - api:home/visits: true
- - app-root-element:home/visit-listener: true
- # Configure the dynamic homepage layout
- - home-page-layout:home/dynamic-homepage-layout:
+ path: / # or /home, /start, etc.
+
+ # Optional: disable homepage instead of community home
+ # - page:homepage: false
+
+ - home-page-layout:homepage/dynamic-homepage-layout:
config:
- customizable: true # or false for read-only layout
+ customizable: true
widgetLayout:
- RhdhTemplateSection:
- priority: 300 # priority is considered for only Read-only Grid layout
- breakpoints:
- xl: { w: 12, h: 5 }
- lg: { w: 12, h: 5 }
- # ... md, sm, xs, xxs
- RhdhEntitySection:
- priority: 200
- breakpoints:
- xl: { w: 12, h: 7 }
- # ...
- RhdhOnboardingSection:
- priority: 100
- breakpoints:
- xl: { w: 12, h: 6 }
- # ...
+ # keys match widget `name` / layout config
+ ...
```
-### Modules
+Visit tracking (for recently/top visited) still uses community home APIs when that package is installed:
+
+```yaml
+app:
+ extensions:
+ - api:home/visits: true
+ - app-root-element:home/visit-listener: true
+```
-The following modules are available from the primary package entry point:
+### Plugins / modules
-| Module | Description |
-| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
-| `homePageModule` (default) | Home page layout and widgets (Onboarding, Entity, Templates, Quick Access, Search, Recently Visited, Top Visited, etc.) |
-| `homepageTranslationsModule` | i18n translations (en, de, es, fr, it, ja) |
+| Export | Type | Description |
+| ---------------------------- | ---------------- | --------------------------------------------------------------------------- |
+| `homepagePlugin` (default) | `FrontendPlugin` | Own plugin with `page:homepage` + widgets/layout/APIs. |
+| `homepageHomeModule` | `FrontendModule` | Optional: mirror RH widgets onto `page:home`; disable toolkit/joke/starred. |
+| `homepageTranslationsModule` | `FrontendModule` | i18n translations |
`homepageTranslationsModule` (`pluginId: 'app'`) is also available as a dedicated Module Federation entry:
@@ -83,18 +75,10 @@ The following modules are available from the primary package entry point:
### Extensions
-The `homePageModule` extends the `home` plugin (`@backstage/plugin-home`) with:
-
-- `home-page-layout:home/dynamic-homepage-layout` – Custom layout with config-driven widget arrangement and priority
-- `home-page-widget:home/rhdh-onboarding-section` – Onboarding section
-- `home-page-widget:home/rhdh-entity-section` – Software catalog section
-- `home-page-widget:home/rhdh-template-section` – Templates section
-- `home-page-widget:home/quick-access-card` – Quick access card
-- `home-page-widget:home/search-bar` – Search bar
-- `home-page-widget:home/featured-docs-card` – Featured docs
-- `home-page-widget:home/recently-visited` – Recently visited
-- `home-page-widget:home/top-visited` – Top visited
-- `api:home/quickaccess` – Quick access API
+- `page:homepage` – Homepage route (config: `path`)
+- `home-page-layout:homepage/dynamic-homepage-layout` – persona filtering via homepage-backend (`page:homepage` only)
+- `home-page-widget:homepage/...` – Onboarding, Entity, Templates, Quick Access, Search, Featured docs, Recently/Top visited, Catalog starred (mirrored as `home-page-widget:home/...` via `homepageHomeModule`, without RH layout filtering)
+- `api:homepage/quickaccess`, `api:homepage/default-widgets`
## Legacy System (Dynamic Plugins)
diff --git a/workspaces/homepage/plugins/homepage/dev/index.tsx b/workspaces/homepage/plugins/homepage/dev/index.tsx
index cd8e2180f0c..b0e9a806aec 100644
--- a/workspaces/homepage/plugins/homepage/dev/index.tsx
+++ b/workspaces/homepage/plugins/homepage/dev/index.tsx
@@ -45,8 +45,10 @@ import {
catalogApiRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
+import homePlugin from '@backstage/plugin-home/alpha';
import {
- homePageModule,
+ homepagePlugin,
+ homepageHomeModule,
homepageTranslationsModule,
} from '@red-hat-developer-hub/backstage-plugin-homepage';
import { rhdhThemeModule } from '@red-hat-developer-hub/backstage-plugin-theme/alpha';
@@ -153,7 +155,9 @@ const app = createApp({
devNavModule,
catalogPlugin,
searchPlugin,
- homePageModule,
+ homePlugin,
+ homepagePlugin,
+ homepageHomeModule,
homepageTranslationsModule,
homepageApiMocksModule,
catalogDevModule,
diff --git a/workspaces/homepage/plugins/homepage/report-alpha.api.md b/workspaces/homepage/plugins/homepage/report-alpha.api.md
index bda2d3dab0e..cb2fc807c14 100644
--- a/workspaces/homepage/plugins/homepage/report-alpha.api.md
+++ b/workspaces/homepage/plugins/homepage/report-alpha.api.md
@@ -3,9 +3,313 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
+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 { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
+import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
+import { ExtensionInput } from '@backstage/frontend-plugin-api';
+import { FrontendModule } from '@backstage/frontend-plugin-api';
+import { HomePageLayoutBlueprintParams } from '@backstage/plugin-home-react/alpha';
+import { HomePageLayoutProps } from '@backstage/plugin-home-react/alpha';
+import { HomePageWidgetBlueprintParams } from '@backstage/plugin-home-react/alpha';
+import { HomePageWidgetData } from '@backstage/plugin-home-react/alpha';
+import { IconElement } 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 { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/frontend-plugin-api';
import { TranslationResource } from '@backstage/frontend-plugin-api';
+// @alpha
+const homepageHomeModule: FrontendModule;
+export { homepageHomeModule as homePageModule };
+export { homepageHomeModule };
+
+// @alpha
+const homepagePlugin: OverridableFrontendPlugin<
+ {
+ root: RouteRef;
+ },
+ {},
+ {
+ 'api:homepage/default-widgets': OverridableExtensionDefinition<{
+ kind: 'api';
+ name: 'default-widgets';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: <
+ TApi,
+ TImpl extends TApi,
+ TDeps extends { [name in string]: unknown },
+ >(
+ params: ApiFactory,
+ ) => ExtensionBlueprintParams;
+ }>;
+ 'api:homepage/quickaccess': OverridableExtensionDefinition<{
+ kind: 'api';
+ name: 'quickaccess';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: <
+ TApi,
+ TImpl extends TApi,
+ TDeps extends { [name in string]: unknown },
+ >(
+ params: ApiFactory,
+ ) => ExtensionBlueprintParams;
+ }>;
+ 'home-page-layout:homepage/dynamic-homepage-layout': OverridableExtensionDefinition<{
+ config: {
+ customizable: boolean | undefined;
+ widgetLayout:
+ | Record<
+ string,
+ {
+ priority?: number | undefined;
+ breakpoints?:
+ | Record<
+ string,
+ {
+ w?: number | undefined;
+ h?: number | undefined;
+ x?: number | undefined;
+ y?: number | undefined;
+ }
+ >
+ | undefined;
+ }
+ >
+ | undefined;
+ };
+ configInput: {
+ customizable?: boolean | undefined;
+ widgetLayout?:
+ | Record<
+ string,
+ {
+ priority?: number | undefined;
+ breakpoints?:
+ | Record<
+ string,
+ {
+ w?: number | undefined;
+ h?: number | undefined;
+ x?: number | undefined;
+ y?: number | undefined;
+ }
+ >
+ | undefined;
+ }
+ >
+ | undefined;
+ };
+ output: ExtensionDataRef<
+ (props: HomePageLayoutProps) => JSX_2.Element,
+ 'home.layout.component',
+ {}
+ >;
+ inputs: {};
+ kind: 'home-page-layout';
+ name: 'dynamic-homepage-layout';
+ params: HomePageLayoutBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/catalog-starred-entities-card': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'catalog-starred-entities-card';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/featured-docs-card': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'featured-docs-card';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/quickaccess-card': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'quickaccess-card';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/recently-visited-card': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'recently-visited-card';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/rhdh-entity-section': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'rhdh-entity-section';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/rhdh-onboarding-section': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'rhdh-onboarding-section';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/rhdh-template-section': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'rhdh-template-section';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/search-bar': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'search-bar';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'home-page-widget:homepage/top-visited-card': OverridableExtensionDefinition<{
+ kind: 'home-page-widget';
+ name: 'top-visited-card';
+ config: {};
+ configInput: {};
+ output: ExtensionDataRef;
+ inputs: {};
+ params: HomePageWidgetBlueprintParams;
+ }>;
+ 'page:homepage': OverridableExtensionDefinition<{
+ config: {
+ path: string | undefined;
+ title: string | undefined;
+ };
+ configInput: {
+ path?: string | undefined | undefined;
+ title?: string | undefined | undefined;
+ };
+ output:
+ | ExtensionDataRef
+ | ExtensionDataRef<
+ RouteRef,
+ 'core.routing.ref',
+ {
+ optional: true;
+ }
+ >
+ | ExtensionDataRef
+ | ExtensionDataRef<
+ string,
+ 'core.title',
+ {
+ optional: true;
+ }
+ >
+ | ExtensionDataRef<
+ IconElement,
+ 'core.icon',
+ {
+ optional: true;
+ }
+ >;
+ inputs: {
+ pages: ExtensionInput<
+ | ConfigurableExtensionDataRef
+ | ConfigurableExtensionDataRef
+ | ConfigurableExtensionDataRef<
+ RouteRef,
+ 'core.routing.ref',
+ {
+ optional: true;
+ }
+ >
+ | ConfigurableExtensionDataRef<
+ string,
+ 'core.title',
+ {
+ optional: true;
+ }
+ >
+ | ConfigurableExtensionDataRef<
+ IconElement,
+ 'core.icon',
+ {
+ optional: true;
+ }
+ >,
+ {
+ singleton: false;
+ optional: false;
+ internal: false;
+ }
+ >;
+ widgets: ExtensionInput<
+ ConfigurableExtensionDataRef<
+ HomePageWidgetData,
+ 'home.widget.data',
+ {}
+ >,
+ {
+ singleton: false;
+ optional: false;
+ internal: false;
+ }
+ >;
+ layout: ExtensionInput<
+ ConfigurableExtensionDataRef<
+ (props: HomePageLayoutProps) => JSX_2.Element,
+ 'home.layout.component',
+ {}
+ >,
+ {
+ singleton: true;
+ optional: true;
+ internal: true;
+ }
+ >;
+ };
+ kind: 'page';
+ name: undefined;
+ params: {
+ path: string;
+ title?: string;
+ icon?: IconElement;
+ loader?: () => Promise;
+ routeRef?: RouteRef;
+ noHeader?: boolean;
+ };
+ }>;
+ }
+>;
+export default homepagePlugin;
+export { homepagePlugin as homePagePlugin };
+export { homepagePlugin };
+
+// @alpha
+export const homepageRouteRef: RouteRef;
+
// @public
export const homepageTranslationRef: TranslationRef<
'plugin.homepage',
diff --git a/workspaces/homepage/plugins/homepage/src/components/HomePageLayout.tsx b/workspaces/homepage/plugins/homepage/src/components/HomePageLayout.tsx
index 192ac2de60c..44b75b5af8c 100644
--- a/workspaces/homepage/plugins/homepage/src/components/HomePageLayout.tsx
+++ b/workspaces/homepage/plugins/homepage/src/components/HomePageLayout.tsx
@@ -14,36 +14,58 @@
* limitations under the License.
*/
-import { Content, EmptyState, Page } from '@backstage/core-components';
+import {
+ Content,
+ EmptyState,
+ Page,
+ Progress,
+} from '@backstage/core-components';
+import { useMemo } from 'react';
import { useTranslation } from '../hooks/useTranslation';
+import { useDefaultWidgets } from '../legacy/hooks/useDefaultWidgets';
import { HeaderProps, Header } from './Header';
import { ReadOnlyGridLayout } from './ReadOnlyGridLayout';
import { CustomizableGridLayout } from './CustomizableGridLayout';
import { HomePageCardConfig } from '../types';
+import { applyDefaultWidgetsToNfsWidgets } from './applyDefaultWidgets';
/**
* Props for the NFS home page layout component.
*/
export interface HomePageProps extends HeaderProps {
widgets: HomePageCardConfig[];
- customizable: boolean;
+ customizable?: boolean;
}
/**
* NFS home page layout that renders widgets in a read-only or customizable grid.
- * Used by the dynamic-homepage-layout extension.
*
+ * When `homepage.defaultWidgets` is available from the backend, applies the same
+ * persona / RBAC filtering as the legacy homepage (`if` / `unless` / `tags`).
*/
-export const HomePageLayout = ({ widgets, customizable }: HomePageProps) => {
+export const HomePageLayout = ({
+ widgets,
+ customizable = true,
+}: HomePageProps) => {
const { t } = useTranslation();
+ const { defaultWidgets, loading } = useDefaultWidgets();
+
+ const visibleWidgets = useMemo(() => {
+ if (!defaultWidgets) {
+ return widgets;
+ }
+ return applyDefaultWidgetsToNfsWidgets(widgets, defaultWidgets);
+ }, [widgets, defaultWidgets]);
let content: React.ReactNode;
- if (widgets.length === 0) {
+ if (loading) {
+ content = ;
+ } else if (visibleWidgets.length === 0) {
content = ;
} else if (customizable) {
- content = ;
+ content = ;
} else {
- content = ;
+ content = ;
}
return (
diff --git a/workspaces/homepage/plugins/homepage/src/components/applyDefaultWidgets.test.ts b/workspaces/homepage/plugins/homepage/src/components/applyDefaultWidgets.test.ts
new file mode 100644
index 00000000000..ada99c4bf34
--- /dev/null
+++ b/workspaces/homepage/plugins/homepage/src/components/applyDefaultWidgets.test.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * 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 type { AppNode } from '@backstage/frontend-plugin-api';
+import {
+ applyDefaultWidgetsToNfsWidgets,
+ getHomepageWidgetExtensionName,
+} from './applyDefaultWidgets';
+import type { HomePageCardConfig } from '../types';
+
+function widget(
+ extensionName: string,
+ extras: Partial = {},
+): HomePageCardConfig {
+ return {
+ node: {
+ spec: { id: `home-page-widget:homepage/${extensionName}` },
+ } as AppNode,
+ component: null as unknown as React.ReactElement,
+ name: extensionName,
+ ...extras,
+ };
+}
+
+describe('applyDefaultWidgetsToNfsWidgets', () => {
+ it('filters and orders by visible defaultWidgets refs', () => {
+ const widgets = [
+ widget('rhdh-onboarding-section'),
+ widget('rhdh-entity-section'),
+ widget('featured-docs-card'),
+ ];
+
+ const result = applyDefaultWidgetsToNfsWidgets(widgets, [
+ { id: 'featured', ref: 'featured-docs-card' },
+ { id: 'onboarding', ref: 'rhdh-onboarding-section' },
+ ]);
+
+ expect(result.map(getHomepageWidgetExtensionName)).toEqual([
+ 'featured-docs-card',
+ 'rhdh-onboarding-section',
+ ]);
+ });
+
+ it('matches defaultWidgets refs to NFS extension names', () => {
+ const widgets = [
+ widget('quickaccess-card'),
+ widget('recently-visited-card'),
+ ];
+
+ const result = applyDefaultWidgetsToNfsWidgets(widgets, [
+ { id: 'qa', ref: 'quickaccess-card' },
+ { id: 'recent', ref: 'recently-visited-card' },
+ ]);
+
+ expect(result.map(getHomepageWidgetExtensionName)).toEqual([
+ 'quickaccess-card',
+ 'recently-visited-card',
+ ]);
+ });
+
+ it('applies layout from defaultWidgets', () => {
+ const widgets = [widget('rhdh-entity-section')];
+ const result = applyDefaultWidgetsToNfsWidgets(widgets, [
+ {
+ id: 'entity-list',
+ ref: 'rhdh-entity-section',
+ layout: { xl: { w: 12, h: 7 } },
+ },
+ ]);
+
+ expect(result[0].breakpointLayouts).toEqual({ xl: { w: 12, h: 7 } });
+ });
+});
diff --git a/workspaces/homepage/plugins/homepage/src/components/applyDefaultWidgets.ts b/workspaces/homepage/plugins/homepage/src/components/applyDefaultWidgets.ts
new file mode 100644
index 00000000000..3eb59e9858a
--- /dev/null
+++ b/workspaces/homepage/plugins/homepage/src/components/applyDefaultWidgets.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * 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 type { VisibleDefaultWidget } from '../api/DefaultWidgetsApiClient';
+import type { HomePageCardConfig, Breakpoint, Layout } from '../types';
+
+/**
+ * Extension name from `home-page-widget:/`.
+ */
+export function getHomepageWidgetExtensionName(
+ widget: HomePageCardConfig,
+): string {
+ const id = widget.node?.spec?.id ?? '';
+ const fromId = id.includes('/') ? id.split('/').pop() : undefined;
+ return fromId || widget.name || '';
+}
+
+/**
+ * Filter/order NFS widgets using backend persona visibility (`defaultWidgets`).
+ * Applies per-widget layouts from the visible default-widget entries.
+ */
+export function applyDefaultWidgetsToNfsWidgets(
+ widgets: HomePageCardConfig[],
+ defaultWidgets: VisibleDefaultWidget[],
+): HomePageCardConfig[] {
+ const byExtensionName = new Map();
+ for (const widget of widgets) {
+ const name = getHomepageWidgetExtensionName(widget);
+ if (name) {
+ byExtensionName.set(name, widget);
+ }
+ }
+
+ const result: HomePageCardConfig[] = [];
+
+ for (const defaultWidget of defaultWidgets) {
+ const match = byExtensionName.get(defaultWidget.ref);
+ if (!match) {
+ continue;
+ }
+
+ const layout = defaultWidget.layout as Record | undefined;
+
+ result.push({
+ ...match,
+ breakpointLayouts: layout
+ ? (layout as Record)
+ : match.breakpointLayouts,
+ });
+ }
+
+ return result;
+}
+
+/** @internal exported for tests */
+export function widgetMatchesDefaultRef(
+ widget: HomePageCardConfig,
+ ref: string,
+): boolean {
+ return getHomepageWidgetExtensionName(widget) === ref;
+}
diff --git a/workspaces/homepage/plugins/homepage/src/extensions/homePageCards.tsx b/workspaces/homepage/plugins/homepage/src/extensions/homePageCards.tsx
index 90137ffb86c..101ff41d759 100644
--- a/workspaces/homepage/plugins/homepage/src/extensions/homePageCards.tsx
+++ b/workspaces/homepage/plugins/homepage/src/extensions/homePageCards.tsx
@@ -14,21 +14,21 @@
* limitations under the License.
*/
-import { HomePageWidgetBlueprint } from '@backstage/plugin-home-react/alpha';
+import {
+ HomePageWidgetBlueprint,
+ type HomePageWidgetBlueprintParams,
+} from '@backstage/plugin-home-react/alpha';
import homePlugin from '@backstage/plugin-home/alpha';
import { compatWrapper } from '@backstage/core-compat-api';
import { createTranslatedCardRenderer } from '../utils/translatedCardRenderer';
+import { homepageWidgetAttachTo } from './homepageAttach';
/**
* NFS homepage widgets.
*
- * i18n follows the upstream `@backstage/plugin-home` model:
- * - Blueprint `params.title` / `description` are English catalog labels.
- * `AddWidgetDialog` renders them as-is (no `t()`).
- * - On-card headers are translated at render time via
- * {@link createTranslatedCardRenderer} or Content hooks such as
- * `useHomePageCardTitle`.
- * - In-widget copy uses `homepageTranslationRef` through `useTranslation`.
+ * NFS allows only one `attachTo` per extension. To show widgets on both
+ * `page:homepage` and community `page:home`, each widget is registered twice.
+ * Persona filtering (homepage-backend) applies only on `page:homepage`.
*/
const defaultCardLayout = {
@@ -44,192 +44,175 @@ const defaultCardLayout = {
},
} as const;
-/**
- * NFS widget: OnboardingSection (migrated from mountPoint home.page/cards).
- */
-export const onboardingSectionWidget = HomePageWidgetBlueprint.make({
- name: 'rhdh-onboarding-section',
- params: {
- name: 'Red Hat Developer Hub - Onboarding',
- layout: defaultCardLayout,
- components: () =>
- import('../components/OnboardingSection/OnboardingSection').then(m => ({
- Content: m.OnboardingSectionContent,
- })),
- },
+function makeDualHomeWidgets(
+ name: TName,
+ params: HomePageWidgetBlueprintParams,
+) {
+ return {
+ homepage: HomePageWidgetBlueprint.make({
+ attachTo: homepageWidgetAttachTo,
+ name,
+ params,
+ }),
+ community: HomePageWidgetBlueprint.make({
+ name,
+ params,
+ }),
+ };
+}
+
+const upstreamHomeCardRenderer = ({
+ Content,
+}: {
+ Content: React.ComponentType;
+}) => ;
+
+const onboarding = makeDualHomeWidgets('rhdh-onboarding-section', {
+ name: 'Red Hat Developer Hub - Onboarding',
+ layout: defaultCardLayout,
+ components: () =>
+ import('../components/OnboardingSection/OnboardingSection').then(m => ({
+ Content: m.OnboardingSectionContent,
+ })),
});
-/**
- * NFS widget: EntitySection (migrated from mountPoint home.page/cards).
- */
-export const entitySectionWidget = HomePageWidgetBlueprint.make({
- name: 'rhdh-entity-section',
- params: {
- name: 'Red Hat Developer Hub - Software Catalog',
- description:
- 'Browse the Systems, Components, Resources, and APIs that are available in your organization.',
- layout: defaultCardLayout,
- components: () =>
- import('../components/EntitySection/EntitySection').then(m => ({
- Content: m.EntitySectionContent,
- Renderer: createTranslatedCardRenderer('entities.title'),
- })),
- },
+const entity = makeDualHomeWidgets('rhdh-entity-section', {
+ name: 'Red Hat Developer Hub - Software Catalog',
+ description:
+ 'Browse the Systems, Components, Resources, and APIs that are available in your organization.',
+ layout: defaultCardLayout,
+ components: () =>
+ import('../components/EntitySection/EntitySection').then(m => ({
+ Content: m.EntitySectionContent,
+ Renderer: createTranslatedCardRenderer('entities.title'),
+ })),
});
-/**
- * NFS widget: TemplateSection (migrated from mountPoint home.page/cards).
- */
-export const templateSectionWidget = HomePageWidgetBlueprint.make({
- name: 'rhdh-template-section',
- params: {
- name: 'Red Hat Developer Hub - Explore templates',
- layout: defaultCardLayout,
- components: () =>
- import('../components/TemplateSection/TemplateSection').then(m => ({
- Content: m.TemplateSectionContent,
- Renderer: createTranslatedCardRenderer('templates.title'),
- })),
- },
+const template = makeDualHomeWidgets('rhdh-template-section', {
+ name: 'Red Hat Developer Hub - Explore templates',
+ layout: defaultCardLayout,
+ components: () =>
+ import('../components/TemplateSection/TemplateSection').then(m => ({
+ Content: m.TemplateSectionContent,
+ Renderer: createTranslatedCardRenderer('templates.title'),
+ })),
});
-/**
- * NFS widget: QuickAccessCard (migrated from mountPoint home.page/cards).
- */
-export const quickAccessCardWidget = HomePageWidgetBlueprint.make({
- name: 'quick-access-card',
- params: {
- name: 'Quick Access Card',
- title: 'Quick Access',
- layout: defaultCardLayout,
- components: () =>
- import('../components/QuickAccessCard').then(m => ({
- Content: m.QuickAccessCardContent,
- Renderer: createTranslatedCardRenderer('quickAccess.title', {
- quickAccessStyle: true,
- }),
- })),
- },
+const quickAccess = makeDualHomeWidgets('quickaccess-card', {
+ name: 'Quick Access Card',
+ title: 'Quick Access',
+ layout: defaultCardLayout,
+ components: () =>
+ import('../components/QuickAccessCard').then(m => ({
+ Content: m.QuickAccessCardContent,
+ Renderer: createTranslatedCardRenderer('quickAccess.title', {
+ quickAccessStyle: true,
+ }),
+ })),
});
-/**
- * NFS widget: SearchBar (migrated from mountPoint home.page/cards).
- */
-export const searchBarWidget = HomePageWidgetBlueprint.make({
- name: 'search-bar',
- params: {
- name: 'Search',
- layout: {
- ...defaultCardLayout,
- height: {
- ...defaultCardLayout.height,
- defaultRows: 2,
- minRows: 1,
- maxRows: 1,
- },
+const searchBar = makeDualHomeWidgets('search-bar', {
+ name: 'Search',
+ layout: {
+ ...defaultCardLayout,
+ height: {
+ ...defaultCardLayout.height,
+ defaultRows: 2,
+ minRows: 1,
+ maxRows: 1,
},
- components: () =>
- import('../components/SearchBar').then(m => ({
- Content: m.SearchBar,
- Renderer: ({ Content }: { Content: React.ComponentType }) =>
- compatWrapper(),
- })),
},
+ components: () =>
+ import('../components/SearchBar').then(m => ({
+ Content: m.SearchBar,
+ Renderer: ({ Content }: { Content: React.ComponentType }) =>
+ compatWrapper(),
+ })),
});
-/**
- * Renders upstream home cards that include their own InfoCard shell.
- */
-const upstreamHomeCardRenderer = ({
- Content,
-}: {
- Content: React.ComponentType;
-}) => ;
+const featuredDocs = makeDualHomeWidgets('featured-docs-card', {
+ name: 'Featured docs',
+ title: 'Featured Docs',
+ layout: defaultCardLayout,
+ components: () =>
+ import('../components/FeaturedDocsCard').then(m => ({
+ Content: m.FeaturedDocsCard,
+ Renderer: upstreamHomeCardRenderer,
+ })),
+});
-/**
- * NFS widget: FeaturedDocsCard (migrated from mountPoint home.page/cards).
- */
-export const featuredDocsCardWidget = HomePageWidgetBlueprint.make({
- name: 'featured-docs-card',
- params: {
- name: 'Featured docs',
- title: 'Featured Docs',
- layout: defaultCardLayout,
- components: () =>
- import('../components/FeaturedDocsCard').then(m => ({
- Content: m.FeaturedDocsCard,
- Renderer: upstreamHomeCardRenderer,
- })),
- },
+const catalogStarred = makeDualHomeWidgets('catalog-starred-entities-card', {
+ name: 'Catalog starred',
+ title: 'Starred Catalog Entities',
+ layout: defaultCardLayout,
+ components: () =>
+ import('../components/TranslatedUpstreamHomePageCards').then(m => ({
+ Content: m.CatalogStarredEntitiesCard,
+ Renderer: upstreamHomeCardRenderer,
+ })),
});
-/**
- * NFS widget: CatalogStarred (migrated from mountPoint home.page/cards).
- */
-export const catalogStarredWidget = homePlugin
+const recentlyVisited = makeDualHomeWidgets('recently-visited-card', {
+ layout: defaultCardLayout,
+ name: 'Recently visited',
+ title: 'Recently Visited',
+ description: 'Quick access to recently viewed entities and pages',
+ components: () =>
+ import('../components/TranslatedUpstreamHomePageCards').then(m => ({
+ Content: m.RecentlyVisitedCard,
+ Renderer: upstreamHomeCardRenderer,
+ })),
+});
+
+const topVisited = makeDualHomeWidgets('top-visited-card', {
+ layout: defaultCardLayout,
+ name: 'Top visited',
+ title: 'Top Visited',
+ description: 'Your most frequently accessed entities and services',
+ components: () =>
+ import('../components/TranslatedUpstreamHomePageCards').then(m => ({
+ Content: m.TopVisitedCard,
+ Renderer: upstreamHomeCardRenderer,
+ })),
+});
+
+export const onboardingSectionWidget = onboarding.homepage;
+export const entitySectionWidget = entity.homepage;
+export const templateSectionWidget = template.homepage;
+export const quickAccessCardWidget = quickAccess.homepage;
+export const searchBarWidget = searchBar.homepage;
+export const featuredDocsCardWidget = featuredDocs.homepage;
+export const catalogStarredWidget = catalogStarred.homepage;
+export const RecentlyVisitedWidget = recentlyVisited.homepage;
+export const TopVisitedWidget = topVisited.homepage;
+
+/** RH widget twins for community `page:home` (via `homepageHomeModule`). */
+export const communityHomeWidgets = [
+ onboarding.community,
+ entity.community,
+ template.community,
+ quickAccess.community,
+ featuredDocs.community,
+ searchBar.community,
+ topVisited.community,
+ recentlyVisited.community,
+ catalogStarred.community,
+];
+
+export const overrideHomeCatalogStarredWidget = homePlugin
.getExtension('home-page-widget:home/starred-entities')
.override({
- params: {
- name: 'Catalog starred',
- title: 'Starred Catalog Entities',
- components: () =>
- import('../components/TranslatedUpstreamHomePageCards').then(m => ({
- Content: m.CatalogStarredEntitiesCard,
- Renderer: upstreamHomeCardRenderer,
- })),
- },
+ disabled: true,
});
-/**
- * Disables the default home plugin toolkit widget.
- */
export const disableToolkit = homePlugin
.getExtension('home-page-widget:home/toolkit')
.override({
disabled: true,
});
-/**
- * Disables the upstream demo random-joke widget.
- */
export const disableRandomJoke = homePlugin
.getExtension('home-page-widget:home/random-joke')
.override({
disabled: true,
});
-
-/**
- * NFS widget: RecentlyVisited (migrated from mountPoint home.page/cards).
- */
-export const RecentlyVisitedWidget = HomePageWidgetBlueprint.make({
- name: 'recently-visited',
- params: {
- layout: defaultCardLayout,
- name: 'Recently visited',
- title: 'Recently Visited',
- description: 'Quick access to recently viewed entities and pages',
- components: () =>
- import('../components/TranslatedUpstreamHomePageCards').then(m => ({
- Content: m.RecentlyVisitedCard,
- Renderer: upstreamHomeCardRenderer,
- })),
- },
-});
-
-/**
- * NFS widget: TopVisited (migrated from mountPoint home.page/cards).
- */
-export const TopVisitedWidget = HomePageWidgetBlueprint.make({
- name: 'top-visited',
- params: {
- layout: defaultCardLayout,
- name: 'Top visited',
- title: 'Top Visited',
- description: 'Your most frequently accessed entities and services',
- components: () =>
- import('../components/TranslatedUpstreamHomePageCards').then(m => ({
- Content: m.TopVisitedCard,
- Renderer: upstreamHomeCardRenderer,
- })),
- },
-});
diff --git a/workspaces/homepage/plugins/homepage/src/extensions/homePageLayoutExtension.tsx b/workspaces/homepage/plugins/homepage/src/extensions/homePageLayoutExtension.tsx
index d9accc22429..e992f45ef71 100644
--- a/workspaces/homepage/plugins/homepage/src/extensions/homePageLayoutExtension.tsx
+++ b/workspaces/homepage/plugins/homepage/src/extensions/homePageLayoutExtension.tsx
@@ -17,20 +17,18 @@
import { HomePageLayoutBlueprint } from '@backstage/plugin-home-react/alpha';
import { z } from 'zod';
import { HomePageCardConfig } from '../types';
+import { homepageLayoutAttachTo } from './homepageAttach';
/**
- * Custom home page layout extension for the New Frontend System.
- *
- * Config-driven layout with `widgetLayout` (priority, breakpoints per widget),
- * supports both customizable (drag/drop) and read-only modes.
- *
- * The layout component is loaded via dynamic `import()` inside the async
- * loader so it stays out of the Module Federation sync chunk graph.
+ * Custom home page layout for `page:homepage` only.
*
+ * Applies persona-based `homepage.defaultWidgets` filtering via HomePageLayout
+ * (homepage-backend). Community `page:home` keeps the upstream default layout.
*/
export const homePageLayoutExtension =
HomePageLayoutBlueprint.makeWithOverrides({
name: 'dynamic-homepage-layout',
+ attachTo: homepageLayoutAttachTo,
configSchema: {
customizable: z.boolean().optional(),
widgetLayout: z
@@ -77,7 +75,7 @@ export const homePageLayoutExtension =
};
})
.sort((a, b) => {
- if (customizable) return 0; // keep original order
+ if (customizable) return 0;
const priorityA = layoutConfig[a.name ?? '']?.priority ?? 0;
const priorityB = layoutConfig[b.name ?? '']?.priority ?? 0;
diff --git a/workspaces/homepage/plugins/homepage/src/extensions/homepageAttach.ts b/workspaces/homepage/plugins/homepage/src/extensions/homepageAttach.ts
new file mode 100644
index 00000000000..1688901e453
--- /dev/null
+++ b/workspaces/homepage/plugins/homepage/src/extensions/homepageAttach.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * 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.
+ */
+
+/** NFS page extension id owned by the homepage plugin (`pluginId: homepage`). */
+export const HOMEPAGE_PAGE_ID = 'page:homepage';
+
+/** Attach widgets contributed by this plugin to {@link HOMEPAGE_PAGE_ID}. */
+export const homepageWidgetAttachTo = {
+ id: HOMEPAGE_PAGE_ID,
+ input: 'widgets' as const,
+};
+
+/** Attach layout contributed by this plugin to {@link HOMEPAGE_PAGE_ID}. */
+export const homepageLayoutAttachTo = {
+ id: HOMEPAGE_PAGE_ID,
+ input: 'layout' as const,
+};
diff --git a/workspaces/homepage/plugins/homepage/src/extensions/homepagePage.tsx b/workspaces/homepage/plugins/homepage/src/extensions/homepagePage.tsx
new file mode 100644
index 00000000000..8004645aa02
--- /dev/null
+++ b/workspaces/homepage/plugins/homepage/src/extensions/homepagePage.tsx
@@ -0,0 +1,89 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * 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 {
+ createExtensionInput,
+ createRouteRef,
+ ExtensionBoundary,
+ PageBlueprint,
+} from '@backstage/frontend-plugin-api';
+import {
+ homePageLayoutComponentDataRef,
+ homePageWidgetDataRef,
+ HomePageLayoutBlueprint,
+} from '@backstage/plugin-home-react/alpha';
+import HomeIcon from '@mui/icons-material/Home';
+import { lazy } from 'react';
+
+/**
+ * Route ref for the homepage-owned NFS page.
+ *
+ * @alpha
+ */
+export const homepageRouteRef = createRouteRef();
+
+/**
+ * Homepage page (`page:homepage`) with configurable `path`.
+ *
+ * Owns widgets/layout inputs so this plugin works without community
+ * `@backstage/plugin-home`. Disable independently via app-config:
+ * `page:homepage: false` or `page:home: false`.
+ *
+ * @alpha
+ */
+export const homepagePage = PageBlueprint.makeWithOverrides({
+ inputs: {
+ widgets: createExtensionInput([homePageWidgetDataRef]),
+ layout: createExtensionInput([HomePageLayoutBlueprint.dataRefs.component], {
+ singleton: true,
+ optional: true,
+ internal: true,
+ }),
+ },
+ factory(originalFactory, { node, inputs }) {
+ return originalFactory({
+ path: '/',
+ noHeader: true,
+ routeRef: homepageRouteRef,
+ title: 'Home',
+ icon: ,
+ loader: async () => {
+ const LazyDefaultLayout = lazy(() =>
+ import('../components/HomePageLayout').then(m => ({
+ default: m.HomePageLayout,
+ })),
+ );
+
+ const DefaultLayoutComponent = (props: { widgets: unknown[] }) => (
+
+
+
+ );
+
+ const Layout =
+ inputs.layout?.get(homePageLayoutComponentDataRef) ??
+ DefaultLayoutComponent;
+
+ const widgets = inputs.widgets.map(widget => ({
+ ...widget.get(homePageWidgetDataRef),
+ node: widget.node,
+ }));
+
+ return ;
+ },
+ });
+ },
+});
diff --git a/workspaces/homepage/plugins/homepage/src/index.ts b/workspaces/homepage/plugins/homepage/src/index.ts
index 5cf77c528d0..d0b4a2cd8c9 100644
--- a/workspaces/homepage/plugins/homepage/src/index.ts
+++ b/workspaces/homepage/plugins/homepage/src/index.ts
@@ -17,8 +17,6 @@
/**
* Dynamic Home Page plugin for the New Frontend System.
*
- * Extends the upstream `home` plugin with a custom layout and RHDH widgets.
- *
* @packageDocumentation
*/
@@ -31,14 +29,19 @@ ClassNameGenerator.configure(componentName => {
});
import { TranslationBlueprint } from '@backstage/plugin-app-react';
-import { createFrontendModule } from '@backstage/frontend-plugin-api';
+import {
+ createFrontendModule,
+ createFrontendPlugin,
+} from '@backstage/frontend-plugin-api';
import {
catalogStarredWidget,
+ communityHomeWidgets,
disableRandomJoke,
disableToolkit,
entitySectionWidget,
featuredDocsCardWidget,
onboardingSectionWidget,
+ overrideHomeCatalogStarredWidget,
quickAccessCardWidget,
RecentlyVisitedWidget,
searchBarWidget,
@@ -47,37 +50,74 @@ import {
} from './extensions/homePageCards';
import { homepageTranslations } from './translations';
import { homePageLayoutExtension } from './extensions/homePageLayoutExtension';
+import { homepagePage, homepageRouteRef } from './extensions/homepagePage';
import { defaultWidgetsApi, quickAccessApi } from './extensions/apis';
/**
- * Frontend module for the Dynamic Home Page plugin (New Frontend System).
+ * Extensions owned by the homepage plugin.
+ *
+ * Widgets/layout attach to `page:homepage`. Persona filtering via
+ * homepage-backend is applied only by that layout.
+ */
+const homepageExtensions = [
+ homepagePage,
+ homePageLayoutExtension,
+ onboardingSectionWidget,
+ entitySectionWidget,
+ templateSectionWidget,
+ defaultWidgetsApi,
+ quickAccessApi,
+ quickAccessCardWidget,
+ featuredDocsCardWidget,
+ searchBarWidget,
+ TopVisitedWidget,
+ RecentlyVisitedWidget,
+ catalogStarredWidget,
+];
+
+/**
+ * Homepage frontend plugin (`pluginId: homepage`).
*
- * Extends the `home` plugin with a custom layout and RHDH widgets: Onboarding,
- * Entity Catalog, Templates, Quick Access, Search, Recently Visited, Top Visited, etc.
- * Add to your app's `createApp({ features: [..., homePageModule] })`.
+ * @public
+ */
+export const homepagePlugin = createFrontendPlugin({
+ pluginId: 'homepage',
+ extensions: homepageExtensions,
+ routes: {
+ root: homepageRouteRef,
+ },
+});
+
+/**
+ * Optional module for when community `@backstage/plugin-home` is also installed.
+ *
+ * Mirrors RH widgets onto `page:home` (no RH layout / no homepage-backend
+ * filtering) and disables community toolkit / joke / starred demos.
*
* @public
*/
-export const homePageModule = createFrontendModule({
- pluginId: 'home', // upstream home!
+export const homepageHomeModule = createFrontendModule({
+ pluginId: 'home',
extensions: [
- homePageLayoutExtension,
- onboardingSectionWidget,
- entitySectionWidget,
- templateSectionWidget,
- defaultWidgetsApi,
- quickAccessApi,
- quickAccessCardWidget,
- featuredDocsCardWidget,
- searchBarWidget,
- TopVisitedWidget,
- RecentlyVisitedWidget,
- catalogStarredWidget,
+ ...communityHomeWidgets,
+ overrideHomeCatalogStarredWidget,
disableToolkit,
disableRandomJoke,
],
});
+/**
+ * @public
+ * @deprecated Use {@link homepageHomeModule}.
+ */
+export { homepageHomeModule as homePageModule };
+
+/**
+ * @public
+ * @deprecated Use {@link homepagePlugin}.
+ */
+export { homepagePlugin as homePagePlugin };
+
/**
* Translation module for the Dynamic Home Page plugin.
*
@@ -97,9 +137,11 @@ export const homepageTranslationsModule = createFrontendModule({
export { homepageTranslationRef, homepageTranslations } from './translations';
+export { homepageRouteRef } from './extensions/homepagePage';
+
/**
* Default export required for Module Federation to emit the NFS expose.
*
* @public
*/
-export default homePageModule;
+export default homepagePlugin;
diff --git a/workspaces/homepage/plugins/homepage/src/nfsExports.test.ts b/workspaces/homepage/plugins/homepage/src/nfsExports.test.ts
index 880c86c3511..c9646b88ee3 100644
--- a/workspaces/homepage/plugins/homepage/src/nfsExports.test.ts
+++ b/workspaces/homepage/plugins/homepage/src/nfsExports.test.ts
@@ -14,11 +14,20 @@
* limitations under the License.
*/
-import { homepageTranslationsModule, homePageModule } from '.';
+import homePlugin from '@backstage/plugin-home/alpha';
import translationsModuleDefault from './homepageTranslationsModuleExport';
+import {
+ homepageHomeModule,
+ homepagePlugin,
+ homepageTranslationsModule,
+ homePageModule,
+ homePagePlugin,
+} from '.';
import { homepageTranslationRef, homepageTranslations } from './translations';
import { homePageLayoutExtension } from './extensions/homePageLayoutExtension';
+import { HOMEPAGE_PAGE_ID } from './extensions/homepageAttach';
import {
+ communityHomeWidgets,
onboardingSectionWidget,
entitySectionWidget,
templateSectionWidget,
@@ -33,12 +42,134 @@ import {
} from './extensions/homePageCards';
import { quickAccessApi, defaultWidgetsApi } from './extensions/apis';
+type ExtensionAttach = { id: string; input: string };
+
+type RuntimeExtension = {
+ id: string;
+ attachTo?: ExtensionAttach;
+ disabled?: boolean;
+};
+
+function getRuntimeExtensions(feature: object): RuntimeExtension[] {
+ const extensions = (feature as { extensions?: RuntimeExtension[] })
+ .extensions;
+ expect(extensions).toBeDefined();
+ return extensions!;
+}
+
+function getAttachTo(extension: RuntimeExtension): ExtensionAttach {
+ expect(extension.attachTo).toBeDefined();
+ return extension.attachTo!;
+}
+
+const COMMUNITY_DEMO_OVERRIDE_IDS = [
+ 'home-page-widget:home/starred-entities',
+ 'home-page-widget:home/toolkit',
+ 'home-page-widget:home/random-joke',
+] as const;
+
describe('Dynamic Home Page plugin (NFS)', () => {
- describe('Modules', () => {
- it('should export homePageModule with correct structure', () => {
- expect(homePageModule).toBeDefined();
- expect(homePageModule.$$type).toBe('@backstage/FrontendModule');
- expect(homePageModule.pluginId).toBe('home');
+ describe('Install models', () => {
+ it('homepagePlugin owns page:homepage with widgets attached to it', () => {
+ expect(homepagePlugin).toBeDefined();
+ expect(homepagePlugin.$$type).toBe('@backstage/FrontendPlugin');
+ expect(homepagePlugin.id).toBe('homepage');
+ expect(homepagePlugin.id).not.toBe(homePlugin.id);
+ expect(homePagePlugin).toBe(homepagePlugin);
+ expect(homepagePlugin.getExtension(HOMEPAGE_PAGE_ID)).toBeDefined();
+ expect(
+ homepagePlugin.getExtension(
+ 'home-page-widget:homepage/rhdh-onboarding-section',
+ ),
+ ).toBeDefined();
+ expect(
+ homepagePlugin.getExtension(
+ 'home-page-layout:homepage/dynamic-homepage-layout',
+ ),
+ ).toBeDefined();
+ expect(
+ getRuntimeExtensions(homepagePlugin).some(
+ ext => ext.id === 'page:home',
+ ),
+ ).toBe(false);
+ });
+
+ it('homepageHomeModule mirrors RH widgets onto page:home without RH layout', () => {
+ expect(homepageHomeModule).toBeDefined();
+ expect(homepageHomeModule.$$type).toBe('@backstage/FrontendModule');
+ expect(homepageHomeModule.pluginId).toBe('home');
+ expect(homePageModule).toBe(homepageHomeModule);
+ expect(communityHomeWidgets).toHaveLength(9);
+
+ const extensions = getRuntimeExtensions(homepageHomeModule);
+ const ids = extensions.map(ext => ext.id);
+
+ expect(ids).toEqual(
+ expect.arrayContaining([
+ 'home-page-widget:home/rhdh-onboarding-section',
+ 'home-page-widget:home/quickaccess-card',
+ ...COMMUNITY_DEMO_OVERRIDE_IDS,
+ ]),
+ );
+ expect(ids).not.toContain(
+ 'home-page-widget:homepage/rhdh-onboarding-section',
+ );
+ expect(ids).not.toContain(
+ 'home-page-layout:home/dynamic-homepage-layout',
+ );
+ expect(ids.some(id => id.startsWith('api:'))).toBe(false);
+
+ const mirroredWidgets = extensions.filter(
+ ext =>
+ ext.id.startsWith('home-page-widget:home/') &&
+ !COMMUNITY_DEMO_OVERRIDE_IDS.includes(
+ ext.id as (typeof COMMUNITY_DEMO_OVERRIDE_IDS)[number],
+ ),
+ );
+ expect(mirroredWidgets).toHaveLength(communityHomeWidgets.length);
+
+ for (const ext of mirroredWidgets) {
+ expect(ext.disabled).toBe(false);
+ expect(getAttachTo(ext)).toEqual({
+ id: 'page:home',
+ input: 'widgets',
+ });
+ }
+
+ for (const id of COMMUNITY_DEMO_OVERRIDE_IDS) {
+ expect(extensions.find(ext => ext.id === id)?.disabled).toBe(true);
+ }
+ });
+
+ it('homepage widgets, layout, and default-widgets API attach to page:homepage', () => {
+ const homepageExtensions = getRuntimeExtensions(homepagePlugin);
+
+ const homepageWidgets = homepageExtensions.filter(ext =>
+ ext.id.startsWith('home-page-widget:'),
+ );
+ expect(homepageWidgets.length).toBeGreaterThan(0);
+
+ for (const ext of homepageWidgets) {
+ expect(getAttachTo(ext)).toEqual({
+ id: HOMEPAGE_PAGE_ID,
+ input: 'widgets',
+ });
+ }
+
+ const layout = homepageExtensions.find(ext =>
+ ext.id.startsWith('home-page-layout:'),
+ );
+ expect(layout).toBeDefined();
+ expect(getAttachTo(layout!)).toEqual({
+ id: HOMEPAGE_PAGE_ID,
+ input: 'layout',
+ });
+
+ expect(
+ homepageExtensions.some(
+ ext => ext.id === 'api:homepage/default-widgets',
+ ),
+ ).toBe(true);
});
it('should export homepageTranslationsModule with correct structure', () => {
@@ -81,6 +212,7 @@ describe('Dynamic Home Page plugin (NFS)', () => {
expect(searchBarWidget).toBeDefined();
expect(featuredDocsCardWidget).toBeDefined();
expect(catalogStarredWidget).toBeDefined();
+ expect(communityHomeWidgets).toBeDefined();
expect(disableToolkit).toBeDefined();
expect(disableRandomJoke).toBeDefined();
expect(RecentlyVisitedWidget).toBeDefined();
diff --git a/workspaces/homepage/yarn.lock b/workspaces/homepage/yarn.lock
index 92f22532156..040e0f20463 100644
--- a/workspaces/homepage/yarn.lock
+++ b/workspaces/homepage/yarn.lock
@@ -13657,31 +13657,31 @@ __metadata:
dependencies:
"@axe-core/playwright": "npm:^4.10.0"
"@backstage-community/plugin-rbac": "npm:^1.52.1"
- "@backstage/cli": "npm:^0.36.5"
- "@backstage/core-compat-api": "npm:^0.5.14"
- "@backstage/core-components": "npm:^0.18.13"
- "@backstage/core-plugin-api": "npm:^1.12.9"
- "@backstage/frontend-defaults": "npm:^0.5.5"
- "@backstage/frontend-plugin-api": "npm:^0.18.0"
- "@backstage/frontend-test-utils": "npm:^0.6.3"
- "@backstage/integration-react": "npm:^1.2.21"
- "@backstage/plugin-api-docs": "npm:^0.14.4"
- "@backstage/plugin-app-react": "npm:^0.2.6"
- "@backstage/plugin-app-visualizer": "npm:^0.2.7"
- "@backstage/plugin-catalog": "npm:^2.0.8"
- "@backstage/plugin-catalog-graph": "npm:^0.6.7"
- "@backstage/plugin-catalog-import": "npm:^0.13.16"
- "@backstage/plugin-home": "npm:^0.9.9"
- "@backstage/plugin-kubernetes": "npm:^0.12.22"
- "@backstage/plugin-notifications": "npm:^0.5.20"
- "@backstage/plugin-org": "npm:^0.7.7"
- "@backstage/plugin-scaffolder": "npm:^1.38.2"
- "@backstage/plugin-search": "npm:^1.7.7"
- "@backstage/plugin-signals": "npm:^0.0.34"
- "@backstage/plugin-techdocs": "npm:^1.18.0"
- "@backstage/plugin-techdocs-module-addons-contrib": "npm:^1.1.39"
- "@backstage/plugin-user-settings": "npm:^0.9.6"
- "@backstage/ui": "npm:^0.17.1"
+ "@backstage/cli": "npm:^0.36.3"
+ "@backstage/core-compat-api": "npm:^0.5.12"
+ "@backstage/core-components": "npm:^0.18.11"
+ "@backstage/core-plugin-api": "npm:^1.12.7"
+ "@backstage/frontend-defaults": "npm:^0.5.3"
+ "@backstage/frontend-plugin-api": "npm:^0.17.2"
+ "@backstage/frontend-test-utils": "npm:^0.6.1"
+ "@backstage/integration-react": "npm:^1.2.19"
+ "@backstage/plugin-api-docs": "npm:^0.14.2"
+ "@backstage/plugin-app-react": "npm:^0.2.4"
+ "@backstage/plugin-app-visualizer": "npm:^0.2.5"
+ "@backstage/plugin-catalog": "npm:^2.0.6"
+ "@backstage/plugin-catalog-graph": "npm:^0.6.5"
+ "@backstage/plugin-catalog-import": "npm:^0.13.14"
+ "@backstage/plugin-home": "npm:^0.9.7"
+ "@backstage/plugin-kubernetes": "npm:^0.12.20"
+ "@backstage/plugin-notifications": "npm:^0.5.18"
+ "@backstage/plugin-org": "npm:^0.7.5"
+ "@backstage/plugin-scaffolder": "npm:^1.38.0"
+ "@backstage/plugin-search": "npm:^1.7.5"
+ "@backstage/plugin-signals": "npm:^0.0.32"
+ "@backstage/plugin-techdocs": "npm:^1.17.7"
+ "@backstage/plugin-techdocs-module-addons-contrib": "npm:^1.1.37"
+ "@backstage/plugin-user-settings": "npm:^0.9.4"
+ "@backstage/ui": "npm:^0.16.0"
"@mui/icons-material": "npm:5.18.0"
"@mui/material": "npm:5.18.0"
"@playwright/test": "npm:1.61.1"