diff --git a/.changeset/pink-countries-pump.md b/.changeset/pink-countries-pump.md new file mode 100644 index 0000000000..b7a79238fd --- /dev/null +++ b/.changeset/pink-countries-pump.md @@ -0,0 +1,24 @@ +--- +'@backstage/plugin-catalog': minor +'@backstage/plugin-catalog-react': minor +--- + +The default `CatalogPage` has been reworked to be more composable and make +customization easier. This change only affects those who have replaced the +default `CatalogPage` with a custom implementation; others can safely ignore the +rest of this changelog. + +If you created a custom `CatalogPage` to **add or remove tabs** from the +catalog, a custom page is no longer necessary. The fixed tabs have been replaced +with a `spec.type` dropdown that shows all available `Component` types in the +catalog. + +For other needs, customizing the `CatalogPage` should now be easier. The new +[CatalogPage.tsx](https://github.com/backstage/backstage/blob/9a4baa74509b6452d7dc054d34cf079f9997166d/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx) +shows the default implementation. Overriding this with your own, similar +`CatalogPage` component in your `App.tsx` routing allows you to adjust the +layout, header, and which filters are available. + +See the documentation added on [Catalog +Customization](https://backstage.io/docs/features/software-catalog/catalog-customization) +for instructions. diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md new file mode 100644 index 0000000000..e0228bf7d0 --- /dev/null +++ b/docs/features/software-catalog/catalog-customization.md @@ -0,0 +1,183 @@ +--- +id: catalog-customization +title: Catalog Customization +# prettier-ignore +description: How to add custom filters or interface elements to the Backstage software catalog +--- + +The Backstage software catalog comes with a default `CatalogIndexPage` to filter +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`. + +> 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 +> of drifting out of date over time. Be sure to check the catalog +> [CHANGELOG](https://github.com/backstage/backstage/blob/master/plugins/catalog/CHANGELOG.md) +> periodically. + +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): + +```tsx +// imports, etc omitted for brevity. for full source see: +// https://github.com/backstage/backstage/blob/master/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +export const CustomCatalogPage = () => { + return ( + + + + + All your software catalog entities + +
+ +
+
+ +
+
+
+
+ ); +}; +``` + +The `EntityListProvider` shown here provides a list of entities from the +`catalog-backend`, and a way to hook in filters. + +Now we're ready to create a new filter that implements the `EntityFilter` +interface: + +```ts +import { EntityFilter } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; + +class EntitySecurityTierFilter implements EntityFilter { + constructor(readonly values: string[]) {} + filterEntity(entity: Entity): boolean { + const tier = entity.metadata.annotations?.['company.com/security-tier']; + return tier !== undefined && this.values.includes(tier); + } +} +``` + +The `EntityFilter` interface permits backend filters, which are passed along to +the `catalog-backend` - or frontend filters, which are applied after entities +are loaded from the backend. + +We'll use this filter to extend the default filters in a type-safe way. Let's +create the custom filter shape extending the default somewhere alongside this +filter: + +```ts +export type CustomFilters = DefaultEntityFilters & { + securityTiers?: EntitySecurityTierFilter; +}; +``` + +To control this filter, we can create a React component that shows checkboxes +for the security tiers. This component will make use of the +`useEntityListProvider` hook, which accepts this extended filter type as a +[generic](https://www.typescriptlang.org/docs/handbook/2/generics.html) +parameter: + +```tsx +export const EntitySecurityTierPicker = () => { + // The securityTiers key is recognized due to the CustomFilter generic + const { + filters: { securityTiers }, + updateFilters, + } = useEntityListProvider(); + + // Toggles the value, depending on whether it's already selected + function onChange(value: string) { + const newTiers = securityTiers?.values.includes(value) + ? securityTiers.values.filter(tier => tier !== value) + : [...(securityTiers?.values ?? []), value]; + updateFilters({ + securityTiers: newTiers.length + ? new EntitySecurityTierFilter(newTiers) + : undefined, + }); + } + + const tierOptions = ['1', '2', '3']; + return ( + + Security Tier + + {tierOptions.map(tier => ( + onChange(tier)} + /> + } + label={`Tier ${tier}`} + /> + ))} + + + ); +}; +``` + +Now we can add the component to `CustomCatalogPage`: + +```diff +export const CustomCatalogPage = () => { + return ( + ... + +
+
+ +
+ ... +}; +``` + +This page itself can be exported as a routable extension in the plugin: + +```ts +export const CustomCatalogIndexPage = myPlugin.provide( + createRoutableExtension({ + 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`. + +```diff +# packages/app/src/App.tsx +const routes = ( + + +- } /> ++ } /> +``` + +The same method can be used to customize the _default_ filters with a different +interface - for such usage, the generic argument isn't needed since the filter +shape remains the same as the default. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 9bb263e79a..56be7464a5 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -43,6 +43,7 @@ "features/software-catalog/well-known-statuses", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", + "features/software-catalog/catalog-customization", "features/software-catalog/software-catalog-api" ] }, diff --git a/mkdocs.yml b/mkdocs.yml index afc0726ce1..e4f4f01e8a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Well-known Statuses: 'features/software-catalog/well-known-statuses.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' + - Catalog Customization: 'features/software-catalog/catalog-customization.md' - API: 'features/software-catalog/api.md' - Kubernetes: - Overview: 'features/kubernetes/index.md' diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index abce5a1996..53e6a45134 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -32,6 +32,8 @@ "@backstage/catalog-model": "^0.7.9", "@backstage/core": "^0.7.9", "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", "lodash": "^4.17.15", "react": "^16.13.1", @@ -41,6 +43,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.11", + "@backstage/core": "^0.7.10", "@backstage/dev-utils": "^0.1.14", "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx new file mode 100644 index 0000000000..2973074a5b --- /dev/null +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { EntityKindFilter } from '../../types'; +import { EntityKindPicker } from './EntityKindPicker'; + +describe('', () => { + it('sets the selected kind filter', async () => { + const updateFilters = jest.fn(); + render( + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + kind: new EntityKindFilter('component'), + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx new file mode 100644 index 0000000000..86980d098f --- /dev/null +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from 'react'; +import { Alert } from '@material-ui/lab'; +import { useEntityListProvider } from '../../hooks'; +import { EntityKindFilter } from '../../types'; + +type EntityKindFilterProps = { + initialFilter?: string; + hidden: boolean; +}; + +export const EntityKindPicker = ({ + initialFilter, + hidden, +}: EntityKindFilterProps) => { + const [selectedKind] = useState(initialFilter); + const { updateFilters } = useEntityListProvider(); + + useEffect(() => { + updateFilters({ + kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined, + }); + }, [selectedKind, updateFilters]); + + if (hidden) return null; + + // TODO(timbonicus): This should load available kinds from the catalog-backend, similar to + // EntityTypePicker. + + return Kind filter not yet available; +}; diff --git a/plugins/catalog/src/filter/index.ts b/plugins/catalog-react/src/components/EntityKindPicker/index.ts similarity index 57% rename from plugins/catalog/src/filter/index.ts rename to plugins/catalog-react/src/components/EntityKindPicker/index.ts index da73147ef9..ec9fde9cde 100644 --- a/plugins/catalog/src/filter/index.ts +++ b/plugins/catalog-react/src/components/EntityKindPicker/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,15 +14,4 @@ * limitations under the License. */ -export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; -export type { - EntityFilterFn, - FilterGroup, - FilterGroupState, - FilterGroupStates, - FilterGroupStatesError, - FilterGroupStatesLoading, - FilterGroupStatesReady, -} from './types'; -export { useEntityFilterGroup } from './useEntityFilterGroup'; -export { useFilteredEntities } from './useFilteredEntities'; +export { EntityKindPicker } from './EntityKindPicker'; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx new file mode 100644 index 0000000000..e601d1b8ab --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { EntityTagPicker } from './EntityTagPicker'; +import { EntityTagFilter } from '../../types'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; + +const taggedEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-1', + tags: ['tag1', 'tag2'], + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-2', + tags: ['tag3', 'tag4'], + }, + }, +]; + +describe('', () => { + it('renders all tags', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Tags')).toBeInTheDocument(); + + fireEvent.click(rendered.getByTestId('tag-picker-expand')); + taggedEntities + .flatMap(e => e.metadata.tags!) + .forEach(tag => { + expect(rendered.getByText(tag)).toBeInTheDocument(); + }); + }); + + it('adds tags to filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + + fireEvent.click(rendered.getByTestId('tag-picker-expand')); + fireEvent.click(rendered.getByText('tag1')); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1']), + }); + }); + + it('removes tags from filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + fireEvent.click(rendered.getByTestId('tag-picker-expand')); + expect(rendered.getByLabelText('tag1')).toBeChecked(); + + fireEvent.click(rendered.getByLabelText('tag1')); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: undefined, + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx new file mode 100644 index 0000000000..d57700ca7d --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo } from 'react'; +import { + Checkbox, + FormControlLabel, + TextField, + Typography, +} from '@material-ui/core'; +import { Autocomplete } from '@material-ui/lab'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { Entity } from '@backstage/catalog-model'; +import { EntityTagFilter } from '../../types'; +import { useEntityListProvider } from '../../hooks/useEntityListProvider'; + +const icon = ; +const checkedIcon = ; + +export const EntityTagPicker = () => { + const { updateFilters, backendEntities, filters } = useEntityListProvider(); + const availableTags = useMemo( + () => [ + ...new Set( + backendEntities + .flatMap((e: Entity) => e.metadata.tags) + .filter(Boolean) as string[], + ), + ], + [backendEntities], + ); + + if (!availableTags.length) return null; + + const onChange = (tags: string[]) => { + updateFilters({ + tags: tags.length ? new EntityTagFilter(tags) : undefined, + }); + }; + + return ( + <> + Tags + + multiple + options={availableTags} + value={filters.tags?.values ?? []} + onChange={(_: object, value: string[]) => onChange(value)} + renderOption={(option, { selected }) => ( + + } + label={option} + /> + )} + size="small" + popupIcon={} + renderInput={params => } + /> + + ); +}; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/index.ts b/plugins/catalog-react/src/components/EntityTagPicker/index.ts new file mode 100644 index 0000000000..5e797e1ef5 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTagPicker/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityTagPicker } from './EntityTagPicker'; diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx new file mode 100644 index 0000000000..f33feb3a57 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { capitalize } from 'lodash'; +import { + AlertApi, + alertApiRef, + ApiProvider, + ApiRegistry, +} from '@backstage/core'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { EntityTypePicker } from './EntityTypePicker'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { catalogApiRef } from '../../api'; +import { EntityKindFilter, EntityTypeFilter } from '../../types'; + +const entities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-1', + }, + spec: { + type: 'service', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-2', + }, + spec: { + type: 'website', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-3', + }, + spec: { + type: 'library', + }, + }, +]; + +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, + ], +]); + +describe('', () => { + it('renders available entity types', async () => { + const rendered = render( + + + + + , + ); + expect(rendered.getByText('Type')).toBeInTheDocument(); + + const input = rendered.getByTestId('select'); + fireEvent.click(input); + + await waitFor(() => rendered.getByText('Service')); + + entities.forEach(entity => { + expect( + rendered.getByText(capitalize(entity.spec!.type as string)), + ).toBeInTheDocument(); + }); + }); + + it('sets the selected type filter', async () => { + const updateFilters = jest.fn(); + const rendered = render( + + + + + , + ); + const input = rendered.getByTestId('select'); + fireEvent.click(input); + + await waitFor(() => rendered.getByText('Service')); + fireEvent.click(rendered.getByText('Service')); + + expect(updateFilters).toHaveBeenLastCalledWith({ + type: new EntityTypeFilter('service'), + }); + + fireEvent.click(input); + fireEvent.click(rendered.getByText('All')); + + expect(updateFilters).toHaveBeenLastCalledWith({ type: undefined }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx new file mode 100644 index 0000000000..f5a21d4284 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { capitalize } from 'lodash'; +import { Box } from '@material-ui/core'; +import { alertApiRef, Select, useApi } from '@backstage/core'; +import { useEntityTypeFilter } from '../../hooks/useEntityTypeFilter'; + +export const EntityTypePicker = () => { + const alertApi = useApi(alertApiRef); + const { error, types, selectedType, setType } = useEntityTypeFilter(); + + if (!types) return null; + + if (error) { + alertApi.post({ + message: `Failed to load entity types`, + severity: 'error', + }); + return null; + } + + const items = [ + { value: 'all', label: 'All' }, + ...types.map((type: string) => ({ + value: type, + label: capitalize(type), + })), + ]; + + return ( + +