diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e139e26..14efd96 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Orchestrator skill for RHDH plugin development - onboard, update, and maintain plugins in the Extensions Catalog", - "version": "0.6.0" + "version": "0.6.1" }, "plugins": [ { "name": "rhdh", "source": "./", "description": "Skills for RHDH plugin lifecycle management", - "version": "0.6.0", + "version": "0.6.1", "strict": true } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index eadcf3d..ec8a1cd 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "rhdh", "description": "All-in-one toolkit for Red Hat Developer Hub (RHDH). Covers plugin development, overlay management, environment setup, version compatibility, CI/CD, and RHDH ecosystem navigation.", - "version": "0.6.0", + "version": "0.6.1", "author": { "name": "RHDH Store Manager" }, diff --git a/README.md b/README.md index 755de14..13766c3 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,7 @@ Build dynamic plugins from scratch — backend or frontend — and get them depl Migrate your plugins from the legacy Backstage frontend system to the New Frontend System (NFS). -Start with the **[NFS Migration Guide](./docs/nfs-migration-guide.md)** -- it covers what NFS is, why you need to migrate, the deprecation timeline, and walks through every migration pattern with code examples. - -When you're ready to migrate, use the Agent Skill to automate it: - -- **[nfs-migration](./skills/nfs-migration/SKILL.md)** -- Analyzes your existing plugin, applies the right Blueprint patterns, updates exports, and verifies the result. Two approaches: direct-to-GA (recommended) or phased with backward compatibility. +- **[nfs-migration](./skills/nfs-migration/SKILL.md)** -- Analyzes your existing plugin, applies the right Blueprint patterns, updates exports, and verifies the result. Two approaches: alpha (default, NFS at `./alpha`) or colocated (NFS + legacy both from root). Reference files cover every extension type, mount point mapping, operator config, gotchas, and verification. ### Backstage Upgrade diff --git a/docs/nfs-migration-guide.md b/docs/nfs-migration-guide.md deleted file mode 100644 index a519cd7..0000000 --- a/docs/nfs-migration-guide.md +++ /dev/null @@ -1,484 +0,0 @@ -# Migrating RHDH Plugins to the New Frontend System (NFS) - -A practical guide for Red Hat Developer Hub plugin authors migrating from the legacy Backstage frontend system to NFS. - -> **Agent skill users:** The `nfs-migration` skill (`skills/nfs-migration/`) contains the same patterns broken into reference files optimized for agent consumption. This guide is the authoritative human-readable source. When updating migration patterns, update this guide first, then sync the corresponding reference file. - ---- - -## 1. What is the New Frontend System - -The Backstage New Frontend System (NFS) replaces the legacy frontend plugin API. Instead of manually wiring plugins into an app with `createPlugin`, `createRoutableExtension`, `FlatRoutes`, and imperative JSX route trees, NFS uses declarative extension **Blueprints** (`PageBlueprint`, `ApiBlueprint`, `EntityContentBlueprint`, etc.) and `createFrontendPlugin` from `@backstage/frontend-plugin-api`. - -The app assembles itself from features: - -```ts -import { createApp } from '@backstage/frontend-defaults'; - -const app = createApp({ features: [myPlugin, catalogPlugin, ...] }); -``` - -Plugins declare what they provide. The app decides what to render. - ---- - -## 2. Why Migrate - -- **Declarative**: plugins describe their own routes, nav items, and APIs -- no more manual wiring in the app -- **Configurable**: extensions can be enabled, disabled, or reordered via `app-config.yaml` -- **Auto-discoverable**: apps can detect installed plugins automatically -- **Composable**: modules can inject extensions into other plugins (e.g. entity tabs into catalog) -- **Required**: the legacy APIs are being deprecated and will be removed - ---- - -## 3. Deprecation Timeline - -| Phase | What happens | -|-------|-------------| -| **Current (RHDH 1.10)** | NFS available as `/alpha` exports alongside legacy | -| **Next release (GA)** | NFS becomes the root export (`.`); legacy moves to `/legacy` with `@deprecated` tags | -| **GA + 2 releases** | Legacy `/legacy` exports removed entirely | - ---- - -## 4. Key Concepts - -### Blueprints vs Legacy Extension Factories - -Blueprints are declarative factories that replace imperative helpers like `createRoutableExtension()`. Each blueprint type (`PageBlueprint`, `ApiBlueprint`, `EntityContentBlueprint`) knows how to register itself with the app. Nav items are auto-discovered from pages -- no separate blueprint needed. - -```ts -// Legacy -export const MyPage = myPlugin.provide(createRoutableExtension({ ... })); - -// NFS -const myPage = PageBlueprint.make({ params: { path: '/my-plugin', loader: () => ... } }); -``` - -### `createFrontendPlugin` vs `createPlugin` - -| Legacy `createPlugin` | NFS `createFrontendPlugin` | -|---|---| -| `id: 'my-plugin'` | `pluginId: 'my-plugin'` | -| `apis: [createApiFactory(...)]` | APIs go in `extensions` array as `ApiBlueprint` | -| Pages/routes wired externally | Pages declared as `PageBlueprint` in `extensions` | -| Named export | **Default export** | - -### `createFrontendModule` - -Bundles extensions that target *another* plugin. Common cases: - -- **Translations** target `pluginId: 'app'` -- **Homepage widgets** target `pluginId: 'home'` - -Modules are separate exports, not part of the plugin itself. Note: entity content and cards can go directly in your plugin's `extensions` array (they declare their own attach point) — a separate catalog module is only needed when injecting content from outside a plugin you don't own. - -### Route Refs - -You can reuse existing route refs from `@backstage/core-plugin-api` or create new ones from `@backstage/frontend-plugin-api`. Both work -- no need to migrate route refs immediately. - ---- - -## 5. Migration Patterns - -### Plugin Definition - -**Legacy:** - -```ts -import { createPlugin, createApiFactory, configApiRef, fetchApiRef } from '@backstage/core-plugin-api'; -import { rootRouteRef } from './routes'; -import { myApiRef, MyApiClient } from './api'; - -export const myPlugin = createPlugin({ - id: 'my-plugin', - routes: { root: rootRouteRef }, - apis: [ - createApiFactory({ - api: myApiRef, - deps: { configApi: configApiRef, fetchApi: fetchApiRef }, - factory: ({ configApi, fetchApi }) => new MyApiClient({ configApi, fetchApi }), - }), - ], -}); -``` - -**NFS:** - -```tsx -import { - createFrontendPlugin, ApiBlueprint, PageBlueprint, - configApiRef, fetchApiRef, createApiFactory, -} from '@backstage/frontend-plugin-api'; -import { rootRouteRef } from './routes'; -import { myApiRef, MyApiClient } from './api'; -import { RiToolsLine } from '@remixicon/react'; - -const myApi = ApiBlueprint.make({ - params: defineParams => defineParams({ - api: myApiRef, - deps: { configApi: configApiRef, fetchApi: fetchApiRef }, - factory: ({ configApi, fetchApi }) => new MyApiClient({ configApi, fetchApi }), - }), -}); - -const myPage = PageBlueprint.make({ - params: { - path: '/my-plugin', - title: 'My Plugin', - icon: , - routeRef: rootRouteRef, - loader: () => import('./components/MyPage').then(m => ), - }, -}); - -export default createFrontendPlugin({ - pluginId: 'my-plugin', - title: 'My Plugin', - icon: , - extensions: [myApi, myPage], - routes: { root: rootRouteRef }, -}); -``` - -Key changes: APIs and pages are extensions in the `extensions` array. Nav items are auto-discovered from pages with `title`, `icon`, and `routeRef`. The plugin is the **default export**. - -### Pages - -**Legacy** -- routable extension provided by the plugin, path set in the app's `FlatRoutes`: - -```tsx -export const MyPage = myPlugin.provide( - createRoutableExtension({ - name: 'MyPage', - component: () => import('./components/MyPage').then(m => m.MyPage), - mountPoint: rootRouteRef, - }), -); - -// In the app: - - } /> - -``` - -**NFS** -- the plugin owns its path. No app-side route wiring. The NFS page component must **not** include its own page shell (`PageWithHeader`) — the framework provides the header automatically: - -```tsx -const myPage = PageBlueprint.make({ - params: { - path: '/my-plugin', - routeRef: rootRouteRef, - loader: () => import('./components/MyPage').then(m => ), - }, -}); -``` - -Create a separate NFS variant of each page component without the page shell. See `references/migrate-page.md` for the dual header pattern (Pattern A for simple pages, Pattern B for complex pages). - -### Nav Items - -**Legacy** -- manually added in the app's sidebar: - -```tsx - -``` - -**NFS** -- auto-discovered from pages. Set `title` and `icon` on `PageBlueprint` params and the app generates nav entries automatically. No separate blueprint needed — see the Plugin Definition example above. - -> Earlier Backstage versions used `NavItemBlueprint`. It has been removed — see `references/api-changes.md`. - -### APIs - -**Legacy** -- `createApiFactory` in the plugin's `apis` array. - -**NFS** -- wrap the existing `createApiFactory` call in `ApiBlueprint.make` using the `defineParams` callback. See the Plugin Definition example above. The `defineParams` callback is required -- it's how the blueprint validates the factory. See `references/migrate-page.md` for the full pattern. - -### Entity Content (Catalog Tabs) - -Entity content goes in your plugin's `extensions` array. The blueprint declares its own attach point, so the app discovers it automatically: - -```tsx -import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; -import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; - -const myEntityContent = EntityContentBlueprint.make({ - params: { - path: '/my-tab', - title: 'My Tab', - loader: () => import('./components/MyTab').then(m => ), - }, -}); - -export default createFrontendPlugin({ - pluginId: 'my-plugin', - extensions: [myEntityContent], -}); -``` - -If you need to provide entity content from a separate package (third-party addon), use `createFrontendModule({ pluginId: 'catalog' })` instead. - -### Translations - -Translations must be in a separate module targeting `pluginId: 'app'`: - -```tsx -import { createFrontendModule } from '@backstage/frontend-plugin-api'; -import { TranslationBlueprint } from '@backstage/plugin-app-react'; -import { myTranslations } from './translations'; - -export const myTranslationsModule = createFrontendModule({ - pluginId: 'app', - extensions: [ - TranslationBlueprint.make({ - name: 'my-plugin-translations', - params: { resource: myTranslations }, - }), - ], -}); -``` - -### RHDH-Specific Extensions - -**Drawer panels** -- `AppDrawerContentBlueprint`: - -```tsx -import { AppDrawerContentBlueprint } from '@red-hat-developer-hub/backstage-plugin-app-react/alpha'; - -const myDrawer = AppDrawerContentBlueprint.make({ - params: { - title: 'My Drawer', - loader: () => import('./components/MyDrawer').then(m => ), - }, -}); -``` - -**Global header menu items** -- `GlobalHeaderMenuItemBlueprint`: - -```tsx -import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; - -const myMenuItem = GlobalHeaderMenuItemBlueprint.make({ - params: { - title: 'My Action', - icon: MyIcon, - routeRef: rootRouteRef, - }, -}); -``` - -**Homepage widgets** -- `HomePageWidgetBlueprint`: - -```tsx -import { HomePageWidgetBlueprint } from '@backstage/plugin-home-react/alpha'; - -const myWidget = HomePageWidgetBlueprint.make({ - params: { - title: 'My Widget', - loader: () => import('./components/MyWidget').then(m => ), - }, -}); -``` - -### RHDH Mount Point Migration - -If your plugin uses RHDH dynamic plugin mount points (`app-config.dynamic.yaml`), these map directly to NFS blueprints. See `references/mount-point-mapping.md` for the complete mapping table with before/after examples for each mount point type. - -### Shared Components (Legacy + NFS) - -Keep component imports (`useApi`, `useRouteRef`, etc.) on `@backstage/core-plugin-api` — they work in both legacy and NFS contexts. This lets the same components serve both export paths without changes: - -```tsx -// Keep this — works in both legacy and NFS -import { useApi, useRouteRef } from '@backstage/core-plugin-api'; -``` - -Don't migrate component imports to `@backstage/frontend-plugin-api` if you need to support legacy consumers — it breaks the legacy code path. - -### CompatWrapper (rare) - -Only needed when a component depends on legacy context providers that aren't available in NFS (e.g. old `SidebarContext`). Most plugins won't need this. Wrap the JSX element in the loader: - -```tsx -loader: () => import('./components/MyPage').then(m => compatWrapper()) -``` - -Import `compatWrapper` from `@backstage/core-compat-api`. - ---- - -## 6. Choosing Your Approach - -### Approach A -- Direct to GA (recommended) - -NFS becomes the root export immediately. Legacy code moves to `/legacy` or is removed. - -Best when: -- You control all consumers -- You can do a clean migration in one pass -- You want the simplest result - -### Approach B -- Phased - -Add NFS as `/alpha` exports alongside existing legacy exports. Graduate later by swapping. - -Best when: -- External consumers depend on legacy exports -- You need time to migrate tests and stories -- You want to ship incrementally - -| | Direct to GA | Phased | -|---|---|---| -| Complexity | Lower | Higher (two export sets) | -| Consumer impact | Breaking change | Non-breaking initially | -| Maintenance | One code path | Two code paths temporarily | -| Recommended for | Internal plugins | Shared/published plugins | - ---- - -## 7. Package.json Changes - -Update your `package.json` exports for the GA structure: - -```json -{ - "exports": { - ".": "./src/index.ts", - "./legacy": "./src/legacy.ts", - "./package.json": "./package.json" - }, - "typesVersions": { - "*": { - "legacy": ["src/legacy.ts"], - "package.json": ["package.json"] - } - } -} -``` - -- `.` -- NFS plugin (default export from `createFrontendPlugin`) -- `./legacy` -- old `createPlugin`-based exports for consumers who haven't migrated yet -- Remove the `./legacy` entry when you drop legacy support - ---- - -## 8. Verifying Your Migration - -Run through this checklist: - -- [ ] `yarn tsc` passes with no type errors -- [ ] `yarn build` succeeds -- [ ] Plugin default export is the `createFrontendPlugin` result -- [ ] All extensions (pages, APIs) are in the `extensions` array -- [ ] NFS page components don't include `PageWithHeader`/`Page` shell (dual header pattern) -- [ ] Routes are declared in the plugin's `routes` object -- [ ] Translations are in a separate `createFrontendModule` with `pluginId: 'app'` -- [ ] Entity content extensions are in the plugin's `extensions` array -- [ ] `package.json` exports are updated (`.` for NFS, `./legacy` for old) -- [ ] `src/index.ts` does NOT re-export legacy APIs (legacy only via `./legacy` subpath) -- [ ] Plugin file uses `.tsx` extension if it contains JSX in blueprint loaders -- [ ] Component imports stay on `@backstage/core-plugin-api` (shared between legacy and NFS) -- [ ] `dev/index.tsx` uses NFS dev app pattern; legacy dev app moved to `dev/legacy.tsx` -- [ ] Workspace app (`packages/app`) is NFS; legacy consumers moved to `./legacy` subpath or a separate `packages/app-legacy` - ---- - -## 9. Testing with RHDH - -### Local Testing - -Use the `rhdh-local` skill to test in a local RHDH instance. If NFS is not yet the default app shell, enable it with environment variables: - -```bash -APP_CONFIG_app_packageName=app-next -ENABLE_STANDARD_MODULE_FEDERATION=true -``` - -Export the plugin as a dynamic plugin and deploy it locally. Verify that: -- The plugin loads without errors -- Nav items appear in the sidebar -- Pages render at the correct paths -- Entity tabs show up on the right entity kinds - -### Cluster Testing - -For OpenShift/K8s deployments, add the plugin to your `dynamic-plugins.yaml` configuration and verify it loads in the NFS app shell. Check the browser console for extension registration logs. - ---- - -## 10. Common Gotchas - -1. **Import paths depend on your approach**: Direct-to-GA → import from root (`.`). Phased → import NFS from `./alpha`. Getting this wrong causes silent failures. - -2. **TranslationBlueprint must target `pluginId: 'app'`**: Putting translations in the plugin itself won't work. They must be in a separate `createFrontendModule({ pluginId: 'app' })`. - -3. **Nav items require `title` + `icon` + `routeRef` on the page**: Nav entries are auto-discovered from `PageBlueprint` extensions. If your plugin's nav item isn't appearing, ensure all three params are set. `NavItemBlueprint` was removed in recent Backstage versions -- see `references/api-changes.md`. - -4. **Entity content not showing on entity pages**: Ensure `path`, `title`, and `loader` are all set on `EntityContentBlueprint`. The blueprint declares its own attach point — it works directly in the plugin's `extensions` array. - -5. **ApiBlueprint uses `defineParams` callback**: Don't pass the factory directly -- wrap it: `params: defineParams => defineParams(createApiFactory(...))`. - -6. **Keep component imports on `@backstage/core-plugin-api`**: Hooks like `useApi()` and `useRouteRef()` from `core-plugin-api` work in both legacy and NFS. Don't migrate them to `frontend-plugin-api` if you support legacy consumers. Only use `compatWrapper()` when a component depends on legacy context providers (e.g. old `SidebarContext`). - -7. **Drawer content only renders when active**: If your drawer needs initialization logic on mount, use `AppRootElementBlueprint` for the persistent part. - -8. **Module federation sharing**: Host and remote apps must share the same `@backstage/plugin-app-react` instance. Version mismatches cause runtime errors. - -9. **NFS page components must not include a page shell**: The framework provides the header via `PageLayout`. If your NFS component wraps content in `PageWithHeader` or `Page` + `Header`, you'll get double headers. Create an `NfsMyPage` variant without the shell — see `references/migrate-page.md` for the dual header pattern. - -10. **`useRouteRef` returns `undefined` in NFS**: The NFS `useRouteRef` from `@backstage/frontend-plugin-api` returns `RouteFunc | undefined` (the route might not be bound). The legacy version from `core-plugin-api` throws instead. When writing NFS-specific components, handle the `undefined` case. - ---- - -## 11. Recent API Changes - -If you migrated a plugin against an earlier Backstage NFS alpha, some APIs have changed. Key changes include the removal of `NavItemBlueprint`, deprecation of `makeWithOverrides` config pattern, and new params on `PageBlueprint` and `createFrontendPlugin`. - -See the full list in [references/api-changes.md](../skills/nfs-migration/references/api-changes.md). - ---- - -## 12. Automate It - -Instead of migrating manually, use the included Agent Skill: - -```bash -npx skills add redhat-developer/rhdh-skill --skill nfs-migration -``` - -Then tell your agent: *"Migrate my plugin to NFS"* -- it will analyze your plugin, apply the right patterns, update exports, and verify the result. - -See [skills/nfs-migration/SKILL.md](../skills/nfs-migration/SKILL.md) for details. - ---- - -## 13. Reference PRs - -Real RHDH plugin migrations to study: - -| Plugin | PR | What to learn | -|--------|-----|---------------| -| adoption-insights | [#2309](https://github.com/redhat-developer/rhdh-plugins/pull/2309) | Simple page plugin: Page + Nav + API | -| bulk-import | [#2247](https://github.com/redhat-developer/rhdh-plugins/pull/2247) | Page + Nav + permission patterns | -| scorecard | [#2487](https://github.com/redhat-developer/rhdh-plugins/pull/2487) | EntityContent + HomePage widgets | -| orchestrator | [#2526](https://github.com/redhat-developer/rhdh-plugins/pull/2526) | EntityContent + multi-route | -| lightspeed | [#2721](https://github.com/redhat-developer/rhdh-plugins/pull/2721) | Drawer + FAB (RHDH-specific) | -| extensions | [#2527](https://github.com/redhat-developer/rhdh-plugins/pull/2527) | compatWrapper usage | -| homepage | [#2423](https://github.com/redhat-developer/rhdh-plugins/pull/2423) | HomePageWidgets + compatWrapper | -| quickstart | [#2842](https://github.com/redhat-developer/rhdh-plugins/pull/2842) | Drawer + GlobalHeaderMenuItem | - -### Upstream Backstage Docs - -- [Plugin migration guide](https://backstage.io/docs/frontend-system/building-plugins/migrating) -- [Common extension blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints) -- [App migration guide](https://backstage.io/docs/frontend-system/building-apps/migrating) - ---- - -## 14. Need Help? - -- [RHDH Plugins GitHub Issues](https://github.com/redhat-developer/rhdh-plugins/issues) -- [Backstage Discord](https://discord.gg/backstage-687207715902193673) -- [Backstage Community](https://backstage.io/community/) -- [RHDH Documentation](https://docs.redhat.com/en/documentation/red_hat_developer_hub/) diff --git a/pyproject.toml b/pyproject.toml index 3d5653d..f76fce1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rhdh-skill" -version = "0.6.0" +version = "0.6.1" description = "Claude Code skill for RHDH plugin development" readme = "README.md" license = "Apache-2.0" diff --git a/skills/nfs-migration/SKILL.md b/skills/nfs-migration/SKILL.md index 5428a23..0b14dd3 100644 --- a/skills/nfs-migration/SKILL.md +++ b/skills/nfs-migration/SKILL.md @@ -10,16 +10,14 @@ description: > to the new frontend system for RHDH. --- -> **Human-readable guide:** `docs/nfs-migration-guide.md` is the authoritative source for migration patterns. These reference files are optimized for agent consumption. When patterns diverge, the guide takes precedence. - Always read the plugin's `package.json`, `src/plugin.ts` (or `src/plugin.tsx`), route refs, API factories, and exported components before making any changes. Understand what exists before migrating. - -NFS should be the root export (`.`). Legacy goes to `./legacy` with `@deprecated` tags if kept. This is the GA pattern. + +NFS is not GA yet. The default approach is to add NFS at `./alpha` while keeping legacy at the root export (`.`). This avoids breaking existing consumers. @@ -31,11 +29,11 @@ Entity content and cards can go directly in the plugin's `extensions` array — -Keep component imports (`useApi`, `useRouteRef`, etc.) on `@backstage/core-plugin-api` — they work in both legacy and NFS contexts. This lets the same components serve both the root export (NFS) and `./legacy` export. Only use `compatWrapper()` when a component depends on legacy context providers (e.g. old `SidebarContext`) that aren't available in NFS. Don't migrate component imports to `@backstage/frontend-plugin-api` if you need to support legacy consumers. +Keep component imports (`useApi`, `useRouteRef`, etc.) on `@backstage/core-plugin-api` — they work in both legacy and NFS contexts. This lets the same components serve both export paths. Only use `compatWrapper()` when a component depends on legacy context providers (e.g. old `SidebarContext`) that aren't available in NFS. Don't migrate component imports to `@backstage/frontend-plugin-api` if you need to support legacy consumers. - -Ask the user if they want to keep legacy exports at `./legacy`. If yes, move old `plugin.ts` code there with `@deprecated` JSDoc. If no, remove it. + +Legacy exports must remain available since NFS is not GA. With the alpha approach, legacy stays at root unchanged. With the colocated approach, legacy source moves to `legacy.ts` but is re-exported from `index.ts` so existing consumers don't break. @@ -58,7 +56,7 @@ Ask the user if they want to keep legacy exports at `./legacy`. If yes, move old |----------|--------| | 1, "migrate", "convert", "NFS" | Follow the migration workflow below | | 2, "test", "verify", "deploy" | Read `workflows/test-nfs-plugin.md` | -| 3, "learn", "guide", "overview" | Read `../../docs/nfs-migration-guide.md` and present key sections to the user | +| 3, "learn", "guide", "overview" | Read `references/overview.md` and present key sections to the user | @@ -83,9 +81,9 @@ If the plugin's `@backstage/*` dependencies are outdated, upgrade them first usi ### Step 2: Choose Approach -Use **Direct to GA** by default: NFS becomes root export (`.`), legacy at `./legacy`. +NFS is not GA yet. Use the **Alpha** approach by default: NFS at `./alpha`, legacy stays at root (`.`). -Only ask about the **Phased** approach (`./alpha`) if the user says they have external consumers that can't migrate yet. +The **Colocated** approach is the alternative: NFS as default export in `index.ts`, legacy source in `legacy.ts` but re-exported from `index.ts` for backward compatibility. Use this when the user wants NFS and legacy APIs available from the same import path. ### Step 3: Migrate Extensions @@ -103,14 +101,14 @@ Apply each reference's patterns to the discovered extensions. For page plugins, ### Step 4: Update package.json -Load `references/package-json.md` and apply the export configuration matching the chosen approach (GA or phased). +Load `references/package-json.md` and apply the export configuration matching the chosen approach (alpha or colocated). ### Step 5: Update App Wiring Load `references/app-setup.md` and: -- Convert `dev/index.tsx` to use the NFS dev app pattern (`createDevApp` from `@backstage/frontend-dev-utils`) -- Move the old legacy dev app to `dev/legacy.tsx` with a `start:legacy` script -- If `packages/app` imports legacy APIs from the plugin root, update those imports to use the `./legacy` subpath (or create a separate `packages/app-legacy` for the old frontend system, keeping `packages/app` as NFS) +- Add an NFS dev app at `dev/index.tsx` (or `dev/nfs.tsx`) using `createApp` from `@backstage/frontend-defaults` +- Keep the existing legacy dev app working +- Verify consumer imports still resolve (alpha approach: no changes needed; colocated approach: legacy re-exports from `index.ts` maintain compatibility) ### Step 6: Verify @@ -135,20 +133,21 @@ Load `references/verification.md` and run all checks. Run `yarn tsc` from the ** | `references/testing-rhdh.md` | Testing with a real RHDH instance | | `references/gotchas.md` | Troubleshooting migration issues | | `references/reference-prs.md` | Looking for real migration examples | +| `references/operator-config.md` | Plugin uses RHDH operator config or needs `app.extensions` / `app.routes.bindings` reference | +| `references/overview.md` | User wants to learn about NFS before migrating | | `references/support.md` | User needs help beyond what the skill covers | -| `../../docs/nfs-migration-guide.md` | User wants to learn about NFS | -- Plugin default-exports a `createFrontendPlugin` result +- `./alpha` (or root, for colocated) default-exports a `createFrontendPlugin` result - All legacy extensions have NFS Blueprint equivalents - Pages that need nav entries have `title` and `icon` set (on `PageBlueprint` or `createFrontendPlugin`) -- `package.json` exports NFS at `.` (direct-to-GA) or `./alpha` (phased) +- `package.json` exports NFS at `./alpha` (alpha approach) or `.` (colocated approach) - Translations are in a `createFrontendModule` with `pluginId: 'app'` - Entity content extensions are in the plugin's `extensions` array (or a catalog module if injecting from outside) - `yarn tsc` and `yarn build` pass -- Legacy code is at `./legacy` with `@deprecated` tags (if kept) +- Legacy exports remain available (unchanged at root for alpha; re-exported from `index.ts` for colocated) diff --git a/skills/nfs-migration/examples/before-after-drawer.md b/skills/nfs-migration/examples/before-after-drawer.md index 9d3ccad..9970e17 100644 --- a/skills/nfs-migration/examples/before-after-drawer.md +++ b/skills/nfs-migration/examples/before-after-drawer.md @@ -28,7 +28,7 @@ const myDrawer = AppDrawerContentBlueprint.make({ name: 'my-drawer', params: { id: MY_DRAWER_ID, - loader: () => import('./components/DrawerContent').then(m => ), + element: , resizable: true, defaultWidth: 400, }, diff --git a/skills/nfs-migration/references/api-changes.md b/skills/nfs-migration/references/api-changes.md index 0f01f98..472206e 100644 --- a/skills/nfs-migration/references/api-changes.md +++ b/skills/nfs-migration/references/api-changes.md @@ -6,7 +6,7 @@ Breaking and notable changes between the early NFS alpha and the current Backsta ## Component imports — keep on `core-plugin-api` -Hooks like `useApi`, `useRouteRef`, and `useRouteRefParams` from `@backstage/core-plugin-api` work in both legacy and NFS contexts. **Keep component imports on `core-plugin-api`** so the same components serve both the root NFS export and the `./legacy` export. +Hooks like `useApi`, `useRouteRef`, and `useRouteRefParams` from `@backstage/core-plugin-api` work in both legacy and NFS contexts. **Keep component imports on `core-plugin-api`** so the same components serve both export paths. Only the plugin definition code (`plugin.tsx`) and blueprint/API factory code use `@backstage/frontend-plugin-api` imports. Don't migrate component-level imports — it breaks legacy consumers. @@ -191,6 +191,21 @@ export const viewTechDocRouteRef = createExternalRouteRef({ }); ``` +## `AppDrawerContentBlueprint` uses `element`, not `loader` + +The RHDH `AppDrawerContentBlueprint` from `@red-hat-developer-hub/backstage-plugin-app-react/alpha` accepts an `element` param (a React element), not `loader` (a lazy import function): + +```tsx +// Correct +AppDrawerContentBlueprint.make({ + name: 'my-drawer', + params: { + id: MY_DRAWER_ID, + element: , + }, +}); +``` + ## New catalog-react blueprints The `@backstage/plugin-catalog-react/alpha` package now exports additional blueprints: diff --git a/skills/nfs-migration/references/app-setup.md b/skills/nfs-migration/references/app-setup.md index 9c3b727..dda2840 100644 --- a/skills/nfs-migration/references/app-setup.md +++ b/skills/nfs-migration/references/app-setup.md @@ -33,8 +33,8 @@ ReactDOM.createRoot(document.getElementById('root')!).render(App.createRoot()); | Approach | Plugin import | Module imports | |----------|--------------|----------------| -| Direct to GA | `import myPlugin from '@scope/my-plugin'` | `import { myTranslationsModule } from '@scope/my-plugin'` | -| Phased | `import myPlugin from '@scope/my-plugin/alpha'` | `import { myTranslationsModule } from '@scope/my-plugin/alpha'` | +| Alpha (default) | `import myPlugin from '@scope/my-plugin/alpha'` | `import { myTranslationsModule } from '@scope/my-plugin/alpha'` | +| Colocated | `import myPlugin from '@scope/my-plugin'` | `import { myTranslationsModule } from '@scope/my-plugin'` | - The default export is always the plugin (`createFrontendPlugin` result) - Named exports are modules (`createFrontendModule` results) @@ -42,7 +42,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(App.createRoot()); ## Dev app setup -For direct-to-GA, `dev/index.tsx` should be the NFS dev app (it's the default `yarn start` entry point). Keep the old legacy dev app at `dev/legacy.tsx` and add a `start:legacy` script. +Add an NFS dev app alongside the existing legacy dev app. Keep the legacy dev app as the default `yarn start` entry point (since NFS is not GA). Add the NFS dev app at `dev/nfs.tsx` with a `start:nfs` script, or at `dev/index.tsx` if you prefer NFS as default during development. ### NFS dev app (`dev/index.tsx`) @@ -96,12 +96,12 @@ Move the old `createDevApp` from `@backstage/dev-utils` code here. Add to `packa "start:legacy": "backstage-cli package start --entrypoint dev/legacy" ``` -## Consumer migration (packages/app) +## Consumer imports -If the workspace has a `packages/app` that imports legacy APIs from the plugin's root, those imports will break after the GA migration (legacy is no longer at the root export). Two approaches: +Since NFS is not GA, legacy exports must remain accessible from the package root: -1. **Update imports** — Change `import { MyPage } from '@scope/my-plugin'` to `import { MyPage } from '@scope/my-plugin/legacy'` -2. **Create a separate legacy app** — Keep `packages/app` as the NFS app and create `packages/app-legacy` for the old frontend system. This is the pattern used in `rhdh-plugins`. +- **Alpha approach:** No consumer changes needed — legacy stays at root, NFS is at `./alpha`. +- **Colocated approach:** Legacy is re-exported from `index.ts` — existing imports continue to work. NFS consumers use the default import. ## Dynamic plugin considerations (RHDH) diff --git a/skills/nfs-migration/references/gotchas.md b/skills/nfs-migration/references/gotchas.md index 8bb8fbd..a7f4d35 100644 --- a/skills/nfs-migration/references/gotchas.md +++ b/skills/nfs-migration/references/gotchas.md @@ -103,19 +103,18 @@ export default createFrontendPlugin({ pluginId: 'my-plugin', ... }); **Fix:** Use `plugin.tsx` (not `.ts`) for the NFS plugin file. Imports like `from './plugin'` resolve both extensions automatically. -## 12. Re-exporting legacy APIs from the root `index.ts` +## 12. Forgetting to keep legacy exports accessible -**Why:** For direct-to-GA, the root export (`.`) should be NFS-only. If you re-export legacy named exports from `index.ts`, consumers get both APIs from the same path, which defeats the purpose of the GA migration. +**Why:** NFS is not GA yet. Existing consumers import legacy APIs from the package root. If you remove or move those exports without re-exporting them, consumers break. -**Fix:** Legacy exports should only be reachable via the `./legacy` subpath: +**Fix:** Depends on approach: +- **Alpha approach:** Legacy stays at root — no changes needed. +- **Colocated approach:** Legacy source moves to `legacy.ts`, but must be re-exported from `index.ts`: ```tsx -// src/index.ts — NFS only +// src/index.ts — NFS default + legacy re-exports export { default } from './plugin'; export { isMyPluginAvailable } from './utils'; -// Do NOT re-export legacy APIs here - -// src/legacy.ts — legacy only, reachable via '@scope/my-plugin/legacy' -export { myPlugin, MyPage } from './legacyPlugin'; +export { myPlugin, MyPage } from './legacy'; // backward compat ``` ## 13. Double headers in NFS pages @@ -139,3 +138,4 @@ const link = useRouteRef(myRouteRef); // link might be undefined — check before calling const href = link?.() ?? '/fallback'; ``` + diff --git a/skills/nfs-migration/references/migrate-app-level.md b/skills/nfs-migration/references/migrate-app-level.md index cf82039..aa13667 100644 --- a/skills/nfs-migration/references/migrate-app-level.md +++ b/skills/nfs-migration/references/migrate-app-level.md @@ -34,6 +34,20 @@ const appElement = AppRootElementBlueprint.make({ }); ``` +## PluginWrapperBlueprint — wraps a single plugin's UI + +Use for context providers that should only wrap one plugin, not the entire app. Imported from `@backstage/frontend-plugin-api/alpha`. + +```tsx +import { PluginWrapperBlueprint } from '@backstage/frontend-plugin-api/alpha'; + +const myPluginWrapper = PluginWrapperBlueprint.make({ + params: { component: MyPluginProvider }, +}); +``` + +Unlike `AppRootWrapperBlueprint` (app-wide), this scopes the provider to your plugin's extensions only. Note: this is an `@alpha` API — the import path may change in future Backstage releases. + ## Shared components (legacy + NFS) Hooks like `useApi` and `useRouteRef` from `@backstage/core-plugin-api` work in both legacy and NFS contexts. Keep component imports on `core-plugin-api` so the same components serve both export paths: @@ -61,6 +75,7 @@ Import `compatWrapper` from `@backstage/core-compat-api`. Most plugins won't nee | Need invisible element at root (init, snackbars, FABs) | `AppRootElementBlueprint` | | Components using `useApi`/`useRouteRef` | Keep on `@backstage/core-plugin-api` — works in both systems | | Component depends on legacy context providers | Wrap with `compatWrapper()` (rare) | +| Provider scoped to one plugin only | `PluginWrapperBlueprint` | | Both wrapping and init logic needed | Use both separately — don't combine | All app-level extensions go in your plugin's `extensions` array (they belong to your plugin, not to another plugin). diff --git a/skills/nfs-migration/references/migrate-entity-content.md b/skills/nfs-migration/references/migrate-entity-content.md index ffda6c6..3bcc5dc 100644 --- a/skills/nfs-migration/references/migrate-entity-content.md +++ b/skills/nfs-migration/references/migrate-entity-content.md @@ -87,3 +87,70 @@ export const myCatalogModule = createFrontendModule({ ``` Export the module so consumers can include it in their app's `features` array. + +## EntityContextMenuItemBlueprint + +See `mount-point-mapping.md` for the migration pattern (replaces `entity.context.menu` mount point). + +## Entity tab groups + +Tabs are organized into groups on `page:catalog/entity`. Default groups: `overview`, `documentation`, `development`, `deployment`, `operation`, `observability`. + +**Plugin authors** assign content to a group via the `group` param: + +```tsx +const entityContent = EntityContentBlueprint.make({ + name: 'my-tab', + params: { + path: '/my-tab', + title: 'My Tab', + group: 'development', + loader: () => import('./MyTab').then(m => ), + }, +}); +``` + +**Operators** configure groups in `app-config.yaml`: + +```yaml +app: + extensions: + - page:catalog/entity: + config: + showNavItemIcons: true + groups: + - overview: + title: Overview + - documentation: + title: Documentation + - development: + title: Development + - custom: + title: My Custom Group + - deployment: false # hide this group +``` + +Set `group: false` on an `entity-content:*` extension to show it as a standalone tab outside any group. + +See `operator-config.md` for the full operator configuration reference. + +## Card layout: `type: info` vs `type: content` + +Entity overview uses `DefaultEntityContentLayout` with two card types: + +- **`type: info`** — renders in a sticky sidebar (right side). Use for compact summary cards like About, Links. +- **`type: content`** — renders in the main area (left side). Default if not specified. + +```yaml +app: + extensions: + - entity-card:catalog/about: + config: + type: info + - entity-card:catalog/links: + config: + type: info +``` + +Warnings (orphan, relation, processing errors) are built into the layout — no separate mount point configuration needed. + diff --git a/skills/nfs-migration/references/migrate-rhdh-extensions.md b/skills/nfs-migration/references/migrate-rhdh-extensions.md index 54a99cb..8f9a7d3 100644 --- a/skills/nfs-migration/references/migrate-rhdh-extensions.md +++ b/skills/nfs-migration/references/migrate-rhdh-extensions.md @@ -110,3 +110,108 @@ const myWrapper = AppRootWrapperBlueprint.make({ ``` Register via `createFrontendModule({ pluginId: 'app' })`. + +## IconBundleBlueprint — custom icon sets + +Replaces `appIcons` config. Registers multiple icons for use across the app (e.g. as string IDs in `config.icon` on page and entity-content extensions). + +```tsx +import { IconBundleBlueprint } from '@backstage/plugin-app-react'; + +const myIcons = IconBundleBlueprint.make({ + params: { + icons: { + fooIcon: , + barIcon: , + }, + }, +}); +``` + +Add to `createFrontendPlugin({ extensions: [...] })`. Icons are auto-discovered with the plugin. For a single icon on one page, using the `icon` param on `PageBlueprint` is simpler. + +## ThemeBlueprint — custom themes + +Replaces legacy `themes` config with `id`, `title`, `variant`, `importName`. + +```tsx +import { ThemeBlueprint } from '@backstage/plugin-app-react'; +import { lightTheme } from './lightTheme'; + +const customLightTheme = ThemeBlueprint.make({ + name: 'light', + params: { + theme: lightTheme, + title: 'Light', + variant: 'light', + icon: , + }, +}); +``` + +Use `name: 'light'` or `name: 'dark'` to override the built-in themes. Adopters can override the title via `app.extensions`: + +```yaml +app: + extensions: + - theme:my-plugin/light: + config: + title: Corporate Light +``` + +## FormFieldBlueprint — custom scaffolder fields + +Replaces legacy `scaffolderFieldExtensions` config with `importName`. + +```tsx +import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha'; + +export const myField = FormFieldBlueprint.make({ + name: 'MyCustomField', + params: { + schema: { /* JSON schema fragment */ }, + loader: async () => { + const { MyCustomField } = await import('./MyCustomField'); + return MyCustomField; + }, + }, +}); +``` + +Fields are auto-discovered via `formFieldsApiRef` when the plugin is installed. No YAML registration needed — template authors use the field name in `template.yaml` as before. + +## AddonBlueprint — TechDocs addons + +Replaces legacy `techdocsAddons` config with `importName`. + +```tsx +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; +import { TechDocsAddonLocations } from '@backstage/plugin-techdocs-react'; + +const exampleAddon = AddonBlueprint.make({ + name: 'example', + params: { + location: TechDocsAddonLocations.Content, + component: ExampleAddon, + }, +}); +``` + +Addons are collected via `techdocsAddonsApiRef` and merged into TechDocs reader and entity content extensions automatically. The `staticJSXContent` pattern from legacy dynamic plugins is no longer needed. + +## NavContentBlueprint — custom sidebar layout + +Replaces the entire sidebar navigation component. Use this as an escape hatch when you need custom navigation structure (e.g. RHDH nested `menuItems.parent` groups, which have no direct NFS equivalent). + +```tsx +import { NavContentBlueprint } from '@backstage/plugin-app-react'; + +const customNav = NavContentBlueprint.make({ + params: { + component: MyCustomSidebar, + }, +}); +``` + +Most plugins don't need this — standard page auto-discovery provides sidebar items. Only use when you need non-standard sidebar structure like nested groups. + diff --git a/skills/nfs-migration/references/migrate-translations.md b/skills/nfs-migration/references/migrate-translations.md index 8f10f55..bd89935 100644 --- a/skills/nfs-migration/references/migrate-translations.md +++ b/skills/nfs-migration/references/migrate-translations.md @@ -35,21 +35,40 @@ export { default as default } from './plugin'; export { myTranslationsModule } from './modules'; ``` -## App integration +## Auto-discovery via separate entry point (RHDH dynamic plugins) -The consuming app must include the module in its `features` array: +Modules targeting `pluginId: 'app'` are not auto-discovered by `app.packages: all` because they are not part of `createFrontendPlugin`. To make them auto-discoverable without explicit code changes in the consuming app, re-export the module as a **default export** from a separate file and add it as its own entry point in `package.json`: ```tsx -import myPlugin, { myTranslationsModule } from '@scope/my-plugin'; +// src/myTranslationsModuleExport.ts +export { myTranslationsModule as default } from './index'; +``` -createApp({ - features: [myPlugin, myTranslationsModule], -}); +```json +{ + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./my-translations-module": "./src/myTranslationsModuleExport.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": ["src/alpha.tsx"], + "my-translations-module": ["src/myTranslationsModuleExport.ts"], + "package.json": ["package.json"] + } + } +} ``` +Module federation treats each entry point as a separate remote. This lets the Backstage app load the module automatically without adding it to the `features` array. + +This pattern works for any `createFrontendModule` that targets a different plugin (init logic modules, translation modules, etc.). See the [quickstart plugin](https://github.com/redhat-developer/rhdh-plugins/tree/main/workspaces/quickstart/plugins/quickstart) for a real example. + ## Key rules - **Always** `pluginId: 'app'` — translations are app-level, not plugin-level - Each language gets its own `createTranslationResource` call -- Export the module as a named export alongside the default plugin export -- The app must explicitly opt in by adding the module to `features` +- Export the module as a named export from `index.ts` for direct consumers +- For auto-discovery in RHDH, add a separate entry point with a default export diff --git a/skills/nfs-migration/references/mount-point-mapping.md b/skills/nfs-migration/references/mount-point-mapping.md index 8d0e4ca..dba7408 100644 --- a/skills/nfs-migration/references/mount-point-mapping.md +++ b/skills/nfs-migration/references/mount-point-mapping.md @@ -16,7 +16,12 @@ RHDH's legacy dynamic plugin system used mount points in `app-config.dynamic.yam | `application/internal/drawer-state` | Init logic in `AppRootElementBlueprint` | `@backstage/frontend-plugin-api` | | `global.header/*` | `GlobalHeaderMenuItemBlueprint` | `@red-hat-developer-hub/backstage-plugin-global-header/alpha` | | `header/component`, `header/*` | `GlobalHeaderMenuItemBlueprint` | `@red-hat-developer-hub/backstage-plugin-global-header/alpha` | -| `appIcons` | `icon` param on `createFrontendPlugin` | — | +| `appIcons` | `IconBundleBlueprint` (or `icon` param on `createFrontendPlugin` for single icons) | `@backstage/plugin-app-react` | +| `entity.context.menu` | `EntityContextMenuItemBlueprint` | `@backstage/plugin-catalog-react/alpha` | +| `search.page.results` | `SearchResultListItemBlueprint` | `@backstage/plugin-search-react/alpha` | +| `search.page.filters` | `SearchFilterBlueprint` | `@backstage/plugin-search-react/alpha` | +| `search.page.types` | `SearchFilterResultTypeBlueprint` | `@backstage/plugin-search-react/alpha` | +| `application/header` | `AppRootElementBlueprint` | `@backstage/frontend-plugin-api` | ## Dynamic routes → PageBlueprint @@ -205,6 +210,136 @@ const myMenuItem = GlobalHeaderMenuItemBlueprint.make({ The `target` param maps to the header section: `help`, `profile`, `create`, etc. Use `priority` to control ordering. +## Entity context menu → EntityContextMenuItemBlueprint + +**Before (mount point):** +```yaml +mountPoints: + - mountPoint: entity.context.menu + importName: SimpleDialog + config: + props: + title: Open Dialog + icon: dialogIcon +``` + +**After (NFS):** +```tsx +import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha'; + +const myMenuItem = EntityContextMenuItemBlueprint.make({ + name: 'my-action', + params: { + icon: , + useProps() { + return { + title: 'Open Dialog', + onClick: () => { /* handle action */ }, + }; + }, + }, +}); +``` + +The `useProps` hook can call other React hooks and returns `{ title, onClick }` or `{ title, href }` plus optional `disabled`. + +## Search page results → SearchResultListItemBlueprint + +**Before (mount point):** +```yaml +mountPoints: + - mountPoint: search.page.results + importName: MySearchResultItem +``` + +**After (NFS):** +```tsx +import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; + +const mySearchItem = SearchResultListItemBlueprint.make({ + params: { + predicate: result => result.type === 'my-type', + component: async () => { + const { MyResultItem } = await import('./MyResultItem'); + return props => ; + }, + }, +}); +``` + +## Search page filters → SearchFilterBlueprint + +**Before (mount point):** +```yaml +mountPoints: + - mountPoint: search.page.filters + importName: MySearchFilter +``` + +**After (NFS):** +```tsx +import { SearchFilterBlueprint } from '@backstage/plugin-search-react/alpha'; + +const myFilter = SearchFilterBlueprint.make({ + params: { + loader: async () => { + const { MySearchFilter } = await import('./MySearchFilter'); + return props => ; + }, + }, +}); +``` + +## Search page types → SearchFilterResultTypeBlueprint + +**Before (mount point):** +```yaml +mountPoints: + - mountPoint: search.page.types + importName: MySearchType +``` + +**After (NFS):** +```tsx +import { SearchFilterResultTypeBlueprint } from '@backstage/plugin-search-react/alpha'; + +const myType = SearchFilterResultTypeBlueprint.make({ + params: { + value: 'my-type', + name: 'My Type', + icon: , + }, +}); +``` + +## Application header → AppRootElementBlueprint + +**Before (mount point):** +```yaml +mountPoints: + - mountPoint: application/header + importName: GlobalHeader + config: + position: above-main-content +``` + +**After (NFS):** +```tsx +import { AppRootElementBlueprint } from '@backstage/frontend-plugin-api'; + +const myHeader = AppRootElementBlueprint.make({ + name: 'my-header', + params: { + loader: async () => { + const { GlobalHeader } = await import('./GlobalHeader'); + return ; + }, + }, +}); +``` + +RHDH's global header plugin is being migrated to extension blueprints in `rhdh-plugins`. The `position: above-main-content` concept is app-layout-specific — verify layout behavior when migrating. + ## Real migration examples | Plugin | Mount points used | NFS blueprints | PR | diff --git a/skills/nfs-migration/references/operator-config.md b/skills/nfs-migration/references/operator-config.md new file mode 100644 index 0000000..a3691dc --- /dev/null +++ b/skills/nfs-migration/references/operator-config.md @@ -0,0 +1,123 @@ +# Operator Configuration Reference (New Frontend System) + +Operators and platform admins customize NFS apps through `app-config.yaml` keys, not plugin code. This reference covers the configuration surface. For plugin-author migration, see the other reference files. + +## `app.extensions` + +The primary tool for enabling, disabling, reordering, and configuring extensions. + +### Resolution rules + +1. All extensions from installed plugins are **auto-discovered** and loaded by default. +2. Entries in `app.extensions` **override** matching extensions by ID. +3. Extensions **listed** in `app.extensions` are **reordered** to appear first, in list order. Unlisted extensions keep their default order afterward. + +You typically list only extensions you want to customize — not the full inventory. + +### Extension ID format + +`[kind:]namespace[/name]` — for example `page:catalog`, `entity-card:catalog/about`, `entity-content:techdocs`. + +### Syntax + +```yaml +app: + extensions: + # Shorthand: enable with defaults + - entity-card:catalog/about + + # Shorthand: disable + - page:catalog-unprocessed-entities: false + + # Full form with config + - entity-card:catalog/links: + config: + filter: + kind: component + type: info +``` + +### Config merging caveat + +Backstage merges config files by **replacing entire arrays**. If `app.extensions` appears in multiple config files, the higher-priority file's array **replaces** the lower-priority one — entries are not merged entry-by-entry. Individual extension `config` objects are also replaced wholesale when overridden. + +Because unlisted extensions are still auto-discovered, a local override file can contain only the extensions you want to change. + +## `app.routes.bindings` + +Replaces legacy `routeBindings` in `dynamicPlugins.frontend`. Uses `pluginId.routeName` syntax: + +```yaml +app: + routes: + bindings: + catalog.viewTechDoc: techdocs.docRoot + catalog.createComponent: scaffolder.index + scaffolder.registerComponent: false # disable a binding +``` + +See [Frontend Routes](https://backstage.io/docs/frontend-system/architecture/routes/#binding-external-route-references) for details. + +## `app.packages` + +Controls which frontend plugin packages are auto-discovered: + +```yaml +app: + packages: all +``` + +Or restrict explicitly: + +```yaml +app: + packages: + include: + - '@backstage/plugin-catalog' + - '@backstage/plugin-techdocs' + exclude: [] +``` + +Dynamic plugins loaded at runtime through the frontend feature loader are discovered separately from this setting. + +## Scaffolder template grouping + +Group templates on the scaffolder page using `sub-page:scaffolder/templates`: + +```yaml +app: + extensions: + - sub-page:scaffolder/templates: + config: + groups: + - title: Recommended Services + filter: + spec.type: service + - title: Internal Tools + filter: + spec.type: tool +``` + +## Operator cheat sheet + +| Task | Legacy RHDH | New frontend system | +|------|-------------|---------------------| +| Install a plugin | `dynamic-plugins.yaml` entry | Same — `enabled: true` | +| Disable a plugin page | Remove route or `menuItem.enabled: false` | `page:my-plugin: false` | +| Rename sidebar item | `menuItem.text` | `page:my-plugin` → `config.title` | +| Reorder sidebar | `menuItems.*.priority` | Order in `app.extensions` | +| Hide entity overview card | Remove mount point entry | `entity-card:*: false` | +| Change card visibility filter | `mountPoints[].config.if` | `entity-card:*` → `config.filter` | +| Rename entity tab | `entityTabs[].title` | `entity-content:*` → `config.title` | +| Reorder / group entity tabs | `entityTabs` + `priority` | `page:catalog/entity` → `config.groups` | +| Hide entity tab | Negative `entityTabs` priority | `entity-content:*: false` | +| Bind cross-plugin routes | `routeBindings` | `app.routes.bindings` | +| Disable route binding | Omit binding | `app.routes.bindings.: false` | + +## What you cannot do from configuration alone + +- **Attach arbitrary exported components** to mount points without a matching NFS extension from the plugin. +- **Replicate `mountPoints[].config.layout`** grid column positioning — use card `type: info`/`type: content` or adjust the component layout. +- **Add a new entity tab** without a plugin that exports `entity-content:*`. +- **Add cards to General settings** until upstream exposes extension inputs on `sub-page:user-settings/general`. +- **Use RHDH-only mount points** (some global header slots) until equivalent NFS extensions exist. Application drawers have `AppDrawerContentBlueprint` — see `migrate-rhdh-extensions.md`. diff --git a/skills/nfs-migration/references/overview.md b/skills/nfs-migration/references/overview.md new file mode 100644 index 0000000..7e784e4 --- /dev/null +++ b/skills/nfs-migration/references/overview.md @@ -0,0 +1,48 @@ +# NFS Overview + +## What is the New Frontend System + +The Backstage New Frontend System (NFS) replaces the legacy frontend plugin API. Instead of manually wiring plugins into an app with `createPlugin`, `createRoutableExtension`, `FlatRoutes`, and imperative JSX route trees, NFS uses declarative extension **Blueprints** (`PageBlueprint`, `ApiBlueprint`, `EntityContentBlueprint`, etc.) and `createFrontendPlugin` from `@backstage/frontend-plugin-api`. + +Plugins declare what they provide. The app assembles itself from features: + +```ts +import { createApp } from '@backstage/frontend-defaults'; +const app = createApp({ features: [myPlugin, catalogPlugin, ...] }); +``` + +## Why migrate + +- **Declarative**: plugins describe their own routes, nav items, and APIs — no manual wiring in the app +- **Configurable**: extensions can be enabled, disabled, or reordered via `app-config.yaml` +- **Auto-discoverable**: apps detect installed plugins automatically via `app.packages: all` +- **Composable**: modules can inject extensions into other plugins (e.g. entity tabs into catalog) +- **Required**: the legacy APIs are being deprecated and will be removed + +## Deprecation timeline + +| Phase | Status | What happens | +|-------|--------|-------------| +| Current (RHDH 1.10) | NFS available as `./alpha` | Add NFS exports alongside legacy. Both work side-by-side. | +| Next | NFS becomes default | NFS moves to root (`.`), legacy moves to `./legacy`. | +| GA + 2 releases | Legacy removed | `./legacy` subpath is removed. Only NFS remains. | + +## Key concepts + +| Legacy | NFS equivalent | +|--------|---------------| +| `createPlugin` | `createFrontendPlugin` (default export) | +| `createRoutableExtension` | `PageBlueprint` | +| `createComponentExtension` | `EntityContentBlueprint`, `EntityCardBlueprint`, etc. | +| `createApiFactory` in plugin `apis` array | `ApiBlueprint` with `defineParams` wrapper | +| Manual route wiring in `App.tsx` | Auto-discovered from `PageBlueprint` `routeRef` | +| `menuItem` in dynamic routes config | Auto-discovered from `PageBlueprint` `title` + `icon` | +| `mountPoints` in `app-config.yaml` | Extension blueprints with built-in attach points | +| `routeBindings` in plugin config | `externalRoutes` on plugin + `app.routes.bindings` | + +## Two migration approaches + +- **Alpha (default)** — NFS at `./alpha`, legacy unchanged at root. No breaking changes for consumers. +- **Colocated** — NFS as default export in `index.ts`, legacy re-exported from `index.ts` for backward compat. + +See `package-json.md` for the export configuration for each approach. diff --git a/skills/nfs-migration/references/package-json.md b/skills/nfs-migration/references/package-json.md index 3570c48..0ee4cc1 100644 --- a/skills/nfs-migration/references/package-json.md +++ b/skills/nfs-migration/references/package-json.md @@ -1,62 +1,79 @@ # Package.json Export Configuration -## Direct to GA (recommended) +## Alpha approach (default) -NFS is the root export. Legacy moves to `./legacy`. +NFS at `./alpha`, legacy stays at root. No breaking changes for consumers. This is the recommended approach while NFS is not GA. ```json { "exports": { ".": "./src/index.ts", - "./legacy": "./src/legacy.ts", + "./alpha": "./src/alpha.tsx", "./package.json": "./package.json" }, "typesVersions": { "*": { - "legacy": ["src/legacy.ts"], + "alpha": ["src/alpha.tsx"], "package.json": ["package.json"] } - }, - "publishConfig": { - "access": "public", - "legacy": { - "types": "dist/legacy.d.ts", - "default": "dist/legacy.esm.js" - } } } ``` ### File layout -- `src/index.ts` — re-exports default from `plugin.tsx`, plus shared utilities (e.g. `isMyPluginAvailable`). **Do not re-export legacy APIs here** — they are only reachable via the `./legacy` subpath +- `src/index.ts` — existing legacy exports (unchanged) +- `src/alpha.tsx` — default-exports `createFrontendPlugin(...)`, named-exports modules. Use `.tsx` since blueprint loaders return JSX + +## Colocated approach + +NFS as default export in `index.ts`, legacy source in `legacy.ts` but re-exported from `index.ts` for backward compatibility. Use when you want both APIs available from the same import path. + +```json +{ + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } +} +``` + +### File layout + +- `src/index.ts` — default-exports `createFrontendPlugin` from `plugin.tsx`, AND re-exports legacy named exports from `legacy.ts` for backward compatibility - `src/plugin.tsx` — NFS plugin definition (`createFrontendPlugin` with blueprints, default export). Use `.tsx` since blueprint loaders return JSX - `src/legacy.ts` — old `createPlugin(...)` result with `@deprecated` JSDoc tags -## Phased approach +```tsx +// src/index.ts (colocated approach) +export { default } from './plugin'; +export { isMyPluginAvailable } from './utils'; -NFS at `./alpha`, legacy stays at root. +// Re-export legacy APIs for backward compatibility +export { myPlugin, MyPage, MyCard } from './legacy'; +``` + +## Module entry points for auto-discovery + +Modules targeting `pluginId: 'app'` (translations, init logic) are not auto-discovered by `app.packages: all`. To enable auto-discovery in RHDH dynamic plugins, add each module as a separate entry point with a default export: + +```tsx +// src/myTranslationsModuleExport.ts +export { myTranslationsModule as default } from './index'; +``` ```json { "exports": { ".": "./src/index.ts", "./alpha": "./src/alpha.tsx", + "./my-translations-module": "./src/myTranslationsModuleExport.ts", "./package.json": "./package.json" - }, - "typesVersions": { - "*": { - "alpha": ["src/alpha.tsx"], - "package.json": ["package.json"] - } } } ``` -### File layout - -- `src/index.ts` — existing legacy exports (unchanged) -- `src/alpha.tsx` — default-exports `createFrontendPlugin(...)`, named-exports modules +Module federation treats each entry point as a separate remote, so it gets loaded without explicit `features` array changes. See `migrate-translations.md` for the full pattern. ## Required backstage fields @@ -78,10 +95,50 @@ Ensure these exist in `package.json`: - `pluginId`: must match the `pluginId` passed to `createFrontendPlugin` - `pluginPackages`: array of all packages in this plugin family (frontend, backend, common, etc.) +## Scalprum configuration (dual-export period) + +RHDH dynamic plugins use a `scalprum` section in the derived package's `package.json` for the legacy Webpack module-federation container. Keep this working during migration. + +```json +{ + "scalprum": { + "name": "my-plugin-package", + "exposedModules": { + "PluginRoot": "./src/index.ts", + "FooModule": "./src/foo.ts" + } + } +} +``` + +- **`scalprum.name`** — Webpack container name. This is also the key under `dynamicPlugins.frontend` in operator YAML — it may differ from the npm package name. +- **`scalprum.exposedModules`** — maps module names to source entrypoints. Each key becomes a loadable entrypoint in the dynamic plugin bundle. + +Legacy wiring resolves modules via `module` (which `exposedModules` key to load, defaults to `PluginRoot`) and `importName` (which named export to render, defaults to default export): + +```yaml +dynamicPlugins: + frontend: + my-plugin-package: + mountPoints: + - mountPoint: entity.page.overview/cards + module: FooModule + importName: MyCard +``` + +The RHDH CLI `--scalprum-config` option can override this at export time. + +### Dual-export checklist + +- [ ] Legacy `scalprum.exposedModules` resolves all `importName`/`module` references in existing operator config +- [ ] `./alpha` export added for NFS (`createFrontendPlugin` default export) +- [ ] As each feature moves to NFS extensions, delete the corresponding `dynamicPlugins.frontend` YAML keys +- [ ] Re-export with a CLI version from the RHDH version matrix + ## Checklist -- [ ] `exports` field has `.` pointing to NFS entry +- [ ] `exports` field has `./alpha` (alpha approach) or `.` (colocated approach) pointing to NFS entry - [ ] `typesVersions` mirrors any sub-path exports -- [ ] `publishConfig` has types/default for each sub-path (GA approach) +- [ ] Legacy exports remain accessible (unchanged root for alpha; re-exported from `index.ts` for colocated) - [ ] `backstage.role` is `frontend-plugin` - [ ] `backstage.pluginId` matches `createFrontendPlugin({ pluginId: '...' })` diff --git a/skills/nfs-migration/references/support.md b/skills/nfs-migration/references/support.md index cd8dc48..213f2bd 100644 --- a/skills/nfs-migration/references/support.md +++ b/skills/nfs-migration/references/support.md @@ -8,6 +8,15 @@ - **[RHDH Documentation](https://docs.redhat.com/en/documentation/red_hat_developer_hub/)** — Official Red Hat Developer Hub documentation - **[Backstage NFS Docs](https://backstage.io/docs/frontend-system/)** — Upstream New Frontend System documentation, API reference, and migration guides +## Upstream Backstage docs + +- [Frontend System Introduction](https://backstage.io/docs/frontend-system/) +- [Migrating Plugins](https://backstage.io/docs/frontend-system/building-plugins/migrating/) +- [Migrating Apps](https://backstage.io/docs/frontend-system/building-apps/migrating/) +- [Configuring Extensions](https://backstage.io/docs/frontend-system/building-apps/configuring-extensions/) +- [Common Extension Blueprints](https://backstage.io/docs/frontend-system/building-plugins/common-extension-blueprints/) +- [Example `app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml) + ## When to escalate - **Build failures after migration** — Check `references/gotchas.md` first, then file an issue diff --git a/skills/nfs-migration/references/verification.md b/skills/nfs-migration/references/verification.md index 2a43b60..1907735 100644 --- a/skills/nfs-migration/references/verification.md +++ b/skills/nfs-migration/references/verification.md @@ -14,29 +14,6 @@ Run these in order. Stop and fix any failures before continuing. 8. **Entity tabs** (if applicable) — Navigate to an entity, verify tab appears 9. **Translations** (if applicable) — Switch language, verify strings update -## Playwright smoke test (optional) - -```ts -import { test, expect } from '@playwright/test'; - -test('plugin page renders', async ({ page }) => { - await page.goto('/my-plugin'); - await expect(page.locator('h1')).toContainText('My Plugin'); -}); - -test('nav item visible', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('nav')).toContainText('My Plugin'); -}); - -test('entity tab visible', async ({ page }) => { - await page.goto('/catalog/default/component/my-component'); - await expect(page.locator('[role="tab"]')).toContainText('My Plugin'); -}); -``` - -Adapt selectors to your plugin. These are starting points, not production-ready tests. - ## Testing principles (any framework) | What to verify | How | @@ -53,16 +30,24 @@ Adapt selectors to your plugin. These are starting points, not production-ready | Shared components | Component imports stay on `core-plugin-api` (work in both legacy and NFS) | | Legacy compat | Components using `compatWrapper` (if any) render without errors | +## Scalprum / dynamic plugin verification + +If dual-exporting (legacy + NFS), also verify: + +- [ ] `scalprum.exposedModules` still resolves all `importName`/`module` references in existing operator config +- [ ] OCI image rebuilt and smoke-tested in RHDH with `APP_CONFIG_app_packageName=app-next` + ## Consumer import check -After migrating, verify that any workspace apps (`packages/app`, dev apps) that import from the plugin still compile. Legacy consumers must update their imports to use the `./legacy` subpath: +After migrating, verify that any workspace apps (`packages/app`, dev apps) that import from the plugin still compile: ```bash -# Find any imports of legacy named exports from the plugin's root +# Find all imports from the plugin grep -r "from '@scope/my-plugin'" packages/ --include='*.ts' --include='*.tsx' ``` -If hits reference legacy exports (e.g. `MyPage`, `myPlugin`), update them to import from `'@scope/my-plugin/legacy'`. +- **Alpha approach:** No changes needed — legacy is still at the root. +- **Colocated approach:** Legacy re-exports from `index.ts` maintain compatibility — verify they resolve. ## Quick validation commands diff --git a/skills/rhdh-coding/references/nfs.md b/skills/rhdh-coding/references/nfs.md new file mode 100644 index 0000000..6e9c15b --- /dev/null +++ b/skills/rhdh-coding/references/nfs.md @@ -0,0 +1,141 @@ +# NFS (New Frontend System) Coding Patterns + +## Alpha File Structure (`src/alpha.tsx`) + +```tsx +import { + ApiBlueprint, createApiFactory, createFrontendModule, createFrontendPlugin, + createRouteRef, createSubRouteRef, PageBlueprint, +} from '@backstage/frontend-plugin-api'; +import { TranslationBlueprint } from '@backstage/plugin-app-react'; +import { EntityCardBlueprint, EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; +``` + +## Route Refs + +```tsx +const rootRouteRef = createRouteRef(); // bulk-import alpha.tsx +const detailRouteRef = createSubRouteRef({ parent: rootRouteRef, path: '/detail/:id' }); +``` + +Legacy `createRouteRef({ id: 'xxx' })` from `@backstage/core-plugin-api` also works (orchestrator) but prefer NFS-native. + +## PageBlueprint + +```tsx +// bulk-import alpha.tsx +PageBlueprint.make({ + params: { + path: '/bulk-import', routeRef: rootRouteRef, noHeader: true, + loader: () => import('./components').then(({ Router }) => ), + }, +}); +``` + +**noHeader**: RHDH plugins commonly use `noHeader: true` when the plugin renders its own header with breadcrumbs, tabs, or permission controls. Omit it if you want the framework-provided `PluginHeader`. + +## ApiBlueprint + +```tsx +// orchestrator alpha.tsx +ApiBlueprint.make({ + params: defineParams => defineParams( + createApiFactory({ + api: orchestratorApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new OrchestratorClient({ discoveryApi, identityApi }), + }), + ), +}); +``` + +Multiple APIs in one plugin require a `name`: `ApiBlueprint.make({ name: 'npmBackendApi', params: ... })`. + +## EntityContentBlueprint and EntityCardBlueprint + +```tsx +// orchestrator alpha.tsx — entity tab +EntityContentBlueprint.make({ + name: 'workflows', + params: { + path: '/workflows', title: 'Workflows', + filter: (entity) => Boolean(entity.metadata?.annotations?.['orchestrator.io/workflows']), + loader: () => import('./components/CatalogTab').then(m => ), + }, +}); +// npm alpha.tsx (community-plugins) — entity card +EntityCardBlueprint.make({ + name: 'EntityNpmInfoCard', + params: { + filter: isNpmAvailable, + loader: () => import('./components/EntityNpmInfoCard').then(m => ), + }, +}); +``` + +## Nav items + +`NavItemBlueprint` was removed. Nav items are now auto-discovered from `PageBlueprint` extensions with `title`, `icon`, and `routeRef`. No separate blueprint needed. + +## Plugin and Module Exports + +Default export = plugin. TranslationBlueprint must go in a separate module with `pluginId: 'app'`: + +```tsx +export default createFrontendPlugin({ + pluginId: 'bulk-import', + extensions: [bulkImportApi, bulkImportPage], + routes: { root: rootRouteRef, tasks: importHistoryRouteRef }, +}); +export const translationsModule = createFrontendModule({ + pluginId: 'app', + extensions: [TranslationBlueprint.make({ + name: 'bulk-import-translations', params: { resource: bulkImportTranslations }, + })], +}); +``` + +## Package Exports + +### New NFS-only plugins + +The NFS plugin is the root export (`.`). No `./alpha`, no `./legacy`, no +scalprum config, no `dist-scalprum/`. The app discovers and loads it via +`app.packages`. + +```json +{ "main": "src/index.ts", "types": "src/index.ts", + "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, + "backstage": { "role": "frontend-plugin", "pluginId": "my-plugin" } } +``` + +No scalprum section needed. No `export-dynamic-plugin` step. Package as a +standard npm package in an OCI image for deployment. + +### Migrated plugins (alpha approach — default) + +NFS at `./alpha`, legacy unchanged at root. No breaking changes for consumers: + +```json +{ "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.tsx", + "./package.json": "./package.json" + }, + "typesVersions": { "*": { "alpha": ["src/alpha.tsx"] } } } +``` + +- `src/index.ts` — existing legacy exports (unchanged) +- `src/alpha.tsx` — NFS plugin (`createFrontendPlugin`, Blueprints) + +The `scalprum.exposedModules` entries some plugins still have are transition +baggage — NFS apps don't load through scalprum. Remove when legacy is dropped. + +## compatWrapper + +`compatWrapper` from `@backstage/core-compat-api` bridges legacy components into NFS. + +- **Needed**: plugin uses `@material-ui/*` (MUI v4) or legacy route refs (tech-radar uses `compatWrapper` + `convertLegacyRouteRef`). +- **Not needed**: plugin uses `@mui/*` (MUI v5) and NFS-native route refs (orchestrator, bulk-import skip it). +- **Not needed for new NFS-only plugins**: no legacy code to bridge.