diff --git a/.changeset/chilly-ants-taste.md b/.changeset/chilly-ants-taste.md new file mode 100644 index 0000000000..e9422e0317 --- /dev/null +++ b/.changeset/chilly-ants-taste.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Add `EntityLifecyclePicker` and `EntityOwnerPicker` UI components to allow filtering by `spec.lifecycle` and `spec.owner` on catalog-related pages. diff --git a/.changeset/fifty-tigers-learn.md b/.changeset/fifty-tigers-learn.md new file mode 100644 index 0000000000..465c8c54a5 --- /dev/null +++ b/.changeset/fifty-tigers-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': minor +--- + +Rework `ApiExplorerPage` to utilize `EntityListProvider` to provide a consistent UI with the `CatalogIndexPage` which now exposes support for starring entities, pagination, and customizing columns. diff --git a/.changeset/wild-ghosts-deny.md b/.changeset/wild-ghosts-deny.md new file mode 100644 index 0000000000..efd10dbc25 --- /dev/null +++ b/.changeset/wild-ghosts-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Export `CatalogTableRow` type diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index fd3bc353e9..bc937e2aff 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -32,6 +32,7 @@ "@asyncapi/react-component": "^0.23.0", "@backstage/catalog-model": "^0.8.2", "@backstage/core": "^0.7.11", + "@backstage/plugin-catalog": "^0.6.2", "@backstage/plugin-catalog-react": "^0.2.2", "@backstage/theme": "^0.2.8", "@material-icons/font": "^1.0.2", diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index 223734504b..c52c5b88fc 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, @@ -55,6 +55,19 @@ describe('ApiCatalogPage', () => { }), getLocationByEntity: () => Promise.resolve({ id: 'id', type: 'github', target: 'url' }), + getEntityByName: async entityName => { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: entityName.name }, + relations: [ + { + type: RELATION_MEMBER_OF, + target: { namespace: 'default', kind: 'Group', name: 'tools' }, + }, + ], + }; + }, }; const configApi: ConfigApi = new ConfigReader({ diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index 1ed47811ef..6d551ea0c6 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -18,36 +18,46 @@ import { Content, ContentHeader, SupportButton, - useApi, + TableColumn, useRouteRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { Button } from '@material-ui/core'; +import { + EntityKindPicker, + EntityLifecyclePicker, + EntityListProvider, + EntityOwnerPicker, + EntityTagPicker, + EntityTypePicker, + UserListFilterKind, + UserListPicker, +} from '@backstage/plugin-catalog-react'; +import { CatalogTable, CatalogTableRow } from '@backstage/plugin-catalog'; +import { Button, makeStyles } from '@material-ui/core'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { useAsync } from 'react-use'; import { createComponentRouteRef } from '../../routes'; -import { ApiExplorerTable } from '../ApiExplorerTable'; import { ApiExplorerLayout } from './ApiExplorerLayout'; -export const ApiExplorerPage = () => { +const useStyles = makeStyles(theme => ({ + contentWrapper: { + display: 'grid', + gridTemplateAreas: "'filters' 'table'", + gridTemplateColumns: '250px 1fr', + gridColumnGap: theme.spacing(2), + }, +})); + +export type ApiExplorerPageProps = { + initiallySelectedFilter?: UserListFilterKind; + columns?: TableColumn[]; +}; + +export const ApiExplorerPage = ({ + initiallySelectedFilter = 'all', + columns, +}: ApiExplorerPageProps) => { + const styles = useStyles(); const createComponentLink = useRouteRef(createComponentRouteRef); - const catalogApi = useApi(catalogApiRef); - const { loading, error, value: catalogResponse } = useAsync(() => { - return catalogApi.getEntities({ - filter: { kind: 'API' }, - fields: [ - 'apiVersion', - 'kind', - 'metadata', - 'relations', - 'spec.lifecycle', - 'spec.owner', - 'spec.type', - 'spec.system', - ], - }); - }, [catalogApi]); return ( @@ -65,11 +75,19 @@ export const ApiExplorerPage = () => { )} All your APIs - +
+ +
+
+ +
+
); diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx deleted file mode 100644 index bcb310be46..0000000000 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx +++ /dev/null @@ -1,81 +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 { ApiProvider, ApiRegistry } from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render } from '@testing-library/react'; -import * as React from 'react'; -import { apiDocsConfigRef } from '../../config'; -import { ApiExplorerTable } from './ApiExplorerTable'; - -const entities: Entity[] = [ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'API', - metadata: { name: 'api1' }, - spec: { type: 'openapi' }, - }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'API', - metadata: { name: 'api2' }, - spec: { type: 'openapi' }, - }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'API', - metadata: { name: 'api3' }, - spec: { type: 'grpc' }, - }, -]; - -const apiRegistry = ApiRegistry.with(apiDocsConfigRef, { - getApiDefinitionWidget: () => undefined, -}); - -describe('ApiCatalogTable component', () => { - it('should render error message when error is passed in props', async () => { - const rendered = render( - wrapInTestApp( - - - , - ), - ); - const errorMessage = await rendered.findByText( - /Could not fetch catalog entities./, - ); - expect(errorMessage).toBeInTheDocument(); - }); - - it('should display entity names when loading has finished and no error occurred', async () => { - const rendered = render( - wrapInTestApp( - - - , - ), - ); - expect(rendered.getByText(/api1/)).toBeInTheDocument(); - expect(rendered.getByText(/api2/)).toBeInTheDocument(); - expect(rendered.getByText(/api3/)).toBeInTheDocument(); - }); -}); diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx deleted file mode 100644 index 78b5f1d240..0000000000 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ /dev/null @@ -1,216 +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 { - ApiEntityV1alpha1, - Entity, - EntityName, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; -import { - CodeSnippet, - OverflowTooltip, - Table, - TableColumn, - TableFilter, - TableState, - useQueryParamState, - WarningPanel, -} from '@backstage/core'; -import { - EntityRefLink, - EntityRefLinks, - formatEntityRefTitle, - getEntityRelations, -} from '@backstage/plugin-catalog-react'; -import { Chip } from '@material-ui/core'; -import React from 'react'; -import { ApiTypeTitle } from '../ApiDefinitionCard'; - -type EntityRow = { - entity: ApiEntityV1alpha1; - resolved: { - name: string; - partOfSystemRelationTitle?: string; - partOfSystemRelations: EntityName[]; - ownedByRelationsTitle?: string; - ownedByRelations: EntityName[]; - }; -}; - -const columns: TableColumn[] = [ - { - title: 'Name', - field: 'resolved.name', - highlight: true, - render: ({ entity }) => ( - - ), - }, - { - title: 'System', - field: 'resolved.partOfSystemRelationTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Owner', - field: 'resolved.ownedByRelationsTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Lifecycle', - field: 'entity.spec.lifecycle', - }, - { - title: 'Type', - field: 'entity.spec.type', - render: ({ entity }) => , - }, - { - title: 'Description', - field: 'entity.metadata.description', - render: ({ entity }) => ( - - ), - width: 'auto', - }, - { - title: 'Tags', - field: 'entity.metadata.tags', - cellStyle: { - padding: '0px 16px 0px 20px', - }, - render: ({ entity }) => ( - <> - {entity.metadata.tags && - entity.metadata.tags.map(t => ( - - ))} - - ), - }, -]; - -const filters: TableFilter[] = [ - { - column: 'Owner', - type: 'select', - }, - { - column: 'Type', - type: 'multiple-select', - }, - { - column: 'Lifecycle', - type: 'multiple-select', - }, - { - column: 'Tags', - type: 'checkbox-tree', - }, -]; - -type ExplorerTableProps = { - entities: Entity[]; - loading: boolean; - error?: any; -}; - -export const ApiExplorerTable = ({ - entities, - loading, - error, -}: ExplorerTableProps) => { - const [queryParamState, setQueryParamState] = useQueryParamState( - 'apiTable', - ); - - if (error) { - return ( - - - - ); - } - - const rows = entities.map(entity => { - const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { - kind: 'system', - }); - const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); - - return { - entity: entity as ApiEntityV1alpha1, - resolved: { - name: formatEntityRefTitle(entity, { - defaultKind: 'API', - }), - ownedByRelationsTitle: ownedByRelations - .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) - .join(', '), - ownedByRelations, - partOfSystemRelationTitle: partOfSystemRelations - .map(r => - formatEntityRefTitle(r, { - defaultKind: 'system', - }), - ) - .join(', '), - partOfSystemRelations, - }, - }; - }); - - return ( - - isLoading={loading} - columns={columns} - options={{ - paging: true, - pageSize: 20, - pageSizeOptions: [20, 50, 100], - actionsColumnIndex: -1, - loadingType: 'linear', - padding: 'dense', - showEmptyDataSourceMessage: !loading, - }} - data={rows} - filters={filters} - initialState={queryParamState} - onStateChange={setQueryParamState} - /> - ); -}; diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx new file mode 100644 index 0000000000..b8fa9ec1c8 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx @@ -0,0 +1,139 @@ +/* + * 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 { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { EntityLifecycleFilter } from '../../types'; +import { EntityLifecyclePicker } from './EntityLifecyclePicker'; + +const sampleEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-1', + }, + spec: { + lifecycle: 'production', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-2', + }, + spec: { + lifecycle: 'experimental', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-3', + }, + spec: { + lifecycle: 'experimental', + }, + }, +]; + +describe('', () => { + it('renders all lifecycles', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Lifecycle')).toBeInTheDocument(); + + fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); + sampleEntities + .map(e => e.spec?.lifecycle!) + .forEach(lifecycle => { + expect(rendered.getByText(lifecycle as string)).toBeInTheDocument(); + }); + }); + + it('renders unique lifecycles in alphabetical order', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Lifecycle')).toBeInTheDocument(); + + fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); + + expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'experimental', + 'production', + ]); + }); + + it('adds lifecycles to filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + + fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); + fireEvent.click(rendered.getByText('production')); + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: new EntityLifecycleFilter(['production']), + }); + }); + + it('removes lifecycles from filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); + expect(rendered.getByLabelText('production')).toBeChecked(); + + fireEvent.click(rendered.getByLabelText('production')); + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: undefined, + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx new file mode 100644 index 0000000000..b36e332b5d --- /dev/null +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx @@ -0,0 +1,86 @@ +/* + * 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 { + Box, + Checkbox, + FormControlLabel, + TextField, + Typography, +} from '@material-ui/core'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { Autocomplete } from '@material-ui/lab'; +import React, { useMemo } from 'react'; +import { useEntityListProvider } from '../../hooks/useEntityListProvider'; +import { EntityLifecycleFilter } from '../../types'; + +const icon = ; +const checkedIcon = ; + +export const EntityLifecyclePicker = () => { + const { updateFilters, backendEntities, filters } = useEntityListProvider(); + const availableLifecycles = useMemo( + () => + [ + ...new Set( + backendEntities + .map((e: Entity) => e.spec?.lifecycle) + .filter(Boolean) as string[], + ), + ].sort(), + [backendEntities], + ); + + if (!availableLifecycles.length) return null; + + const onChange = (lifecycles: string[]) => { + updateFilters({ + lifecycles: lifecycles.length + ? new EntityLifecycleFilter(lifecycles) + : undefined, + }); + }; + + return ( + + Lifecycle + + multiple + options={availableLifecycles} + value={filters.lifecycles?.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/EntityLifecyclePicker/index.ts b/plugins/catalog-react/src/components/EntityLifecyclePicker/index.ts new file mode 100644 index 0000000000..b947ad520c --- /dev/null +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/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 { EntityLifecyclePicker } from './EntityLifecyclePicker'; diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx new file mode 100644 index 0000000000..49188cbad6 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx @@ -0,0 +1,169 @@ +/* + * 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 { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { EntityOwnerFilter } from '../../types'; +import { EntityOwnerPicker } from './EntityOwnerPicker'; + +const sampleEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-1', + }, + relations: [ + { + type: 'ownedBy', + target: { + name: 'some-owner', + namespace: 'default', + kind: 'Group', + }, + }, + { + type: 'ownedBy', + target: { + name: 'some-owner-2', + namespace: 'default', + kind: 'Group', + }, + }, + ], + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-2', + }, + relations: [ + { + type: 'ownedBy', + target: { + name: 'another-owner', + namespace: 'default', + kind: 'Group', + }, + }, + ], + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-3', + }, + relations: [ + { + type: 'ownedBy', + target: { + name: 'some-owner', + namespace: 'default', + kind: 'Group', + }, + }, + ], + }, +]; + +describe('', () => { + it('renders all owners', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Owner')).toBeInTheDocument(); + + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + sampleEntities + .flatMap(e => e.relations?.map(r => r.target.name)) + .forEach(owner => { + expect(rendered.getByText(owner as string)).toBeInTheDocument(); + }); + }); + + it('renders unique owners in alphabetical order', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Owner')).toBeInTheDocument(); + + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + + expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'another-owner', + 'some-owner', + 'some-owner-2', + ]); + }); + + it('adds owners to filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + fireEvent.click(rendered.getByText('some-owner')); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['some-owner']), + }); + }); + + it('removes owners from filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + expect(rendered.getByLabelText('some-owner')).toBeChecked(); + + fireEvent.click(rendered.getByLabelText('some-owner')); + expect(updateFilters).toHaveBeenLastCalledWith({ + owner: undefined, + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx new file mode 100644 index 0000000000..65fba2150c --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -0,0 +1,90 @@ +/* + * 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, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + Box, + Checkbox, + FormControlLabel, + TextField, + Typography, +} from '@material-ui/core'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { Autocomplete } from '@material-ui/lab'; +import React, { useMemo } from 'react'; +import { useEntityListProvider } from '../../hooks/useEntityListProvider'; +import { EntityOwnerFilter } from '../../types'; +import { getEntityRelations } from '../../utils'; +import { formatEntityRefTitle } from '../EntityRefLink'; + +const icon = ; +const checkedIcon = ; + +export const EntityOwnerPicker = () => { + const { updateFilters, backendEntities, filters } = useEntityListProvider(); + const availableOwners = useMemo( + () => + [ + ...new Set( + backendEntities + .flatMap((e: Entity) => + getEntityRelations(e, RELATION_OWNED_BY).map(o => + formatEntityRefTitle(o, { defaultKind: 'group' }), + ), + ) + .filter(Boolean) as string[], + ), + ].sort(), + [backendEntities], + ); + + if (!availableOwners.length) return null; + + const onChange = (owners: string[]) => { + updateFilters({ + owners: owners.length ? new EntityOwnerFilter(owners) : undefined, + }); + }; + + return ( + + Owner + + multiple + options={availableOwners} + value={filters.owners?.values ?? []} + onChange={(_: object, value: string[]) => onChange(value)} + renderOption={(option, { selected }) => ( + + } + label={option} + /> + )} + size="small" + popupIcon={} + renderInput={params => } + /> + + ); +}; diff --git a/plugins/api-docs/src/components/ApiExplorerTable/index.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/index.ts similarity index 86% rename from plugins/api-docs/src/components/ApiExplorerTable/index.ts rename to plugins/catalog-react/src/components/EntityOwnerPicker/index.ts index a9c79861e8..d46565b7c4 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/index.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/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 { ApiExplorerTable } from './ApiExplorerTable'; +export { EntityOwnerPicker } from './EntityOwnerPicker'; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index e610fb9d2f..18ef8a26a3 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -16,6 +16,7 @@ import { Entity } from '@backstage/catalog-model'; import { + Box, Checkbox, FormControlLabel, TextField, @@ -55,7 +56,7 @@ export const EntityTagPicker = () => { }; return ( - <> + Tags multiple @@ -78,6 +79,6 @@ export const EntityTagPicker = () => { popupIcon={} renderInput={params => } /> - + ); }; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index 4aad517b32..06e155aa43 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ export * from './EntityKindPicker'; +export * from './EntityLifecyclePicker'; +export * from './EntityOwnerPicker'; export * from './EntityProvider'; export * from './EntityRefLink'; export * from './EntityTable'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index a410c243a1..6168a3ac3c 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -29,6 +29,8 @@ import { catalogApiRef } from '../api'; import { EntityFilter, EntityKindFilter, + EntityLifecycleFilter, + EntityOwnerFilter, EntityTagFilter, EntityTypeFilter, UserListFilter, @@ -39,6 +41,8 @@ export type DefaultEntityFilters = { kind?: EntityKindFilter; type?: EntityTypeFilter; user?: UserListFilter; + owners?: EntityOwnerFilter; + lifecycles?: EntityLifecycleFilter; tags?: EntityTagFilter; }; diff --git a/plugins/catalog-react/src/types.ts b/plugins/catalog-react/src/types.ts index 98ea2fb9b0..9ede73baba 100644 --- a/plugins/catalog-react/src/types.ts +++ b/plugins/catalog-react/src/types.ts @@ -14,8 +14,13 @@ * limitations under the License. */ -import { Entity, UserEntity } from '@backstage/catalog-model'; -import { isOwnerOf } from './utils'; +import { + Entity, + RELATION_OWNED_BY, + UserEntity, +} from '@backstage/catalog-model'; +import { getEntityRelations, isOwnerOf } from './utils'; +import { formatEntityRefTitle } from './components/EntityRefLink'; export type EntityFilter = { /** @@ -61,6 +66,26 @@ export class EntityTagFilter implements EntityFilter { } } +export class EntityOwnerFilter implements EntityFilter { + constructor(readonly values: string[]) {} + + filterEntity(entity: Entity): boolean { + return this.values.some(v => + getEntityRelations(entity, RELATION_OWNED_BY).some( + o => formatEntityRefTitle(o, { defaultKind: 'group' }) === v, + ), + ); + } +} + +export class EntityLifecycleFilter implements EntityFilter { + constructor(readonly values: string[]) {} + + filterEntity(entity: Entity): boolean { + return this.values.some(v => entity.spec?.lifecycle === v); + } +} + export type UserListFilterKind = 'owned' | 'starred' | 'all'; export class UserListFilter implements EntityFilter { constructor( diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 80569f4de6..d3ade03fe4 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -24,7 +24,9 @@ import { } from '@backstage/core'; import { EntityKindPicker, + EntityLifecyclePicker, EntityListProvider, + EntityOwnerPicker, EntityTagPicker, EntityTypePicker, UserListFilterKind, @@ -72,6 +74,8 @@ export const CatalogPage = ({