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
@@ -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 />
)),
},
});