Merge pull request #17495 from wss-rbunning/show-aggregate-users-under-groups

Show aggregate users under groups
This commit is contained in:
Patrik Oldsberg
2023-05-04 11:59:07 +02:00
committed by GitHub
10 changed files with 890 additions and 18 deletions
+16
View File
@@ -0,0 +1,16 @@
---
'@backstage/plugin-org': patch
---
Changed the MembersListCard component to allow displaying aggregated members when viewing a group. Now, a toggle switch can be displayed that lets you switch between showing direct members and aggregated members.
To enable this new feature, set the showAggregateMembersToggle prop on EntityMembersListCard:
```jsx
// In packages/app/src/components/catalog/EntityPage.tsx
const groupPage = (
// ...
<EntityMembersListCard showAggregateMembersToggle />
// ...
);
```
+2
View File
@@ -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
+3
View File
@@ -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>
);
+135
View File
@@ -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,
]);
});
});
+118
View File
@@ -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;
});
};
+3
View File
@@ -7641,9 +7641,12 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@material-ui/lab": 4.0.0-alpha.61