feat(customization): support customizing catalog and api index pages via outlets
Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-api-docs': patch
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Support customizing index page layouts via outlets
|
||||
@@ -10,8 +10,7 @@ and find catalog entities. This is already set up by default by
|
||||
`@backstage/create-app`.
|
||||
|
||||
If you want to change the default index page - such as to add a custom filter to
|
||||
the catalog - you can replace the routing in `App.tsx` to point to your own
|
||||
`CatalogIndexPage`.
|
||||
the catalog - you can create your own `CatalogIndexPage`.
|
||||
|
||||
> Note: The catalog index page is designed to have a minimal code footprint to
|
||||
> support easy customization, but creating a copy does introduce a possibility
|
||||
@@ -21,12 +20,11 @@ the catalog - you can replace the routing in `App.tsx` to point to your own
|
||||
|
||||
For example, suppose that I want to allow filtering by a custom annotation added
|
||||
to entities, `company.com/security-tier`. To start, I'll copy the code for the
|
||||
default catalog page and create a component in a
|
||||
[new plugin](../../plugins/create-a-plugin.md):
|
||||
default catalog page and create a component.
|
||||
|
||||
```tsx
|
||||
// imports, etc omitted for brevity. for full source see:
|
||||
// https://github.com/backstage/backstage/blob/master/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
|
||||
// https://github.com/backstage/backstage/blob/master/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx
|
||||
export const CustomCatalogPage = ({
|
||||
columns,
|
||||
actions,
|
||||
@@ -167,21 +165,7 @@ export const CustomCatalogPage = ({
|
||||
};
|
||||
```
|
||||
|
||||
This page itself can be exported as a routable extension in the plugin:
|
||||
|
||||
```ts
|
||||
export const CustomCatalogIndexPage = myPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'CustomCatalogIndexPage',
|
||||
component: () =>
|
||||
import('./components/CustomCatalogPage').then(m => m.CustomCatalogPage),
|
||||
mountPoint: catalogRouteRef,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Finally, we can replace the catalog route in the Backstage application with our
|
||||
new `CustomCatalogIndexPage`.
|
||||
Finally, we can apply our new `CustomCatalogPage`.
|
||||
|
||||
```diff
|
||||
# packages/app/src/App.tsx
|
||||
@@ -189,7 +173,9 @@ const routes = (
|
||||
<FlatRoutes>
|
||||
<Navigate key="/" to="catalog" />
|
||||
- <Route path="/catalog" element={<CatalogIndexPage />} />
|
||||
+ <Route path="/catalog" element={<CustomCatalogIndexPage />} />
|
||||
+ <Route path="/catalog" element={<CatalogIndexPage />}>
|
||||
+ <CustomCatalogPage />
|
||||
+ </Route>
|
||||
```
|
||||
|
||||
The same method can be used to customize the _default_ filters with a different
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
import { Action } from '@material-table/core';
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
@@ -15,6 +14,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
import { default as React_2 } from 'react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { TableColumn } from '@backstage/core-components';
|
||||
import { TableProps } from '@backstage/core-components';
|
||||
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
|
||||
@@ -53,27 +53,17 @@ const apiDocsPlugin: BackstagePlugin<
|
||||
export { apiDocsPlugin };
|
||||
export { apiDocsPlugin as plugin };
|
||||
|
||||
// @public
|
||||
export const ApiExplorerIndexPage: (
|
||||
props: DefaultApiExplorerPageProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ApiExplorerPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const ApiExplorerPage: ({
|
||||
initiallySelectedFilter,
|
||||
columns,
|
||||
actions,
|
||||
}: {
|
||||
initiallySelectedFilter?: UserListFilterKind | undefined;
|
||||
columns?: TableColumn<CatalogTableRow>[] | undefined;
|
||||
actions?:
|
||||
| (
|
||||
| Action<CatalogTableRow>
|
||||
| {
|
||||
action: (rowData: CatalogTableRow) => Action<CatalogTableRow>;
|
||||
position: string;
|
||||
}
|
||||
| ((rowData: CatalogTableRow) => Action<CatalogTableRow>)
|
||||
)[]
|
||||
| undefined;
|
||||
}) => JSX.Element;
|
||||
export const ApiExplorerPage: (
|
||||
props: DefaultApiExplorerPageProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "ApiTypeTitle" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
@@ -110,6 +100,20 @@ export const ConsumedApisCard: ({ variant }: Props_2) => JSX.Element;
|
||||
// @public (undocumented)
|
||||
export const ConsumingComponentsCard: ({ variant }: Props_5) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const DefaultApiExplorerPage: ({
|
||||
initiallySelectedFilter,
|
||||
columns,
|
||||
actions,
|
||||
}: DefaultApiExplorerPageProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type DefaultApiExplorerPageProps = {
|
||||
initiallySelectedFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<CatalogTableRow>[];
|
||||
actions?: TableProps<CatalogTableRow>['actions'];
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "defaultDefinitionWidgets" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2021 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.
|
||||
@@ -14,185 +14,31 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import { TableColumn, TableProps } from '@backstage/core-components';
|
||||
import {
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { CatalogTableRow } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
DefaultStarredEntitiesApi,
|
||||
entityRouteRef,
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
MockStorageApi,
|
||||
TestApiProvider,
|
||||
wrapInTestApp,
|
||||
} from '@backstage/test-utils';
|
||||
import DashboardIcon from '@material-ui/icons/Dashboard';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { useOutlet } from 'react-router';
|
||||
import { ApiExplorerPage } from './ApiExplorerPage';
|
||||
|
||||
describe('ApiCatalogPage', () => {
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
spec: { type: 'openapi' },
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
getEntityByName: async entityName => {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: entityName.name },
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_MEMBER_OF,
|
||||
target: { namespace: 'default', kind: 'Group', name: 'tools' },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useOutlet: jest.fn().mockReturnValue('Route Children'),
|
||||
}));
|
||||
|
||||
const configApi: ConfigApi = new ConfigReader({
|
||||
organization: {
|
||||
name: 'My Company',
|
||||
},
|
||||
jest.mock('./DefaultApiExplorerPage', () => ({
|
||||
DefaultApiExplorerPage: jest.fn().mockReturnValue('DefaultApiExplorerPage'),
|
||||
}));
|
||||
|
||||
describe('ApiExplorerPage', () => {
|
||||
it('renders provided router element', async () => {
|
||||
const { getByText } = await renderInTestApp(<ApiExplorerPage />);
|
||||
|
||||
expect(getByText('Route Children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const apiDocsConfig = {
|
||||
getApiDefinitionWidget: () => undefined,
|
||||
};
|
||||
it('renders DefaultApiExplorerPage home when no router children are provided', async () => {
|
||||
(useOutlet as jest.Mock).mockReturnValueOnce(null);
|
||||
const { getByText } = await renderInTestApp(<ApiExplorerPage />);
|
||||
|
||||
const storageApi = MockStorageApi.create();
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApi],
|
||||
[configApiRef, configApi],
|
||||
[storageApiRef, storageApi],
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
new DefaultStarredEntitiesApi({ storageApi }),
|
||||
],
|
||||
[apiDocsConfigRef, apiDocsConfig],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
it('should render', async () => {
|
||||
const { findByText } = renderWrapped(<ApiExplorerPage />);
|
||||
expect(await findByText(/My Company API Explorer/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the default column of the grid', async () => {
|
||||
const { getAllByRole } = renderWrapped(<ApiExplorerPage />);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
|
||||
expect(columnHeaderLabels).toEqual([
|
||||
'Name',
|
||||
'System',
|
||||
'Owner',
|
||||
'Type',
|
||||
'Lifecycle',
|
||||
'Description',
|
||||
'Tags',
|
||||
'Actions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render the custom column passed as prop', async () => {
|
||||
const columns: TableColumn<CatalogTableRow>[] = [
|
||||
{ title: 'Foo', field: 'entity.foo' },
|
||||
{ title: 'Bar', field: 'entity.bar' },
|
||||
{ title: 'Baz', field: 'entity.spec.lifecycle' },
|
||||
];
|
||||
const { getAllByRole } = renderWrapped(
|
||||
<ApiExplorerPage columns={columns} />,
|
||||
);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
|
||||
expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']);
|
||||
});
|
||||
|
||||
it('should render the default actions of an item in the grid', async () => {
|
||||
const { findByTitle, findByText } = await renderWrapped(
|
||||
<ApiExplorerPage />,
|
||||
);
|
||||
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/View/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/View/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Edit/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Add to favorites/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the custom actions of an item passed as prop', async () => {
|
||||
const actions: TableProps<CatalogTableRow>['actions'] = [
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Foo Action',
|
||||
disabled: false,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Bar Action',
|
||||
disabled: true,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
const { findByTitle, findByText } = await renderWrapped(
|
||||
<ApiExplorerPage actions={actions} />,
|
||||
);
|
||||
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Foo Action/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Bar Action/)).toBeInTheDocument();
|
||||
expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled();
|
||||
expect(getByText('DefaultApiExplorerPage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2021 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.
|
||||
@@ -14,97 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
CreateButton,
|
||||
PageWithHeader,
|
||||
SupportButton,
|
||||
TableColumn,
|
||||
TableProps,
|
||||
} from '@backstage/core-components';
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
CatalogTable,
|
||||
CatalogTableRow,
|
||||
FilteredEntityLayout,
|
||||
EntityListContainer,
|
||||
FilterContainer,
|
||||
} from '@backstage/plugin-catalog';
|
||||
import {
|
||||
EntityKindPicker,
|
||||
EntityLifecyclePicker,
|
||||
EntityListProvider,
|
||||
EntityOwnerPicker,
|
||||
EntityTagPicker,
|
||||
EntityTypePicker,
|
||||
UserListFilterKind,
|
||||
UserListPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { createComponentRouteRef } from '../../routes';
|
||||
import { useOutlet } from 'react-router';
|
||||
import {
|
||||
DefaultApiExplorerPage,
|
||||
DefaultApiExplorerPageProps,
|
||||
} from './DefaultApiExplorerPage';
|
||||
|
||||
const defaultColumns: TableColumn<CatalogTableRow>[] = [
|
||||
CatalogTable.columns.createNameColumn({ defaultKind: 'API' }),
|
||||
CatalogTable.columns.createSystemColumn(),
|
||||
CatalogTable.columns.createOwnerColumn(),
|
||||
CatalogTable.columns.createSpecTypeColumn(),
|
||||
CatalogTable.columns.createSpecLifecycleColumn(),
|
||||
CatalogTable.columns.createMetadataDescriptionColumn(),
|
||||
CatalogTable.columns.createTagsColumn(),
|
||||
];
|
||||
/**
|
||||
* ApiExplorerPage
|
||||
* @public
|
||||
*/
|
||||
export const ApiExplorerPage = (props: DefaultApiExplorerPageProps) => {
|
||||
const outlet = useOutlet();
|
||||
|
||||
type ApiExplorerPageProps = {
|
||||
initiallySelectedFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<CatalogTableRow>[];
|
||||
actions?: TableProps<CatalogTableRow>['actions'];
|
||||
};
|
||||
|
||||
export const ApiExplorerPage = ({
|
||||
initiallySelectedFilter = 'all',
|
||||
columns,
|
||||
actions,
|
||||
}: ApiExplorerPageProps) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const generatedSubtitle = `${
|
||||
configApi.getOptionalString('organization.name') ?? 'Backstage'
|
||||
} API Explorer`;
|
||||
const createComponentLink = useRouteRef(createComponentRouteRef);
|
||||
|
||||
return (
|
||||
<PageWithHeader
|
||||
themeId="apis"
|
||||
title="APIs"
|
||||
subtitle={generatedSubtitle}
|
||||
pageTitleOverride="APIs"
|
||||
>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<CreateButton
|
||||
title="Register Existing API"
|
||||
to={createComponentLink?.()}
|
||||
/>
|
||||
<SupportButton>All your APIs</SupportButton>
|
||||
</ContentHeader>
|
||||
<EntityListProvider>
|
||||
<FilteredEntityLayout>
|
||||
<FilterContainer>
|
||||
<EntityKindPicker initialFilter="api" hidden />
|
||||
<EntityTypePicker />
|
||||
<UserListPicker initialFilter={initiallySelectedFilter} />
|
||||
<EntityOwnerPicker />
|
||||
<EntityLifecyclePicker />
|
||||
<EntityTagPicker />
|
||||
</FilterContainer>
|
||||
<EntityListContainer>
|
||||
<CatalogTable
|
||||
columns={columns || defaultColumns}
|
||||
actions={actions}
|
||||
/>
|
||||
</EntityListContainer>
|
||||
</FilteredEntityLayout>
|
||||
</EntityListProvider>
|
||||
</Content>
|
||||
</PageWithHeader>
|
||||
);
|
||||
return outlet || <DefaultApiExplorerPage {...props} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2021 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, RELATION_MEMBER_OF } from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import { TableColumn, TableProps } from '@backstage/core-components';
|
||||
import {
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { CatalogTableRow } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
DefaultStarredEntitiesApi,
|
||||
entityRouteRef,
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
MockStorageApi,
|
||||
TestApiProvider,
|
||||
wrapInTestApp,
|
||||
} from '@backstage/test-utils';
|
||||
import DashboardIcon from '@material-ui/icons/Dashboard';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { DefaultApiExplorerPage } from './DefaultApiExplorerPage';
|
||||
|
||||
describe('DefaultApiExplorerPage', () => {
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
spec: { type: 'openapi' },
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
getEntityByName: async entityName => {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: entityName.name },
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_MEMBER_OF,
|
||||
target: { namespace: 'default', kind: 'Group', name: 'tools' },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const configApi: ConfigApi = new ConfigReader({
|
||||
organization: {
|
||||
name: 'My Company',
|
||||
},
|
||||
});
|
||||
|
||||
const apiDocsConfig = {
|
||||
getApiDefinitionWidget: () => undefined,
|
||||
};
|
||||
|
||||
const storageApi = MockStorageApi.create();
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApi],
|
||||
[configApiRef, configApi],
|
||||
[storageApiRef, storageApi],
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
new DefaultStarredEntitiesApi({ storageApi }),
|
||||
],
|
||||
[apiDocsConfigRef, apiDocsConfig],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
it('should render', async () => {
|
||||
const { findByText } = renderWrapped(<DefaultApiExplorerPage />);
|
||||
expect(await findByText(/My Company API Explorer/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the default column of the grid', async () => {
|
||||
const { getAllByRole } = renderWrapped(<DefaultApiExplorerPage />);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
|
||||
expect(columnHeaderLabels).toEqual([
|
||||
'Name',
|
||||
'System',
|
||||
'Owner',
|
||||
'Type',
|
||||
'Lifecycle',
|
||||
'Description',
|
||||
'Tags',
|
||||
'Actions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render the custom column passed as prop', async () => {
|
||||
const columns: TableColumn<CatalogTableRow>[] = [
|
||||
{ title: 'Foo', field: 'entity.foo' },
|
||||
{ title: 'Bar', field: 'entity.bar' },
|
||||
{ title: 'Baz', field: 'entity.spec.lifecycle' },
|
||||
];
|
||||
const { getAllByRole } = renderWrapped(
|
||||
<DefaultApiExplorerPage columns={columns} />,
|
||||
);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
|
||||
expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']);
|
||||
});
|
||||
|
||||
it('should render the default actions of an item in the grid', async () => {
|
||||
const { findByTitle, findByText } = await renderWrapped(
|
||||
<DefaultApiExplorerPage />,
|
||||
);
|
||||
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/View/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/View/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Edit/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Add to favorites/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the custom actions of an item passed as prop', async () => {
|
||||
const actions: TableProps<CatalogTableRow>['actions'] = [
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Foo Action',
|
||||
disabled: false,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Bar Action',
|
||||
disabled: true,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
const { findByTitle, findByText } = await renderWrapped(
|
||||
<DefaultApiExplorerPage actions={actions} />,
|
||||
);
|
||||
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Foo Action/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Bar Action/)).toBeInTheDocument();
|
||||
expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
Content,
|
||||
ContentHeader,
|
||||
CreateButton,
|
||||
PageWithHeader,
|
||||
SupportButton,
|
||||
TableColumn,
|
||||
TableProps,
|
||||
} from '@backstage/core-components';
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
CatalogTable,
|
||||
CatalogTableRow,
|
||||
FilteredEntityLayout,
|
||||
EntityListContainer,
|
||||
FilterContainer,
|
||||
} from '@backstage/plugin-catalog';
|
||||
import {
|
||||
EntityKindPicker,
|
||||
EntityLifecyclePicker,
|
||||
EntityListProvider,
|
||||
EntityOwnerPicker,
|
||||
EntityTagPicker,
|
||||
EntityTypePicker,
|
||||
UserListFilterKind,
|
||||
UserListPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { createComponentRouteRef } from '../../routes';
|
||||
|
||||
const defaultColumns: TableColumn<CatalogTableRow>[] = [
|
||||
CatalogTable.columns.createNameColumn({ defaultKind: 'API' }),
|
||||
CatalogTable.columns.createSystemColumn(),
|
||||
CatalogTable.columns.createOwnerColumn(),
|
||||
CatalogTable.columns.createSpecTypeColumn(),
|
||||
CatalogTable.columns.createSpecLifecycleColumn(),
|
||||
CatalogTable.columns.createMetadataDescriptionColumn(),
|
||||
CatalogTable.columns.createTagsColumn(),
|
||||
];
|
||||
|
||||
/**
|
||||
* DefaultApiExplorerPageProps
|
||||
* @public
|
||||
*/
|
||||
export type DefaultApiExplorerPageProps = {
|
||||
initiallySelectedFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<CatalogTableRow>[];
|
||||
actions?: TableProps<CatalogTableRow>['actions'];
|
||||
};
|
||||
|
||||
/**
|
||||
* DefaultApiExplorerPage
|
||||
* @public
|
||||
*/
|
||||
export const DefaultApiExplorerPage = ({
|
||||
initiallySelectedFilter = 'all',
|
||||
columns,
|
||||
actions,
|
||||
}: DefaultApiExplorerPageProps) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const generatedSubtitle = `${
|
||||
configApi.getOptionalString('organization.name') ?? 'Backstage'
|
||||
} API Explorer`;
|
||||
const createComponentLink = useRouteRef(createComponentRouteRef);
|
||||
|
||||
return (
|
||||
<PageWithHeader
|
||||
themeId="apis"
|
||||
title="APIs"
|
||||
subtitle={generatedSubtitle}
|
||||
pageTitleOverride="APIs"
|
||||
>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<CreateButton
|
||||
title="Register Existing API"
|
||||
to={createComponentLink?.()}
|
||||
/>
|
||||
<SupportButton>All your APIs</SupportButton>
|
||||
</ContentHeader>
|
||||
<EntityListProvider>
|
||||
<FilteredEntityLayout>
|
||||
<FilterContainer>
|
||||
<EntityKindPicker initialFilter="api" hidden />
|
||||
<EntityTypePicker />
|
||||
<UserListPicker initialFilter={initiallySelectedFilter} />
|
||||
<EntityOwnerPicker />
|
||||
<EntityLifecyclePicker />
|
||||
<EntityTagPicker />
|
||||
</FilterContainer>
|
||||
<EntityListContainer>
|
||||
<CatalogTable
|
||||
columns={columns || defaultColumns}
|
||||
actions={actions}
|
||||
/>
|
||||
</EntityListContainer>
|
||||
</FilteredEntityLayout>
|
||||
</EntityListProvider>
|
||||
</Content>
|
||||
</PageWithHeader>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2021 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.
|
||||
@@ -14,4 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ApiExplorerPage } from './ApiExplorerPage';
|
||||
export { ApiExplorerPage as ApiExplorerIndexPage } from './ApiExplorerPage';
|
||||
export { DefaultApiExplorerPage } from './DefaultApiExplorerPage';
|
||||
export type { DefaultApiExplorerPageProps } from './DefaultApiExplorerPage';
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './ApiExplorerPage';
|
||||
export * from './ApiDefinitionCard';
|
||||
export * from './ApisCards';
|
||||
export * from './AsyncApiDefinitionWidget';
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ApiExplorerPage = apiDocsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'ApiExplorerPage',
|
||||
component: () =>
|
||||
import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage),
|
||||
import('./components/ApiExplorerPage').then(m => m.ApiExplorerIndexPage),
|
||||
mountPoint: rootRoute,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -126,15 +126,10 @@ export class CatalogClientWrapper implements CatalogApi {
|
||||
// @public (undocumented)
|
||||
export const CatalogEntityPage: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "CatalogPageProps" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "CatalogIndexPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const CatalogIndexPage: ({
|
||||
columns,
|
||||
actions,
|
||||
initiallySelectedFilter,
|
||||
}: CatalogPageProps) => JSX.Element;
|
||||
export const CatalogIndexPage: (props: DefaultCatalogPageProps) => JSX.Element;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "CatalogKindHeaderProps" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "CatalogKindHeader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -236,6 +231,13 @@ export function createSystemColumn(): TableColumn<CatalogTableRow>;
|
||||
// @public (undocumented)
|
||||
export function createTagsColumn(): TableColumn<CatalogTableRow>;
|
||||
|
||||
// @public
|
||||
export type DefaultCatalogPageProps = {
|
||||
initiallySelectedFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<CatalogTableRow>[];
|
||||
actions?: TableProps<CatalogTableRow>['actions'];
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntityAboutCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2021 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.
|
||||
@@ -14,272 +14,31 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
Entity,
|
||||
RELATION_MEMBER_OF,
|
||||
RELATION_OWNED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import { TableColumn, TableProps } from '@backstage/core-components';
|
||||
import {
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
ProfileInfo,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
DefaultStarredEntitiesApi,
|
||||
entityRouteRef,
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
mockBreakpoint,
|
||||
MockStorageApi,
|
||||
renderWithEffects,
|
||||
TestApiProvider,
|
||||
wrapInTestApp,
|
||||
} from '@backstage/test-utils';
|
||||
import DashboardIcon from '@material-ui/icons/Dashboard';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { createComponentRouteRef } from '../../routes';
|
||||
import { EntityRow } from '../CatalogTable';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { useOutlet } from 'react-router';
|
||||
import { CatalogPage } from './CatalogPage';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useOutlet: jest.fn().mockReturnValue('Route Children'),
|
||||
}));
|
||||
|
||||
jest.mock('./DefaultCatalogPage', () => ({
|
||||
DefaultCatalogPage: jest.fn().mockReturnValue('DefaultCatalogPage'),
|
||||
}));
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
const origReplaceState = window.history.replaceState;
|
||||
beforeEach(() => {
|
||||
window.history.replaceState = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState = origReplaceState;
|
||||
it('renders provided router element', async () => {
|
||||
const { getByText } = await renderInTestApp(<CatalogPage />);
|
||||
|
||||
expect(getByText('Route Children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
spec: {
|
||||
owner: 'tools',
|
||||
type: 'service',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: { kind: 'Group', name: 'tools', namespace: 'default' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity2',
|
||||
},
|
||||
spec: {
|
||||
owner: 'not-tools',
|
||||
type: 'service',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'not-tools',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
getEntityByName: async entityName => {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: entityName.name },
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_MEMBER_OF,
|
||||
target: { namespace: 'default', kind: 'Group', name: 'tools' },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const testProfile: Partial<ProfileInfo> = {
|
||||
displayName: 'Display Name',
|
||||
};
|
||||
const identityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'tools',
|
||||
getIdToken: async () => undefined,
|
||||
getProfile: () => testProfile,
|
||||
};
|
||||
const storageApi = MockStorageApi.create();
|
||||
it('renders DefaultCatalogPage home when no router children are provided', async () => {
|
||||
(useOutlet as jest.Mock).mockReturnValueOnce(null);
|
||||
const { getByText } = await renderInTestApp(<CatalogPage />);
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, identityApi],
|
||||
[storageApiRef, storageApi],
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
new DefaultStarredEntitiesApi({ storageApi }),
|
||||
],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create': createComponentRouteRef,
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(freben): The test timeouts are bumped in this file, because it seems
|
||||
// page and table rerenders accumulate to occasionally go over the default
|
||||
// limit. We should investigate why these timeouts happen.
|
||||
|
||||
it('should render the default column of the grid', async () => {
|
||||
const { getAllByRole } = await renderWrapped(<CatalogPage />);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
|
||||
expect(columnHeaderLabels).toEqual([
|
||||
'Name',
|
||||
'System',
|
||||
'Owner',
|
||||
'Type',
|
||||
'Lifecycle',
|
||||
'Description',
|
||||
'Tags',
|
||||
'Actions',
|
||||
]);
|
||||
}, 20_000);
|
||||
|
||||
it('should render the custom column passed as prop', async () => {
|
||||
const columns: TableColumn<EntityRow>[] = [
|
||||
{ title: 'Foo', field: 'entity.foo' },
|
||||
{ title: 'Bar', field: 'entity.bar' },
|
||||
{ title: 'Baz', field: 'entity.spec.lifecycle' },
|
||||
];
|
||||
const { getAllByRole } = await renderWrapped(
|
||||
<CatalogPage columns={columns} />,
|
||||
);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']);
|
||||
}, 20_000);
|
||||
|
||||
it('should render the default actions of an item in the grid', async () => {
|
||||
const { getByTestId, findByTitle, findByText } = await renderWrapped(
|
||||
<CatalogPage />,
|
||||
);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/View/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Edit/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Add to favorites/)).toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
it('should render the custom actions of an item passed as prop', async () => {
|
||||
const actions: TableProps<EntityRow>['actions'] = [
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Foo Action',
|
||||
disabled: false,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Bar Action',
|
||||
disabled: true,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId, findByTitle, findByText } = await renderWrapped(
|
||||
<CatalogPage actions={actions} />,
|
||||
);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Foo Action/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Bar Action/)).toBeInTheDocument();
|
||||
expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled();
|
||||
}, 20_000);
|
||||
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
it('should render', async () => {
|
||||
const { findByText, getByTestId } = await renderWrapped(<CatalogPage />);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
await expect(findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
|
||||
fireEvent.click(getByTestId('user-picker-all'));
|
||||
await expect(findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
it('should set initial filter correctly', async () => {
|
||||
const { findByText } = await renderWrapped(
|
||||
<CatalogPage initiallySelectedFilter="all" />,
|
||||
);
|
||||
await expect(findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
// this test is for fixing the bug after favoriting an entity, the matching
|
||||
// entities defaulting to "owned" filter and not based on the selected filter
|
||||
it('should render the correct entities filtered on the selected filter', async () => {
|
||||
const { getByTestId } = await renderWrapped(<CatalogPage />);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('user-picker-starred'));
|
||||
await expect(
|
||||
screen.findByText(/Starred \(0\)/),
|
||||
).resolves.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('user-picker-all'));
|
||||
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
|
||||
const starredIcons = await screen.findAllByTitle('Add to favorites');
|
||||
fireEvent.click(starredIcons[0]);
|
||||
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('user-picker-starred'));
|
||||
await expect(
|
||||
screen.findByText(/Starred \(1\)/),
|
||||
).resolves.toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
it('should wrap filter in drawer on smaller screens', async () => {
|
||||
mockBreakpoint({ matches: true });
|
||||
const { getByRole } = await renderWrapped(<CatalogPage />);
|
||||
const button = getByRole('button', { name: 'Filters' });
|
||||
expect(getByRole('presentation', { hidden: true })).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(getByRole('presentation')).toBeVisible();
|
||||
}, 20_000);
|
||||
expect(getByText('DefaultCatalogPage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2021 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.
|
||||
@@ -14,76 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
CreateButton,
|
||||
PageWithHeader,
|
||||
SupportButton,
|
||||
TableColumn,
|
||||
TableProps,
|
||||
} from '@backstage/core-components';
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
EntityLifecyclePicker,
|
||||
EntityListProvider,
|
||||
EntityOwnerPicker,
|
||||
EntityTagPicker,
|
||||
EntityTypePicker,
|
||||
UserListFilterKind,
|
||||
UserListPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { createComponentRouteRef } from '../../routes';
|
||||
import { CatalogTable } from '../CatalogTable';
|
||||
import { EntityRow } from '../CatalogTable/types';
|
||||
import { useOutlet } from 'react-router';
|
||||
import {
|
||||
FilteredEntityLayout,
|
||||
EntityListContainer,
|
||||
FilterContainer,
|
||||
} from '../FilteredEntityLayout';
|
||||
import { CatalogKindHeader } from '../CatalogKindHeader';
|
||||
DefaultCatalogPage,
|
||||
DefaultCatalogPageProps,
|
||||
} from './DefaultCatalogPage';
|
||||
|
||||
export type CatalogPageProps = {
|
||||
initiallySelectedFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<EntityRow>[];
|
||||
actions?: TableProps<EntityRow>['actions'];
|
||||
};
|
||||
|
||||
export const CatalogPage = ({
|
||||
columns,
|
||||
actions,
|
||||
initiallySelectedFilter = 'owned',
|
||||
}: CatalogPageProps) => {
|
||||
const orgName =
|
||||
useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage';
|
||||
const createComponentLink = useRouteRef(createComponentRouteRef);
|
||||
|
||||
return (
|
||||
<PageWithHeader title={`${orgName} Catalog`} themeId="home">
|
||||
<EntityListProvider>
|
||||
<Content>
|
||||
<ContentHeader titleComponent={<CatalogKindHeader />}>
|
||||
<CreateButton
|
||||
title="Create Component"
|
||||
to={createComponentLink && createComponentLink()}
|
||||
/>
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<FilteredEntityLayout>
|
||||
<FilterContainer>
|
||||
<EntityTypePicker />
|
||||
<UserListPicker initialFilter={initiallySelectedFilter} />
|
||||
<EntityOwnerPicker />
|
||||
<EntityLifecyclePicker />
|
||||
<EntityTagPicker />
|
||||
</FilterContainer>
|
||||
<EntityListContainer>
|
||||
<CatalogTable columns={columns} actions={actions} />
|
||||
</EntityListContainer>
|
||||
</FilteredEntityLayout>
|
||||
</Content>
|
||||
</EntityListProvider>
|
||||
</PageWithHeader>
|
||||
);
|
||||
export const CatalogPage = (props: DefaultCatalogPageProps) => {
|
||||
const outlet = useOutlet();
|
||||
|
||||
return outlet || <DefaultCatalogPage {...props} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* Copyright 2021 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 { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
Entity,
|
||||
RELATION_MEMBER_OF,
|
||||
RELATION_OWNED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import { TableColumn, TableProps } from '@backstage/core-components';
|
||||
import {
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
ProfileInfo,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
DefaultStarredEntitiesApi,
|
||||
entityRouteRef,
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
mockBreakpoint,
|
||||
MockStorageApi,
|
||||
renderWithEffects,
|
||||
TestApiProvider,
|
||||
wrapInTestApp,
|
||||
} from '@backstage/test-utils';
|
||||
import DashboardIcon from '@material-ui/icons/Dashboard';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { createComponentRouteRef } from '../../routes';
|
||||
import { EntityRow } from '../CatalogTable';
|
||||
import { DefaultCatalogPage } from './DefaultCatalogPage';
|
||||
|
||||
describe('DefaultCatalogPage', () => {
|
||||
const origReplaceState = window.history.replaceState;
|
||||
beforeEach(() => {
|
||||
window.history.replaceState = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState = origReplaceState;
|
||||
});
|
||||
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
spec: {
|
||||
owner: 'tools',
|
||||
type: 'service',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: { kind: 'Group', name: 'tools', namespace: 'default' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity2',
|
||||
},
|
||||
spec: {
|
||||
owner: 'not-tools',
|
||||
type: 'service',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
target: {
|
||||
kind: 'Group',
|
||||
name: 'not-tools',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
getEntityByName: async entityName => {
|
||||
return {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: { name: entityName.name },
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_MEMBER_OF,
|
||||
target: { namespace: 'default', kind: 'Group', name: 'tools' },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const testProfile: Partial<ProfileInfo> = {
|
||||
displayName: 'Display Name',
|
||||
};
|
||||
const identityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'tools',
|
||||
getIdToken: async () => undefined,
|
||||
getProfile: () => testProfile,
|
||||
};
|
||||
const storageApi = MockStorageApi.create();
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, identityApi],
|
||||
[storageApiRef, storageApi],
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
new DefaultStarredEntitiesApi({ storageApi }),
|
||||
],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create': createComponentRouteRef,
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(freben): The test timeouts are bumped in this file, because it seems
|
||||
// page and table rerenders accumulate to occasionally go over the default
|
||||
// limit. We should investigate why these timeouts happen.
|
||||
|
||||
it('should render the default column of the grid', async () => {
|
||||
const { getAllByRole } = await renderWrapped(<DefaultCatalogPage />);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
|
||||
expect(columnHeaderLabels).toEqual([
|
||||
'Name',
|
||||
'System',
|
||||
'Owner',
|
||||
'Type',
|
||||
'Lifecycle',
|
||||
'Description',
|
||||
'Tags',
|
||||
'Actions',
|
||||
]);
|
||||
}, 20_000);
|
||||
|
||||
it('should render the custom column passed as prop', async () => {
|
||||
const columns: TableColumn<EntityRow>[] = [
|
||||
{ title: 'Foo', field: 'entity.foo' },
|
||||
{ title: 'Bar', field: 'entity.bar' },
|
||||
{ title: 'Baz', field: 'entity.spec.lifecycle' },
|
||||
];
|
||||
const { getAllByRole } = await renderWrapped(
|
||||
<DefaultCatalogPage columns={columns} />,
|
||||
);
|
||||
|
||||
const columnHeader = getAllByRole('button').filter(
|
||||
c => c.tagName === 'SPAN',
|
||||
);
|
||||
const columnHeaderLabels = columnHeader.map(c => c.textContent);
|
||||
expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']);
|
||||
}, 20_000);
|
||||
|
||||
it('should render the default actions of an item in the grid', async () => {
|
||||
const { getByTestId, findByTitle, findByText } = await renderWrapped(
|
||||
<DefaultCatalogPage />,
|
||||
);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/View/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Edit/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Add to favorites/)).toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
it('should render the custom actions of an item passed as prop', async () => {
|
||||
const actions: TableProps<EntityRow>['actions'] = [
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Foo Action',
|
||||
disabled: false,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
icon: () => <DashboardIcon fontSize="small" />,
|
||||
tooltip: 'Bar Action',
|
||||
disabled: true,
|
||||
onClick: () => jest.fn(),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId, findByTitle, findByText } = await renderWrapped(
|
||||
<DefaultCatalogPage actions={actions} />,
|
||||
);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Foo Action/)).toBeInTheDocument();
|
||||
expect(await findByTitle(/Bar Action/)).toBeInTheDocument();
|
||||
expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled();
|
||||
}, 20_000);
|
||||
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
it('should render', async () => {
|
||||
const { findByText, getByTestId } = await renderWrapped(
|
||||
<DefaultCatalogPage />,
|
||||
);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
await expect(findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
|
||||
fireEvent.click(getByTestId('user-picker-all'));
|
||||
await expect(findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
it('should set initial filter correctly', async () => {
|
||||
const { findByText } = await renderWrapped(
|
||||
<DefaultCatalogPage initiallySelectedFilter="all" />,
|
||||
);
|
||||
await expect(findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
// this test is for fixing the bug after favoriting an entity, the matching
|
||||
// entities defaulting to "owned" filter and not based on the selected filter
|
||||
it('should render the correct entities filtered on the selected filter', async () => {
|
||||
const { getByTestId } = await renderWrapped(<DefaultCatalogPage />);
|
||||
fireEvent.click(getByTestId('user-picker-owned'));
|
||||
await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('user-picker-starred'));
|
||||
await expect(
|
||||
screen.findByText(/Starred \(0\)/),
|
||||
).resolves.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('user-picker-all'));
|
||||
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
|
||||
const starredIcons = await screen.findAllByTitle('Add to favorites');
|
||||
fireEvent.click(starredIcons[0]);
|
||||
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('user-picker-starred'));
|
||||
await expect(
|
||||
screen.findByText(/Starred \(1\)/),
|
||||
).resolves.toBeInTheDocument();
|
||||
}, 20_000);
|
||||
|
||||
it('should wrap filter in drawer on smaller screens', async () => {
|
||||
mockBreakpoint({ matches: true });
|
||||
const { getByRole } = await renderWrapped(<DefaultCatalogPage />);
|
||||
const button = getByRole('button', { name: 'Filters' });
|
||||
expect(getByRole('presentation', { hidden: true })).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(getByRole('presentation')).toBeVisible();
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
Content,
|
||||
ContentHeader,
|
||||
CreateButton,
|
||||
PageWithHeader,
|
||||
SupportButton,
|
||||
TableColumn,
|
||||
TableProps,
|
||||
} from '@backstage/core-components';
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
EntityLifecyclePicker,
|
||||
EntityListProvider,
|
||||
EntityOwnerPicker,
|
||||
EntityTagPicker,
|
||||
EntityTypePicker,
|
||||
UserListFilterKind,
|
||||
UserListPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { createComponentRouteRef } from '../../routes';
|
||||
import { CatalogTable } from '../CatalogTable';
|
||||
import { EntityRow } from '../CatalogTable/types';
|
||||
import {
|
||||
FilteredEntityLayout,
|
||||
EntityListContainer,
|
||||
FilterContainer,
|
||||
} from '../FilteredEntityLayout';
|
||||
import { CatalogKindHeader } from '../CatalogKindHeader';
|
||||
|
||||
/**
|
||||
* DefaultCatalogPageProps
|
||||
* @public
|
||||
*/
|
||||
export type DefaultCatalogPageProps = {
|
||||
initiallySelectedFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<EntityRow>[];
|
||||
actions?: TableProps<EntityRow>['actions'];
|
||||
};
|
||||
|
||||
export const DefaultCatalogPage = ({
|
||||
columns,
|
||||
actions,
|
||||
initiallySelectedFilter = 'owned',
|
||||
}: DefaultCatalogPageProps) => {
|
||||
const orgName =
|
||||
useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage';
|
||||
const createComponentLink = useRouteRef(createComponentRouteRef);
|
||||
|
||||
return (
|
||||
<PageWithHeader title={`${orgName} Catalog`} themeId="home">
|
||||
<EntityListProvider>
|
||||
<Content>
|
||||
<ContentHeader titleComponent={<CatalogKindHeader />}>
|
||||
<CreateButton
|
||||
title="Create Component"
|
||||
to={createComponentLink && createComponentLink()}
|
||||
/>
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<FilteredEntityLayout>
|
||||
<FilterContainer>
|
||||
<EntityTypePicker />
|
||||
<UserListPicker initialFilter={initiallySelectedFilter} />
|
||||
<EntityOwnerPicker />
|
||||
<EntityLifecyclePicker />
|
||||
<EntityTagPicker />
|
||||
</FilterContainer>
|
||||
<EntityListContainer>
|
||||
<CatalogTable columns={columns} actions={actions} />
|
||||
</EntityListContainer>
|
||||
</FilteredEntityLayout>
|
||||
</Content>
|
||||
</EntityListProvider>
|
||||
</PageWithHeader>
|
||||
);
|
||||
};
|
||||
@@ -14,3 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { CatalogPage } from './CatalogPage';
|
||||
export { DefaultCatalogPage } from './DefaultCatalogPage';
|
||||
export type { DefaultCatalogPageProps } from './DefaultCatalogPage';
|
||||
|
||||
@@ -54,3 +54,4 @@ export {
|
||||
|
||||
export type { EntityLinksEmptyStateClassKey } from './components/EntityLinksCard';
|
||||
export type { SystemDiagramCardClassKey } from './components/SystemDiagramCard';
|
||||
export type { DefaultCatalogPageProps } from './components/CatalogPage';
|
||||
|
||||
Reference in New Issue
Block a user