docs: frontend plugin golden path (#33541)
* docs: frontend plugin golden path guide Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * add changeset Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * Apply suggestion from @aramissennyeydd Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * fix template Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * fix template test Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * print stderr on failure Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * try writing directly Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * maybe this? Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * address feedback Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * 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 <noreply@anthropic.com> Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * 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 <noreply@anthropic.com> Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * 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 <noreply@anthropic.com> Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * 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 <noreply@anthropic.com> Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * 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 <noreply@anthropic.com> Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * fix: add example data when backend request fails Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * better logging setup Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * address feedback Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * better config driven example Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * debug logs Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * fix build failure related to unknown version Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * revert e2e run changes Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * 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 <aramis.sennyey@doordash.com> * remove another ref Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com> * 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 <aramis.sennyey@doordash.com> Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli-module-new': patch
|
||||
---
|
||||
|
||||
Updated frontend-plugin template to provide a todo list visualization compatible with the backend plugin.
|
||||
@@ -568,6 +568,7 @@ VMware
|
||||
Vodafone
|
||||
VPCs
|
||||
VSCode
|
||||
walkthrough
|
||||
walkthroughs
|
||||
Wayfair
|
||||
Weaveworks
|
||||
|
||||
@@ -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?
|
||||
|
||||
<!--TODO-->
|
||||
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
|
||||
|
||||
<!--TODO-->
|
||||
- **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.
|
||||
|
||||
@@ -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 => <m.TodoPage />),
|
||||
},
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 => (
|
||||
<m.TodoPage subtitle={config.subtitle} />
|
||||
)),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
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 (
|
||||
<Container>
|
||||
{subtitle && <Typography variant="subtitle1">{subtitle}</Typography>}
|
||||
{/* rest of the page */}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Why does this work?
|
||||
Adopters can now set the subtitle in their `app-config.yaml`:
|
||||
|
||||
<!--TODO-->
|
||||
```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.
|
||||
|
||||
@@ -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<TodoItem[]> => {
|
||||
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://<pluginId>` 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
|
||||
|
||||
<!-- TODO: Update this to be a Utility API + discuss mocking in tests. -->
|
||||
|
||||
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<TodoItem[]> {
|
||||
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<TodoItem> {
|
||||
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.
|
||||
|
||||
@@ -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(<TodoList todos={todos} />);
|
||||
|
||||
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(<TodoPage />);
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'@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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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": {
|
||||
|
||||
-28
@@ -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(<ExampleComponent />);
|
||||
expect(
|
||||
screen.getByText('Welcome to {{pluginId}}!'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-37
@@ -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 = () => (
|
||||
<Page themeId="tool">
|
||||
<Header title="Welcome to {{pluginId}}!" subtitle="Optional subtitle">
|
||||
<HeaderLabel label="Owner" value="Team X" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Plugin title">
|
||||
<SupportButton>A description of your plugin goes here.</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard title="Information card">
|
||||
<Typography variant="body1">
|
||||
All content should be wrapped in a card like this.
|
||||
</Typography>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ExampleFetchComponent />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { ExampleComponent } from './ExampleComponent';
|
||||
-19
@@ -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(<ExampleFetchComponent />);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
-308
@@ -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: (
|
||||
<img
|
||||
src={user.picture}
|
||||
className={classes.avatar}
|
||||
alt={user.name.first}
|
||||
/>
|
||||
),
|
||||
name: `${user.name.first} ${user.name.last}`,
|
||||
email: user.email,
|
||||
nationality: user.nat,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Table
|
||||
title="Example User List"
|
||||
options=\{{ search: false, paging: false }}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExampleFetchComponent = () => {
|
||||
|
||||
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
|
||||
// Would use fetch in a real world example
|
||||
return exampleUsers.results;
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
return <DenseTable users={value || []} />;
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { ExampleFetchComponent } from './ExampleFetchComponent';
|
||||
+18
@@ -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(<TodoList todos={todos} />);
|
||||
|
||||
expect(await screen.findByText('First task')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Second task')).toBeInTheDocument();
|
||||
expect(await screen.findByText('user:default/guest')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+42
@@ -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<TodoItem>[] = [
|
||||
{
|
||||
id: 'title',
|
||||
label: 'Title',
|
||||
cell: item => <CellText title={item.title} />,
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
label: 'Created by',
|
||||
cell: item => <CellText title={item.createdBy} />,
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
label: 'Created at',
|
||||
cell: item => <CellText title={new Date(item.createdAt).toLocaleString()} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const TodoList = ({ todos }: { todos: TodoItem[] }) => {
|
||||
const { tableProps } = useTable({
|
||||
mode: 'complete',
|
||||
data: todos,
|
||||
paginationOptions: { pageSize: todos.length || 1 },
|
||||
});
|
||||
|
||||
return (
|
||||
<Table
|
||||
columnConfig={columns}
|
||||
{...tableProps}
|
||||
pagination={{ type: 'none' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TodoList } from './TodoList';
|
||||
export type { TodoItem } from './TodoList';
|
||||
+43
@@ -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(<TodoPage />);
|
||||
|
||||
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(<TodoPage />);
|
||||
|
||||
expect(await screen.findByText('Install the backend plugin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+52
@@ -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<TodoItem[]> => {
|
||||
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 <Progress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Welcome to {{pluginId}}!" />
|
||||
<Container>
|
||||
<TodoList todos={error ? exampleTodos : (todos ?? [])} />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { TodoPage } from './TodoPage';
|
||||
@@ -10,9 +10,9 @@ export const page = PageBlueprint.make({
|
||||
path: '/{{pluginId}}',
|
||||
routeRef: rootRouteRef,
|
||||
loader: () =>
|
||||
import('./components/ExampleComponent').then(m =>
|
||||
<m.ExampleComponent />,
|
||||
),
|
||||
import('./components/TodoPage').then(m => (
|
||||
<m.TodoPage />
|
||||
)),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user