Skip to content

Commit 1751168

Browse files
feat: add groups membership section for learners (#1459)
* feat: add groups membership section for learners * test: fetchEnterpriseGroupMemberships unit test * fix: fit and finish
1 parent c98e477 commit 1751168

12 files changed

+245
-18
lines changed

src/components/PeopleManagement/GroupDetailCard.jsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { connect } from 'react-redux';
44
import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils';
55

66
import { Card, Hyperlink } from '@openedx/paragon';
7-
import { ROUTE_NAMES } from '../EnterpriseApp/data/constants';
87
import EVENT_NAMES from '../../eventTracking';
8+
import { groupDetailPageUrl } from './utils';
99

1010
const GroupDetailCard = ({ enterpriseUUID, group }) => {
1111
const { enterpriseSlug } = useParams();
12+
const groupDetailUrl = groupDetailPageUrl({ enterpriseSlug, groupUuid: group.uuid });
1213
return (
1314
<Card className="group-detail-card">
1415
<Card.Header title={group.name} />
@@ -18,7 +19,7 @@ const GroupDetailCard = ({ enterpriseUUID, group }) => {
1819
<Card.Footer className="card-button">
1920
<Hyperlink
2021
className="btn btn-outline-primary"
21-
destination={`/${enterpriseSlug}/admin/${ROUTE_NAMES.peopleManagement}/${group.uuid}`}
22+
destination={groupDetailUrl}
2223
onClick={() => {
2324
sendEnterpriseTrackEvent(
2425
enterpriseUUID,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useParams } from 'react-router-dom';
2+
import PropTypes from 'prop-types';
3+
import {
4+
Hyperlink, Icon, Skeleton,
5+
} from '@openedx/paragon';
6+
import { NorthEast } from '@openedx/paragon/icons';
7+
import { useIntl } from '@edx/frontend-platform/i18n';
8+
9+
import { groupDetailPageUrl } from '../utils';
10+
import { useEnterpriseGroupMemberships } from '../data/hooks';
11+
import { EnterpriseGroupMembershipArgs } from '../../../data/services/LmsApiService';
12+
13+
type GroupMembershipLinkProps = {
14+
groupMembership: EnterpriseGroupMembership
15+
};
16+
17+
const GroupMembershipLink = ({ groupMembership }: GroupMembershipLinkProps) => {
18+
const { enterpriseSlug } = useParams() as { enterpriseSlug: string };
19+
const { groupUuid, groupName, recentAction } = groupMembership;
20+
const groupDetailUrl = groupDetailPageUrl({ enterpriseSlug, groupUuid });
21+
22+
return (
23+
<div className="pl-3">
24+
<Hyperlink
25+
className="font-weight-bold pb-2"
26+
destination={groupDetailUrl}
27+
target="_blank"
28+
showLaunchIcon={false}
29+
>
30+
{groupName}
31+
<Icon
32+
id="SampleIcon"
33+
size="xs"
34+
src={NorthEast}
35+
screenReaderText="Visit group detail page"
36+
className="ml-1 mb-1"
37+
/>
38+
</Hyperlink>
39+
<p className="small pb-2">{recentAction}</p>
40+
</div>
41+
);
42+
};
43+
44+
GroupMembershipLink.propTypes = {
45+
groupMembership: PropTypes.shape({
46+
groupUuid: PropTypes.string.isRequired,
47+
groupName: PropTypes.string.isRequired,
48+
recentAction: PropTypes.string.isRequired,
49+
}).isRequired,
50+
};
51+
52+
const LearnerDetailGroupMemberships = ({ enterpriseUuid, lmsUserId }: EnterpriseGroupMembershipArgs) => {
53+
const { isLoading, data } = useEnterpriseGroupMemberships({ enterpriseUuid, lmsUserId });
54+
const groupMemberships = data?.data?.results;
55+
const intl = useIntl();
56+
const groupsHeader = intl.formatMessage({
57+
id: 'adminPortal.peopleManagement.learnerDetailPage.groupsHeader',
58+
defaultMessage: 'Groups',
59+
description: 'Header for groups the learner is part of',
60+
});
61+
62+
return (
63+
<div>
64+
{isLoading ? (
65+
<Skeleton
66+
width={400}
67+
height={200}
68+
/>
69+
) : (groupMemberships && (
70+
<div className="pt-3">
71+
<h3 className="pb-3">{groupsHeader}</h3>
72+
<div className="learner-detail-section">
73+
{groupMemberships?.map((groupMembership) => (<GroupMembershipLink groupMembership={groupMembership} />))}
74+
</div>
75+
</div>
76+
))}
77+
</div>
78+
);
79+
};
80+
81+
LearnerDetailGroupMemberships.propTypes = {
82+
groupMembership: PropTypes.shape({
83+
groupUuid: PropTypes.string.isRequired,
84+
groupName: PropTypes.string.isRequired,
85+
groupMembership: PropTypes.string.isRequired,
86+
}).isRequired,
87+
};
88+
89+
export default LearnerDetailGroupMemberships;
90+
91+
// 21:1 error Trailing spaces not allowed no-trailing-spaces
92+
// 60:5 error Missing semicolon @typescript-eslint/semi

src/components/PeopleManagement/LearnerDetailPage/LearnerDetailPage.jsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Person } from '@openedx/paragon/icons';
1111
import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants';
1212
import { useEnterpriseGroupUuid } from '../data/hooks';
1313
import { useEnterpriseLearnerData } from './data/hooks';
14+
import LearnerDetailGroupMemberships from './LearnerDetailGroupMemberships';
1415

1516
const LearnerDetailPage = ({ enterpriseUUID }) => {
1617
const { enterpriseSlug, groupUuid, learnerId } = useParams();
@@ -48,7 +49,7 @@ const LearnerDetailPage = ({ enterpriseUUID }) => {
4849
return baseLinks;
4950
}, [intl, enterpriseSlug, groupUuid, enterpriseGroup]);
5051
return (
51-
<div className="pt-4 pl-4">
52+
<div className="pt-4 pl-4 mb-3">
5253
<Breadcrumb
5354
ariaLabel="Learner detail page breadcrumb navigation"
5455
links={links}
@@ -70,6 +71,7 @@ const LearnerDetailPage = ({ enterpriseUUID }) => {
7071
</Card.Section>
7172
</Card>
7273
)}
74+
<LearnerDetailGroupMemberships enterpriseUuid={enterpriseUUID} lmsUserId={learnerId} />
7375
</div>
7476
);
7577
};

src/components/PeopleManagement/_PeopleManagement.scss

+7
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,11 @@
2121
border-radius: 50%;
2222
padding: 10px;
2323
color: white;
24+
}
25+
.learner-detail-section {
26+
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.175);
27+
padding-top: 2rem;
28+
padding-bottom: 1px;
29+
width: 25rem;
30+
border-radius: 10px;
2431
}

src/components/PeopleManagement/constants.js

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const peopleManagementQueryKeys = {
1616
members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid],
1717
removeMember: (groupUuid) => [...peopleManagementQueryKeys.all, 'removeMember', groupUuid],
1818
learners: (groupUuid) => [...peopleManagementQueryKeys.all, 'learners', groupUuid],
19+
groupMemberships: ({ enterpriseUuid, lmsUserId }) => [...peopleManagementQueryKeys.all, 'groupMemberships', enterpriseUuid, lmsUserId],
1920
};
2021

