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 (
+
+
+ );
+};
diff --git a/plugins/catalog-react/src/components/EntityTypePicker/index.ts b/plugins/catalog-react/src/components/EntityTypePicker/index.ts
new file mode 100644
index 0000000000..a5e7377f4c
--- /dev/null
+++ b/plugins/catalog-react/src/components/EntityTypePicker/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 { EntityTypePicker } from './EntityTypePicker';
diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
new file mode 100644
index 0000000000..dc3338645c
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
@@ -0,0 +1,221 @@
+/*
+ * 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,
+ RELATION_OWNED_BY,
+ UserEntity,
+} from '@backstage/catalog-model';
+import { UserListPicker } from './UserListPicker';
+import { MockEntityListContextProvider } from '../../testUtils/providers';
+import {
+ ApiProvider,
+ ApiRegistry,
+ ConfigApi,
+ configApiRef,
+ IdentityApi,
+ identityApiRef,
+ storageApiRef,
+} from '@backstage/core';
+import { EntityTagFilter, UserListFilter } from '../../types';
+import { CatalogApi } from '@backstage/catalog-client';
+import { catalogApiRef } from '../../api';
+import { MockStorageApi } from '@backstage/test-utils';
+
+const mockUser: UserEntity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'User',
+ metadata: {
+ namespace: 'default',
+ name: 'testUser',
+ },
+ spec: {
+ memberOf: [],
+ },
+};
+
+const mockConfigApi = {
+ getOptionalString: () => 'Test Company',
+} as Partial;
+
+const mockCatalogApi = {
+ getEntityByName: () => Promise.resolve(mockUser),
+} as Partial;
+
+const mockIdentityApi = {
+ getUserId: () => '',
+} as Partial;
+
+const apis = ApiRegistry.from([
+ [configApiRef, mockConfigApi],
+ [catalogApiRef, mockCatalogApi],
+ [identityApiRef, mockIdentityApi],
+ [storageApiRef, MockStorageApi.create()],
+]);
+
+const mockIsStarredEntity = (entity: Entity) =>
+ entity.metadata.name === 'component-3';
+
+jest.mock('../../hooks', () => {
+ const actual = jest.requireActual('../../hooks');
+ return {
+ ...actual,
+ useOwnUser: () => ({ value: mockUser }),
+ useStarredEntities: () => ({
+ isStarredEntity: mockIsStarredEntity,
+ }),
+ };
+});
+
+const backendEntities: Entity[] = [
+ {
+ apiVersion: '1',
+ kind: 'Component',
+ metadata: {
+ namespace: 'namespace-1',
+ name: 'component-1',
+ tags: ['tag1'],
+ },
+ relations: [
+ {
+ type: RELATION_OWNED_BY,
+ target: { kind: 'User', namespace: 'default', name: 'testUser' },
+ },
+ ],
+ },
+ {
+ apiVersion: '1',
+ kind: 'Component',
+ metadata: {
+ namespace: 'namespace-2',
+ name: 'component-2',
+ tags: ['tag1'],
+ },
+ },
+ {
+ apiVersion: '1',
+ kind: 'Component',
+ metadata: {
+ namespace: 'namespace-2',
+ name: 'component-3',
+ tags: [],
+ },
+ },
+ {
+ apiVersion: '1',
+ kind: 'Component',
+ metadata: {
+ namespace: 'namespace-2',
+ name: 'component-4',
+ tags: [],
+ },
+ relations: [
+ {
+ type: RELATION_OWNED_BY,
+ target: { kind: 'User', namespace: 'default', name: 'testUser' },
+ },
+ ],
+ },
+];
+
+describe('', () => {
+ it('renders filter groups', () => {
+ const { queryByText } = render(
+
+
+
+
+ ,
+ );
+
+ expect(queryByText('Personal')).toBeInTheDocument();
+ expect(queryByText('Test Company')).toBeInTheDocument();
+ });
+
+ it('renders filters', () => {
+ const { getAllByRole } = render(
+
+
+
+
+ ,
+ );
+
+ expect(
+ getAllByRole('menuitem').map(({ textContent }) => textContent),
+ ).toEqual(['Owned', 'Starred', 'All']);
+ });
+
+ it('includes counts alongside each filter', () => {
+ const { getAllByRole } = render(
+
+
+
+
+ ,
+ );
+
+ // Material UI renders ListItemSecondaryActions outside the
+ // menuitem itself, so we pick off the next sibling.
+ expect(
+ getAllByRole('menuitem').map(
+ ({ nextSibling }) => nextSibling?.textContent,
+ ),
+ ).toEqual(['2', '1', '4']);
+ });
+
+ it('respects other frontend filters in counts', () => {
+ const { getAllByRole } = render(
+
+
+
+
+ ,
+ );
+
+ expect(
+ getAllByRole('menuitem').map(
+ ({ nextSibling }) => nextSibling?.textContent,
+ ),
+ ).toEqual(['1', '0', '2']);
+ });
+
+ it('updates user filter when a menuitem is selected', () => {
+ const updateFilters = jest.fn();
+ const { getByText } = render(
+
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByText('Starred'));
+
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ user: new UserListFilter('starred', mockUser, mockIsStarredEntity),
+ });
+ });
+});
diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
new file mode 100644
index 0000000000..c799a317a5
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
@@ -0,0 +1,206 @@
+/*
+ * 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, { Fragment, useEffect, useMemo, useState } from 'react';
+import { compact } from 'lodash';
+import { configApiRef, IconComponent, useApi } from '@backstage/core';
+import { UserListFilter, UserListFilterKind } from '../../types';
+import {
+ useEntityListProvider,
+ useOwnUser,
+ useStarredEntities,
+} from '../../hooks';
+import {
+ Card,
+ List,
+ ListItemIcon,
+ ListItemSecondaryAction,
+ ListItemText,
+ makeStyles,
+ MenuItem,
+ Theme,
+ Typography,
+} from '@material-ui/core';
+import SettingsIcon from '@material-ui/icons/Settings';
+import StarIcon from '@material-ui/icons/Star';
+import { reduceEntityFilters } from '../../utils';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ backgroundColor: 'rgba(0, 0, 0, .11)',
+ boxShadow: 'none',
+ margin: theme.spacing(1, 0, 1, 0),
+ },
+ title: {
+ margin: theme.spacing(1, 0, 0, 1),
+ textTransform: 'uppercase',
+ fontSize: 12,
+ fontWeight: 'bold',
+ },
+ listIcon: {
+ minWidth: 30,
+ color: theme.palette.text.primary,
+ },
+ menuItem: {
+ minHeight: theme.spacing(6),
+ },
+ groupWrapper: {
+ margin: theme.spacing(1, 1, 2, 1),
+ },
+}));
+
+export type ButtonGroup = {
+ name: string;
+ items: {
+ id: 'owned' | 'starred' | 'all';
+ label: string;
+ icon?: IconComponent;
+ }[];
+};
+
+function getFilterGroups(orgName: string | undefined): ButtonGroup[] {
+ return [
+ {
+ name: 'Personal',
+ items: [
+ {
+ id: 'owned',
+ label: 'Owned',
+ icon: SettingsIcon,
+ },
+ {
+ id: 'starred',
+ label: 'Starred',
+ icon: StarIcon,
+ },
+ ],
+ },
+ {
+ name: orgName ?? 'Company',
+ items: [
+ {
+ id: 'all',
+ label: 'All',
+ },
+ ],
+ },
+ ];
+}
+
+type UserListPickerProps = {
+ initialFilter?: UserListFilterKind;
+};
+
+export const UserListPicker = ({ initialFilter }: UserListPickerProps) => {
+ const classes = useStyles();
+ const configApi = useApi(configApiRef);
+ const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
+ const filterGroups = getFilterGroups(orgName);
+
+ const { value: user } = useOwnUser();
+ const { isStarredEntity } = useStarredEntities();
+ const [selectedUserFilter, setSelectedUserFilter] = useState(initialFilter);
+
+ // Static filters; used for generating counts of potentially unselected kinds
+ const ownedFilter = useMemo(
+ () => new UserListFilter('owned', user, isStarredEntity),
+ [user, isStarredEntity],
+ );
+ const starredFilter = useMemo(
+ () => new UserListFilter('starred', user, isStarredEntity),
+ [user, isStarredEntity],
+ );
+
+ const { filters, updateFilters, backendEntities } = useEntityListProvider();
+
+ useEffect(() => {
+ updateFilters({
+ user: selectedUserFilter
+ ? new UserListFilter(selectedUserFilter, user, isStarredEntity)
+ : undefined,
+ });
+ }, [selectedUserFilter, user, isStarredEntity, updateFilters]);
+
+ // To show proper counts for each section, apply all other frontend filters _except_ the user
+ // filter that's controlled by this picker.
+ const [entitiesWithoutUserFilter, setEntitiesWithoutUserFilter] = useState(
+ backendEntities,
+ );
+ useEffect(() => {
+ const filterFn = reduceEntityFilters(
+ compact(Object.values({ ...filters, user: undefined })),
+ );
+ setEntitiesWithoutUserFilter(backendEntities.filter(filterFn));
+ }, [filters, backendEntities]);
+
+ function getFilterCount(id: UserListFilterKind) {
+ switch (id) {
+ case 'owned':
+ return entitiesWithoutUserFilter.filter(entity =>
+ ownedFilter.filterEntity(entity),
+ ).length;
+ case 'starred':
+ return entitiesWithoutUserFilter.filter(entity =>
+ starredFilter.filterEntity(entity),
+ ).length;
+ default:
+ return entitiesWithoutUserFilter.length;
+ }
+ }
+
+ return (
+
+ {filterGroups.map(group => (
+
+
+ {group.name}
+
+
+
+ {group.items.map(item => (
+
+ ))}
+
+
+
+ ))}
+
+ );
+};
diff --git a/plugins/catalog/src/components/CatalogFilter/index.ts b/plugins/catalog-react/src/components/UserListPicker/index.ts
similarity index 87%
rename from plugins/catalog/src/components/CatalogFilter/index.ts
rename to plugins/catalog-react/src/components/UserListPicker/index.ts
index 5103b16307..ad45965c17 100644
--- a/plugins/catalog/src/components/CatalogFilter/index.ts
+++ b/plugins/catalog-react/src/components/UserListPicker/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,4 +14,4 @@
* limitations under the License.
*/
-export { CatalogFilter } from './CatalogFilter';
+export { UserListPicker } from './UserListPicker';
diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts
index 5181b8f0ea..4aad517b32 100644
--- a/plugins/catalog-react/src/components/index.ts
+++ b/plugins/catalog-react/src/components/index.ts
@@ -13,6 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+export * from './EntityKindPicker';
export * from './EntityProvider';
export * from './EntityRefLink';
export * from './EntityTable';
+export * from './EntityTagPicker';
+export * from './EntityTypePicker';
+export * from './UserListPicker';
diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts
index d964c22a3b..5fbdc1ced2 100644
--- a/plugins/catalog-react/src/hooks/index.ts
+++ b/plugins/catalog-react/src/hooks/index.ts
@@ -15,5 +15,13 @@
*/
export { EntityContext, useEntity, useEntityFromUrl } from './useEntity';
export { useEntityCompoundName } from './useEntityCompoundName';
+export {
+ EntityListContext,
+ EntityListProvider,
+ useEntityListProvider,
+} from './useEntityListProvider';
+export type { DefaultEntityFilters } from './useEntityListProvider';
+export { useEntityTypeFilter } from './useEntityTypeFilter';
+export { useOwnUser } from './useOwnUser';
export { useRelatedEntities } from './useRelatedEntities';
export { useStarredEntities } from './useStarredEntities';
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
new file mode 100644
index 0000000000..fcb599888e
--- /dev/null
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
@@ -0,0 +1,200 @@
+/*
+ * 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, { PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react-hooks';
+import {
+ ApiProvider,
+ ApiRegistry,
+ ConfigApi,
+ configApiRef,
+ IdentityApi,
+ identityApiRef,
+ storageApiRef,
+} from '@backstage/core';
+import { MockStorageApi } from '@backstage/test-utils';
+import { CatalogApi } from '@backstage/catalog-client';
+import { Entity, UserEntity } from '@backstage/catalog-model';
+import {
+ EntityListProvider,
+ useEntityListProvider,
+} from './useEntityListProvider';
+import { catalogApiRef } from '../api';
+import {
+ EntityKindFilter,
+ EntityTypeFilter,
+ UserListFilter,
+ UserListFilterKind,
+} from '../types';
+import { EntityKindPicker, UserListPicker } from '../components';
+
+const mockUser: UserEntity = {
+ apiVersion: 'backstage.io/v1beta1',
+ kind: 'User',
+ metadata: {
+ name: 'guest',
+ },
+ spec: {
+ memberOf: [],
+ },
+};
+
+const entities: Entity[] = [
+ {
+ apiVersion: '1',
+ kind: 'Component',
+ metadata: {
+ name: 'component-1',
+ },
+ relations: [
+ {
+ type: 'ownedBy',
+ target: {
+ name: 'guest',
+ namespace: 'default',
+ kind: 'User',
+ },
+ },
+ ],
+ },
+ {
+ apiVersion: '1',
+ kind: 'Component',
+ metadata: {
+ name: 'component-2',
+ },
+ },
+];
+
+const mockConfigApi = {
+ getOptionalString: () => '',
+} as Partial;
+const mockIdentityApi: Partial = {
+ getUserId: () => 'guest@example.com',
+};
+const mockCatalogApi: Partial = {
+ getEntities: jest
+ .fn()
+ .mockImplementation(() => Promise.resolve({ items: entities })),
+ getEntityByName: () => Promise.resolve(mockUser),
+};
+const apis = ApiRegistry.from([
+ [configApiRef, mockConfigApi],
+ [catalogApiRef, mockCatalogApi],
+ [identityApiRef, mockIdentityApi],
+ [storageApiRef, MockStorageApi.create()],
+]);
+
+const wrapper = ({
+ userFilter,
+ children,
+}: PropsWithChildren<{ userFilter: UserListFilterKind }>) => {
+ return (
+
+
+
+
+ {children}
+
+
+ );
+};
+
+describe('', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('resolves backend filters', async () => {
+ const { result, waitForValueToChange } = renderHook(
+ () => useEntityListProvider(),
+ {
+ wrapper,
+ },
+ );
+ await waitForValueToChange(() => result.current.backendEntities);
+ expect(result.current.backendEntities.length).toBe(2);
+ expect(mockCatalogApi.getEntities).toHaveBeenCalledWith({
+ filter: { kind: 'component' },
+ });
+ });
+
+ it('resolves frontend filters', async () => {
+ const { result, waitFor } = renderHook(() => useEntityListProvider(), {
+ wrapper,
+ initialProps: {
+ userFilter: 'owned',
+ },
+ });
+ await waitFor(() => !!result.current.entities.length);
+ expect(result.current.backendEntities.length).toBe(2);
+ expect(result.current.entities.length).toBe(1);
+ });
+
+ it('does not fetch when only frontend filters change', async () => {
+ const { result, waitFor } = renderHook(() => useEntityListProvider(), {
+ wrapper,
+ });
+ await waitFor(() => !!result.current.entities.length);
+ expect(result.current.entities.length).toBe(2);
+ expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
+
+ act(() =>
+ result.current.updateFilters({
+ user: new UserListFilter('owned', mockUser, () => true),
+ }),
+ );
+ expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
+ expect(result.current.entities.length).toBe(1);
+ });
+
+ it('debounces multiple filter changes', async () => {
+ const { result, waitForNextUpdate, waitForValueToChange } = renderHook(
+ () => useEntityListProvider(),
+ {
+ wrapper,
+ },
+ );
+ await waitForValueToChange(() => result.current.backendEntities);
+ expect(result.current.backendEntities.length).toBe(2);
+ expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ result.current.updateFilters({ kind: new EntityKindFilter('component') });
+ result.current.updateFilters({ type: new EntityTypeFilter('service') });
+ });
+ await waitForNextUpdate();
+ expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(2);
+ });
+
+ it('returns an error on catalogApi failure', async () => {
+ const { result, waitForNextUpdate, waitForValueToChange } = renderHook(
+ () => useEntityListProvider(),
+ {
+ wrapper,
+ },
+ );
+ await waitForValueToChange(() => result.current.backendEntities);
+ expect(result.current.backendEntities.length).toBe(2);
+
+ mockCatalogApi.getEntities = jest.fn().mockRejectedValue('error');
+ act(() => {
+ result.current.updateFilters({ kind: new EntityKindFilter('api') });
+ });
+ await waitForNextUpdate();
+ expect(result.current.error).toBeDefined();
+ });
+});
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
new file mode 100644
index 0000000000..159c898b74
--- /dev/null
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2020 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, {
+ createContext,
+ PropsWithChildren,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import { useAsyncFn, useDebounce } from 'react-use';
+import { useApi } from '@backstage/core';
+import { Entity } from '@backstage/catalog-model';
+import { reduceCatalogFilters, reduceEntityFilters } from '../utils';
+import { catalogApiRef } from '../api';
+import {
+ EntityFilter,
+ EntityKindFilter,
+ EntityTagFilter,
+ EntityTypeFilter,
+ UserListFilter,
+} from '../types';
+import { compact, isEqual } from 'lodash';
+
+export type DefaultEntityFilters = {
+ kind?: EntityKindFilter;
+ type?: EntityTypeFilter;
+ user?: UserListFilter;
+ tags?: EntityTagFilter;
+};
+
+export type EntityListContextProps<
+ EntityFilters extends DefaultEntityFilters = DefaultEntityFilters
+> = {
+ /**
+ * The currently registered filters, adhering to the shape of DefaultEntityFilters or an extension
+ * of that default (to add custom filter types).
+ */
+ filters: EntityFilters;
+
+ /**
+ * The resolved list of catalog entities, after all filters are applied.
+ */
+ entities: Entity[];
+
+ /**
+ * The resolved list of catalog entities, after _only catalog-backend_ filters are applied.
+ */
+ backendEntities: Entity[];
+
+ /**
+ * Update one or more of the registered filters. Optional filters can be set to `undefined` to
+ * reset the filter.
+ */
+ updateFilters: (
+ filters:
+ | Partial
+ | ((prevFilters: EntityFilters) => Partial),
+ ) => void;
+
+ loading: boolean;
+ error?: Error;
+};
+
+export const EntityListContext = createContext<
+ EntityListContextProps | undefined
+>(undefined);
+
+export const EntityListProvider = ({
+ children,
+}: PropsWithChildren<{}>) => {
+ const catalogApi = useApi(catalogApiRef);
+
+ const [filters, setFilters] = useState({} as EntityFilters);
+ const [entities, setEntities] = useState([]);
+ const [backendEntities, setBackendEntities] = useState([]);
+
+ // Store resolved catalog-backend filters and deep compare on filter updates, to avoid refetching
+ // when only frontend filters change
+ const [backendFilters, setBackendFilters] = useState<
+ Record
+ >(reduceCatalogFilters(compact(Object.values(filters))));
+
+ useEffect(() => {
+ const newBackendFilters = reduceCatalogFilters(
+ compact(Object.values(filters)),
+ );
+ if (!isEqual(newBackendFilters, backendFilters)) {
+ setBackendFilters(newBackendFilters);
+ }
+ }, [backendFilters, filters]);
+
+ const [{ loading, error }, refresh] = useAsyncFn(async () => {
+ // TODO(timbonicus): should limit fields here, but would need filter fields + table columns
+ const items = await catalogApi
+ .getEntities({
+ filter: backendFilters,
+ })
+ .then(response => response.items);
+ setBackendEntities(items);
+ }, [backendFilters, catalogApi]);
+
+ // Slight debounce on the catalog-backend call, to prevent eager refresh on multiple programmatic
+ // filter changes.
+ useDebounce(refresh, 10, [backendFilters]);
+
+ // Apply frontend filters
+ useEffect(() => {
+ const resolvedEntities = (backendEntities ?? []).filter(
+ reduceEntityFilters(compact(Object.values(filters))),
+ );
+ setEntities(resolvedEntities);
+ }, [backendEntities, filters]);
+
+ const updateFilters = useCallback(
+ (
+ update:
+ | Partial
+ | ((prevFilters: EntityFilters) => Partial),
+ ) => {
+ if (typeof update === 'function') {
+ setFilters(prevFilters => ({ ...prevFilters, ...update(prevFilters) }));
+ } else {
+ setFilters(prevFilters => ({ ...prevFilters, ...update }));
+ }
+ },
+ [],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export function useEntityListProvider<
+ EntityFilters extends DefaultEntityFilters
+>(): EntityListContextProps {
+ const context = useContext(EntityListContext);
+ if (!context)
+ throw new Error(
+ 'useEntityListProvider must be used within EntityListProvider',
+ );
+ return context;
+}
diff --git a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx
new file mode 100644
index 0000000000..c5e03b90c6
--- /dev/null
+++ b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx
@@ -0,0 +1,97 @@
+/*
+ * 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 { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAsync } from 'react-use';
+import { useApi } from '@backstage/core';
+import { catalogApiRef } from '../api';
+import {
+ DefaultEntityFilters,
+ useEntityListProvider,
+} from './useEntityListProvider';
+import { EntityTypeFilter } from '../types';
+
+type EntityTypeReturn = {
+ loading: boolean;
+ error?: Error;
+ types: string[];
+ selectedType: string | undefined;
+ setType: (type: string | undefined) => void;
+};
+
+/**
+ * A hook built on top of `useEntityListProvider` for enabling selection of valid `spec.type` values
+ * based on the selected EntityKindFilter.
+ */
+export function useEntityTypeFilter(): EntityTypeReturn {
+ const catalogApi = useApi(catalogApiRef);
+ const {
+ filters: { kind: kindFilter, type: typeFilter },
+ updateFilters,
+ } = useEntityListProvider();
+
+ const [types, setTypes] = useState([]);
+ const kind = useMemo(() => kindFilter?.value, [kindFilter]);
+
+ // Load all valid spec.type values straight from the catalogApi, paying attention to only the
+ // kind filter for a complete list.
+ const { error, loading, value: entities } = useAsync(async () => {
+ if (kind) {
+ const items = await catalogApi
+ .getEntities({
+ filter: { kind },
+ fields: ['spec.type'],
+ })
+ .then(response => response.items);
+ return items;
+ }
+ return [];
+ }, [kind, catalogApi]);
+
+ useEffect(() => {
+ // Resolve the unique set of types from returned entities; could be optimized by a new endpoint
+ // in the catalog-backend that does this, rather than loading entities with redundant types.
+ const newTypes = [
+ ...new Set(
+ (entities ?? []).map(e => e.spec?.type).filter(Boolean) as string[],
+ ),
+ ].sort();
+ setTypes(newTypes);
+
+ // Reset type filter if no longer applicable
+ updateFilters((oldFilters: DefaultEntityFilters) =>
+ oldFilters.type && !newTypes.includes(oldFilters.type.value)
+ ? { type: undefined }
+ : {},
+ );
+ }, [updateFilters, entities]);
+
+ const setType = useCallback(
+ (type: string | undefined) =>
+ updateFilters({
+ type: type === undefined ? undefined : new EntityTypeFilter(type),
+ }),
+ [updateFilters],
+ );
+
+ return {
+ loading,
+ error,
+ types,
+ selectedType: typeFilter?.value,
+ setType,
+ };
+}
diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog-react/src/hooks/useOwnUser.ts
similarity index 95%
rename from plugins/catalog/src/components/useOwnUser.ts
rename to plugins/catalog-react/src/hooks/useOwnUser.ts
index 29d8a0d11f..d79cfbe92c 100644
--- a/plugins/catalog/src/components/useOwnUser.ts
+++ b/plugins/catalog-react/src/hooks/useOwnUser.ts
@@ -16,9 +16,9 @@
import { UserEntity } from '@backstage/catalog-model';
import { identityApiRef, useApi } from '@backstage/core';
-import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
+import { catalogApiRef } from '../api';
/**
* Get the catalog User entity (if any) that matches the logged-in user.
diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts
index af3eca4e0a..8333925652 100644
--- a/plugins/catalog-react/src/index.ts
+++ b/plugins/catalog-react/src/index.ts
@@ -24,4 +24,6 @@ export {
entityRouteRef,
rootRoute,
} from './routes';
+export * from './testUtils';
+export * from './types';
export * from './utils';
diff --git a/plugins/catalog-react/src/testUtils/index.ts b/plugins/catalog-react/src/testUtils/index.ts
new file mode 100644
index 0000000000..090e9190e4
--- /dev/null
+++ b/plugins/catalog-react/src/testUtils/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 { MockEntityListContextProvider } from './providers';
diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx
new file mode 100644
index 0000000000..956df15c1f
--- /dev/null
+++ b/plugins/catalog-react/src/testUtils/providers.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 React, { PropsWithChildren } from 'react';
+import {
+ EntityListContext,
+ EntityListContextProps,
+} from '../hooks/useEntityListProvider';
+
+export const MockEntityListContextProvider = ({
+ children,
+ value,
+}: PropsWithChildren<{ value: Partial }>) => {
+ const defaultContext: EntityListContextProps = {
+ entities: [],
+ backendEntities: [],
+ updateFilters: jest.fn(),
+ filters: {},
+ loading: false,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/plugins/catalog-react/src/types.ts b/plugins/catalog-react/src/types.ts
new file mode 100644
index 0000000000..98ea2fb9b0
--- /dev/null
+++ b/plugins/catalog-react/src/types.ts
@@ -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 { Entity, UserEntity } from '@backstage/catalog-model';
+import { isOwnerOf } from './utils';
+
+export type EntityFilter = {
+ /**
+ * Get filters to add to the catalog-backend request. These are a dot-delimited field with
+ * value(s) to accept, extracted on the backend by parseEntityFilterParams. For example:
+ * { field: 'kind', values: ['component'] }
+ * { field: 'metadata.name', values: ['component-1', 'component-2'] }
+ */
+ getCatalogFilters?: () => Record;
+
+ /**
+ * Filter entities on the frontend after a catalog-backend request. This function will be called
+ * with each backend-resolved entity. This is used when frontend information is required for
+ * filtering, such as a user's starred entities.
+ *
+ * @param entity
+ * @param env
+ */
+ filterEntity?: (entity: Entity) => boolean;
+};
+
+export class EntityKindFilter implements EntityFilter {
+ constructor(readonly value: string) {}
+
+ getCatalogFilters(): Record {
+ return { kind: this.value };
+ }
+}
+
+export class EntityTypeFilter implements EntityFilter {
+ constructor(readonly value: string) {}
+
+ getCatalogFilters(): Record {
+ return { 'spec.type': this.value };
+ }
+}
+
+export class EntityTagFilter implements EntityFilter {
+ constructor(readonly values: string[]) {}
+
+ filterEntity(entity: Entity): boolean {
+ return this.values.every(v => (entity.metadata.tags ?? []).includes(v));
+ }
+}
+
+export type UserListFilterKind = 'owned' | 'starred' | 'all';
+export class UserListFilter implements EntityFilter {
+ constructor(
+ readonly value: UserListFilterKind,
+ readonly user: UserEntity | undefined,
+ readonly isStarredEntity: (entity: Entity) => boolean,
+ ) {}
+
+ filterEntity(entity: Entity): boolean {
+ switch (this.value) {
+ case 'owned':
+ return this.user !== undefined && isOwnerOf(this.user, entity);
+ case 'starred':
+ return this.isStarredEntity(entity);
+ default:
+ return true;
+ }
+ }
+}
diff --git a/plugins/catalog-react/src/utils/filters.ts b/plugins/catalog-react/src/utils/filters.ts
new file mode 100644
index 0000000000..73f1763bb1
--- /dev/null
+++ b/plugins/catalog-react/src/utils/filters.ts
@@ -0,0 +1,38 @@
+/*
+ * 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 { Entity } from '@backstage/catalog-model';
+import { EntityFilter } from '../types';
+
+export function reduceCatalogFilters(
+ filters: EntityFilter[],
+): Record {
+ return filters.reduce((compoundFilter, filter) => {
+ return {
+ ...compoundFilter,
+ ...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}),
+ };
+ }, {} as Record);
+}
+
+export function reduceEntityFilters(
+ filters: EntityFilter[],
+): (entity: Entity) => boolean {
+ return (entity: Entity) =>
+ filters.every(
+ filter => !filter.filterEntity || filter.filterEntity(entity),
+ );
+}
diff --git a/plugins/catalog-react/src/utils/index.ts b/plugins/catalog-react/src/utils/index.ts
index 2efb35703e..8d045e08ac 100644
--- a/plugins/catalog-react/src/utils/index.ts
+++ b/plugins/catalog-react/src/utils/index.ts
@@ -13,5 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+export * from './filters';
export { getEntityRelations } from './getEntityRelations';
export { isOwnerOf } from './isOwnerOf';
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 6cbf033a86..2896d82fe3 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -44,6 +44,7 @@
"@types/react": "^16.9",
"classnames": "^2.2.6",
"git-url-parse": "^11.4.4",
+ "lodash": "^4.17.21",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "6.1.0",
diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx
deleted file mode 100644
index 0cc2108985..0000000000
--- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-/*
- * Copyright 2020 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 { CatalogApi } from '@backstage/catalog-client';
-import { Entity } from '@backstage/catalog-model';
-import {
- ApiProvider,
- ApiRegistry,
- IdentityApi,
- identityApiRef,
- storageApiRef,
-} from '@backstage/core';
-import { catalogApiRef } from '@backstage/plugin-catalog-react';
-import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
-import { fireEvent, render, waitFor } from '@testing-library/react';
-import React from 'react';
-import { EntityFilterGroupsProvider } from '../../filter';
-import { ButtonGroup, CatalogFilter } from './CatalogFilter';
-
-describe('Catalog Filter', () => {
- const catalogApi: Partial = {
- getEntities: () =>
- Promise.resolve({
- items: [
- {
- apiVersion: 'backstage.io/v1alpha1',
- kind: 'Component',
- metadata: {
- name: 'Entity1',
- },
- spec: {
- owner: 'tools@example.com',
- type: 'service',
- },
- },
- {
- apiVersion: 'backstage.io/v1alpha1',
- kind: 'Component',
- metadata: {
- name: 'Entity2',
- },
- spec: {
- owner: 'not-tools@example.com',
- type: 'service',
- },
- },
- ] as Entity[],
- }),
- };
-
- const identityApi: Partial = {
- getUserId: () => 'tools@example.com',
- };
-
- const renderWrapped = (children: React.ReactNode) =>
- render(
- wrapInTestApp(
-
- {children},
- ,
- ),
- );
-
- it('should render the different groups', async () => {
- const mockGroups: ButtonGroup[] = [
- { name: 'Test Group 1', items: [] },
- { name: 'Test Group 2', items: [] },
- ];
- const { findByText } = renderWrapped(
- ,
- );
- for (const group of mockGroups) {
- expect(await findByText(group.name)).toBeInTheDocument();
- }
- });
-
- it('should render the different items and their names', async () => {
- const mockGroups: ButtonGroup[] = [
- {
- name: 'Test Group 1',
- items: [
- {
- id: 'all',
- label: 'First Label',
- filterFn: () => true,
- },
- {
- id: 'starred',
- label: 'Second Label',
- filterFn: () => false,
- },
- ],
- },
- ];
-
- const { findByText } = renderWrapped(
- ,
- );
-
- for (const item of mockGroups[0].items) {
- expect(await findByText(item.label)).toBeInTheDocument();
- }
- });
-
- it('selects the first item if no desired initial one is set', async () => {
- const mockGroups: ButtonGroup[] = [
- {
- name: 'Test Group 1',
- items: [
- {
- id: 'all',
- label: 'First Label',
- filterFn: () => true,
- },
- {
- id: 'starred',
- label: 'Second Label',
- filterFn: () => false,
- },
- ],
- },
- ];
-
- const onChange = jest.fn();
-
- renderWrapped(
- ,
- );
-
- await waitFor(() => {
- expect(onChange).toHaveBeenLastCalledWith({
- id: 'all',
- label: 'First Label',
- });
- });
- });
-
- it('selects the initial item', async () => {
- const mockGroups: ButtonGroup[] = [
- {
- name: 'Test Group 1',
- items: [
- {
- id: 'all',
- label: 'First Label',
- filterFn: () => true,
- },
- {
- id: 'starred',
- label: 'Second Label',
- filterFn: () => false,
- },
- ],
- },
- ];
-
- const onChange = jest.fn();
-
- renderWrapped(
- ,
- );
-
- await waitFor(() => {
- expect(onChange).toHaveBeenLastCalledWith({
- id: 'starred',
- label: 'Second Label',
- });
- });
- });
-
- it('can change the selected item', async () => {
- const mockGroups: ButtonGroup[] = [
- {
- name: 'Test Group 1',
- items: [
- {
- id: 'all',
- label: 'First Label',
- filterFn: () => true,
- },
- {
- id: 'starred',
- label: 'Second Label',
- filterFn: () => false,
- },
- ],
- },
- ];
-
- const onChange = jest.fn();
-
- const { findByText } = renderWrapped(
- ,
- );
-
- await waitFor(() => {
- expect(onChange).toHaveBeenLastCalledWith({
- id: 'all',
- label: 'First Label',
- });
- });
-
- fireEvent.click(await findByText('Second Label'));
-
- await waitFor(() => {
- expect(onChange).toHaveBeenLastCalledWith({
- id: 'starred',
- label: 'Second Label',
- });
- });
- });
-
- it('displays match counts properly', async () => {
- const mockGroups: ButtonGroup[] = [
- {
- name: 'Test Group 1',
- items: [
- {
- id: 'owned',
- label: 'First Label',
- filterFn: entity => entity.spec?.owner === 'tools@example.com',
- },
- ],
- },
- ];
-
- const { findByText } = renderWrapped(
- ,
- );
-
- expect(await findByText('1')).toBeInTheDocument();
- });
-});
diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx
deleted file mode 100644
index 6de4c318b5..0000000000
--- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- * Copyright 2020 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 { Entity } from '@backstage/catalog-model';
-import { IconComponent } from '@backstage/core';
-import {
- Card,
- List,
- ListItemIcon,
- ListItemSecondaryAction,
- ListItemText,
- makeStyles,
- MenuItem,
- Theme,
- Typography,
-} from '@material-ui/core';
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import { FilterGroup, useEntityFilterGroup } from '../../filter';
-
-export type ButtonGroup = {
- name: string;
- items: {
- id: string;
- label: string;
- icon?: IconComponent;
- filterFn: (entity: Entity) => boolean;
- }[];
-};
-
-const useStyles = makeStyles(theme => ({
- root: {
- backgroundColor: 'rgba(0, 0, 0, .11)',
- boxShadow: 'none',
- },
- title: {
- margin: theme.spacing(1, 0, 0, 1),
- textTransform: 'uppercase',
- fontSize: 12,
- fontWeight: 'bold',
- },
- listIcon: {
- minWidth: 30,
- color: theme.palette.text.primary,
- },
- menuItem: {
- minHeight: theme.spacing(6),
- },
- groupWrapper: {
- margin: theme.spacing(1, 1, 2, 1),
- },
- menuTitle: {
- fontWeight: 500,
- },
-}));
-
-type OnChangeCallback = (item: { id: string; label: string }) => void;
-
-type Props = {
- buttonGroups: ButtonGroup[];
- initiallySelected: string;
- onChange?: OnChangeCallback;
-};
-
-/**
- * Sidebar filter type and human readable label for it. owned/starred/all
- */
-export type CatalogFilterType = {
- id: string;
- label: string;
-};
-
-/**
- * The main filter group in the sidebar, toggling owned/starred/all.
- */
-export const CatalogFilter = ({
- buttonGroups,
- onChange,
- initiallySelected,
-}: Props) => {
- const classes = useStyles();
- const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
- buttonGroups,
- initiallySelected,
- );
-
- const onChangeRef = useRef();
- useEffect(() => {
- onChangeRef.current = onChange;
- }, [onChange]);
-
- const setCurrent = useCallback(
- (item: { id: string; label: string }) => {
- setCurrentFilter(item.id);
- onChangeRef.current?.({ id: item.id, label: item.label });
- },
- [setCurrentFilter],
- );
-
- // Make one initial onChange to inform the surroundings about the selected
- // item
- useEffect(() => {
- const items = buttonGroups.flatMap(g => g.items);
- const item = items.find(i => i.id === initiallySelected) || items[0];
- if (item) {
- onChangeRef.current?.({ id: item.id, label: item.label });
- }
- // intentionally only happens on startup
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return (
-
- {buttonGroups.map(group => (
-
-
- {group.name}
-
-
-
- {group.items.map(item => (
-
- ))}
-
-
-
- ))}
-
- );
-};
-
-function useFilter(
- buttonGroups: ButtonGroup[],
- initiallySelected: string,
-): {
- currentFilter: string;
- setCurrentFilter: (filterId: string) => void;
- getFilterCount: (filterId: string) => number | undefined;
-} {
- const [currentFilter, setCurrentFilter] = useState(initiallySelected);
-
- const filterGroup = useMemo(
- () => ({
- filters: Object.fromEntries(
- buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]),
- ),
- }),
- [buttonGroups],
- );
-
- const { setSelectedFilters, state } = useEntityFilterGroup(
- 'primary-sidebar',
- filterGroup,
- [initiallySelected],
- );
-
- const setCurrent = useCallback(
- (filterId: string) => {
- setCurrentFilter(filterId);
- setSelectedFilters([filterId]);
- },
- [setCurrentFilter, setSelectedFilters],
- );
-
- const getFilterCount = useCallback(
- (filterId: string) => {
- if (state.type !== 'ready') {
- return undefined;
- }
- return state.state.filters[filterId].matchCount;
- },
- [state],
- );
-
- return {
- currentFilter,
- setCurrentFilter: setCurrent,
- getFilterCount,
- };
-}
diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
index 03f211a7bb..358c7f7dc8 100644
--- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
+++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
@@ -29,10 +29,13 @@ import {
storageApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
-import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
-import { fireEvent, render, waitFor } from '@testing-library/react';
+import {
+ MockStorageApi,
+ renderWithEffects,
+ wrapInTestApp,
+} from '@backstage/test-utils';
+import { fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
-import { EntityFilterGroupsProvider } from '../../filter';
import { createComponentRouteRef } from '../../routes';
import { CatalogPage } from './CatalogPage';
@@ -106,7 +109,7 @@ describe('CatalogPage', () => {
};
const renderWrapped = (children: React.ReactNode) =>
- render(
+ renderWithEffects(
wrapInTestApp(
{
[storageApiRef, MockStorageApi.create()],
])}
>
- {children},
+ {children}
,
{
mountedRoutes: {
@@ -129,35 +132,35 @@ describe('CatalogPage', () => {
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
- const { findByText, getByText } = renderWrapped();
- expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
- fireEvent.click(getByText(/All/));
- expect(await findByText(/All \(2\)/)).toBeInTheDocument();
+ const { getByText, getByTestId } = await renderWrapped();
+ expect(getByText(/Owned \(1\)/)).toBeInTheDocument();
+ fireEvent.click(getByTestId('user-picker-all'));
+ expect(getByText(/All \(2\)/)).toBeInTheDocument();
});
it('should set initial filter correctly', async () => {
- const { findByText } = renderWrapped(
+ const { getByText } = await renderWrapped(
,
);
- expect(await findByText(/All \(2\)/)).toBeInTheDocument();
+ expect(getByText(/All \(2\)/)).toBeInTheDocument();
});
// 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 selectedfilter', async () => {
- const { findByText, findAllByTitle, getByText } = renderWrapped(
+ const { getByText, findAllByTitle, getByTestId } = await renderWrapped(
,
);
- expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
- expect(await findByText(/Starred/)).toBeInTheDocument();
- fireEvent.click(getByText(/Starred/));
- expect(await findByText(/Starred \(0\)/)).toBeInTheDocument();
- fireEvent.click(getByText(/All/));
- expect(await findByText(/All \(2\)/)).toBeInTheDocument();
+ expect(getByText(/Owned \(1\)/)).toBeInTheDocument();
+ expect(getByText(/Starred/)).toBeInTheDocument();
+ fireEvent.click(getByTestId('user-picker-starred'));
+ expect(getByText(/Starred \(0\)/)).toBeInTheDocument();
+ fireEvent.click(getByTestId('user-picker-all'));
+ expect(getByText(/All \(2\)/)).toBeInTheDocument();
const starredIcons = await findAllByTitle('Add to favorites');
fireEvent.click(starredIcons[0]);
- expect(await findByText(/All \(2\)/)).toBeInTheDocument();
+ expect(getByText(/All \(2\)/)).toBeInTheDocument();
- fireEvent.click(getByText(/Starred/));
- waitFor(() => expect(findByText(/Starred \(1\)/)).toBeInTheDocument());
+ fireEvent.click(getByTestId('user-picker-starred'));
+ waitFor(() => expect(getByText(/Starred \(1\)/)).toBeInTheDocument());
});
});
diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
index bbb26d7be7..80569f4de6 100644
--- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
+++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
@@ -14,40 +14,27 @@
* limitations under the License.
*/
+import React from 'react';
+import { makeStyles } from '@material-ui/core';
import {
- configApiRef,
Content,
ContentHeader,
- errorApiRef,
SupportButton,
TableColumn,
- useApi,
- useRouteRef,
} from '@backstage/core';
import {
- catalogApiRef,
- isOwnerOf,
- useStarredEntities,
+ EntityKindPicker,
+ EntityListProvider,
+ EntityTagPicker,
+ EntityTypePicker,
+ UserListFilterKind,
+ UserListPicker,
} from '@backstage/plugin-catalog-react';
-import { Button, makeStyles } from '@material-ui/core';
-import SettingsIcon from '@material-ui/icons/Settings';
-import StarIcon from '@material-ui/icons/Star';
-import React, { useCallback, useMemo, useState } from 'react';
-import { Link as RouterLink } from 'react-router-dom';
-import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
-import { createComponentRouteRef } from '../../routes';
-import {
- ButtonGroup,
- CatalogFilter,
- CatalogFilterType,
-} from '../CatalogFilter/CatalogFilter';
-import { CatalogTable } from '../CatalogTable/CatalogTable';
+import { CatalogTable } from '../CatalogTable';
import { EntityRow } from '../CatalogTable/types';
-import { ResultsFilter } from '../ResultsFilter/ResultsFilter';
-import { useOwnUser } from '../useOwnUser';
import CatalogLayout from './CatalogLayout';
-import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
+import { CreateComponentButton } from '../CreateComponentButton';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -62,170 +49,35 @@ const useStyles = makeStyles(theme => ({
}));
export type CatalogPageProps = {
- initiallySelectedFilter?: string;
+ initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn[];
};
-const CatalogPageContents = (props: CatalogPageProps) => {
+export const CatalogPage = ({
+ initiallySelectedFilter = 'owned',
+ columns,
+}: CatalogPageProps) => {
const styles = useStyles();
- const {
- loading,
- error,
- reload,
- matchingEntities,
- availableTags,
- isCatalogEmpty,
- } = useFilteredEntities();
- const configApi = useApi(configApiRef);
- const catalogApi = useApi(catalogApiRef);
- const errorApi = useApi(errorApiRef);
- const { isStarredEntity } = useStarredEntities();
- const [selectedTab, setSelectedTab] = useState();
- const [
- selectedSidebarItem,
- setSelectedSidebarItem,
- ] = useState();
- const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
- const initiallySelectedFilter =
- selectedSidebarItem?.id ?? props.initiallySelectedFilter ?? 'owned';
- const createComponentLink = useRouteRef(createComponentRouteRef);
- const addMockData = useCallback(async () => {
- try {
- const promises: Promise[] = [];
- const root = configApi.getConfig('catalog.exampleEntityLocations');
- for (const type of root.keys()) {
- for (const target of root.getStringArray(type)) {
- promises.push(catalogApi.addLocation({ target }));
- }
- }
- await Promise.all(promises);
- await reload();
- } catch (err) {
- errorApi.post(err);
- }
- }, [catalogApi, configApi, errorApi, reload]);
-
- const tabs = useMemo(
- () => [
- {
- id: 'service',
- label: 'Services',
- },
- {
- id: 'website',
- label: 'Websites',
- },
- {
- id: 'library',
- label: 'Libraries',
- },
- {
- id: 'documentation',
- label: 'Documentation',
- },
- {
- id: 'other',
- label: 'Other',
- },
- ],
- [],
- );
-
- const { value: user } = useOwnUser();
-
- const filterGroups = useMemo(
- () => [
- {
- name: 'Personal',
- items: [
- {
- id: 'owned',
- label: 'Owned',
- icon: SettingsIcon,
- filterFn: entity => user !== undefined && isOwnerOf(user, entity),
- },
- {
- id: 'starred',
- label: 'Starred',
- icon: StarIcon,
- filterFn: isStarredEntity,
- },
- ],
- },
- {
- name: orgName,
- items: [
- {
- id: 'all',
- label: 'All',
- filterFn: () => true,
- },
- ],
- },
- ],
- [isStarredEntity, orgName, user],
- );
-
- const showAddExampleEntities =
- configApi.has('catalog.exampleEntityLocations') && isCatalogEmpty;
return (
- setSelectedTab(label)}
- />
-
- {createComponentLink && (
-
- )}
- {showAddExampleEntities && (
-
- )}
+
+ All your software catalog entities