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