From 6d33b091b0e2721f5f670f284183840adc4808a8 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 7 Jun 2023 20:29:48 +0200 Subject: [PATCH 01/24] catalog-react: move useQueryEntities Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/useQueryEntities.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts 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..4d1373bfde --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts @@ -0,0 +1,70 @@ +/* + * 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, + ): Promise => { + const initialRequest = request as { text: string }; + const cursorRequest = request as QueryEntitiesResponse; + const 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 }, + ); +} From 2d99146c9183a472ea403b2a2b5e19cb9b1b76dd Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 7 Jun 2023 20:30:55 +0200 Subject: [PATCH 02/24] catalog-react: add useFacetsEntities hook Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/useFacetsEntities.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts 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..7f648d9206 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts @@ -0,0 +1,124 @@ +/* + * 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; +}; + +export function useFacetsEntities({ enabled }: { enabled: boolean }) { + const catalogApi = useApi(catalogApiRef); + + const [facetsPromise] = useState(() => + Promise.resolve().then(() => { + 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'), + ), + ); + }), + ); + + return useAsyncFn< + ( + request: { text: string } | FacetsEntitiesResponse, + ) => Promise + >( + async request => { + const facets = await facetsPromise; + + if (!facets) { + return { + items: [], + }; + } + const initialRequest = request as { text: string }; + const cursorRequest = request as FacetsEntitiesResponse; + + const limit = 20; + + if (cursorRequest.cursor) { + const { start, text } = JSON.parse( + atob(cursorRequest.cursor), + ) as FacetsCursor; + const filteredRefs = facets.filter(e => filterEntity(text, e)); + if (start === undefined) { + return request as FacetsEntitiesResponse; + } + const end = start + limit; + + return { + items: filteredRefs.slice(0, end), + cursor: btoa( + JSON.stringify({ + text, + ...(end < filteredRefs.length && { start: end }), + }), + ), + }; + } + + return { + items: facets + .filter(e => filterEntity(initialRequest.text, e)) + .slice(0, limit), + cursor: btoa( + JSON.stringify({ text: initialRequest.text, start: limit }), + ), + }; + }, + [], + { loading: true }, + ); +} + +function filterEntity(text: string, entity: Entity) { + return ( + entity.kind.includes(text) || + entity.metadata.namespace?.includes(text) || + entity.metadata.name.includes(text) + ); +} From 473f5ff4ddb68358b4a2c5de3bfe55ef5635ceff Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 7 Jun 2023 20:39:08 +0200 Subject: [PATCH 03/24] catalog-react: introduce mode on EntityOwnerPicker Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/EntityOwnerPicker.tsx | 132 +++++------------- .../EntityOwnerPicker/useFetchEntities.ts | 92 ++++++++++++ 2 files changed, 130 insertions(+), 94 deletions(-) create mode 100644 plugins/catalog-react/src/components/EntityOwnerPicker/useFetchEntities.ts diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 8b223c50b1..f894368a4a 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,22 @@ const useStyles = makeStyles( const icon = ; const checkedIcon = ; +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 +80,14 @@ export const EntityOwnerPicker = () => { queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], ); - const { getEntity, setEntity } = useSelectedOwners(selectedOwners); + const [{ value, loading }, handleFetch, { getEntity, setEntity }] = + 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 +131,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' + ? getEntity(o) || + parseEntityRef(o, { + defaultKind: 'group', + defaultNamespace: 'default', + }) + : o; + return humanizeEntity(entity, humanizeEntityRef(entity)); }} onChange={(_: object, owners) => { setText(''); @@ -181,7 +158,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 +209,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 +220,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/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; + }, + }; +} From 25712780ce2592bfa4e91fc4cef2c93e6b2a0d38 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 7 Jun 2023 20:41:22 +0200 Subject: [PATCH 04/24] catalog-react: keep tests for mode all Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/EntityOwnerPicker.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx index e5497d022a..c6aad7d1bd 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx @@ -121,7 +121,7 @@ const mockCatalogApi: Partial = { const mockErrorApi = new MockErrorApi(); -describe('', () => { +describe('', () => { const mockApis = TestApiRegistry.from( [catalogApiRef, mockCatalogApi], [errorApiRef, mockErrorApi], @@ -154,7 +154,7 @@ describe('', () => { await renderWithEffects( - + , ); @@ -205,7 +205,7 @@ describe('', () => { queryParameters, }} > - + , ); @@ -243,7 +243,7 @@ describe('', () => { queryParameters, }} > - + , ); @@ -284,7 +284,7 @@ describe('', () => { updateFilters, }} > - + , ); @@ -312,7 +312,7 @@ describe('', () => { filters: { owners: new EntityOwnerFilter(['some-owner']) }, }} > - + , ); @@ -344,7 +344,7 @@ describe('', () => { queryParameters: { owners: ['team-a'] }, }} > - + , ); @@ -362,7 +362,7 @@ describe('', () => { queryParameters: { owners: ['team-b'] }, }} > - + , ); From 524de8f7d3af994093d03be3a18a46d2fc5ac79b Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 7 Jun 2023 20:55:29 +0200 Subject: [PATCH 05/24] catalog-react: add tests for owners-only mode Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker.test.tsx | 179 +++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx index c6aad7d1bd..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,9 +114,13 @@ 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(); @@ -371,3 +375,176 @@ describe('', () => { }); }); }); + +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( + + + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['group:default/team-b']), + }); + }); +}); From fbfbd829dfa8e4bb008155b9593d5d62f1552801 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 9 Jun 2023 23:19:44 +0200 Subject: [PATCH 06/24] catalog-react: fix cursors Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/useFacetsEntities.ts | 115 +++++++++++------- 1 file changed, 69 insertions(+), 46 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts index 7f648d9206..a8b177eaca 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts @@ -32,42 +32,41 @@ type FacetsEntitiesResponse = { export function useFacetsEntities({ enabled }: { enabled: boolean }) { const catalogApi = useApi(catalogApiRef); - const [facetsPromise] = useState(() => - Promise.resolve().then(() => { - 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'), - ), - ); - }), - ); + 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'), + ), + ); + }); return useAsyncFn< ( request: { text: string } | FacetsEntitiesResponse, + options?: { limit?: number }, ) => Promise >( - async request => { + async (request, options) => { const facets = await facetsPromise; if (!facets) { @@ -78,12 +77,10 @@ export function useFacetsEntities({ enabled }: { enabled: boolean }) { const initialRequest = request as { text: string }; const cursorRequest = request as FacetsEntitiesResponse; - const limit = 20; + const limit = options?.limit ?? 20; if (cursorRequest.cursor) { - const { start, text } = JSON.parse( - atob(cursorRequest.cursor), - ) as FacetsCursor; + const { start, text } = decodeCursor(cursorRequest.cursor); const filteredRefs = facets.filter(e => filterEntity(text, e)); if (start === undefined) { return request as FacetsEntitiesResponse; @@ -92,29 +89,55 @@ export function useFacetsEntities({ enabled }: { enabled: boolean }) { return { items: filteredRefs.slice(0, end), - cursor: btoa( - JSON.stringify({ + ...encodeCursor({ + entities: filteredRefs, + limit: end, + payload: { text, - ...(end < filteredRefs.length && { start: end }), - }), - ), + start: end, + }, + }), }; } + const filteredRefs = facets.filter(e => + filterEntity(initialRequest.text, e), + ); + return { - items: facets - .filter(e => filterEntity(initialRequest.text, e)) - .slice(0, limit), - cursor: btoa( - JSON.stringify({ text: initialRequest.text, start: limit }), - ), + items: filteredRefs.slice(0, limit), + + ...encodeCursor({ + entities: filteredRefs, + limit, + payload: { text: initialRequest.text, start: limit }, + }), }; }, [], - { loading: true }, + { loading: true, value: { items: [] } }, ); } +function decodeCursor(cursor: string): FacetsCursor { + return JSON.parse(atob(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) { return ( entity.kind.includes(text) || From 31bdcbc81a1d42b9cd670923941298a98ad49e53 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 9 Jun 2023 23:20:14 +0200 Subject: [PATCH 07/24] catalog-react: add useFacetsEntities tests Signed-off-by: Vincenzo Scamporlino --- .../useFacetsEntities.test.ts | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts 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..abad3c38dc --- /dev/null +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts @@ -0,0 +1,244 @@ +/* + * 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, + }); + }); + }); +}); From 6f09df7a1af4c9e1a92d55166bb7b89b298d407c Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Sun, 11 Jun 2023 23:44:59 +0200 Subject: [PATCH 08/24] catalog-react: add filter tests Signed-off-by: Vincenzo Scamporlino --- .../useFacetsEntities.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts index abad3c38dc..6f6da43307 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts @@ -241,4 +241,59 @@ describe('useFacetsEntities', () => { }); }); }); + + 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, + }); + }); + }); }); From 27b5d4049254a998a9eee819fc6d8b45151b7ff3 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Sun, 11 Jun 2023 23:45:30 +0200 Subject: [PATCH 09/24] catalog-react: refactor useFacetsEntities Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/useFacetsEntities.ts | 75 +++++++++---------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts index a8b177eaca..45df259707 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts @@ -20,7 +20,7 @@ import { useState } from 'react'; import { Entity, parseEntityRef } from '@backstage/catalog-model'; type FacetsCursor = { - start?: number; + start: number; text: string; }; @@ -29,6 +29,10 @@ type FacetsEntitiesResponse = { cursor?: string; }; +type FacetsInitialRequest = { + text: string; +}; + export function useFacetsEntities({ enabled }: { enabled: boolean }) { const catalogApi = useApi(catalogApiRef); @@ -62,7 +66,7 @@ export function useFacetsEntities({ enabled }: { enabled: boolean }) { return useAsyncFn< ( - request: { text: string } | FacetsEntitiesResponse, + request: FacetsInitialRequest | FacetsEntitiesResponse, options?: { limit?: number }, ) => Promise >( @@ -74,53 +78,45 @@ export function useFacetsEntities({ enabled }: { enabled: boolean }) { items: [], }; } - const initialRequest = request as { text: string }; - const cursorRequest = request as FacetsEntitiesResponse; const limit = options?.limit ?? 20; - if (cursorRequest.cursor) { - const { start, text } = decodeCursor(cursorRequest.cursor); - const filteredRefs = facets.filter(e => filterEntity(text, e)); - if (start === undefined) { - return request as FacetsEntitiesResponse; - } - const end = start + limit; - - return { - items: filteredRefs.slice(0, end), - ...encodeCursor({ - entities: filteredRefs, - limit: end, - payload: { - text, - start: end, - }, - }), - }; - } - - const filteredRefs = facets.filter(e => - filterEntity(initialRequest.text, e), - ); - + const { text, start } = decodeCursor(request); + const filteredRefs = facets.filter(e => filterEntity(text, e)); + const end = start + limit; return { - items: filteredRefs.slice(0, limit), - + items: filteredRefs.slice(0, end), ...encodeCursor({ entities: filteredRefs, - limit, - payload: { text: initialRequest.text, start: limit }, + limit: end, + payload: { + text, + start: end, + }, }), }; }, - [], + [facetsPromise], { loading: true, value: { items: [] } }, ); } -function decodeCursor(cursor: string): FacetsCursor { - return JSON.parse(atob(cursor)); +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({ @@ -139,9 +135,10 @@ function encodeCursor({ } function filterEntity(text: string, entity: Entity) { + const normalizedText = text.trim(); return ( - entity.kind.includes(text) || - entity.metadata.namespace?.includes(text) || - entity.metadata.name.includes(text) + entity.kind.includes(normalizedText) || + entity.metadata.namespace?.includes(normalizedText) || + entity.metadata.name.includes(normalizedText) ); } From 03cbaa4ae4975bc4de61f50fe569a3ca2a9d21af Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Sun, 11 Jun 2023 23:48:54 +0200 Subject: [PATCH 10/24] catalog-react: pass optional limit Signed-off-by: Vincenzo Scamporlino --- .../src/components/EntityOwnerPicker/useQueryEntities.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts index 4d1373bfde..186cfa5566 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.ts @@ -28,10 +28,11 @@ export function useQueryEntities() { return useAsyncFn( async ( request: { text: string } | QueryEntitiesResponse, + options?: { limit: number }, ): Promise => { const initialRequest = request as { text: string }; const cursorRequest = request as QueryEntitiesResponse; - const limit = 20; + const limit = options?.limit ?? 20; if (cursorRequest.cursor) { const response = await catalogApi.queryEntities({ From 84c03b1fe35ade57757c852e3af157821adb9673 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Sun, 11 Jun 2023 23:57:27 +0200 Subject: [PATCH 11/24] catalog-react: api report Signed-off-by: Vincenzo Scamporlino --- plugins/catalog-react/api-report.md | 9 ++++++++- .../components/EntityOwnerPicker/EntityOwnerPicker.tsx | 3 +++ .../src/components/EntityOwnerPicker/index.ts | 5 ++++- 3 files changed, 15 insertions(+), 2 deletions(-) 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.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index f894368a4a..47e9689004 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -55,6 +55,9 @@ const useStyles = makeStyles( const icon = ; const checkedIcon = ; +/** + * @public + */ export type EntityOwnerPickerProps = { mode?: 'owners-only' | 'all'; }; 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'; From e1ed21f0ea81a3adab8026ffc088fb090f1a8410 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 00:12:52 +0200 Subject: [PATCH 12/24] catalog-react: add useQueryEntities tests Signed-off-by: Vincenzo Scamporlino --- .../useQueryEntities.test.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts 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, + }); + }); +}); From b59ed039c2a9a7ad5a52c2caf75cea7c0b0fd796 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 00:13:36 +0200 Subject: [PATCH 13/24] catalog-react: clarify methods Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/EntityOwnerPicker.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 47e9689004..ed22e126c5 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -83,11 +83,10 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], ); - const [{ value, loading }, handleFetch, { getEntity, setEntity }] = - useFetchEntities({ - mode, - initialSelectedOwnersRefs: selectedOwners, - }); + const [{ value, loading }, handleFetch, cache] = useFetchEntities({ + mode, + initialSelectedOwnersRefs: selectedOwners, + }); useDebouncedEffect(() => handleFetch({ text }), [text, handleFetch], 250); const availableOwners = value?.items || []; @@ -136,7 +135,7 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { getOptionLabel={o => { const entity = typeof o === 'string' - ? getEntity(o) || + ? cache.getEntity(o) || parseEntityRef(o, { defaultKind: 'group', defaultNamespace: 'default', @@ -152,7 +151,7 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { typeof e === 'string' ? e : stringifyEntityRef(e); if (typeof e !== 'string') { - setEntity(e); + cache.setEntity(e); } return entityRef; From 12a34bb4b424a73c436fa0496978fac78f5d77e7 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 00:21:30 +0200 Subject: [PATCH 14/24] catalog-react: clarify changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/little-boats-think.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/little-boats-think.md b/.changeset/little-boats-think.md index d389bfbbb1..e2c81b6f21 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 drowback of displaying users and groups who aren't owner of any entity. From 3cdfc8fed2117f42133ab442b846b7bc631bbef8 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 00:25:08 +0200 Subject: [PATCH 15/24] catalog: expose owner picker mode Signed-off-by: Vincenzo Scamporlino --- plugins/catalog/api-report.md | 3 +++ .../src/components/CatalogPage/DefaultCatalogPage.tsx | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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.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) { - + From 886055301abeda69c8d6a46d4be5fb266a1a8d0b Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 09:58:07 +0200 Subject: [PATCH 16/24] catalog: add changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/unlucky-feet-smell.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/unlucky-feet-smell.md 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. From 4d2f271845a0427c2e64db529709ebfaff3eae64 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 11:01:07 +0200 Subject: [PATCH 17/24] techdocs: mock facets endpoint Signed-off-by: Vincenzo Scamporlino --- .../src/home/components/DefaultTechDocsHome.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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({ From 46bc0d3248d5e39cca50eca3b7a23ef02651c21b Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 11:11:22 +0200 Subject: [PATCH 18/24] catalog-react: fix spelling Signed-off-by: Vincenzo Scamporlino --- .changeset/little-boats-think.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/little-boats-think.md b/.changeset/little-boats-think.md index e2c81b6f21..1787477c38 100644 --- a/.changeset/little-boats-think.md +++ b/.changeset/little-boats-think.md @@ -9,4 +9,4 @@ The component now loads entities asynchronously, resulting in improved performan - ``: 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 drowback of displaying users and groups who aren't owner of any entity. +- `` 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. From df3abef6400a34c12a3554b30a1e2ba0efbae92e Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 11:45:41 +0200 Subject: [PATCH 19/24] api-docs: mock facets endpoint Signed-off-by: Vincenzo Scamporlino --- .../DefaultApiExplorerPage.test.tsx | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx index f89897524d..d0c9e66d18 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 { @@ -60,25 +55,12 @@ 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({ From 842c2bedfe0fe8f4a315133b843c394717451d2a Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 12:03:25 +0200 Subject: [PATCH 20/24] api-docs: remove warnings in test Signed-off-by: Vincenzo Scamporlino --- .../DefaultApiExplorerPage.test.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx index d0c9e66d18..0480ef30a8 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx @@ -37,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,7 +60,9 @@ describe('DefaultApiExplorerPage', () => { getLocationByRef: () => Promise.resolve({ id: 'id', type: 'url', target: 'url' }), getEntitiesByRefs: () => Promise.resolve({ items: [] }), - getEntityFacets: async () => ({ facets: { 'relations.ownedBy': [] } }), + getEntityFacets: async () => ({ + facets: { 'relations.ownedBy': [] }, + }), }; const configApi: ConfigApi = new ConfigReader({ @@ -116,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 () => { @@ -143,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 () => { From a31373e11148c37915166fee4295bd3ba09ef3b6 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 12 Jun 2023 12:56:45 +0200 Subject: [PATCH 21/24] catalog: mock entity facets endpoint Signed-off-by: Vincenzo Scamporlino --- .../src/components/CatalogPage/DefaultCatalogPage.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx index 3411656426..eb79cd048c 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx @@ -105,6 +105,7 @@ describe('DefaultCatalogPage', () => { }), getLocationByRef: () => Promise.resolve({ id: 'id', type: 'url', target: 'url' }), + getEntityFacets: async () => ({ facets: { 'relations.ownedBy': [] } }), getEntityByRef: async entityRef => { return { apiVersion: 'backstage.io/v1alpha1', From 6e13d7449475d4e8ff32fad7bc4484ea267f16d8 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 13 Jun 2023 11:25:37 +0200 Subject: [PATCH 22/24] catalog: fix mocked methods Signed-off-by: Vincenzo Scamporlino --- .../CatalogPage/DefaultCatalogPage.test.tsx | 38 +++++-------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx index eb79cd048c..4a60b7e6c5 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,32 +97,18 @@ describe('DefaultCatalogPage', () => { }, ], }, - ] as Entity[], + ], }), getLocationByRef: () => Promise.resolve({ id: 'id', type: 'url', target: 'url' }), - getEntityFacets: async () => ({ facets: { 'relations.ownedBy': [] } }), - 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', From e4fc4cf0dedb76f42bc7ff6e93123d717f57e22b Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 13 Jun 2023 13:13:20 +0200 Subject: [PATCH 23/24] catalog-react: add clarifying comment Signed-off-by: Vincenzo Scamporlino --- .../src/components/EntityOwnerPicker/useFacetsEntities.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts index 45df259707..76f0b0986e 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts @@ -33,6 +33,13 @@ 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); From 6fb1e94f17a72862d0db5cd17511b73e7ba97ae4 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 13 Jun 2023 20:38:23 +0200 Subject: [PATCH 24/24] catalog-react: catch error when fetching facets Signed-off-by: Vincenzo Scamporlino --- .../EntityOwnerPicker/useFacetsEntities.ts | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts index 76f0b0986e..b9af10b697 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.ts @@ -48,27 +48,30 @@ export function useFacetsEntities({ enabled }: { enabled: boolean }) { 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'), - ), - ); + 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<