Merge pull request #4251 from backstage/rugvip/extmpl

cli: update frontend plugin template to use new extension API
This commit is contained in:
Patrik Oldsberg
2021-02-01 13:01:47 +01:00
committed by GitHub
21 changed files with 188 additions and 123 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Update `create-plugin` template to use the new composability API, by switching to exporting a single routable extension component.
+1
View File
@@ -0,0 +1 @@
<mxfile host="Chrome" modified="2021-01-03T17:00:54.430Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" version="13.9.5" etag="DwPjZwigIikbKX1hFX51" type="device"><diagram id="WdRoEH4gdfKcJUWp3sm5" name="Page-1">7VpLc6M4EP41PiaFBLbh6LxmLlubSqZ2Z+amBQVUBYgScmzPrx9hJIMQGJzEhtTOxUaNnp++bnU3mtm3yfYLQ1n0Fw1wPINWsJ3ZdzMIAfQ88VdIdqXEs2EpCBkJZKVK8Ex+YSm0pHRNApxrFTmlMSeZLvRpmmKfazLEGN3o1V5orI+aoRAbgmcfxab0XxLwqJS6cFnJv2ISRmpksJALTpCqLFeSRyigm5rIvp/Zt4xSXj4l21scF+ApXMp2Dx1vDxNjOOVDGvwk7Nv6n4erTfLNgzcr9/HvH5sru+zlFcVrueBVlsn58p0CId+QJEapKN3I+phxvO2cCDgsT/AC0wRzthNVZANXAiIZARayvKnwPdSJathCRwqR3NPw0HW1bPEgV34CCs4gFBhdpwEu+rEEEJuIcPycIb94uxHkF7KIJ2LcOzAUp+4t6QQP6uA5JnZt0J0NOduEzsAtFMBlnXhIxUX/qerWqXxyFv2EsuFFCQVNvbrfcpzmhKb5aMRyjsPo6CgCZ1xqKSbVMHyia46f8MtEEQSuDqEzNoLgsyPYRkLQpsrnw9CA8IHRVKhyIKSP8Tok6buPy7cgBVtsnHVRG2eSqwOOC1ELfqZz05ucZh6HDyygjh8cFz8wPdPWB+C0COga+I3voPRosM5AAMamoNUN4UQRbPjJYGQOgoWBoAGcOGhXRRgvSn6M8pz4OlYByqM9rhVwODBC+iGwiWHpmvl4wJZzxEJ8rMOOgKUG9LwFaCVjOEacvOqLaENfjvBIiVjeYZu9xjZbjf0rlykbwVrCQPWjGtqNhuWyjYb7vT+s8h10WL6fDmKf2e67VLN94UdRuIZzVb7b1t/e7VRpS/h31Yl4rjcTxapVUVCNzs63oXQblW9LnW/eqXRTNs7R+3EvzD7zRJy6R9F/IF42VgKmV1uciCxF8dSh7D8ZLxx2ms7F6baw1aQdt2iFLXuWQ1DGIxpSsXv3lbTTwvYZ2MnYSm9MW3lg1k63ne81lmA+zFoKwqBdrVpWVMiPzLcxzsI6bVq2Vl88lBP4UMsNB+Sga2px+A5j1d3HooBiEqaFNglGYiYEhaUhPopX8kVCgmCvBKKPrOg52YbFZ6/r8ksTLP+LbhXr9x/CgH0t8L1JKfcjKX+JSfb1zFbtqunvt+SMnBaqw7MZtXnr+TDpoLORj3THTumaIdPkIWy4Ke7IcTs0w4xVRibrmzSV2DWV+LLwfb7MEVhMTYvbHeVpYzgxNbZND1mo8QPyOZW9TxDDiemybWbRDeSGRhkqlBgSZczOHAsobvQGA+VZMFqizr2eL4ED3fJ3sdTY8eY8SiMfA5r0OXMiRV3w+rCs7ttj0jdm/ZafI+tnW7MRydtkWTNqGErXnuzhR8WxDTdq2RPHNr0u7wJxrG1eGjIU508cW23GaHEs+N9uVNellJ1uWj5+Y2bFtRt1U7ZUuOq+sX3/Gw==</diagram></mxfile>
+46 -21
View File
@@ -23,35 +23,60 @@ browser APIs or by depending on external modules to do the work.
### Routing
Each plugin is responsible for registering its components to corresponding
routes in the app.
Each plugin can export routable extensions, which are then imported into the app
and mounted at a path.
The app will call the `createPlugin` method on each plugin, passing in a
`router` object with a set of methods on it.
First you will need a `RouteRef` instance to serve as the mount point of your
extensions. This can be used within your own plugin to create a link to the
extension page using `useRouteRef`, as well as for other plugins to link to your
extension.
```jsx
It is best to place these in a separate top-level `src/routes.ts` file, in order
to avoid import cycles, for example like this:
```tsx
/* src/routes.ts */
import { createRouteRef } from '@backstage/core';
// Note: This route ref is for internal use only, don't export it from the plugin
export const rootRouteRef = createRouteRef({
title: 'Example Page',
});
```
Now that we have a `RouteRef`, we import it into `src/plugin.ts`, create our
plugin instance with `createPlugin`, as well as create and wrap our routable
extension using `createRoutableExtension` from `@backstage/core`:
```tsx
/* src/plugin.ts */
import { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
export const rootRouteRef = createRouteRef({
path: '/new-plugin',
title: 'New plugin',
});
export const plugin = createPlugin({
id: 'new-plugin',
register({ router }) {
router.addRoute(rootRouteRef, ExampleComponent);
// Create a plugin instance and export this from your plugin package
export const examplePlugin = createPlugin({
id: 'example',
routes: {
root: rootRouteRef, // This is where the route ref should be exported for usage in the app
},
});
// This creates a routable extension, which are typically full pages of content.
// Each extension should also be exported from your plugin package.
export const ExamplePage = examplePlugin.provide(
createRoutableExtension({
// The component needs to be lazy-loaded. It's what will actually be rendered in the end.
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
// This binds the extension to this route ref, which allows for routing within and across plugin extensions
mountPoint: rootRouteRef,
}),
);
```
#### `router` API
This extension can then be imported and used in the app as follow, typically
placed within the top-level `<FlatRoutes>`:
```typescript
addRoute(
target: RouteRef,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
```tsx
<Route route="/any-path" element={<ExamplePage />} />
```
+24 -16
View File
@@ -28,6 +28,7 @@ new-plugin/
index.ts
plugin.test.ts
plugin.ts
routes.ts
jest.config.js
jest.setup.ts
package.json
@@ -56,26 +57,30 @@ package.json to declare the plugin dependencies, metadata and scripts.
In the `src` folder we get to the interesting bits. Check out the `plugin.ts`:
```jsx
import { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
import { createPlugin, createRoutableExtension } from '@backstage/core';
export const rootRouteRef = createRouteRef({
path: '/new-plugin',
title: 'New plugin',
});
import { rootRouteRef } from './routes';
export const plugin = createPlugin({
id: 'new-plugin',
register({ router }) {
router.addRoute(rootRouteRef, ExampleComponent);
export const examplePlugin = createPlugin({
id: 'example',
routes: {
root: rootRouteRef,
},
});
export const ExamplePage = examplePlugin.provide(
createRoutableExtension({
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
mountPoint: rootRouteRef,
}),
);
```
This is where the plugin is created and where it hooks into the app by declaring
what component should be shown on what URL. See reference docs for
[createPlugin](../reference/createPlugin.md) or
[router](../reference/createPlugin-router.md).
This is where the plugin is created and where it creates and exports extensions
that can be imported and used the app. See reference docs for
[createPlugin](../reference/createPlugin.md) or introduction to the new
[Composability System](./composability.md).
## Components
@@ -91,12 +96,15 @@ You may tweak these components, rename them and/or replace them completely.
## Connecting the plugin to the Backstage app
There are two things needed for a Backstage app to start making use of a plugin.
There are three things needed for a Backstage app to start making use of a
plugin.
1. Add plugin as dependency in `app/package.json`
2. `import` plugin in `app/src/plugins.ts`
3. Import and use one or more plugin extensions, for example in
`app/src/App.tsx`.
Luckily these two steps happen automatically when you create a plugin with the
Luckily these three steps happen automatically when you create a plugin with the
Backstage CLI.
## Talking to the outside world
-31
View File
@@ -1,31 +0,0 @@
---
id: createPlugin-router
title: createPlugin - router
description: Documentation on createPlugin - router
---
The router that is passed to the `register` function makes it possible for
plugins to hook into routing of the Backstage app and provide the end users with
new views to navigate to. This is done by utilising the following methods on the
`router`:
```typescript
addRoute(
target: RouteRef,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
```
## RouteRef
`addRoute` method is using mutable RouteRefs, which can be created as following:
```ts
import { createRouteRef } from '@backstage/core';
const myPluginRouteRef = createRouteRef({
path: '/my-plugin',
title: 'My Plugin',
});
```
+3 -11
View File
@@ -17,32 +17,24 @@ type PluginConfig = {
};
type PluginHooks = {
router: RouterHooks;
featureFlags: FeatureFlagsHooks;
};
```
- [Read more about the router here](createPlugin-router.md)
- [Read more about feature flags here](createPlugin-feature-flags.md)
## Example Uses
### Creating a basic plugin
Showcasing adding a route and a feature flag.
Showcasing adding a feature flag.
```jsx
import { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
export const rootRouteRef = createRouteRef({
path: '/new-plugin',
title: 'New Plugin',
});
import { createPlugin } from '@backstage/core';
export default createPlugin({
id: 'new-plugin',
register({ router, featureFlags }) {
router.addRoute(rootRouteRef, ExampleComponent);
featureFlags.register('enable-example-component');
},
});
@@ -20,6 +20,8 @@ import chalk from 'chalk';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath, join as joinPath } from 'path';
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';
import os from 'os';
import { Command } from 'commander';
import {
@@ -104,16 +106,12 @@ export async function addPluginDependencyToApp(
});
}
export async function addPluginToApp(
export async function addPluginImportToApp(
rootDir: string,
pluginName: string,
pluginVar: string,
pluginPackage: string,
) {
const pluginNameCapitalized = pluginName
.split('-')
.map(name => capitalize(name))
.join('');
const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`;
const pluginExport = `export { ${pluginVar} } from '${pluginPackage}';`;
const pluginsFilePath = 'packages/app/src/plugins.ts';
const pluginsFile = resolvePath(rootDir, pluginsFilePath);
@@ -126,6 +124,46 @@ export async function addPluginToApp(
});
}
export async function addPluginExtensionToApp(
pluginId: string,
extensionName: string,
pluginPackage: string,
) {
const pluginsFilePath = paths.resolveTargetRoot('packages/app/src/App.tsx');
if (!(await fs.pathExists(pluginsFilePath))) {
return;
}
await Task.forItem('processing', pluginsFilePath, async () => {
const content = await fs.readFile(pluginsFilePath, 'utf8');
const revLines = content.split('\n').reverse();
const lastImportIndex = revLines.findIndex(line =>
line.match(/ from ("|').*("|')/),
);
const lastRouteIndex = revLines.findIndex(line =>
line.match(/<\/FlatRoutes/),
);
if (lastImportIndex !== -1 && lastRouteIndex !== -1) {
revLines.splice(
lastImportIndex,
0,
`import { ${extensionName} } from '${pluginPackage}';`,
);
const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
revLines.splice(
lastRouteIndex + 1,
0,
`${indentation}<Route path="/${pluginId}" element={<${extensionName} />}/>`,
);
const newContent = revLines.reverse().join('\n');
await fs.writeFile(pluginsFilePath, newContent, 'utf8');
}
});
}
async function cleanUp(tempDir: string) {
await Task.forItem('remove', 'temporary directory', async () => {
await fs.remove(tempDir);
@@ -223,6 +261,8 @@ export default async (cmd: Command) => {
const name = cmd.scope
? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}`
: `plugin-${pluginId}`;
const pluginVar = `${camelCase(answers.id)}Plugin`;
const extensionName = `${upperFirst(camelCase(answers.id))}Page`;
const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : '';
const privatePackage = cmd.private === false ? false : true;
const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
@@ -259,7 +299,9 @@ export default async (cmd: Command) => {
tempDir,
{
...answers,
pluginVar,
pluginVersion,
extensionName,
name,
privatePackage,
npmRegistry,
@@ -278,7 +320,8 @@ export default async (cmd: Command) => {
await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion);
Task.section('Import plugin in app');
await addPluginToApp(paths.targetRoot, pluginId, name);
await addPluginImportToApp(paths.targetRoot, pluginVar, name);
await addPluginExtensionToApp(pluginId, extensionName, name);
}
if (ownerIds && ownerIds.length) {
+5
View File
@@ -39,6 +39,11 @@ const fileHandlers = [
patterns: ['package.json'],
handler: handlers.packageJson,
},
{
// Not all plugins have routes
patterns: ['src/routes.ts'],
handler: handlers.skip,
},
{
// make sure files in 1st level of src/ and dev/ exist
patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/],
@@ -1,4 +0,0 @@
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
@@ -0,0 +1,11 @@
import React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { {{ pluginVar }}, {{ extensionName }} } from '../src/plugin';
createDevApp()
.registerPlugin({{ pluginVar }})
.addPage({
element: <{{ extensionName }} />,
title: 'Root Page',
})
.render();
@@ -1,13 +1,12 @@
import React from 'react';
import { render } from '@testing-library/react';
import ExampleComponent from './ExampleComponent';
import { ExampleComponent } from './ExampleComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw } from '@backstage/test-utils';
describe('ExampleComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
@@ -15,15 +14,17 @@ describe('ExampleComponent', () => {
// setup mock response
beforeEach(() => {
server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))))
})
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<ExampleComponent />
</ThemeProvider>,
);
expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
);
expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
});
});
@@ -9,9 +9,9 @@ import {
HeaderLabel,
SupportButton,
} from '@backstage/core';
import ExampleFetchComponent from '../ExampleFetchComponent';
import { ExampleFetchComponent } from '../ExampleFetchComponent';
const ExampleComponent = () => (
export const ExampleComponent = () => (
<Page themeId="tool">
<Header title="Welcome to {{ id }}!" subtitle="Optional subtitle">
<HeaderLabel label="Owner" value="Team X" />
@@ -36,5 +36,3 @@ const ExampleComponent = () => (
</Content>
</Page>
);
export default ExampleComponent;
@@ -1 +1 @@
export { default } from './ExampleComponent';
export { ExampleComponent } from './ExampleComponent';
@@ -1,6 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
import ExampleFetchComponent from './ExampleFetchComponent';
import { ExampleFetchComponent } from './ExampleFetchComponent';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw } from '@backstage/test-utils';
@@ -9,11 +9,15 @@ describe('ExampleFetchComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
msw.setupDefaultHandlers(server);
// setup mock response
beforeEach(() => {
server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({}))))
})
server.use(
rest.get('https://randomuser.me/*', (_, res, ctx) =>
res(ctx.status(200), ctx.delay(2000), ctx.json({})),
),
);
});
it('should render', async () => {
const rendered = render(<ExampleFetchComponent />);
expect(await rendered.findByTestId('progress')).toBeInTheDocument();
@@ -48,7 +48,7 @@ export const DenseTable = ({ users }: DenseTableProps) => {
{ title: 'Nationality', field: 'nationality' },
];
const data = users.map((user) => {
const data = users.map(user => {
return {
avatar: (
<img
@@ -73,7 +73,7 @@ export const DenseTable = ({ users }: DenseTableProps) => {
);
};
const ExampleFetchComponent = () => {
export const ExampleFetchComponent = () => {
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
const response = await fetch('https://randomuser.me/api/?results=20');
const data = await response.json();
@@ -88,5 +88,3 @@ const ExampleFetchComponent = () => {
return <DenseTable users={value || []} />;
};
export default ExampleFetchComponent;
@@ -1 +1 @@
export { default } from './ExampleFetchComponent';
export { ExampleFetchComponent } from './ExampleFetchComponent';
@@ -1 +0,0 @@
export { plugin } from './plugin';
@@ -0,0 +1 @@
export { {{ pluginVar }}, {{ extensionName }} } from './plugin';
@@ -1,7 +1,7 @@
import { plugin } from './plugin';
import { {{ pluginVar }} } from './plugin';
describe('{{ id }}', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
expect({{ pluginVar }}).toBeDefined();
});
});
@@ -1,14 +1,18 @@
import { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
import { createPlugin, createRoutableExtension } from '@backstage/core';
export const rootRouteRef = createRouteRef({
path: '/{{ id }}',
title: '{{ id }}',
});
import { rootRouteRef } from './routes';
export const plugin = createPlugin({
export const {{ pluginVar }} = createPlugin({
id: '{{ id }}',
register({ router }) {
router.addRoute(rootRouteRef, ExampleComponent);
routes: {
root: rootRouteRef,
},
});
export const {{ extensionName }} = {{ pluginVar }}.provide(
createRoutableExtension({
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
mountPoint: rootRouteRef,
}),
);
@@ -0,0 +1,5 @@
import { createRouteRef } from '@backstage/core';
export const rootRouteRef = createRouteRef({
title: '{{ id }}',
});