diff --git a/.changeset/crazy-pants-exist.md b/.changeset/crazy-pants-exist.md index c43a1134fa..5eca381523 100644 --- a/.changeset/crazy-pants-exist.md +++ b/.changeset/crazy-pants-exist.md @@ -2,4 +2,4 @@ '@backstage/cli': minor --- -Support frontend plugin modules in CLI +Added plugin and module templates for the new frontend system. These templates are not included by default, but can be included by adding `@backstage/cli/templates/new-frontend-plugin` and `@backstage/cli/templates/new-frontend-plugin-module` as [custom templates](https://backstage.io/docs/tooling/cli/templates#installing-custom-templates). diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index 5b5f74b89c..696728795a 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -42,7 +42,9 @@ import { version as coreComponents } from '../../../../packages/core-components/ import { version as corePluginApi } from '../../../../packages/core-plugin-api/package.json'; import { version as devUtils } from '../../../../packages/dev-utils/package.json'; import { version as errors } from '../../../../packages/errors/package.json'; +import { version as frontendDefaults } from '../../../../packages/frontend-defaults/package.json'; import { version as frontendPluginApi } from '../../../../packages/frontend-plugin-api/package.json'; +import { version as frontendTestUtils } from '../../../../packages/frontend-test-utils/package.json'; import { version as testUtils } from '../../../../packages/test-utils/package.json'; import { version as scaffolderNode } from '../../../../plugins/scaffolder-node/package.json'; import { version as scaffolderNodeTestUtils } from '../../../../plugins/scaffolder-node-test-utils/package.json'; @@ -64,7 +66,9 @@ export const packageVersions: Record = { '@backstage/core-plugin-api': corePluginApi, '@backstage/dev-utils': devUtils, '@backstage/errors': errors, + '@backstage/frontend-defaults': frontendDefaults, '@backstage/frontend-plugin-api': frontendPluginApi, + '@backstage/frontend-test-utils': frontendTestUtils, '@backstage/test-utils': testUtils, '@backstage/theme': theme, '@backstage/plugin-scaffolder-node': scaffolderNode, diff --git a/packages/cli/templates/new-frontend-plugin/.eslintrc.js.hbs b/packages/cli/templates/new-frontend-plugin/.eslintrc.js.hbs new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/.eslintrc.js.hbs @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli/templates/new-frontend-plugin/README.md.hbs b/packages/cli/templates/new-frontend-plugin/README.md.hbs new file mode 100644 index 0000000000..3f1ff3e4f7 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/README.md.hbs @@ -0,0 +1,20 @@ +# {{pluginId}} + +Welcome to the {{pluginId}} plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +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 +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). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/packages/cli/templates/new-frontend-plugin/dev/index.tsx b/packages/cli/templates/new-frontend-plugin/dev/index.tsx new file mode 100644 index 0000000000..e1bcb0401e --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/dev/index.tsx @@ -0,0 +1,10 @@ +import { createApp } from '@backstage/frontend-defaults'; +import ReactDOM from 'react-dom'; + +import plugin from '../src'; + +const app = createApp({ + features: [plugin], +}); + +ReactDOM.render(app.createRoot(), document.getElementById('root')); diff --git a/packages/cli/templates/new-frontend-plugin/package.json.hbs b/packages/cli/templates/new-frontend-plugin/package.json.hbs new file mode 100644 index 0000000000..7ee1aed5e9 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/package.json.hbs @@ -0,0 +1,49 @@ +{ + "name": "{{packageName}}", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin", + "pluginId": "{{pluginId}}" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@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'}}", + "react-use": "{{versionQuery 'react-use' '17.2.4'}}" + }, + "peerDependencies": { + "react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}" + }, + "devDependencies": { + "@backstage/cli": "{{versionQuery '@backstage/cli'}}", + "@backstage/frontend-defaults": "{{versionQuery '@backstage/frontend-defaults'}}", + "@backstage/frontend-test-utils": "{{versionQuery '@backstage/frontend-test-utils'}}", + "@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}", + "@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}", + "@testing-library/user-event": "{{versionQuery '@testing-library/user-event' '14.0.0'}}", + "msw": "{{versionQuery 'msw' '1.0.0'}}", + "react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0 || ^18.0.0'}}" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/templates/new-frontend-plugin/portable-template.yaml b/packages/cli/templates/new-frontend-plugin/portable-template.yaml new file mode 100644 index 0000000000..a70ce316f1 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/portable-template.yaml @@ -0,0 +1,5 @@ +name: frontend-plugin +role: frontend-plugin +description: A new frontend plugin +values: + pluginVar: '{{ camelCase pluginId }}Plugin' diff --git a/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs new file mode 100644 index 0000000000..d1a8a93d0b --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs @@ -0,0 +1,28 @@ +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(); + expect( + screen.getByText('Welcome to {{pluginId}}!'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs new file mode 100644 index 0000000000..d117fad131 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs @@ -0,0 +1,37 @@ +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 = () => ( + +
+ + +
+ + + A description of your plugin goes here. + + + + + + All content should be wrapped in a card like this. + + + + + + + + +
+); diff --git a/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/index.ts b/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/index.ts new file mode 100644 index 0000000000..8b8437521b --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/components/ExampleComponent/index.ts @@ -0,0 +1 @@ +export { ExampleComponent } from './ExampleComponent'; diff --git a/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs new file mode 100644 index 0000000000..c9b553287f --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs @@ -0,0 +1,19 @@ +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(); + + // 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(); + }); +}); diff --git a/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs new file mode 100644 index 0000000000..bd6bd9d75b --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs @@ -0,0 +1,308 @@ +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: ( + {user.name.first} + ), + name: `${user.name.first} ${user.name.last}`, + email: user.email, + nationality: user.nat, + }; + }); + + return ( + + ); +}; + +export const ExampleFetchComponent = () => { + + const { value, loading, error } = useAsync(async (): Promise => { + // Would use fetch in a real world example + return exampleUsers.results; + }, []); + + if (loading) { + return ; + } else if (error) { + return ; + } + + return ; +}; diff --git a/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/index.ts new file mode 100644 index 0000000000..41a43e84f1 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/components/ExampleFetchComponent/index.ts @@ -0,0 +1 @@ +export { ExampleFetchComponent } from './ExampleFetchComponent'; diff --git a/packages/cli/templates/new-frontend-plugin/src/index.ts.hbs b/packages/cli/templates/new-frontend-plugin/src/index.ts.hbs new file mode 100644 index 0000000000..0d6810ad6b --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/index.ts.hbs @@ -0,0 +1 @@ +export { {{ pluginVar }} as default } from './plugin'; diff --git a/packages/cli/templates/new-frontend-plugin/src/plugin.test.ts.hbs b/packages/cli/templates/new-frontend-plugin/src/plugin.test.ts.hbs new file mode 100644 index 0000000000..cf27fa33d2 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/plugin.test.ts.hbs @@ -0,0 +1,7 @@ +import { {{ pluginVar }} } from './plugin'; + +describe('{{pluginId}}', () => { + it('should export plugin', () => { + expect({{ pluginVar }}).toBeDefined(); + }); +}); diff --git a/packages/cli/templates/new-frontend-plugin/src/plugin.tsx.hbs b/packages/cli/templates/new-frontend-plugin/src/plugin.tsx.hbs new file mode 100644 index 0000000000..229a795f11 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/plugin.tsx.hbs @@ -0,0 +1,26 @@ +import React from 'react'; +import { + createFrontendPlugin, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; + +import { rootRouteRef } from './routes'; + +export const page = PageBlueprint.make({ + params: { + path: '/{{pluginId}}', + routeRef: rootRouteRef, + loader: () => + import('./components/ExampleComponent').then(m => + , + ), + }, +}); + +export const {{ pluginVar }} = createFrontendPlugin({ + id: '{{pluginId}}', + extensions: [page], + routes: { + root: rootRouteRef, + } +}); diff --git a/packages/cli/templates/new-frontend-plugin/src/routes.ts b/packages/cli/templates/new-frontend-plugin/src/routes.ts new file mode 100644 index 0000000000..559a9cc02c --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/routes.ts @@ -0,0 +1,3 @@ +import { createRouteRef } from '@backstage/frontend-plugin-api'; + +export const rootRouteRef = createRouteRef(); diff --git a/packages/cli/templates/new-frontend-plugin/src/setupTests.ts b/packages/cli/templates/new-frontend-plugin/src/setupTests.ts new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/packages/cli/templates/new-frontend-plugin/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/create-app/templates/next-app/package.json.hbs b/packages/create-app/templates/next-app/package.json.hbs index 2a476f6dea..178c38403f 100644 --- a/packages/create-app/templates/next-app/package.json.hbs +++ b/packages/create-app/templates/next-app/package.json.hbs @@ -29,7 +29,7 @@ "license": "UNLICENSED" }, "templates": [ - "@backstage/cli/templates/frontend-plugin", + "@backstage/cli/templates/new-frontend-plugin", "@backstage/cli/templates/new-frontend-plugin-module", "@backstage/cli/templates/backend-plugin", "@backstage/cli/templates/backend-plugin-module",