diff --git a/.changeset/cuddly-cooks-fry.md b/.changeset/cuddly-cooks-fry.md new file mode 100644 index 0000000000..028b0291bd --- /dev/null +++ b/.changeset/cuddly-cooks-fry.md @@ -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. diff --git a/.changeset/nasty-ads-nail.md b/.changeset/nasty-ads-nail.md new file mode 100644 index 0000000000..0518b1e569 --- /dev/null +++ b/.changeset/nasty-ads-nail.md @@ -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 +- ++}> +``` diff --git a/.changeset/short-years-smile.md b/.changeset/short-years-smile.md new file mode 100644 index 0000000000..6e7b882c1e --- /dev/null +++ b/.changeset/short-years-smile.md @@ -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). diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 07258a55cb..8f855776a0 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -34,15 +34,14 @@ export const CustomCatalogPage = ({ }: CatalogPageProps) => { return ( - - - - All your software catalog entities - - + + + }> + + All your software catalog entities + - - - + + ); }; diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index b4402f67ac..f73eee1f13 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -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'; diff --git a/packages/core-components/src/layout/ContentHeader/ContentHeader.test.tsx b/packages/core-components/src/layout/ContentHeader/ContentHeader.test.tsx index 498b859db1..37059766a8 100644 --- a/packages/core-components/src/layout/ContentHeader/ContentHeader.test.tsx +++ b/packages/core-components/src/layout/ContentHeader/ContentHeader.test.tsx @@ -32,7 +32,7 @@ describe('', () => { it('should render with titleComponent', async () => { const title = 'Custom title'; - const titleComponent = () =>

{title}

; + const titleComponent =

{title}

; const rendered = await renderInTestApp( , ); diff --git a/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx b/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx index 4bda2d76af..2259731148 100644 --- a/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx +++ b/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx @@ -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 ) : ( ); diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index c5f3226874..5f65426ed3 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -693,7 +693,7 @@ export const MockEntityListContextProvider: ({ children, value, }: React_2.PropsWithChildren<{ - value: Partial; + value?: Partial> | 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) diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index 903a7ba4eb..9912f3d371 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -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('', () => { it('renders available entity types', async () => { - const rendered = render( + const rendered = await renderWithEffects( ', () => { it('sets the selected type filter', async () => { const updateFilters = jest.fn(); - const rendered = render( + const rendered = await renderWithEffects( { } }, [error, alertApi]); - if (!availableTypes || error) return null; + if (availableTypes.length === 0 || error) return null; const items = [ { value: 'all', label: 'All' }, diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 868e50c852..2f3ce8411d 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -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'; diff --git a/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx b/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx new file mode 100644 index 0000000000..d801e508a6 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityKinds.test.tsx @@ -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 = { + getEntities: jest.fn().mockResolvedValue({ items: entities }), +}; + +const wrapper = ({ children }: PropsWithChildren<{}>) => { + return ( + + {children} + + ); +}; + +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']); + }); +}); diff --git a/plugins/catalog-react/src/hooks/useEntityKinds.ts b/plugins/catalog-react/src/hooks/useEntityKinds.ts new file mode 100644 index 0000000000..082e4a207d --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityKinds.ts @@ -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 }; +} diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx index 1f9e4f40d6..2172f6c5a9 100644 --- a/plugins/catalog-react/src/testUtils/providers.tsx +++ b/plugins/catalog-react/src/testUtils/providers.tsx @@ -25,12 +25,12 @@ export const MockEntityListContextProvider = ({ children, value, }: PropsWithChildren<{ - value: Partial; + value?: Partial; }>) => { // Provides a default implementation that stores filter state, for testing components that // reflect filter state. const [filters, setFilters] = useState( - 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 ( 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) diff --git a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx new file mode 100644 index 0000000000..2730665fb6 --- /dev/null +++ b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx @@ -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); + +describe('', () => { + it('renders available kinds', async () => { + const rendered = await renderWithEffects( + + + + + , + ); + + 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( + + + + + , + ); + + expect(rendered.getByText('Frobs')).toBeInTheDocument(); + }); + + it('updates the kind filter', async () => { + const updateFilters = jest.fn(); + const rendered = await renderWithEffects( + + + + + , + ); + + 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'), + }); + }); +}); diff --git a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx new file mode 100644 index 0000000000..41c5888d79 --- /dev/null +++ b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx @@ -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); + + return ( + + ); +}; diff --git a/plugins/catalog/src/components/CatalogKindHeader/index.ts b/plugins/catalog/src/components/CatalogKindHeader/index.ts new file mode 100644 index 0000000000..0078207d58 --- /dev/null +++ b/plugins/catalog/src/components/CatalogKindHeader/index.ts @@ -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'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index a4f6c01bc8..ec29d3839b 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -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 ( - - - - All your software catalog entities - - + + + }> + + All your software catalog entities + - - - + + ); }; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 84c2afa0f6..c85a60c7c1 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -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[] = [ - columnFactories.createNameColumn(), - columnFactories.createSystemColumn(), - columnFactories.createOwnerColumn(), - columnFactories.createSpecTypeColumn(), - columnFactories.createSpecLifecycleColumn(), - columnFactories.createMetadataDescriptionColumn(), - columnFactories.createTagsColumn(), -]; - type CatalogTableProps = { columns?: TableColumn[]; actions?: TableProps['actions']; @@ -57,6 +47,19 @@ export const CatalogTable = ({ columns, actions }: CatalogTableProps) => { const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); const { loading, error, entities, filters } = useEntityListProvider(); + const defaultColumns: TableColumn[] = 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'); diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index c5cc69f609..31184204a9 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -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';