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:
Aramis Sennyey
2026-04-10 14:00:55 -04:00
committed by GitHub
parent 05b1de4321
commit 2b4f97adf7
25 changed files with 653 additions and 436 deletions
+5
View File
@@ -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
+1
View File
@@ -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": {
@@ -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();
});
});
@@ -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 +0,0 @@
export { ExampleComponent } from './ExampleComponent';
@@ -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();
});
});
@@ -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 +0,0 @@
export { ExampleFetchComponent } from './ExampleFetchComponent';
@@ -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();
});
});
@@ -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';
@@ -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();
});
});
@@ -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 />
)),
},
});