Merge pull request #3830 from backstage/mob/entity-compose
catalog: add new composability components and extensions
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Add `CatalogIndexPage` and `CatalogEntityPage`, two new extensions that replace the existing `Router` component.
|
||||
|
||||
Add `EntityLayout` to replace `EntityPageLayout`, using children instead of an element property, and allowing for collection of all `RouteRef` mount points used within tabs.
|
||||
|
||||
Add `EntitySwitch` to be used to select components based on entity data, along with accompanying `isKind`, `isNamespace`, and `isComponentType` filters.
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { EntityProvider } from '../EntityProvider';
|
||||
|
||||
export const CatalogEntityPage = () => {
|
||||
return (
|
||||
<EntityProvider>
|
||||
<Outlet />
|
||||
</EntityProvider>
|
||||
);
|
||||
};
|
||||
@@ -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 { CatalogEntityPage } from './CatalogEntityPage';
|
||||
@@ -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(
|
||||
<ApiProvider apis={mockApis}>
|
||||
<EntityContext.Provider value={mockEntityData}>
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityContext.Provider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</EntityLayout.Route>
|
||||
<div>This will cause app to throw</div>
|
||||
</EntityLayout>,
|
||||
),
|
||||
).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 <EntityLayout> component/,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('navigates when user clicks different tab', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ApiProvider apis={mockApis}>
|
||||
<EntityContext.Provider value={mockEntityData}>
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</EntityLayout.Route>
|
||||
<EntityLayout.Route
|
||||
path="/some-other-path"
|
||||
title="tabbed-test-title-2"
|
||||
>
|
||||
<div>tabbed-test-content-2</div>
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityContext.Provider>
|
||||
</ApiProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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 {
|
||||
attachComponentData,
|
||||
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;
|
||||
|
||||
// This causes all mount points that are discovered within this route to use the path of the route itself
|
||||
attachComponentData(Route, 'core.gatherMountPoints', true);
|
||||
|
||||
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;
|
||||
}) => (
|
||||
<Box display="inline-flex" alignItems="center" height="1em">
|
||||
{title}
|
||||
{entity && <FavouriteEntity entity={entity} />}
|
||||
</Box>
|
||||
);
|
||||
|
||||
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 for
|
||||
* entities using a sub-navigation mechanism.
|
||||
*
|
||||
* Consists of two parts: EntityLayout and EntityLayout.Route
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <EntityLayout>
|
||||
* <EntityLayout.Route path="/example" title="Example tab">
|
||||
* <div>This is rendered under /example/anything-here route</div>
|
||||
* </EntityLayout.Route>
|
||||
* </EntityLayout>
|
||||
* ```
|
||||
*/
|
||||
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 (
|
||||
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
|
||||
<Header
|
||||
title={<EntityLayoutTitle title={headerTitle} entity={entity!} />}
|
||||
pageTitleOverride={headerTitle}
|
||||
type={headerType}
|
||||
>
|
||||
{/* TODO: fix after catalog page customization is added */}
|
||||
{entity && kind !== 'user' && (
|
||||
<>
|
||||
<HeaderLabel
|
||||
label="Owner"
|
||||
value={entity.spec?.owner || 'unknown'}
|
||||
/>
|
||||
<HeaderLabel
|
||||
label="Lifecycle"
|
||||
value={entity.spec?.lifecycle || 'unknown'}
|
||||
/>
|
||||
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
|
||||
</>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
{loading && <Progress />}
|
||||
|
||||
{entity && <TabbedLayout routes={routes} />}
|
||||
|
||||
{error && (
|
||||
<Content>
|
||||
<Alert severity="error">{error.toString()}</Alert>
|
||||
</Content>
|
||||
)}
|
||||
<UnregisterEntityDialog
|
||||
open={confirmationDialogOpen}
|
||||
entity={entity!}
|
||||
onConfirm={cleanUpAfterRemoval}
|
||||
onClose={() => setConfirmationDialogOpen(false)}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
EntityLayout.Route = Route;
|
||||
@@ -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: <div>tabbed-test-content</div>,
|
||||
};
|
||||
const testRoute2 = {
|
||||
title: 'tabbed-test-title-2',
|
||||
path: '/some-other-path',
|
||||
children: <div>tabbed-test-content-2</div>,
|
||||
};
|
||||
|
||||
describe('TabbedLayout', () => {
|
||||
it('renders simplest case', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TabbedLayout routes={[testRoute1]} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/*"
|
||||
element={<TabbedLayout routes={[testRoute1, testRoute2]} />}
|
||||
/>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<TabbedLayout
|
||||
routes={[
|
||||
testRoute1,
|
||||
{
|
||||
...testRoute2,
|
||||
children: (
|
||||
<div>
|
||||
tabbed-test-content-2
|
||||
<Routes>
|
||||
<Route
|
||||
path="/nested"
|
||||
element={<div>tabbed-test-nested-content-2</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{ 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(
|
||||
<TabbedLayout routes={[testRoute1, testRoute2]} />,
|
||||
{ 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(
|
||||
<TabbedLayout routes={[testRoute1, testRoute2]} />,
|
||||
{ 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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<>
|
||||
<HeaderTabs
|
||||
tabs={headerTabs}
|
||||
selectedIndex={index}
|
||||
onChange={onTabChange}
|
||||
/>
|
||||
<LayoutContent>
|
||||
<Helmet title={route.title} />
|
||||
{element}
|
||||
</LayoutContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import { EntityContext } from '../../hooks/useEntity';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntitySwitch } from './EntitySwitch';
|
||||
import { isKind } from './conditions';
|
||||
|
||||
describe('EntitySwitch', () => {
|
||||
it('should switch child when entity switches', () => {
|
||||
const content = (
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={isKind('component')} children="A" />
|
||||
<EntitySwitch.Case if={isKind('template')} children="B" />
|
||||
<EntitySwitch.Case children="C" />
|
||||
</EntitySwitch>
|
||||
);
|
||||
|
||||
const rendered = render(
|
||||
<EntityContext.Provider
|
||||
value={{
|
||||
entity: { kind: 'component' } as Entity,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</EntityContext.Provider>,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('A')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('C')).not.toBeInTheDocument();
|
||||
|
||||
rendered.rerender(
|
||||
<EntityContext.Provider
|
||||
value={{
|
||||
entity: { kind: 'template' } as Entity,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</EntityContext.Provider>,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('A')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('B')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('C')).not.toBeInTheDocument();
|
||||
|
||||
rendered.rerender(
|
||||
<EntityContext.Provider
|
||||
value={{
|
||||
entity: { kind: 'derp' } as Entity,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</EntityContext.Provider>,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('A')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch child when filters switch', () => {
|
||||
const entityContextValue = {
|
||||
entity: { kind: 'component' } as Entity,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<EntityContext.Provider value={entityContextValue}>
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={isKind('component')} children="A" />
|
||||
<EntitySwitch.Case children="B" />
|
||||
</EntitySwitch>
|
||||
</EntityContext.Provider>,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('A')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('B')).not.toBeInTheDocument();
|
||||
|
||||
rendered.rerender(
|
||||
<EntityContext.Provider value={entityContextValue}>
|
||||
<EntitySwitch>
|
||||
<EntitySwitch.Case if={isKind('template')} children="A" />
|
||||
<EntitySwitch.Case children="B" />
|
||||
</EntitySwitch>
|
||||
</EntityContext.Provider>,
|
||||
);
|
||||
|
||||
expect(rendered.queryByText('A')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 {
|
||||
ReactNode,
|
||||
PropsWithChildren,
|
||||
Children,
|
||||
Fragment,
|
||||
useMemo,
|
||||
isValidElement,
|
||||
} from 'react';
|
||||
import { useEntity } from '../../hooks/useEntity';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
const EntitySwitchCase = (_: {
|
||||
if?: (entity: Entity) => boolean;
|
||||
children: ReactNode;
|
||||
}) => null;
|
||||
|
||||
type SwitchCase = {
|
||||
if?: (entity: Entity) => boolean;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
function createSwitchCasesFromChildren(children: ReactNode): SwitchCase[] {
|
||||
return Children.toArray(children).flatMap(child => {
|
||||
if (!isValidElement(child)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (child.type === Fragment) {
|
||||
return createSwitchCasesFromChildren(child.props.children);
|
||||
}
|
||||
|
||||
if (child.type !== EntitySwitchCase) {
|
||||
throw new Error(`Child of EntitySwitch is not an EntitySwitch.Case`);
|
||||
}
|
||||
|
||||
const { if: condition, children } = child.props;
|
||||
return [{ if: condition, children }];
|
||||
});
|
||||
}
|
||||
|
||||
export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => {
|
||||
const { entity } = useEntity();
|
||||
const switchCases = useMemo(() => createSwitchCasesFromChildren(children), [
|
||||
children,
|
||||
]);
|
||||
|
||||
const matchingCase = switchCases.find(switchCase =>
|
||||
switchCase.if ? switchCase.if(entity) : true,
|
||||
);
|
||||
return matchingCase?.children ?? null;
|
||||
};
|
||||
|
||||
EntitySwitch.Case = EntitySwitchCase;
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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, ComponentEntity } from '@backstage/catalog-model';
|
||||
|
||||
function strCmp(a: string | undefined, b: string | undefined): boolean {
|
||||
return Boolean(a && a?.toLowerCase() === b?.toLowerCase());
|
||||
}
|
||||
|
||||
export function isKind(kind: string) {
|
||||
return (entity: Entity) => strCmp(entity?.kind, kind);
|
||||
}
|
||||
|
||||
export function isComponentType(type: string) {
|
||||
return (entity: Entity) => {
|
||||
if (!strCmp(entity?.kind, 'component')) {
|
||||
return false;
|
||||
}
|
||||
const componentEntity = entity as ComponentEntity;
|
||||
return strCmp(componentEntity.spec.type, type);
|
||||
};
|
||||
}
|
||||
|
||||
export function isNamespace(namespace: string) {
|
||||
return (entity: Entity) => strCmp(entity?.metadata?.namespace, namespace);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 { EntitySwitch } from './EntitySwitch';
|
||||
export { isKind, isNamespace, isComponentType } from './conditions';
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 { createRoutableExtension } from '@backstage/core';
|
||||
import { catalogRouteRef, entityRouteRef } from './routes';
|
||||
import { plugin } from './plugin';
|
||||
|
||||
export const CatalogIndexPage = plugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./components/CatalogPage').then(m => m.CatalogPage),
|
||||
mountPoint: catalogRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
export const CatalogEntityPage = plugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./components/CatalogEntityPage/CatalogEntityPage').then(
|
||||
m => m.CatalogEntityPage,
|
||||
),
|
||||
mountPoint: entityRouteRef,
|
||||
}),
|
||||
);
|
||||
@@ -17,8 +17,11 @@
|
||||
export * from '@backstage/catalog-client';
|
||||
export { AboutCard } from './components/AboutCard';
|
||||
export { EntityPageLayout } from './components/EntityPageLayout';
|
||||
export { EntityLayout } from './components/EntityLayout';
|
||||
export * from './components/EntitySwitch';
|
||||
export { Router } from './components/Router';
|
||||
export { useEntityCompoundName } from './components/useEntityCompoundName';
|
||||
export { EntityContext, useEntity } from './hooks/useEntity';
|
||||
export { catalogApiRef, plugin } from './plugin';
|
||||
export * from './routes';
|
||||
export * from './extensions';
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
createPlugin,
|
||||
discoveryApiRef,
|
||||
} from '@backstage/core';
|
||||
import { catalogRouteRef, entityRouteRef } from './routes';
|
||||
|
||||
export const catalogApiRef = createApiRef<CatalogApi>({
|
||||
id: 'plugin.catalog.service',
|
||||
@@ -37,4 +38,8 @@ export const plugin = createPlugin({
|
||||
factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
catalogIndex: catalogRouteRef,
|
||||
catalogEntity: entityRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,11 +24,14 @@ export const rootRoute = createRouteRef({
|
||||
path: '',
|
||||
title: 'Catalog',
|
||||
});
|
||||
export const catalogRouteRef = rootRoute;
|
||||
|
||||
export const entityRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: ':namespace/:kind/:name/*',
|
||||
title: 'Entity',
|
||||
});
|
||||
export const entityRouteRef = entityRoute;
|
||||
|
||||
// Utility function to get suitable route params for entityRoute, given an
|
||||
// entity instance
|
||||
|
||||
Reference in New Issue
Block a user