catalog-react: decouple UserListPicker from backendEntities

Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2023-05-25 12:03:32 +02:00
parent 58b6d860d3
commit 1b31deed8f
6 changed files with 879 additions and 231 deletions
+33 -2
View File
@@ -142,7 +142,7 @@ export const columnFactories: Readonly<{
export type DefaultEntityFilters = {
kind?: EntityKindFilter;
type?: EntityTypeFilter;
user?: UserListFilter;
user?: UserListFilter | UserOwnersFilter;
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[];
@@ -620,7 +630,7 @@ export function useRelatedEntities(
error: Error | undefined;
};
// @public
// @public @deprecated
export class UserListFilter implements EntityFilter {
constructor(
value: UserListFilterKind,
@@ -651,8 +661,29 @@ export const UserListPicker: (
export type UserListPickerProps = {
initialFilter?: UserListFilterKind;
availableFilters?: UserListFilterKind[];
useServerSideFilters?: boolean;
};
// @public (undocumented)
export class UserOwnersFilter implements EntityFilter {
// (undocumented)
static all(): UserOwnersFilter;
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string[]>;
// (undocumented)
static owned(ownershipEntityRefs: string[]): UserOwnersFilter;
// (undocumented)
readonly refs?: string[] | undefined;
// (undocumented)
static starred(starredEntityRefs: string[]): UserOwnersFilter;
// (undocumented)
toQueryValue(): string;
// (undocumented)
readonly value: UserListFilterKind;
}
// @public (undocumented)
export function useStarredEntities(): {
starredEntities: Set<string>;
@@ -16,15 +16,18 @@
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 { Entity, 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 {
EntityTagFilter,
UserListFilter,
UserOwnersFilter,
} 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 +38,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,19 +57,22 @@ 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(
@@ -77,113 +83,117 @@ 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={{}}>
<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>,
);
// 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: {},
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'metadata.name': ['e-1', 'e-2'] },
limit: 1000,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'relations.ownedBy': ['user:default/testuser'] },
limit: 0,
});
});
@@ -192,7 +202,6 @@ describe('<UserListPicker />', () => {
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
filters: { tags: new EntityTagFilter(['tag1']) },
}}
>
@@ -204,35 +213,104 @@ 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, legacy', async () => {
const updateFilters = jest.fn();
const queryParameters = { user: 'owned' };
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters, queryParameters }}
value={{ updateFilters, queryParameters }}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'owned',
expect.any(Function),
expect.any(Function),
),
}),
);
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {},
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'metadata.name': ['e-1', 'e-2'] },
limit: 1000,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'relations.ownedBy': ['user:default/testuser'],
},
limit: 0,
});
});
it('updates user filter when a menuitem is selected', () => {
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { user: 'owned' };
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters }}
value={{ updateFilters, queryParameters }}
>
<UserListPicker useServerSideFilters />
</MockEntityListContextProvider>
</ApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: UserOwnersFilter.owned(ownershipEntityRefs),
}),
);
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {},
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'metadata.name': ['e-1', 'e-2'] },
limit: 1000,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'relations.ownedBy': ['user:default/testuser'],
},
limit: 0,
});
});
it('updates user filter when a menuitem is selected, legacy', async () => {
const updateFilters = jest.fn();
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ updateFilters }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
@@ -240,22 +318,45 @@ describe('<UserListPicker />', () => {
fireEvent.click(screen.getByText('Starred'));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'starred',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'starred',
expect.any(Function),
expect.any(Function),
),
}),
);
});
it('responds to external queryParameters changes', () => {
it('updates user filter when a menuitem is selected', async () => {
const updateFilters = jest.fn();
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ updateFilters }}>
<UserListPicker useServerSideFilters />
</MockEntityListContextProvider>
</ApiProvider>,
);
fireEvent.click(screen.getByText('Starred'));
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: UserOwnersFilter.starred([
'component:default/e-1',
'component:default/e-2',
]),
}),
);
});
it('responds to external queryParameters changes, legacy', async () => {
const updateFilters = jest.fn();
const rendered = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['all'] },
}}
@@ -264,14 +365,21 @@ describe('<UserListPicker />', () => {
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity),
});
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'all',
expect.any(Function),
expect.any(Function),
),
}),
);
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['owned'] },
}}
@@ -281,23 +389,59 @@ describe('<UserListPicker />', () => {
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
user: new UserListFilter(
'owned',
expect.any(Function),
expect.any(Function),
),
});
});
describe.each`
type | filterFn
${'owned'} | ${mockIsOwnedEntity}
${'starred'} | ${mockIsStarredEntity}
`('filter resetting for $type entities', ({ type, filterFn }) => {
let updateFilters: jest.Mock;
const picker = (props: { loading: boolean }) => (
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters, loading: props.loading }}
value={{
updateFilters,
queryParameters: { user: ['all'] },
}}
>
<UserListPicker initialFilter={type} />
<UserListPicker useServerSideFilters />
</MockEntityListContextProvider>
</ApiProvider>,
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: UserOwnersFilter.all(),
}),
);
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { user: ['owned'] },
}}
>
<UserListPicker useServerSideFilters />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: UserOwnersFilter.owned(ownershipEntityRefs),
});
});
describe('filter resetting', () => {
let updateFilters: jest.Mock;
const Picker = (props: UserListPickerProps) => (
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ updateFilters }}>
<UserListPicker {...props} />
</MockEntityListContextProvider>
</ApiProvider>
);
@@ -306,57 +450,213 @@ describe('<UserListPicker />', () => {
updateFilters = jest.fn();
});
describe(`when there are no ${type} entities match the filter`, () => {
beforeEach(() => {
filterFn.mockReturnValue(false);
describe(`when there are no owned entities match the filter`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(
() => new Promise(() => {}),
);
render(<Picker initialFilter="owned" />);
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 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, legacy', 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" />);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'all',
expect.any(Function),
expect.any(Function),
),
}),
);
});
it('resets the filter to "all" when entities are loaded', () => {
render(picker({ loading: false }));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'all',
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
>
)['relations.ownedBy']
) {
return { items: [], totalItems: 0, pageInfo: {} };
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="owned" useServerSideFilters />);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: UserOwnersFilter.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: expect.any(Object),
});
});
it('resets the filter to "all" when entities are loaded, legacy', 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: new UserListFilter(
'all',
expect.any(Function),
expect.any(Function),
),
}),
);
});
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" useServerSideFilters />);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: UserOwnersFilter.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" />,
); /* picker({ loading: true })*/
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter(
'all',
@@ -366,17 +666,74 @@ describe('<UserListPicker />', () => {
});
});
it('does not reset the filter when entities are loaded', () => {
render(picker({ loading: false }));
it('does not reset the filter when entities are loaded', async () => {
render(<Picker initialFilter="owned" />);
expect(updateFilters).toHaveBeenLastCalledWith({
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'owned',
expect.any(Function),
expect.any(Function),
),
}),
);
});
});
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" />,
); /* picker({ loading: true })*/
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter(
type,
'all',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
});
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: new UserListFilter(
'starred',
expect.any(Function),
expect.any(Function),
),
}),
);
});
});
});
});
@@ -32,16 +32,14 @@ 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 { UserListFilter, UserOwnersFilter } from '../../filters';
import { useEntityList, useStarredEntities } from '../../hooks';
import { UserListFilterKind } from '../../types';
import { reduceEntityFilters } from '../../utils';
import { useOwnedEntitiesCount } from './useOwnedEntitiesCount';
import { useAllEntitiesCount } from './useAllEntitiesCount';
import { useStarredEntitiesCount } from './useStarredEntitiesCount';
import { useIsOwnedEntity } from '../../hooks/useEntityOwnership';
/** @public */
export type CatalogReactUserListPickerClassKey =
@@ -122,20 +120,19 @@ function getFilterGroups(orgName: string | undefined): ButtonGroup[] {
export type UserListPickerProps = {
initialFilter?: UserListFilterKind;
availableFilters?: UserListFilterKind[];
useServerSideFilters?: boolean;
};
/** @public */
export const UserListPicker = (props: UserListPickerProps) => {
const { initialFilter, availableFilters } = props;
const { initialFilter, availableFilters, useServerSideFilters } = props;
const classes = useStyles();
const configApi = useApi(configApiRef);
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
const {
filters,
updateFilters,
backendEntities,
queryParameters: { kind: kindParameter, user: userParameter },
loading: loadingBackendEntities,
} = useEntityList();
// Remove group items that aren't in availableFilters and exclude
@@ -153,21 +150,18 @@ 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,
ownershipEntityRefs,
} = useOwnedEntitiesCount();
const { count: allCount } = useAllEntitiesCount();
const {
count: starredEntitiesCount,
filter: starredEntitiesFilter,
loading: loadingStarredEntities,
} = useStarredEntitiesCount();
const queryParamUserFilter = useMemo(
() => [userParameter].flat()[0],
@@ -175,33 +169,19 @@ 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(() => {
return {
all: allCount,
starred: starredEntitiesCount,
owned: ownedEntitiesCount,
};
}, [starredEntitiesCount, ownedEntitiesCount, allCount]);
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 { isStarredEntity } = useStarredEntities();
const isOwnedEntity = useIsOwnedEntity(ownershipEntityRefs);
// Set selected user filter on query parameter updates; this happens at initial page load and from
// external updates to the page location.
@@ -211,6 +191,8 @@ export const UserListPicker = (props: UserListPickerProps) => {
}
}, [queryParamUserFilter]);
const loading = loadingOwnedEntities || loadingStarredEntities;
useEffect(() => {
if (
!loading &&
@@ -223,16 +205,46 @@ 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;
}
if (useServerSideFilters) {
const getFilter = () => {
if (selectedUserFilter === 'owned') {
return ownedEntitiesFilter;
}
if (selectedUserFilter === 'starred') {
return starredEntitiesFilter;
}
return UserOwnersFilter.all();
};
updateFilters({ user: getFilter() });
} else {
// legacy
updateFilters({
user: selectedUserFilter
? new UserListFilter(
selectedUserFilter as UserListFilterKind,
isOwnedEntity,
isStarredEntity,
)
: undefined,
});
}
}, [
selectedUserFilter,
starredEntitiesFilter,
ownedEntitiesFilter,
updateFilters,
useServerSideFilters,
isOwnedEntity,
isStarredEntity,
loading,
]);
return (
<Card className={classes.root}>
@@ -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';
/**
* TODO(vinzscam): we need to find a better way
* for retrieving this value. One possible way, could be to use
* the /entities endpoint: since this method is paginated,
* it should also return how many items matching the provided filters
* are in the catalog
*/
export function useAllEntitiesCount() {
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
const refRequest = useRef<QueryEntitiesInitialRequest>();
useMemo(() => {
const { user, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const filter = reduceCatalogFilters(compacted);
const request: QueryEntitiesInitialRequest = {
filter,
limit: 0,
};
if (isEqual(request, refRequest.current)) {
return refRequest.current;
}
refRequest.current = request;
return request;
}, [filters]);
const { value: count, loading } = useAsync(async () => {
const { totalItems } = await catalogApi.queryEntities(refRequest.current);
return totalItems;
}, [refRequest.current]);
return { count, loading };
}
@@ -0,0 +1,107 @@
/*
* 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 { useMemo, useRef } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { UserOwnersFilter } from '../../filters';
import { useEntityList } from '../../hooks';
import { reduceCatalogFilters } from '../../utils';
export function useOwnedEntitiesCount() {
const identityApi = useApi(identityApiRef);
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
// Trigger load only on mount
const { value: ownershipEntityRefs, loading: loadingEntityRefs } = useAsync(
async () => (await identityApi.getBackstageIdentity()).ownershipEntityRefs,
[],
);
const refRequest = useRef<QueryEntitiesInitialRequest>();
useMemo(async () => {
const compacted = compact(Object.values(filters));
const allFilter = reduceCatalogFilters(compacted);
const { ['metadata.name']: metadata, ...filter } = allFilter;
const facet = 'relations.ownedBy';
const ownedByFilter = Array.isArray(filter[facet])
? (filter[facet] as string[])
: [];
const commonOwnedBy = intersection(ownedByFilter, ownershipEntityRefs);
const ownedBy =
ownedByFilter.length > 0 ? ownedByFilter : ownershipEntityRefs;
if (ownedByFilter.length > 0 && commonOwnedBy.length === 0) {
// don't send any request if another filter sets
// totally different values for relations.ownedBy filter.
// TODO(vinzscam): check conflicts between UserOwnersFilter and EntityOwnerFilter.
// both set filters on the same relations.ownedBy key, so the conflicts need
// to be addressed properly.
refRequest.current = undefined;
return null;
}
const request: QueryEntitiesInitialRequest = {
filter: {
...filter,
'relations.ownedBy': ownedBy ?? [],
},
limit: 0,
};
if (isEqual(request, refRequest.current)) {
return refRequest.current;
}
refRequest.current = request;
return request;
}, [filters, ownershipEntityRefs]);
const { value: count, loading: loadingEntityOwnership } =
useAsync(async () => {
if (!ownershipEntityRefs?.length) {
return 0;
}
if (!refRequest.current) {
return 0;
}
const { totalItems } = await catalogApi.queryEntities(refRequest.current);
return totalItems;
}, [refRequest.current]);
const loading = loadingEntityRefs || loadingEntityOwnership;
const filter = useMemo(
() => UserOwnersFilter.owned(ownershipEntityRefs ?? []),
[ownershipEntityRefs],
);
return {
count,
loading,
filter,
ownershipEntityRefs,
};
}
@@ -0,0 +1,80 @@
/*
* 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 { UserOwnersFilter } 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 refRequest = useRef<QueryEntitiesInitialRequest>();
useMemo(async () => {
const { user, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const filter = reduceCatalogFilters(compacted);
const facet = 'metadata.name';
const request: QueryEntitiesInitialRequest = {
filter: {
...filter,
[facet]: Array.from(starredEntities).map(e => parseEntityRef(e).name),
},
limit: 1000,
};
if (isEqual(request, refRequest.current)) {
return refRequest.current;
}
refRequest.current = request;
return request;
}, [filters, starredEntities]);
const { value: count, loading } = useAsync(async () => {
if (!starredEntities.size) {
return 0;
}
const response = await catalogApi.queryEntities(refRequest.current);
return response.items
.map(e =>
stringifyEntityRef({
kind: e.kind,
namespace: e.metadata.namespace,
name: e.metadata.name,
}),
)
.filter(e => starredEntities.has(e)).length;
}, [refRequest.current, starredEntities]);
const filter = useMemo(
() => UserOwnersFilter.starred(Array.from(starredEntities)),
[starredEntities],
);
return { count, loading, filter };
}