2122
export const MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT = 15;

src/components/PeopleManagement/data/hooks/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid';
22
export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData';
33
export { default as useEnterpriseMembersTableData } from './useEnterpriseMembersTableData';
44
export { default as useAllEnterpriseGroupLearners } from './useAllEnterpriseGroupLearners';
5+
export { default as useEnterpriseGroupMemberships } from './useEnterpriseGroupMemberships';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
3+
import LmsApiService, { EnterpriseGroupMembershipArgs } from '../../../../data/services/LmsApiService';
4+
import { peopleManagementQueryKeys } from '../../constants';
5+
6+
const useEnterpriseGroupMemberships = ({ enterpriseUuid, lmsUserId }: EnterpriseGroupMembershipArgs) => useQuery({
7+
queryKey: peopleManagementQueryKeys.groupMemberships({ enterpriseUuid, lmsUserId }),
8+
queryFn: () => LmsApiService.fetchEnterpriseGroupMemberships({ enterpriseUuid, lmsUserId }),
9+
});
10+
11+
export default useEnterpriseGroupMemberships;

src/components/PeopleManagement/tests/LearnerDetailPage.test.jsx

+42-4
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import '@testing-library/jest-dom/extend-expect';
88
import { QueryClientProvider } from '@tanstack/react-query';
99
import { IntlProvider } from '@edx/frontend-platform/i18n';
1010

