Merge pull request #20339 from backstage/vinzscam/user-list-picker

UserListPicker: decouple from EntityListContext
This commit is contained in:
Patrik Oldsberg
2023-10-23 10:24:48 +02:00
committed by GitHub
19 changed files with 1496 additions and 315 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog-react': minor
---
The `UserListPicker` component has undergone improvements to enhance its performance.
The previous implementation inferred the number of owned and starred entities based on the entities available in the `EntityListContext`. The updated version no longer relies on the `EntityListContext` for inference, allowing for better decoupling.
The component now loads the entities' count asynchronously, resulting in improved performance and responsiveness. For this purpose, some of the exported filters such as `EntityTagFilter`, `EntityOwnerFilter`, `EntityLifecycleFilter` and `EntityNamespaceFilter` have now the `getCatalogFilters` method implemented.
@@ -52,6 +52,10 @@ describe('DefaultApiExplorerPage', () => {
kind: 'API',
metadata: {
name: 'Entity1',
annotations: {
'backstage.io/view-url': 'viewurl',
'backstage.io/edit-url': 'editurl',
},
},
spec: { type: 'openapi' },
},
@@ -63,6 +67,11 @@ describe('DefaultApiExplorerPage', () => {
getEntityFacets: async () => ({
facets: { 'relations.ownedBy': [] },
}),
queryEntities: async () => ({
items: [],
pageInfo: {},
totalItems: 0,
}),
};
const configApi: ConfigApi = new ConfigReader({
@@ -152,37 +161,39 @@ describe('DefaultApiExplorerPage', () => {
it('should render the default actions of an item in the grid', async () => {
await renderWrapped(<DefaultApiExplorerPage />);
expect(await screen.findByText(/All apis \(1\)/)).toBeInTheDocument();
expect(await screen.findByTitle(/View/)).toBeInTheDocument();
expect(await screen.findByTitle(/View/)).toBeInTheDocument();
expect(await screen.findByTitle(/Edit/)).toBeInTheDocument();
expect(await screen.findByTitle(/Add to favorites/)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/All apis \(1\)/)).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /view/i })).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
expect(screen.getByTitle(/Add to favorites/)).toBeInTheDocument();
});
it('should render the custom actions of an item passed as prop', async () => {
const actions: TableProps<CatalogTableRow>['actions'] = [
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Foo Action',
disabled: false,
onClick: () => jest.fn(),
};
{
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Foo Action',
disabled: false,
onClick: jest.fn(),
},
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Bar Action',
disabled: true,
onClick: () => jest.fn(),
};
{
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Bar Action',
disabled: true,
onClick: jest.fn(),
},
];
await renderWrapped(<DefaultApiExplorerPage actions={actions} />);
expect(await screen.findByText(/All apis \(1\)/)).toBeInTheDocument();
expect(await screen.findByTitle(/Foo Action/)).toBeInTheDocument();
expect(await screen.findByTitle(/Bar Action/)).toBeInTheDocument();
expect((await screen.findByTitle(/Bar Action/)).firstChild).toBeDisabled();
await waitFor(() => {
expect(screen.getByText(/All apis \(1\)/)).toBeInTheDocument();
});
expect(screen.getByTitle(/Foo Action/)).toBeInTheDocument();
expect(screen.getByTitle(/Bar Action/)).toBeInTheDocument();
expect(screen.getByTitle(/Bar Action/).firstChild).toBeDisabled();
});
});
+32 -2
View File
@@ -142,7 +142,7 @@ export const columnFactories: Readonly<{
export type DefaultEntityFilters = {
kind?: EntityKindFilter;
type?: EntityTypeFilter;
user?: UserListFilter;
user?: UserListFilter | EntityUserFilter;
owners?: EntityOwnerFilter;
lifecycles?: EntityLifecycleFilter;
tags?: EntityTagFilter;
@@ -224,6 +224,8 @@ export class EntityLifecycleFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -275,6 +277,8 @@ export class EntityNamespaceFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -289,6 +293,8 @@ export class EntityOrphanFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
readonly value: boolean;
}
@@ -297,6 +303,8 @@ export class EntityOwnerFilter implements EntityFilter {
constructor(values: string[]);
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -447,6 +455,8 @@ export class EntityTagFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -497,6 +507,26 @@ export interface EntityTypePickerProps {
initialFilter?: string;
}
// @public (undocumented)
export class EntityUserFilter implements EntityFilter {
// (undocumented)
static all(): EntityUserFilter;
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string[]>;
// (undocumented)
static owned(ownershipEntityRefs: string[]): EntityUserFilter;
// (undocumented)
readonly refs?: string[] | undefined;
// (undocumented)
static starred(starredEntityRefs: string[]): EntityUserFilter;
// (undocumented)
toQueryValue(): string;
// (undocumented)
readonly value: UserListFilterKind;
}
// @public
export const FavoriteEntity: (
props: FavoriteEntityProps,
@@ -620,7 +650,7 @@ export function useRelatedEntities(
error: Error | undefined;
};
// @public
// @public @deprecated
export class UserListFilter implements EntityFilter {
constructor(
value: UserListFilterKind,
@@ -16,15 +16,19 @@
import React from 'react';
import { fireEvent, render, waitFor, screen } from '@testing-library/react';
import {
Entity,
RELATION_OWNED_BY,
UserEntity,
} from '@backstage/catalog-model';
import { UserListPicker } from './UserListPicker';
import { UserEntity } from '@backstage/catalog-model';
import { UserListPicker, UserListPickerProps } from './UserListPicker';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityTagFilter, UserListFilter } from '../../filters';
import { CatalogApi } from '@backstage/catalog-client';
import {
EntityKindFilter,
EntityNamespaceFilter,
EntityTagFilter,
EntityUserFilter,
} from '../../filters';
import {
CatalogApi,
QueryEntitiesInitialRequest,
} from '@backstage/catalog-client';
import { catalogApiRef } from '../../api';
import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils';
import { ApiProvider } from '@backstage/core-app-api';
@@ -35,7 +39,7 @@ import {
identityApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { useEntityOwnership } from '../../hooks';
import { MockStarredEntitiesApi, starredEntitiesApiRef } from '../../apis';
const mockUser: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -54,136 +58,151 @@ const mockConfigApi = {
} as Partial<ConfigApi>;
const mockCatalogApi = {
getEntityByRef: () => Promise.resolve(mockUser),
} as Partial<CatalogApi>;
getEntityByRef: jest.fn(),
queryEntities: jest.fn(),
} as Partial<jest.Mocked<CatalogApi>>;
const mockIdentityApi = {
getUserId: () => 'testUser',
getIdToken: async () => undefined,
} as Partial<IdentityApi>;
getBackstageIdentity: jest.fn(),
} as Partial<jest.Mocked<IdentityApi>>;
const mockStarredEntitiesApi = new MockStarredEntitiesApi();
const apis = TestApiRegistry.from(
[configApiRef, mockConfigApi],
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[starredEntitiesApiRef, mockStarredEntitiesApi],
);
const mockIsOwnedEntity = jest.fn(
(entity: Entity) => entity.metadata.name === 'component-1',
);
const mockIsStarredEntity = jest.fn(
(entity: Entity) => entity.metadata.name === 'component-3',
);
jest.mock('../../hooks', () => {
const actual = jest.requireActual('../../hooks');
return {
...actual,
useEntityOwnership: jest.fn(() => ({
isOwnedEntity: mockIsOwnedEntity,
})),
useStarredEntities: () => ({
isStarredEntity: mockIsStarredEntity,
}),
};
});
const backendEntities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-1',
name: 'component-1',
tags: ['tag1'],
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/testuser',
},
],
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-2',
name: 'component-2',
tags: ['tag1'],
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-2',
name: 'component-3',
tags: [],
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-2',
name: 'component-4',
tags: [],
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/testuser',
},
],
},
];
const ownershipEntityRefs = ['user:default/testuser'];
describe('<UserListPicker />', () => {
it('renders filter groups', () => {
const mockQueryEntitiesImplementation: CatalogApi['queryEntities'] =
async request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
// owned entities
return { items: [], totalItems: 3, pageInfo: {} };
}
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
// starred entities
return {
items: [
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'e-1', namespace: 'default' },
},
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'e-2', namespace: 'default' },
},
],
totalItems: 2,
pageInfo: {},
};
}
// all items
return { items: [], totalItems: 10, pageInfo: {} };
};
beforeAll(() => {
mockStarredEntitiesApi.toggleStarred('component:default/e-1');
mockStarredEntitiesApi.toggleStarred('component:default/e-2');
});
beforeEach(() => {
mockCatalogApi.getEntityByRef?.mockResolvedValue(mockUser);
mockIdentityApi.getBackstageIdentity?.mockResolvedValue({
ownershipEntityRefs,
type: 'user',
userEntityRef: 'user:default/testuser',
});
mockCatalogApi.queryEntities?.mockImplementation(
mockQueryEntitiesImplementation,
);
});
afterEach(() => {
jest.resetAllMocks();
});
it('renders filter groups', async () => {
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<MockEntityListContextProvider value={{}}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalled(),
);
expect(screen.getByText('Personal')).toBeInTheDocument();
expect(screen.getByText('Test Company')).toBeInTheDocument();
});
it('renders filters', () => {
it('renders filters', async () => {
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<MockEntityListContextProvider
value={{
filters: { namespace: new EntityNamespaceFilter(['default']) },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(
screen.getAllByRole('menuitem').map(({ textContent }) => textContent),
).toEqual(['Owned 1', 'Starred 1', 'All 4']);
});
it('includes counts alongside each filter', async () => {
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
// Material UI renders ListItemSecondaryActions outside the
// menuitem itself, so we pick off the next sibling.
await waitFor(() => {
await waitFor(() =>
expect(
screen.getAllByRole('menuitem').map(({ textContent }) => textContent),
).toEqual(['Owned 1', 'Starred 1', 'All 4']);
).toEqual(['Owned 3', 'Starred 2', 'All 10']),
);
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['default'],
},
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['default'],
'relations.ownedBy': ['user:default/testuser'],
},
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['default'],
'metadata.name': ['e-1', 'e-2'],
},
limit: 1000,
});
});
@@ -192,7 +211,6 @@ describe('<UserListPicker />', () => {
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
filters: { tags: new EntityTagFilter(['tag1']) },
}}
>
@@ -204,35 +222,74 @@ describe('<UserListPicker />', () => {
await waitFor(() => {
expect(
screen.getAllByRole('menuitem').map(({ textContent }) => textContent),
).toEqual(['Owned 1', 'Starred 0', 'All 2']);
).toEqual(['Owned 3', 'Starred 2', 'All 10']);
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'metadata.tags': ['tag1'] },
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'metadata.name': ['e-1', 'e-2'], 'metadata.tags': ['tag1'] },
limit: 1000,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'relations.ownedBy': ['user:default/testuser'],
'metadata.tags': ['tag1'],
},
limit: 0,
});
});
it('respects the query parameter filter value', () => {
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { user: 'owned' };
const queryParameters = { user: 'owned', kind: 'component' };
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters, queryParameters }}
value={{
updateFilters,
queryParameters,
filters: { kind: new EntityKindFilter('component') },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { kind: 'component' },
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { kind: 'component', 'metadata.name': ['e-1', 'e-2'] },
limit: 1000,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
kind: 'component',
'relations.ownedBy': ['user:default/testuser'],
},
limit: 0,
});
});
it('updates user filter when a menuitem is selected', () => {
it('updates user filter when a menuitem is selected', async () => {
const updateFilters = jest.fn();
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters }}
>
<MockEntityListContextProvider value={{ updateFilters }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
@@ -240,40 +297,55 @@ describe('<UserListPicker />', () => {
fireEvent.click(screen.getByText('Starred'));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'starred',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.starred([
'component:default/e-1',
'component:default/e-2',
]),
}),
);
});
it('responds to external queryParameters changes', () => {
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['all'] },
queryParameters: { user: ['all'], kind: 'component' },
filters: {
kind: new EntityKindFilter('component'),
user: undefined,
},
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity),
});
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.all(),
}),
);
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['owned'] },
queryParameters: { user: ['owned'], kind: 'component' },
filters: {
kind: new EntityKindFilter('component'),
user: undefined,
},
}}
>
<UserListPicker />
@@ -281,101 +353,237 @@ describe('<UserListPicker />', () => {
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
user: EntityUserFilter.owned(ownershipEntityRefs),
});
});
describe.each`
type | filterFn
${'owned'} | ${mockIsOwnedEntity}
${'starred'} | ${mockIsStarredEntity}
`('filter resetting for $type entities', ({ type, filterFn }) => {
let updateFilters: jest.Mock;
describe('filter resetting', () => {
const updateFilters = jest.fn();
const picker = (props: { loading: boolean }) => (
const Picker = ({ ...props }: UserListPickerProps) => (
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters, loading: props.loading }}
value={{
updateFilters,
filters: { kind: new EntityKindFilter('component') },
}}
>
<UserListPicker initialFilter={type} />
<UserListPicker {...props} />
</MockEntityListContextProvider>
</ApiProvider>
);
beforeEach(() => {
updateFilters = jest.fn();
});
describe(`when there are no owned entities matching the filter`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockReturnValue(new Promise(() => {}));
describe(`when there are no ${type} entities match the filter`, () => {
beforeEach(() => {
filterFn.mockReturnValue(false);
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalled(),
);
await expect(() =>
waitFor(() => expect(updateFilters).toHaveBeenCalled()),
).rejects.toThrow();
});
it('does not reset the filter while entities are loading', () => {
render(picker({ loading: true }));
it('does not reset the filter while owned entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter(
'all',
mockIsOwnedEntity,
mockIsStarredEntity,
),
user: expect.any(Object),
});
});
it('does not reset the filter while owned entities are loading', () => {
const isOwnedEntity = jest.fn(() => false);
(useEntityOwnership as jest.Mock).mockReturnValueOnce({
loading: true,
isOwnedEntity,
it('resets the filter to "all" when entities are loaded', async () => {
mockCatalogApi.queryEntities?.mockImplementation(async request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
return { items: [], totalItems: 0, pageInfo: {} };
}
return mockQueryEntitiesImplementation(request);
});
render(picker({ loading: false }));
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter('all', isOwnedEntity, mockIsStarredEntity),
});
});
render(<Picker initialFilter="owned" />);
it('resets the filter to "all" when entities are loaded', () => {
render(picker({ loading: false }));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'all',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.all(),
}),
);
});
});
describe(`when there are some ${type} entities present`, () => {
beforeEach(() => {
filterFn.mockReturnValue(true);
describe(`when there are no starred entities match the filter`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(
() => new Promise(() => {}),
);
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalled(),
);
expect(updateFilters).not.toHaveBeenCalled();
});
it('does not reset the filter while entities are loading', () => {
render(picker({ loading: true }));
it('does not reset the filter while starred entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter(
'all',
mockIsOwnedEntity,
mockIsStarredEntity,
),
user: expect.any(Object),
});
});
it('does not reset the filter when entities are loaded', () => {
render(picker({ loading: false }));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
type,
mockIsOwnedEntity,
mockIsStarredEntity,
),
it('resets the filter to "all" when entities are loaded', async () => {
mockCatalogApi.queryEntities?.mockImplementation(async request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
return { items: [], totalItems: 0, pageInfo: {} };
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.all(),
}),
);
});
});
describe(`when there are some owned entities present`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: EntityUserFilter.all(),
});
});
it('does not reset the filter when entities are loaded', async () => {
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.owned(expect.any(Array)),
}),
);
});
});
describe(`when there are some starred entities present`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: EntityUserFilter.all(),
});
});
it('does not reset the filter when entities are loaded', async () => {
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.starred([
'component:default/e-1',
'component:default/e-2',
]),
}),
);
});
});
});
@@ -32,16 +32,13 @@ import {
} from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { compact } from 'lodash';
import React, { Fragment, useEffect, useMemo, useState } from 'react';
import { UserListFilter } from '../../filters';
import {
useEntityList,
useStarredEntities,
useEntityOwnership,
} from '../../hooks';
import { EntityUserFilter } from '../../filters';
import { useEntityList } from '../../hooks';
import { UserListFilterKind } from '../../types';
import { reduceEntityFilters } from '../../utils';
import { useOwnedEntitiesCount } from './useOwnedEntitiesCount';
import { useAllEntitiesCount } from './useAllEntitiesCount';
import { useStarredEntitiesCount } from './useStarredEntitiesCount';
/** @public */
export type CatalogReactUserListPickerClassKey =
@@ -133,9 +130,7 @@ export const UserListPicker = (props: UserListPickerProps) => {
const {
filters,
updateFilters,
backendEntities,
queryParameters: { kind: kindParameter, user: userParameter },
loading: loadingBackendEntities,
} = useEntityList();
// Remove group items that aren't in availableFilters and exclude
@@ -153,21 +148,17 @@ export const UserListPicker = (props: UserListPickerProps) => {
}))
.filter(({ items }) => !!items.length);
const { isStarredEntity } = useStarredEntities();
const { isOwnedEntity, loading: loadingEntityOwnership } =
useEntityOwnership();
const loading = loadingBackendEntities || loadingEntityOwnership;
// Static filters; used for generating counts of potentially unselected kinds
const ownedFilter = useMemo(
() => new UserListFilter('owned', isOwnedEntity, isStarredEntity),
[isOwnedEntity, isStarredEntity],
);
const starredFilter = useMemo(
() => new UserListFilter('starred', isOwnedEntity, isStarredEntity),
[isOwnedEntity, isStarredEntity],
);
const {
count: ownedEntitiesCount,
loading: loadingOwnedEntities,
filter: ownedEntitiesFilter,
} = useOwnedEntitiesCount();
const { count: allCount } = useAllEntitiesCount();
const {
count: starredEntitiesCount,
filter: starredEntitiesFilter,
loading: loadingStarredEntities,
} = useStarredEntitiesCount();
const queryParamUserFilter = useMemo(
() => [userParameter].flat()[0],
@@ -175,33 +166,16 @@ export const UserListPicker = (props: UserListPickerProps) => {
);
const [selectedUserFilter, setSelectedUserFilter] = useState(
queryParamUserFilter ?? initialFilter,
(queryParamUserFilter as UserListFilterKind) ?? initialFilter,
);
// To show proper counts for each section, apply all other frontend filters _except_ the user
// filter that's controlled by this picker.
const entitiesWithoutUserFilter = useMemo(
() =>
backendEntities.filter(
reduceEntityFilters(
compact(Object.values({ ...filters, user: undefined })),
),
),
[filters, backendEntities],
);
const filterCounts = useMemo<Record<string, number>>(
() => ({
all: entitiesWithoutUserFilter.length,
starred: entitiesWithoutUserFilter.filter(entity =>
starredFilter.filterEntity(entity),
).length,
owned: entitiesWithoutUserFilter.filter(entity =>
ownedFilter.filterEntity(entity),
).length,
}),
[entitiesWithoutUserFilter, starredFilter, ownedFilter],
);
const filterCounts = useMemo(() => {
return {
all: allCount,
starred: starredEntitiesCount,
owned: ownedEntitiesCount,
};
}, [starredEntitiesCount, ownedEntitiesCount, allCount]);
// Set selected user filter on query parameter updates; this happens at initial page load and from
// external updates to the page location.
@@ -211,6 +185,8 @@ export const UserListPicker = (props: UserListPickerProps) => {
}
}, [queryParamUserFilter]);
const loading = loadingOwnedEntities || loadingStarredEntities;
useEffect(() => {
if (
!loading &&
@@ -223,16 +199,32 @@ export const UserListPicker = (props: UserListPickerProps) => {
}, [loading, filterCounts, selectedUserFilter, setSelectedUserFilter]);
useEffect(() => {
updateFilters({
user: selectedUserFilter
? new UserListFilter(
selectedUserFilter as UserListFilterKind,
isOwnedEntity,
isStarredEntity,
)
: undefined,
});
}, [selectedUserFilter, isOwnedEntity, isStarredEntity, updateFilters]);
if (!selectedUserFilter) {
return;
}
if (loading) {
return;
}
const getFilter = () => {
if (selectedUserFilter === 'owned') {
return ownedEntitiesFilter;
}
if (selectedUserFilter === 'starred') {
return starredEntitiesFilter;
}
return EntityUserFilter.all();
};
updateFilters({ user: getFilter() });
}, [
selectedUserFilter,
starredEntitiesFilter,
ownedEntitiesFilter,
updateFilters,
loading,
]);
return (
<Card className={classes.root}>
@@ -0,0 +1,105 @@
/*
* 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 React, { PropsWithChildren } from 'react';
import { CatalogApi } from '@backstage/catalog-client';
import { useAllEntitiesCount } from './useAllEntitiesCount';
import { renderHook, waitFor } from '@testing-library/react';
import { EntityListProvider, useEntityList } from '../../hooks';
import { catalogApiRef } from '../../api';
import { ApiRef } from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { EntityOwnerFilter } from '../../filters';
import { useMountEffect } from '@react-hookz/web';
const mockQueryEntities: jest.MockedFn<CatalogApi['queryEntities']> = jest.fn();
const mockCatalogApi: jest.Mocked<Partial<CatalogApi>> = {
queryEntities: mockQueryEntities,
};
jest.mock('@backstage/core-plugin-api', () => {
const actual = jest.requireActual('@backstage/core-plugin-api');
return {
...actual,
useApi: (ref: ApiRef<any>) =>
ref === catalogApiRef ? mockCatalogApi : actual.useApi(ref),
};
});
describe('useAllEntitiesCount', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the count', async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
function WrapFilters(props: PropsWithChildren<{}>) {
const { updateFilters } = useEntityList();
useMountEffect(() => {
updateFilters({
owners: new EntityOwnerFilter(['user:default/owner']),
});
});
return <>{props.children}</>;
}
const { result } = renderHook(() => useAllEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>
<WrapFilters>{children}</WrapFilters>
</EntityListProvider>
</MemoryRouter>
),
});
await waitFor(() =>
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'relations.ownedBy': ['user:default/owner'],
},
limit: 0,
}),
);
expect(result.current).toEqual({ count: 10, loading: false });
});
it(`shouldn't invoke the endpoint at startup, when filters are missing`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useAllEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>{children}</EntityListProvider>
</MemoryRouter>
),
});
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({ count: 0, loading: false });
});
});
@@ -0,0 +1,61 @@
/*
* 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 { QueryEntitiesInitialRequest } from '@backstage/catalog-client';
import { useApi } from '@backstage/core-plugin-api';
import { compact, isEqual } from 'lodash';
import { useMemo, useRef } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { useEntityList } from '../../hooks';
import { reduceCatalogFilters } from '../../utils';
export function useAllEntitiesCount() {
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
const prevRequest = useRef<QueryEntitiesInitialRequest>();
const request = useMemo(() => {
const { user, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const filter = reduceCatalogFilters(compacted);
const newRequest: QueryEntitiesInitialRequest = {
filter,
limit: 0,
};
if (Object.keys(filter).length === 0) {
prevRequest.current = undefined;
return prevRequest.current;
}
if (isEqual(newRequest, prevRequest.current)) {
return prevRequest.current;
}
prevRequest.current = newRequest;
return newRequest;
}, [filters]);
const { value: count, loading } = useAsync(async () => {
if (request === undefined) {
return 0;
}
const { totalItems } = await catalogApi.queryEntities(request);
return totalItems;
}, [request]);
return { count, loading };
}
@@ -0,0 +1,235 @@
/*
* 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 React, { PropsWithChildren } from 'react';
import { CatalogApi } from '@backstage/catalog-client';
import { renderHook, waitFor } from '@testing-library/react';
import {
DefaultEntityFilters,
EntityListProvider,
useEntityList,
} from '../../hooks';
import { catalogApiRef } from '../../api';
import {
ApiRef,
IdentityApi,
identityApiRef,
} from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { useOwnedEntitiesCount } from './useOwnedEntitiesCount';
import {
EntityNamespaceFilter,
EntityOwnerFilter,
EntityUserFilter,
} from '../../filters';
import { useMountEffect } from '@react-hookz/web';
const mockQueryEntities: jest.MockedFn<CatalogApi['queryEntities']> = jest.fn();
const mockCatalogApi: jest.Mocked<Partial<CatalogApi>> = {
queryEntities: mockQueryEntities,
};
const mockGetBackstageIdentity: jest.MockedFn<
IdentityApi['getBackstageIdentity']
> = jest.fn();
jest.mock('@backstage/core-plugin-api', () => {
const actual = jest.requireActual('@backstage/core-plugin-api');
return {
...actual,
useApi: (ref: ApiRef<any>) => {
if (ref === catalogApiRef) {
return mockCatalogApi;
}
if (ref === identityApiRef) {
return {
getBackstageIdentity: mockGetBackstageIdentity,
};
}
return actual.useApi(ref);
},
};
});
describe('useOwnedEntitiesCount', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBackstageIdentity.mockResolvedValue({
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
userEntityRef: 'user:default/spiderman',
type: 'user',
});
});
it(`shouldn't invoke queryEntities when filters are loading`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({
count: 0,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
it(`should properly apply the filters`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({
namespace: new EntityNamespaceFilter(['a-namespace']),
}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await waitFor(() =>
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['a-namespace'],
'relations.ownedBy': ['user:default/spiderman', 'user:group/a-group'],
},
limit: 0,
}),
);
expect(result.current).toEqual({
count: 10,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
it(`should return count 0 without invoking queryEntities if owners filter doesn't have claims in common with logged in user`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({
namespace: new EntityNamespaceFilter(['a-namespace']),
owners: new EntityOwnerFilter(['group:default/monsters']),
}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({
count: 0,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
it(`should send claims in common between owners filter and logged in user`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({
namespace: new EntityNamespaceFilter(['a-namespace']),
owners: new EntityOwnerFilter([
'group:default/monsters',
'user:group/a-group',
]),
}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await waitFor(() =>
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['a-namespace'],
'relations.ownedBy': ['user:group/a-group'],
},
limit: 0,
}),
);
expect(result.current).toEqual({
count: 10,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
});
function createWrapperWithInitialFilters(
filters: Partial<DefaultEntityFilters>,
) {
function WrapFilters(props: PropsWithChildren<{}>) {
const { updateFilters } = useEntityList();
useMountEffect(() => {
updateFilters(filters);
});
return <>{props.children}</>;
}
return function Wrapper(props: PropsWithChildren<{}>) {
return (
<MemoryRouter>
<EntityListProvider>
<WrapFilters>{props.children}</WrapFilters>
</EntityListProvider>
</MemoryRouter>
);
};
}
@@ -0,0 +1,133 @@
/*
* 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 { QueryEntitiesInitialRequest } from '@backstage/catalog-client';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
import { compact, intersection, isEqual } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { EntityOwnerFilter, EntityUserFilter } from '../../filters';
import { useEntityList } from '../../hooks';
import { reduceCatalogFilters } from '../../utils';
import useAsyncFn from 'react-use/lib/useAsyncFn';
export function useOwnedEntitiesCount() {
const identityApi = useApi(identityApiRef);
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
const { value: ownershipEntityRefs, loading: loadingEntityRefs } = useAsync(
async () => (await identityApi.getBackstageIdentity()).ownershipEntityRefs,
// load only on mount
[],
);
const prevRequest = useRef<QueryEntitiesInitialRequest>();
const request = useMemo(() => {
const { user, owners, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const allFilter = reduceCatalogFilters(compacted);
const { ['metadata.name']: metadata, ...filter } = allFilter;
const countFilter = getOwnedCountClaims(owners, ownershipEntityRefs);
if (
ownershipEntityRefs?.length === 0 ||
countFilter === undefined ||
Object.keys(filter).length === 0
) {
prevRequest.current = undefined;
return undefined;
}
const newRequest: QueryEntitiesInitialRequest = {
filter: {
...filter,
'relations.ownedBy': countFilter,
},
limit: 0,
};
if (isEqual(newRequest, prevRequest.current)) {
return prevRequest.current;
}
prevRequest.current = newRequest;
return newRequest;
}, [filters, ownershipEntityRefs]);
const [{ value: count, loading: loadingEntityOwnership }, fetchEntities] =
useAsyncFn(
async (
req: QueryEntitiesInitialRequest | undefined,
ownershipEntityRefsParam: string[],
) => {
if (ownershipEntityRefsParam && !req) {
// this implicitly means that there aren't claims in common with
// the logged in users, so avoid invoking the queryEntities endpoint
// which will implicitly returns 0
return 0;
}
const { totalItems } = await catalogApi.queryEntities(req);
return totalItems;
},
[],
{ loading: true },
);
useEffect(() => {
if (ownershipEntityRefs) {
if (request && Object.keys(request).length === 0) {
return;
}
fetchEntities(request, ownershipEntityRefs);
}
}, [fetchEntities, request, ownershipEntityRefs]);
const loading = loadingEntityRefs || loadingEntityOwnership;
const filter = useMemo(
() => EntityUserFilter.owned(ownershipEntityRefs ?? []),
[ownershipEntityRefs],
);
return {
count,
loading,
filter,
ownershipEntityRefs,
};
}
function getOwnedCountClaims(
owners: EntityOwnerFilter | undefined,
ownershipEntityRefs: string[] | undefined,
) {
if (ownershipEntityRefs === undefined) {
return undefined;
}
const ownersRefs = owners?.values ?? [];
if (ownersRefs.length) {
const commonOwnedBy = intersection(ownersRefs, ownershipEntityRefs);
if (commonOwnedBy.length === 0) {
return undefined;
}
return commonOwnedBy;
}
return ownershipEntityRefs;
}
@@ -0,0 +1,127 @@
/*
* 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 React from 'react';
import { CatalogApi } from '@backstage/catalog-client';
import { EntityListProvider, useStarredEntities } from '../../hooks';
import { catalogApiRef } from '../../api';
import { ApiRef } from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { useStarredEntitiesCount } from './useStarredEntitiesCount';
import { renderHook, waitFor } from '@testing-library/react';
const mockQueryEntities: jest.MockedFn<CatalogApi['queryEntities']> = jest.fn();
const mockCatalogApi: jest.Mocked<Partial<CatalogApi>> = {
queryEntities: mockQueryEntities,
};
const mockStarredEntities: jest.MockedFn<() => Set<string>> = jest.fn();
const mockUseStarredEntities: ReturnType<typeof useStarredEntities> = {
get starredEntities() {
return mockStarredEntities();
},
} as ReturnType<typeof useStarredEntities>;
jest.mock('../../hooks', () => {
const actual = jest.requireActual('../../hooks');
return { ...actual, useStarredEntities: () => mockUseStarredEntities };
});
jest.mock('@backstage/core-plugin-api', () => {
const actual = jest.requireActual('@backstage/core-plugin-api');
return {
...actual,
useApi: (ref: ApiRef<any>) =>
ref === catalogApiRef ? mockCatalogApi : actual.useApi(ref),
};
});
describe('useStarredEntitiesCount', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the count', async () => {
mockStarredEntities.mockReturnValue(
new Set(['component:default/favourite1', 'component:default/favourite2']),
);
mockQueryEntities.mockResolvedValue({
items: [
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'favourite1' },
},
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'favourite2' },
},
],
totalItems: 2,
pageInfo: {},
});
const { result } = renderHook(() => useStarredEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>{children}</EntityListProvider>
</MemoryRouter>
),
});
await waitFor(() => {
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'metadata.name': ['favourite1', 'favourite2'],
},
limit: 1000,
});
expect(result.current).toEqual({
count: 2,
loading: false,
filter: {
refs: [
'component:default/favourite1',
'component:default/favourite2',
],
value: 'starred',
},
});
});
});
it(`shouldn't invoke the endpoint if there are no starred entities`, async () => {
mockStarredEntities.mockReturnValue(new Set());
const { result } = renderHook(() => useStarredEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>{children}</EntityListProvider>
</MemoryRouter>
),
});
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({
count: 0,
loading: false,
filter: { refs: [], value: 'starred' },
});
});
});
@@ -0,0 +1,95 @@
/*
* 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 { QueryEntitiesInitialRequest } from '@backstage/catalog-client';
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { compact, isEqual } from 'lodash';
import { useMemo, useRef } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { EntityUserFilter } from '../../filters';
import { useEntityList, useStarredEntities } from '../../hooks';
import { reduceCatalogFilters } from '../../utils';
export function useStarredEntitiesCount() {
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
const { starredEntities } = useStarredEntities();
const prevRequest = useRef<QueryEntitiesInitialRequest>();
const request = useMemo(() => {
const { user, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const filter = reduceCatalogFilters(compacted);
const facet = 'metadata.name';
const newRequest: QueryEntitiesInitialRequest = {
filter: {
...filter,
/**
* here we are filtering entities by `name`. Given this filter,
* the response might contain more entities than expected, in case multiple entities
* of different kind or namespace share the same name. Those extra entities are filtered out
* client side by `EntityUserFilter`, so they won't be visible to the user.
*/
[facet]: Array.from(starredEntities).map(e => parseEntityRef(e).name),
},
/**
* limit is set to a high value as we are not expecting many starred entities
*/
limit: 1000,
};
if (isEqual(newRequest, prevRequest.current)) {
return prevRequest.current;
}
prevRequest.current = newRequest;
return newRequest;
}, [filters, starredEntities]);
const { value: count, loading } = useAsync(async () => {
if (!starredEntities.size) {
return 0;
}
/**
* given a list of starred entity refs and some filters coming from CatalogPage,
* it reduces the list of starred entities, to a list of entities that matches the
* provided filters. It won't be possible to getEntitiesByRefs
* as the method doesn't accept any filter.
*/
const response = await catalogApi.queryEntities(request);
return response.items
.map(e =>
stringifyEntityRef({
kind: e.kind,
namespace: e.metadata.namespace,
name: e.metadata.name,
}),
)
.filter(e => starredEntities.has(e)).length;
}, [request, starredEntities]);
const filter = useMemo(
() => EntityUserFilter.starred(Array.from(starredEntities)),
[starredEntities],
);
return { count, loading, filter };
}
+82
View File
@@ -72,6 +72,10 @@ export class EntityTagFilter implements EntityFilter {
return this.values.every(v => (entity.metadata.tags ?? []).includes(v));
}
getCatalogFilters(): Record<string, string | string[]> {
return { 'metadata.tags': this.values };
}
toQueryValue(): string[] {
return this.values;
}
@@ -136,6 +140,10 @@ export class EntityOwnerFilter implements EntityFilter {
}, [] as string[]);
}
getCatalogFilters(): Record<string, string | string[]> {
return { 'relations.ownedBy': this.values };
}
filterEntity(entity: Entity): boolean {
return this.values.some(v =>
getEntityRelations(entity, RELATION_OWNED_BY).some(
@@ -160,6 +168,10 @@ export class EntityOwnerFilter implements EntityFilter {
export class EntityLifecycleFilter implements EntityFilter {
constructor(readonly values: string[]) {}
getCatalogFilters(): Record<string, string | string[]> {
return { 'spec.lifecycle': this.values };
}
filterEntity(entity: Entity): boolean {
return this.values.some(v => entity.spec?.lifecycle === v);
}
@@ -176,6 +188,9 @@ export class EntityLifecycleFilter implements EntityFilter {
export class EntityNamespaceFilter implements EntityFilter {
constructor(readonly values: string[]) {}
getCatalogFilters(): Record<string, string | string[]> {
return { 'metadata.namespace': this.values };
}
filterEntity(entity: Entity): boolean {
return this.values.some(v => entity.metadata.namespace === v);
}
@@ -185,8 +200,66 @@ export class EntityNamespaceFilter implements EntityFilter {
}
}
/**
* @public
*/
export class EntityUserFilter implements EntityFilter {
private constructor(
readonly value: UserListFilterKind,
readonly refs?: string[],
) {}
static owned(ownershipEntityRefs: string[]) {
return new EntityUserFilter('owned', ownershipEntityRefs);
}
static all() {
return new EntityUserFilter('all');
}
static starred(starredEntityRefs: string[]) {
return new EntityUserFilter('starred', starredEntityRefs);
}
getCatalogFilters(): Record<string, string[]> {
if (this.value === 'owned') {
return { 'relations.ownedBy': this.refs ?? [] };
}
if (this.value === 'starred') {
return {
'metadata.name': this.refs?.map(e => parseEntityRef(e).name) ?? [],
};
}
return {};
}
filterEntity(entity: Entity) {
if (this.value === 'starred') {
return this.refs?.includes(stringifyEntityRef(entity)) ?? true;
}
// used only for retro-compatibility with non paginated data.
// This is supposed to return always true for paginated
// owned entities, since the filters are applied server side.
if (this.value === 'owned') {
const relations = getEntityRelations(entity, RELATION_OWNED_BY);
return (
this.refs?.some(v =>
relations.some(o => stringifyEntityRef(o) === v),
) ?? false
);
}
return true;
}
toQueryValue(): string {
return this.value;
}
}
/**
* Filters entities based on whatever the user has starred or owns them.
* @deprecated use EntityUserFilter
* @public
*/
export class UserListFilter implements EntityFilter {
@@ -218,6 +291,14 @@ export class UserListFilter implements EntityFilter {
*/
export class EntityOrphanFilter implements EntityFilter {
constructor(readonly value: boolean) {}
getCatalogFilters(): Record<string, string | string[]> {
if (this.value) {
return { 'metadata.annotations.backstage.io/orphan': String(this.value) };
}
return {};
}
filterEntity(entity: Entity): boolean {
const orphan = entity.metadata.annotations?.['backstage.io/orphan'];
return orphan !== undefined && this.value.toString() === orphan;
@@ -230,6 +311,7 @@ export class EntityOrphanFilter implements EntityFilter {
*/
export class EntityErrorFilter implements EntityFilter {
constructor(readonly value: boolean) {}
filterEntity(entity: Entity): boolean {
const error =
((entity as AlphaEntity)?.status?.items?.length as number) > 0;
@@ -32,7 +32,11 @@ import { MemoryRouter } from 'react-router-dom';
import { catalogApiRef } from '../api';
import { starredEntitiesApiRef, MockStarredEntitiesApi } from '../apis';
import { EntityKindPicker, UserListPicker } from '../components';
import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters';
import {
EntityKindFilter,
EntityTypeFilter,
EntityUserFilter,
} from '../filters';
import { UserListFilterKind } from '../types';
import { EntityListProvider, useEntityList } from './useEntityListProvider';
@@ -62,11 +66,14 @@ const entities: Entity[] = [
const mockConfigApi = {
getOptionalString: () => '',
} as Partial<ConfigApi>;
const ownershipEntityRefs = ['user:default/guest'];
const mockIdentityApi: Partial<IdentityApi> = {
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: [],
ownershipEntityRefs,
}),
getCredentials: async () => ({ token: undefined }),
};
@@ -148,11 +155,7 @@ describe('<EntityListProvider />', () => {
act(() =>
result.current.updateFilters({
user: new UserListFilter(
'owned',
entity => entity.metadata.name === 'component-1',
() => true,
),
user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
@@ -193,11 +196,7 @@ describe('<EntityListProvider />', () => {
act(() =>
result.current.updateFilters({
user: new UserListFilter(
'owned',
entity => entity.metadata.name === 'component-1',
() => true,
),
user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
@@ -41,16 +41,17 @@ import {
EntityTypeFilter,
UserListFilter,
EntityNamespaceFilter,
EntityUserFilter,
} from '../filters';
import { EntityFilter } from '../types';
import { reduceCatalogFilters, reduceEntityFilters } from '../utils';
import { reduceBackendCatalogFilters, reduceEntityFilters } from '../utils';
import { useApi } from '@backstage/core-plugin-api';
/** @public */
export type DefaultEntityFilters = {
kind?: EntityKindFilter;
type?: EntityTypeFilter;
user?: UserListFilter;
user?: UserListFilter | EntityUserFilter;
owners?: EntityOwnerFilter;
lifecycles?: EntityLifecycleFilter;
tags?: EntityTagFilter;
@@ -156,8 +157,8 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
async () => {
const compacted = compact(Object.values(requestedFilters));
const entityFilter = reduceEntityFilters(compacted);
const backendFilter = reduceCatalogFilters(compacted);
const previousBackendFilter = reduceCatalogFilters(
const backendFilter = reduceBackendCatalogFilters(compacted);
const previousBackendFilter = reduceBackendCatalogFilters(
compact(Object.values(outputState.appliedFilters)),
);
@@ -41,13 +41,18 @@ export function useEntityOwnership(): {
const identityApi = useApi(identityApiRef);
// Trigger load only on mount
const { loading, value: refs } = useAsync(async () => {
const { ownershipEntityRefs } = await identityApi.getBackstageIdentity();
return ownershipEntityRefs;
}, []);
const { loading, value: refs } = useAsync(
async () => {
const { ownershipEntityRefs } = await identityApi.getBackstageIdentity();
return ownershipEntityRefs;
},
// load only on mount
[],
);
const isOwnedEntity = useMemo(() => {
const myOwnerRefs = new Set(refs ?? []);
return (entity: Entity) => {
const entityOwnerRefs = getEntityRelations(entity, RELATION_OWNED_BY).map(
stringifyEntityRef,
@@ -61,5 +66,5 @@ export function useEntityOwnership(): {
};
}, [refs]);
return useMemo(() => ({ loading, isOwnedEntity }), [loading, isOwnedEntity]);
return { loading, isOwnedEntity };
}
@@ -16,7 +16,8 @@
import { Entity } from '@backstage/catalog-model';
import { TestApiProvider } from '@backstage/test-utils';
import { act, renderHook, waitFor } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react';
import { waitFor } from '@testing-library/react';
import React, { PropsWithChildren } from 'react';
import {
starredEntitiesApiRef,
@@ -16,7 +16,8 @@
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { TestApiProvider } from '@backstage/test-utils';
import { renderHook, waitFor } from '@testing-library/react';
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import React, { PropsWithChildren } from 'react';
import Observable from 'zen-observable';
import { StarredEntitiesApi, starredEntitiesApiRef } from '../apis';
@@ -16,6 +16,16 @@
import { Entity } from '@backstage/catalog-model';
import { EntityFilter } from '../types';
import {
EntityLifecycleFilter,
EntityNamespaceFilter,
EntityOrphanFilter,
EntityOwnerFilter,
EntityTagFilter,
EntityTextFilter,
EntityUserFilter,
UserListFilter,
} from '../filters';
export function reduceCatalogFilters(
filters: EntityFilter[],
@@ -28,6 +38,38 @@ export function reduceCatalogFilters(
}, {} as Record<string, string | symbol | (string | symbol)[]>);
}
/**
* This function computes and returns an object containing the filters to be sent
* to the backend. Any filter coming from `EntityKindFilter` and `EntityTypeFilter`, together
* with custom filter set by the adopters is allowed. This function is used by `EntityListProvider`
* and it won't be needed anymore in the future once pagination is implemented, as all the filters
* will be applied backend-side.
*/
export function reduceBackendCatalogFilters(filters: EntityFilter[]) {
const backendCatalogFilters: Record<
string,
string | symbol | (string | symbol)[]
> = {};
filters.forEach(filter => {
if (
filter instanceof EntityTagFilter ||
filter instanceof EntityOwnerFilter ||
filter instanceof EntityLifecycleFilter ||
filter instanceof EntityNamespaceFilter ||
filter instanceof EntityUserFilter ||
filter instanceof EntityOrphanFilter ||
filter instanceof EntityTextFilter ||
filter instanceof UserListFilter
) {
return;
}
Object.assign(backendCatalogFilters, filter.getCatalogFilters?.() || {});
});
return backendCatalogFilters;
}
export function reduceEntityFilters(
filters: EntityFilter[],
): (entity: Entity) => boolean {
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { CatalogApi } from '@backstage/catalog-client';
import {
CatalogApi,
QueryEntitiesInitialRequest,
} from '@backstage/catalog-client';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import { TableColumn, TableProps } from '@backstage/core-components';
import {
@@ -36,7 +39,7 @@ import {
renderInTestApp,
} from '@backstage/test-utils';
import DashboardIcon from '@material-ui/icons/Dashboard';
import { fireEvent, screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { createComponentRouteRef } from '../../routes';
import { CatalogTableRow } from '../CatalogTable';
@@ -49,10 +52,12 @@ describe('DefaultCatalogPage', () => {
});
afterEach(() => {
window.history.replaceState = origReplaceState;
jest.clearAllMocks();
});
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
const catalogApi: jest.Mocked<Partial<CatalogApi>> = {
getEntities: jest.fn().mockImplementation(() =>
Promise.resolve({
items: [
{
@@ -78,6 +83,7 @@ describe('DefaultCatalogPage', () => {
kind: 'Component',
metadata: {
name: 'Entity2',
namespace: 'default',
},
spec: {
owner: 'not-tools',
@@ -97,17 +103,47 @@ describe('DefaultCatalogPage', () => {
},
],
}),
getLocationByRef: () =>
Promise.resolve({ id: 'id', type: 'url', target: 'url' }),
getEntityFacets: async () => ({
),
getLocationByRef: jest
.fn()
.mockImplementation(() =>
Promise.resolve({ id: 'id', type: 'url', target: 'url' }),
),
getEntityFacets: jest.fn().mockImplementation(async () => ({
facets: {
'relations.ownedBy': [
{ count: 1, value: 'group:default/not-tools' },
{ count: 1, value: 'group:default/tools' },
],
},
}),
})),
queryEntities: jest
.fn()
.mockImplementation(async (request: QueryEntitiesInitialRequest) => {
if ((request.filter as any)['relations.ownedBy']) {
// owned entities
return { items: [], totalItems: 3, pageInfo: {} };
}
if ((request.filter as any)['metadata.name']) {
// starred entities
return {
items: [
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'Entity1', namespace: 'default' },
},
],
totalItems: 1,
pageInfo: {},
};
}
// all items
return { items: [], totalItems: 2, pageInfo: {} };
}),
};
const testProfile: Partial<ProfileInfo> = {
displayName: 'Display Name',
};
@@ -183,6 +219,8 @@ describe('DefaultCatalogPage', () => {
it('should render the default actions of an item in the grid', async () => {
await renderWrapped(<DefaultCatalogPage />);
await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(
screen.findByText(/Owned components \(1\)/),
@@ -215,6 +253,8 @@ describe('DefaultCatalogPage', () => {
];
await renderWrapped(<DefaultCatalogPage actions={actions} />);
await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(
screen.findByText(/Owned components \(1\)/),
@@ -231,7 +271,10 @@ describe('DefaultCatalogPage', () => {
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
await renderWrapped(<DefaultCatalogPage />);
await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(
screen.findByText(/Owned components \(1\)/),
).resolves.toBeInTheDocument();
@@ -252,6 +295,8 @@ describe('DefaultCatalogPage', () => {
// entities defaulting to "owned" filter and not based on the selected filter
it('should render the correct entities filtered on the selected filter', async () => {
await renderWrapped(<DefaultCatalogPage />);
await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled());
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(
screen.findByText(/Owned components \(1\)/),
@@ -275,10 +320,9 @@ describe('DefaultCatalogPage', () => {
// Now that we've starred an entity, the "Starred" menu option should be
// enabled.
expect(screen.getByTestId('user-picker-starred')).not.toHaveAttribute(
'aria-disabled',
'true',
);
expect(
await screen.findByTestId('user-picker-starred'),
).not.toHaveAttribute('aria-disabled', 'true');
fireEvent.click(screen.getByTestId('user-picker-starred'));
await expect(
screen.findByText(/Starred components \(1\)/),