Merge pull request #3830 from backstage/mob/entity-compose

catalog: add new composability components and extensions
This commit is contained in:
Patrik Oldsberg
2020-12-23 14:43:57 +01:00
committed by GitHub
17 changed files with 922 additions and 0 deletions
+9
View File
@@ -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';
+37
View File
@@ -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,
}),
);
+3
View File
@@ -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';
+5
View File
@@ -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,
},
});
+3
View File
@@ -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