diff --git a/.changeset/little-boats-think.md b/.changeset/little-boats-think.md index d389bfbbb1..1787477c38 100644 --- a/.changeset/little-boats-think.md +++ b/.changeset/little-boats-think.md @@ -3,6 +3,10 @@ --- The `EntityOwnerPicker` component has undergone improvements to enhance its performance. - -The component now loads entities asynchronously, resulting in improved performance and responsiveness. Instead of loading all entities upfront, they are now loaded in batches as the user scrolls. The previous implementation inferred users and groups displayed by the `EntityOwnerPicker` component based on the entities available in the `EntityListContext`. The updated version no longer relies on the `EntityListContext` for inference, allowing for better decoupling and improved performance. + +The component now loads entities asynchronously, resulting in improved performance and responsiveness. A new `mode` prop has been introduced which provides two different behaviours: + +- ``: loads the owners data asynchronously using the facets endpoint. The data is kept in memory and rendered asynchronously as the user scrolls. This is the default mode and is supposed to be retro-compatible with the previous implementation. + +- `` loads all users and groups present in the catalog asynchronously. The data is loaded in batches as the user scrolls. This is more efficient than `owners-only`, but has the drawback of displaying users and groups who aren't owner of any entity. diff --git a/.changeset/unlucky-feet-smell.md b/.changeset/unlucky-feet-smell.md new file mode 100644 index 0000000000..67936fe3ad --- /dev/null +++ b/.changeset/unlucky-feet-smell.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +--- + +`CatalogIndexPage` now accepts an optional `ownerPickerMode` for toggling the behavior of the `EntityOwnerPicker`, +exposing a new mode `` particularly suitable for larger catalogs. In this new mode, `EntityOwnerPicker` will display all the users and groups present in the catalog. diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx index e3723dba5b..ee3b522a3c 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx @@ -14,11 +14,6 @@ * limitations under the License. */ -import { - Entity, - parseEntityRef, - RELATION_MEMBER_OF, -} from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/core-app-api'; import { TableColumn, TableProps } from '@backstage/core-components'; import { @@ -42,7 +37,7 @@ import { wrapInTestApp, } from '@backstage/test-utils'; import DashboardIcon from '@material-ui/icons/Dashboard'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { apiDocsConfigRef } from '../../config'; import { DefaultApiExplorerPage } from './DefaultApiExplorerPage'; @@ -60,25 +55,14 @@ describe('DefaultApiExplorerPage', () => { }, spec: { type: 'openapi' }, }, - ] as Entity[], + ], }), getLocationByRef: () => Promise.resolve({ id: 'id', type: 'url', target: 'url' }), getEntitiesByRefs: () => Promise.resolve({ items: [] }), - getEntityByRef: async entityRef => { - return { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { name: parseEntityRef(entityRef).name }, - relations: [ - { - type: RELATION_MEMBER_OF, - targetRef: 'group:default/tools', - target: { namespace: 'default', kind: 'group', name: 'tools' }, - }, - ], - }; - }, + getEntityFacets: async () => ({ + facets: { 'relations.ownedBy': [] }, + }), }; const configApi: ConfigApi = new ConfigReader({ @@ -134,16 +118,18 @@ describe('DefaultApiExplorerPage', () => { ); const columnHeaderLabels = columnHeader.map(c => c.textContent); - expect(columnHeaderLabels).toEqual([ - 'Name', - 'System', - 'Owner', - 'Type', - 'Lifecycle', - 'Description', - 'Tags', - 'Actions', - ]); + await waitFor(() => + expect(columnHeaderLabels).toEqual([ + 'Name', + 'System', + 'Owner', + 'Type', + 'Lifecycle', + 'Description', + 'Tags', + 'Actions', + ]), + ); }); it('should render the custom column passed as prop', async () => { @@ -161,7 +147,9 @@ describe('DefaultApiExplorerPage', () => { ); const columnHeaderLabels = columnHeader.map(c => c.textContent); - expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']); + await waitFor(() => + expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']), + ); }); it('should render the default actions of an item in the grid', async () => { diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 2fab514de1..e9acfa76ce 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -266,7 +266,14 @@ export class EntityOwnerFilter implements EntityFilter { } // @public (undocumented) -export const EntityOwnerPicker: () => JSX.Element | null; +export const EntityOwnerPicker: ( + props?: EntityOwnerPickerProps, +) => JSX.Element | null; + +// @public (undocumented) +export type EntityOwnerPickerProps = { + mode?: 'owners-only' | 'all'; +}; // @public export const EntityPeekAheadPopover: ( diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx index e5497d022a..a7b98e890f 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MockEntityListContextProvider } from '../../testUtils/providers'; @@ -114,14 +114,18 @@ const mockedQueryEntities: jest.MockedFn = const mockedGetEntitiesByRef: jest.MockedFn = jest.fn(); +const mockedGetEntityFacets: jest.MockedFn = + jest.fn(); + const mockCatalogApi: Partial = { queryEntities: mockedQueryEntities, getEntitiesByRefs: mockedGetEntitiesByRef, + getEntityFacets: mockedGetEntityFacets, }; const mockErrorApi = new MockErrorApi(); -describe('', () => { +describe('', () => { const mockApis = TestApiRegistry.from( [catalogApiRef, mockCatalogApi], [errorApiRef, mockErrorApi], @@ -154,7 +158,7 @@ describe('', () => { await renderWithEffects( - + , ); @@ -205,7 +209,7 @@ describe('', () => { queryParameters, }} > - + , ); @@ -243,7 +247,7 @@ describe('', () => { queryParameters, }} > - + , ); @@ -284,7 +288,7 @@ describe('', () => { updateFilters, }} > - + , ); @@ -312,7 +316,7 @@ describe('', () => { filters: { owners: new EntityOwnerFilter(['some-owner']) }, }} > - + , ); @@ -344,7 +348,7 @@ describe('', () => { queryParameters: { owners: ['team-a'] }, }} > - + , ); @@ -362,7 +366,180 @@ describe('', () => { queryParameters: { owners: ['team-b'] }, }} > - + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['group:default/team-b']), + }); + }); +}); + +describe('', () => { + const mockApis = TestApiRegistry.from( + [catalogApiRef, mockCatalogApi], + [errorApiRef, mockErrorApi], + ); + + beforeEach(() => { + jest.resetAllMocks(); + + mockedGetEntityFacets.mockResolvedValue({ + facets: { + 'relations.ownedBy': [ + ...[...ownerEntitiesBatch1, ...ownerEntitiesBatch2].map(o => ({ + count: 1, + value: stringifyEntityRef(o), + })), + ], + }, + }); + }); + + it('renders all users and groups', async () => { + await renderWithEffects( + + + + + , + ); + expect(screen.getByText('Owner')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('owner-picker-expand')); + + await waitFor(() => + expect(screen.getByText('another-owner')).toBeInTheDocument(), + ); + + ['some-owner', 'some-owner-2', 'test-namespace/another-owner-2'].forEach( + owner => { + expect(screen.getByText(owner)).toBeInTheDocument(); + }, + ); + + expect(mockedGetEntityFacets).toHaveBeenCalledTimes(1); + + fireEvent.scroll(screen.getByTestId('owner-picker-listbox')); + + await waitFor(() => + expect(screen.getByText('some-owner-batch-2')).toBeInTheDocument(), + ); + + [ + 'some-owner-batch-2', + 'some-owner-2-batch-2', + 'test-namespace/another-owner-2-batch-2', + ].forEach(owner => { + expect(screen.getByText(owner)).toBeInTheDocument(); + }); + }); + + it('respects the query parameter filter value', async () => { + const updateFilters = jest.fn(); + const queryParameters = { owners: ['another-owner'] }; + await renderWithEffects( + + + + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['group:default/another-owner']), + }); + }); + + it('adds owners to filters', async () => { + const updateFilters = jest.fn(); + await renderWithEffects( + + + + + , + ); + expect(mockedGetEntitiesByRef).not.toHaveBeenCalled(); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: undefined, + }); + + fireEvent.click(screen.getByTestId('owner-picker-expand')); + await waitFor(() => screen.getByText('some-owner')); + + fireEvent.click(screen.getByText('some-owner')); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['group:default/some-owner']), + }); + }); + + it('removes owners from filters', async () => { + const updateFilters = jest.fn(); + await renderWithEffects( + + + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['group:default/some-owner']), + }); + fireEvent.click(screen.getByTestId('owner-picker-expand')); + + await waitFor(() => + expect(screen.getByLabelText('some-owner')).toBeChecked(), + ); + + fireEvent.click(screen.getByLabelText('some-owner')); + expect(updateFilters).toHaveBeenLastCalledWith({ + owner: undefined, + }); + }); + + it('responds to external queryParameters changes', async () => { + const updateFilters = jest.fn(); + const rendered = await renderWithEffects( + + + + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['group:default/team-a']), + }); + rendered.rerender( + + + , ); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 8b223c50b1..ed22e126c5 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -14,7 +14,11 @@ * limitations under the License. */ -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { + Entity, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { Box, Checkbox, @@ -27,17 +31,14 @@ 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, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useEntityList } from '../../hooks/useEntityListProvider'; import { EntityOwnerFilter } from '../../filters'; -import { useApi } from '@backstage/core-plugin-api'; -import { catalogApiRef } from '../../api'; -import useAsync from 'react-use/lib/useAsync'; -import useAsyncFn from 'react-use/lib/useAsyncFn'; import { useDebouncedEffect } from '@react-hookz/web'; import PersonIcon from '@material-ui/icons/Person'; import GroupIcon from '@material-ui/icons/Group'; -import { humanizeEntity } from '../EntityRefLink/humanize'; +import { humanizeEntity, humanizeEntityRef } from '../EntityRefLink/humanize'; +import { useFetchEntities } from './useFetchEntities'; /** @public */ export type CatalogReactEntityOwnerPickerClassKey = 'input'; @@ -54,57 +55,25 @@ const useStyles = makeStyles( const icon = ; const checkedIcon = ; +/** + * @public + */ +export type EntityOwnerPickerProps = { + mode?: 'owners-only' | 'all'; +}; + /** @public */ -export const EntityOwnerPicker = () => { +export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { const classes = useStyles(); + const { mode = 'owners-only' } = props || {}; const { updateFilters, filters, queryParameters: { owners: ownersParameter }, } = useEntityList(); - const catalogApi = useApi(catalogApiRef); const [text, setText] = useState(''); - const [{ value, loading }, handleFetch] = useAsyncFn( - async (request: { text: string } | { cursor: string; prev: Entity[] }) => { - const initialRequest = request as { text: string }; - const cursorRequest = request as { cursor: string; prev: Entity[] }; - const limit = 20; - - if (cursorRequest.cursor) { - const response = await catalogApi.queryEntities({ - cursor: cursorRequest.cursor, - limit, - }); - return { - ...response, - items: [...cursorRequest.prev, ...response.items], - }; - } - - return catalogApi.queryEntities({ - fullTextFilter: { - term: initialRequest.text || '', - fields: [ - 'metadata.name', - 'kind', - 'spec.profile.displayname', - 'metadata.title', - ], - }, - filter: { kind: ['User', 'Group'] }, - orderFields: [{ field: 'metadata.name', order: 'asc' }], - limit, - }); - }, - [text], - ); - - useDebouncedEffect(() => handleFetch({ text }), [text], 250); - - const availableOwners = value?.items || []; - const queryParamOwners = useMemo( () => [ownersParameter].flat().filter(Boolean) as string[], [ownersParameter], @@ -114,7 +83,13 @@ export const EntityOwnerPicker = () => { queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], ); - const { getEntity, setEntity } = useSelectedOwners(selectedOwners); + const [{ value, loading }, handleFetch, cache] = useFetchEntities({ + mode, + initialSelectedOwnersRefs: selectedOwners, + }); + useDebouncedEffect(() => handleFetch({ text }), [text, handleFetch], 250); + + const availableOwners = value?.items || []; // Set selected owners on query parameter updates; this happens at initial page load and from // external updates to the page location. @@ -158,11 +133,15 @@ export const EntityOwnerPicker = () => { return o === v; }} getOptionLabel={o => { - const entity = typeof o === 'string' ? getEntity(o) || o : o; - - return typeof entity === 'string' - ? entity - : humanizeEntity(entity, entity.metadata.name); + const entity = + typeof o === 'string' + ? cache.getEntity(o) || + parseEntityRef(o, { + defaultKind: 'group', + defaultNamespace: 'default', + }) + : o; + return humanizeEntity(entity, humanizeEntityRef(entity)); }} onChange={(_: object, owners) => { setText(''); @@ -172,7 +151,7 @@ export const EntityOwnerPicker = () => { typeof e === 'string' ? e : stringifyEntityRef(e); if (typeof e !== 'string') { - setEntity(e); + cache.setEntity(e); } return entityRef; @@ -181,7 +160,7 @@ export const EntityOwnerPicker = () => { }} filterOptions={x => x} renderOption={(entity, { selected }) => { - const isGroup = entity.kind === 'Group'; + const isGroup = entity.kind.toLocaleLowerCase('en-US') === 'group'; return ( { )}   - {humanizeEntity(entity, entity.metadata.name)} + {humanizeEntity( + entity, + humanizeEntityRef(entity, { defaultKind: entity.kind }), + )} } /> @@ -229,11 +211,8 @@ export const EntityOwnerPicker = () => { element.scrollTop, ) < 1; - if (hasReachedEnd && value?.pageInfo.nextCursor) { - handleFetch({ - cursor: value.pageInfo.nextCursor, - prev: value.items, - }); + if (hasReachedEnd && value?.cursor) { + handleFetch({ items: value.items, cursor: value.cursor }); } }, 'data-testid': 'owner-picker-listbox', @@ -243,36 +222,3 @@ export const EntityOwnerPicker = () => { ); }; - -/** - * Hook used for storing the full entity of the specified owners - * in order to display users and group using the information contained on each entity. - * When a component is rendered for the first time, it loads the content of the entities - * specified by `initialSelectedOwnersRefs` and export the `getEntity` and `setEntity` - * utilities, used to retrieve and modify the owners. - */ -function useSelectedOwners(initialSelectedOwnersRefs: string[]) { - const allEntities = useRef>({}); - const catalogApi = useApi(catalogApiRef); - - useAsync(async () => { - if (initialSelectedOwnersRefs.length === 0) { - return; - } - const initialSelectedEntities = await catalogApi.getEntitiesByRefs({ - entityRefs: initialSelectedOwnersRefs, - }); - initialSelectedEntities.items.forEach(e => { - if (e) { - allEntities.current[stringifyEntityRef(e)] = e; - } - }); - }, []); - - return { - getEntity: (entityRef: string) => allEntities.current[entityRef], - setEntity: (entity: Entity) => { - allEntities.current[stringifyEntityRef(entity)] = entity; - }, - }; -} diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/index.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/index.ts index c20db945f7..8685d838cd 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/index.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/index.ts @@ -15,4 +15,7 @@ */ export { EntityOwnerPicker } from './EntityOwnerPicker'; -export type { CatalogReactEntityOwnerPickerClassKey } from './EntityOwnerPicker'; +export type { + CatalogReactEntityOwnerPickerClassKey, + EntityOwnerPickerProps, +} from './EntityOwnerPicker'; diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts new file mode 100644 index 0000000000..6f6da43307 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts @@ -0,0 +1,299 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { useFacetsEntities } from './useFacetsEntities'; +import { CatalogApi } from '@backstage/catalog-client'; + +const mockedGetEntityFacets: jest.MockedFn = + jest.fn(); + +const mockCatalogApi: Partial = { + getEntityFacets: mockedGetEntityFacets, +}; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: () => mockCatalogApi, +})); + +describe('useFacetsEntities', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it(`should return empty items when facets are loading`, () => { + mockedGetEntityFacets.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useFacetsEntities({ enabled: true })); + expect(result.current[0]).toEqual({ value: { items: [] }, loading: true }); + }); + + it(`should return the owners`, async () => { + mockedGetEntityFacets.mockResolvedValue({ + facets: { + 'relations.ownedBy': [ + { count: 1, value: 'component:default/e2' }, + { count: 1, value: 'component:default/e1' }, + ], + }, + }); + + const { result, waitFor } = renderHook(() => + useFacetsEntities({ enabled: true }), + ); + + result.current[1]({ text: '' }); + await waitFor(() => { + expect(result.current[0]).toEqual({ + value: { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'e1', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'e2', namespace: 'default' }, + }, + ], + }, + loading: false, + }); + }); + }); + + it(`should return the owners sorted by namespace, name and kind`, async () => { + const entityRefs = [ + 'group:namespace/team-b', + 'component:default/c', + 'group:default/a', + 'component:default/a', + 'component:default/b', + ]; + + mockedGetEntityFacets.mockResolvedValue({ + facets: { + 'relations.ownedBy': entityRefs.map(value => ({ count: 1, value })), + }, + }); + + const { result, waitFor } = renderHook(() => + useFacetsEntities({ enabled: true }), + ); + + result.current[1]({ text: '' }); + await waitFor(() => { + expect(result.current[0]).toEqual({ + value: { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'a', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'a', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'b', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'c', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'team-b', namespace: 'namespace' }, + }, + ], + }, + loading: false, + }); + }); + }); + + it(`should paginate the data accordingly`, async () => { + const entityRefs = [ + 'group:namespace/team-b', + 'component:default/c', + 'group:default/a', + 'component:default/a', + 'component:default/b', + ]; + + mockedGetEntityFacets.mockResolvedValue({ + facets: { + 'relations.ownedBy': entityRefs.map(value => ({ count: 1, value })), + }, + }); + + const { result, waitFor } = renderHook(() => + useFacetsEntities({ enabled: true }), + ); + + result.current[1]({ text: '' }, { limit: 2 }); + await waitFor(() => { + expect(result.current[0]).toEqual({ + value: { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'a', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'a', namespace: 'default' }, + }, + ], + cursor: 'eyJ0ZXh0IjoiIiwic3RhcnQiOjJ9', + }, + loading: false, + }); + }); + + result.current[1](result.current[0].value!, { limit: 2 }); + await waitFor(() => { + expect(result.current[0]).toEqual({ + value: { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'a', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'a', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'b', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'c', namespace: 'default' }, + }, + ], + cursor: 'eyJ0ZXh0IjoiIiwic3RhcnQiOjR9', + }, + loading: false, + }); + }); + + result.current[1](result.current[0].value!, { limit: 2 }); + await waitFor(() => { + expect(result.current[0]).toEqual({ + value: { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'a', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'a', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'b', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'c', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'team-b', namespace: 'namespace' }, + }, + ], + }, + loading: false, + }); + }); + }); + + it('should filter the data accordingly', async () => { + const entityRefs = [ + 'group:namespace/spiderman', + 'group:spiders/go', + 'group:default/go', + 'component:spiders/a-component', + 'component:default/a-component', + 'component:default/spider', + 'spid:default/lemon', + 'component:default/lemon', + 'component:default/nade', + ]; + + mockedGetEntityFacets.mockResolvedValue({ + facets: { + 'relations.ownedBy': entityRefs.map(value => ({ count: 1, value })), + }, + }); + + const { result, waitFor } = renderHook(() => + useFacetsEntities({ enabled: true }), + ); + + result.current[1]({ text: 'der ' }); + await waitFor(() => { + expect(result.current[0]).toEqual({ + value: { + items: [ + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'spider', namespace: 'default' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'spiderman', namespace: 'namespace' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'component', + metadata: { name: 'a-component', namespace: 'spiders' }, + }, + { + apiVersion: 'backstage.io/v1beta1', + kind: 'group', + metadata: { name: 'go', namespace: 'spiders' }, + }, + ], + }, + loading: false, + }); + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts new file mode 100644 index 0000000000..b9af10b697 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useApi } from '@backstage/core-plugin-api'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { catalogApiRef } from '../../api'; +import { useState } from 'react'; +import { Entity, parseEntityRef } from '@backstage/catalog-model'; + +type FacetsCursor = { + start: number; + text: string; +}; + +type FacetsEntitiesResponse = { + items: Entity[]; + cursor?: string; +}; + +type FacetsInitialRequest = { + text: string; +}; + +/** + * This hook asynchronously loads the entity owners using the facets endpoint. + * EntityOwnerPicker uses this hook when mode="owners-only" is passed as prop. + * All the owners are kept internally in memory and rendered in batches once requested + * by the frontend. The values returned by this hook are compatible with `useQueryEntities` + * hook, which is also used by EntityOwnerPicker. + */ +export function useFacetsEntities({ enabled }: { enabled: boolean }) { + const catalogApi = useApi(catalogApiRef); + + const [facetsPromise] = useState(async () => { + if (!enabled) { + return []; + } + const facet = 'relations.ownedBy'; + return catalogApi + .getEntityFacets({ facets: [facet] }) + .then(response => + response.facets[facet] + .map(e => e.value) + .map(ref => { + const { kind, name, namespace } = parseEntityRef(ref); + return { + apiVersion: 'backstage.io/v1beta1', + kind, + metadata: { name, namespace }, + }; + }) + .sort( + (a, b) => + (a.metadata.namespace || '').localeCompare( + b.metadata.namespace || '', + 'en-US', + ) || + a.metadata.name.localeCompare(b.metadata.name, 'en-US') || + a.kind.localeCompare(b.kind, 'en-US'), + ), + ) + .catch(() => []); + }); + + return useAsyncFn< + ( + request: FacetsInitialRequest | FacetsEntitiesResponse, + options?: { limit?: number }, + ) => Promise + >( + async (request, options) => { + const facets = await facetsPromise; + + if (!facets) { + return { + items: [], + }; + } + + const limit = options?.limit ?? 20; + + const { text, start } = decodeCursor(request); + const filteredRefs = facets.filter(e => filterEntity(text, e)); + const end = start + limit; + return { + items: filteredRefs.slice(0, end), + ...encodeCursor({ + entities: filteredRefs, + limit: end, + payload: { + text, + start: end, + }, + }), + }; + }, + [facetsPromise], + { loading: true, value: { items: [] } }, + ); +} + +function decodeCursor( + request: FacetsInitialRequest | FacetsEntitiesResponse, +): FacetsCursor { + if (isFacetsResponse(request) && request.cursor) { + return JSON.parse(atob(request.cursor)); + } + return { + text: (request as FacetsInitialRequest).text || '', + start: 0, + }; +} + +function isFacetsResponse( + request: FacetsInitialRequest | FacetsEntitiesResponse, +): request is FacetsEntitiesResponse { + return !!(request as FacetsEntitiesResponse).cursor; +} + +function encodeCursor({ + entities, + limit, + payload, +}: { + entities: Entity[]; + limit: number; + payload: { text: string; start: number }; +}) { + if (entities.length > limit) { + return { cursor: btoa(JSON.stringify(payload)) }; + } + return {}; +} + +function filterEntity(text: string, entity: Entity) { + const normalizedText = text.trim(); + return ( + entity.kind.includes(normalizedText) || + entity.metadata.namespace?.includes(normalizedText) || + entity.metadata.name.includes(normalizedText) + ); +} diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFetchEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFetchEntities.ts new file mode 100644 index 0000000000..01cef24fd6 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFetchEntities.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useRef } from 'react'; +import { useFacetsEntities } from './useFacetsEntities'; +import { useQueryEntities } from './useQueryEntities'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '../../api'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { useMountEffect } from '@react-hookz/web'; + +export function useFetchEntities({ + mode, + initialSelectedOwnersRefs, +}: { + mode: 'owners-only' | 'all'; + initialSelectedOwnersRefs: string[]; +}) { + const isOwnersOnlyMode = mode === 'owners-only'; + const queryEntitiesResponse = useQueryEntities(); + const facetsEntitiesResponse = useFacetsEntities({ + enabled: isOwnersOnlyMode, + }); + + const [state, handleFetch] = isOwnersOnlyMode + ? facetsEntitiesResponse + : queryEntitiesResponse; + + return [ + state, + handleFetch, + useSelectedOwners({ + enabled: !isOwnersOnlyMode, + initialSelectedOwnersRefs, + }), + ] as const; +} + +/** + * Hook used for storing the full entity of the specified owners + * in order to display users and group using the information contained on each entity. + * When a component is rendered for the first time, it loads the content of the entities + * specified by `initialSelectedOwnersRefs` and export the `getEntity` and `setEntity` + * utilities, used to retrieve and modify the owners. + */ +function useSelectedOwners({ + enabled, + initialSelectedOwnersRefs, +}: { + enabled: boolean; + initialSelectedOwnersRefs: string[]; +}) { + const allEntities = useRef>({}); + const catalogApi = useApi(catalogApiRef); + + const [, handleFetch] = useAsyncFn(async () => { + const initialSelectedEntities = await catalogApi.getEntitiesByRefs({ + entityRefs: initialSelectedOwnersRefs, + }); + initialSelectedEntities.items.forEach(e => { + if (e) { + allEntities.current[stringifyEntityRef(e)] = e; + } + }); + }, []); + + useMountEffect(() => { + if (enabled && initialSelectedOwnersRefs.length > 0) { + handleFetch(); + } + }); + + return { + getEntity: (entityRef: string) => allEntities.current[entityRef], + setEntity: (entity: Entity) => { + allEntities.current[stringifyEntityRef(entity)] = entity; + }, + }; +} diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts new file mode 100644 index 0000000000..d974709d34 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { CatalogApi } from '@backstage/catalog-client'; +import { useQueryEntities } from './useQueryEntities'; + +const mockedQueryEntities: jest.MockedFn = + jest.fn(); + +const mockCatalogApi: Partial = { + queryEntities: mockedQueryEntities, +}; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: () => mockCatalogApi, +})); + +describe('useQueryEntities', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it(`should not invoke queryEntities on mount`, () => { + mockedQueryEntities.mockResolvedValue({ + items: [], + pageInfo: {}, + totalItems: 0, + }); + + renderHook(() => useQueryEntities()); + expect(mockedQueryEntities).not.toHaveBeenCalled(); + }); + + it(`should fetch the data accordingly`, async () => { + mockedQueryEntities + .mockResolvedValueOnce({ + items: [ + { apiVersion: '1', kind: 'kind', metadata: { name: 'name-1' } }, + ], + pageInfo: { nextCursor: 'next' }, + totalItems: 2, + }) + .mockResolvedValueOnce({ + items: [ + { apiVersion: '1', kind: 'kind', metadata: { name: 'name-2' } }, + ], + pageInfo: {}, + totalItems: 2, + }); + + const { result, waitFor } = renderHook(() => useQueryEntities()); + const [, fetch] = result.current!; + fetch({ text: 'text' }); + + await waitFor(() => + expect(result.current[0].value!).toEqual({ + items: [ + { apiVersion: '1', kind: 'kind', metadata: { name: 'name-1' } }, + ], + cursor: 'next', + }), + ); + expect(mockedQueryEntities).toHaveBeenCalledWith({ + filter: { kind: ['User', 'Group'] }, + fullTextFilter: { + fields: [ + 'metadata.name', + 'kind', + 'spec.profile.displayname', + 'metadata.title', + ], + term: 'text', + }, + limit: 20, + orderFields: [{ field: 'metadata.name', order: 'asc' }], + }); + + fetch(result.current[0].value!); + await waitFor(() => + expect(result.current[0].value!).toEqual({ + items: [ + { apiVersion: '1', kind: 'kind', metadata: { name: 'name-1' } }, + { apiVersion: '1', kind: 'kind', metadata: { name: 'name-2' } }, + ], + }), + ); + expect(mockedQueryEntities).toHaveBeenCalledWith({ + cursor: 'next', + limit: 20, + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts new file mode 100644 index 0000000000..186cfa5566 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '../../api'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { Entity } from '@backstage/catalog-model'; + +type QueryEntitiesResponse = { + items: Entity[]; + cursor?: string; +}; + +export function useQueryEntities() { + const catalogApi = useApi(catalogApiRef); + return useAsyncFn( + async ( + request: { text: string } | QueryEntitiesResponse, + options?: { limit: number }, + ): Promise => { + const initialRequest = request as { text: string }; + const cursorRequest = request as QueryEntitiesResponse; + const limit = options?.limit ?? 20; + + if (cursorRequest.cursor) { + const response = await catalogApi.queryEntities({ + cursor: cursorRequest.cursor, + limit, + }); + return { + cursor: response.pageInfo.nextCursor, + items: [...cursorRequest.items, ...response.items], + }; + } + + const response = await catalogApi.queryEntities({ + fullTextFilter: { + term: initialRequest.text || '', + fields: [ + 'metadata.name', + 'kind', + 'spec.profile.displayname', + 'metadata.title', + ], + }, + filter: { kind: ['User', 'Group'] }, + orderFields: [{ field: 'metadata.name', order: 'asc' }], + limit, + }); + + return { + cursor: response.pageInfo.nextCursor, + items: response.items, + }; + }, + [], + { loading: true }, + ); +} diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 6bb4fcc4a1..f053c15c8a 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -10,6 +10,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ComponentEntity } from '@backstage/catalog-model'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; +import { EntityOwnerPickerProps } from '@backstage/plugin-catalog-react'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { IndexableDocument } from '@backstage/plugin-search-common'; @@ -215,6 +216,8 @@ export interface DefaultCatalogPageProps { // (undocumented) initiallySelectedFilter?: UserListFilterKind; // (undocumented) + ownerPickerMode?: EntityOwnerPickerProps['mode']; + // (undocumented) tableOptions?: TableProps['options']; } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx index 485cb490d8..f5027a332e 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx @@ -15,12 +15,7 @@ */ import { CatalogApi } from '@backstage/catalog-client'; -import { - Entity, - parseEntityRef, - RELATION_MEMBER_OF, - RELATION_OWNED_BY, -} from '@backstage/catalog-model'; +import { RELATION_OWNED_BY } from '@backstage/catalog-model'; import { TableColumn, TableProps } from '@backstage/core-components'; import { IdentityApi, @@ -67,6 +62,7 @@ describe('DefaultCatalogPage', () => { kind: 'Component', metadata: { name: 'Entity1', + namespace: 'default', }, spec: { owner: 'tools', @@ -101,31 +97,18 @@ describe('DefaultCatalogPage', () => { }, ], }, - ] as Entity[], + ], }), getLocationByRef: () => Promise.resolve({ id: 'id', type: 'url', target: 'url' }), - getEntityByRef: async entityRef => { - return { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { name: parseEntityRef(entityRef).name }, - relations: [ - { - type: RELATION_MEMBER_OF, - targetRef: 'group:default/tools', - target: { namespace: 'default', kind: 'Group', name: 'tools' }, - }, + getEntityFacets: async () => ({ + facets: { + 'relations.ownedBy': [ + { count: 1, value: 'group:default/not-tools' }, + { count: 1, value: 'group:default/tools' }, ], - }; - }, - /** - * For the purposes of this test case, use existing functionality. The picker - * isn't being tested, just needs this method to render correctly. - */ - getEntitiesByRefs: async refs => { - return { items: refs.entityRefs.map(() => undefined) }; - }, + }, + }), }; const testProfile: Partial = { displayName: 'Display Name', diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index b0fb52b4f0..04f39ee66c 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -36,6 +36,7 @@ import { UserListPicker, EntityKindPicker, EntityNamespacePicker, + EntityOwnerPickerProps, } from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; import { createComponentRouteRef } from '../../routes'; @@ -54,6 +55,7 @@ export interface DefaultCatalogPageProps { initialKind?: string; tableOptions?: TableProps['options']; emptyContent?: ReactNode; + ownerPickerMode?: EntityOwnerPickerProps['mode']; } export function DefaultCatalogPage(props: DefaultCatalogPageProps) { @@ -64,6 +66,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { initialKind = 'component', tableOptions = {}, emptyContent, + ownerPickerMode, } = props; const orgName = useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage'; @@ -87,7 +90,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { - + diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 9ac2ac4ef4..1f073e184d 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx @@ -36,8 +36,8 @@ import React from 'react'; import { rootDocsRouteRef } from '../../routes'; import { DefaultTechDocsHome } from './DefaultTechDocsHome'; -const mockCatalogApi = { - getEntityByRef: () => Promise.resolve(), +const mockCatalogApi: Partial = { + getEntityFacets: async () => ({ facets: { 'relations.ownedBy': [] } }), getEntitiesByRefs: () => Promise.resolve({ items: [] }), getEntities: async () => ({ items: [ @@ -51,7 +51,7 @@ const mockCatalogApi = { }, ], }), -} as Partial; +}; describe('TechDocs Home', () => { const configApi: ConfigApi = new ConfigReader({