11-
import { useEnterpriseGroupUuid } from '../data/hooks';
11+
import { useEnterpriseGroupUuid, useEnterpriseGroupMemberships } from '../data/hooks';
1212
import LearnerDetailPage from '../LearnerDetailPage/LearnerDetailPage';
1313
import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants';
1414
import LmsApiService from '../../../data/services/LmsApiService';
@@ -24,6 +24,18 @@ const TEST_GROUP = {
2424
groupType: 'flex',
2525
};
2626

27+
const TEST_GROUPS = [
28+
{
29+
groupUuid: TEST_GROUP.uuid,
30+
groupName: TEST_GROUP.name,
31+
recentAction: 'Accepted: March 10, 2025',
32+
}, {
33+
groupUuid: '6721',
34+
groupName: 'Another Group',
35+
recentAction: 'Removed: March 17, 2025',
36+
},
37+
];
38+
2739
const TEST_ENTERPRISE_USER = {
2840
data: {
2941
results: [{
@@ -59,6 +71,7 @@ jest.mock('react-router-dom', () => ({
5971

6072
jest.mock('../data/hooks', () => ({
6173
...jest.requireActual('../data/hooks'),
74+
useEnterpriseGroupMemberships: jest.fn(),
6275
useEnterpriseGroupUuid: jest.fn(),
6376
}));
6477

@@ -84,6 +97,13 @@ const LearnerDetailPageWrapper = ({
8497
describe('LearnerDetailPage', () => {
8598
beforeEach(() => {
8699
useEnterpriseGroupUuid.mockReturnValue({ data: TEST_GROUP });
100+
useEnterpriseGroupMemberships.mockReturnValue({
101+
data: {
102+
data: {
103+
results: TEST_GROUPS,
104+
},
105+
},
106+
});
87107
LmsApiService.fetchEnterpriseCustomerMembers.mockResolvedValue(TEST_ENTERPRISE_USER);
88108
});
89109
it('renders breadcrumb from people management page', async () => {
@@ -105,9 +125,9 @@ describe('LearnerDetailPage', () => {
105125
});
106126
render(<LearnerDetailPageWrapper />);
107127
const expectedLink = `/${ENTERPRISE_SLUG}/admin/${ROUTE_NAMES.peopleManagement}/${TEST_GROUP.uuid}`;
108-
const groupDetailBreadcrumb = screen.getByText(TEST_GROUP.name);
109-
expect(groupDetailBreadcrumb).toBeInTheDocument();
110-
expect(groupDetailBreadcrumb).toHaveAttribute('href', expectedLink);
128+
const groupDetailBreadcrumbs = screen.getAllByText(TEST_GROUP.name);
129+
expect(groupDetailBreadcrumbs).toHaveLength(2);
130+
expect(groupDetailBreadcrumbs[0]).toHaveAttribute('href', expectedLink);
111131
});
112132
it('renders learner detail card', async () => {
113133
useParams.mockReturnValue({
@@ -120,4 +140,22 @@ describe('LearnerDetailPage', () => {
120140
expect(screen.getByText('[email protected]')).toBeInTheDocument();
121141
expect(screen.getByText('Joined on Jun 30, 2023')).toBeInTheDocument();
122142
});
143+
it('renders groups section', async () => {
144+
useParams.mockReturnValue({
145+
enterpriseSlug: ENTERPRISE_SLUG,
146+
learnerId: LMS_USER_ID,
147+
});
148+
render(<LearnerDetailPageWrapper />);
149+
await waitFor(() => {
150+
expect(screen.getByText('Groups')).toBeInTheDocument();
151+
});
152+
const firstGroupLink = screen.getByText(TEST_GROUP.name);
153+
expect(firstGroupLink).toBeInTheDocument();
154+
expect(firstGroupLink).toHaveAttribute('href', '/test-slug/admin/people-management/1276');
155+
const secondGroupLink = screen.getByText('Another Group');
156+
expect(secondGroupLink).toBeInTheDocument();
157+
expect(secondGroupLink).toHaveAttribute('href', '/test-slug/admin/people-management/6721');
158+
expect(screen.getByText('Accepted: March 10, 2025').toBeInTheDocument);
159+
expect(screen.getByText('Removed: March 17, 2025').toBeInTheDocument);
160+
});
123161
});
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dayjs from 'dayjs';
22
import { MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT } from './constants';
3+
import { ROUTE_NAMES } from '../EnterpriseApp/data/constants';
34

45
/**
56
* Formats provided dates for display
@@ -12,17 +13,6 @@ export default function formatDates(timestamp) {
1213
return dayjs(timestamp).format(DATE_FORMAT);
1314
}
1415

15-
export const getSelectedEmailsByRow = (selectedFlatRows) => {
16-
const emails = [];
17-
Object.keys(selectedFlatRows).forEach(key => {
18-
const { original } = selectedFlatRows[key];
19-
if (original.enterpriseCustomerUser !== null) {
20-
emails.push(original.enterpriseCustomerUser.email);
21-
}
22-
});
23-
return emails;
24-
};
25-
2616
/**
2717
* Determine whether the number of learner emails exceeds a certain
2818
* threshold, whereby the list of emails should be truncated.
@@ -32,3 +22,14 @@ export const getSelectedEmailsByRow = (selectedFlatRows) => {
3222
export const hasLearnerEmailsSummaryListTruncation = (learnerEmails) => (
3323
learnerEmails.length > MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT
3424
);
25+
26+
export type GroupDetailPageUrlArgs = {
27+
enterpriseSlug: string,
28+
groupUuid: string,
29+
};
30+
/**
31+
*
32+
* @param GroupDetailPageUrlArgs enterpriseSlug and groupUuid pointing to Group Detail page
33+
* @returns url to Group Detail page
34+
*/
35+
export const groupDetailPageUrl = ({ enterpriseSlug, groupUuid }: GroupDetailPageUrlArgs) => `/${enterpriseSlug}/admin/${ROUTE_NAMES.peopleManagement}/${groupUuid}`;

src/data/services/LmsApiService.ts

+17
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ export type UpdateEnterpriseGroupArgs = {
1717
name: string,
1818
};
1919

20+
export type EnterpriseGroupMembershipArgs = {
21+
lmsUserId: string,
22+
enterpriseUuid: string,
23+
};
24+
2025
export type EnterpriseGroupResponse = Promise<AxiosResponse<EnterpriseGroup>>;
2126
export type EnterpriseGroupListResponse = Promise<AxiosResponse<PaginatedCurrentPage<EnterpriseGroup>>>;
2227
export type EnterpriseLearnersListResponse = Promise<AxiosResponse<PaginatedCurrentPage<EnterpriseLearner>>>;
28+
export type EnterpriseGroupMembershipResponse = Promise<AxiosResponse<PaginatedCurrentPage<EnterpriseGroupMembership>>>;
2329

2430
export type FetchEnterpriseLearnerDataArgs = { enterpriseCustomer?: string, userId?: string, username?: string };
2531

@@ -66,6 +72,8 @@ class LmsApiService {
6672

6773
static enterpriseGroupListUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise_group/`;
6874

75+
static enterpriseGroupMembershipUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-group-membership/`;
76+
6977
static enterpriseLearnerUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-learner/`;
7078

7179
static async createEnterpriseGroup(
@@ -540,6 +548,15 @@ class LmsApiService {
540548
const url = `${LmsApiService.enterpriseLearnerUrl}?${queryParams.toString()}`;
541549
return LmsApiService.apiClient().get(url);
542550
};
551+
552+
static fetchEnterpriseGroupMemberships = async ({ lmsUserId, enterpriseUuid }:
553+
EnterpriseGroupMembershipArgs): EnterpriseGroupMembershipResponse => {
554+
const queryString = `?lms_user_id=${lmsUserId}&enterprise_uuid=${enterpriseUuid}`;
555+
const groupEndpoint = `${LmsApiService.enterpriseGroupMembershipUrl}${queryString}`;
556+
const response = await LmsApiService.apiClient().get(groupEndpoint);
557+
response.data = camelCaseObject(response.data);
558+
return response;
559+
};
543560
}
544561

545562
export default LmsApiService;

0 commit comments

Comments
 (0)