Merge pull request #18199 from backstage/vinzscam/entity-owner-picker-introduce-modes

EntityOwnerPicker: add owners-only and all modes
This commit is contained in:
Patrik Oldsberg
2023-06-13 21:25:37 +02:00
committed by GitHub
16 changed files with 1013 additions and 171 deletions
+6 -2
View File
@@ -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:
- `<EntityOwnerPicker mode="owners-only" />`: 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.
- `<EntityOwnerPicker mode="all" />` 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.
+6
View File
@@ -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 `<CatalogIndexPage ownerPickerMode="all" />` particularly suitable for larger catalogs. In this new mode, `EntityOwnerPicker` will display all the users and groups present in the catalog.
@@ -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 () => {
+8 -1
View File
@@ -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: (
@@ -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<CatalogApi['queryEntities']> =
const mockedGetEntitiesByRef: jest.MockedFn<CatalogApi['getEntitiesByRefs']> =
jest.fn();
const mockedGetEntityFacets: jest.MockedFn<CatalogApi['getEntityFacets']> =
jest.fn();
const mockCatalogApi: Partial<CatalogApi> = {
queryEntities: mockedQueryEntities,
getEntitiesByRefs: mockedGetEntitiesByRef,
getEntityFacets: mockedGetEntityFacets,
};
const mockErrorApi = new MockErrorApi();
describe('<EntityOwnerPicker/>', () => {
describe('<EntityOwnerPicker mode="all" />', () => {
const mockApis = TestApiRegistry.from(
[catalogApiRef, mockCatalogApi],
[errorApiRef, mockErrorApi],
@@ -154,7 +158,7 @@ describe('<EntityOwnerPicker/>', () => {
await renderWithEffects(
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider value={{}}>
<EntityOwnerPicker />
<EntityOwnerPicker mode="all" />
</MockEntityListContextProvider>
</ApiProvider>,
);
@@ -205,7 +209,7 @@ describe('<EntityOwnerPicker/>', () => {
queryParameters,
}}
>
<EntityOwnerPicker />
<EntityOwnerPicker mode="all" />
</MockEntityListContextProvider>
</ApiProvider>,
);
@@ -243,7 +247,7 @@ describe('<EntityOwnerPicker/>', () => {
queryParameters,
}}
>
<EntityOwnerPicker />
<EntityOwnerPicker mode="all" />
</MockEntityListContextProvider>
</ApiProvider>,
);
@@ -284,7 +288,7 @@ describe('<EntityOwnerPicker/>', () => {
updateFilters,
}}
>
<EntityOwnerPicker />
<EntityOwnerPicker mode="all" />
</MockEntityListContextProvider>
</ApiProvider>,
);
@@ -312,7 +316,7 @@ describe('<EntityOwnerPicker/>', () => {
filters: { owners: new EntityOwnerFilter(['some-owner']) },
}}
>
<EntityOwnerPicker />
<EntityOwnerPicker mode="all" />
</MockEntityListContextProvider>
</ApiProvider>,
);
@@ -344,7 +348,7 @@ describe('<EntityOwnerPicker/>', () => {
queryParameters: { owners: ['team-a'] },
}}
>
<EntityOwnerPicker />
<EntityOwnerPicker mode="all" />
</MockEntityListContextProvider>
</ApiProvider>,
);
@@ -362,7 +366,180 @@ describe('<EntityOwnerPicker/>', () => {
queryParameters: { owners: ['team-b'] },
}}
>
<EntityOwnerPicker />
<EntityOwnerPicker mode="all" />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
owners: new EntityOwnerFilter(['group:default/team-b']),
});
});
});
describe('<EntityOwnerPicker mode="owners-only" />', () => {
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(
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider value={{}}>
<EntityOwnerPicker mode="owners-only" />
</MockEntityListContextProvider>
</ApiProvider>,
);
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(
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters,
}}
>
<EntityOwnerPicker mode="owners-only" />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
owners: new EntityOwnerFilter(['group:default/another-owner']),
});
});
it('adds owners to filters', async () => {
const updateFilters = jest.fn();
await renderWithEffects(
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityOwnerPicker mode="owners-only" />
</MockEntityListContextProvider>
</ApiProvider>,
);
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(
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider
value={{
updateFilters,
filters: { owners: new EntityOwnerFilter(['some-owner']) },
}}
>
<EntityOwnerPicker mode="owners-only" />
</MockEntityListContextProvider>
</ApiProvider>,
);
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(
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { owners: ['team-a'] },
}}
>
<EntityOwnerPicker mode="owners-only" />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
owners: new EntityOwnerFilter(['group:default/team-a']),
});
rendered.rerender(
<ApiProvider apis={mockApis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { owners: ['team-b'] },
}}
>
<EntityOwnerPicker mode="owners-only" />
</MockEntityListContextProvider>
</ApiProvider>,
);
@@ -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 = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
/**
* @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 (
<FormControlLabel
@@ -201,7 +180,10 @@ export const EntityOwnerPicker = () => {
<PersonIcon fontSize="small" />
)}
&nbsp;
{humanizeEntity(entity, entity.metadata.name)}
{humanizeEntity(
entity,
humanizeEntityRef(entity, { defaultKind: entity.kind }),
)}
</Box>
}
/>
@@ -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 = () => {
</Box>
);
};
/**
* 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<Record<string, Entity>>({});
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;
},
};
}
@@ -15,4 +15,7 @@
*/
export { EntityOwnerPicker } from './EntityOwnerPicker';
export type { CatalogReactEntityOwnerPickerClassKey } from './EntityOwnerPicker';
export type {
CatalogReactEntityOwnerPickerClassKey,
EntityOwnerPickerProps,
} from './EntityOwnerPicker';
@@ -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<CatalogApi['getEntityFacets']> =
jest.fn();
const mockCatalogApi: Partial<CatalogApi> = {
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,
});
});
});
});
@@ -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<Entity>(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<FacetsEntitiesResponse>
>(
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)
);
}
@@ -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<Record<string, Entity>>({});
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;
},
};
}
@@ -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<CatalogApi['queryEntities']> =
jest.fn();
const mockCatalogApi: Partial<CatalogApi> = {
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,
});
});
});
@@ -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<QueryEntitiesResponse> => {
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 },
);
}
+3
View File
@@ -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<CatalogTableRow>['options'];
}
@@ -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<ProfileInfo> = {
displayName: 'Display Name',
@@ -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<CatalogTableRow>['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) {
<EntityKindPicker initialFilter={initialKind} />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
<EntityOwnerPicker mode={ownerPickerMode} />
<EntityLifecyclePicker />
<EntityTagPicker />
<EntityProcessingStatusPicker />
@@ -36,8 +36,8 @@ import React from 'react';
import { rootDocsRouteRef } from '../../routes';
import { DefaultTechDocsHome } from './DefaultTechDocsHome';
const mockCatalogApi = {
getEntityByRef: () => Promise.resolve(),
const mockCatalogApi: Partial<CatalogApi> = {
getEntityFacets: async () => ({ facets: { 'relations.ownedBy': [] } }),
getEntitiesByRefs: () => Promise.resolve({ items: [] }),
getEntities: async () => ({
items: [
@@ -51,7 +51,7 @@ const mockCatalogApi = {
},
],
}),
} as Partial<CatalogApi>;
};
describe('TechDocs Home', () => {
const configApi: ConfigApi = new ConfigReader({