diff --git a/.changeset/new-roses-fail.md b/.changeset/new-roses-fail.md
new file mode 100644
index 0000000000..77495c5f54
--- /dev/null
+++ b/.changeset/new-roses-fail.md
@@ -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 = (
+ // ...
+
+ // ...
+);
+```
diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md
index 1111253119..7f21bfbb6b 100644
--- a/plugins/org/api-report.md
+++ b/plugins/org/api-report.md
@@ -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
diff --git a/plugins/org/package.json b/plugins/org/package.json
index 8d6490b2e2..91d0d8eac2 100644
--- a/plugins/org/package.json
+++ b/plugins/org/package.json
@@ -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",
diff --git a/plugins/org/src/__testUtils__/catalogMocks.ts b/plugins/org/src/__testUtils__/catalogMocks.ts
new file mode 100644
index 0000000000..b14856e345
--- /dev/null
+++ b/plugins/org/src/__testUtils__/catalogMocks.ts
@@ -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([
+ ['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([
+ ['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 | undefined;
+
+export const mockedCatalogApiSupportingGroups: Partial = {
+ 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 };
+ },
+};
diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx
index 073f892302..6b34d8b934 100644
--- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx
+++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.stories.tsx
@@ -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 = () => (
);
+
+export const AggregateMembersToggle = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx
index cc524b60a2..47e954eb17 100644
--- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx
+++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx
@@ -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 = {
+ starredEntitie$: () => {
+ return {
+ subscribe: () => {
+ return {
+ unsubscribe() {
+ // This is intentional
+ },
+ };
+ },
+ } as Observable>;
+ },
+};
+
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(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const toggleSwitch = screen.queryByRole('checkbox');
+ expect(toggleSwitch).toBeNull();
+ });
+
+ it('Shows the aggregate members toggle if the showAggregateMembersToggle prop is true', async () => {
+ await renderInTestApp(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.queryByRole('checkbox')).toBeInTheDocument();
+ });
+
+ it('Shows only direct members if the showAggregateMembersToggle prop is undefined', async () => {
+ await renderInTestApp(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ // 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,
+ );
+ });
+ });
});
diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx
index eb61989144..0a5386ea20 100644
--- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx
+++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx
@@ -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 }) => {
>
{
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();
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 ;
} else if (error) {
@@ -193,21 +226,39 @@ export const MembersListCard = (props: {
subheader={`of ${displayName}`}
{...(nbPages <= 1 ? {} : { actions: pagination })}
>
-
- {members && members.length > 0 ? (
- members
- .slice(pageSize * (page - 1), pageSize * page)
- .map(member => (
-
- ))
- ) : (
-
-
- This group has no {memberDisplayTitle.toLocaleLowerCase()}.
-
-
- )}
-
+ {showAggregateMembersToggle && (
+ <>
+ Direct Members
+ {
+ setShowAggregateMembers(!showAggregateMembers);
+ }}
+ inputProps={{ 'aria-label': 'Users Type Switch' }}
+ />
+ Aggregated Members
+ >
+ )}
+ {showAggregateMembers && loadingDescendantMembers ? (
+
+ ) : (
+
+ {members && members.length > 0 ? (
+ members
+ .slice(pageSize * (page - 1), pageSize * page)
+ .map(member => (
+
+ ))
+ ) : (
+
+
+ This group has no {memberDisplayTitle.toLocaleLowerCase()}.
+
+
+ )}
+
+ )}
);
diff --git a/plugins/org/src/helpers/helpers.test.ts b/plugins/org/src/helpers/helpers.test.ts
new file mode 100644
index 0000000000..4cee5422ce
--- /dev/null
+++ b/plugins/org/src/helpers/helpers.test.ts
@@ -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,
+ ]);
+ });
+});
diff --git a/plugins/org/src/helpers/helpers.ts b/plugins/org/src/helpers/helpers.ts
new file mode 100644
index 0000000000..7da4ee32d8
--- /dev/null
+++ b/plugins/org/src/helpers/helpers.ts
@@ -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();
+ 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();
+
+ return entityArray.filter(entity => {
+ const stringifiedEntity = stringifyEntityRef(entity);
+ const isDuplicate = seenEntities.has(stringifiedEntity);
+
+ seenEntities.set(stringifiedEntity, true);
+ return !isDuplicate;
+ });
+};
diff --git a/yarn.lock b/yarn.lock
index 3da8172870..aacd7591e6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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