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() {}, });