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"