diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index a72df6bb3d..3faa5524c9 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 | UserOwnersFilter; 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[]; @@ -620,7 +630,7 @@ export function useRelatedEntities( error: Error | undefined; }; -// @public +// @public @deprecated export class UserListFilter implements EntityFilter { constructor( value: UserListFilterKind, @@ -651,8 +661,29 @@ export const UserListPicker: ( export type UserListPickerProps = { initialFilter?: UserListFilterKind; availableFilters?: UserListFilterKind[]; + useServerSideFilters?: boolean; }; +// @public (undocumented) +export class UserOwnersFilter implements EntityFilter { + // (undocumented) + static all(): UserOwnersFilter; + // (undocumented) + filterEntity(entity: Entity): boolean; + // (undocumented) + getCatalogFilters(): Record; + // (undocumented) + static owned(ownershipEntityRefs: string[]): UserOwnersFilter; + // (undocumented) + readonly refs?: string[] | undefined; + // (undocumented) + static starred(starredEntityRefs: string[]): UserOwnersFilter; + // (undocumented) + toQueryValue(): string; + // (undocumented) + readonly value: UserListFilterKind; +} + // @public (undocumented) export function useStarredEntities(): { starredEntities: Set; diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index c31381df20..1315c95061 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -16,15 +16,18 @@ import React from 'react'; import { fireEvent, render, waitFor, screen } from '@testing-library/react'; -import { - Entity, - RELATION_OWNED_BY, - UserEntity, -} from '@backstage/catalog-model'; -import { UserListPicker } from './UserListPicker'; +import { Entity, UserEntity } from '@backstage/catalog-model'; +import { UserListPicker, UserListPickerProps } from './UserListPicker'; import { MockEntityListContextProvider } from '../../testUtils/providers'; -import { EntityTagFilter, UserListFilter } from '../../filters'; -import { CatalogApi } from '@backstage/catalog-client'; +import { + EntityTagFilter, + UserListFilter, + UserOwnersFilter, +} from '../../filters'; +import { + CatalogApi, + QueryEntitiesInitialRequest, +} from '@backstage/catalog-client'; import { catalogApiRef } from '../../api'; import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils'; import { ApiProvider } from '@backstage/core-app-api'; @@ -35,7 +38,7 @@ import { identityApiRef, storageApiRef, } from '@backstage/core-plugin-api'; -import { useEntityOwnership } from '../../hooks'; +import { MockStarredEntitiesApi, starredEntitiesApiRef } from '../../apis'; const mockUser: UserEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -54,19 +57,22 @@ const mockConfigApi = { } as Partial; 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( @@ -77,113 +83,117 @@ const mockIsStarredEntity = jest.fn( (entity: Entity) => entity.metadata.name === 'component-3', ); -jest.mock('../../hooks', () => { - const actual = jest.requireActual('../../hooks'); - return { - ...actual, - useEntityOwnership: jest.fn(() => ({ - isOwnedEntity: mockIsOwnedEntity, - })), - useStarredEntities: () => ({ - isStarredEntity: mockIsStarredEntity, - }), - }; -}); - -const backendEntities: Entity[] = [ - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-1', - name: 'component-1', - tags: ['tag1'], - }, - relations: [ - { - type: RELATION_OWNED_BY, - targetRef: 'user:default/testuser', - }, - ], - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-2', - name: 'component-2', - tags: ['tag1'], - }, - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-2', - name: 'component-3', - tags: [], - }, - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-2', - name: 'component-4', - tags: [], - }, - relations: [ - { - type: RELATION_OWNED_BY, - targetRef: 'user:default/testuser', - }, - ], - }, -]; - +const ownershipEntityRefs = ['user:default/testuser']; describe('', () => { - 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( - - - - - , - ); - - // Material UI renders ListItemSecondaryActions outside the - // menuitem itself, so we pick off the next sibling. - await waitFor(() => { + await waitFor(() => expect( screen.getAllByRole('menuitem').map(({ textContent }) => textContent), - ).toEqual(['Owned 1', 'Starred 1', 'All 4']); + ).toEqual(['Owned 3', 'Starred 2', 'All 10']), + ); + + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: {}, + limit: 0, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { 'metadata.name': ['e-1', 'e-2'] }, + limit: 1000, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { 'relations.ownedBy': ['user:default/testuser'] }, + limit: 0, }); }); @@ -192,7 +202,6 @@ describe('', () => { @@ -204,35 +213,104 @@ 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, legacy', async () => { const updateFilters = jest.fn(); const queryParameters = { user: 'owned' }; render( , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity), + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter( + 'owned', + expect.any(Function), + expect.any(Function), + ), + }), + ); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: {}, + limit: 0, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { 'metadata.name': ['e-1', 'e-2'] }, + limit: 1000, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { + 'relations.ownedBy': ['user:default/testuser'], + }, + limit: 0, }); }); - it('updates user filter when a menuitem is selected', () => { + it('respects the query parameter filter value', async () => { const updateFilters = jest.fn(); + const queryParameters = { user: 'owned' }; render( + + + , + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: UserOwnersFilter.owned(ownershipEntityRefs), + }), + ); + + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: {}, + limit: 0, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { 'metadata.name': ['e-1', 'e-2'] }, + limit: 1000, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { + 'relations.ownedBy': ['user:default/testuser'], + }, + limit: 0, + }); + }); + + it('updates user filter when a menuitem is selected, legacy', async () => { + const updateFilters = jest.fn(); + render( + + , @@ -240,22 +318,45 @@ describe('', () => { fireEvent.click(screen.getByText('Starred')); - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter( - 'starred', - mockIsOwnedEntity, - mockIsStarredEntity, - ), - }); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter( + 'starred', + expect.any(Function), + expect.any(Function), + ), + }), + ); }); - it('responds to external queryParameters changes', () => { + it('updates user filter when a menuitem is selected', async () => { + const updateFilters = jest.fn(); + render( + + + + + , + ); + + fireEvent.click(screen.getByText('Starred')); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: UserOwnersFilter.starred([ + 'component:default/e-1', + 'component:default/e-2', + ]), + }), + ); + }); + + it('responds to external queryParameters changes, legacy', async () => { const updateFilters = jest.fn(); const rendered = render( ', () => { , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity), - }); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter( + 'all', + expect.any(Function), + expect.any(Function), + ), + }), + ); + rendered.rerender( ', () => { , ); expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity), + user: new UserListFilter( + 'owned', + expect.any(Function), + expect.any(Function), + ), }); }); - describe.each` - type | filterFn - ${'owned'} | ${mockIsOwnedEntity} - ${'starred'} | ${mockIsStarredEntity} - `('filter resetting for $type entities', ({ type, filterFn }) => { - let updateFilters: jest.Mock; - - const picker = (props: { loading: boolean }) => ( + it('responds to external queryParameters changes', async () => { + const updateFilters = jest.fn(); + const rendered = render( - + + + , + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: UserOwnersFilter.all(), + }), + ); + + rendered.rerender( + + + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + user: UserOwnersFilter.owned(ownershipEntityRefs), + }); + }); + + describe('filter resetting', () => { + let updateFilters: jest.Mock; + + const Picker = (props: UserListPickerProps) => ( + + + ); @@ -306,57 +450,213 @@ describe('', () => { updateFilters = jest.fn(); }); - describe(`when there are no ${type} entities match the filter`, () => { - beforeEach(() => { - filterFn.mockReturnValue(false); + describe(`when there are no owned entities match the filter`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation( + () => new Promise(() => {}), + ); + + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalled(), + ); + expect(updateFilters).not.toHaveBeenCalled(); }); - it('does not reset the filter while entities are loading', () => { - render(picker({ loading: true })); + it('does not reset the filter while owned entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation(request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + return new Promise(() => {}); + } + return mockQueryEntitiesImplementation(request); + }); + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); expect(updateFilters).not.toHaveBeenCalledWith({ - user: new UserListFilter( - 'all', - mockIsOwnedEntity, - mockIsStarredEntity, - ), + user: expect.any(Object), }); }); - it('does not reset the filter while owned entities are loading', () => { - const isOwnedEntity = jest.fn(() => false); - (useEntityOwnership as jest.Mock).mockReturnValueOnce({ - loading: true, - isOwnedEntity, + it('resets the filter to "all" when entities are loaded, legacy', async () => { + mockCatalogApi.queryEntities?.mockImplementation(async request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + return { items: [], totalItems: 0, pageInfo: {} }; + } + return mockQueryEntitiesImplementation(request); }); - render(picker({ loading: false })); - expect(updateFilters).not.toHaveBeenCalledWith({ - user: new UserListFilter('all', isOwnedEntity, mockIsStarredEntity), - }); + render(); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter( + 'all', + expect.any(Function), + expect.any(Function), + ), + }), + ); }); - it('resets the filter to "all" when entities are loaded', () => { - render(picker({ loading: false })); - - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter( - 'all', - mockIsOwnedEntity, - mockIsStarredEntity, - ), + it('resets the filter to "all" when entities are loaded', async () => { + mockCatalogApi.queryEntities?.mockImplementation(async request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + return { items: [], totalItems: 0, pageInfo: {} }; + } + return mockQueryEntitiesImplementation(request); }); + + render(); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: UserOwnersFilter.all(), + }), + ); }); }); - describe(`when there are some ${type} entities present`, () => { - beforeEach(() => { - filterFn.mockReturnValue(true); + describe(`when there are no starred entities match the filter`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation( + () => new Promise(() => {}), + ); + + render(); + + 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: expect.any(Object), + }); + }); + + it('resets the filter to "all" when entities are loaded, legacy', async () => { + mockCatalogApi.queryEntities?.mockImplementation(async request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['metadata.name'] + ) { + return { items: [], totalItems: 0, pageInfo: {} }; + } + return mockQueryEntitiesImplementation(request); + }); + + render(); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter( + 'all', + expect.any(Function), + expect.any(Function), + ), + }), + ); + }); + + it('resets the filter to "all" when entities are loaded', async () => { + mockCatalogApi.queryEntities?.mockImplementation(async request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['metadata.name'] + ) { + return { items: [], totalItems: 0, pageInfo: {} }; + } + return mockQueryEntitiesImplementation(request); + }); + + render(); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: UserOwnersFilter.all(), + }), + ); + }); + }); + + describe(`when there are some owned entities present`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation(request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + return new Promise(() => {}); + } + return mockQueryEntitiesImplementation(request); + }); + + render( + , + ); /* picker({ loading: true })*/ + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); expect(updateFilters).not.toHaveBeenCalledWith({ user: new UserListFilter( 'all', @@ -366,17 +666,74 @@ describe('', () => { }); }); - it('does not reset the filter when entities are loaded', () => { - render(picker({ loading: false })); + it('does not reset the filter when entities are loaded', async () => { + render(); - expect(updateFilters).toHaveBeenLastCalledWith({ + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter( + 'owned', + expect.any(Function), + expect.any(Function), + ), + }), + ); + }); + }); + + describe(`when there are some starred entities present`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation(request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['metadata.name'] + ) { + return new Promise(() => {}); + } + return mockQueryEntitiesImplementation(request); + }); + + render( + , + ); /* picker({ loading: true })*/ + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); + expect(updateFilters).not.toHaveBeenCalledWith({ user: new UserListFilter( - type, + 'all', mockIsOwnedEntity, mockIsStarredEntity, ), }); }); + + it('does not reset the filter when entities are loaded', async () => { + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter( + 'starred', + expect.any(Function), + expect.any(Function), + ), + }), + ); + }); }); }); }); diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx index e7c306e0d0..153cdfdb9e 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx @@ -32,16 +32,14 @@ import { } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; import StarIcon from '@material-ui/icons/Star'; -import { compact } from 'lodash'; import React, { Fragment, useEffect, useMemo, useState } from 'react'; -import { UserListFilter } from '../../filters'; -import { - useEntityList, - useStarredEntities, - useEntityOwnership, -} from '../../hooks'; +import { UserListFilter, UserOwnersFilter } from '../../filters'; +import { useEntityList, useStarredEntities } from '../../hooks'; import { UserListFilterKind } from '../../types'; -import { reduceEntityFilters } from '../../utils'; +import { useOwnedEntitiesCount } from './useOwnedEntitiesCount'; +import { useAllEntitiesCount } from './useAllEntitiesCount'; +import { useStarredEntitiesCount } from './useStarredEntitiesCount'; +import { useIsOwnedEntity } from '../../hooks/useEntityOwnership'; /** @public */ export type CatalogReactUserListPickerClassKey = @@ -122,20 +120,19 @@ function getFilterGroups(orgName: string | undefined): ButtonGroup[] { export type UserListPickerProps = { initialFilter?: UserListFilterKind; availableFilters?: UserListFilterKind[]; + useServerSideFilters?: boolean; }; /** @public */ export const UserListPicker = (props: UserListPickerProps) => { - const { initialFilter, availableFilters } = props; + const { initialFilter, availableFilters, useServerSideFilters } = props; const classes = useStyles(); const configApi = useApi(configApiRef); const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; const { filters, updateFilters, - backendEntities, queryParameters: { kind: kindParameter, user: userParameter }, - loading: loadingBackendEntities, } = useEntityList(); // Remove group items that aren't in availableFilters and exclude @@ -153,21 +150,18 @@ export const UserListPicker = (props: UserListPickerProps) => { })) .filter(({ items }) => !!items.length); - const { isStarredEntity } = useStarredEntities(); - const { isOwnedEntity, loading: loadingEntityOwnership } = - useEntityOwnership(); - - const loading = loadingBackendEntities || loadingEntityOwnership; - - // Static filters; used for generating counts of potentially unselected kinds - const ownedFilter = useMemo( - () => new UserListFilter('owned', isOwnedEntity, isStarredEntity), - [isOwnedEntity, isStarredEntity], - ); - const starredFilter = useMemo( - () => new UserListFilter('starred', isOwnedEntity, isStarredEntity), - [isOwnedEntity, isStarredEntity], - ); + const { + count: ownedEntitiesCount, + loading: loadingOwnedEntities, + filter: ownedEntitiesFilter, + ownershipEntityRefs, + } = useOwnedEntitiesCount(); + const { count: allCount } = useAllEntitiesCount(); + const { + count: starredEntitiesCount, + filter: starredEntitiesFilter, + loading: loadingStarredEntities, + } = useStarredEntitiesCount(); const queryParamUserFilter = useMemo( () => [userParameter].flat()[0], @@ -175,33 +169,19 @@ export const UserListPicker = (props: UserListPickerProps) => { ); const [selectedUserFilter, setSelectedUserFilter] = useState( - queryParamUserFilter ?? initialFilter, + (queryParamUserFilter as UserListFilterKind) ?? initialFilter, ); - // To show proper counts for each section, apply all other frontend filters _except_ the user - // filter that's controlled by this picker. - const entitiesWithoutUserFilter = useMemo( - () => - backendEntities.filter( - reduceEntityFilters( - compact(Object.values({ ...filters, user: undefined })), - ), - ), - [filters, backendEntities], - ); + const filterCounts = useMemo(() => { + return { + all: allCount, + starred: starredEntitiesCount, + owned: ownedEntitiesCount, + }; + }, [starredEntitiesCount, ownedEntitiesCount, allCount]); - const filterCounts = useMemo>( - () => ({ - all: entitiesWithoutUserFilter.length, - starred: entitiesWithoutUserFilter.filter(entity => - starredFilter.filterEntity(entity), - ).length, - owned: entitiesWithoutUserFilter.filter(entity => - ownedFilter.filterEntity(entity), - ).length, - }), - [entitiesWithoutUserFilter, starredFilter, ownedFilter], - ); + const { isStarredEntity } = useStarredEntities(); + const isOwnedEntity = useIsOwnedEntity(ownershipEntityRefs); // Set selected user filter on query parameter updates; this happens at initial page load and from // external updates to the page location. @@ -211,6 +191,8 @@ export const UserListPicker = (props: UserListPickerProps) => { } }, [queryParamUserFilter]); + const loading = loadingOwnedEntities || loadingStarredEntities; + useEffect(() => { if ( !loading && @@ -223,16 +205,46 @@ export const UserListPicker = (props: UserListPickerProps) => { }, [loading, filterCounts, selectedUserFilter, setSelectedUserFilter]); useEffect(() => { - updateFilters({ - user: selectedUserFilter - ? new UserListFilter( - selectedUserFilter as UserListFilterKind, - isOwnedEntity, - isStarredEntity, - ) - : undefined, - }); - }, [selectedUserFilter, isOwnedEntity, isStarredEntity, updateFilters]); + if (!selectedUserFilter) { + return; + } + if (loading) { + return; + } + if (useServerSideFilters) { + const getFilter = () => { + if (selectedUserFilter === 'owned') { + return ownedEntitiesFilter; + } + if (selectedUserFilter === 'starred') { + return starredEntitiesFilter; + } + return UserOwnersFilter.all(); + }; + + updateFilters({ user: getFilter() }); + } else { + // legacy + updateFilters({ + user: selectedUserFilter + ? new UserListFilter( + selectedUserFilter as UserListFilterKind, + isOwnedEntity, + isStarredEntity, + ) + : undefined, + }); + } + }, [ + selectedUserFilter, + starredEntitiesFilter, + ownedEntitiesFilter, + updateFilters, + useServerSideFilters, + isOwnedEntity, + isStarredEntity, + loading, + ]); return ( 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..499762ff87 --- /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'; + +/** + * TODO(vinzscam): we need to find a better way + * for retrieving this value. One possible way, could be to use + * the /entities endpoint: since this method is paginated, + * it should also return how many items matching the provided filters + * are in the catalog + */ +export function useAllEntitiesCount() { + const catalogApi = useApi(catalogApiRef); + const { filters } = useEntityList(); + + const refRequest = useRef(); + useMemo(() => { + const { user, ...allFilters } = filters; + const compacted = compact(Object.values(allFilters)); + const filter = reduceCatalogFilters(compacted); + const request: QueryEntitiesInitialRequest = { + filter, + limit: 0, + }; + + if (isEqual(request, refRequest.current)) { + return refRequest.current; + } + refRequest.current = request; + + return request; + }, [filters]); + + const { value: count, loading } = useAsync(async () => { + const { totalItems } = await catalogApi.queryEntities(refRequest.current); + + return totalItems; + }, [refRequest.current]); + + return { count, loading }; +} 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..32e367cc07 --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryEntitiesInitialRequest } from '@backstage/catalog-client'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { compact, intersection, isEqual } from 'lodash'; +import { useMemo, useRef } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; +import { UserOwnersFilter } from '../../filters'; +import { useEntityList } from '../../hooks'; +import { reduceCatalogFilters } from '../../utils'; + +export function useOwnedEntitiesCount() { + const identityApi = useApi(identityApiRef); + const catalogApi = useApi(catalogApiRef); + + const { filters } = useEntityList(); + // Trigger load only on mount + const { value: ownershipEntityRefs, loading: loadingEntityRefs } = useAsync( + async () => (await identityApi.getBackstageIdentity()).ownershipEntityRefs, + + [], + ); + + const refRequest = useRef(); + + useMemo(async () => { + const compacted = compact(Object.values(filters)); + const allFilter = reduceCatalogFilters(compacted); + const { ['metadata.name']: metadata, ...filter } = allFilter; + + const facet = 'relations.ownedBy'; + + const ownedByFilter = Array.isArray(filter[facet]) + ? (filter[facet] as string[]) + : []; + + const commonOwnedBy = intersection(ownedByFilter, ownershipEntityRefs); + + const ownedBy = + ownedByFilter.length > 0 ? ownedByFilter : ownershipEntityRefs; + if (ownedByFilter.length > 0 && commonOwnedBy.length === 0) { + // don't send any request if another filter sets + // totally different values for relations.ownedBy filter. + // TODO(vinzscam): check conflicts between UserOwnersFilter and EntityOwnerFilter. + // both set filters on the same relations.ownedBy key, so the conflicts need + // to be addressed properly. + refRequest.current = undefined; + return null; + } + const request: QueryEntitiesInitialRequest = { + filter: { + ...filter, + 'relations.ownedBy': ownedBy ?? [], + }, + limit: 0, + }; + + if (isEqual(request, refRequest.current)) { + return refRequest.current; + } + + refRequest.current = request; + + return request; + }, [filters, ownershipEntityRefs]); + + const { value: count, loading: loadingEntityOwnership } = + useAsync(async () => { + if (!ownershipEntityRefs?.length) { + return 0; + } + if (!refRequest.current) { + return 0; + } + const { totalItems } = await catalogApi.queryEntities(refRequest.current); + + return totalItems; + }, [refRequest.current]); + + const loading = loadingEntityRefs || loadingEntityOwnership; + const filter = useMemo( + () => UserOwnersFilter.owned(ownershipEntityRefs ?? []), + [ownershipEntityRefs], + ); + + return { + count, + loading, + filter, + ownershipEntityRefs, + }; +} 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..ea7423efe8 --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryEntitiesInitialRequest } from '@backstage/catalog-client'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { compact, isEqual } from 'lodash'; +import { useMemo, useRef } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; +import { UserOwnersFilter } from '../../filters'; +import { useEntityList, useStarredEntities } from '../../hooks'; +import { reduceCatalogFilters } from '../../utils'; + +export function useStarredEntitiesCount() { + const catalogApi = useApi(catalogApiRef); + const { filters } = useEntityList(); + const { starredEntities } = useStarredEntities(); + + const refRequest = useRef(); + useMemo(async () => { + const { user, ...allFilters } = filters; + const compacted = compact(Object.values(allFilters)); + const filter = reduceCatalogFilters(compacted); + + const facet = 'metadata.name'; + + const request: QueryEntitiesInitialRequest = { + filter: { + ...filter, + [facet]: Array.from(starredEntities).map(e => parseEntityRef(e).name), + }, + limit: 1000, + }; + if (isEqual(request, refRequest.current)) { + return refRequest.current; + } + refRequest.current = request; + + return request; + }, [filters, starredEntities]); + + const { value: count, loading } = useAsync(async () => { + if (!starredEntities.size) { + return 0; + } + + const response = await catalogApi.queryEntities(refRequest.current); + + return response.items + .map(e => + stringifyEntityRef({ + kind: e.kind, + namespace: e.metadata.namespace, + name: e.metadata.name, + }), + ) + .filter(e => starredEntities.has(e)).length; + }, [refRequest.current, starredEntities]); + + const filter = useMemo( + () => UserOwnersFilter.starred(Array.from(starredEntities)), + [starredEntities], + ); + + return { count, loading, filter }; +}