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:
@@ -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