From 2b4f97adf7d3b6225bf7195d6dafcdc32d0be065 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:00:55 -0400 Subject: [PATCH] docs: frontend plugin golden path (#33541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: frontend plugin golden path guide Signed-off-by: aramissennyeydd * add changeset Signed-off-by: aramissennyeydd * Apply suggestion from @aramissennyeydd Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Signed-off-by: aramissennyeydd * fix template Signed-off-by: aramissennyeydd * fix template test Signed-off-by: aramissennyeydd * print stderr on failure Signed-off-by: aramissennyeydd * try writing directly Signed-off-by: aramissennyeydd * maybe this? Signed-off-by: aramissennyeydd * address feedback Signed-off-by: aramissennyeydd * fix: avoid destructuring FetchApi and fix template issues - Use fetchApi.fetch() instead of destructuring to preserve this binding - Add discoveryApi and fetchApi to useAsync dependency array - Use react-use/esm/useAsync to match repo conventions - Replace waitFor + getAllByText with findByText in error test - Update HTTP client doc to match template changes Co-Authored-By: Claude Opus 4.6 Signed-off-by: aramissennyeydd * fix: address docs review feedback - Use stronger guidance tone in scaffolding guide intro - Slim down file tree to show folder structure only - Mention that plugin path depends on chosen plugin ID - Link to installation docs for non-discovery case - Quote page:todo YAML key to avoid parse errors - Remove "new" from "new frontend system" in template README Co-Authored-By: Claude Opus 4.6 Signed-off-by: aramissennyeydd * fix: improve error handling in e2e plugin creation - Narrow error to non-null object before using in operator - Also write error.stdout since tools like Jest report to stdout - Avoid variable shadowing with outer scope stdout/stderr Co-Authored-By: Claude Opus 4.6 Signed-off-by: aramissennyeydd * revert: keep destructured fetch from FetchApi Destructuring fetch from FetchApi is fine — revert to original pattern while keeping the dependency array and other fixes. Co-Authored-By: Claude Opus 4.6 Signed-off-by: aramissennyeydd * feat: use @backstage/ui components in frontend plugin template Replace core-components layout and table with @backstage/ui equivalents: - Use HeaderPage and Container instead of Page, Header, Content, ContentHeader - Use BUI Table with useTable and CellText instead of core-components Table - Add @backstage/ui to template package.json dependencies - Update poking-around docs to reflect BUI component usage Co-Authored-By: Claude Opus 4.6 Signed-off-by: aramissennyeydd * fix: add example data when backend request fails Signed-off-by: aramissennyeydd * better logging setup Signed-off-by: aramissennyeydd * address feedback Signed-off-by: aramissennyeydd * better config driven example Signed-off-by: aramissennyeydd * debug logs Signed-off-by: aramissennyeydd * fix build failure related to unknown version Signed-off-by: aramissennyeydd * revert e2e run changes Signed-off-by: aramissennyeydd * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> * skip the discovery api for now Signed-off-by: aramissennyeydd * remove another ref Signed-off-by: aramissennyeydd * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> --------- Signed-off-by: aramissennyeydd Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .changeset/silver-rice-pull.md | 5 + .../config/vocabularies/Backstage/accept.txt | 1 + .../plugins/frontend/001-first-steps.md | 89 ++++- .../plugins/frontend/002-poking-around.md | 89 ++++- .../plugins/frontend/003-dynamic-config.md | 112 ++++++- .../plugins/frontend/004-http-client.md | 110 ++++++- .../plugins/frontend/005-testing.md | 106 +++++- docs/golden-path/plugins/frontend/__meta__.md | 10 +- docs/golden-path/plugins/index.md | 1 + packages/cli-module-new/src/lib/version.ts | 2 + .../templates/frontend-plugin/README.md.hbs | 2 +- .../frontend-plugin/package.json.hbs | 4 +- .../ExampleComponent.test.tsx.hbs | 28 -- .../ExampleComponent/ExampleComponent.tsx.hbs | 37 --- .../src/components/ExampleComponent/index.ts | 1 - .../ExampleFetchComponent.test.tsx.hbs | 19 -- .../ExampleFetchComponent.tsx.hbs | 308 ------------------ .../components/ExampleFetchComponent/index.ts | 1 - .../src/components/TodoList/TodoList.test.tsx | 18 + .../src/components/TodoList/TodoList.tsx | 42 +++ .../src/components/TodoList/index.ts | 2 + .../components/TodoPage/TodoPage.test.tsx.hbs | 43 +++ .../src/components/TodoPage/TodoPage.tsx.hbs | 52 +++ .../src/components/TodoPage/index.ts | 1 + .../frontend-plugin/src/plugin.tsx.hbs | 6 +- 25 files changed, 653 insertions(+), 436 deletions(-) create mode 100644 .changeset/silver-rice-pull.md delete mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs delete mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs delete mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/index.ts delete mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs delete mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs delete mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/index.ts create mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.test.tsx create mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.tsx create mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/index.ts create mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.test.tsx.hbs create mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.tsx.hbs create mode 100644 packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/index.ts diff --git a/.changeset/silver-rice-pull.md b/.changeset/silver-rice-pull.md new file mode 100644 index 0000000000..1b4f966fa4 --- /dev/null +++ b/.changeset/silver-rice-pull.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-new': patch +--- + +Updated frontend-plugin template to provide a todo list visualization compatible with the backend plugin. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 0a96735646..f6cbd31439 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -568,6 +568,7 @@ VMware Vodafone VPCs VSCode +walkthrough walkthroughs Wayfair Weaveworks diff --git a/docs/golden-path/plugins/frontend/001-first-steps.md b/docs/golden-path/plugins/frontend/001-first-steps.md index 313a990b62..5ece316d9f 100644 --- a/docs/golden-path/plugins/frontend/001-first-steps.md +++ b/docs/golden-path/plugins/frontend/001-first-steps.md @@ -5,12 +5,95 @@ title: How to scaffold a new plugin? description: How to scaffold a new Backstage frontend plugin using the CLI --- -Running `yarn new` -> `frontend-plugin`. +# Scaffolding a new plugin + +Create a new frontend plugin package by running the following command in your +Backstage repository's root directory: + +```sh +yarn new --select frontend-plugin --option pluginId=todo --option owner= +``` + +This creates a new NPM package named something like `@internal/plugin-todo`, +depending on the flags passed to the `new` command and your settings in the root +`package.json`. For more options, see +[the CLI docs](../../../tooling/cli/03-commands.md#new). + +Creating the plugin takes a moment. Once the command finishes, a new folder +appears at `plugins/todo` (the path depends on the plugin ID you chose) with +a structure like this: + +``` +plugins/todo/ +├── dev/ # Standalone dev server setup +├── src/ +│ ├── components/ +│ │ ├── TodoList/ +│ │ └── TodoPage/ +│ └── ... # Plugin definition, routes, tests +└── package.json +``` ## What did we create? - +Here is a quick overview of the key files: + +- **`src/plugin.tsx`** — The main plugin definition. This is where the plugin + is created using `createFrontendPlugin` and where page extensions are + registered using `PageBlueprint`. + +- **`src/plugin.test.ts`** — Tests for the plugin definition. Verifies that + the plugin and its extensions are created correctly. + +- **`src/routes.ts`** — Route reference definitions used for navigation between plugins. + +- **`src/index.ts`** — The package entry point, which exports the plugin as + the default export. + +- **`src/components/TodoPage/`** — The main page component. It fetches todo + items from the backend and renders them using the `TodoList` component. + +- **`src/components/TodoList/`** — A presentational component that renders a + table of todo items using `@backstage/ui`. + +- **`dev/index.tsx`** — A standalone development app that loads only your + plugin. Run `yarn start` from the plugin directory to launch it. + +- **`package.json`** — Notice the `backstage.role` field is set to + `"frontend-plugin"`. This tells the Backstage tooling how to build and + treat the package. + +## Verifying the plugin + +If your app has feature discovery enabled (the default), your plugin is +automatically picked up. If you are not using feature discovery, see the +[installation docs](../../../frontend-system/building-apps/05-installing-plugins.md) +for how to manually add the plugin to your app. Start the full app from the +repository root: + +```sh +yarn start +``` + +Then navigate to `http://localhost:3000/todo` in your browser (the path +matches the plugin ID you chose). You see the todo page with a header and +example data. If you also have the backend todo plugin running, the page +displays your real todo items instead. + +Run the plugin in isolation using its standalone development server: + +```sh +yarn workspace @internal/plugin-todo start +``` ## Common issues - +- **Plugin page not showing up.** Verify that `app.packages` is set to `all` + in your `app-config.yaml`. If you use include/exclude filters, make sure your + plugin package is not excluded. +- **`yarn new --select frontend-plugin --option pluginId=todo --option owner=` fails during installation.** Make sure + you have run `yarn install` in the repository root first and that your + Node.js version matches the one required by the project. +- **TypeScript errors after scaffolding.** Run `yarn tsc` from the repository + root to check for type errors. A fresh scaffold should compile cleanly — if + not, try running `yarn install` again. diff --git a/docs/golden-path/plugins/frontend/002-poking-around.md b/docs/golden-path/plugins/frontend/002-poking-around.md index 33223019d0..0987251587 100644 --- a/docs/golden-path/plugins/frontend/002-poking-around.md +++ b/docs/golden-path/plugins/frontend/002-poking-around.md @@ -5,14 +5,93 @@ title: 002 - Poking around description: Exploring the default frontend plugin structure and components --- -Our frontend TODO plugin is a bit more simplistic than the backend one. We need to implement a new UI to replace the example components we have. +Walk through the code that `yarn new --select frontend-plugin --option pluginId=todo --option owner=` generated. -Let's use this React component to start. Copy this to `plugins/todo/src/components/TodoList.tsx`. +## Plugin definition + +Open `plugins/todo/src/plugin.tsx`. This is the entry point for the plugin: ```tsx -// todo +import { + createFrontendPlugin, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; + +import { rootRouteRef } from './routes'; + +export const page = PageBlueprint.make({ + params: { + path: '/todo', + routeRef: rootRouteRef, + loader: () => import('./components/TodoPage').then(m => ), + }, +}); + +export const todoPlugin = createFrontendPlugin({ + pluginId: 'todo', + extensions: [page], + routes: { + root: rootRouteRef, + }, +}); ``` -### Data Mocking +- `createFrontendPlugin` registers the plugin with Backstage. +- `PageBlueprint.make` defines a page extension — a route in the app that + lazy-loads the `TodoPage` component. +- `rootRouteRef` is a route reference that other plugins can use to link to + your plugin's page. -You already have a backend with dynamic data. Let's start a little smaller. Using hard coded data can be a great way to iterate quickly. +## The TodoPage component + +Open `plugins/todo/src/components/TodoPage/TodoPage.tsx`. This component +fetches data from the backend and renders it: + +```tsx +const { value: todos, loading, error } = useTodos(); +``` + +The `useTodos` hook uses Backstage's **`fetchApiRef`** to request +`plugin://todo/todos`. + +- **`fetchApiRef`** wraps the browser `fetch`, automatically injects + authentication credentials, and resolves the `plugin://` URL scheme to the + correct backend plugin endpoint (for example, + `http://localhost:7007/api/todo/todos`). + +If the backend is not running, the page falls back to example data so that +the plugin still renders correctly out of the box. + +## The TodoList component + +Open `plugins/todo/src/components/TodoList/TodoList.tsx`. This is a +presentational component that receives a list of todos as props and renders +them in a `Table` from `@backstage/ui`. + +The `TodoItem` type matches the shape returned by the backend plugin: + +```ts +export type TodoItem = { + title: string; + id: string; + createdBy: string; + createdAt: string; +}; +``` + +## Understanding the page structure + +The scaffolded plugin uses components from `@backstage/ui` and +`@backstage/core-components` to give the page a consistent look and feel +across all Backstage plugins: + +- The page's top bar is typically provided by the surrounding `PageLayout` + (commonly `PluginHeader` in the default app), rather than by a custom + `Header` inside the page component. +- `Container` is the main content area of the page (from `@backstage/ui`). +- `Table` renders a data table with column configuration (from `@backstage/ui`). +- `Progress` shows a loading indicator (from `@backstage/core-components`). + +Keeping your plugin visually consistent with the rest of Backstage is important +— users should feel at home regardless of which plugin they are interacting +with. diff --git a/docs/golden-path/plugins/frontend/003-dynamic-config.md b/docs/golden-path/plugins/frontend/003-dynamic-config.md index 8f3b7ff1ef..4732e328b2 100644 --- a/docs/golden-path/plugins/frontend/003-dynamic-config.md +++ b/docs/golden-path/plugins/frontend/003-dynamic-config.md @@ -5,26 +5,116 @@ title: 003 - Dynamic Config description: How to use dynamic configuration to control frontend plugin components --- -Your plugin should have been generated by default for the New Frontend System which is config-first. That means you can easily control your frontend components through your `app-config.yaml`. +Your plugin was generated for the frontend system, which is config-first. +That means you can control frontend components through `app-config.yaml` +without changing any code. -Let's try this quickly by disabling our entire TODO page, +## Disabling an extension -```yaml -# TODO +Every extension in the frontend system can be toggled on or off through +configuration. To disable the todo page entirely, add the following to your +`app-config.yaml`: + +```yaml title="app-config.yaml" +app: + extensions: + - 'page:todo': false ``` -We can also do really cool things like provide React props directly through config. Let's try moving our hard coded list of TODOs to config instead, +Start the app and try navigating to `/todo` — you get a "page not found" +response. Remove the line (or set it to `true`) to bring it back. + +## Configuring an extension + +Every extension blueprint supports its own set of configuration options that +adopters can set through `app-config.yaml`. `PageBlueprint` supports `path` +and `title` out of the box. To change the page title, add the following: + +```yaml title="app-config.yaml" +app: + extensions: + - page:todo: + config: + title: My Custom Todo List +``` + +Restart the app and you should see "My Custom Todo List" as the page title. +No code changes needed — the `PageBlueprint` reads the `title` config and +passes it to the page header automatically. + +## Adding custom configuration + +When the built-in config options are not enough, you can define your own +config schema. Values are validated automatically and passed to your +extension factory so that your components never need to read raw +configuration directly. + +For example, let's add a configurable subtitle. In `plugin.tsx`, switch +from `PageBlueprint.make` to `PageBlueprint.makeWithOverrides` and declare +a config schema: ```tsx -// todo +export const page = PageBlueprint.makeWithOverrides({ + config: { + schema: { + subtitle: z => z.string().optional(), + }, + }, + factory(origFactory, { config }) { + return origFactory({ + path: '/todo', + routeRef: rootRouteRef, + loader: () => + import('./components/TodoPage').then(m => ( + + )), + }); + }, +}); ``` -and the config, +Then update `TodoPage` to accept the new prop and render it: -```yaml -# TODO +```tsx +export function TodoPage({ subtitle }: { subtitle?: string }) { + // ... existing component code + return ( + + {subtitle && {subtitle}} + {/* rest of the page */} + + ); +} ``` -### Why does this work? +Adopters can now set the subtitle in their `app-config.yaml`: - +```yaml title="app-config.yaml" +app: + extensions: + - page:todo: + config: + subtitle: Things to get done today +``` + +The value flows from configuration, through the schema validation, into the +factory function, and finally into the component as a prop — no `configApiRef` +needed. + +## Why does this work? + +The frontend system treats configuration as a first-class concept. +Each extension is registered with the app under a unique ID (for example, +`page:todo`). The app reads the `app.extensions` section of the configuration +to decide which extensions to enable, disable, or reconfigure. + +Extension blueprints declare a `config.schema` using +[Zod](https://zod.dev/) validators. When the app starts, the framework +parses and validates the configuration against the schema, then passes the +result to the extension's factory function. This means your components +receive typed, validated values instead of reading raw configuration +strings at runtime. + +This config-first approach means that adopters of your plugin can customize +its behavior without forking the code — they only need to adjust their +configuration files. diff --git a/docs/golden-path/plugins/frontend/004-http-client.md b/docs/golden-path/plugins/frontend/004-http-client.md index 00678df1d6..cb7f3fd67a 100644 --- a/docs/golden-path/plugins/frontend/004-http-client.md +++ b/docs/golden-path/plugins/frontend/004-http-client.md @@ -5,14 +5,114 @@ title: 004 - HTTP Client description: How to build an HTTP client for your frontend plugin to fetch backend data --- -Now, let's really make our page dynamic. We'll start by writing an HTTP client by hand. +The scaffolded `TodoPage` already fetches data from the backend. Let's look at +how that works and how you can extend it. + +## How the scaffolded code works + +Open `plugins/todo/src/components/TodoPage/TodoPage.tsx` and look at the +`useTodos` hook: ```tsx -class TodoClient { - // TODO +function useTodos() { + const { fetch } = useApi(fetchApiRef); + + return useAsync(async (): Promise => { + const response = await fetch(`plugin://todo/todos`); + + if (!response.ok) { + throw new Error( + `Failed to fetch todos: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.items; + }, [fetch]); } ``` -## OpenAPI Generated Clients +Here, we're using Backstage's `fetchApi` which wraps the browser `fetch` and automatically does 2 things, -You can also skip a step and ensure your frontend and backend stay in sync by generating the client from an OpenAPI schema. +1. Injects authentication credentials - you don't need to attach any `Authorization` headers manually. +2. Resolves `plugin://` URL schemes to the real plugin URL for your instance. + +The `useAsync` hook from `react-use` runs the async function on mount and +returns `{ value, loading, error }`, which the component uses to show a +loading spinner, example todo items if the backend request fails, or the +fetched todo list. + +## Trying it out + +Make sure both the frontend and backend are running (`yarn start` from the +repository root starts both). Navigate to `http://localhost:3000/todo` and +you should see todos fetched from your backend. + +:::tip +You can create todos using `curl` as described in the +[backend golden path](../backend/002-poking-around.md), then refresh the +frontend page to see them appear. +::: + +## Extracting a client class + + + +For plugins with several endpoints, extracting a dedicated client class +keeps your components focused on rendering. Create +`plugins/todo/src/api/TodoClient.ts`: + +```ts +import { FetchApi } from '@backstage/frontend-plugin-api'; +import type { TodoItem } from '../components/TodoList'; + +export class TodoClient { + readonly #fetchApi: FetchApi; + + constructor(options: { fetchApi: FetchApi }) { + this.#fetchApi = options.fetchApi; + } + + async listTodos(): Promise { + const response = await this.#fetchApi.fetch(`plugin://todo/todos`); + + if (!response.ok) { + throw new Error( + `Failed to fetch todos: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.items; + } + + async createTodo(title: string): Promise { + const response = await this.#fetchApi.fetch(`plugin://todo/todos`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + + if (!response.ok) { + throw new Error( + `Failed to create todo: ${response.status} ${response.statusText}`, + ); + } + + return response.json(); + } +} +``` + +This is optional for the scaffolded example, but becomes valuable as +your plugin grows. + +## OpenAPI generated clients + +You can also keep your frontend and backend in sync by generating the +client from an OpenAPI schema. If your backend plugin exposes an OpenAPI +spec (see the +[backend golden path](../backend/001-first-steps.md) for details), +you can generate a type-safe client that updates automatically whenever the +API changes. This approach reduces the risk of the frontend and backend +drifting apart over time. diff --git a/docs/golden-path/plugins/frontend/005-testing.md b/docs/golden-path/plugins/frontend/005-testing.md index 6a672998aa..0767d8c073 100644 --- a/docs/golden-path/plugins/frontend/005-testing.md +++ b/docs/golden-path/plugins/frontend/005-testing.md @@ -5,12 +5,108 @@ title: 005 - Testing description: How to write tests for your Backstage frontend plugin components --- -Everyone's favorite part! Let's make sure our components continue to work even when we're not able to validate the changes. +The scaffolded plugin comes with tests already in place. Let's walk through +how they work and how to extend them. -## Unit Tests +## Unit tests -Use Jest + RTL + MSW v2. +Backstage frontend plugins use **Jest** as the test runner, +**React Testing Library** for rendering components, and **MSW** +(Mock Service Worker) for intercepting HTTP requests. -## Integration Tests +### TodoList tests -Use Playwright. +Open `plugins/todo/src/components/TodoList/TodoList.test.tsx`. The `TodoList` +component is presentational, so testing it is straightforward — pass in data +and verify it renders: + +```tsx +await renderInTestApp(); + +expect(screen.getByText('First task')).toBeInTheDocument(); +``` + +`renderInTestApp` from `@backstage/frontend-test-utils` sets up a minimal +Backstage app context around the component, providing all the standard APIs +that components might depend on. + +### TodoPage tests + +Open `plugins/todo/src/components/TodoPage/TodoPage.test.tsx`. The `TodoPage` +component fetches data from the backend, so the tests use MSW to intercept +HTTP requests and return test data: + +```tsx +const server = setupServer(); +registerMswTestHooks(server); + +it('renders todos from the backend', async () => { + server.use( + rest.get('*/api/todo/todos', (req, res, ctx) => + res( + ctx.json({ + items: [{ id: '1', title: 'Mocked task' /* ... */ }], + }), + ), + ), + ); + + await renderInTestApp(); + + expect(await screen.findByText('Mocked task')).toBeInTheDocument(); +}); +``` + +A few things to note: + +- **`registerMswTestHooks`** sets up and tears down the MSW server around + each test, so handlers do not leak between tests. +- **`screen.findByText`** returns a promise that waits for the element to + appear. This handles the asynchronous nature of data fetching without + needing explicit `waitFor` calls. +- The URL pattern `*/api/todo/todos` matches regardless of the host, which + keeps the test independent of the discovery API's resolved base URL. + +The tests also cover the error case — when the backend returns a 500 status, +the component falls back to rendering example todo items instead of +displaying an error panel. + +### Running the tests + +From the repository root: + +```sh +yarn test plugins/todo +``` + +### Writing additional tests + +As you add features to your plugin, follow the same patterns: + +- For presentational components, pass props and assert on rendered output. +- For components that fetch data, use MSW to mock the HTTP responses. +- Prefer `screen.findByText` over `waitFor` for async assertions. +- Test both the success and error paths. + +## Integration tests + +For end-to-end validation, you can use **Playwright** to test your plugin in +a real browser against a running Backstage instance. Integration tests catch +issues that unit tests cannot, such as routing problems, CSS regressions, or +issues with the full API round-trip. + +A basic Playwright test for the todo page might look like this: + +```ts +import { test, expect } from '@playwright/test'; + +test('todo page shows the todo list', async ({ page }) => { + await page.goto('/todo'); + await expect(page.getByText('Welcome to todo!')).toBeVisible(); + await expect(page.getByRole('table')).toBeVisible(); +}); +``` + +Integration tests are most valuable for critical user flows. For most +component-level validation, the unit testing approach described above +provides faster feedback with less setup. diff --git a/docs/golden-path/plugins/frontend/__meta__.md b/docs/golden-path/plugins/frontend/__meta__.md index 088d72ce90..892dd235b3 100644 --- a/docs/golden-path/plugins/frontend/__meta__.md +++ b/docs/golden-path/plugins/frontend/__meta__.md @@ -14,19 +14,17 @@ How to handle common errors. ## First steps with the new plugin -### Creating a todo plugin +### Exploring the scaffolded code -We're going to be creating the frontend for a todo list plugin. We want the user to be able to create todos for themselves and show the user their current list of todos. - -To start, we'll use a list of mocked data. +Walk through the generated TodoPage and TodoList components, the plugin definition, and how they fit together. ### Controlling your component dynamically -Update the mocked data to be controlled by config. +Show how to disable extensions and configure them dynamically using a PageBlueprint config schema and validated config passed into factories (without using configApiRef). ### HTTP API -We want our todo plugin to reach the backend that we implemented in [the backend plugin Golden Path](../backend/001-first-steps.md). Let's write a client to do this (or use OpenAPI to generate a client for us). +The scaffolded plugin already fetches from the backend. Walk through how discoveryApiRef and fetchApiRef work together, and show how to extract a client class. ### Testing diff --git a/docs/golden-path/plugins/index.md b/docs/golden-path/plugins/index.md index 43ffe612e8..c1a926f32f 100644 --- a/docs/golden-path/plugins/index.md +++ b/docs/golden-path/plugins/index.md @@ -32,3 +32,4 @@ To start, this guide will walk through creating a backend plugin. You'll get you - [Why build plugins?](./why-build-plugins.md) - [Sustainable plugin development](./sustainable-plugin-development.md) - [Golden path: Backend plugins](./backend/001-first-steps.md) +- [Golden path: Frontend plugins](./frontend/001-first-steps.md) diff --git a/packages/cli-module-new/src/lib/version.ts b/packages/cli-module-new/src/lib/version.ts index 27e63bafdf..fcfde359c1 100644 --- a/packages/cli-module-new/src/lib/version.ts +++ b/packages/cli-module-new/src/lib/version.ts @@ -49,6 +49,7 @@ import { version as frontendTestUtils } from '../../../frontend-test-utils/packa import { version as testUtils } from '../../../test-utils/package.json'; import { version as theme } from '../../../theme/package.json'; import { version as types } from '../../../types/package.json'; +import { version as ui } from '../../../ui/package.json'; import { version as authBackend } from '../../../../plugins/auth-backend/package.json'; import { version as authBackendModuleGuestProvider } from '../../../../plugins/auth-backend-module-guest-provider/package.json'; import { version as catalogNode } from '../../../../plugins/catalog-node/package.json'; @@ -74,6 +75,7 @@ export const packageVersions: Record = { '@backstage/test-utils': testUtils, '@backstage/theme': theme, '@backstage/types': types, + '@backstage/ui': ui, '@backstage/plugin-scaffolder-node': scaffolderNode, '@backstage/plugin-scaffolder-node-test-utils': scaffolderNodeTestUtils, '@backstage/plugin-auth-backend': authBackend, diff --git a/packages/cli-module-new/templates/frontend-plugin/README.md.hbs b/packages/cli-module-new/templates/frontend-plugin/README.md.hbs index 3f1ff3e4f7..4da2dcdb54 100644 --- a/packages/cli-module-new/templates/frontend-plugin/README.md.hbs +++ b/packages/cli-module-new/templates/frontend-plugin/README.md.hbs @@ -10,7 +10,7 @@ Your plugin has been added to the app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/{{pluginId}}](http://localhost:3000/{{pluginId}}). -This plugin is built with Backstage's [new frontend +This plugin is built with Backstage's [frontend system](https://backstage.io/docs/frontend-system/architecture/index), and you can find more information about building plugins in the [plugin builder documentation](https://backstage.io/docs/frontend-system/building-plugins/index). diff --git a/packages/cli-module-new/templates/frontend-plugin/package.json.hbs b/packages/cli-module-new/templates/frontend-plugin/package.json.hbs index 955c6077b8..9f00ae8539 100644 --- a/packages/cli-module-new/templates/frontend-plugin/package.json.hbs +++ b/packages/cli-module-new/templates/frontend-plugin/package.json.hbs @@ -25,9 +25,7 @@ "@backstage/core-components": "{{versionQuery '@backstage/core-components'}}", "@backstage/frontend-plugin-api": "{{versionQuery '@backstage/frontend-plugin-api'}}", "@backstage/theme": "{{versionQuery '@backstage/theme'}}", - "@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}", - "@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}", - "@material-ui/lab": "{{versionQuery '@material-ui/lab' '4.0.0-alpha.61'}}", + "@backstage/ui": "{{versionQuery '@backstage/ui'}}", "react-use": "{{versionQuery 'react-use' '17.2.4'}}" }, "peerDependencies": { diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs deleted file mode 100644 index d1a8a93d0b..0000000000 --- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs +++ /dev/null @@ -1,28 +0,0 @@ -import { ExampleComponent } from './ExampleComponent'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { screen } from '@testing-library/react'; -import { - registerMswTestHooks, - renderInTestApp, -} from '@backstage/frontend-test-utils'; - -describe('ExampleComponent', () => { - const server = setupServer(); - // Enable sane handlers for network requests - registerMswTestHooks(server); - - // setup mock response - beforeEach(() => { - server.use( - rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))), - ); - }); - - it('should render', async () => { - await renderInTestApp(); - expect( - screen.getByText('Welcome to {{pluginId}}!'), - ).toBeInTheDocument(); - }); -}); diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs deleted file mode 100644 index d117fad131..0000000000 --- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs +++ /dev/null @@ -1,37 +0,0 @@ -import { Typography, Grid } from '@material-ui/core'; -import { - InfoCard, - Header, - Page, - Content, - ContentHeader, - HeaderLabel, - SupportButton, -} from '@backstage/core-components'; -import { ExampleFetchComponent } from '../ExampleFetchComponent'; - -export const ExampleComponent = () => ( - -
- - -
- - - A description of your plugin goes here. - - - - - - All content should be wrapped in a card like this. - - - - - - - - -
-); diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/index.ts deleted file mode 100644 index 8b8437521b..0000000000 --- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleComponent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ExampleComponent } from './ExampleComponent'; diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs deleted file mode 100644 index c9b553287f..0000000000 --- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs +++ /dev/null @@ -1,19 +0,0 @@ -import { renderInTestApp } from '@backstage/frontend-test-utils'; -import { ExampleFetchComponent } from './ExampleFetchComponent'; - -describe('ExampleFetchComponent', () => { - it('renders the user table', async () => { - const { getAllByText, getByAltText, getByText, findByRole } = - await renderInTestApp(); - - // Wait for the table to render - const table = await findByRole('table'); - const nationality = getAllByText('GB'); - // Assert that the table contains the expected user data - expect(table).toBeInTheDocument(); - expect(getByAltText('Carolyn')).toBeInTheDocument(); - expect(getByText('Carolyn Moore')).toBeInTheDocument(); - expect(getByText('carolyn.moore@example.com')).toBeInTheDocument(); - expect(nationality[0]).toBeInTheDocument(); - }); -}); diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs deleted file mode 100644 index bd6bd9d75b..0000000000 --- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs +++ /dev/null @@ -1,308 +0,0 @@ -import { makeStyles } from '@material-ui/core/styles'; -import { - Table, - TableColumn, - Progress, - ResponseErrorPanel, -} from '@backstage/core-components'; -import useAsync from 'react-use/lib/useAsync'; - -export const exampleUsers = { - results: [ - { - gender: 'female', - name: { - title: 'Miss', - first: 'Carolyn', - last: 'Moore', - }, - email: 'carolyn.moore@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Carolyn', - nat: 'GB', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Esma', - last: 'Berberoğlu', - }, - email: 'esma.berberoglu@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Esma', - nat: 'TR', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Isabella', - last: 'Rhodes', - }, - email: 'isabella.rhodes@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella', - nat: 'GB', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Derrick', - last: 'Carter', - }, - email: 'derrick.carter@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Derrick', - nat: 'IE', - }, - { - gender: 'female', - name: { - title: 'Miss', - first: 'Mattie', - last: 'Lambert', - }, - email: 'mattie.lambert@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mattie', - nat: 'AU', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Mijat', - last: 'Rakić', - }, - email: 'mijat.rakic@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mijat', - nat: 'RS', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Javier', - last: 'Reid', - }, - email: 'javier.reid@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Javier', - nat: 'US', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Isabella', - last: 'Li', - }, - email: 'isabella.li@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella', - nat: 'CA', - }, - { - gender: 'female', - name: { - title: 'Mrs', - first: 'Stephanie', - last: 'Garrett', - }, - email: 'stephanie.garrett@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Stephanie', - nat: 'AU', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Antonia', - last: 'Núñez', - }, - email: 'antonia.nunez@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Antonia', - nat: 'ES', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Donald', - last: 'Young', - }, - email: 'donald.young@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Donald', - nat: 'US', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Iegor', - last: 'Holodovskiy', - }, - email: 'iegor.holodovskiy@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Iegor', - nat: 'UA', - }, - { - gender: 'female', - name: { - title: 'Madame', - first: 'Jessica', - last: 'David', - }, - email: 'jessica.david@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jessica', - nat: 'CH', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Eve', - last: 'Martinez', - }, - email: 'eve.martinez@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Eve', - nat: 'FR', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Caleb', - last: 'Silva', - }, - email: 'caleb.silva@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Caleb', - nat: 'US', - }, - { - gender: 'female', - name: { - title: 'Miss', - first: 'Marcia', - last: 'Jenkins', - }, - email: 'marcia.jenkins@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Marcia', - nat: 'US', - }, - { - gender: 'female', - name: { - title: 'Mrs', - first: 'Mackenzie', - last: 'Jones', - }, - email: 'mackenzie.jones@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mackenzie', - nat: 'NZ', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Jeremiah', - last: 'Gutierrez', - }, - email: 'jeremiah.gutierrez@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jeremiah', - nat: 'AU', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Luciara', - last: 'Souza', - }, - email: 'luciara.souza@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Luciara', - nat: 'BR', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Valgi', - last: 'da Cunha', - }, - email: 'valgi.dacunha@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Valgi', - nat: 'BR', - }, - ], -}; - -const useStyles = makeStyles({ - avatar: { - height: 32, - width: 32, - borderRadius: '50%', - }, -}); - -type User = { - gender: string; // "male" - name: { - title: string; // "Mr", - first: string; // "Duane", - last: string; // "Reed" - }; - email: string; // "duane.reed@example.com" - picture: string; // "https://api.dicebear.com/6.x/open-peeps/svg?seed=Duane" - nat: string; // "AU" -}; - -type DenseTableProps = { - users: User[]; -}; - -export const DenseTable = ({ users }: DenseTableProps) => { - const classes = useStyles(); - - const columns: TableColumn[] = [ - { title: 'Avatar', field: 'avatar' }, - { title: 'Name', field: 'name' }, - { title: 'Email', field: 'email' }, - { title: 'Nationality', field: 'nationality' }, - ]; - - const data = users.map(user => { - return { - avatar: ( - {user.name.first} - ), - name: `${user.name.first} ${user.name.last}`, - email: user.email, - nationality: user.nat, - }; - }); - - return ( - - ); -}; - -export const ExampleFetchComponent = () => { - - const { value, loading, error } = useAsync(async (): Promise => { - // Would use fetch in a real world example - return exampleUsers.results; - }, []); - - if (loading) { - return ; - } else if (error) { - return ; - } - - return ; -}; diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/index.ts deleted file mode 100644 index 41a43e84f1..0000000000 --- a/packages/cli-module-new/templates/frontend-plugin/src/components/ExampleFetchComponent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ExampleFetchComponent } from './ExampleFetchComponent'; diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.test.tsx b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.test.tsx new file mode 100644 index 0000000000..fd1f4620b4 --- /dev/null +++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.test.tsx @@ -0,0 +1,18 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { TodoList } from './TodoList'; + +describe('TodoList', () => { + it('renders a list of todos', async () => { + const todos = [ + { id: '1', title: 'First task', createdBy: 'user:default/guest', createdAt: '2025-01-01T00:00:00.000Z' }, + { id: '2', title: 'Second task', createdBy: 'user:default/admin', createdAt: '2025-01-02T00:00:00.000Z' }, + ]; + + await renderInTestApp(); + + expect(await screen.findByText('First task')).toBeInTheDocument(); + expect(await screen.findByText('Second task')).toBeInTheDocument(); + expect(await screen.findByText('user:default/guest')).toBeInTheDocument(); + }); +}); diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.tsx b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..63cd37483d --- /dev/null +++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/TodoList.tsx @@ -0,0 +1,42 @@ +import { Table, useTable, CellText, type ColumnConfig } from '@backstage/ui'; + +export type TodoItem = { + title: string; + id: string; + createdBy: string; + createdAt: string; +}; + +const columns: ColumnConfig[] = [ + { + id: 'title', + label: 'Title', + cell: item => , + }, + { + id: 'createdBy', + label: 'Created by', + cell: item => , + }, + { + id: 'createdAt', + label: 'Created at', + cell: item => , + }, +]; + +export const TodoList = ({ todos }: { todos: TodoItem[] }) => { + const { tableProps } = useTable({ + mode: 'complete', + data: todos, + paginationOptions: { pageSize: todos.length || 1 }, + }); + + return ( +
+ ); +}; diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/index.ts new file mode 100644 index 0000000000..b71610df90 --- /dev/null +++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoList/index.ts @@ -0,0 +1,2 @@ +export { TodoList } from './TodoList'; +export type { TodoItem } from './TodoList'; diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.test.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.test.tsx.hbs new file mode 100644 index 0000000000..309f03fbfa --- /dev/null +++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.test.tsx.hbs @@ -0,0 +1,43 @@ +import { screen } from '@testing-library/react'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { + registerMswTestHooks, + renderInTestApp, +} from '@backstage/frontend-test-utils'; +import { TodoPage } from './TodoPage'; + +describe('TodoPage', () => { + const server = setupServer(); + registerMswTestHooks(server); + + it('renders todos from the backend', async () => { + server.use( + rest.get('*/api/{{pluginId}}/todos', (_, res, ctx) => + res( + ctx.json({ + items: [ + { id: '1', title: 'Mocked task', createdBy: 'user:default/guest', createdAt: '2025-01-01T00:00:00.000Z' }, + ], + }), + ), + ), + ); + + await renderInTestApp(); + + expect(await screen.findByText('Mocked task')).toBeInTheDocument(); + }); + + it('falls back to example data when the backend fails', async () => { + server.use( + rest.get('*/api/{{pluginId}}/todos', (_, res, ctx) => + res(ctx.status(500), ctx.json({ message: 'Internal Server Error' })), + ), + ); + + await renderInTestApp(); + + expect(await screen.findByText('Install the backend plugin')).toBeInTheDocument(); + }); +}); diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.tsx.hbs new file mode 100644 index 0000000000..d650cfed08 --- /dev/null +++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/TodoPage.tsx.hbs @@ -0,0 +1,52 @@ +import { Progress } from '@backstage/core-components'; +import { + useApi, + fetchApiRef, +} from '@backstage/frontend-plugin-api'; +import { Header, Container } from '@backstage/ui'; +import useAsync from 'react-use/esm/useAsync'; +import { TodoList } from '../TodoList'; +import type { TodoItem } from '../TodoList'; + +const exampleTodos: TodoItem[] = [ + { id: '1', title: 'Install the backend plugin', createdBy: 'user:default/guest', createdAt: new Date().toISOString() }, + { id: '2', title: 'Connect the frontend to real data', createdBy: 'user:default/guest', createdAt: new Date().toISOString() }, +]; + +// TEMPLATE NOTE: +// This is a simple example of fetching data from the backend plugin. +// You can replace this with your own data fetching logic or use a +// generated client from an OpenAPI schema. +function useTodos() { + const { fetch } = useApi(fetchApiRef); + + return useAsync(async (): Promise => { + const response = await fetch(`plugin://{{pluginId}}/todos`); + + if (!response.ok) { + throw new Error( + `Failed to fetch todos: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.items; + }, [fetch]); +} + +export const TodoPage = () => { + const { value: todos, loading, error } = useTodos(); + + if (loading) { + return ; + } + + return ( + <> +
+ + + + + ); +}; diff --git a/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/index.ts b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/index.ts new file mode 100644 index 0000000000..bfae53f4db --- /dev/null +++ b/packages/cli-module-new/templates/frontend-plugin/src/components/TodoPage/index.ts @@ -0,0 +1 @@ +export { TodoPage } from './TodoPage'; diff --git a/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs b/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs index 435f9d4d8e..bb4aa701fd 100644 --- a/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs +++ b/packages/cli-module-new/templates/frontend-plugin/src/plugin.tsx.hbs @@ -10,9 +10,9 @@ export const page = PageBlueprint.make({ path: '/{{pluginId}}', routeRef: rootRouteRef, loader: () => - import('./components/ExampleComponent').then(m => - , - ), + import('./components/TodoPage').then(m => ( + + )), }, });