Merge pull request #6895 from backstage/timbonicus/kind-picker

Add kind picker to the CatalogPage
This commit is contained in:
Tim Hansen
2021-08-28 23:09:06 -06:00
committed by GitHub
21 changed files with 446 additions and 62 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-react': patch
---
Added a `useEntityKinds` hook to load a unique list of entity kinds from the catalog.
Fixed a bug in `EntityTypePicker` where the component did not hide when no types were available in returned entities.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/core-components': minor
---
Changed the `titleComponent` prop on `ContentHeader` to accept `ReactNode` instead of a React `ComponentType`. Usages of this prop should be converted from passing a component to passing in the rendered element:
```diff
-<ContentHeader titleComponent={MyComponent}>
+<ContentHeader titleComponent={<MyComponent />}>
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Added the ability to switch entity kind on the catalog index page. This is a non-breaking change, but if you created a custom `CatalogPage` and wish to use this feature, make the modifications shown on [#6895](https://github.com/backstage/backstage/pull/6895).
@@ -34,15 +34,14 @@ export const CustomCatalogPage = ({
}: CatalogPageProps) => {
return (
<PageWithHeader title={`${orgName} Catalog`} themeId="home">
<Content>
<ContentHeader title="Components">
<CreateButton title="Create Component" to={link} />
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<EntityListProvider>
<EntityListProvider>
<Content>
<ContentHeader titleComponent={<CatalogKindHeader />}>
<CreateButton title="Create Component" to={link} />
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<FilteredEntityLayout>
<FilterContainer>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityTagPicker />
@@ -51,8 +50,8 @@ export const CustomCatalogPage = ({
<CatalogTable columns={columns} actions={actions} />
</EntityListContainer>
</FilteredEntityLayout>
</EntityListProvider>
</Content>
</Content>
</EntityListProvider>
</PageWithHeader>
);
};
-1
View File
@@ -15,7 +15,6 @@ import { Column } from '@material-table/core';
import { CommonProps } from '@material-ui/core/OverridableComponent';
import { ComponentClass } from 'react';
import { ComponentProps } from 'react';
import { ComponentType } from 'react';
import { Context } from 'react';
import { default as CSS_2 } from 'csstype';
import { CSSProperties } from 'react';
@@ -32,7 +32,7 @@ describe('<ContentHeader/>', () => {
it('should render with titleComponent', async () => {
const title = 'Custom title';
const titleComponent = () => <h1>{title}</h1>;
const titleComponent = <h1>{title}</h1>;
const rendered = await renderInTestApp(
<ContentHeader titleComponent={titleComponent} />,
);
@@ -18,7 +18,7 @@
* TODO favoriteable capability
*/
import React, { ComponentType, PropsWithChildren } from 'react';
import React, { PropsWithChildren, ReactNode } from 'react';
import { Typography, makeStyles } from '@material-ui/core';
import { Helmet } from 'react-helmet';
@@ -77,7 +77,7 @@ const ContentHeaderTitle = ({
type ContentHeaderProps = {
title?: ContentHeaderTitleProps['title'];
titleComponent?: ComponentType;
titleComponent?: ReactNode;
description?: string;
textAlign?: 'left' | 'right' | 'center';
};
@@ -92,7 +92,7 @@ export const ContentHeader = ({
const classes = useStyles({ textAlign })();
const renderedTitle = TitleComponent ? (
<TitleComponent />
TitleComponent
) : (
<ContentHeaderTitle title={title} className={classes.title} />
);
+10 -1
View File
@@ -693,7 +693,7 @@ export const MockEntityListContextProvider: ({
children,
value,
}: React_2.PropsWithChildren<{
value: Partial<EntityListContextProps>;
value?: Partial<EntityListContextProps<DefaultEntityFilters>> | undefined;
}>) => JSX.Element;
// Warning: (ae-missing-release-tag) "reduceCatalogFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -750,6 +750,15 @@ export const useEntityCompoundName: () => {
// @public (undocumented)
export const useEntityFromUrl: () => EntityLoadingStatus;
// Warning: (ae-missing-release-tag) "useEntityKinds" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function useEntityKinds(): {
error: Error | undefined;
loading: boolean;
kinds: string[] | undefined;
};
// Warning: (ae-missing-release-tag) "useEntityListProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import { capitalize } from 'lodash';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
@@ -26,6 +26,7 @@ import { EntityKindFilter, EntityTypeFilter } from '../../filters';
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { renderWithEffects } from '@backstage/test-utils';
const entities: Entity[] = [
{
@@ -60,26 +61,15 @@ const entities: Entity[] = [
},
];
const apis = ApiRegistry.from([
[
catalogApiRef,
{
getEntities: jest
.fn()
.mockImplementation(() => Promise.resolve({ items: entities })),
} as unknown as CatalogApi,
],
[
alertApiRef,
{
post: jest.fn(),
} as unknown as AlertApi,
],
]);
const apis = ApiRegistry.with(catalogApiRef, {
getEntities: jest.fn().mockResolvedValue({ items: entities }),
} as unknown as CatalogApi).with(alertApiRef, {
post: jest.fn(),
} as unknown as AlertApi);
describe('<EntityTypePicker/>', () => {
it('renders available entity types', async () => {
const rendered = render(
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ filters: { kind: new EntityKindFilter('component') } }}
@@ -104,7 +94,7 @@ describe('<EntityTypePicker/>', () => {
it('sets the selected type filter', async () => {
const updateFilters = jest.fn();
const rendered = render(
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
@@ -36,7 +36,7 @@ export const EntityTypePicker = () => {
}
}, [error, alertApi]);
if (!availableTypes || error) return null;
if (availableTypes.length === 0 || error) return null;
const items = [
{ value: 'all', label: 'All' },
+1
View File
@@ -22,6 +22,7 @@ export {
} from './useEntityListProvider';
export type { DefaultEntityFilters } from './useEntityListProvider';
export { useEntityTypeFilter } from './useEntityTypeFilter';
export { useEntityKinds } from './useEntityKinds';
export { useOwnUser } from './useOwnUser';
export { useRelatedEntities } from './useRelatedEntities';
export { useStarredEntities } from './useStarredEntities';
@@ -0,0 +1,91 @@
/*
* 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 React, { PropsWithChildren } from 'react';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { catalogApiRef } from '../api';
import { renderHook } from '@testing-library/react-hooks';
import { useEntityKinds } from './useEntityKinds';
const entities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
},
{
apiVersion: '1',
kind: 'Template',
metadata: {
name: 'template',
},
},
{
apiVersion: '1',
kind: 'System',
metadata: {
name: 'system',
},
},
];
const mockCatalogApi: Partial<CatalogApi> = {
getEntities: jest.fn().mockResolvedValue({ items: entities }),
};
const wrapper = ({ children }: PropsWithChildren<{}>) => {
return (
<ApiProvider apis={ApiRegistry.with(catalogApiRef, mockCatalogApi)}>
{children}
</ApiProvider>
);
};
describe('useEntityKinds', () => {
it('does not return duplicate kinds', async () => {
const { result, waitForValueToChange } = renderHook(
() => useEntityKinds(),
{
wrapper,
},
);
await waitForValueToChange(() => result.current);
expect(result.current.kinds).toBeDefined();
expect(result.current.kinds!.length).toBe(3);
});
it('sorts entity kinds', async () => {
const { result, waitForValueToChange } = renderHook(
() => useEntityKinds(),
{
wrapper,
},
);
await waitForValueToChange(() => result.current);
expect(result.current.kinds).toEqual(['Component', 'System', 'Template']);
});
});
@@ -0,0 +1,37 @@
/*
* 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 { useAsync } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '../api';
// Retrieve a list of unique entity kinds present in the catalog
export function useEntityKinds() {
const catalogApi = useApi(catalogApiRef);
const {
error,
loading,
value: kinds,
} = useAsync(async () => {
const entities = await catalogApi
.getEntities({ fields: ['kind'] })
.then(response => response.items);
return [...new Set(entities.map(e => e.kind))].sort();
});
return { error, loading, kinds };
}
@@ -25,12 +25,12 @@ export const MockEntityListContextProvider = ({
children,
value,
}: PropsWithChildren<{
value: Partial<EntityListContextProps>;
value?: Partial<EntityListContextProps>;
}>) => {
// Provides a default implementation that stores filter state, for testing components that
// reflect filter state.
const [filters, setFilters] = useState<DefaultEntityFilters>(
value.filters ?? {},
value?.filters ?? {},
);
const updateFilters = useCallback(
(
@@ -60,7 +60,7 @@ export const MockEntityListContextProvider = ({
// Extract value.filters to avoid overwriting it; some tests exercise filter updates. The value
// provided is used as the initial seed in useState above.
const { filters: _, ...otherContextFields } = value;
const { filters: _, ...otherContextFields } = value ?? {};
return (
<EntityListContext.Provider
+8
View File
@@ -114,6 +114,14 @@ export const CatalogIndexPage: ({
initiallySelectedFilter,
}: CatalogPageProps) => 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)
//
// @public (undocumented)
export const CatalogKindHeader: ({
initialFilter,
}: CatalogKindHeaderProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "catalogPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -0,0 +1,119 @@
/*
* 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 React from 'react';
import { fireEvent } from '@testing-library/react';
import { Entity } from '@backstage/catalog-model';
import {
CatalogApi,
catalogApiRef,
EntityKindFilter,
MockEntityListContextProvider,
} from '@backstage/plugin-catalog-react';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { renderWithEffects } from '@backstage/test-utils';
import { CatalogKindHeader } from './CatalogKindHeader';
const entities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
},
{
apiVersion: '1',
kind: 'Template',
metadata: {
name: 'template',
},
},
{
apiVersion: '1',
kind: 'System',
metadata: {
name: 'system',
},
},
];
const apis = ApiRegistry.with(catalogApiRef, {
getEntities: jest.fn().mockResolvedValue({ items: entities }),
} as Partial<CatalogApi>);
describe('<CatalogKindHeader />', () => {
it('renders available kinds', async () => {
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider>
<CatalogKindHeader />
</MockEntityListContextProvider>
</ApiProvider>,
);
const input = rendered.getByText('Components');
fireEvent.mouseDown(input);
entities.map(entity => {
expect(
rendered.getByRole('option', { name: `${entity.kind}s` }),
).toBeInTheDocument();
});
});
it('renders unknown kinds provided in query parameters', async () => {
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ queryParameters: { kind: 'frob' } }}
>
<CatalogKindHeader />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(rendered.getByText('Frobs')).toBeInTheDocument();
});
it('updates the kind filter', async () => {
const updateFilters = jest.fn();
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ updateFilters }}>
<CatalogKindHeader />
</MockEntityListContextProvider>
</ApiProvider>,
);
const input = rendered.getByText('Components');
fireEvent.mouseDown(input);
const option = rendered.getByRole('option', { name: 'Templates' });
fireEvent.click(option);
expect(updateFilters).toHaveBeenCalledWith({
kind: new EntityKindFilter('template'),
});
});
});
@@ -0,0 +1,91 @@
/*
* 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 React, { useEffect, useState } from 'react';
import {
capitalize,
createStyles,
InputBase,
makeStyles,
MenuItem,
Select,
Theme,
} from '@material-ui/core';
import {
EntityKindFilter,
useEntityKinds,
useEntityListProvider,
} from '@backstage/plugin-catalog-react';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
...theme.typography.h4,
},
}),
);
type CatalogKindHeaderProps = {
initialFilter?: string;
};
export const CatalogKindHeader = ({
initialFilter = 'component',
}: CatalogKindHeaderProps) => {
const classes = useStyles();
const { kinds: allKinds = [] } = useEntityKinds();
const { updateFilters, queryParameters } = useEntityListProvider();
const [selectedKind, setSelectedKind] = useState(
([queryParameters.kind].flat()[0] ?? initialFilter).toLocaleLowerCase(
'en-US',
),
);
useEffect(() => {
updateFilters({
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
});
}, [selectedKind, updateFilters]);
// Before allKinds is loaded, or when a kind is entered manually in the URL, selectedKind may not
// be present in allKinds. It should still be shown in the dropdown, but may not have the nice
// enforced casing from the catalog-backend. This makes a key/value record for the Select options,
// including selectedKind if it's unknown - but allows the selectedKind to get clobbered by the
// more proper catalog kind if it exists.
const options = [capitalize(selectedKind)]
.concat(allKinds)
.sort()
.reduce((acc, kind) => {
acc[kind.toLocaleLowerCase('en-US')] = kind;
return acc;
}, {} as Record<string, string>);
return (
<Select
input={<InputBase value={selectedKind} />}
value={selectedKind}
onChange={e => setSelectedKind(e.target.value as string)}
classes={classes}
>
{Object.keys(options).map(kind => (
<MenuItem value={kind} key={kind}>
{`${options[kind]}s`}
</MenuItem>
))}
</Select>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { CatalogKindHeader } from './CatalogKindHeader';
@@ -25,7 +25,6 @@ import {
} from '@backstage/core-components';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
EntityKindPicker,
EntityLifecyclePicker,
EntityListProvider,
EntityOwnerPicker,
@@ -43,6 +42,7 @@ import {
EntityListContainer,
FilterContainer,
} from '../FilteredEntityLayout';
import { CatalogKindHeader } from '../CatalogKindHeader';
export type CatalogPageProps = {
initiallySelectedFilter?: UserListFilterKind;
@@ -61,18 +61,17 @@ export const CatalogPage = ({
return (
<PageWithHeader title={`${orgName} Catalog`} themeId="home">
<Content>
<ContentHeader title="Components">
<CreateButton
title="Create Component"
to={createComponentLink && createComponentLink()}
/>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<EntityListProvider>
<EntityListProvider>
<Content>
<ContentHeader titleComponent={<CatalogKindHeader />}>
<CreateButton
title="Create Component"
to={createComponentLink && createComponentLink()}
/>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<FilteredEntityLayout>
<FilterContainer>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
@@ -83,8 +82,8 @@ export const CatalogPage = ({
<CatalogTable columns={columns} actions={actions} />
</EntityListContainer>
</FilteredEntityLayout>
</EntityListProvider>
</Content>
</Content>
</EntityListProvider>
</PageWithHeader>
);
};
@@ -27,7 +27,7 @@ import {
import Edit from '@material-ui/icons/Edit';
import OpenInNew from '@material-ui/icons/OpenInNew';
import { capitalize } from 'lodash';
import React from 'react';
import React, { useMemo } from 'react';
import * as columnFactories from './columns';
import { EntityRow } from './types';
import {
@@ -38,16 +38,6 @@ import {
WarningPanel,
} from '@backstage/core-components';
const defaultColumns: TableColumn<EntityRow>[] = [
columnFactories.createNameColumn(),
columnFactories.createSystemColumn(),
columnFactories.createOwnerColumn(),
columnFactories.createSpecTypeColumn(),
columnFactories.createSpecLifecycleColumn(),
columnFactories.createMetadataDescriptionColumn(),
columnFactories.createTagsColumn(),
];
type CatalogTableProps = {
columns?: TableColumn<EntityRow>[];
actions?: TableProps<EntityRow>['actions'];
@@ -57,6 +47,19 @@ export const CatalogTable = ({ columns, actions }: CatalogTableProps) => {
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
const { loading, error, entities, filters } = useEntityListProvider();
const defaultColumns: TableColumn<EntityRow>[] = useMemo(
() => [
columnFactories.createNameColumn({ defaultKind: filters.kind?.value }),
columnFactories.createSystemColumn(),
columnFactories.createOwnerColumn(),
columnFactories.createSpecTypeColumn(),
columnFactories.createSpecLifecycleColumn(),
columnFactories.createMetadataDescriptionColumn(),
columnFactories.createTagsColumn(),
],
[filters.kind?.value],
);
const showTypeColumn = filters.type === undefined;
// TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar
const titlePreamble = capitalize(filters.user?.value ?? 'all');
+1
View File
@@ -16,6 +16,7 @@
export { CatalogClientWrapper } from './CatalogClientWrapper';
export * from './components/AboutCard';
export * from './components/CatalogKindHeader';
export * from './components/CatalogResultListItem';
export { CatalogTable } from './components/CatalogTable';
export type { EntityRow as CatalogTableRow } from './components/CatalogTable';