From d3db3a784997765295e9d72f3112b5c4688ea54c Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Fri, 21 Aug 2020 23:04:51 +0200 Subject: [PATCH 01/21] feat: IoC, DI, indirection, routes, tabs, catalog, app, github-actions --- packages/app/src/App.tsx | 15 +- packages/app/src/components/Root/Root.tsx | 2 +- .../app/src/components/catalog/Component.tsx | 82 +++++++++ .../app/src/components/catalog/index.tsx | 15 +- plugins/catalog/package.json | 1 + plugins/catalog/src/Router.tsx | 164 ++++++++++++++++++ .../EntityPageTabs/EntityPageTabs.tsx | 94 ++++++++++ .../src/components/EntityPageTabs}/index.ts | 3 +- plugins/catalog/src/hooks/useEntity.ts | 55 ++++++ plugins/catalog/src/index.ts | 3 + plugins/catalog/src/routes.ts | 6 +- plugins/github-actions/package.json | 1 + plugins/github-actions/src/Router.tsx | 34 ++++ .../WorkflowRunDetails/WorkflowRunDetails.tsx | 28 ++- .../WorkflowRunDetailsPage.tsx | 65 ------- .../WorkflowRunsPage/WorkflowRunsPage.tsx | 56 ------ .../WorkflowRunsTable/WorkflowRunsTable.tsx | 19 +- .../src/components/useProjectName.ts | 10 +- plugins/github-actions/src/index.ts | 2 + plugins/github-actions/src/plugin.ts | 17 +- 20 files changed, 494 insertions(+), 178 deletions(-) create mode 100644 packages/app/src/components/catalog/Component.tsx rename plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts => packages/app/src/components/catalog/index.tsx (60%) create mode 100644 plugins/catalog/src/Router.tsx create mode 100644 plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx rename plugins/{github-actions/src/components/WorkflowRunsPage => catalog/src/components/EntityPageTabs}/index.ts (91%) create mode 100644 plugins/catalog/src/hooks/useEntity.ts create mode 100644 plugins/github-actions/src/Router.tsx delete mode 100644 plugins/github-actions/src/components/WorkflowRunDetailsPage/WorkflowRunDetailsPage.tsx delete mode 100644 plugins/github-actions/src/components/WorkflowRunsPage/WorkflowRunsPage.tsx diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 993fd77978..cb0853a013 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -26,6 +26,11 @@ import * as plugins from './plugins'; import { apis } from './apis'; import { hot } from 'react-hot-loader/root'; import { providers } from './identityProviders'; +import { CatalogPlugin } from '@backstage/plugin-catalog'; +// import { ExplorePlugin } from '@backstage/plugin-explore'; +import { Route, Routes } from 'react-router'; + +import { EntityPage } from './components/catalog'; const app = createApp({ apis, @@ -46,8 +51,16 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const AppRoutes = app.getRoutes(); +const AppRoutes = () => ( + + } + /> + {/* } /> */} + +); const App: FC<{}> = () => ( diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 1d32b6e16e..c6ce87444c 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -89,7 +89,7 @@ const Root: FC<{}> = ({ children }) => ( {/* Global nav, not org-specific */} - + diff --git a/packages/app/src/components/catalog/Component.tsx b/packages/app/src/components/catalog/Component.tsx new file mode 100644 index 0000000000..2d2aef9f79 --- /dev/null +++ b/packages/app/src/components/catalog/Component.tsx @@ -0,0 +1,82 @@ +/* + * 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 { + GitHubActionsPlugin, + GITHUB_ACTIONS_ANNOTATION, +} from '@backstage/plugin-github-actions'; +import { + useEntity, + EntityPageTabs as Tabs, + EntityMetadataCard, +} from '@backstage/plugin-catalog'; +import { Grid } from '@material-ui/core'; + +const OverviewPage = () => { + const entity = useEntity(); + return ( + + + + ); +}; + +// Just for illustration purposes, gonna live in plugin-circle-ci +const CIRCLE_CI_ANNOTATION = 'circle-ci-slug-dummy'; + +const DefaultEntity = () => { + const entity = useEntity(); + + const isCIAvailable = [ + entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION], + entity!.metadata!.annotations?.[CIRCLE_CI_ANNOTATION], + ].some(Boolean); + + return ( + + + + + {isCIAvailable && ( + + {entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION] && ( + + )} + + )} + + {/* eslint-disable-next-line no-console */} +
{console.log('was here /docs')}Docs tab contents
+
+
+ ); +}; + +const Service = () => ; +const Website = () => ; +const UnkownEntity = () =>
Unknown entity!
; + +export const ComponentEntity = () => { + const entity = useEntity(); + switch (entity.spec!.type) { + case 'service': + return ; + case 'website': + return ; + default: + return ; + } +}; diff --git a/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts b/packages/app/src/components/catalog/index.tsx similarity index 60% rename from plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts rename to packages/app/src/components/catalog/index.tsx index 443bcacc88..9aaf1a568b 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetailsPage/index.ts +++ b/packages/app/src/components/catalog/index.tsx @@ -13,5 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import React from 'react'; +import { useEntity } from '@backstage/plugin-catalog'; +import { ComponentEntity } from './Component'; -export { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage'; +const UnkownEntityKind = () =>
Unknown entity kind!
; + +export const EntityPage = () => { + const entity = useEntity(); + switch (entity.kind) { + case 'Component': + return ; + default: + return ; + } +}; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 1e10e91478..4768fdf985 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -36,6 +36,7 @@ "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", diff --git a/plugins/catalog/src/Router.tsx b/plugins/catalog/src/Router.tsx new file mode 100644 index 0000000000..701b9d1045 --- /dev/null +++ b/plugins/catalog/src/Router.tsx @@ -0,0 +1,164 @@ +/* + * 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, { FC, useState } from 'react'; +import { CatalogPage } from './components/CatalogPage/CatalogPage'; +// import { EntityPage } from './components/EntityPage/EntityPage'; +import { Route, Routes, useParams, useNavigate } from 'react-router'; +import { entityRoute, rootRoute, entityRouteDefault } from './routes'; +import { useEntityFromUrl, EntityContext, useEntity } from './hooks/useEntity'; +import { + pageTheme, + PageTheme, + Page, + Header, + HeaderLabel, + Content, + Progress, +} from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { FavouriteEntity } from './components/FavouriteEntity/FavouriteEntity'; +import { Box } from '@material-ui/core'; +import { EntityContextMenu } from './components/EntityContextMenu/EntityContextMenu'; +import { UnregisterEntityDialog } from './components/UnregisterEntityDialog/UnregisterEntityDialog'; +import { Alert } from '@material-ui/lab'; +// const EntityContext = React.createContext(null); +export 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; + })(), + }; +} +const EntityPageLayout = ({ children }: { children: React.ReactNode }) => { + const { entity, loading, error } = useEntityFromUrl(); + const { optionalNamespaceAndName, kind } = useParams() as { + optionalNamespaceAndName: string; + kind: string; + }; + const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); + + 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)} + /> +
+ ); +}; + +export const CatalogPlugin = ({ EntityPage }) => ( + + } /> + + + + } + /> + + + + } + /> + +); + +export { EntityMetadataCard } from './components/EntityMetadataCard/EntityMetadataCard'; diff --git a/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx b/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx new file mode 100644 index 0000000000..f4f9308669 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx @@ -0,0 +1,94 @@ +/* + * 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 { Grid } from '@material-ui/core'; +import { Helmet } from 'react-helmet'; + +const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => { + if (!matchedRoute) return 0; + const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path); + return ~tabIndex ? tabIndex : 0; +}; +export const EntityPageTabs = ({ 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; + } + const pathAndId = + (child as JSX.Element).props.path + + ((child as JSX.Element).props.exact ? '' : '/*'); + routes.push({ + path: pathAndId, + element: (child as JSX.Element).props.children, + }); + tabs.push({ + id: pathAndId, + label: (child as JSX.Element).props.title, + }); + routes.push({ + path: '/*', + element: , + }); + }); + const [matchedRoute] = + matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; + const selectedIndex = getSelectedIndex(matchedRoute, tabs); + const currentTab = tabs[selectedIndex]; + const title = currentTab.label; + + const onTabChange = (index: number) => navigate(tabs[index].id.slice(1, -2)); + + const currentRouteElement = useRoutes(routes); + + return ( + <> + + + + + {currentRouteElement} + + + + ); +}; +type TabProps = { + children: React.ReactNode; + title: string; + path: string; + exact?: boolean; +}; +EntityPageTabs.Tab = (_props: TabProps) => null; diff --git a/plugins/github-actions/src/components/WorkflowRunsPage/index.ts b/plugins/catalog/src/components/EntityPageTabs/index.ts similarity index 91% rename from plugins/github-actions/src/components/WorkflowRunsPage/index.ts rename to plugins/catalog/src/components/EntityPageTabs/index.ts index ef511763f7..83fc3d1095 100644 --- a/plugins/github-actions/src/components/WorkflowRunsPage/index.ts +++ b/plugins/catalog/src/components/EntityPageTabs/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 { EntityPageTabs } from './EntityPageTabs'; diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog/src/hooks/useEntity.ts new file mode 100644 index 0000000000..022be1126f --- /dev/null +++ b/plugins/catalog/src/hooks/useEntity.ts @@ -0,0 +1,55 @@ +/* + * 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; + +export const EntityContext = createContext((null as any) as Entity); +export const useEntityFromUrl = () => { + 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); + } + }, [errorApi, navigate, error, loading, entity]); + + if (!name) { + navigate('/catalog'); + return { entity: null, loading: null, error: new Error('No name in url') }; + } + + return { entity, loading, error }; +}; + +export const useEntity = () => useContext(EntityContext); diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 36fd69971d..ba8826aaa9 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -19,3 +19,6 @@ export * from './api/CatalogClient'; export * from './api/types'; export * from './routes'; export { useEntityCompoundName } from './components/useEntityCompoundName'; +export * from './Router'; +export { useEntity } from './hooks/useEntity'; +export { EntityPageTabs } from './components/EntityPageTabs'; diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index a8ff8df685..c3fd97564a 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -20,16 +20,16 @@ const NoIcon = () => null; export const rootRoute = createRouteRef({ icon: NoIcon, - path: '/', + path: '', title: 'Catalog', }); export const entityRoute = createRouteRef({ icon: NoIcon, - path: '/catalog/:kind/:optionalNamespaceAndName/:selectedTabId/*', + path: ':kind/:optionalNamespaceAndName/*', 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 db35022fae..3386ed0db2 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/Router.tsx b/plugins/github-actions/src/Router.tsx new file mode 100644 index 0000000000..b843c594b9 --- /dev/null +++ b/plugins/github-actions/src/Router.tsx @@ -0,0 +1,34 @@ +/* + * 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 './components/WorkflowRunDetails'; +import { WorkflowRunsTable } from './components/WorkflowRunsTable'; + +export const GitHubActionsPlugin = ({ entity }: { entity: 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..7e971118fc 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -25,6 +25,7 @@ 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 +135,7 @@ export const WorkflowRunsTableView: FC = ({ data={runs ?? []} onChangePage={onChangePage} onChangeRowsPerPage={onChangePageSize} + style={{ width: '100%' }} title={ @@ -146,25 +148,14 @@ 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 }: { entity: Entity }) => { + const { value: projectName, loading } = useProjectName(entity); const [owner, repo] = (projectName ?? '/').split('/'); const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({ owner, repo, }); + 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 4a69c363cd..f500685507 100644 --- a/plugins/github-actions/src/index.ts +++ b/plugins/github-actions/src/index.ts @@ -17,3 +17,5 @@ export { plugin } from './plugin'; export * from './api'; export { Widget } from './components/Widget'; +export { GitHubActionsPlugin } from './Router'; +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..ead33b7959 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -15,28 +15,19 @@ */ 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); - }, + register() {}, }); From e76d33e97df7195b521c13df22dde5ff05a99376 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Sat, 22 Aug 2020 10:37:02 +0200 Subject: [PATCH 02/21] fix: move catch-all route out of loop --- .../src/components/EntityPageTabs/EntityPageTabs.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx b/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx index f4f9308669..2053c05b91 100644 --- a/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx +++ b/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx @@ -38,6 +38,7 @@ export const EntityPageTabs = ({ children }: { children: React.ReactNode }) => { 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 @@ -54,11 +55,14 @@ export const EntityPageTabs = ({ children }: { children: React.ReactNode }) => { id: pathAndId, label: (child as JSX.Element).props.title, }); - routes.push({ - path: '/*', - element: , - }); }); + + // Add catch-all for incorrect sub-routes + routes.push({ + path: '/*', + element: , + }); + const [matchedRoute] = matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; const selectedIndex = getSelectedIndex(matchedRoute, tabs); From 43ac2c0af7f32c80188fd44468bbae279bf08069 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Mon, 31 Aug 2020 14:30:01 +0200 Subject: [PATCH 03/21] feat(catalog, app): new api 4 plugins integration --- packages/app/package.json | 1 + packages/app/src/App.tsx | 7 +- packages/app/src/apis.ts | 5 +- .../app/src/components/catalog/Component.tsx | 82 ------- .../app/src/components/catalog/EntityPage.tsx | 89 +++++++ plugins/catalog/src/Router.tsx | 164 +++---------- .../{EntityPageTabs => CatalogPage}/index.ts | 2 +- .../components/EntityPage/EntityPage.test.tsx | 100 -------- .../src/components/EntityPage/EntityPage.tsx | 231 ------------------ .../EntityPageLayout/EntityPageLayout.tsx | 147 +++++++++++ .../EntityPageLayout/Tabbed/Tabbed.tsx | 98 ++++++++ .../EntityPageLayout/Tabbed/index.ts | 16 ++ .../src/components/EntityPageLayout/index.ts | 16 ++ .../EntityPageTabs/EntityPageTabs.tsx | 98 -------- .../EntityProvider/EntityProvider.tsx | 21 +- .../src/components/EntityProvider/index.ts | 16 ++ plugins/catalog/src/hooks/useEntity.ts | 32 ++- plugins/catalog/src/index.ts | 3 +- plugins/catalog/src/plugin.ts | 8 - plugins/catalog/src/routes.ts | 2 +- plugins/github-actions/src/Router.tsx | 37 ++- plugins/github-actions/src/index.ts | 2 +- plugins/github-actions/src/plugin.ts | 1 - 23 files changed, 492 insertions(+), 686 deletions(-) delete mode 100644 packages/app/src/components/catalog/Component.tsx create mode 100644 packages/app/src/components/catalog/EntityPage.tsx rename plugins/catalog/src/components/{EntityPageTabs => CatalogPage}/index.ts (92%) delete mode 100644 plugins/catalog/src/components/EntityPage/EntityPage.test.tsx delete mode 100644 plugins/catalog/src/components/EntityPage/EntityPage.tsx create mode 100644 plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx create mode 100644 plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx create mode 100644 plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts create mode 100644 plugins/catalog/src/components/EntityPageLayout/index.ts delete mode 100644 plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx rename packages/app/src/components/catalog/index.tsx => plugins/catalog/src/components/EntityProvider/EntityProvider.tsx (60%) create mode 100644 plugins/catalog/src/components/EntityProvider/index.ts diff --git a/packages/app/package.json b/packages/app/package.json index d6598b9954..4b9b40466b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,6 +5,7 @@ "dependencies": { "@backstage/cli": "^0.1.1-alpha.19", "@backstage/core": "^0.1.1-alpha.19", + "@backstage/catalog-model": "^0.1.1-alpha.19", "@backstage/plugin-api-docs": "^0.1.1-alpha.19", "@backstage/plugin-catalog": "^0.1.1-alpha.19", "@backstage/plugin-circleci": "^0.1.1-alpha.19", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index cb0853a013..5abc058920 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -26,11 +26,11 @@ import * as plugins from './plugins'; import { apis } from './apis'; import { hot } from 'react-hot-loader/root'; import { providers } from './identityProviders'; -import { CatalogPlugin } from '@backstage/plugin-catalog'; +import { CatalogRouter } from '@backstage/plugin-catalog'; // import { ExplorePlugin } from '@backstage/plugin-explore'; import { Route, Routes } from 'react-router'; -import { EntityPage } from './components/catalog'; +import { EntityPage } from './components/catalog/EntityPage'; const app = createApp({ apis, @@ -56,11 +56,12 @@ const AppRoutes = () => ( } + element={} /> {/* } /> */} ); + const App: FC<{}> = () => ( diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index df023b80cb..f8a28ca85a 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -74,7 +74,10 @@ import { TravisCIApi, travisCIApiRef, } from '@roadiehq/backstage-plugin-travis-ci'; -import { GithubPullRequestsClient, githubPullRequestsApiRef } from '@roadiehq/backstage-plugin-github-pull-requests'; +import { + GithubPullRequestsClient, + githubPullRequestsApiRef, +} from '@roadiehq/backstage-plugin-github-pull-requests'; export const apis = (config: ConfigApi) => { // eslint-disable-next-line no-console diff --git a/packages/app/src/components/catalog/Component.tsx b/packages/app/src/components/catalog/Component.tsx deleted file mode 100644 index 2d2aef9f79..0000000000 --- a/packages/app/src/components/catalog/Component.tsx +++ /dev/null @@ -1,82 +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 React from 'react'; -import { - GitHubActionsPlugin, - GITHUB_ACTIONS_ANNOTATION, -} from '@backstage/plugin-github-actions'; -import { - useEntity, - EntityPageTabs as Tabs, - EntityMetadataCard, -} from '@backstage/plugin-catalog'; -import { Grid } from '@material-ui/core'; - -const OverviewPage = () => { - const entity = useEntity(); - return ( - - - - ); -}; - -// Just for illustration purposes, gonna live in plugin-circle-ci -const CIRCLE_CI_ANNOTATION = 'circle-ci-slug-dummy'; - -const DefaultEntity = () => { - const entity = useEntity(); - - const isCIAvailable = [ - entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION], - entity!.metadata!.annotations?.[CIRCLE_CI_ANNOTATION], - ].some(Boolean); - - return ( - - - - - {isCIAvailable && ( - - {entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION] && ( - - )} - - )} - - {/* eslint-disable-next-line no-console */} -
{console.log('was here /docs')}Docs tab contents
-
-
- ); -}; - -const Service = () => ; -const Website = () => ; -const UnkownEntity = () =>
Unknown entity!
; - -export const ComponentEntity = () => { - const entity = useEntity(); - switch (entity.spec!.type) { - case 'service': - return ; - case 'website': - return ; - default: - return ; - } -}; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx new file mode 100644 index 0000000000..4368d8e26b --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -0,0 +1,89 @@ +/* + * 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 { GitHubActionsPage } from '@backstage/plugin-github-actions'; +import React from 'react'; +import { + EntityPageLayout, + useEntity, + EntityMetadataCard, +} from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; + +export const OverviewPage = ({ entity }: { entity: Entity }) => ( + +); + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + Much docs, such wow ({entity.metadata.name})🐶} + /> + +); + +const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + Much docs, such wow ({entity.metadata.name})🐶} + /> + +); + +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/plugins/catalog/src/Router.tsx b/plugins/catalog/src/Router.tsx index 701b9d1045..161536c714 100644 --- a/plugins/catalog/src/Router.tsx +++ b/plugins/catalog/src/Router.tsx @@ -13,152 +13,58 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useState } from 'react'; -import { CatalogPage } from './components/CatalogPage/CatalogPage'; -// import { EntityPage } from './components/EntityPage/EntityPage'; -import { Route, Routes, useParams, useNavigate } from 'react-router'; +import React, { ComponentType } from 'react'; +import { CatalogPage } from './components/CatalogPage'; +import { EntityPageLayout } from './components/EntityPageLayout'; +import { Route, Routes } from 'react-router'; import { entityRoute, rootRoute, entityRouteDefault } from './routes'; -import { useEntityFromUrl, EntityContext, useEntity } from './hooks/useEntity'; -import { - pageTheme, - PageTheme, - Page, - Header, - HeaderLabel, - Content, - Progress, -} from '@backstage/core'; -import { Entity } from '@backstage/catalog-model'; -import { FavouriteEntity } from './components/FavouriteEntity/FavouriteEntity'; -import { Box } from '@material-ui/core'; -import { EntityContextMenu } from './components/EntityContextMenu/EntityContextMenu'; -import { UnregisterEntityDialog } from './components/UnregisterEntityDialog/UnregisterEntityDialog'; -import { Alert } from '@material-ui/lab'; -// const EntityContext = React.createContext(null); -export const getPageTheme = (entity?: Entity): PageTheme => { - const themeKey = entity?.spec?.type?.toString() ?? 'home'; - return pageTheme[themeKey] ?? pageTheme.home; -}; +import { Content } from '@backstage/core'; +import { Typography, Link } from '@material-ui/core'; +import { EntityProvider } from './components/EntityProvider'; -const EntityPageTitle = ({ - entity, - title, -}: { - title: string; - entity: Entity | undefined; -}) => ( - - {title} - {entity && } - +const DefaultEntityPage = () => ( + + + This is default entity page. + + To override this component with your custom implementation, read + docs on{' '} + + backstage.io/docs + + + + } + /> + ); -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; - })(), - }; -} -const EntityPageLayout = ({ children }: { children: React.ReactNode }) => { - const { entity, loading, error } = useEntityFromUrl(); - const { optionalNamespaceAndName, kind } = useParams() as { - optionalNamespaceAndName: string; - kind: string; - }; - const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); - - 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)} - /> -
- ); -}; - -export const CatalogPlugin = ({ EntityPage }) => ( +export const CatalogRouter = ({ + EntityPage = DefaultEntityPage, +}: { + EntityPage?: ComponentType; +}) => ( } /> + - + } /> + - + } /> ); - -export { EntityMetadataCard } from './components/EntityMetadataCard/EntityMetadataCard'; diff --git a/plugins/catalog/src/components/EntityPageTabs/index.ts b/plugins/catalog/src/components/CatalogPage/index.ts similarity index 92% rename from plugins/catalog/src/components/EntityPageTabs/index.ts rename to plugins/catalog/src/components/CatalogPage/index.ts index 83fc3d1095..1adb8866c8 100644 --- a/plugins/catalog/src/components/EntityPageTabs/index.ts +++ b/plugins/catalog/src/components/CatalogPage/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { EntityPageTabs } from './EntityPageTabs'; +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 adbc758067..0000000000 --- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx +++ /dev/null @@ -1,231 +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 { 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', - }, - { - 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/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx new file mode 100644 index 0000000000..532a9e3b0b --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -0,0 +1,147 @@ +/* + * 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 } from 'react'; +import { useParams, useNavigate } from 'react-router'; + +import { + useEntityFromUrl, + EntityContext, + useEntity, +} 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 } = useEntity(); + 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.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx new file mode 100644 index 0000000000..6e32dd1c80 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -0,0 +1,98 @@ +/* + * 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 { Grid } from '@material-ui/core'; +import { Helmet } from 'react-helmet'; + +const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => { + if (!matchedRoute) return 0; + const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path); + return ~tabIndex ? tabIndex : 0; +}; + +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; + } + 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 + routes.push({ + path: '/*', + element: , + }); + + const [matchedRoute] = + matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; + const selectedIndex = getSelectedIndex(matchedRoute, tabs); + const currentTab = tabs[selectedIndex]; + const title = currentTab.label; + + const onTabChange = (index: number) => + navigate(tabs[index].id.slice(1, -2)); + + const currentRouteElement = useRoutes(routes); + + return ( + <> + + + + + {currentRouteElement} + + + + ); + }, + Content: (_props: { path: string; title: string; element: JSX.Element }) => + null, +}; diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts b/plugins/catalog/src/components/EntityPageLayout/Tabbed/index.ts new file mode 100644 index 0000000000..d58cd626ba --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/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 { Tabbed } from './Tabbed'; diff --git a/plugins/catalog/src/components/EntityPageLayout/index.ts b/plugins/catalog/src/components/EntityPageLayout/index.ts new file mode 100644 index 0000000000..acf6f948f1 --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/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 { EntityPageLayout } from './EntityPageLayout'; diff --git a/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx b/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx deleted file mode 100644 index 2053c05b91..0000000000 --- a/plugins/catalog/src/components/EntityPageTabs/EntityPageTabs.tsx +++ /dev/null @@ -1,98 +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 React from 'react'; -import { - useParams, - useNavigate, - PartialRouteObject, - matchRoutes, - RouteObject, - useRoutes, - Navigate, - RouteMatch, -} from 'react-router'; -import { Tab, HeaderTabs, Content } from '@backstage/core'; -import { Grid } from '@material-ui/core'; -import { Helmet } from 'react-helmet'; - -const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => { - if (!matchedRoute) return 0; - const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path); - return ~tabIndex ? tabIndex : 0; -}; -export const EntityPageTabs = ({ 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; - } - const pathAndId = - (child as JSX.Element).props.path + - ((child as JSX.Element).props.exact ? '' : '/*'); - routes.push({ - path: pathAndId, - element: (child as JSX.Element).props.children, - }); - tabs.push({ - id: pathAndId, - label: (child as JSX.Element).props.title, - }); - }); - - // Add catch-all for incorrect sub-routes - routes.push({ - path: '/*', - element: , - }); - - const [matchedRoute] = - matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; - const selectedIndex = getSelectedIndex(matchedRoute, tabs); - const currentTab = tabs[selectedIndex]; - const title = currentTab.label; - - const onTabChange = (index: number) => navigate(tabs[index].id.slice(1, -2)); - - const currentRouteElement = useRoutes(routes); - - return ( - <> - - - - - {currentRouteElement} - - - - ); -}; -type TabProps = { - children: React.ReactNode; - title: string; - path: string; - exact?: boolean; -}; -EntityPageTabs.Tab = (_props: TabProps) => null; diff --git a/packages/app/src/components/catalog/index.tsx b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx similarity index 60% rename from packages/app/src/components/catalog/index.tsx rename to plugins/catalog/src/components/EntityProvider/EntityProvider.tsx index 9aaf1a568b..e328b64d44 100644 --- a/packages/app/src/components/catalog/index.tsx +++ b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx @@ -13,18 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { useEntity } from '@backstage/plugin-catalog'; -import { ComponentEntity } from './Component'; +import React, { ReactNode } from 'react'; +import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; -const UnkownEntityKind = () =>
Unknown entity kind!
; +export const EntityProvider = ({ children }: { children: ReactNode }) => { + const { entity, loading, error } = useEntityFromUrl(); -export const EntityPage = () => { - const entity = useEntity(); - switch (entity.kind) { - case 'Component': - return ; - default: - return ; - } + 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/hooks/useEntity.ts b/plugins/catalog/src/hooks/useEntity.ts index 022be1126f..ede45efeef 100644 --- a/plugins/catalog/src/hooks/useEntity.ts +++ b/plugins/catalog/src/hooks/useEntity.ts @@ -22,8 +22,19 @@ import { Entity } from '@backstage/catalog-model'; const REDIRECT_DELAY = 2000; -export const EntityContext = createContext((null as any) as Entity); -export const useEntityFromUrl = () => { +type EntityLoadingStatus = { + entity?: Entity; + loading: boolean | null; + error?: Error; +}; + +export const EntityContext = createContext({ + entity: undefined as any, + loading: null, + error: undefined, +}); + +export const useEntityFromUrl = (): EntityLoadingStatus => { const { optionalNamespaceAndName, kind } = useParams(); const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); const navigate = useNavigate(); @@ -36,7 +47,7 @@ export const useEntityFromUrl = () => { ); useEffect(() => { - if (!error && !loading && !entity) { + if (error || (!loading && !entity)) { errorApi.post(new Error('Entity not found!')); setTimeout(() => { navigate('/'); @@ -46,10 +57,21 @@ export const useEntityFromUrl = () => { if (!name) { navigate('/catalog'); - return { entity: null, loading: null, error: new Error('No name in url') }; + return { + entity: undefined, + loading: null, + error: new Error('No name in url'), + } as never; } return { entity, loading, error }; }; -export const useEntity = () => useContext(EntityContext); +/** + * Always going to return an entity, or throw an error if not a descendant of a EntityProvider. + * Otherwise the useEntityFromUrl will take care of the `undefined` entity + */ +export const useEntity = () => + useContext & { entity: Entity }>( + EntityContext as any, + ); diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index ba8826aaa9..d0e9778a00 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -21,4 +21,5 @@ export * from './routes'; export { useEntityCompoundName } from './components/useEntityCompoundName'; export * from './Router'; export { useEntity } from './hooks/useEntity'; -export { EntityPageTabs } from './components/EntityPageTabs'; +export { EntityMetadataCard } from './components/EntityMetadataCard/EntityMetadataCard'; +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 c3fd97564a..a9606760fb 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -30,6 +30,6 @@ export const entityRoute = createRouteRef({ }); export const entityRouteDefault = createRouteRef({ icon: NoIcon, - path: ':kind/:optionalNamespaceAndName', + path: ':kind/:optionalNamespaceAndName/*', title: 'Entity', }); diff --git a/plugins/github-actions/src/Router.tsx b/plugins/github-actions/src/Router.tsx index b843c594b9..c508302e6d 100644 --- a/plugins/github-actions/src/Router.tsx +++ b/plugins/github-actions/src/Router.tsx @@ -19,16 +19,29 @@ import { Routes, Route } from 'react-router'; import { rootRouteRef, buildRouteRef } from './plugin'; import { WorkflowRunDetails } from './components/WorkflowRunDetails'; import { WorkflowRunsTable } from './components/WorkflowRunsTable'; +import { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; +import { WarningPanel } from '@backstage/core'; -export const GitHubActionsPlugin = ({ entity }: { entity: Entity }) => ( - - } - /> - } - /> - -); +const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity?.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION]) && + entity?.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== ''; + +export const GitHubActionsPage = ({ entity }: { entity: Entity }) => + !isPluginApplicableToEntity(entity) ? ( + + `entity.metadata.annotations[' + {GITHUB_ACTIONS_ANNOTATION}']` key is missing on the entity.{' '} + + ) : ( + + } + /> + } + /> + ) + + ); diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts index f500685507..0bb5862d8b 100644 --- a/plugins/github-actions/src/index.ts +++ b/plugins/github-actions/src/index.ts @@ -17,5 +17,5 @@ export { plugin } from './plugin'; export * from './api'; export { Widget } from './components/Widget'; -export { GitHubActionsPlugin } from './Router'; +export { GitHubActionsPage } from './Router'; export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index ead33b7959..7e08229d6d 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -29,5 +29,4 @@ export const buildRouteRef = createRouteRef({ export const plugin = createPlugin({ id: 'github-actions', - register() {}, }); From e57798cd882131feda647c7a4ec2f796535e574e Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Mon, 31 Aug 2020 18:01:43 +0200 Subject: [PATCH 04/21] test(catalog): test Tabbed implementation --- .../EntityPageLayout/Tabbed/Tabbed.test.tsx | 183 ++++++++++++++++++ .../EntityPageLayout/Tabbed/Tabbed.tsx | 19 +- 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx 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..3eccafbf7a --- /dev/null +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx @@ -0,0 +1,183 @@ +/* + * 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('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 index 6e32dd1c80..6f3e5e1fcf 100644 --- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -34,6 +34,23 @@ const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => { return ~tabIndex ? tabIndex : 0; }; +/** + * 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[] = []; @@ -73,7 +90,7 @@ export const Tabbed = { const title = currentTab.label; const onTabChange = (index: number) => - navigate(tabs[index].id.slice(1, -2)); + navigate(tabs[index].id.replace(/\/\*$/g, '')); const currentRouteElement = useRoutes(routes); From 713060d1f320280300dbf9742c5a8cbe22941cdd Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Mon, 31 Aug 2020 23:18:06 +0200 Subject: [PATCH 05/21] docs: plugin integration into catalog --- docs/plugins/index.md | 7 + ...integrating-plugin-into-service-catalog.md | 125 ++++++++++++++++++ microsite/i18n/en.json | 5 +- microsite/sidebars.json | 1 + .../app/src/components/catalog/EntityPage.tsx | 20 +-- .../EntityPageLayout/Tabbed/Tabbed.tsx | 9 +- .../src/{ => components}/Router.tsx | 11 +- plugins/github-actions/src/index.ts | 3 +- 8 files changed, 154 insertions(+), 27 deletions(-) create mode 100644 docs/plugins/integrating-plugin-into-service-catalog.md rename plugins/github-actions/src/{ => components}/Router.tsx (80%) diff --git a/docs/plugins/index.md b/docs/plugins/index.md index bb8951f959..857b8f7fcf 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 catalog service + +If your plugin isn't supposed to live as a standalone page, but rather needs to +be presented as a part of a catalog service (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..7819737c52 --- /dev/null +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -0,0 +1,125 @@ +--- +id: integrating-plugin-into-service-catalog +title: Integrate into the catalog service +--- + +> 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; +}; +``` + +> The recommended pattern is to get the `entity` in the App and then pass it to +> the plugin's Router component as a prop. However if it inconvenient for you, +> use `useEntity` hook from `@backstage/plugin-catalog` directly inside your +> plugin. diff --git a/microsite/i18n/en.json b/microsite/i18n/en.json index d2593a13ce..1f04f7430b 100644 --- a/microsite/i18n/en.json +++ b/microsite/i18n/en.json @@ -199,7 +199,7 @@ "title": "Backend plugin" }, "plugins/call-existing-api": { - "title": "Call existing API" + "title": "Call Existing API" }, "plugins/create-a-plugin": { "title": "Create a Backstage Plugin" @@ -210,6 +210,9 @@ "plugins/index": { "title": "Intro" }, + "plugins/integrating-plugin-into-service-catalog": { + "title": "Integrate into the catalog service" + }, "plugins/plugin-development": { "title": "Plugin Development in Backstage" }, diff --git a/microsite/sidebars.json b/microsite/sidebars.json index b07f326086..7576296b7a 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -73,6 +73,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/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 4368d8e26b..1c7a08e362 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { GitHubActionsPage } from '@backstage/plugin-github-actions'; +import { Router as GitHubActionsRouter } from '@backstage/plugin-github-actions'; import React from 'react'; import { EntityPageLayout, @@ -22,7 +22,7 @@ import { } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; -export const OverviewPage = ({ entity }: { entity: Entity }) => ( +const OverviewPage = ({ entity }: { entity: Entity }) => ( ); @@ -36,12 +36,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( } - /> - Much docs, such wow ({entity.metadata.name})🐶} + element={} /> ); @@ -56,12 +51,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( } - /> - Much docs, such wow ({entity.metadata.name})🐶} + element={} /> ); @@ -69,7 +59,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( } /> diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx index 6f3e5e1fcf..ad0b9d067c 100644 --- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -78,10 +78,11 @@ export const Tabbed = { }); // Add catch-all for incorrect sub-routes - routes.push({ - path: '/*', - element: , - }); + if ((routes?.[0]?.path ?? '') !== '') + routes.push({ + path: '/*', + element: , + }); const [matchedRoute] = matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; diff --git a/plugins/github-actions/src/Router.tsx b/plugins/github-actions/src/components/Router.tsx similarity index 80% rename from plugins/github-actions/src/Router.tsx rename to plugins/github-actions/src/components/Router.tsx index c508302e6d..80637b75f0 100644 --- a/plugins/github-actions/src/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -16,17 +16,18 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from './plugin'; -import { WorkflowRunDetails } from './components/WorkflowRunDetails'; -import { WorkflowRunsTable } from './components/WorkflowRunsTable'; -import { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; +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 GitHubActionsPage = ({ entity }: { entity: Entity }) => +export const Router = ({ entity }: { entity: Entity }) => + // TODO(shmidt-i): move warning to a separate standardized component !isPluginApplicableToEntity(entity) ? ( `entity.metadata.annotations[' diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts index 0bb5862d8b..7f8b968c16 100644 --- a/plugins/github-actions/src/index.ts +++ b/plugins/github-actions/src/index.ts @@ -16,6 +16,5 @@ export { plugin } from './plugin'; export * from './api'; -export { Widget } from './components/Widget'; -export { GitHubActionsPage } from './Router'; +export { Router } from './components/Router'; export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; From e8259d15150b8fc1f0a1fb9fc4ffdfc7406e97c5 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Mon, 31 Aug 2020 23:34:32 +0200 Subject: [PATCH 06/21] fix: adjust code after rebasing --- packages/app/src/App.tsx | 3 +- .../EntityPageLayout/Tabbed/Tabbed.tsx | 18 ++++++--- yarn.lock | 37 +++++++++++++++++-- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 5abc058920..642eb7b004 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -28,7 +28,7 @@ import { hot } from 'react-hot-loader/root'; import { providers } from './identityProviders'; import { CatalogRouter } from '@backstage/plugin-catalog'; // import { ExplorePlugin } from '@backstage/plugin-explore'; -import { Route, Routes } from 'react-router'; +import { Route, Routes, Navigate } from 'react-router'; import { EntityPage } from './components/catalog/EntityPage'; @@ -58,6 +58,7 @@ const AppRoutes = () => ( path="/catalog/*" element={} /> + } /> {/* } /> */} ); diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx index ad0b9d067c..693ee10c78 100644 --- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -28,10 +28,14 @@ import { Tab, HeaderTabs, Content } from '@backstage/core'; import { Grid } from '@material-ui/core'; import { Helmet } from 'react-helmet'; -const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => { - if (!matchedRoute) return 0; +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 : 0; + return ~tabIndex ? tabIndex : defaultIndex; }; /** @@ -86,12 +90,16 @@ export const Tabbed = { const [matchedRoute] = matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; - const selectedIndex = getSelectedIndex(matchedRoute, tabs); + const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs); const currentTab = tabs[selectedIndex]; const title = currentTab.label; const onTabChange = (index: number) => - navigate(tabs[index].id.replace(/\/\*$/g, '')); + // 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); diff --git a/yarn.lock b/yarn.lock index d90056f47d..aa85370465 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2724,7 +2724,7 @@ dependencies: "@types/node" ">= 8" -"@open-draft/until@^1.0.3": +"@open-draft/until@^1.0.0", "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== @@ -4106,6 +4106,11 @@ dependencies: "@types/express" "*" +"@types/cookie@^0.3.3": + version "0.3.3" + resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + "@types/cookie@^0.4.0": version "0.4.0" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" @@ -11650,7 +11655,7 @@ graphql-upload@^8.0.2: http-errors "^1.7.3" object-path "^0.11.4" -graphql@15.3.0, graphql@^15.3.0: +graphql@15.3.0, graphql@^15.0.0, graphql@^15.3.0: version "15.3.0" resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== @@ -11858,7 +11863,7 @@ he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -headers-utils@^1.2.0: +headers-utils@^1.1.9, headers-utils@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/headers-utils/-/headers-utils-1.2.0.tgz#5e10d1bc9d2bccf789547afca5b991a3167241e8" integrity sha512-4/BMXcWrJErw7JpM87gF8MNEXcIMLzepYZjNRv/P9ctgupl2Ywa3u1PgHtNhSRq84bHH9Ndlkdy7bSi+bZ9I9A== @@ -15561,6 +15566,22 @@ ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +msw@^0.19.5: + version "0.19.5" + resolved "https://registry.npmjs.org/msw/-/msw-0.19.5.tgz#7d2a1a852ccf1644d3db6735d69fff6777aac33f" + integrity sha512-J5eQ++gDVZoHPC8gVXtWcakLjgmPipvFj/sEnlRV/WViXuiq2CamSqO3Wbh6H8bAmj+k2vUWCfcVT1HjMdKB2Q== + dependencies: + "@open-draft/until" "^1.0.0" + "@types/cookie" "^0.3.3" + chalk "^4.0.0" + cookie "^0.4.1" + graphql "^15.0.0" + headers-utils "^1.1.9" + node-match-path "^0.4.2" + node-request-interceptor "^0.2.5" + statuses "^2.0.0" + yargs "^15.3.1" + msw@^0.20.5: version "0.20.5" resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a" @@ -15807,7 +15828,7 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-match-path@^0.4.4: +node-match-path@^0.4.2, node-match-path@^0.4.4: version "0.4.4" resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.4.4.tgz#516a10926093c0cc6f237d020685b593b19baebb" integrity sha512-pBq9gp7TG0r0VXuy/oeZmQsjBSnYQo7G886Ly/B3azRwZuEtHCY155dzmfoKWcDPGgyfIGD8WKVC7h3+6y7yTg== @@ -15866,6 +15887,14 @@ node-releases@^1.1.29, node-releases@^1.1.52, node-releases@^1.1.58: resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== +node-request-interceptor@^0.2.5: + version "0.2.6" + resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.2.6.tgz#541278d7033bb6a8befb5dd793f83428cf6446a2" + integrity sha512-aJW1tPSM7nzuZFRe+C/KSz22GJO3CVFMxHHmMGX8Z+tjP7TCIVbzeckLFVfJG68BdVgrdOOP7Ejc57ag820eyA== + dependencies: + debug "^4.1.1" + headers-utils "^1.2.0" + node-request-interceptor@^0.3.5: version "0.3.5" resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.3.5.tgz#4b26159617829c9a70643012c0fdc3ae4c78ae43" From 8539b14a7e9b9099f131d8dfcd57d45ac488c53c Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Mon, 31 Aug 2020 23:44:33 +0200 Subject: [PATCH 07/21] refactor: simplify optional chaining --- plugins/github-actions/src/components/Router.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index 80637b75f0..d0745f67d4 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -23,8 +23,8 @@ 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] !== ''; + 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 From 5127da1f68facd2409dd2b566133a8885691ec30 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 00:13:46 +0200 Subject: [PATCH 08/21] fix(github-actions): cards --- .../components/EntityPageCi/EntityPageCi.tsx | 5 +- .../EntityPageLayout/EntityPageLayout.tsx | 6 +-- .../EntityPageOverview/EntityPageOverview.tsx | 7 +-- .../{Widget/Widget.tsx => Cards/Cards.tsx} | 50 +++---------------- .../src/components/{Widget => Cards}/index.ts | 2 +- .../WorkflowRunsTable/WorkflowRunsTable.tsx | 10 +++- plugins/github-actions/src/index.ts | 1 + 7 files changed, 22 insertions(+), 59 deletions(-) rename plugins/github-actions/src/components/{Widget/Widget.tsx => Cards/Cards.tsx} (75%) rename plugins/github-actions/src/components/{Widget => Cards}/index.ts (88%) 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 index 532a9e3b0b..17f9f46830 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -16,11 +16,7 @@ import React, { useState } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { - useEntityFromUrl, - EntityContext, - useEntity, -} from '../../hooks/useEntity'; +import { useEntity } from '../../hooks/useEntity'; import { pageTheme, PageTheme, diff --git a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx b/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx index 45a62198c7..26a1e08d42 100644 --- a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx +++ b/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx @@ -14,10 +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 { SentryIssuesWidget } from '@backstage/plugin-sentry'; -import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions'; import { JenkinsBuildsWidget, JenkinsLastBuildWidget, @@ -47,11 +47,6 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => { )} - {entity.metadata?.annotations?.['backstage.io/github-actions-id'] && ( - - - - )} { - 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/Widget/index.ts b/plugins/github-actions/src/components/Cards/index.ts similarity index 88% rename from plugins/github-actions/src/components/Widget/index.ts rename to plugins/github-actions/src/components/Cards/index.ts index 0bd5dad98a..8c987ea1d5 100644 --- a/plugins/github-actions/src/components/Widget/index.ts +++ b/plugins/github-actions/src/components/Cards/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 { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards'; diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 7e971118fc..cc834f09ee 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -23,7 +23,6 @@ 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'; @@ -148,12 +147,19 @@ export const WorkflowRunsTableView: FC = ({ ); }; -export const WorkflowRunsTable = ({ entity }: { entity: Entity }) => { +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 ( diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts index 7f8b968c16..17c1fa2dd7 100644 --- a/plugins/github-actions/src/index.ts +++ b/plugins/github-actions/src/index.ts @@ -17,4 +17,5 @@ export { plugin } from './plugin'; export * from './api'; export { Router } from './components/Router'; +export * from './components/Cards'; export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; From a62d0af2c8c8374eb1a2f809eb4824aba87d2761 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 11:12:43 +0200 Subject: [PATCH 09/21] chore(eslint): adjust no-unused-vars rule --- packages/cli/config/eslint.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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, From 542ee253df7432e14b861a214399a730a53d8d87 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 11:13:24 +0200 Subject: [PATCH 10/21] fix: update create-app to match example-app --- .../default-app/packages/app/src/App.tsx | 2 +- .../packages/app/src/components/AppRoutes.tsx | 14 ++++ .../app/src/components/catalog/EntityPage.tsx | 79 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/create-app/templates/default-app/packages/app/src/components/AppRoutes.tsx create mode 100644 packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx 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..9b431ca441 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,7 @@ import { import { apis } from './apis'; import * as plugins from './plugins'; import { AppSidebar } from './sidebar'; +import { AppRoutes } from './components/AppRoutes'; const app = createApp({ apis, @@ -16,7 +17,6 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); -const AppRoutes = app.getRoutes(); const App: FC<{}> = () => ( diff --git a/packages/create-app/templates/default-app/packages/app/src/components/AppRoutes.tsx b/packages/create-app/templates/default-app/packages/app/src/components/AppRoutes.tsx new file mode 100644 index 0000000000..b689052a4a --- /dev/null +++ b/packages/create-app/templates/default-app/packages/app/src/components/AppRoutes.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router'; +import { CatalogRouter } from '@backstage/plugin-catalog'; +import { EntityPage } from './catalog/EntityPage'; + +export const AppRoutes = () => ( + + } + /> + } /> + +); 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..1c7a08e362 --- /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, + EntityMetadataCard, +} from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; + +const OverviewPage = ({ 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 ; + } +}; From 7dc5425119af6bdc1246c6cf161ea55a246398c7 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 11:22:27 +0200 Subject: [PATCH 11/21] fix: typedep --- plugins/catalog/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 0a8b01b63f..1c830d74f6 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -52,6 +52,7 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/react": "^16.9", "jest-fetch-mock": "^3.0.3", "msw": "^0.20.5", "react-test-renderer": "^16.13.1", From 7b098885c51d6cdf2040079f323592c598338414 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 11:41:33 +0200 Subject: [PATCH 12/21] fix: AboutCard --- packages/app/src/components/catalog/EntityPage.tsx | 4 ++-- .../packages/app/src/components/catalog/EntityPage.tsx | 4 ++-- plugins/catalog/src/index.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 1c7a08e362..b56268036f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -18,12 +18,12 @@ import React from 'react'; import { EntityPageLayout, useEntity, - EntityMetadataCard, + AboutCard, } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; const OverviewPage = ({ entity }: { entity: Entity }) => ( - + ); const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( 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 index 1c7a08e362..b56268036f 100644 --- 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 @@ -18,12 +18,12 @@ import React from 'react'; import { EntityPageLayout, useEntity, - EntityMetadataCard, + AboutCard, } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; const OverviewPage = ({ entity }: { entity: Entity }) => ( - + ); const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index d0e9778a00..26285b4814 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -21,5 +21,5 @@ export * from './routes'; export { useEntityCompoundName } from './components/useEntityCompoundName'; export * from './Router'; export { useEntity } from './hooks/useEntity'; -export { EntityMetadataCard } from './components/EntityMetadataCard/EntityMetadataCard'; +export { AboutCard } from './components/AboutCard'; export { EntityPageLayout } from './components/EntityPageLayout'; From 39f739408f8bc4f213528acbf1e93668447e4c76 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 11:54:59 +0200 Subject: [PATCH 13/21] fix: typedeps again --- plugins/catalog/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 1c830d74f6..ec4ad4999a 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -40,7 +40,8 @@ "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.20", @@ -52,7 +53,6 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "@types/react": "^16.9", "jest-fetch-mock": "^3.0.3", "msw": "^0.20.5", "react-test-renderer": "^16.13.1", From 4f1bb23e6a6a6a6a6b725f6d437e61bc1aa932de Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 12:23:06 +0200 Subject: [PATCH 14/21] fix: package deps for create-app template --- .../templates/default-app/packages/app/package.json.hbs | 2 ++ 1 file changed, 2 insertions(+) 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 7d34a94c7b..9a59785388 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 @@ -11,6 +11,8 @@ "@backstage/plugin-register-component": "^{{version}}", "@backstage/plugin-scaffolder": "^{{version}}", "@backstage/plugin-techdocs": "^{{version}}", + "@backstage/catalog-model": "^{{version}}", + "@backstage/plugin-github-actions": "^{{version}}", "@backstage/test-utils": "^{{version}}", "@backstage/theme": "^{{version}}", "history": "^5.0.0", From 56412628699114d4fe732c349a3b48bd202a2481 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 12:44:06 +0200 Subject: [PATCH 15/21] fix: disable plugin verification in browser [temporary] --- packages/e2e-test/src/e2e-test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index 7377655497..6dab8caafb 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -277,11 +277,12 @@ async function testAppServe(pluginName: string, appDir: string) { const browser = new Browser(); await waitForPageWithText(browser, '/', 'Backstage Service Catalog'); - await waitForPageWithText( - browser, - `/${pluginName}`, - `Welcome to ${pluginName}!`, - ); + // TODO(shmidt-i): adjust the plugin creation flow with new routing patterns + // await waitForPageWithText( + // browser, + // `/${pluginName}`, + // `Welcome to ${pluginName}!`, + // ); print('Both App and Plugin loaded correctly'); successful = true; From aa2eeb8c49aae07d2bf67df6864c432c4f48e0a7 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 14:19:29 +0200 Subject: [PATCH 16/21] fix: e2e tsc --- packages/e2e-test/src/e2e-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index 6dab8caafb..4942a5abf5 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -266,7 +266,7 @@ async function createPlugin(pluginName: string, appDir: string) { /** * Start serving the newly created app and make sure that the create plugin is rendering correctly */ -async function testAppServe(pluginName: string, appDir: string) { +async function testAppServe(_pluginName: string, appDir: string) { const startApp = spawnPiped(['yarn', 'start'], { cwd: appDir, }); From 9a99a92f8462c8ac7fc27eb09fe352918a106de6 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Tue, 1 Sep 2020 15:53:12 +0200 Subject: [PATCH 17/21] fix: e2e hanging --- packages/e2e-test/src/e2e-test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index 4942a5abf5..f74c3f7166 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); } /** From 9988479c61cfac34e6e5089797d5316790858b51 Mon Sep 17 00:00:00 2001 From: Ivan Shmidt Date: Wed, 2 Sep 2020 15:56:14 +0200 Subject: [PATCH 18/21] refactor: address PR comments --- docs/plugins/index.md | 4 +-- ...integrating-plugin-into-service-catalog.md | 5 ++- packages/app/src/App.tsx | 8 ++--- .../app/src/components/catalog/EntityPage.tsx | 7 ++++- packages/core-api/src/app/App.tsx | 11 ++----- packages/core-api/src/app/types.ts | 2 +- packages/dev-utils/src/devApp/render.tsx | 4 +-- packages/e2e-test/src/e2e-test.ts | 13 ++++---- .../EntityPageLayout/EntityPageLayout.tsx | 9 +++--- .../EntityPageLayout/Tabbed/Tabbed.test.tsx | 15 +++++++++ .../EntityPageLayout/Tabbed/Tabbed.tsx | 15 +++++---- .../catalog/src/{ => components}/Router.tsx | 31 ++++++++++--------- plugins/catalog/src/hooks/useEntity.ts | 29 +++++++---------- plugins/catalog/src/index.ts | 2 +- plugins/catalog/src/routes.ts | 5 --- .../JobStatusModal/JobStatusModal.tsx | 4 +-- 16 files changed, 86 insertions(+), 78 deletions(-) rename plugins/catalog/src/{ => components}/Router.tsx (71%) diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 6941ff3e18..41ad082d7c 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -27,9 +27,9 @@ 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 catalog service +## 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 catalog service (e.g. a separate tab or a card on an +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 index 7819737c52..4408c27ccc 100644 --- a/docs/plugins/integrating-plugin-into-service-catalog.md +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -1,6 +1,6 @@ --- id: integrating-plugin-into-service-catalog -title: Integrate into the catalog service +title: Integrate into the Service Catalog --- > This is an advanced use case and currently is an experimental feature. Expect @@ -119,7 +119,6 @@ type EntityPageLayoutContentProps = { }; ``` -> The recommended pattern is to get the `entity` in the App and then pass it to -> the plugin's Router component as a prop. However if it inconvenient for you, +> 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/packages/app/src/App.tsx b/packages/app/src/App.tsx index 642eb7b004..cb38726716 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -26,8 +26,7 @@ import * as plugins from './plugins'; import { apis } from './apis'; import { hot } from 'react-hot-loader/root'; import { providers } from './identityProviders'; -import { CatalogRouter } from '@backstage/plugin-catalog'; -// import { ExplorePlugin } from '@backstage/plugin-explore'; +import { Router as CatalogRouter } from '@backstage/plugin-catalog'; import { Route, Routes, Navigate } from 'react-router'; import { EntityPage } from './components/catalog/EntityPage'; @@ -51,6 +50,7 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); +const deprecatedAppRoutes = app.getRoutes(); const AppRoutes = () => ( @@ -58,8 +58,8 @@ const AppRoutes = () => ( path="/catalog/*" element={} /> - } /> - {/* } /> */} + + {...deprecatedAppRoutes} ); diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index b56268036f..913c063f90 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -21,9 +21,14 @@ import { AboutCard, } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; +import { Grid } from '@material-ui/core'; const OverviewPage = ({ entity }: { entity: Entity }) => ( - + + + + + ); const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( 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/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 f74c3f7166..5dfac68d6a 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -269,7 +269,7 @@ async function createPlugin(pluginName: string, appDir: string) { /** * Start serving the newly created app and make sure that the create plugin is rendering correctly */ -async function testAppServe(_pluginName: string, appDir: string) { +async function testAppServe(pluginName: string, appDir: string) { const startApp = spawnPiped(['yarn', 'start'], { cwd: appDir, }); @@ -280,12 +280,11 @@ async function testAppServe(_pluginName: string, appDir: string) { const browser = new Browser(); await waitForPageWithText(browser, '/', 'Backstage Service Catalog'); - // TODO(shmidt-i): adjust the plugin creation flow with new routing patterns - // await waitForPageWithText( - // browser, - // `/${pluginName}`, - // `Welcome to ${pluginName}!`, - // ); + await waitForPageWithText( + browser, + `/${pluginName}`, + `Welcome to ${pluginName}!`, + ); print('Both App and Plugin loaded correctly'); successful = true; diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 17f9f46830..6a1b80150e 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { useEntity } from '../../hooks/useEntity'; +import { EntityContext } from '../../hooks/useEntity'; import { pageTheme, PageTheme, @@ -74,7 +74,7 @@ function headerProps( export const EntityPageLayout = ({ children, }: { - children: React.ReactNode; + children?: React.ReactNode; }) => { const { optionalNamespaceAndName, kind } = useParams() as { optionalNamespaceAndName: string; @@ -82,7 +82,7 @@ export const EntityPageLayout = ({ }; const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); - const { entity, loading, error } = useEntity(); + const { entity, loading, error } = useContext(EntityContext); const { headerTitle, headerType } = headerProps( kind, namespace, @@ -139,5 +139,4 @@ export const EntityPageLayout = ({ ); }; - 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 index 3eccafbf7a..da1e696fde 100644 --- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx @@ -36,6 +36,21 @@ describe('Tabbed layout', () => { 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(/EntityPageLayout component only accepts/); + }); + it('navigates when user clicks different tab', async () => { const rendered = await renderInTestApp( diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx index 693ee10c78..096b2fa708 100644 --- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx @@ -25,7 +25,6 @@ import { RouteMatch, } from 'react-router'; import { Tab, HeaderTabs, Content } from '@backstage/core'; -import { Grid } from '@material-ui/core'; import { Helmet } from 'react-helmet'; const getSelectedIndexOrDefault = ( @@ -67,6 +66,11 @@ export const Tabbed = { // 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 @@ -92,7 +96,7 @@ export const Tabbed = { matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? []; const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs); const currentTab = tabs[selectedIndex]; - const title = currentTab.label; + const title = currentTab?.label; const onTabChange = (index: number) => // Remove trailing /* @@ -103,6 +107,7 @@ export const Tabbed = { const currentRouteElement = useRoutes(routes); + if (!currentTab) return null; return ( <> - - - {currentRouteElement} - + + {currentRouteElement} ); diff --git a/plugins/catalog/src/Router.tsx b/plugins/catalog/src/components/Router.tsx similarity index 71% rename from plugins/catalog/src/Router.tsx rename to plugins/catalog/src/components/Router.tsx index 161536c714..61855f56d9 100644 --- a/plugins/catalog/src/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ import React, { ComponentType } from 'react'; -import { CatalogPage } from './components/CatalogPage'; -import { EntityPageLayout } from './components/EntityPageLayout'; +import { CatalogPage } from './CatalogPage'; +import { EntityPageLayout } from './EntityPageLayout'; import { Route, Routes } from 'react-router'; -import { entityRoute, rootRoute, entityRouteDefault } from './routes'; +import { entityRoute, rootRoute } from '../routes'; import { Content } from '@backstage/core'; import { Typography, Link } from '@material-ui/core'; -import { EntityProvider } from './components/EntityProvider'; +import { EntityProvider } from './EntityProvider'; +import { useEntity } from '../hooks/useEntity'; const DefaultEntityPage = () => ( @@ -43,7 +44,17 @@ const DefaultEntityPage = () => ( ); -export const CatalogRouter = ({ +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; @@ -54,15 +65,7 @@ export const CatalogRouter = ({ path={`/${entityRoute.path}`} element={ - - - } - /> - - + } /> diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog/src/hooks/useEntity.ts index ede45efeef..53b1cff73a 100644 --- a/plugins/catalog/src/hooks/useEntity.ts +++ b/plugins/catalog/src/hooks/useEntity.ts @@ -24,13 +24,13 @@ const REDIRECT_DELAY = 2000; type EntityLoadingStatus = { entity?: Entity; - loading: boolean | null; + loading: boolean; error?: Error; }; export const EntityContext = createContext({ - entity: undefined as any, - loading: null, + entity: undefined, + loading: true, error: undefined, }); @@ -53,25 +53,20 @@ export const useEntityFromUrl = (): EntityLoadingStatus => { navigate('/'); }, REDIRECT_DELAY); } - }, [errorApi, navigate, error, loading, entity]); - if (!name) { - navigate('/catalog'); - return { - entity: undefined, - loading: null, - error: new Error('No name in url'), - } as never; - } + 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. - * Otherwise the useEntityFromUrl will take care of the `undefined` entity */ -export const useEntity = () => - useContext & { entity: Entity }>( - EntityContext as any, - ); +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 26285b4814..a6d86e8102 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -19,7 +19,7 @@ export * from './api/CatalogClient'; export * from './api/types'; export * from './routes'; export { useEntityCompoundName } from './components/useEntityCompoundName'; -export * from './Router'; +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/routes.ts b/plugins/catalog/src/routes.ts index a9606760fb..14e23ff73b 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -28,8 +28,3 @@ export const entityRoute = createRouteRef({ path: ':kind/:optionalNamespaceAndName/*', title: 'Entity', }); -export const entityRouteDefault = createRouteRef({ - icon: NoIcon, - path: ':kind/:optionalNamespaceAndName/*', - title: 'Entity', -}); 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 && (