Merge pull request #9554 from backstage/jhaals/remove-router

catalog: remove Router and EntityPageLayout
This commit is contained in:
Johan Haals
2022-02-15 16:49:41 +01:00
committed by GitHub
9 changed files with 6 additions and 698 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': minor
---
Removed deprecated `Router` and `EntityPageLayout` exports.
`Router` is replaced by plugin extensions and `EntityPageLayout` is replaced by `CatalogEntityPage`.
-26
View File
@@ -349,22 +349,6 @@ export const EntityListContainer: ({
// @public
export const EntityOrphanWarning: () => JSX.Element;
// Warning: (ae-missing-release-tag) "EntityPageLayout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public @deprecated
export const EntityPageLayout: {
({
children,
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
}: EntityPageLayoutProps): JSX.Element;
Content: (_props: {
path: string;
title: string;
element: JSX.Element;
}) => null;
};
// Warning: (ae-missing-release-tag) "EntityProcessingErrorsPanel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
@@ -452,15 +436,6 @@ export const RelatedEntitiesCard: <T extends Entity>(props: {
asRenderableEntities: (entities: Entity[]) => T[];
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public @deprecated (undocumented)
export const Router: ({
EntityPage,
}: {
EntityPage?: React_2.ComponentType<{}> | undefined;
}) => JSX.Element;
// @public (undocumented)
export type SystemDiagramCardClassKey =
| 'domainNode'
@@ -475,6 +450,5 @@ export type SystemDiagramCardClassKey =
// src/components/CatalogTable/CatalogTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts
// src/components/EntityLayout/EntityLayout.d.ts:43:5 - (ae-forgotten-export) The symbol "EntityLayoutProps" needs to be exported by the entry point index.d.ts
// src/components/EntityLayout/EntityLayout.d.ts:44:5 - (ae-forgotten-export) The symbol "SubRoute" needs to be exported by the entry point index.d.ts
// src/components/EntityPageLayout/EntityPageLayout.d.ts:22:5 - (ae-forgotten-export) The symbol "EntityPageLayoutProps" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:22:5 - (ae-forgotten-export) The symbol "ColumnBreakpoints" needs to be exported by the entry point index.d.ts
```
@@ -1,214 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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,
DEFAULT_NAMESPACE,
RELATION_OWNED_BY,
} from '@backstage/catalog-model';
import {
EntityContext,
EntityRefLinks,
FavoriteEntity,
getEntityRelations,
InspectEntityDialog,
UnregisterEntityDialog,
useEntityCompoundName,
} from '@backstage/plugin-catalog-react';
import { Box } from '@material-ui/core';
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { Tabbed } from './Tabbed';
import {
Content,
Header,
HeaderLabel,
Link,
Page,
Progress,
ResponseErrorPanel,
WarningPanel,
} from '@backstage/core-components';
import { IconComponent } from '@backstage/core-plugin-api';
const EntityPageTitle = ({
entity,
title,
}: {
title: string;
entity: Entity | undefined;
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavoriteEntity entity={entity} />}
</Box>
);
const EntityLabels = ({ entity }: { entity: Entity }) => {
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
return (
<>
{ownedByRelations.length > 0 && (
<HeaderLabel
label="Owner"
value={
<EntityRefLinks
entityRefs={ownedByRelations}
defaultKind="Group"
color="inherit"
/>
}
/>
)}
{entity.spec?.lifecycle && (
<HeaderLabel label="Lifecycle" value={entity.spec.lifecycle} />
)}
</>
);
};
const headerProps = (
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } => {
return {
headerTitle: `${name}${
namespace && namespace !== DEFAULT_NAMESPACE ? ` in ${namespace}` : ''
}`,
headerType: (() => {
let t = kind.toLocaleLowerCase('en-US');
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLocaleLowerCase('en-US');
}
return t;
})(),
};
};
// NOTE(freben): Intentionally not exported at this point, since it's part of
// the unstable extra context menu items concept below
type ExtraContextMenuItem = {
title: string;
Icon: IconComponent;
onClick: () => void;
};
// unstable context menu option, eg: disable the unregister entity menu
type contextMenuOptions = {
disableUnregister: boolean;
};
type EntityPageLayoutProps = {
UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[];
UNSTABLE_contextMenuOptions?: contextMenuOptions;
children?: React.ReactNode;
};
/**
* Old entity page, only used by the old router based hierarchies.
*
* @deprecated Please use CatalogEntityPage instead
*/
export const EntityPageLayout = ({
children,
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
}: EntityPageLayoutProps) => {
const { kind, namespace, name } = useEntityCompoundName();
const { entity, loading, error } = useContext(EntityContext);
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity!,
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [inspectionDialogOpen, setInspectionDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
return (
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity!} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{/* TODO: Make entity labels configurable for entity kind / type */}
{entity && (
<>
<EntityLabels entity={entity} />
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
onUnregisterEntity={() => setConfirmationDialogOpen(true)}
onInspectEntity={() => setInspectionDialogOpen(true)}
/>
</>
)}
</Header>
{loading && (
<Content>
<Progress />
</Content>
)}
{entity && <Tabbed.Layout>{children}</Tabbed.Layout>}
{error && (
<Content>
<ResponseErrorPanel error={error} />
</Content>
)}
{!loading && !error && !entity && (
<Content>
<WarningPanel title="Entity not found">
There is no {kind} with the requested{' '}
<Link to="https://backstage.io/docs/features/software-catalog/references">
kind, namespace, and name
</Link>
.
</WarningPanel>
</Content>
)}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity!}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
<InspectEntityDialog
open={inspectionDialogOpen}
entity={entity!}
onClose={() => setInspectionDialogOpen(false)}
/>
</Page>
);
};
EntityPageLayout.Content = Tabbed.Content;
@@ -1,197 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Tabbed } from './Tabbed';
import { renderInTestApp } from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import { Routes, Route } from 'react-router';
describe('Tabbed layout', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
</Tabbed.Layout>,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
});
it('throws if any other component is a child of Tabbed.Layout', async () => {
await expect(
renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
<div>This will cause app to throw</div>
</Tabbed.Layout>,
),
).rejects.toThrow(/This component only accepts/);
});
it('navigates when user clicks different tab', async () => {
const rendered = await renderInTestApp(
<Routes>
<Route
path="/*"
element={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>
}
/>
</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={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path/*"
element={
<div>
tabbed-test-content-2
<Routes>
<Route
path="/nested"
element={<div>tabbed-test-nested-content-2</div>}
/>
</Routes>
</div>
}
/>
</Tabbed.Layout>
}
/>
</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(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ routeEntries: ['/some-other-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ 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();
});
});
@@ -1,127 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Helmet } from 'react-helmet';
import { Tab, HeaderTabs, Content } from '@backstage/core-components';
const getSelectedIndexOrDefault = (
matchedRoute: RouteMatch,
tabs: Tab[],
defaultIndex = 0,
) => {
if (!matchedRoute) return defaultIndex;
const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path);
return ~tabIndex ? tabIndex : defaultIndex;
};
/**
* Compound component, which allows you to define layout
* for EntityPage using Tabs as a sub-navigation mechanism
* Consists of 2 parts: Tabbed.Layout and Tabbed.Content.
* Takes care of: tabs, routes, document titles, spacing around content
*
* @example
* ```jsx
* <Tabbed.Layout>
* <Tabbed.Content
* title="Example tab"
* route="/example/*"
* element={<div>This is rendered under /example/anything-here route</div>}
* />
* </TabbedLayout>
* ```
*/
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;
}
if (child.type !== Tabbed.Content) {
throw new Error(
'This component only accepts Content elements as direct children. Check the code of the EntityPage.',
);
}
const pathAndId = (child as JSX.Element).props.path;
// Child here must be then always a functional component without any wrappers
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
if ((routes?.[0]?.path ?? '') !== '')
routes.push({
path: '/*',
element: <Navigate to={routes[0].path!} />,
});
const [matchedRoute] =
matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? [];
const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs);
const currentTab = tabs[selectedIndex];
const title = currentTab?.label;
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(tabs[index].id.replace(/\/\*$/, '').replace(/^\//, ''));
const currentRouteElement = useRoutes(routes);
if (!currentTab) return null;
return (
<>
<HeaderTabs
tabs={tabs}
selectedIndex={selectedIndex}
onChange={onTabChange}
/>
<Content>
<Helmet title={title} />
{currentRouteElement}
</Content>
</>
);
},
Content: (_props: { path: string; title: string; element: JSX.Element }) =>
null,
};
@@ -1,16 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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';
@@ -1,16 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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';
-100
View File
@@ -1,100 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
AsyncEntityProvider,
useEntity,
useEntityFromUrl,
} from '@backstage/plugin-catalog-react';
import { Typography } from '@material-ui/core';
import React, { ComponentType, ReactNode } from 'react';
import { Navigate, Route, Routes, useParams } from 'react-router';
import { CatalogPage } from './CatalogPage';
import { EntityNotFound } from './EntityNotFound';
import { EntityPageLayout } from './EntityPageLayout';
import { Content, Link } from '@backstage/core-components';
const DefaultEntityPage = () => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={
<Content>
<Typography variant="h2">This is the default entity page.</Typography>
<Typography variant="body1">
To override this component with your custom implementation, read
docs on{' '}
<Link to="https://backstage.io/docs">backstage.io/docs</Link>
</Typography>
</Content>
}
/>
</EntityPageLayout>
);
const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => {
const { entity, loading, error } = useEntity();
// Loading and error states
if (loading) return <EntityPageLayout />;
if (error || !entity) return <EntityNotFound />;
// Otherwise EntityPage provided from the App
// Note that EntityPage will include EntityPageLayout already
return <EntityPage />;
};
const OldEntityRouteRedirect = () => {
const { optionalNamespaceAndName, '*': rest } = useParams() as any;
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const namespaceLower =
namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE;
const restWithSlash = rest ? `/${rest}` : '';
return (
<Navigate
to={`../../${namespaceLower}/component/${name}${restWithSlash}`}
/>
);
};
export const EntityLoader = (props: { children: ReactNode }) => (
<AsyncEntityProvider {...useEntityFromUrl()} {...props} />
);
/**
* @deprecated Use plugin extensions instead
* */
export const Router = ({
EntityPage = DefaultEntityPage,
}: {
EntityPage?: ComponentType;
}) => (
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route
path="/:namespace/:kind/:name"
element={
<EntityLoader>
<EntityPageSwitch EntityPage={EntityPage} />
</EntityLoader>
}
/>
<Route
path="Component/:optionalNamespaceAndName/*"
element={<OldEntityRouteRedirect />}
/>
</Routes>
);
-2
View File
@@ -30,11 +30,9 @@ export * from './components/CatalogTable/columns';
export * from './components/EntityLayout';
export * from './components/EntityOrphanWarning';
export * from './components/EntityProcessingErrorsPanel';
export * from './components/EntityPageLayout';
export * from './components/EntitySwitch';
export * from './components/FilteredEntityLayout';
export * from './overridableComponents';
export { Router } from './components/Router';
export {
CatalogEntityPage,
CatalogIndexPage,