diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 2271081628..5aaf8acadf 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -26,3 +26,10 @@ This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. + +## Integrate into the Service Catalog + +If your plugin isn't supposed to live as a standalone page, but rather needs to +be presented as a part of a Service Catalog (e.g. a separate tab or a card on an +"Overview" tab), then check out +[the instruction](integrating-plugin-into-service-catalog.md). on how to do it. diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-service-catalog.md new file mode 100644 index 0000000000..4408c27ccc --- /dev/null +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -0,0 +1,124 @@ +--- +id: integrating-plugin-into-service-catalog +title: Integrate into the Service Catalog +--- + +> This is an advanced use case and currently is an experimental feature. Expect +> API to change over time + +## Steps + +1. [Create a plugin](#create-a-plugin) +1. [Export a router with relative routes](#export-a-router) +1. [Import and use router in the APP](#import-and-use-router-in-the-app) + +### Create a plugin + +Follow the [same process](create-a-plugin.md) as for standalone plugin. You +should have a separate package in a folder, which represents your plugin. + +Example: + +``` +$ yarn create-plugin +> ? Enter an ID for the plugin [required] my-plugin +> ? Enter the owner(s) of the plugin. If specified, this will be added to CODEOWNERS for the plugin path. [optional] + +Creating the plugin... +``` + +### Export a router + +Now in the plugin you have a `Router.tsx` file in the `src` folder. By default +it contains only one example route. Create a routing structure needed for your +plugin, keeping in mind that the whole set of routes defined here are going to +be mounted under some different route in the App. + +Example: + +`my-plugin` consists of 2 different views - `/me` and `/about`. I envision +people integrating it into plugin catalog as a tab named "MyPlugin". Then, my +`Routes.tsx` for the plugin is going to look like: + +```tsx + + } /> + } /> + +``` + +(where MePage and AboutPage are 2 components defined in your plugin and imported +accordingly inside `Router.tsx`) + +> Pay attention, if your `MePage` references the `AboutPage` it needs to do it +> through link to `about`, not `/about`. This allows react-router v6 to enable +> its relative routing mechanism. Read more - +> https://reacttraining.com/blog/react-router-v6-pre/#relative-route-path-and-link-to + +### Import and use router in the APP + +In the `app/src/components/catalog/EntityPage.tsx` (app === your folder, +containing backstage app) import your created Router: + +```tsx +import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin; +``` + +Now, you need to mount `MyPluginRouter` onto some route, for example if you had: + +```tsx +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); +``` + +after you add your code it becomes: + +```tsx +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); +``` + +All of magic happens thanks to the `EntityPageLayout` component, which comes as +an export from `@backstage/plugin-catalog` package. + +```tsx +type EntityPageLayoutContentProps = { + /** + * Going to be transformed into react-router v6 + * path under the hood. Read more at https://reacttraining.com/blog/react-router-v6-pre + */ + path: string; + /** + * Gets transformed into the title for the tab + */ + title: string; + /** + * Element that is rendered when the location + * matches the path provided + */ + element: JSX.Element; +}; +``` + +> You can either pass the entity from App to the plugin's router as a prop or +> use `useEntity` hook from `@backstage/plugin-catalog` directly inside your +> plugin. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 5d8952ac41..f5741bd355 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -74,6 +74,7 @@ "plugins/create-a-plugin", "plugins/plugin-development", "plugins/structure-of-a-plugin", + "plugins/integrating-plugin-into-service-catalog", { "type": "subcategory", "label": "Backends and APIs", diff --git a/packages/app/package.json b/packages/app/package.json index 738370c2b9..1affbe4d09 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,6 +5,7 @@ "bundled": true, "dependencies": { "@backstage/cli": "^0.1.1-alpha.21", + "@backstage/catalog-model": "^0.1.1-alpha.21", "@backstage/core": "^0.1.1-alpha.21", "@backstage/plugin-api-docs": "^0.1.1-alpha.21", "@backstage/plugin-catalog": "^0.1.1-alpha.21", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 993fd77978..cb38726716 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -26,6 +26,10 @@ import * as plugins from './plugins'; import { apis } from './apis'; import { hot } from 'react-hot-loader/root'; import { providers } from './identityProviders'; +import { Router as CatalogRouter } from '@backstage/plugin-catalog'; +import { Route, Routes, Navigate } from 'react-router'; + +import { EntityPage } from './components/catalog/EntityPage'; const app = createApp({ apis, @@ -46,7 +50,18 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const AppRoutes = app.getRoutes(); +const deprecatedAppRoutes = app.getRoutes(); + +const AppRoutes = () => ( + + } + /> + + {...deprecatedAppRoutes} + +); const App: FC<{}> = () => ( diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index d0a0171207..ab95f623af 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -90,7 +90,7 @@ const Root: FC<{}> = ({ children }) => ( {/* Global nav, not org-specific */} - + diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx new file mode 100644 index 0000000000..879e2fe6f7 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Router as GitHubActionsRouter } from '@backstage/plugin-github-actions'; +import React from 'react'; +import { + EntityPageLayout, + useEntity, + AboutCard, +} from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import { Grid } from '@material-ui/core'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + +); + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +export const EntityPage = () => { + const { entity } = useEntity(); + switch (entity?.spec?.type) { + case 'service': + return ; + case 'website': + return ; + default: + return ; + } +}; diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 2416a250e1..792cd467d5 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -56,7 +56,12 @@ module.exports = { '@typescript-eslint/no-unused-expressions': 'error', '@typescript-eslint/no-unused-vars': [ 'warn', - { vars: 'all', args: 'after-used', ignoreRestSiblings: true }, + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: true, + argsIgnorePattern: '^_', + }, ], 'no-restricted-imports': [ 2, diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 27a63ea04a..bfa26b7547 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -136,7 +136,7 @@ export class PrivateAppImpl implements BackstageApp { return this.icons[key]; } - getRoutes(): ComponentType<{}> { + getRoutes(): JSX.Element[] { const routes = new Array(); const registeredFeatureFlags = new Array(); @@ -191,14 +191,9 @@ export class PrivateAppImpl implements BackstageApp { FeatureFlags.registeredFeatureFlags = registeredFeatureFlags; } - const rendered = ( - - {routes} - } /> - - ); + routes.push(} />); - return () => rendered; + return routes; } getProvider(): ComponentType<{}> { diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 6b022c2736..db7a0897b1 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -168,5 +168,5 @@ export type BackstageApp = { /** * Routes component that contains all routes for plugin pages in the app. */ - getRoutes(): ComponentType<{}>; + getRoutes(): JSX.Element[]; }; diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 9d4993c4dc..c80d9c92d8 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -12,6 +12,7 @@ "@backstage/plugin-register-component": "^{{version}}", "@backstage/plugin-scaffolder": "^{{version}}", "@backstage/plugin-techdocs": "^{{version}}", + "@backstage/catalog-model": "^{{version}}", "@backstage/plugin-circleci": "^{{version}}", "@backstage/plugin-explore": "^{{version}}", "@backstage/plugin-lighthouse": "^{{version}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index c96bd9d05e..f002bbc7c7 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -8,6 +8,9 @@ import { import { apis } from './apis'; import * as plugins from './plugins'; import { AppSidebar } from './sidebar'; +import { Route, Routes, Navigate } from 'react-router'; +import { Router as CatalogRouter } from '@backstage/plugin-catalog'; +import { EntityPage } from './components/catalog/EntityPage'; const app = createApp({ apis, @@ -16,7 +19,7 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const AppRoutes = app.getRoutes(); +const deprecatedAppRoutes = app.getRoutes(); const App: FC<{}> = () => ( @@ -25,7 +28,14 @@ const App: FC<{}> = () => ( - + + } + /> + + {deprecatedAppRoutes} + diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx new file mode 100644 index 0000000000..4087789deb --- /dev/null +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Router as GitHubActionsRouter } from '@backstage/plugin-github-actions'; +import React from 'react'; +import { + EntityPageLayout, + useEntity, + AboutCard, +} from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + +); + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +export const EntityPage = () => { + const { entity } = useEntity(); + switch (entity?.spec?.type) { + case 'service': + return ; + case 'website': + return ; + default: + return ; + } +}; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 410f7592a5..cde39fdd05 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -85,7 +85,7 @@ class DevAppBuilder { const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); - const AppRoutes = app.getRoutes(); + const deprecatedAppRoutes = app.getRoutes(); const sidebar = this.setupSidebar(this.plugins); @@ -99,7 +99,7 @@ class DevAppBuilder { {sidebar} - + {deprecatedAppRoutes} diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index 7377655497..5dfac68d6a 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -63,6 +63,9 @@ async function main() { print('All tests successful, removing test dir'); await fs.remove(rootDir); + + // Just in case some child process was left hanging + process.exit(0); } /** diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 3da3d45688..b088e1699b 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -35,10 +35,12 @@ "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-helmet": "6.1.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "swr": "^0.3.0" + "swr": "^0.3.0", + "@types/react": "^16.9" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.21", diff --git a/plugins/github-actions/src/components/WorkflowRunsPage/index.ts b/plugins/catalog/src/components/CatalogPage/index.ts similarity index 91% rename from plugins/github-actions/src/components/WorkflowRunsPage/index.ts rename to plugins/catalog/src/components/CatalogPage/index.ts index ef511763f7..1adb8866c8 100644 --- a/plugins/github-actions/src/components/WorkflowRunsPage/index.ts +++ b/plugins/catalog/src/components/CatalogPage/index.ts @@ -13,5 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -export { WorkflowRunsPage } from './WorkflowRunsPage'; +export { CatalogPage } from './CatalogPage'; diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx deleted file mode 100644 index 1b966c0bde..0000000000 --- a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - const mockNavigate = jest.fn(); - return { - ...actual, - useNavigate: jest.fn(() => mockNavigate), - useParams: jest.fn(), - }; -}); - -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { Entity } from '@backstage/catalog-model'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render, waitFor } from '@testing-library/react'; -import * as React from 'react'; -import { CatalogApi, catalogApiRef } from '../../api/types'; -import { EntityPage, getPageTheme } from './EntityPage'; - -const { - useParams, - useNavigate, -}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock( - 'react-router-dom', -); - -const errorApi = { post: () => {} }; - -describe('EntityPage', () => { - it('should redirect to catalog page when name is not provided', async () => { - useParams.mockReturnValue({ - kind: 'Component', - optionalNamespaceAndName: '', - }); - - render( - wrapInTestApp( - ) as CatalogApi, - ], - ])} - > - - , - ), - ); - - await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog')); - }); -}); - -describe('getPageTheme', () => { - const defaultPageTheme = getPageTheme(); - it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])( - 'should select right theme for predefined type: %p ̰ ', - type => { - const theme = getPageTheme(({ - spec: { - type, - }, - } as any) as Entity); - expect(theme).toBeDefined(); - expect(theme).not.toBe(defaultPageTheme); - }, - ); - - it('should select default theme for unknown/unspecified types', () => { - const theme1 = getPageTheme(({ - spec: { - type: 'unknown-type', - }, - } as any) as Entity); - const theme2 = getPageTheme(({ - spec: {}, - } as any) as Entity); - expect(theme1).toBe(defaultPageTheme); - expect(theme2).toBe(defaultPageTheme); - }); -}); diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx deleted file mode 100644 index 558f4b6551..0000000000 --- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { - Content, - errorApiRef, - Header, - HeaderLabel, - Page, - pageTheme, - PageTheme, - Progress, - useApi, - HeaderTabs, -} from '@backstage/core'; -import { Box } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import React, { FC, useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { useAsync } from 'react-use'; -import { catalogApiRef } from '../..'; -import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; -import { EntityPageDocs } from '../EntityPageDocs/EntityDocsPage'; -import { EntityPageApi } from '../EntityPageApi/EntityPageApi'; -import { EntityPageCi } from '../EntityPageCi/EntityPageCi'; -import { EntityPageOverview } from '../EntityPageOverview/EntityPageOverview'; -import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; -import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; - -const REDIRECT_DELAY = 1000; -function headerProps( - kind: string, - namespace: string | undefined, - name: string, - entity: Entity | undefined, -): { headerTitle: string; headerType: string } { - return { - headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`, - headerType: (() => { - let t = kind.toLowerCase(); - if (entity && entity.spec && 'type' in entity.spec) { - t += ' — '; - t += (entity.spec as { type: string }).type.toLowerCase(); - } - return t; - })(), - }; -} - -export const getPageTheme = (entity?: Entity): PageTheme => { - const themeKey = entity?.spec?.type?.toString() ?? 'home'; - return pageTheme[themeKey] ?? pageTheme.home; -}; - -const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({ - entity, - title, -}) => ( - - {title} - {entity && } - -); - -export const EntityPage: FC<{}> = () => { - const { - optionalNamespaceAndName, - kind, - selectedTabId = 'overview', - } = useParams() as { - optionalNamespaceAndName: string; - kind: string; - selectedTabId: string; - }; - const navigate = useNavigate(); - - const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); - - const errorApi = useApi(errorApiRef); - const catalogApi = useApi(catalogApiRef); - - const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); - const { value: entity, error, loading } = useAsync( - () => catalogApi.getEntityByName({ kind, namespace, name }), - [catalogApi, kind, namespace, name], - ); - - useEffect(() => { - if (!error && !loading && !entity) { - errorApi.post(new Error('Entity not found!')); - setTimeout(() => { - navigate('/'); - }, REDIRECT_DELAY); - } - }, [errorApi, navigate, error, loading, entity]); - - if (!name) { - navigate('/catalog'); - return null; - } - - const cleanUpAfterRemoval = async () => { - setConfirmationDialogOpen(false); - navigate('/'); - }; - - const showRemovalDialog = () => setConfirmationDialogOpen(true); - - // TODO - Replace with proper tabs implementation - const tabs = [ - { - id: 'overview', - label: 'Overview', - content: (e: Entity) => , - }, - { - id: 'ci', - label: 'CI/CD', - content: (e: Entity) => , - }, - { - id: 'tests', - label: 'Tests', - }, - { - id: 'api', - label: 'API', - show: (e: Entity) => !!e?.spec?.implementsApis, - content: (e: Entity) => , - }, - { - id: 'monitoring', - label: 'Monitoring', - }, - { - id: 'quality', - label: 'Quality', - }, - { - id: 'docs', - label: 'Docs', - show: (e: Entity) => - !!e.metadata.annotations?.['backstage.io/techdocs-ref'], - content: (e: Entity) => , - }, - ]; - - const { headerTitle, headerType } = headerProps( - kind, - namespace, - name, - entity, - ); - - const selectedTab = tabs.find(tab => tab.id === selectedTabId); - - const filteredHeaderTabs = entity - ? tabs.filter(tab => (tab.show ? tab.show(entity) : true)) - : []; - - return ( - -
} - pageTitleOverride={headerTitle} - type={headerType} - > - {entity && ( - <> - - - - - )} -
- - {loading && } - - {error && ( - - {error.toString()} - - )} - - {entity && ( - <> - { - navigate( - `/catalog/${kind}/${optionalNamespaceAndName}/${filteredHeaderTabs[idx].id}`, - ); - }} - selectedIndex={filteredHeaderTabs.findIndex( - tab => tab.id === selectedTabId, - )} - /> - - {selectedTab && selectedTab.content - ? selectedTab.content(entity) - : null} - - setConfirmationDialogOpen(false)} - /> - - )} -
- ); -}; diff --git a/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx b/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx index d39617da25..3261764d11 100644 --- a/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx +++ b/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ +// TODO(shmidt-i): move to the app import { Entity } from '@backstage/catalog-model'; import { Content } from '@backstage/core'; -import { RecentWorkflowRunsCard as GithubActionsListWidget } from '@backstage/plugin-github-actions'; +import { LatestWorkflowsForBranchCard } from '@backstage/plugin-github-actions'; import { Grid } from '@material-ui/core'; import React, { FC } from 'react'; @@ -26,7 +27,7 @@ export const EntityPageCi: FC<{ entity: Entity }> = ({ entity }) => { {entity.metadata?.annotations?.['backstage.io/github-actions-id'] && ( - + )} diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx new file mode 100644 index 0000000000..6a1b80150e --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -0,0 +1,142 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useState, useContext } from 'react'; +import { useParams, useNavigate } from 'react-router'; + +import { EntityContext } from '../../hooks/useEntity'; +import { + pageTheme, + PageTheme, + Page, + Header, + HeaderLabel, + Content, + Progress, +} from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; +import { Box } from '@material-ui/core'; +import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; +import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { Alert } from '@material-ui/lab'; +import { Tabbed } from './Tabbed'; + +const getPageTheme = (entity?: Entity): PageTheme => { + const themeKey = entity?.spec?.type?.toString() ?? 'home'; + return pageTheme[themeKey] ?? pageTheme.home; +}; + +const EntityPageTitle = ({ + entity, + title, +}: { + title: string; + entity: Entity | undefined; +}) => ( + + {title} + {entity && } + +); + +function headerProps( + kind: string, + namespace: string | undefined, + name: string, + entity: Entity | undefined, +): { headerTitle: string; headerType: string } { + return { + headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`, + headerType: (() => { + let t = kind.toLowerCase(); + if (entity && entity.spec && 'type' in entity.spec) { + t += ' — '; + t += (entity.spec as { type: string }).type.toLowerCase(); + } + return t; + })(), + }; +} + +export const EntityPageLayout = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const { optionalNamespaceAndName, kind } = useParams() as { + optionalNamespaceAndName: string; + kind: string; + }; + const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); + + const { entity, loading, error } = useContext(EntityContext); + const { headerTitle, headerType } = headerProps( + kind, + namespace, + name, + entity!, + ); + + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const navigate = useNavigate(); + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + navigate('/'); + }; + + const showRemovalDialog = () => setConfirmationDialogOpen(true); + + return ( + +
} + pageTitleOverride={headerTitle} + type={headerType} + > + {entity && ( + <> + + + + + )} +
+ + {loading && } + + {entity && {children}} + + {error && ( + + {error.toString()} + + )} + setConfirmationDialogOpen(false)} + /> +
+ ); +}; +EntityPageLayout.Content = Tabbed.Content; diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx new file mode 100644 index 0000000000..d5ca9170a2 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Tabbed } from './Tabbed'; +import { renderInTestApp } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { Routes, Route } from 'react-router'; + +describe('Tabbed layout', () => { + it('renders simplest case', async () => { + const rendered = await renderInTestApp( + + tabbed-test-content} + /> + , + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + }); + + it('throws if any other component is a child of Tabbed.Layout', async () => { + await expect( + renderInTestApp( + + tabbed-test-content} + /> +
This will cause app to throw
+
, + ), + ).rejects.toThrow(/This component only accepts/); + }); + + it('navigates when user clicks different tab', async () => { + const rendered = await renderInTestApp( + + + tabbed-test-content} + /> + tabbed-test-content-2} + /> + + } + /> + , + ); + + const secondTab = rendered.queryAllByRole('tab')[1]; + act(() => { + fireEvent.click(secondTab); + }); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); + + describe('correctly delegates nested links', () => { + const renderRoute = (route: string) => + renderInTestApp( + + + tabbed-test-content} + /> + + tabbed-test-content-2 + + tabbed-test-nested-content-2} + /> + + + } + /> + + } + /> + , + { routeEntries: [route] }, + ); + + it('works for nested content', async () => { + const rendered = await renderRoute('/some-other-path/nested'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).toBeInTheDocument(); + }); + + it('works for non-nested content', async () => { + const rendered = await renderRoute('/some-other-path/'); + + expect( + rendered.queryByText('tabbed-test-content'), + ).not.toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect( + rendered.queryByText('tabbed-test-nested-content-2'), + ).not.toBeInTheDocument(); + }); + }); + + it('shows only one tab contents at a time', async () => { + const rendered = await renderInTestApp( + + tabbed-test-content} + /> + tabbed-test-content-2} + /> + , + { routeEntries: ['/some-other-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); + + it('redirects to the top level when no route is matching the url', async () => { + const rendered = await renderInTestApp( + + tabbed-test-content} + /> + tabbed-test-content-2} + /> + , + { routeEntries: ['/non-existing-path'] }, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); + + expect( + rendered.queryByText('tabbed-test-content-2'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx new file mode 100644 index 0000000000..096b2fa708 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { + useParams, + useNavigate, + PartialRouteObject, + matchRoutes, + RouteObject, + useRoutes, + Navigate, + RouteMatch, +} from 'react-router'; +import { Tab, HeaderTabs, Content } from '@backstage/core'; +import { Helmet } from 'react-helmet'; + +const getSelectedIndexOrDefault = ( + matchedRoute: RouteMatch, + tabs: Tab[], + defaultIndex = 0, +) => { + if (!matchedRoute) return defaultIndex; + const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path); + return ~tabIndex ? tabIndex : defaultIndex; +}; + +/** + * Compound component, which allows you to define layout + * for EntityPage using Tabs as a subnavigation mechanism + * Constists of 2 parts: Tabbed.Layout and Tabbed.Content. + * Takes care of: tabs, routes, document titles, spacing around content + * + * @example + * ```jsx + * + * This is rendered under /example/anything-here route} + * /> + * + * ``` + */ +export const Tabbed = { + Layout: ({ children }: { children: React.ReactNode }) => { + const routes: PartialRouteObject[] = []; + const tabs: Tab[] = []; + const params = useParams(); + const navigate = useNavigate(); + + React.Children.forEach(children, child => { + if (!React.isValidElement(child)) { + // Skip conditionals resolved to falses/nulls/undefineds etc + return; + } + if (child.type !== Tabbed.Content) { + throw new Error( + 'This component only accepts Content elements as direct children. Check the code of the EntityPage.', + ); + } + const pathAndId = (child as JSX.Element).props.path; + + // Child here must be then always a functional component without any wrappers + tabs.push({ + id: pathAndId, + label: (child as JSX.Element).props.title, + }); + + routes.push({ + path: pathAndId, + element: child.props.element, + }); + }); + + // Add catch-all for incorrect sub-routes + if ((routes?.[0]?.path ?? '') !== '') + routes.push({ + path: '/*', + element: , + }); + + const [matchedRoute] = + matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; + const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs); + const currentTab = tabs[selectedIndex]; + const title = currentTab?.label; + + const onTabChange = (index: number) => + // Remove trailing /* + // And remove leading / for relative navigation + // Note! route resolves relative to the position in the React tree, + // not relative to current location + navigate(tabs[index].id.replace(/\/\*$/, '').replace(/^\//, '')); + + const currentRouteElement = useRoutes(routes); + + if (!currentTab) return null; + return ( + <> + + + + {currentRouteElement} + + + ); + }, + Content: (_props: { path: string; title: string; element: JSX.Element }) => + null, +}; diff --git a/plugins/github-actions/src/components/Widget/index.ts b/plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts similarity index 90% rename from plugins/github-actions/src/components/Widget/index.ts rename to plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts index 0bd5dad98a..d58cd626ba 100644 --- a/plugins/github-actions/src/components/Widget/index.ts +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Widget, RecentWorkflowRunsCard } from './Widget'; +export { Tabbed } from './Tabbed'; diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts b/plugins/catalog/src/components/EntityPageLayout/index.ts similarity index 89% rename from plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts rename to plugins/catalog/src/components/EntityPageLayout/index.ts index 443bcacc88..acf6f948f1 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts +++ b/plugins/catalog/src/components/EntityPageLayout/index.ts @@ -13,5 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -export { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage'; +export { EntityPageLayout } from './EntityPageLayout'; diff --git a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx b/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx index ec697e7c06..9b29b205cf 100644 --- a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx +++ b/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ +// TODO(shmidt-i): move to the app import { Entity } from '@backstage/catalog-model'; import { Content } from '@backstage/core'; -import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions'; +import { LatestWorkflowRunCard } from '@backstage/plugin-github-actions'; import { JenkinsBuildsWidget, JenkinsLastBuildWidget, @@ -48,7 +49,7 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => { )} {entity.metadata?.annotations?.['backstage.io/github-actions-id'] && ( - + )} diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx new file mode 100644 index 0000000000..e328b64d44 --- /dev/null +++ b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { ReactNode } from 'react'; +import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; + +export const EntityProvider = ({ children }: { children: ReactNode }) => { + const { entity, loading, error } = useEntityFromUrl(); + + return ( + + {children} + + ); +}; diff --git a/plugins/catalog/src/components/EntityProvider/index.ts b/plugins/catalog/src/components/EntityProvider/index.ts new file mode 100644 index 0000000000..01eae4737f --- /dev/null +++ b/plugins/catalog/src/components/EntityProvider/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityProvider } from './EntityProvider'; diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx new file mode 100644 index 0000000000..61855f56d9 --- /dev/null +++ b/plugins/catalog/src/components/Router.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { ComponentType } from 'react'; +import { CatalogPage } from './CatalogPage'; +import { EntityPageLayout } from './EntityPageLayout'; +import { Route, Routes } from 'react-router'; +import { entityRoute, rootRoute } from '../routes'; +import { Content } from '@backstage/core'; +import { Typography, Link } from '@material-ui/core'; +import { EntityProvider } from './EntityProvider'; +import { useEntity } from '../hooks/useEntity'; + +const DefaultEntityPage = () => ( + + + This is default entity page. + + To override this component with your custom implementation, read + docs on{' '} + + backstage.io/docs + + + + } + /> + +); + +const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => { + const { entity } = useEntity(); + // Loading and error states + if (!entity) return ; + + // Otherwise EntityPage provided from the App + // Note that EntityPage will include EntityPageLayout already + return ; +}; + +export const Router = ({ + EntityPage = DefaultEntityPage, +}: { + EntityPage?: ComponentType; +}) => ( + + } /> + + + + } + /> + +); diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog/src/hooks/useEntity.ts new file mode 100644 index 0000000000..53b1cff73a --- /dev/null +++ b/plugins/catalog/src/hooks/useEntity.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect, createContext, useContext } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useApi, errorApiRef } from '@backstage/core'; +import { catalogApiRef } from '../api/types'; +import { useAsync } from 'react-use'; +import { Entity } from '@backstage/catalog-model'; + +const REDIRECT_DELAY = 2000; + +type EntityLoadingStatus = { + entity?: Entity; + loading: boolean; + error?: Error; +}; + +export const EntityContext = createContext({ + entity: undefined, + loading: true, + error: undefined, +}); + +export const useEntityFromUrl = (): EntityLoadingStatus => { + const { optionalNamespaceAndName, kind } = useParams(); + const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); + const navigate = useNavigate(); + const errorApi = useApi(errorApiRef); + const catalogApi = useApi(catalogApiRef); + + const { value: entity, error, loading } = useAsync( + () => catalogApi.getEntityByName({ kind, namespace, name }), + [catalogApi, kind, namespace, name], + ); + + useEffect(() => { + if (error || (!loading && !entity)) { + errorApi.post(new Error('Entity not found!')); + setTimeout(() => { + navigate('/'); + }, REDIRECT_DELAY); + } + + if (!name) { + errorApi.post(new Error('No name provided!')); + navigate('/'); + } + }, [errorApi, navigate, error, loading, entity, name]); + + return { entity, loading, error }; +}; + +/** + * Always going to return an entity, or throw an error if not a descendant of a EntityProvider. + */ +export const useEntity = () => { + const { entity } = useContext<{ entity: Entity }>(EntityContext as any); + return { entity }; +}; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 36fd69971d..a6d86e8102 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -19,3 +19,7 @@ export * from './api/CatalogClient'; export * from './api/types'; export * from './routes'; export { useEntityCompoundName } from './components/useEntityCompoundName'; +export { Router } from './components/Router'; +export { useEntity } from './hooks/useEntity'; +export { AboutCard } from './components/AboutCard'; +export { EntityPageLayout } from './components/EntityPageLayout'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 483e1f8464..4bfbf2453b 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -15,15 +15,7 @@ */ import { createPlugin } from '@backstage/core'; -import { CatalogPage } from './components/CatalogPage/CatalogPage'; -import { EntityPage } from './components/EntityPage/EntityPage'; -import { entityRoute, rootRoute, entityRouteDefault } from './routes'; export const plugin = createPlugin({ id: 'catalog', - register({ router }) { - router.addRoute(rootRoute, CatalogPage); - router.addRoute(entityRoute, EntityPage); - router.addRoute(entityRouteDefault, EntityPage); - }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index a8ff8df685..14e23ff73b 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -20,16 +20,11 @@ const NoIcon = () => null; export const rootRoute = createRouteRef({ icon: NoIcon, - path: '/', + path: '', title: 'Catalog', }); export const entityRoute = createRouteRef({ icon: NoIcon, - path: '/catalog/:kind/:optionalNamespaceAndName/:selectedTabId/*', - title: 'Entity', -}); -export const entityRouteDefault = createRouteRef({ - icon: NoIcon, - path: '/catalog/:kind/:optionalNamespaceAndName', + path: ':kind/:optionalNamespaceAndName/*', title: 'Entity', }); diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 2dc3c9fa61..e4f09976eb 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -35,6 +35,7 @@ "moment": "^2.27.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3" }, diff --git a/plugins/github-actions/src/components/Widget/Widget.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx similarity index 75% rename from plugins/github-actions/src/components/Widget/Widget.tsx rename to plugins/github-actions/src/components/Cards/Cards.tsx index 59e1cc5289..478b3810cb 100644 --- a/plugins/github-actions/src/components/Widget/Widget.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -74,7 +74,7 @@ const WidgetContent = ({ ); }; -export const Widget = ({ +export const LatestWorkflowRunCard = ({ entity, branch = 'master', }: { @@ -109,50 +109,14 @@ export const Widget = ({ ); }; -const RecentWorkflowRunsCardContent = ({ - error, - loading, - branch, -}: { - error?: Error; - loading?: boolean; - branch: string; -}) => { - if (error) return Couldn't fetch {branch} runs; - if (loading) return ; - return ; -}; - -export const RecentWorkflowRunsCard = ({ +export const LatestWorkflowsForBranchCard = ({ entity, branch = 'master', }: { entity: Entity; branch: string; -}) => { - const errorApi = useApi(errorApiRef); - const [owner, repo] = ( - entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/' - ).split('/'); - const [{ loading, error }] = useWorkflowRuns({ - owner, - repo, - branch, - }); - - useEffect(() => { - if (error) { - errorApi.post(error); - } - }, [error, errorApi]); - - return ( - - - - ); -}; +}) => ( + + + +); diff --git a/plugins/github-actions/src/components/Cards/index.ts b/plugins/github-actions/src/components/Cards/index.ts new file mode 100644 index 0000000000..8c987ea1d5 --- /dev/null +++ b/plugins/github-actions/src/components/Cards/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards'; diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx new file mode 100644 index 0000000000..d0745f67d4 --- /dev/null +++ b/plugins/github-actions/src/components/Router.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Routes, Route } from 'react-router'; +import { rootRouteRef, buildRouteRef } from '../plugin'; +import { WorkflowRunDetails } from './WorkflowRunDetails'; +import { WorkflowRunsTable } from './WorkflowRunsTable'; +import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName'; +import { WarningPanel } from '@backstage/core'; + +const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]) && + entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== ''; + +export const Router = ({ entity }: { entity: Entity }) => + // TODO(shmidt-i): move warning to a separate standardized component + !isPluginApplicableToEntity(entity) ? ( + + `entity.metadata.annotations[' + {GITHUB_ACTIONS_ANNOTATION}']` key is missing on the entity.{' '} + + ) : ( + + } + /> + } + /> + ) + + ); diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx index 8c445ca9f6..d8e3b02524 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ import React from 'react'; -import { useEntityCompoundName } from '@backstage/plugin-catalog'; import { useWorkflowRunsDetails } from './useWorkflowRunsDetails'; import { useWorkflowRunJobs } from './useWorkflowRunJobs'; import { useProjectName } from '../useProjectName'; @@ -35,13 +34,16 @@ import { LinearProgress, CircularProgress, Theme, - Link, + Breadcrumbs, + Link as MaterialLink, } from '@material-ui/core'; import { Jobs, Job, Step } from '../../api'; import moment from 'moment'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExternalLinkIcon from '@material-ui/icons/Launch'; +import { Entity } from '@backstage/catalog-model'; +import { Link } from '@backstage/core'; const useStyles = makeStyles(theme => ({ root: { @@ -140,18 +142,8 @@ const JobListItem = ({ job, className }: { job: Job; className: string }) => { ); }; -export const WorkflowRunDetails = () => { - let entityCompoundName = useEntityCompoundName(); - if (!entityCompoundName.name) { - // TODO(shmidt-i): remove when is fully integrated - // into the entity view - entityCompoundName = { - kind: 'Component', - name: 'backstage', - namespace: 'default', - }; - } - const projectName = useProjectName(entityCompoundName); +export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { + const projectName = useProjectName(entity); const [owner, repo] = projectName.value ? projectName.value.split('/') : []; const details = useWorkflowRunsDetails(repo, owner); @@ -170,6 +162,10 @@ export const WorkflowRunDetails = () => { } return (
+ + Workflow runs + Workflow run details + @@ -211,10 +207,10 @@ export const WorkflowRunDetails = () => { {details.value?.html_url && ( - + Workflow runs on GitHub{' '} - + )} diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx b/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx deleted file mode 100644 index ec9a3f484d..0000000000 --- a/plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Typography, Grid, Breadcrumbs } from '@material-ui/core'; - -import React from 'react'; -import { - Link, - Page, - Header, - HeaderLabel, - Content, - ContentHeader, - SupportButton, - pageTheme, -} from '@backstage/core'; - -import { WorkflowRunDetails } from '../WorkflowRunDetails'; - -/** - * A component for Jobs visualization. Jobs are a property of a Workflow Run. - */ -export const WorkflowRunDetailsPage = () => { - return ( - -
- - -
- - - - This plugin allows you to view and interact with your builds within - the GitHub Actions environment. - - - - Workflow runs - Workflow run details - - - - - - - -
- ); -}; diff --git a/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx b/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx deleted file mode 100644 index b75a70f326..0000000000 --- a/plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Header, - HeaderLabel, - pageTheme, - Page, - Content, - ContentHeader, - SupportButton, -} from '@backstage/core'; -import { Grid } from '@material-ui/core'; -import React from 'react'; - -import { WorkflowRunsTable } from '../WorkflowRunsTable'; - -export const WorkflowRunsPage = () => { - return ( - -
- - -
- - - - This plugin allows you to view and interact with your builds within - the GitHub Actions environment. - - - - - - - - -
- ); -}; diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index a2a6ead9b5..cc834f09ee 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -23,8 +23,8 @@ import { useWorkflowRuns } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; import { buildRouteRef } from '../../plugin'; -import { useEntityCompoundName } from '@backstage/plugin-catalog'; import { useProjectName } from '../useProjectName'; +import { Entity } from '@backstage/catalog-model'; export type WorkflowRun = { id: string; @@ -134,6 +134,7 @@ export const WorkflowRunsTableView: FC = ({ data={runs ?? []} onChangePage={onChangePage} onChangeRowsPerPage={onChangePageSize} + style={{ width: '100%' }} title={ @@ -146,25 +147,21 @@ export const WorkflowRunsTableView: FC = ({ ); }; -export const WorkflowRunsTable = () => { - let entityCompoundName = useEntityCompoundName(); - - if (!entityCompoundName.name) { - // TODO(shmidt-i): remove when is fully integrated - // into the entity view - entityCompoundName = { - kind: 'Component', - name: 'backstage', - namespace: 'default', - }; - } - - const { value: projectName, loading } = useProjectName(entityCompoundName); +export const WorkflowRunsTable = ({ + entity, + branch, +}: { + entity: Entity; + branch?: string; +}) => { + const { value: projectName, loading } = useProjectName(entity); const [owner, repo] = (projectName ?? '/').split('/'); const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({ owner, repo, + branch, }); + return ( { - const catalogApi = useApi(catalogApiRef); +export const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; +export const useProjectName = (entity: Entity) => { const { value, loading, error } = useAsync(async () => { - const entity = await catalogApi.getEntityByName(name); - return entity?.metadata.annotations?.['github.com/project-slug'] ?? ''; + return entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? ''; }); return { value, loading, error }; }; diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts index 4e383fdd3d..17c1fa2dd7 100644 --- a/plugins/github-actions/src/index.ts +++ b/plugins/github-actions/src/index.ts @@ -16,4 +16,6 @@ export { plugin } from './plugin'; export * from './api'; -export { Widget, RecentWorkflowRunsCard } from './components/Widget'; +export { Router } from './components/Router'; +export * from './components/Cards'; +export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index dbc366bd71..7e08229d6d 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -15,28 +15,18 @@ */ import { createPlugin, createRouteRef } from '@backstage/core'; -import { WorkflowRunDetailsPage } from './components/WorkflowRunDetailsPage'; -import { WorkflowRunsPage } from './components/WorkflowRunsPage'; // TODO(freben): This is just a demo route for now export const rootRouteRef = createRouteRef({ - path: '/github-actions', + path: '', title: 'GitHub Actions', }); -export const projectRouteRef = createRouteRef({ - path: '/github-actions/:kind/:optionalNamespaceAndName/', - title: 'GitHub Actions for project', -}); + export const buildRouteRef = createRouteRef({ - path: '/github-actions/workflow-run/:id', + path: ':id', title: 'GitHub Actions Workflow Run', }); export const plugin = createPlugin({ id: 'github-actions', - register({ router }) { - router.addRoute(rootRouteRef, WorkflowRunsPage); - router.addRoute(projectRouteRef, WorkflowRunsPage); - router.addRoute(buildRouteRef, WorkflowRunDetailsPage); - }, }); diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index f5a592b7b7..c6ed55c92c 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -26,7 +26,7 @@ import { useJobPolling } from './useJobPolling'; import { Job } from '../../types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Button } from '@backstage/core'; -import { entityRouteDefault } from '@backstage/plugin-catalog'; +import { entityRoute } from '@backstage/plugin-catalog'; import { generatePath } from 'react-router-dom'; type Props = { @@ -72,7 +72,7 @@ export const JobStatusModal = ({ {entity && (