diff --git a/.changeset/shaggy-buses-beg.md b/.changeset/shaggy-buses-beg.md
new file mode 100644
index 0000000000..d2c9351b5e
--- /dev/null
+++ b/.changeset/shaggy-buses-beg.md
@@ -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.
diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx
index fa698ded58..6d0b191d68 100644
--- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx
+++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx
@@ -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();
- 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['actions'] = [
- () => {
- return {
- icon: () => ,
- tooltip: 'Foo Action',
- disabled: false,
- onClick: () => jest.fn(),
- };
+ {
+ icon: () => ,
+ tooltip: 'Foo Action',
+ disabled: false,
+ onClick: jest.fn(),
},
- () => {
- return {
- icon: () => ,
- tooltip: 'Bar Action',
- disabled: true,
- onClick: () => jest.fn(),
- };
+ {
+ icon: () => ,
+ tooltip: 'Bar Action',
+ disabled: true,
+ onClick: jest.fn(),
},
];
await renderWrapped();
- 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();
});
});
diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md
index a72df6bb3d..7422440955 100644
--- a/plugins/catalog-react/api-report.md
+++ b/plugins/catalog-react/api-report.md
@@ -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;
+ // (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -275,6 +277,8 @@ export class EntityNamespaceFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
+ getCatalogFilters(): Record;
+ // (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -289,6 +293,8 @@ export class EntityOrphanFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
+ getCatalogFilters(): Record;
+ // (undocumented)
readonly value: boolean;
}
@@ -297,6 +303,8 @@ export class EntityOwnerFilter implements EntityFilter {
constructor(values: string[]);
// (undocumented)
filterEntity(entity: Entity): boolean;
+ // (undocumented)
+ getCatalogFilters(): Record;
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -447,6 +455,8 @@ export class EntityTagFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
+ getCatalogFilters(): Record;
+ // (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;
+ // (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,
diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
index c31381df20..ad56681d6c 100644
--- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
+++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
@@ -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;
const mockCatalogApi = {
- getEntityByRef: () => Promise.resolve(mockUser),
-} as Partial;
+ getEntityByRef: jest.fn(),
+ queryEntities: jest.fn(),
+} as Partial>;
const mockIdentityApi = {
- getUserId: () => 'testUser',
- getIdToken: async () => undefined,
-} as Partial;
+ getBackstageIdentity: jest.fn(),
+} as Partial>;
+
+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('', () => {
- 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(
-
+
,
);
+ 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(
-
+
,
);
- expect(
- screen.getAllByRole('menuitem').map(({ textContent }) => textContent),
- ).toEqual(['Owned 1', 'Starred 1', 'All 4']);
- });
-
- it('includes counts alongside each filter', async () => {
- render(
-
-
-
-
- ,
+ 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('', () => {
@@ -204,35 +222,74 @@ describe('', () => {
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(
,
);
+ 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(
-
+
,
@@ -240,40 +297,55 @@ describe('', () => {
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(
,
);
- 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(
@@ -281,101 +353,237 @@ describe('', () => {
,
);
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) => (
-
+
);
- 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();
+
+ 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();
+
+ 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();
- 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() =>
+ expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
+ );
+
+ await waitFor(() =>
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ user: EntityUserFilter.starred([
+ 'component:default/e-1',
+ 'component:default/e-2',
+ ]),
+ }),
+ );
});
});
});
diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
index e7c306e0d0..854a1cb1b7 100644
--- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
+++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
@@ -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>(
- () => ({
- 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 (
diff --git a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx
new file mode 100644
index 0000000000..dc3bc5d2bd
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx
@@ -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 = jest.fn();
+const mockCatalogApi: jest.Mocked> = {
+ queryEntities: mockQueryEntities,
+};
+
+jest.mock('@backstage/core-plugin-api', () => {
+ const actual = jest.requireActual('@backstage/core-plugin-api');
+ return {
+ ...actual,
+ useApi: (ref: ApiRef) =>
+ 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 }) => (
+
+
+ {children}
+
+
+ ),
+ });
+
+ 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 }) => (
+
+ {children}
+
+ ),
+ });
+
+ await expect(
+ waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
+ ).rejects.toThrow();
+ expect(result.current).toEqual({ count: 0, loading: false });
+ });
+});
diff --git a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts
new file mode 100644
index 0000000000..ba971b088d
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts
@@ -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();
+ 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 };
+}
diff --git a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx
new file mode 100644
index 0000000000..1f6fbfd53d
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx
@@ -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 = jest.fn();
+const mockCatalogApi: jest.Mocked> = {
+ 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) => {
+ 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,
+) {
+ function WrapFilters(props: PropsWithChildren<{}>) {
+ const { updateFilters } = useEntityList();
+
+ useMountEffect(() => {
+ updateFilters(filters);
+ });
+ return <>{props.children}>;
+ }
+
+ return function Wrapper(props: PropsWithChildren<{}>) {
+ return (
+
+
+ {props.children}
+
+
+ );
+ };
+}
diff --git a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts
new file mode 100644
index 0000000000..1adab49361
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts
@@ -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();
+
+ 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;
+}
diff --git a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx
new file mode 100644
index 0000000000..5fe648e1bc
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx
@@ -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 = jest.fn();
+const mockCatalogApi: jest.Mocked> = {
+ queryEntities: mockQueryEntities,
+};
+
+const mockStarredEntities: jest.MockedFn<() => Set> = jest.fn();
+
+const mockUseStarredEntities: ReturnType = {
+ get starredEntities() {
+ return mockStarredEntities();
+ },
+} as ReturnType;
+
+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) =>
+ 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 }) => (
+
+ {children}
+
+ ),
+ });
+
+ 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 }) => (
+
+ {children}
+
+ ),
+ });
+
+ await expect(
+ waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
+ ).rejects.toThrow();
+ expect(result.current).toEqual({
+ count: 0,
+ loading: false,
+ filter: { refs: [], value: 'starred' },
+ });
+ });
+});
diff --git a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts
new file mode 100644
index 0000000000..f7b2b101f3
--- /dev/null
+++ b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts
@@ -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();
+ 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 };
+}
diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts
index c717a35202..55983d124b 100644
--- a/plugins/catalog-react/src/filters.ts
+++ b/plugins/catalog-react/src/filters.ts
@@ -72,6 +72,10 @@ export class EntityTagFilter implements EntityFilter {
return this.values.every(v => (entity.metadata.tags ?? []).includes(v));
}
+ getCatalogFilters(): Record {
+ return { 'metadata.tags': this.values };
+ }
+
toQueryValue(): string[] {
return this.values;
}
@@ -136,6 +140,10 @@ export class EntityOwnerFilter implements EntityFilter {
}, [] as string[]);
}
+ getCatalogFilters(): Record {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
index d77655ebcc..edbfb65c0c 100644
--- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
@@ -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;
+
+const ownershipEntityRefs = ['user:default/guest'];
+
const mockIdentityApi: Partial = {
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
- ownershipEntityRefs: [],
+ ownershipEntityRefs,
}),
getCredentials: async () => ({ token: undefined }),
};
@@ -148,11 +155,7 @@ describe('', () => {
act(() =>
result.current.updateFilters({
- user: new UserListFilter(
- 'owned',
- entity => entity.metadata.name === 'component-1',
- () => true,
- ),
+ user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
@@ -193,11 +196,7 @@ describe('', () => {
act(() =>
result.current.updateFilters({
- user: new UserListFilter(
- 'owned',
- entity => entity.metadata.name === 'component-1',
- () => true,
- ),
+ user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
index b4c464a26c..948efc969f 100644
--- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
@@ -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 = (
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)),
);
diff --git a/plugins/catalog-react/src/hooks/useEntityOwnership.ts b/plugins/catalog-react/src/hooks/useEntityOwnership.ts
index f8a37a2c83..fdd73434ec 100644
--- a/plugins/catalog-react/src/hooks/useEntityOwnership.ts
+++ b/plugins/catalog-react/src/hooks/useEntityOwnership.ts
@@ -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 };
}
diff --git a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx
index e3010a0f4a..07738fd3d9 100644
--- a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx
+++ b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx
@@ -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,
diff --git a/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx
index e64a9bbdf8..44a4c7aaa3 100644
--- a/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx
+++ b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx
@@ -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';
diff --git a/plugins/catalog-react/src/utils/filters.ts b/plugins/catalog-react/src/utils/filters.ts
index 109e864c52..580d7b0b76 100644
--- a/plugins/catalog-react/src/utils/filters.ts
+++ b/plugins/catalog-react/src/utils/filters.ts
@@ -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);
}
+/**
+ * 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 {
diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx
index b7d3ca3ed8..0ced712344 100644
--- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx
+++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx
@@ -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 = {
- getEntities: () =>
+ const catalogApi: jest.Mocked> = {
+ 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 = {
displayName: 'Display Name',
};
@@ -183,6 +219,8 @@ describe('DefaultCatalogPage', () => {
it('should render the default actions of an item in the grid', async () => {
await renderWrapped();
+ 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();
+ 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();
+ 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();
+ 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\)/),