Merge pull request #20339 from backstage/vinzscam/user-list-picker
UserListPicker: decouple from EntityListContext
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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\)/),
|
||||
|
||||
Reference in New Issue
Block a user