diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx new file mode 100644 index 0000000000..5d1f010740 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 { EntityLayout } from './EntityLayout'; +import { + AlertApi, + alertApiRef, + ApiProvider, + ApiRegistry, +} from '@backstage/core'; +import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { Routes, Route } from 'react-router'; +import { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '../../hooks/useEntity'; +import { catalogApiRef } from '../../plugin'; +import { CatalogApi } from '@backstage/catalog-client'; + +const mockEntityData = { + loading: false, + error: undefined, + entity: { + kind: 'MyKind', + metadata: { + name: 'my-entity', + }, + } as Entity, +}; + +const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi).with( + alertApiRef, + {} as AlertApi, +); + +describe('EntityLayout', () => { + it('renders simplest case', async () => { + const rendered = await renderInTestApp( + + + + +
tabbed-test-content
+
+
+
+
, + ); + + expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + }); + + it('throws if any other component is a child of TabbedLayout', async () => { + const { error } = await withLogCollector(async () => { + await expect( + renderInTestApp( + + +
tabbed-test-content
+
+
This will cause app to throw
+
, + ), + ).rejects.toThrow(/Child of EntityLayout must be an EntityLayout.Route/); + }); + + expect(error).toEqual([ + expect.stringMatching( + /Child of EntityLayout must be an EntityLayout.Route/, + ), + expect.stringMatching( + /The above error occurred in the component/, + ), + ]); + }); + + 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(); + }); +}); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx new file mode 100644 index 0000000000..2855074568 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -0,0 +1,182 @@ +/* + * 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, { + Children, + Fragment, + PropsWithChildren, + ReactNode, + isValidElement, + useContext, + useState, +} from 'react'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { Content, Header, HeaderLabel, Page, Progress } from '@backstage/core'; +import { Box } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { useNavigate } from 'react-router'; +import { EntityContext } from '../../hooks/useEntity'; +import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; +import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; +import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { useEntityCompoundName } from '../useEntityCompoundName'; +import { TabbedLayout } from './TabbedLayout'; + +type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; + +const Route: (props: SubRoute) => null = () => null; + +export function createSubRoutesFromChildren(children: ReactNode): SubRoute[] { + return Children.toArray(children).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSubRoutesFromChildren(child.props.children); + } + + if (child.type !== Route) { + throw new Error('Child of EntityLayout must be an EntityLayout.Route'); + } + + const { path, title, children } = child.props; + return [{ path, title, children }]; + }); +} + +const EntityLayoutTitle = ({ + entity, + title, +}: { + title: string; + entity: Entity | undefined; +}) => ( + + {title} + {entity && } + +); + +const headerProps = ( + paramKind: string | undefined, + paramNamespace: string | undefined, + paramName: string | undefined, + entity: Entity | undefined, +): { headerTitle: string; headerType: string } => { + const kind = paramKind ?? entity?.kind ?? ''; + const namespace = paramNamespace ?? entity?.metadata.namespace ?? ''; + const name = paramName ?? entity?.metadata.name ?? ''; + return { + headerTitle: `${name}${ + namespace && namespace !== ENTITY_DEFAULT_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; + })(), + }; +}; + +/** + * EntityLayout is a compound component, which allows you to define a layout + * entities using a sub-navigation mechanism. + * + * Consists of 2 parts: EntityLayout and EntityLayout.Route + * + * @example + * ```jsx + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + */ +export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { + const { kind, namespace, name } = useEntityCompoundName(); + const { entity, loading, error } = useContext(EntityContext); + + const routes = createSubRoutesFromChildren(children); + 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} + > + {/* TODO: fix after catalog page customization is added */} + {entity && kind !== 'user' && ( + <> + + + + + )} +
+ + {loading && } + + {entity && } + + {error && ( + + {error.toString()} + + )} + setConfirmationDialogOpen(false)} + /> +
+ ); +}; + +EntityLayout.Route = Route; diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx new file mode 100644 index 0000000000..065265cea4 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx @@ -0,0 +1,150 @@ +/* + * 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 { TabbedLayout } from './TabbedLayout'; +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'; + +const testRoute1 = { + path: '', + title: 'tabbed-test-title', + children:
tabbed-test-content
, +}; +const testRoute2 = { + title: 'tabbed-test-title-2', + path: '/some-other-path', + children:
tabbed-test-content-2
, +}; + +describe('TabbedLayout', () => { + it('renders simplest case', async () => { + const rendered = await renderInTestApp( + , + ); + + 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( + + } + /> + , + ); + + 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-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( + , + { 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( + , + { 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/EntityLayout/TabbedLayout.tsx b/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx new file mode 100644 index 0000000000..ffe5709b48 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx @@ -0,0 +1,75 @@ +/* + * 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, { useMemo } from 'react'; +import { useParams, useNavigate, matchRoutes, useRoutes } from 'react-router'; +import { HeaderTabs, Content as LayoutContent } from '@backstage/core'; +import { Helmet } from 'react-helmet'; +import { SubRoute } from './types'; + +export function useSelectedSubRoute( + subRoutes: SubRoute[], +): { index: number; route: SubRoute; element: JSX.Element } { + const params = useParams(); + + const routes = subRoutes.map(({ path, children }) => ({ + caseSensitive: false, + path: `${path}/*`, + element: children, + })); + + const element = useRoutes(routes) ?? subRoutes[0].children; + + const [matchedRoute] = matchRoutes(routes, `/${params['*']}`) ?? []; + const foundIndex = matchedRoute + ? subRoutes.findIndex(t => t.path === matchedRoute.route.path) + : 0; + + return { + index: foundIndex === -1 ? 0 : foundIndex, + element, + route: subRoutes[foundIndex] ?? subRoutes[0], + }; +} + +export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { + const navigate = useNavigate(); + const { index, route, element } = useSelectedSubRoute(routes); + const headerTabs = useMemo( + () => routes.map(t => ({ id: t.path, label: t.title })), + [routes], + ); + + const onTabChange = (index: number) => + // Remove trailing /* + // And remove leading / for relative navigation + // Note! route resolves relative to the position in the React tree, + // not relative to current location + navigate(routes[index].path.replace(/\/\*$/, '').replace(/^\//, '')); + + return ( + <> + + + + {element} + + + ); +}; diff --git a/plugins/catalog/src/components/EntityLayout/index.ts b/plugins/catalog/src/components/EntityLayout/index.ts new file mode 100644 index 0000000000..2e399765c3 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/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 { EntityLayout } from './EntityLayout'; diff --git a/plugins/catalog/src/components/EntityLayout/types.ts b/plugins/catalog/src/components/EntityLayout/types.ts new file mode 100644 index 0000000000..29ade88dc0 --- /dev/null +++ b/plugins/catalog/src/components/EntityLayout/types.ts @@ -0,0 +1,21 @@ +/* + * 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 type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 1ee6f44dad..41e6413f39 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -17,6 +17,7 @@ export * from '@backstage/catalog-client'; export { AboutCard } from './components/AboutCard'; export { EntityPageLayout } from './components/EntityPageLayout'; +export { EntityLayout } from './components/EntityLayout'; export { Router } from './components/Router'; export { useEntityCompoundName } from './components/useEntityCompoundName'; export { EntityContext, useEntity } from './hooks/useEntity';