Merge pull request #17495 from wss-rbunning/show-aggregate-users-under-groups
Show aggregate users under groups
This commit is contained in:
@@ -20,6 +20,7 @@ export const EntityGroupProfileCard: (props: {
|
||||
export const EntityMembersListCard: (props: {
|
||||
memberDisplayTitle?: string | undefined;
|
||||
pageSize?: number | undefined;
|
||||
showAggregateMembersToggle?: boolean | undefined;
|
||||
}) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -47,6 +48,7 @@ export const GroupProfileCard: (props: {
|
||||
export const MembersListCard: (props: {
|
||||
memberDisplayTitle?: string;
|
||||
pageSize?: number;
|
||||
showAggregateMembersToggle?: boolean;
|
||||
}) => JSX.Element;
|
||||
|
||||
// @public
|
||||
|
||||
@@ -51,7 +51,10 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@testing-library/dom": "^8.0.0",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* 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 {
|
||||
CatalogApi,
|
||||
GetEntitiesByRefsRequest,
|
||||
GetEntitiesRequest,
|
||||
} from '@backstage/catalog-client';
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
Entity,
|
||||
GroupEntity,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
export const groupA: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-a',
|
||||
title: 'Group A',
|
||||
description: 'Testing Group A',
|
||||
},
|
||||
spec: {
|
||||
type: 'testing-group',
|
||||
children: [],
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-b',
|
||||
},
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-c',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const groupAWithALeader: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-a',
|
||||
title: 'Group A',
|
||||
description: 'Testing Group A',
|
||||
},
|
||||
spec: {
|
||||
type: 'testing-group',
|
||||
children: [],
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'teamLeadBy',
|
||||
targetRef: 'user:default/group-a-user-one',
|
||||
},
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-b',
|
||||
},
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-c',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const groupARef: CompoundEntityRef = {
|
||||
kind: 'group',
|
||||
name: 'group-a',
|
||||
namespace: 'default',
|
||||
};
|
||||
|
||||
export const groupB: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-b',
|
||||
title: 'Group B',
|
||||
description: 'Testing Group B',
|
||||
},
|
||||
spec: {
|
||||
type: 'testing-group',
|
||||
children: [],
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-a',
|
||||
},
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-d',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const groupBRef: CompoundEntityRef = {
|
||||
kind: 'group',
|
||||
name: 'group-b',
|
||||
namespace: 'default',
|
||||
};
|
||||
|
||||
export const groupC: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-c',
|
||||
title: 'Group C',
|
||||
description: 'Testing Group C',
|
||||
},
|
||||
spec: {
|
||||
type: 'testing-group',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const groupCRef: CompoundEntityRef = {
|
||||
kind: 'group',
|
||||
name: 'group-c',
|
||||
namespace: 'default',
|
||||
};
|
||||
|
||||
export const groupD: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-d',
|
||||
title: 'Group D',
|
||||
description: 'Testing Group D',
|
||||
},
|
||||
spec: {
|
||||
type: 'testing-group',
|
||||
children: [],
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-c',
|
||||
},
|
||||
{
|
||||
type: 'parentOf',
|
||||
targetRef: 'group:default/group-e',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const groupDRef: CompoundEntityRef = {
|
||||
kind: 'group',
|
||||
name: 'group-d',
|
||||
namespace: 'default',
|
||||
};
|
||||
|
||||
export const groupE: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-e',
|
||||
title: 'Group E',
|
||||
description: 'Testing Group E',
|
||||
},
|
||||
spec: {
|
||||
type: 'testing-group',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const groupERef: CompoundEntityRef = {
|
||||
kind: 'group',
|
||||
name: 'group-e',
|
||||
namespace: 'default',
|
||||
};
|
||||
|
||||
export const groupF: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'group-f',
|
||||
title: 'Group F',
|
||||
description: 'Testing Group F',
|
||||
},
|
||||
spec: {
|
||||
type: 'testing-group',
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const groupAUserOne: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'group-a-user-one',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Group A User One',
|
||||
email: 'group-a-user-one@testing.email',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const groupBUserOne: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'group-b-user-one',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Group B User One',
|
||||
email: 'group-b-user-one@testing.email',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const groupDUserOne: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'group-d-user-one',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Group D User One',
|
||||
email: 'group-d-user-one@testing.email',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const groupEUserOne: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'group-e-user-one',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Group E User One',
|
||||
email: 'group-e-user-one@testing.email',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const groupFUserOne: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'group-f-user-one',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Group F User One',
|
||||
email: 'group-f-user-one@testing.email',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const duplicatedUser: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'duplicated-user',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Duplicated User',
|
||||
email: 'duplicated-user@testing.email',
|
||||
picture:
|
||||
'https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockedRefsToRelationsMap = new Map<string, GroupEntity>([
|
||||
['group:default/group-a', groupA],
|
||||
['group:default/group-b', groupB],
|
||||
['group:default/group-c', groupC],
|
||||
['group:default/group-d', groupD],
|
||||
['group:default/group-e', groupE],
|
||||
['group:default/group-f', groupF],
|
||||
]);
|
||||
|
||||
const mockedMembersMapping = new Map<string, Entity[]>([
|
||||
['group:default/group-a', [groupAUserOne, duplicatedUser]],
|
||||
['group:default/group-b', [groupBUserOne]],
|
||||
['group:default/group-c', []],
|
||||
['group:default/group-d', [groupDUserOne, duplicatedUser]],
|
||||
['group:default/group-e', [groupEUserOne, duplicatedUser]],
|
||||
['group:default/group-f', [groupFUserOne]],
|
||||
]);
|
||||
|
||||
type Nullable<T> = T | undefined;
|
||||
|
||||
export const mockedCatalogApiSupportingGroups: Partial<CatalogApi> = {
|
||||
getEntities: async (request?: GetEntitiesRequest) => {
|
||||
const actualFilter = (request?.filter as Nullable<{
|
||||
'relations.memberof': string[];
|
||||
}>) ?? { 'relations.memberof': [] };
|
||||
const items =
|
||||
actualFilter['relations.memberof'].length > 0
|
||||
? (actualFilter['relations.memberof']
|
||||
.map(group => mockedMembersMapping.get(group))
|
||||
.flat()
|
||||
.filter(entity => !!entity) as Entity[])
|
||||
: [...mockedMembersMapping.values()].flat();
|
||||
|
||||
return { items };
|
||||
},
|
||||
getEntityByRef: async (entityRef: CompoundEntityRef) =>
|
||||
mockedRefsToRelationsMap.get(stringifyEntityRef(entityRef)),
|
||||
getEntitiesByRefs: async (request: GetEntitiesByRefsRequest) => {
|
||||
const items = request.entityRefs.map(entityRef =>
|
||||
mockedRefsToRelationsMap.get(entityRef),
|
||||
);
|
||||
return { items };
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,10 @@ import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
groupA,
|
||||
mockedCatalogApiSupportingGroups,
|
||||
} from '../../../../__testUtils__/catalogMocks';
|
||||
import { MembersListCard } from './MembersListCard';
|
||||
|
||||
export default {
|
||||
@@ -132,3 +136,17 @@ export const Empty = () => (
|
||||
</TestApiProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
export const AggregateMembersToggle = () => (
|
||||
<MemoryRouter>
|
||||
<TestApiProvider apis={[[catalogApiRef, mockedCatalogApiSupportingGroups]]}>
|
||||
<EntityProvider entity={groupA}>
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<MembersListCard showAggregateMembersToggle />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityProvider>
|
||||
</TestApiProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -19,14 +19,26 @@ import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
StarredEntitiesApi,
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
renderInTestApp,
|
||||
renderWithEffects,
|
||||
TestApiProvider,
|
||||
wrapInTestApp,
|
||||
} from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { MembersListCard } from './MembersListCard';
|
||||
import {
|
||||
groupA,
|
||||
mockedCatalogApiSupportingGroups,
|
||||
} from '../../../../__testUtils__/catalogMocks';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { EntityLayout } from '@backstage/plugin-catalog';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
// Mock needed because jsdom doesn't correctly implement box-sizing
|
||||
// https://github.com/ShinyChang/React-Text-Truncate/issues/70
|
||||
@@ -41,6 +53,20 @@ jest.mock('react-text-truncate', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockedStarredEntitiesApi: Partial<StarredEntitiesApi> = {
|
||||
starredEntitie$: () => {
|
||||
return {
|
||||
subscribe: () => {
|
||||
return {
|
||||
unsubscribe() {
|
||||
// This is intentional
|
||||
},
|
||||
};
|
||||
},
|
||||
} as Observable<Set<string>>;
|
||||
},
|
||||
};
|
||||
|
||||
describe('MemberTab Test', () => {
|
||||
const groupEntity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -131,4 +157,164 @@ describe('MemberTab Test', () => {
|
||||
|
||||
expect(rendered.getByText('Testers (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Aggregate members toggle', () => {
|
||||
it('Does not show the aggregate members toggle if the showAggregateMembersToggle prop is undefined', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, mockedCatalogApiSupportingGroups],
|
||||
[starredEntitiesApiRef, mockedStarredEntitiesApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={groupA}>
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="Title">
|
||||
<MembersListCard />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const toggleSwitch = screen.queryByRole('checkbox');
|
||||
expect(toggleSwitch).toBeNull();
|
||||
});
|
||||
|
||||
it('Shows the aggregate members toggle if the showAggregateMembersToggle prop is true', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, mockedCatalogApiSupportingGroups],
|
||||
[starredEntitiesApiRef, mockedStarredEntitiesApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={groupA}>
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="Title">
|
||||
<MembersListCard showAggregateMembersToggle />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows only direct members if the showAggregateMembersToggle prop is undefined', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, mockedCatalogApiSupportingGroups],
|
||||
[starredEntitiesApiRef, mockedStarredEntitiesApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={groupA}>
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="Title">
|
||||
<MembersListCard />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const displayedMemberNames = screen.queryAllByTestId('user-link');
|
||||
const duplicatedUserText = screen.getByText('Duplicated User');
|
||||
const groupAUserOneText = screen.getByText('Group A User One');
|
||||
|
||||
expect(displayedMemberNames).toHaveLength(2);
|
||||
expect(duplicatedUserText).toBeInTheDocument();
|
||||
expect(groupAUserOneText).toBeInTheDocument();
|
||||
expect(
|
||||
duplicatedUserText.compareDocumentPosition(groupAUserOneText),
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it('Shows only direct members if the aggregate members switch is turned off', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, mockedCatalogApiSupportingGroups],
|
||||
[starredEntitiesApiRef, mockedStarredEntitiesApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={groupA}>
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="Title">
|
||||
<MembersListCard showAggregateMembersToggle />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
const displayedMemberNames = screen.queryAllByTestId('user-link');
|
||||
const duplicatedUserText = screen.getByText('Duplicated User');
|
||||
const groupAUserOneText = screen.getByText('Group A User One');
|
||||
|
||||
expect(displayedMemberNames).toHaveLength(2);
|
||||
expect(duplicatedUserText).toBeInTheDocument();
|
||||
expect(groupAUserOneText).toBeInTheDocument();
|
||||
expect(
|
||||
duplicatedUserText.compareDocumentPosition(groupAUserOneText),
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it('Shows all descendant members of the group when the aggregate users switch is turned on, showing duplicated members only once', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, mockedCatalogApiSupportingGroups],
|
||||
[starredEntitiesApiRef, mockedStarredEntitiesApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={groupA}>
|
||||
<EntityLayout>
|
||||
<EntityLayout.Route path="/" title="Title">
|
||||
<MembersListCard showAggregateMembersToggle />
|
||||
</EntityLayout.Route>
|
||||
</EntityLayout>
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
// Click the toggle switch
|
||||
await userEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
const displayedMemberNames = screen.queryAllByTestId('user-link');
|
||||
const duplicatedUserText = screen.getByText('Duplicated User');
|
||||
const groupAUserOneText = screen.getByText('Group A User One');
|
||||
const groupBUserOneText = screen.getByText('Group B User One');
|
||||
const groupDUserOneText = screen.getByText('Group D User One');
|
||||
const groupEUserOneText = screen.getByText('Group E User One');
|
||||
|
||||
expect(displayedMemberNames).toHaveLength(5);
|
||||
|
||||
expect(duplicatedUserText).toBeInTheDocument();
|
||||
expect(groupAUserOneText).toBeInTheDocument();
|
||||
expect(groupBUserOneText).toBeInTheDocument();
|
||||
expect(groupDUserOneText).toBeInTheDocument();
|
||||
expect(groupEUserOneText).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
duplicatedUserText.compareDocumentPosition(groupAUserOneText),
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(groupAUserOneText.compareDocumentPosition(groupBUserOneText)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(groupBUserOneText.compareDocumentPosition(groupDUserOneText)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(groupDUserOneText.compareDocumentPosition(groupEUserOneText)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,11 +30,12 @@ import {
|
||||
createStyles,
|
||||
Grid,
|
||||
makeStyles,
|
||||
Switch,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import Pagination from '@material-ui/lab/Pagination';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
@@ -47,6 +48,10 @@ import {
|
||||
OverflowTooltip,
|
||||
} from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
getAllDesendantMembersForGroupEntity,
|
||||
removeDuplicateEntitiesFrom,
|
||||
} from '../../../../helpers/helpers';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -98,6 +103,7 @@ const MemberComponent = (props: { member: UserEntity }) => {
|
||||
>
|
||||
<Typography variant="h6">
|
||||
<Link
|
||||
data-testid="user-link"
|
||||
to={generatePath(
|
||||
`/catalog/:namespace/user/${metaName}`,
|
||||
entityRouteParams(props.member),
|
||||
@@ -125,8 +131,13 @@ const MemberComponent = (props: { member: UserEntity }) => {
|
||||
export const MembersListCard = (props: {
|
||||
memberDisplayTitle?: string;
|
||||
pageSize?: number;
|
||||
showAggregateMembersToggle?: boolean;
|
||||
}) => {
|
||||
const { memberDisplayTitle = 'Members', pageSize = 50 } = props;
|
||||
const {
|
||||
memberDisplayTitle = 'Members',
|
||||
pageSize = 50,
|
||||
showAggregateMembersToggle,
|
||||
} = props;
|
||||
|
||||
const { entity: groupEntity } = useEntity<GroupEntity>();
|
||||
const {
|
||||
@@ -144,10 +155,23 @@ export const MembersListCard = (props: {
|
||||
setPage(pageIndex);
|
||||
};
|
||||
|
||||
const [showAggregateMembers, setShowAggregateMembers] = useState(false);
|
||||
|
||||
const { loading: loadingDescendantMembers, value: descendantMembers } =
|
||||
useAsync(async () => {
|
||||
if (!showAggregateMembersToggle) {
|
||||
return [] as UserEntity[];
|
||||
}
|
||||
|
||||
return await getAllDesendantMembersForGroupEntity(
|
||||
groupEntity,
|
||||
catalogApi,
|
||||
);
|
||||
}, [catalogApi, groupEntity, showAggregateMembersToggle]);
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
value: members,
|
||||
value: directMembers,
|
||||
} = useAsync(async () => {
|
||||
const membersList = await catalogApi.getEntities({
|
||||
filter: {
|
||||
@@ -165,6 +189,15 @@ export const MembersListCard = (props: {
|
||||
return membersList.items as UserEntity[];
|
||||
}, [catalogApi, groupEntity]);
|
||||
|
||||
const members = removeDuplicateEntitiesFrom(
|
||||
[
|
||||
...(directMembers ?? []),
|
||||
...(descendantMembers && showAggregateMembers ? descendantMembers : []),
|
||||
].sort((a, b) =>
|
||||
stringifyEntityRef(a).localeCompare(stringifyEntityRef(b)),
|
||||
),
|
||||
) as UserEntity[];
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
@@ -193,21 +226,39 @@ export const MembersListCard = (props: {
|
||||
subheader={`of ${displayName}`}
|
||||
{...(nbPages <= 1 ? {} : { actions: pagination })}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
{members && members.length > 0 ? (
|
||||
members
|
||||
.slice(pageSize * (page - 1), pageSize * page)
|
||||
.map(member => (
|
||||
<MemberComponent member={member} key={member.metadata.uid} />
|
||||
))
|
||||
) : (
|
||||
<Box p={2}>
|
||||
<Typography>
|
||||
This group has no {memberDisplayTitle.toLocaleLowerCase()}.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
{showAggregateMembersToggle && (
|
||||
<>
|
||||
Direct Members
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={showAggregateMembers}
|
||||
onChange={() => {
|
||||
setShowAggregateMembers(!showAggregateMembers);
|
||||
}}
|
||||
inputProps={{ 'aria-label': 'Users Type Switch' }}
|
||||
/>
|
||||
Aggregated Members
|
||||
</>
|
||||
)}
|
||||
{showAggregateMembers && loadingDescendantMembers ? (
|
||||
<Progress />
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{members && members.length > 0 ? (
|
||||
members
|
||||
.slice(pageSize * (page - 1), pageSize * page)
|
||||
.map(member => (
|
||||
<MemberComponent member={member} key={member.metadata.uid} />
|
||||
))
|
||||
) : (
|
||||
<Box p={2}>
|
||||
<Typography>
|
||||
This group has no {memberDisplayTitle.toLocaleLowerCase()}.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
getAllDesendantMembersForGroupEntity,
|
||||
getMembersFromGroups,
|
||||
getDescendantGroupsFromGroup,
|
||||
removeDuplicateEntitiesFrom,
|
||||
} from './helpers';
|
||||
import {
|
||||
groupA,
|
||||
groupARef,
|
||||
groupB,
|
||||
groupBRef,
|
||||
groupC,
|
||||
groupCRef,
|
||||
groupD,
|
||||
groupDRef,
|
||||
groupE,
|
||||
groupERef,
|
||||
groupAUserOne,
|
||||
groupBUserOne,
|
||||
groupDUserOne,
|
||||
groupEUserOne,
|
||||
duplicatedUser,
|
||||
mockedCatalogApiSupportingGroups,
|
||||
} from '../__testUtils__/catalogMocks';
|
||||
|
||||
describe('Helper functions', () => {
|
||||
it('getAllDesendantMembersForGroupEntity correctly recursively returns all descendant members', async () => {
|
||||
const catalogApi = mockedCatalogApiSupportingGroups as CatalogApi;
|
||||
|
||||
const actualGroupADescendantMembers =
|
||||
await getAllDesendantMembersForGroupEntity(groupA, catalogApi);
|
||||
const actualGroupCDescendantMembers =
|
||||
await getAllDesendantMembersForGroupEntity(groupC, catalogApi);
|
||||
|
||||
expect(actualGroupADescendantMembers).toStrictEqual([
|
||||
groupBUserOne,
|
||||
groupDUserOne,
|
||||
duplicatedUser,
|
||||
groupEUserOne,
|
||||
duplicatedUser,
|
||||
]);
|
||||
expect(actualGroupCDescendantMembers).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('getMembersFromGroups correctly returns all members of provided groups', async () => {
|
||||
const catalogApi = mockedCatalogApiSupportingGroups as CatalogApi;
|
||||
|
||||
const actualNoGroupsMembers = await getMembersFromGroups([], catalogApi);
|
||||
const actualGroupAMembers = await getMembersFromGroups(
|
||||
[groupARef],
|
||||
catalogApi,
|
||||
);
|
||||
const actualGroupCMembers = await getMembersFromGroups(
|
||||
[groupCRef],
|
||||
catalogApi,
|
||||
);
|
||||
const actualMultipleGroupMembers = await getMembersFromGroups(
|
||||
[groupARef, groupBRef, groupCRef],
|
||||
catalogApi,
|
||||
);
|
||||
|
||||
expect(actualNoGroupsMembers).toStrictEqual([]);
|
||||
expect(actualGroupAMembers).toStrictEqual([groupAUserOne, duplicatedUser]);
|
||||
expect(actualGroupCMembers).toStrictEqual([]);
|
||||
expect(actualMultipleGroupMembers).toStrictEqual([
|
||||
groupAUserOne,
|
||||
duplicatedUser,
|
||||
groupBUserOne,
|
||||
]);
|
||||
});
|
||||
|
||||
it('removeDuplicateEntitiesFrom correctly removes duplicate entities', () => {
|
||||
const actualNoDuplicatedEntitiesResult = removeDuplicateEntitiesFrom([
|
||||
groupA,
|
||||
groupB,
|
||||
groupC,
|
||||
groupD,
|
||||
]);
|
||||
const actualDuplicatedEntitiesResult = removeDuplicateEntitiesFrom([
|
||||
groupA,
|
||||
groupB,
|
||||
groupB,
|
||||
groupC,
|
||||
groupC,
|
||||
groupD,
|
||||
groupE,
|
||||
]);
|
||||
const actualNoEntitiesProvidedResult = removeDuplicateEntitiesFrom([]);
|
||||
|
||||
expect(actualNoDuplicatedEntitiesResult).toStrictEqual([
|
||||
groupA,
|
||||
groupB,
|
||||
groupC,
|
||||
groupD,
|
||||
]);
|
||||
expect(actualDuplicatedEntitiesResult).toStrictEqual([
|
||||
groupA,
|
||||
groupB,
|
||||
groupC,
|
||||
groupD,
|
||||
groupE,
|
||||
]);
|
||||
expect(actualNoEntitiesProvidedResult).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('getDescendantGroupsFromGroup correctly recursively returns descendant groups, ignoring duplicates', async () => {
|
||||
const actualDescendantGroups = await getDescendantGroupsFromGroup(
|
||||
groupA,
|
||||
mockedCatalogApiSupportingGroups as CatalogApi,
|
||||
);
|
||||
expect(actualDescendantGroups).toStrictEqual([
|
||||
groupBRef,
|
||||
groupCRef,
|
||||
groupDRef,
|
||||
groupERef,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 {
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
Entity,
|
||||
GroupEntity,
|
||||
RELATION_PARENT_OF,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
CatalogApi,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
|
||||
export const getMembersFromGroups = async (
|
||||
groups: CompoundEntityRef[],
|
||||
catalogApi: CatalogApi,
|
||||
) => {
|
||||
const membersList =
|
||||
groups.length === 0
|
||||
? { items: [] }
|
||||
: await catalogApi.getEntities({
|
||||
filter: {
|
||||
kind: 'User',
|
||||
'relations.memberof': groups.map(group =>
|
||||
stringifyEntityRef({
|
||||
kind: 'group',
|
||||
namespace: group.namespace.toLocaleLowerCase('en-US'),
|
||||
name: group.name.toLocaleLowerCase('en-US'),
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return membersList.items as UserEntity[];
|
||||
};
|
||||
|
||||
export const getDescendantGroupsFromGroup = async (
|
||||
group: GroupEntity,
|
||||
catalogApi: CatalogApi,
|
||||
) => {
|
||||
const alreadyQueuedOrExpandedGroupNames = new Map<string, boolean>();
|
||||
const groupRef: CompoundEntityRef = {
|
||||
kind: group.kind,
|
||||
namespace: group.metadata.namespace ?? DEFAULT_NAMESPACE,
|
||||
name: group.metadata.name,
|
||||
};
|
||||
|
||||
const groupQueue = [groupRef];
|
||||
const resultantGroupRefs: CompoundEntityRef[] = [];
|
||||
|
||||
// Continue expanding groups until there are no more
|
||||
while (groupQueue.length > 0) {
|
||||
const activeGroupRef = groupQueue.shift() as CompoundEntityRef;
|
||||
const activeGroup = await catalogApi.getEntityByRef(activeGroupRef);
|
||||
alreadyQueuedOrExpandedGroupNames.set(
|
||||
stringifyEntityRef(activeGroupRef),
|
||||
true,
|
||||
);
|
||||
|
||||
const childGroups = getEntityRelations(activeGroup, RELATION_PARENT_OF, {
|
||||
kind: 'Group',
|
||||
}).filter(
|
||||
currentGroup =>
|
||||
!alreadyQueuedOrExpandedGroupNames.has(
|
||||
stringifyEntityRef(currentGroup),
|
||||
),
|
||||
);
|
||||
childGroups.forEach(childGroup =>
|
||||
alreadyQueuedOrExpandedGroupNames.set(
|
||||
stringifyEntityRef(childGroup),
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
groupQueue.push(...childGroups);
|
||||
resultantGroupRefs.push(...childGroups);
|
||||
}
|
||||
|
||||
return resultantGroupRefs;
|
||||
};
|
||||
|
||||
export const getAllDesendantMembersForGroupEntity = async (
|
||||
groupEntity: GroupEntity,
|
||||
catalogApi: CatalogApi,
|
||||
) =>
|
||||
getMembersFromGroups(
|
||||
await getDescendantGroupsFromGroup(groupEntity, catalogApi),
|
||||
catalogApi,
|
||||
);
|
||||
|
||||
export const removeDuplicateEntitiesFrom = (entityArray: Entity[]) => {
|
||||
const seenEntities = new Map<string, boolean>();
|
||||
|
||||
return entityArray.filter(entity => {
|
||||
const stringifiedEntity = stringifyEntityRef(entity);
|
||||
const isDuplicate = seenEntities.has(stringifiedEntity);
|
||||
|
||||
seenEntities.set(stringifiedEntity, true);
|
||||
return !isDuplicate;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user