diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx
index 6dd04a9364..df4e16f244 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.jsx
@@ -19,7 +19,7 @@ const Leaderboard = ({ startDate, endDate, enterpriseId }) => {
})}
tableSubtitle={intl.formatMessage({
id: 'advance.analytics.leaderboard.tab.datatable.leaderboard.subtitle',
- defaultMessage: 'See the top learners by different measures of engagement. The results are defaulted to sort by learning hours. Download the full CSV below to sort by other metrics.',
+ defaultMessage: 'Explore the top learners ranked by engagement metrics. The list is sorted by learning hours by default. To dive deeper, download the full CSV to explore and sort by other metrics. *Only learners who have passed the course and completed at least one engagement activity (watching a video, submitting a problem, or posting in forums) are included.',
description: 'Subtitle for the leaderboard datatable.',
})}
startDate={startDate}
diff --git a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx
index 0d56699b70..2d13dc97ee 100644
--- a/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx
+++ b/src/components/AdvanceAnalyticsV2/tabs/Leaderboard.test.jsx
@@ -75,7 +75,7 @@ describe('Leaderboard Component', () => {
{
className: '.leaderboard-datatable-container',
title: 'Leaderboard',
- subtitle: 'See the top learners by different measures of engagement. The results are defaulted to sort by learning hours. Download the full CSV below to sort by other metrics.',
+ subtitle: 'Explore the top learners ranked by engagement metrics. The list is sorted by learning hours by default. To dive deeper, download the full CSV to explore and sort by other metrics. *Only learners who have passed the course and completed at least one engagement activity (watching a video, submitting a problem, or posting in forums) are included.',
},
];
diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx
new file mode 100644
index 0000000000..29d355e9b0
--- /dev/null
+++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Stack, Icon } from '@openedx/paragon';
+import { Error } from '@openedx/paragon/icons';
+
+const AddMemberModalSummaryDuplicate = () => (
+
+
+
+
Only 1 invite per email address will be sent.
+
One or more duplicate emails were detected. Ensure that your entry is correct before proceeding.
+
+
+);
+
+export default AddMemberModalSummaryDuplicate;
diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryEmptyState.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryEmptyState.jsx
new file mode 100644
index 0000000000..f9157bfab2
--- /dev/null
+++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryEmptyState.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+
+const AddMemberModalSummaryEmptyState = () => (
+ <>
+
You haven't uploaded any members yet.
+ Upload a CSV file or select members to get started.
+ >
+);
+
+export default AddMemberModalSummaryEmptyState;
diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryErrorState.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryErrorState.jsx
new file mode 100644
index 0000000000..9ffc2256ed
--- /dev/null
+++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryErrorState.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Stack, Icon } from '@openedx/paragon';
+import { Error } from '@openedx/paragon/icons';
+
+const AddMemberModalSummaryErrorState = () => (
+
+
+
+
Members can't be added as entered.
+
Please check your file and try again.
+
+
+);
+
+export default AddMemberModalSummaryErrorState;
diff --git a/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryLearnerList.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryLearnerList.jsx
new file mode 100644
index 0000000000..9349533315
--- /dev/null
+++ b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryLearnerList.jsx
@@ -0,0 +1,68 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { v4 as uuidv4 } from 'uuid';
+import {
+ Button, Stack, Icon,
+} from '@openedx/paragon';
+import { Person } from '@openedx/paragon/icons';
+
+import { MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT } from '../constants';
+import { hasLearnerEmailsSummaryListTruncation } from '../utils';
+
+const AddMemberModalSummaryLearnerList = ({
+ learnerEmails,
+}) => {
+ const [isTruncated, setIsTruncated] = useState(hasLearnerEmailsSummaryListTruncation(learnerEmails));
+ const truncatedLearnerEmails = learnerEmails.slice(0, MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT);
+ const displayedLearnerEmails = isTruncated ? truncatedLearnerEmails : learnerEmails;
+
+ useEffect(() => {
+ setIsTruncated(hasLearnerEmailsSummaryListTruncation(learnerEmails));
+ }, [learnerEmails]);
+
+ const expandCollapseMessage = isTruncated
+ ? `Show ${learnerEmails.length - MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT} more`
+ : 'Show less';
+
+ return (
+
+
+ {displayedLearnerEmails.map((emailAddress) => (
+
+
+
+
+
+
+ {emailAddress}
+
+
+
+
+
+ ))}
+
+ {hasLearnerEmailsSummaryListTruncation(learnerEmails) && (
+ setIsTruncated(prevState => !prevState)}
+ >
+ {expandCollapseMessage}
+
+ )}
+
+ );
+};
+
+AddMemberModalSummaryLearnerList.propTypes = {
+ learnerEmails: PropTypes.arrayOf(PropTypes.string).isRequired,
+};
+
+export default AddMemberModalSummaryLearnerList;
diff --git a/src/components/PeopleManagement/AddMembersModal/AddMembersModal.jsx b/src/components/PeopleManagement/AddMembersModal/AddMembersModal.jsx
new file mode 100644
index 0000000000..b64c5e96e2
--- /dev/null
+++ b/src/components/PeopleManagement/AddMembersModal/AddMembersModal.jsx
@@ -0,0 +1,136 @@
+import React, { useCallback, useState, useEffect } from 'react';
+import { logError } from '@edx/frontend-platform/logging';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { useQueryClient } from '@tanstack/react-query';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { snakeCaseObject } from '@edx/frontend-platform/utils';
+import {
+ ActionRow, Button, FullscreenModal, StatefulButton, useToggle,
+} from '@openedx/paragon';
+import LmsApiService from '../../../data/services/LmsApiService';
+import SystemErrorAlertModal from '../../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal';
+import AddMembersModalContent from './AddMembersModalContent';
+import { learnerCreditManagementQueryKeys } from '../../learner-credit-management/data';
+import { useAllEnterpriseGroupLearners } from '../data/hooks';
+
+const AddMembersModal = ({
+ isModalOpen,
+ closeModal,
+ enterpriseUUID,
+ groupName,
+ groupUuid,
+}) => {
+ const intl = useIntl();
+ const [learnerEmails, setLearnerEmails] = useState([]);
+ const [addButtonState, setAddButtonState] = useState('default');
+ const [canAddMembers, setCanAddMembersGroup] = useState(false);
+ const [isSystemErrorModalOpen, openSystemErrorModal, closeSystemErrorModal] = useToggle(false);
+ const handleCloseAddMembersModal = () => {
+ closeModal();
+ setAddButtonState('default');
+ };
+ const queryClient = useQueryClient();
+ const {
+ isLoading,
+ enterpriseGroupLearners,
+ } = useAllEnterpriseGroupLearners(groupUuid);
+
+ const handleAddMembers = async () => {
+ setAddButtonState('pending');
+ try {
+ const requestBody = snakeCaseObject({
+ learnerEmails,
+ });
+ await LmsApiService.inviteEnterpriseLearnersToGroup(groupUuid, requestBody);
+ queryClient.invalidateQueries({
+ queryKey: learnerCreditManagementQueryKeys.group(groupUuid),
+ });
+ setAddButtonState('complete');
+ handleCloseAddMembersModal();
+ } catch (err) {
+ logError(err);
+ setAddButtonState('error');
+ openSystemErrorModal();
+ }
+ };
+
+ const handleEmailAddressesChange = useCallback((
+ value,
+ { canInvite = false } = {},
+ ) => {
+ setLearnerEmails(value);
+ setCanAddMembersGroup(canInvite);
+ }, []);
+
+ useEffect(() => {
+ setCanAddMembersGroup(false);
+ if (canAddMembers) {
+ setCanAddMembersGroup(true);
+ }
+ }, [canAddMembers]);
+ return (
+
+ {!isLoading ? (
+
+
+
+ Cancel
+
+
+ )}
+ >
+
+
+
+
+ ) : null}
+
+ );
+};
+
+AddMembersModal.propTypes = {
+ enterpriseUUID: PropTypes.string.isRequired,
+ isModalOpen: PropTypes.bool.isRequired,
+ closeModal: PropTypes.func.isRequired,
+ groupUuid: PropTypes.string.isRequired,
+ groupName: PropTypes.string,
+};
+
+const mapStateToProps = state => ({
+ enterpriseUUID: state.portalConfiguration.enterpriseId,
+});
+
+export default connect(mapStateToProps)(AddMembersModal);
diff --git a/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx b/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx
new file mode 100644
index 0000000000..4556fa8d3a
--- /dev/null
+++ b/src/components/PeopleManagement/AddMembersModal/AddMembersModalContent.jsx
@@ -0,0 +1,149 @@
+import React, {
+ useCallback, useEffect, useMemo, useState,
+} from 'react';
+import PropTypes from 'prop-types';
+import debounce from 'lodash.debounce';
+import {
+ Col, Container, Row, Hyperlink,
+} from '@openedx/paragon';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+import AddMembersModalSummary from './AddMembersModalSummary';
+import InviteSummaryCount from '../../learner-credit-management/invite-modal/InviteSummaryCount';
+import FileUpload from '../../learner-credit-management/invite-modal/FileUpload';
+import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isInviteEmailAddressesInputValueValid } from '../../learner-credit-management/cards/data';
+import EnterpriseCustomerUserDatatable from '../EnterpriseCustomerUserDatatable';
+import { useEnterpriseLearners } from '../../learner-credit-management/data';
+
+const AddMembersModalContent = ({
+ onEmailAddressesChange,
+ enterpriseUUID,
+ groupName,
+ enterpriseGroupLearners,
+}) => {
+ const [learnerEmails, setLearnerEmails] = useState([]);
+ const [emailAddressesInputValue, setEmailAddressesInputValue] = useState('');
+ const [memberInviteMetadata, setMemberInviteMetadata] = useState({
+ isValidInput: null,
+ lowerCasedEmails: [],
+ duplicateEmails: [],
+ emailsNotInOrg: [],
+ });
+ const { allEnterpriseLearners } = useEnterpriseLearners({ enterpriseUUID });
+
+ const handleAddMembersBulkAction = useCallback((value) => {
+ if (!value) {
+ setLearnerEmails([]);
+ onEmailAddressesChange([]);
+ return;
+ }
+ setLearnerEmails(prev => [...prev, ...value]);
+ }, [onEmailAddressesChange]);
+
+ const handleRemoveMembersBulkAction = useCallback((value) => {
+ if (!value) {
+ setLearnerEmails([]);
+ onEmailAddressesChange([]);
+ return;
+ }
+ setLearnerEmails(prev => prev.filter((el) => !value.includes(el)));
+ }, [onEmailAddressesChange]);
+
+ const handleEmailAddressesChanged = useCallback((value) => {
+ if (!value) {
+ setLearnerEmails([]);
+ onEmailAddressesChange([]);
+ return;
+ }
+ // handles csv upload value and formats emails into an array of strings
+ const emails = value.split('\n').map((email) => email.trim()).filter((email) => email.length > 0);
+ setLearnerEmails(emails);
+ }, [onEmailAddressesChange]);
+
+ const debouncedHandleEmailAddressesChanged = useMemo(
+ () => debounce(handleEmailAddressesChanged, EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY),
+ [handleEmailAddressesChanged],
+ );
+
+ useEffect(() => {
+ debouncedHandleEmailAddressesChanged(emailAddressesInputValue);
+ }, [emailAddressesInputValue, debouncedHandleEmailAddressesChanged]);
+
+ // Validate the learner emails emails from user input whenever it changes
+ useEffect(() => {
+ const inviteMetadata = isInviteEmailAddressesInputValueValid({
+ learnerEmails,
+ allEnterpriseLearners,
+ });
+ setMemberInviteMetadata(inviteMetadata);
+ if (inviteMetadata.canInvite) {
+ onEmailAddressesChange(learnerEmails, { canInvite: true });
+ } else {
+ onEmailAddressesChange([]);
+ }
+ }, [onEmailAddressesChange, learnerEmails, allEnterpriseLearners]);
+
+ return (
+
+
+
+
+
+
+ Only members registered with your organization can be added to your group.
+
+ Learn more.
+
+
+ Group Name
+ {groupName}
+
+
+
+
+
+ Select group members
+
+
+
+
+
+
+ Details
+
+
+
+
+
+
+
+ );
+};
+
+AddMembersModalContent.propTypes = {
+ onEmailAddressesChange: PropTypes.func.isRequired,
+ enterpriseUUID: PropTypes.string.isRequired,
+ groupName: PropTypes.string,
+ enterpriseGroupLearners: PropTypes.arrayOf(PropTypes.shape({})),
+};
+
+export default AddMembersModalContent;
diff --git a/src/components/PeopleManagement/AddMembersModal/AddMembersModalSummary.jsx b/src/components/PeopleManagement/AddMembersModal/AddMembersModalSummary.jsx
new file mode 100644
index 0000000000..a74df3337c
--- /dev/null
+++ b/src/components/PeopleManagement/AddMembersModal/AddMembersModalSummary.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { Card, Stack } from '@openedx/paragon';
+import isEmpty from 'lodash/isEmpty';
+
+import AddMemberModalSummaryEmptyState from './AddMemberModalSummaryEmptyState';
+import AddMemberModalSummaryLearnerList from './AddMemberModalSummaryLearnerList';
+import AddMemberModalSummaryErrorState from './AddMemberModalSummaryErrorState';
+import AddMemberModalSummaryDuplicate from './AddMemberModalSummaryDuplicate';
+import LearnerNotInOrgErrorState from '../LearnerNotInOrgErrorState';
+
+const AddMembersModalSummary = ({
+ memberInviteMetadata,
+}) => {
+ const {
+ isValidInput,
+ lowerCasedEmails,
+ duplicateEmails,
+ emailsNotInOrg,
+ } = memberInviteMetadata;
+ const hasEmailsNotInOrg = emailsNotInOrg.length > 0;
+ const renderCard = (contents, showErrorHighlight) => (
+
+
+
+ {contents}
+
+
+
+ );
+
+ const hasLearnerEmails = lowerCasedEmails?.length > 0;
+ let cardSections = [];
+ if (hasLearnerEmails) {
+ cardSections = cardSections.concat(
+ renderCard( ),
+ );
+ }
+
+ if (!isValidInput) {
+ cardSections = cardSections.concat(
+ renderCard( , true),
+ );
+ }
+
+ if (hasEmailsNotInOrg) {
+ cardSections = cardSections.concat(
+ ,
+ );
+ }
+
+ if (isEmpty(cardSections)) {
+ cardSections = cardSections.concat(
+ renderCard( ),
+ );
+ }
+
+ let summaryHeading = 'Summary';
+ if (hasLearnerEmails) {
+ summaryHeading = `${summaryHeading} (${lowerCasedEmails.length})`;
+ }
+ return (
+ <>
+ {summaryHeading}
+ {cardSections}
+ {duplicateEmails?.length > 0 && }
+ >
+ );
+};
+
+AddMembersModalSummary.propTypes = {
+ memberInviteMetadata: PropTypes.shape({
+ isValidInput: PropTypes.bool,
+ lowerCasedEmails: PropTypes.arrayOf(PropTypes.string),
+ duplicateEmails: PropTypes.arrayOf(PropTypes.string),
+ emailsNotInOrg: PropTypes.arrayOf(PropTypes.string),
+ }).isRequired,
+};
+
+export default AddMembersModalSummary;
diff --git a/src/components/PeopleManagement/CreateGroupModal.jsx b/src/components/PeopleManagement/CreateGroupModal.jsx
index ecf7e9aae3..212053809a 100644
--- a/src/components/PeopleManagement/CreateGroupModal.jsx
+++ b/src/components/PeopleManagement/CreateGroupModal.jsx
@@ -11,7 +11,7 @@ import {
import LmsApiService from '../../data/services/LmsApiService';
import SystemErrorAlertModal from '../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal';
import CreateGroupModalContent from './CreateGroupModalContent';
-import { learnerCreditManagementQueryKeys } from '../learner-credit-management/data';
+import { peopleManagementQueryKeys } from './constants';
const CreateGroupModal = ({
isModalOpen,
@@ -53,7 +53,7 @@ const CreateGroupModal = ({
});
await LmsApiService.inviteEnterpriseLearnersToGroup(groupCreationResponse.data.uuid, requestBody);
queryClient.invalidateQueries({
- queryKey: learnerCreditManagementQueryKeys.group(enterpriseUUID),
+ queryKey: peopleManagementQueryKeys.group(enterpriseUUID),
});
setCreateButtonState('complete');
handleCloseCreateGroupModal();
diff --git a/src/components/PeopleManagement/CreateGroupModalContent.jsx b/src/components/PeopleManagement/CreateGroupModalContent.jsx
index 55abdfabf3..7c9eab902d 100644
--- a/src/components/PeopleManagement/CreateGroupModalContent.jsx
+++ b/src/components/PeopleManagement/CreateGroupModalContent.jsx
@@ -13,7 +13,7 @@ import InviteSummaryCount from '../learner-credit-management/invite-modal/Invite
import FileUpload from '../learner-credit-management/invite-modal/FileUpload';
import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY, isInviteEmailAddressesInputValueValid } from '../learner-credit-management/cards/data';
import { MAX_LENGTH_GROUP_NAME } from './constants';
-import EnterpriseCustomerUserDatatable from '../learner-credit-management/invite-modal/EnterpriseCustomerUserDatatable';
+import EnterpriseCustomerUserDatatable from './EnterpriseCustomerUserDatatable';
import { useEnterpriseLearners } from '../learner-credit-management/data';
const CreateGroupModalContent = ({
diff --git a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx
new file mode 100644
index 0000000000..32b517baea
--- /dev/null
+++ b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx
@@ -0,0 +1,140 @@
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ DataTable,
+ TextFilter,
+ CheckboxControl,
+} from '@openedx/paragon';
+import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants';
+import MemberDetailsCell from './MemberDetailsCell';
+import AddMembersBulkAction from './GroupDetailPage/AddMembersBulkAction';
+import RemoveMembersBulkAction from './RemoveMembersBulkAction';
+import MemberJoinedDateCell from './MemberJoinedDateCell';
+import { useEnterpriseMembersTableData } from './data/hooks';
+
+export const BaseSelectWithContext = ({ row, enterpriseGroupLearners }) => {
+ const {
+ indeterminate,
+ checked,
+ ...toggleRowSelectedProps
+ } = row.getToggleRowSelectedProps();
+ const isAddedMember = enterpriseGroupLearners.find(learner => learner.enterpriseCustomerUserId === Number(row.id));
+ return (
+
+
+
+ );
+};
+const FilterStatus = (rest) => ;
+
+const EnterpriseCustomerUserDatatable = ({
+ enterpriseId,
+ learnerEmails,
+ onHandleAddMembersBulkAction,
+ onHandleRemoveMembersBulkAction,
+ enterpriseGroupLearners,
+}) => {
+ const {
+ isLoading,
+ enterpriseMembersTableData,
+ fetchEnterpriseMembersTableData,
+ } = useEnterpriseMembersTableData({ enterpriseId });
+
+ return (
+ ,
+ ,
+ ]}
+ columns={[
+ {
+ Header: 'Member details',
+ accessor: 'name',
+ Cell: MemberDetailsCell,
+ },
+ {
+ Header: 'Joined organization',
+ accessor: 'joinedOrg',
+ Cell: MemberJoinedDateCell,
+ disableFilters: true,
+ },
+ ]}
+ initialState={{
+ pageIndex: GROUP_MEMBERS_TABLE_DEFAULT_PAGE,
+ pageSize: GROUP_MEMBERS_TABLE_PAGE_SIZE,
+ sortBy: [
+ { id: 'name', desc: true },
+ ],
+ filters: [],
+ }}
+ data={enterpriseMembersTableData.results}
+ defaultColumnValues={{ Filter: TextFilter }}
+ FilterStatusComponent={FilterStatus}
+ fetchData={fetchEnterpriseMembersTableData}
+ isFilterable
+ isLoading={isLoading}
+ isPaginated
+ isSelectable
+ itemCount={enterpriseMembersTableData.itemCount}
+ manualFilters
+ manualPagination
+ isSortable
+ manualSortBy
+ initialTableOptions={{
+ getRowId: row => row.enterpriseCustomerUser.name.toString(),
+ }}
+ pageCount={enterpriseMembersTableData.pageCount}
+ manualSelectColumn={
+ {
+ id: 'selection',
+ Header: DataTable.ControlledSelectHeader,
+ /* eslint-disable react/no-unstable-nested-components */
+ Cell: (props) => ,
+ disableSortBy: true,
+ }
+ }
+ />
+ );
+};
+
+EnterpriseCustomerUserDatatable.defaultProps = {
+ enterpriseGroupLearners: [],
+};
+
+EnterpriseCustomerUserDatatable.propTypes = {
+ enterpriseId: PropTypes.string.isRequired,
+ learnerEmails: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onHandleRemoveMembersBulkAction: PropTypes.func.isRequired,
+ onHandleAddMembersBulkAction: PropTypes.func.isRequired,
+ enterpriseGroupLearners: PropTypes.arrayOf(PropTypes.shape({})),
+};
+
+BaseSelectWithContext.propTypes = {
+ row: PropTypes.shape({
+ getToggleRowSelectedProps: PropTypes.func.isRequired,
+ id: PropTypes.string,
+ }).isRequired,
+ contextKey: PropTypes.string.isRequired,
+ enterpriseGroupLearners: PropTypes.arrayOf(PropTypes.shape({})),
+};
+
+const mapStateToProps = state => ({
+ enterpriseId: state.portalConfiguration.enterpriseId,
+});
+
+export default connect(mapStateToProps)(EnterpriseCustomerUserDatatable);
diff --git a/src/components/PeopleManagement/GroupCardGrid.jsx b/src/components/PeopleManagement/GroupCardGrid.jsx
index 8c1cde76e0..c875938da1 100644
--- a/src/components/PeopleManagement/GroupCardGrid.jsx
+++ b/src/components/PeopleManagement/GroupCardGrid.jsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
-import { CardGrid, Collapsible } from '@openedx/paragon';
+import { CardGrid, Collapsible, Icon } from '@openedx/paragon';
+import { ExpandLess, ExpandMore } from '@openedx/paragon/icons';
import GroupDetailCard from './GroupDetailCard';
@@ -30,24 +31,30 @@ const GroupCardGrid = ({ groups }) => {
))}
{overflowGroups && (
-
-
- {overflowGroups.map((group) => (
-
- ))}
-
-
+
+
+
+ {overflowGroups.map((group) => (
+
+ ))}
+
+
+
+
+ Show all {groups.length} groups
+
+
+ Show less
+
+
+
)}
>
);
diff --git a/src/components/PeopleManagement/GroupDetailPage/AddMemberTableAction.jsx b/src/components/PeopleManagement/GroupDetailPage/AddMemberTableAction.jsx
new file mode 100644
index 0000000000..6e846598f0
--- /dev/null
+++ b/src/components/PeopleManagement/GroupDetailPage/AddMemberTableAction.jsx
@@ -0,0 +1,19 @@
+import { Button } from '@openedx/paragon';
+import { Add } from '@openedx/paragon/icons';
+import PropTypes from 'prop-types';
+
+const AddMemberTableAction = ({ openModal }) => (
+ Add members
+
+);
+
+AddMemberTableAction.propTypes = {
+ openModal: PropTypes.func.isRequired,
+};
+
+export default AddMemberTableAction;
diff --git a/src/components/PeopleManagement/GroupDetailPage/AddMembersBulkAction.jsx b/src/components/PeopleManagement/GroupDetailPage/AddMembersBulkAction.jsx
new file mode 100644
index 0000000000..cd1d6a4d95
--- /dev/null
+++ b/src/components/PeopleManagement/GroupDetailPage/AddMembersBulkAction.jsx
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import { StatefulButton } from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { useGetAllEnterpriseLearnerEmails } from '../data/hooks/useEnterpriseLearnersTableData';
+import { getSelectedEmailsByRow } from '../utils';
+
+const AddMembersBulkAction = ({
+ isEntireTableSelected,
+ selectedFlatRows,
+ onHandleAddMembersBulkAction,
+ enterpriseId,
+ enterpriseGroupLearners,
+}) => {
+ const intl = useIntl();
+ const { fetchLearnerEmails, addButtonState } = useGetAllEnterpriseLearnerEmails({
+ enterpriseId,
+ isEntireTableSelected,
+ onHandleAddMembersBulkAction,
+ enterpriseGroupLearners,
+ });
+ const handleOnClick = () => {
+ if (isEntireTableSelected) {
+ fetchLearnerEmails();
+ return;
+ }
+ const addedMemberEmails = enterpriseGroupLearners.map(learner => learner.memberDetails.userEmail);
+ const emails = getSelectedEmailsByRow(selectedFlatRows).filter(email => !addedMemberEmails.includes(email));
+ onHandleAddMembersBulkAction(emails);
+ };
+
+ return (
+
+ );
+};
+
+AddMembersBulkAction.propTypes = {
+ selectedFlatRows: PropTypes.arrayOf(PropTypes.shape()).isRequired,
+ enterpriseId: PropTypes.string.isRequired,
+ onHandleAddMembersBulkAction: PropTypes.func.isRequired,
+ isEntireTableSelected: PropTypes.bool,
+ enterpriseGroupLearners: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default AddMembersBulkAction;
diff --git a/src/components/PeopleManagement/GroupDetailPage/DownloadCsvIconButton.jsx b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvIconButton.jsx
new file mode 100644
index 0000000000..57bc5b7893
--- /dev/null
+++ b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvIconButton.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, useIntl } from '@edx/frontend-platform/i18n';
+
+import {
+ Icon, IconButtonWithTooltip, Toast, useToggle,
+} from '@openedx/paragon';
+import { Download } from '@openedx/paragon/icons';
+import { logError } from '@edx/frontend-platform/logging';
+import GeneralErrorModal from '../GeneralErrorModal';
+import { downloadCsv, getTimeStampedFilename } from '../../../utils';
+
+const csvHeaders = ['Name', 'Email', 'Recent action', 'Enrollments'];
+
+const DownloadCsvIconButton = ({ fetchAllData, dataCount, testId }) => {
+ const [isToastOpen, openToast, closeToast] = useToggle(false);
+ const [isErrorModalOpen, openErrorModal, closeErrorModal] = useToggle(false);
+ const intl = useIntl();
+ const messages = defineMessages({
+ downloadToastText: {
+ id: 'adminPortal.peopleManagement.groupDetail.downloadCsv.toast',
+ defaultMessage: 'Downloaded group members',
+ description: 'Toast message for the download button on the group detail page.',
+ },
+ downloadHoverText: {
+ id: 'adminPortal.peopleManagement.groupDetail.downloadCsv.hoverTooltip',
+ defaultMessage: `Download (${dataCount})`,
+ description: 'Tooltip message for the download button on the group detail page.',
+ },
+ });
+
+ const dataEntryToRow = (entry) => {
+ const { memberDetails: { userEmail, userName }, recentAction, enrollments } = entry;
+ return [userName, userEmail, recentAction, enrollments];
+ };
+
+ const handleClick = async () => {
+ fetchAllData().then((response) => {
+ const fileName = getTimeStampedFilename('group-report.csv');
+ downloadCsv(fileName, response.results, csvHeaders, dataEntryToRow);
+ openToast();
+ }).catch((err) => {
+ logError(err);
+ openErrorModal();
+ });
+ };
+
+ return (
+ <>
+ { isToastOpen
+ && (
+
+ {intl.formatMessage(messages.downloadToastText)}
+
+ )}
+
+
+ >
+ );
+};
+
+DownloadCsvIconButton.defaultProps = {
+ testId: 'download-csv-icon-button',
+};
+
+DownloadCsvIconButton.propTypes = {
+ fetchAllData: PropTypes.func.isRequired,
+ dataCount: PropTypes.number.isRequired,
+ testId: PropTypes.string,
+};
+
+export default DownloadCsvIconButton;
diff --git a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx
index 84a0f2d782..8da3181588 100644
--- a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx
+++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx
@@ -11,7 +11,8 @@ import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants';
import DeleteGroupModal from './DeleteGroupModal';
import EditGroupNameModal from './EditGroupNameModal';
import formatDates from '../utils';
-import GroupMembersTable from '../GroupMembersTable';
+import GroupMembersTable from './GroupMembersTable';
+import AddMembersModal from '../AddMembersModal/AddMembersModal';
const GroupDetailPage = () => {
const intl = useIntl();
@@ -21,11 +22,15 @@ const GroupDetailPage = () => {
const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false);
const [isLoading, setIsLoading] = useState(true);
const [groupName, setGroupName] = useState(enterpriseGroup?.name);
+ const [isAddMembersModalOpen, openAddMembersModal, closeAddMembersModal] = useToggle(false);
const {
isLoading: isTableLoading,
enterpriseGroupLearnersTableData,
fetchEnterpriseGroupLearnersTableData,
- } = useEnterpriseGroupLearnersTableData({ groupUuid });
+ fetchAllEnterpriseGroupLearnersData,
+ refresh,
+ setRefresh,
+ } = useEnterpriseGroupLearnersTableData({ groupUuid, isAddMembersModalOpen });
const handleNameUpdate = (name) => {
setGroupName(name);
};
@@ -92,7 +97,7 @@ const GroupDetailPage = () => {
data-testid="edit-modal-icon"
/>
>
- )}
+ )}
subtitle={`${enterpriseGroup.acceptedMembersCount} accepted members`}
/>
@@ -106,7 +111,6 @@ const GroupDetailPage = () => {
{
isLoading={isTableLoading}
tableData={enterpriseGroupLearnersTableData}
fetchTableData={fetchEnterpriseGroupLearnersTableData}
+ fetchAllData={fetchAllEnterpriseGroupLearnersData}
+ dataCount={enterpriseGroupLearnersTableData.itemCount}
groupUuid={groupUuid}
+ refresh={refresh}
+ setRefresh={setRefresh}
+ openAddMembersModal={openAddMembersModal}
+ />
+
);
diff --git a/src/components/PeopleManagement/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx
similarity index 51%
rename from src/components/PeopleManagement/GroupMembersTable.jsx
rename to src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx
index be59163a0a..4c399da953 100644
--- a/src/components/PeopleManagement/GroupMembersTable.jsx
+++ b/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx
@@ -1,41 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
- DataTable, Dropdown, Icon, IconButton,
+ DataTable, Dropdown, Icon, IconButton, useToggle,
} from '@openedx/paragon';
import { MoreVert, RemoveCircle } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
-import TableTextFilter from '../learner-credit-management/TableTextFilter';
-import CustomDataTableEmptyState from '../learner-credit-management/CustomDataTableEmptyState';
-import MemberDetailsTableCell from '../learner-credit-management/members-tab/MemberDetailsTableCell';
-import EnrollmentsTableColumnHeader from './EnrollmentsTableColumnHeader';
-import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants';
-import RecentActionTableCell from './RecentActionTableCell';
+
+import TableTextFilter from '../../learner-credit-management/TableTextFilter';
+import CustomDataTableEmptyState from '../../learner-credit-management/CustomDataTableEmptyState';
+import MemberDetailsTableCell from '../../learner-credit-management/members-tab/MemberDetailsTableCell';
+import EnrollmentsTableColumnHeader from '../EnrollmentsTableColumnHeader';
+import {
+ GROUP_MEMBERS_TABLE_DEFAULT_PAGE,
+ GROUP_MEMBERS_TABLE_PAGE_SIZE,
+} from '../constants';
+import RecentActionTableCell from '../RecentActionTableCell';
+import DownloadCsvIconButton from './DownloadCsvIconButton';
+import RemoveMemberModal from './RemoveMemberModal';
+import GeneralErrorModal from '../GeneralErrorModal';
+import AddMemberTableAction from './AddMemberTableAction';
const FilterStatus = (rest) => ;
-const KabobMenu = () => (
-
-
-
-
-
-
-
-
-);
+
+
+
+
+
+
+
+ >
+ );
+};
+
+KabobMenu.propTypes = {
+ row: PropTypes.shape({}).isRequired,
+ groupUuid: PropTypes.string.isRequired,
+ refresh: PropTypes.bool.isRequired,
+ setRefresh: PropTypes.func.isRequired,
+};
const selectColumn = {
id: 'selection',
@@ -48,7 +84,12 @@ const GroupMembersTable = ({
isLoading,
tableData,
fetchTableData,
+ fetchAllData,
+ dataCount,
groupUuid,
+ refresh,
+ setRefresh,
+ openAddMembersModal,
}) => {
const intl = useIntl();
return (
@@ -101,9 +142,7 @@ const GroupMembersTable = ({
initialState={{
pageSize: GROUP_MEMBERS_TABLE_PAGE_SIZE,
pageIndex: GROUP_MEMBERS_TABLE_DEFAULT_PAGE,
- sortBy: [
- { id: 'memberDetails', desc: true },
- ],
+ sortBy: [{ id: 'memberDetails', desc: true }],
filters: [],
}}
additionalColumns={[
@@ -112,10 +151,23 @@ const GroupMembersTable = ({
Header: '',
// eslint-disable-next-line react/no-unstable-nested-components
Cell: (props) => (
-
+
),
},
]}
+ tableActions={[
+ ,
+ ,
+ ]}
fetchData={fetchTableData}
data={tableData.results}
itemCount={tableData.itemCount}
@@ -129,13 +181,17 @@ const GroupMembersTable = ({
GroupMembersTable.propTypes = {
isLoading: PropTypes.bool.isRequired,
tableData: PropTypes.shape({
- results: PropTypes.arrayOf(PropTypes.shape({
- })),
+ results: PropTypes.arrayOf(PropTypes.shape({})),
itemCount: PropTypes.number.isRequired,
pageCount: PropTypes.number.isRequired,
}).isRequired,
fetchTableData: PropTypes.func.isRequired,
+ fetchAllData: PropTypes.func.isRequired,
+ dataCount: PropTypes.number.isRequired,
groupUuid: PropTypes.string.isRequired,
+ refresh: PropTypes.bool.isRequired,
+ setRefresh: PropTypes.func.isRequired,
+ openAddMembersModal: PropTypes.func.isRequired,
};
export default GroupMembersTable;
diff --git a/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx b/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx
new file mode 100644
index 0000000000..7ed1d09463
--- /dev/null
+++ b/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx
@@ -0,0 +1,88 @@
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import PropTypes from 'prop-types';
+import {
+ ActionRow, Button, ModalDialog,
+} from '@openedx/paragon';
+import { RemoveCircle } from '@openedx/paragon/icons';
+import { logError } from '@edx/frontend-platform/logging';
+
+import LmsApiService from '../../../data/services/LmsApiService';
+
+const RemoveMemberModal = ({
+ groupUuid, row, isOpen, close, openError, refresh, setRefresh,
+}) => {
+ const removeEnterpriseGroupMember = async () => {
+ try {
+ const rowEmail = row.id;
+ const formData = new FormData();
+ formData.append('learner_emails', rowEmail);
+ await LmsApiService.removeEnterpriseLearnersFromGroup(groupUuid, formData);
+ setRefresh(!refresh);
+ close();
+ } catch (error) {
+ close();
+ logError(error);
+ openError();
+ }
+ };
+ return (
+
+
+
+ Remove member?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Go back
+
+ Remove member
+
+
+
+
+ );
+};
+
+RemoveMemberModal.propTypes = {
+ groupUuid: PropTypes.string.isRequired,
+ row: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ close: PropTypes.func.isRequired,
+ openError: PropTypes.func.isRequired,
+ refresh: PropTypes.bool.isRequired,
+ setRefresh: PropTypes.func.isRequired,
+};
+
+export default RemoveMemberModal;
diff --git a/src/components/PeopleManagement/MemberDetailsCell.jsx b/src/components/PeopleManagement/MemberDetailsCell.jsx
new file mode 100644
index 0000000000..ce0be726d4
--- /dev/null
+++ b/src/components/PeopleManagement/MemberDetailsCell.jsx
@@ -0,0 +1,26 @@
+import PropTypes from 'prop-types';
+import { Stack } from '@openedx/paragon';
+
+const MemberDetailsCell = ({ row }) => (
+
+
+ {row.original?.enterpriseCustomerUser?.name}
+
+
+ {row.original?.enterpriseCustomerUser?.email}
+
+
+);
+
+MemberDetailsCell.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ enterpriseCustomerUser: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ email: PropTypes.string.isRequired,
+ }).isRequired,
+ }).isRequired,
+ }).isRequired,
+};
+
+export default MemberDetailsCell;
diff --git a/src/components/PeopleManagement/MemberJoinedDateCell.jsx b/src/components/PeopleManagement/MemberJoinedDateCell.jsx
new file mode 100644
index 0000000000..dca184a116
--- /dev/null
+++ b/src/components/PeopleManagement/MemberJoinedDateCell.jsx
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+
+const MemberJoinedDateCell = ({ row }) => (
+
+ {row.original.enterpriseCustomerUser.joinedOrg}
+
+);
+
+MemberJoinedDateCell.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ enterpriseCustomerUser: PropTypes.shape({
+ joinedOrg: PropTypes.string.isRequired,
+ }),
+ }).isRequired,
+ }).isRequired,
+};
+
+export default MemberJoinedDateCell;
diff --git a/src/components/PeopleManagement/RemoveMembersBulkAction.jsx b/src/components/PeopleManagement/RemoveMembersBulkAction.jsx
new file mode 100644
index 0000000000..99da098997
--- /dev/null
+++ b/src/components/PeopleManagement/RemoveMembersBulkAction.jsx
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import { Button } from '@openedx/paragon';
+import { getSelectedEmailsByRow } from './utils';
+
+const RemoveMembersBulkAction = ({
+ isEntireTableSelected,
+ selectedFlatRows,
+ onHandleRemoveMembersBulkAction,
+ learnerEmails,
+}) => {
+ const handleOnClick = async () => {
+ if (isEntireTableSelected) {
+ onHandleRemoveMembersBulkAction(learnerEmails);
+ }
+ const emails = getSelectedEmailsByRow(selectedFlatRows);
+ onHandleRemoveMembersBulkAction(emails);
+ };
+
+ return (
+
+ Remove
+
+ );
+};
+
+RemoveMembersBulkAction.propTypes = {
+ learnerEmails: PropTypes.arrayOf(PropTypes.string).isRequired,
+ selectedFlatRows: PropTypes.arrayOf(PropTypes.shape()).isRequired,
+ onHandleRemoveMembersBulkAction: PropTypes.func.isRequired,
+ isEntireTableSelected: PropTypes.bool,
+};
+
+export default RemoveMembersBulkAction;
diff --git a/src/components/PeopleManagement/_PeopleManagement.scss b/src/components/PeopleManagement/_PeopleManagement.scss
index 3f943606be..ffd8310883 100644
--- a/src/components/PeopleManagement/_PeopleManagement.scss
+++ b/src/components/PeopleManagement/_PeopleManagement.scss
@@ -7,9 +7,3 @@
padding: .25rem 1.25rem 1.25rem 1.25rem;
}
}
-
-.group-collapsible {
- .collapsible-basic .collapsible-trigger {
- justify-content: right;
- }
-}
diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js
index ef2d298c3f..08fddb3637 100644
--- a/src/components/PeopleManagement/constants.js
+++ b/src/components/PeopleManagement/constants.js
@@ -12,5 +12,9 @@ export const GROUP_MEMBERS_TABLE_DEFAULT_PAGE = 0; // `DataTable` uses zero-inde
// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories.
export const peopleManagementQueryKeys = {
all: ['people-management'],
+ group: (groupUuid) => [...peopleManagementQueryKeys.all, 'group', groupUuid],
members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid],
+ removeMember: (groupUuid) => [...peopleManagementQueryKeys.all, 'removeMember', groupUuid],
};
+
+export const MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT = 15;
diff --git a/src/components/PeopleManagement/data/hooks/index.js b/src/components/PeopleManagement/data/hooks/index.js
index 04bcc3b90e..e17ced260c 100644
--- a/src/components/PeopleManagement/data/hooks/index.js
+++ b/src/components/PeopleManagement/data/hooks/index.js
@@ -1,3 +1,4 @@
export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid';
export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData';
export { default as useEnterpriseMembersTableData } from './useEnterpriseMembersTableData';
+export { default as useAllEnterpriseGroupLearners } from './useAllEnterpriseGroupLearners';
diff --git a/src/components/PeopleManagement/data/hooks/useAllEnterpriseGroupLearners.js b/src/components/PeopleManagement/data/hooks/useAllEnterpriseGroupLearners.js
new file mode 100644
index 0000000000..43c3752c73
--- /dev/null
+++ b/src/components/PeopleManagement/data/hooks/useAllEnterpriseGroupLearners.js
@@ -0,0 +1,35 @@
+import {
+ useEffect, useState,
+} from 'react';
+import { logError } from '@edx/frontend-platform/logging';
+
+import LmsApiService from '../../../../data/services/LmsApiService';
+
+const useAllEnterpriseGroupLearners = (groupUuid) => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [enterpriseGroupLearners, setEnterpriseGroupLearners] = useState([]);
+
+ useEffect(() => {
+ const fetch = async () => {
+ try {
+ setIsLoading(true);
+ const response = await LmsApiService.fetchAllEnterpriseGroupLearners(groupUuid);
+ setEnterpriseGroupLearners(
+ response,
+ );
+ } catch (error) {
+ logError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetch();
+ }, [groupUuid]);
+
+ return {
+ isLoading,
+ enterpriseGroupLearners,
+ };
+};
+
+export default useAllEnterpriseGroupLearners;
diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js
index 2e5d6e926e..d04805dc02 100644
--- a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js
+++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js
@@ -8,8 +8,9 @@ import debounce from 'lodash.debounce';
import LmsApiService from '../../../../data/services/LmsApiService';
-const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => {
+const useEnterpriseGroupLearnersTableData = ({ groupUuid, isAddMembersModalOpen }) => {
const [isLoading, setIsLoading] = useState(true);
+ const [refresh, setRefresh] = useState(false);
const [enterpriseGroupLearnersTableData, setEnterpriseGroupLearnersTableData] = useState({
itemCount: 0,
pageCount: 0,
@@ -44,6 +45,7 @@ const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => {
itemCount: data.count,
pageCount: data.numPages ?? Math.floor(data.count / options.pageSize),
results: data.results.filter(result => result.activatedAt),
+ options,
});
} catch (error) {
logError(error);
@@ -52,18 +54,29 @@ const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => {
}
};
fetch();
- }, [groupUuid]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [groupUuid, isAddMembersModalOpen]);
const debouncedFetchEnterpriseGroupLearnersData = useMemo(
() => debounce(fetchEnterpriseGroupLearnersData, 300),
- [fetchEnterpriseGroupLearnersData],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [fetchEnterpriseGroupLearnersData, refresh],
);
+ const fetchAllEnterpriseGroupLearnersData = useCallback(async () => {
+ const { options, itemCount } = enterpriseGroupLearnersTableData;
+ const fetchAllOptions = { ...options, page: 1, page_size: itemCount };
+ const response = await LmsApiService.fetchEnterpriseGroupLearners(groupUuid, fetchAllOptions);
+ return camelCaseObject(response.data);
+ }, [groupUuid, enterpriseGroupLearnersTableData]);
+
return {
isLoading,
enterpriseGroupLearnersTableData,
fetchEnterpriseGroupLearnersTableData: debouncedFetchEnterpriseGroupLearnersData,
+ fetchAllEnterpriseGroupLearnersData,
+ refresh,
+ setRefresh,
};
};
-
export default useEnterpriseGroupLearnersTableData;
diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js
index a8e97e495f..a7956c84e5 100644
--- a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js
+++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { camelCaseObject } from '@edx/frontend-platform/utils';
-import { learnerCreditManagementQueryKeys } from '../../../learner-credit-management/data/constants';
import LmsApiService from '../../../../data/services/LmsApiService';
+import { peopleManagementQueryKeys } from '../../constants';
/**
* Retrieves an enterprise group by the group UUID from the API.
@@ -17,7 +17,7 @@ const getEnterpriseGroupUuid = async ({ groupUuid }) => {
};
const useEnterpriseGroupUuid = (groupUuid, { queryOptions } = {}) => useQuery({
- queryKey: learnerCreditManagementQueryKeys.group(groupUuid),
+ queryKey: peopleManagementQueryKeys.group(groupUuid),
queryFn: () => getEnterpriseGroupUuid({ groupUuid }),
...queryOptions,
});
diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseLearnersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseLearnersTableData.js
similarity index 85%
rename from src/components/learner-credit-management/data/hooks/useEnterpriseLearnersTableData.js
rename to src/components/PeopleManagement/data/hooks/useEnterpriseLearnersTableData.js
index 78fd7df4c6..824d2cc032 100644
--- a/src/components/learner-credit-management/data/hooks/useEnterpriseLearnersTableData.js
+++ b/src/components/PeopleManagement/data/hooks/useEnterpriseLearnersTableData.js
@@ -11,6 +11,7 @@ import { fetchPaginatedData } from '../../../../data/services/apiServiceUtils';
export const useGetAllEnterpriseLearnerEmails = ({
enterpriseId,
onHandleAddMembersBulkAction,
+ enterpriseGroupLearners,
}) => {
const [isLoading, setIsLoading] = useState(true);
const [addButtonState, setAddButtonState] = useState('default');
@@ -20,7 +21,11 @@ export const useGetAllEnterpriseLearnerEmails = ({
try {
const url = `${LmsApiService.enterpriseLearnerUrl}?enterprise_customer=${enterpriseId}`;
const { results } = await fetchPaginatedData(url);
- const learnerEmails = results.map(result => result?.user?.email).filter(email => email !== undefined);
+ const addedMemberEmails = enterpriseGroupLearners.map(learner => learner.memberDetails.userEmail);
+ const learnerEmails = results
+ .map(result => result?.user?.email)
+ .filter(email => email !== undefined)
+ .filter(email => !addedMemberEmails.includes(email));
onHandleAddMembersBulkAction(learnerEmails);
} catch (error) {
logError(error);
@@ -29,7 +34,7 @@ export const useGetAllEnterpriseLearnerEmails = ({
setIsLoading(false);
setAddButtonState('complete');
}
- }, [enterpriseId, onHandleAddMembersBulkAction]);
+ }, [enterpriseId, onHandleAddMembersBulkAction, enterpriseGroupLearners]);
return {
isLoading,
diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js
index befa640c63..3f4baffc8f 100644
--- a/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js
+++ b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js
@@ -3,6 +3,7 @@ import {
} from 'react';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { logError } from '@edx/frontend-platform/logging';
+import _ from 'lodash';
import debounce from 'lodash.debounce';
import LmsApiService from '../../../../data/services/LmsApiService';
@@ -28,6 +29,13 @@ const useEnterpriseMembersTableData = ({ enterpriseId }) => {
try {
setIsLoading(true);
const options = {};
+ if (args?.sortBy.length > 0) {
+ const sortByValue = args.sortBy[0].id;
+ options.sort_by = _.snakeCase(sortByValue);
+ if (!args.sortBy[0].desc) {
+ options.is_reversed = !args.sortBy[0].desc;
+ }
+ }
args.filters.forEach((filter) => {
const { id, value } = filter;
if (id === 'name') {
diff --git a/src/components/PeopleManagement/tests/AddMembersModal.test.jsx b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx
new file mode 100644
index 0000000000..216a63a5ff
--- /dev/null
+++ b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx
@@ -0,0 +1,275 @@
+import React from 'react';
+import {
+ fireEvent, render, screen, waitFor,
+} from '@testing-library/react';
+import { Provider } from 'react-redux';
+import thunk from 'redux-thunk';
+import configureMockStore from 'redux-mock-store';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom/extend-expect';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { queryClient } from '../../test/testUtils';
+import LmsApiService from '../../../data/services/LmsApiService';
+import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../../learner-credit-management/cards/data';
+import AddMembersModal from '../AddMembersModal/AddMembersModal';
+import {
+ useGetAllEnterpriseLearnerEmails,
+} from '../data/hooks/useEnterpriseLearnersTableData';
+import { useEnterpriseLearners } from '../../learner-credit-management/data';
+import { useAllEnterpriseGroupLearners, useEnterpriseMembersTableData } from '../data/hooks';
+
+jest.mock('@tanstack/react-query', () => ({
+ ...jest.requireActual('@tanstack/react-query'),
+ useQueryClient: jest.fn(),
+}));
+jest.mock('../../../data/services/LmsApiService');
+jest.mock('../data/hooks/useEnterpriseLearnersTableData', () => ({
+ ...jest.requireActual('../data/hooks/useEnterpriseLearnersTableData'),
+ useGetAllEnterpriseLearnerEmails: jest.fn(),
+}));
+jest.mock('../data/hooks', () => ({
+ ...jest.requireActual('../data/hooks'),
+ useAllEnterpriseGroupLearners: jest.fn(),
+ useEnterpriseMembersTableData: jest.fn(),
+}));
+jest.mock('../../learner-credit-management/data', () => ({
+ ...jest.requireActual('../../learner-credit-management/data'),
+ useEnterpriseLearners: jest.fn(),
+}));
+
+const mockStore = configureMockStore([thunk]);
+const getMockStore = store => mockStore(store);
+const TEST_GROUP = 'test-group-uuid';
+const enterpriseSlug = 'test-enterprise';
+const enterpriseUUID = '1234';
+const initialStoreState = {
+ portalConfiguration: {
+ enterpriseId: enterpriseUUID,
+ enterpriseSlug,
+ enableLearnerPortal: true,
+ enterpriseFeatures: {
+ topDownAssignmentRealTimeLcm: true,
+ enterpriseGroupsV1: true,
+ enterpriseGroupsV2: true,
+ },
+ },
+};
+
+const defaultProps = {
+ isModalOpen: true,
+ closeModal: jest.fn(),
+ enterpriseUUID,
+ groupName: 'test-group-name',
+ groupUuid: TEST_GROUP,
+};
+
+const mockTabledata = {
+ itemCount: 3,
+ pageCount: 1,
+ results: [
+ {
+ enterpriseCustomerUser: {
+ user_id: 1,
+ name: 'Test User 1',
+ email: 'testuser-1@2u.com',
+ joinedOrg: 'July 5, 2021',
+ },
+ },
+ {
+ enterpriseCustomerUser: {
+ user_id: 2,
+ name: 'Test User 2',
+ email: 'testuser-2@2u.com',
+ joinedOrg: 'July 2, 2022',
+ },
+ },
+ {
+ enterpriseCustomerUser: {
+ user_id: 3,
+ name: 'Test User 3',
+ email: 'testuser-3@2u.com',
+ joinedOrg: 'July 3, 2023',
+ },
+ },
+ {
+ enterpriseCustomerUser: {
+ user_id: 4,
+ name: 'Test User 4',
+ email: 'testuser-4@2u.com',
+ joinedOrg: 'July 4, 2024',
+ },
+ },
+ ],
+};
+const AddMembersModalWrapper = ({
+ initialState = initialStoreState,
+}) => {
+ const store = getMockStore({ ...initialState });
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+describe(' ', () => {
+ beforeEach(() => {
+ useEnterpriseMembersTableData.mockReturnValue({
+ isLoading: false,
+ enterpriseMembersTableData: mockTabledata,
+ fetchEnterpriseMembersTableData: jest.fn(),
+ });
+ useGetAllEnterpriseLearnerEmails.mockReturnValue({
+ isLoading: false,
+ fetchLearnerEmails: jest.fn(),
+ addButtonState: 'complete',
+ });
+ useEnterpriseLearners.mockReturnValue({
+ allEnterpriseLearners: ['testuser-3@2u.com', 'testuser-3@2u.com', 'testuser-2@2u.com', 'testuser-1@2u.com', 'tomhaverford@pawnee.org'],
+ });
+ useAllEnterpriseGroupLearners.mockReturnValue({
+ isLoading: false,
+ enterpriseGroupLearners: [{
+ activatedAt: '2024-11-06T21:01:32.953901Z',
+ enterprise_group_membership_uuid: TEST_GROUP,
+ memberDetails: {
+ userEmail: 'testuser-3@2u.com',
+ userName: '',
+ },
+ recentAction: 'Accepted: November 06, 2024',
+ status: 'accepted',
+ enrollments: 1,
+ }],
+ });
+ });
+
+ it('renders as expected', async () => {
+ render( );
+ expect(screen.getByText('Add new members to your group')).toBeInTheDocument();
+ expect(screen.getByText('Select group members')).toBeInTheDocument();
+ expect(screen.getByText('Upload a CSV or select members from the table below.')).toBeInTheDocument();
+ expect(screen.getByText('You haven\'t uploaded any members yet.')).toBeInTheDocument();
+ expect(screen.getByText('Upload a CSV file or select members to get started.')).toBeInTheDocument();
+ expect(screen.getByText('Add')).toBeInTheDocument();
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ expect(screen.getByText('test-group-name')).toBeInTheDocument();
+
+ // renders datatable
+ expect(screen.getByText('Member details')).toBeInTheDocument();
+ expect(screen.getByText('Joined organization')).toBeInTheDocument();
+ expect(screen.getByText('Test User 1')).toBeInTheDocument();
+ expect(screen.getByText('testuser-1@2u.com')).toBeInTheDocument();
+ expect(screen.getByText('Test User 2')).toBeInTheDocument();
+ expect(screen.getByText('testuser-2@2u.com')).toBeInTheDocument();
+ expect(screen.getByText('Test User 3')).toBeInTheDocument();
+ expect(screen.getByText('testuser-3@2u.com')).toBeInTheDocument();
+ });
+ it('adds members to a group', async () => {
+ const mockInvite = jest.spyOn(LmsApiService, 'inviteEnterpriseLearnersToGroup');
+
+ const mockInviteData = { records_processed: 1, new_learners: 1, existing_learners: 0 };
+ LmsApiService.inviteEnterpriseLearnersToGroup.mockResolvedValue(mockInviteData);
+
+ render( );
+ expect(screen.getByText('You haven\'t uploaded any members yet.')).toBeInTheDocument();
+ expect(screen.getByText('Upload a CSV file or select members to get started.')).toBeInTheDocument();
+ const fakeFile = new File(['tomhaverford@pawnee.org'], 'emails.csv', { type: 'text/csv' });
+ const dropzone = screen.getByText('Drag and drop your file here or click to upload.');
+ Object.defineProperty(dropzone, 'files', {
+ value: [fakeFile],
+ });
+ fireEvent.drop(dropzone);
+
+ await waitFor(() => {
+ expect(screen.getByText('Summary (1)')).toBeInTheDocument();
+ expect(screen.getByText('tomhaverford@pawnee.org')).toBeInTheDocument();
+ }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 });
+
+ // testing interaction with adding members from the datatable
+ const membersCheckbox = screen.getAllByTitle('Toggle row selected');
+ userEvent.click(membersCheckbox[0]);
+ userEvent.click(membersCheckbox[1]);
+ const addMembersButton = screen.getAllByText('Add')[0];
+ userEvent.click(addMembersButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Summary (3)')).toBeInTheDocument();
+ // checking that each user appears twice, once in the datatable and once in the summary section
+ expect(screen.getAllByText('testuser-1@2u.com')).toHaveLength(2);
+ expect(screen.getAllByText('testuser-2@2u.com')).toHaveLength(2);
+ });
+
+ // testing interaction with removing members from the datatable
+ const removeMembersButton = screen.getByText('Remove');
+ userEvent.click(removeMembersButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Summary (1)')).toBeInTheDocument();
+ expect(screen.getByText('emails.csv')).toBeInTheDocument();
+ expect(screen.getByText('Total members to add')).toBeInTheDocument();
+ expect(screen.getByText('tomhaverford@pawnee.org')).toBeInTheDocument();
+ expect(screen.getAllByText('testuser-1@2u.com')).toHaveLength(1);
+ expect(screen.getAllByText('testuser-2@2u.com')).toHaveLength(1);
+ expect(screen.getAllByText('testuser-3@2u.com')).toHaveLength(1);
+ const formFeedbackText = 'Maximum members at a time: 1000';
+ expect(screen.queryByText(formFeedbackText)).not.toBeInTheDocument();
+ }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 });
+
+ const addButton = screen.getAllByText('Add')[1];
+ userEvent.click(addButton);
+ await waitFor(() => {
+ expect(mockInvite).toHaveBeenCalledTimes(1);
+ });
+ });
+ it('displays error for email not belonging in an org', async () => {
+ const mockInviteData = { records_processed: 1, new_learners: 1, existing_learners: 0 };
+ LmsApiService.inviteEnterpriseLearnersToGroup.mockResolvedValue(mockInviteData);
+ useEnterpriseLearners.mockReturnValue({
+ allEnterpriseLearners: ['testuser-3@2u.com'],
+ });
+ render( );
+ const fakeFile = new File(['tomhaverford@pawnee.org'], 'emails.csv', { type: 'text/csv' });
+ const dropzone = screen.getByText('Drag and drop your file here or click to upload.');
+ Object.defineProperty(dropzone, 'files', {
+ value: [fakeFile],
+ });
+ fireEvent.drop(dropzone);
+ await waitFor(() => {
+ expect(screen.getByText(/Some people can't be added/i)).toBeInTheDocument();
+ expect(/tomhaverford@pawnee.org email address is not available to be added to a group./i);
+ }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 });
+ });
+ it('displays system error modal', async () => {
+ const mockInvite = jest.spyOn(LmsApiService, 'inviteEnterpriseLearnersToGroup');
+ const error = new Error('An error occurred');
+ mockInvite.mockRejectedValueOnce(error);
+
+ render( );
+ const fakeFile = new File(['tomhaverford@pawnee.org'], 'emails.csv', { type: 'text/csv' });
+ const dropzone = screen.getByText('Drag and drop your file here or click to upload.');
+ Object.defineProperty(dropzone, 'files', {
+ value: [fakeFile],
+ });
+ fireEvent.drop(dropzone);
+ await waitFor(() => {
+ expect(screen.getByText('emails.csv')).toBeInTheDocument();
+ expect(screen.getByText('Summary (1)')).toBeInTheDocument();
+ expect(screen.getByText('Total members to add')).toBeInTheDocument();
+ expect(screen.getByText('tomhaverford@pawnee.org')).toBeInTheDocument();
+ const formFeedbackText = 'Maximum members at a time: 1000';
+ expect(screen.queryByText(formFeedbackText)).not.toBeInTheDocument();
+ }, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 });
+ const addButton = screen.getByRole('button', { name: 'Add' });
+ userEvent.click(addButton);
+ await waitFor(() => {
+ expect(screen.getByText(
+ 'We\'re sorry. Something went wrong behind the scenes. Please try again, or reach out to customer support for help.',
+ )).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx
index ca0d39fe01..2a7677cd33 100644
--- a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx
+++ b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx
@@ -14,18 +14,22 @@ import LmsApiService from '../../../data/services/LmsApiService';
import { EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY } from '../../learner-credit-management/cards/data';
import CreateGroupModal from '../CreateGroupModal';
import {
- useEnterpriseLearnersTableData,
useGetAllEnterpriseLearnerEmails,
-} from '../../learner-credit-management/data/hooks/useEnterpriseLearnersTableData';
+} from '../data/hooks/useEnterpriseLearnersTableData';
import { useEnterpriseLearners } from '../../learner-credit-management/data';
+import { useEnterpriseMembersTableData } from '../data/hooks';
+jest.mock('../data/hooks', () => ({
+ ...jest.requireActual('../data/hooks'),
+ useEnterpriseMembersTableData: jest.fn(),
+}));
jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
useQueryClient: jest.fn(),
}));
jest.mock('../../../data/services/LmsApiService');
-jest.mock('../../learner-credit-management/data/hooks/useEnterpriseLearnersTableData', () => ({
- ...jest.requireActual('../../learner-credit-management/data/hooks/useEnterpriseLearnersTableData'),
+jest.mock('../data/hooks/useEnterpriseLearnersTableData', () => ({
+ ...jest.requireActual('../data/hooks/useEnterpriseLearnersTableData'),
useEnterpriseLearnersTableData: jest.fn(),
useGetAllEnterpriseLearnerEmails: jest.fn(),
}));
@@ -62,36 +66,35 @@ const mockTabledata = {
pageCount: 1,
results: [
{
- id: 1,
- user: {
- id: 1,
- username: 'testuser-1',
- firstName: '',
- lastName: '',
+ enterpriseCustomerUser: {
+ user_id: 1,
+ name: 'Test User 1',
email: 'testuser-1@2u.com',
- dateJoined: '2023-05-09T16:18:22Z',
+ joinedOrg: 'July 5, 2021',
},
},
{
- id: 2,
- user: {
- id: 2,
- username: 'testuser-2',
- firstName: '',
- lastName: '',
+ enterpriseCustomerUser: {
+ user_id: 2,
+ name: 'Test User 2',
email: 'testuser-2@2u.com',
- dateJoined: '2023-05-09T16:18:22Z',
+ joinedOrg: 'July 2, 2022',
},
},
{
- id: 3,
- user: {
- id: 3,
- username: 'testuser-3',
- firstName: '',
- lastName: '',
+ enterpriseCustomerUser: {
+ user_id: 3,
+ name: 'Test User 3',
email: 'testuser-3@2u.com',
- dateJoined: '2023-05-09T16:18:22Z',
+ joinedOrg: 'July 3, 2023',
+ },
+ },
+ {
+ enterpriseCustomerUser: {
+ user_id: 4,
+ name: 'Test User 4',
+ email: 'testuser-4@2u.com',
+ joinedOrg: 'July 4, 2024',
},
},
],
@@ -113,10 +116,10 @@ const CreateGroupModalWrapper = ({
describe(' ', () => {
beforeEach(() => {
- useEnterpriseLearnersTableData.mockReturnValue({
+ useEnterpriseMembersTableData.mockReturnValue({
isLoading: false,
- enterpriseCustomerUserTableData: mockTabledata,
- fetchEnterpriseLearnersData: jest.fn(),
+ enterpriseMembersTableData: mockTabledata,
+ fetchEnterpriseMembersTableData: jest.fn(),
});
useGetAllEnterpriseLearnerEmails.mockReturnValue({
isLoading: false,
@@ -141,11 +144,11 @@ describe(' ', () => {
// renders datatable
expect(screen.getByText('Member details')).toBeInTheDocument();
expect(screen.getByText('Joined organization')).toBeInTheDocument();
- expect(screen.getByText('testuser-1')).toBeInTheDocument();
+ expect(screen.getByText('Test User 1')).toBeInTheDocument();
expect(screen.getByText('testuser-1@2u.com')).toBeInTheDocument();
- expect(screen.getByText('testuser-2')).toBeInTheDocument();
+ expect(screen.getByText('Test User 2')).toBeInTheDocument();
expect(screen.getByText('testuser-2@2u.com')).toBeInTheDocument();
- expect(screen.getByText('testuser-3')).toBeInTheDocument();
+ expect(screen.getByText('Test User 3')).toBeInTheDocument();
expect(screen.getByText('testuser-3@2u.com')).toBeInTheDocument();
});
it('creates groups and assigns learners', async () => {
@@ -176,7 +179,7 @@ describe(' ', () => {
}, { timeout: EMAIL_ADDRESSES_INPUT_VALUE_DEBOUNCE_DELAY + 1000 });
// testing interaction with adding members from the datatable
- const membersCheckbox = screen.getAllByTitle('Toggle Row Selected');
+ const membersCheckbox = screen.getAllByTitle('Toggle row selected');
userEvent.click(membersCheckbox[0]);
userEvent.click(membersCheckbox[1]);
const addMembersButton = screen.getByText('Add');
diff --git a/src/components/PeopleManagement/tests/DownloadCsvIconButton.test.jsx b/src/components/PeopleManagement/tests/DownloadCsvIconButton.test.jsx
new file mode 100644
index 0000000000..f5fc01800d
--- /dev/null
+++ b/src/components/PeopleManagement/tests/DownloadCsvIconButton.test.jsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { logError } from '@edx/frontend-platform/logging';
+import {
+ act, fireEvent, render, screen, waitFor,
+} from '@testing-library/react';
+
+import '@testing-library/jest-dom/extend-expect';
+
+import userEvent from '@testing-library/user-event';
+import DownloadCsvIconButton from '../GroupDetailPage/DownloadCsvIconButton';
+import { downloadCsv } from '../../../utils';
+
+jest.mock('file-saver', () => ({
+ ...jest.requireActual('file-saver'),
+ saveAs: jest.fn(),
+}));
+
+jest.mock('../../../utils', () => ({
+ downloadCsv: jest.fn(),
+ getTimeStampedFilename: (suffix) => `2024-04-20-${suffix}`,
+}));
+
+jest.mock('@edx/frontend-platform/logging', () => ({
+ ...jest.requireActual('@edx/frontend-platform/logging'),
+ logError: jest.fn(),
+}));
+
+const mockData = {
+ results: [
+ {
+ memberDetails: {
+ userEmail: 'ga-linda@oz.com',
+ userName: 'Galinda Upland',
+ },
+ recentAction: 'Accepted: January 06, 2021',
+ enrollments: 0,
+ },
+ {
+ memberDetails: {
+ userEmail: 'elphaba@oz.com',
+ userName: 'Elphaba Throppe',
+ },
+ recentAction: 'Accepted: January 05, 2021',
+ enrollments: 3,
+ },
+ ],
+};
+
+const testId = 'test-id-1';
+const DEFAULT_PROPS = {
+ fetchAllData: jest.fn(() => Promise.resolve(mockData)),
+ dataCount: mockData.results.length,
+ testId,
+};
+
+const DownloadCsvIconButtonWrapper = props => (
+
+
+
+);
+
+describe('DownloadCsvIconButton', () => {
+ const flushPromises = () => new Promise(setImmediate);
+
+ it('renders download csv button correctly.', async () => {
+ render( );
+ const downloadIcon = screen.getByTestId(testId);
+ expect(downloadIcon).toBeInTheDocument();
+
+ // show download tooltip
+ act(() => {
+ fireEvent.mouseOver(downloadIcon);
+ });
+ await waitFor(() => {
+ expect(screen.getByText('Download (2)')).toBeInTheDocument();
+ });
+
+ // Click the download button
+ screen.getByTestId(testId).click();
+ await flushPromises();
+
+ expect(DEFAULT_PROPS.fetchAllData).toHaveBeenCalled();
+ const expectedFileName = '2024-04-20-group-report.csv';
+ const expectedHeaders = ['Name', 'Email', 'Recent action', 'Enrollments'];
+ expect(downloadCsv).toHaveBeenCalledWith(expectedFileName, mockData.results, expectedHeaders, expect.any(Function));
+ });
+ it('download button should handle error returned by the API endpoint.', async () => {
+ const props = {
+ ...DEFAULT_PROPS,
+ fetchAllData: jest.fn(() => Promise.reject(new Error('Error fetching data'))),
+ };
+ render( );
+ const downloadIcon = screen.getByTestId(testId);
+
+ expect(downloadIcon).toBeInTheDocument();
+
+ act(() => {
+ userEvent.click(downloadIcon);
+ });
+ await flushPromises();
+
+ expect(DEFAULT_PROPS.fetchAllData).toHaveBeenCalled();
+ expect(logError).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx
index d93a063440..2145898477 100644
--- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx
+++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx
@@ -6,11 +6,13 @@ import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import userEvent from '@testing-library/user-event';
+import { QueryClientProvider } from '@tanstack/react-query';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../data/hooks';
import GroupDetailPage from '../GroupDetailPage/GroupDetailPage';
import LmsApiService from '../../../data/services/LmsApiService';
+import { queryClient } from '../../test/testUtils';
const TEST_ENTERPRISE_SLUG = 'test-enterprise';
const enterpriseUUID = '1234';
@@ -23,6 +25,10 @@ const TEST_GROUP = {
const mockStore = configureMockStore([thunk]);
const getMockStore = store => mockStore(store);
+jest.mock('@tanstack/react-query', () => ({
+ ...jest.requireActual('@tanstack/react-query'),
+ useQueryClient: jest.fn(),
+}));
jest.mock('../data/hooks', () => ({
...jest.requireActual('../data/hooks'),
useEnterpriseGroupUuid: jest.fn(),
@@ -52,7 +58,9 @@ const GroupDetailPageWrapper = ({
return (
-
+
+
+
);
@@ -150,9 +158,17 @@ describe('', () => {
LmsApiService.removeEnterpriseGroup.mockResolvedValueOnce({ status: 204 });
render( );
const deleteGroupIcon = screen.getByTestId('delete-group-icon');
+ // Open tooltip
+ expect(screen.queryByRole('tooltip')).toBeNull();
+ fireEvent.mouseOver(deleteGroupIcon);
+ await waitFor(() => {
+ expect(screen.queryByRole('tooltip')).not.toBeNull();
+ expect(screen.getAllByText('Delete group')).toHaveLength(1);
+ });
deleteGroupIcon.click();
- expect(screen.getByText('Delete group')).toBeInTheDocument();
+ // Open delete group modal
+ expect(screen.getByText('Delete group?')).toBeInTheDocument();
expect(screen.getByText('This action cannot be undone.'));
const deleteGroupButton = screen.getByTestId('delete-group-button');
deleteGroupButton.click();
diff --git a/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx
index fb69b2f231..65ce498167 100644
--- a/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx
+++ b/src/components/PeopleManagement/tests/PeopleManagementPage.test.jsx
@@ -146,10 +146,14 @@ describe('', () => {
expect(screen.getByText('Show all 4 groups')).toBeInTheDocument();
expect(screen.queryByText('fruity pebbles')).not.toBeInTheDocument();
- const collapsible = screen.getByText('Show all 4 groups');
- collapsible.click();
+ const closedCollapsible = screen.getByText('Show all 4 groups');
+ closedCollapsible.click();
await waitFor(() => {
expect(screen.getByText('fruity pebbles')).toBeInTheDocument();
});
+
+ expect(screen.queryByText('Show all 4 groups')).toBeNull();
+ const openCollapsible = screen.getByText('Show less');
+ expect(openCollapsible).toBeInTheDocument();
});
});
diff --git a/src/components/PeopleManagement/utils.js b/src/components/PeopleManagement/utils.js
index 141d82600c..bdba3983ad 100644
--- a/src/components/PeopleManagement/utils.js
+++ b/src/components/PeopleManagement/utils.js
@@ -1,4 +1,5 @@
import dayjs from 'dayjs';
+import { MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT } from './constants';
/**
* Formats provided dates for display
@@ -10,3 +11,24 @@ export default function formatDates(timestamp) {
const DATE_FORMAT = 'MMMM DD, YYYY';
return dayjs(timestamp).format(DATE_FORMAT);
}
+
+export const getSelectedEmailsByRow = (selectedFlatRows) => {
+ const emails = [];
+ Object.keys(selectedFlatRows).forEach(key => {
+ const { original } = selectedFlatRows[key];
+ if (original.enterpriseCustomerUser !== null) {
+ emails.push(original.enterpriseCustomerUser.email);
+ }
+ });
+ return emails;
+};
+
+/**
+ * Determine whether the number of learner emails exceeds a certain
+ * threshold, whereby the list of emails should be truncated.
+ * @param {Array} learnerEmails List of learner emails.
+ * @returns True is learner emails list should be truncated; otherwise, false.
+ */
+export const hasLearnerEmailsSummaryListTruncation = (learnerEmails) => (
+ learnerEmails.length > MAX_INITIAL_LEARNER_EMAILS_DISPLAYED_COUNT
+);
diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js
index 195b3e7b99..5e9d91abe6 100644
--- a/src/components/learner-credit-management/data/constants.js
+++ b/src/components/learner-credit-management/data/constants.js
@@ -121,10 +121,10 @@ export const learnerCreditManagementQueryKeys = {
budgetEnterpriseOffer: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'ecommerce'],
budgetActivity: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'activity'],
budgetActivityOverview: (budgetId) => [...learnerCreditManagementQueryKeys.budgetActivity(budgetId), 'overview'],
- group: (groupUuid) => [...learnerCreditManagementQueryKeys.all, 'group', groupUuid],
budgetGroupLearners: (budgetId) => [...learnerCreditManagementQueryKeys.budget(budgetId), 'group learners'],
enterpriseCustomer: (enterpriseId) => [...learnerCreditManagementQueryKeys.all, 'enterpriseCustomer', enterpriseId],
flexGroup: (enterpriseId) => [...learnerCreditManagementQueryKeys.enterpriseCustomer(enterpriseId), 'flexGroup'],
+ group: (groupUuid) => [...learnerCreditManagementQueryKeys.all, 'group', groupUuid],
catalog: (catalog) => [...learnerCreditManagementQueryKeys.all, 'catalog', catalog],
catalogContainsContentItem: (catalogUuid, contentKey) => [
...learnerCreditManagementQueryKeys.catalog(catalogUuid),
diff --git a/src/components/learner-credit-management/invite-modal/EnterpriseCustomerUserDatatable.jsx b/src/components/learner-credit-management/invite-modal/EnterpriseCustomerUserDatatable.jsx
deleted file mode 100644
index eeee910671..0000000000
--- a/src/components/learner-credit-management/invite-modal/EnterpriseCustomerUserDatatable.jsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import {
- Button,
- DataTable,
- Stack,
- StatefulButton,
- TextFilter,
-} from '@openedx/paragon';
-import { useIntl } from '@edx/frontend-platform/i18n';
-import { useGetAllEnterpriseLearnerEmails, useEnterpriseLearnersTableData } from '../data/hooks/useEnterpriseLearnersTableData';
-import { formatTimestamp } from '../../../utils';
-import { DEFAULT_PAGE, MEMBERS_TABLE_PAGE_SIZE } from '../data';
-
-const getSelectedEmailsByRow = (selectedFlatRows) => {
- const emails = [];
- Object.keys(selectedFlatRows).forEach(key => {
- const { original } = selectedFlatRows[key];
- if (original.user !== null) {
- emails.push(original.user.email);
- }
- });
- return emails;
-};
-
-const MemberDetailsCell = ({ row }) => (
-
-
- {row.original?.user?.username}
-
-
- {row.original?.user?.email}
-
-
-);
-
-const MemberJoinedDateCell = ({ row }) => (
-
- {formatTimestamp({ timestamp: row.original.created, format: 'MMM DD, YYYY' })}
-
-);
-
-const AddMembersBulkAction = ({
- isEntireTableSelected,
- selectedFlatRows,
- onHandleAddMembersBulkAction,
- enterpriseId,
-}) => {
- const intl = useIntl();
- const { fetchLearnerEmails, addButtonState } = useGetAllEnterpriseLearnerEmails({
- enterpriseId,
- isEntireTableSelected,
- onHandleAddMembersBulkAction,
- });
- const handleOnClick = () => {
- if (isEntireTableSelected) {
- fetchLearnerEmails();
- return;
- }
- const emails = getSelectedEmailsByRow(selectedFlatRows);
- onHandleAddMembersBulkAction(emails);
- };
-
- return (
-
- );
-};
-
-const RemoveMembersBulkAction = ({
- isEntireTableSelected,
- selectedFlatRows,
- onHandleRemoveMembersBulkAction,
- learnerEmails,
-}) => {
- const handleOnClick = async () => {
- if (isEntireTableSelected) {
- onHandleRemoveMembersBulkAction(learnerEmails);
- }
- const emails = getSelectedEmailsByRow(selectedFlatRows);
- onHandleRemoveMembersBulkAction(emails);
- };
-
- return (
-
- Remove
-
- );
-};
-
-const selectColumn = {
- id: 'selection',
- Header: DataTable.ControlledSelectHeader,
- Cell: DataTable.ControlledSelect,
-};
-
-// TO-DO: add search functionality on member details once the learner endpoint is updated
-// to support search
-const EnterpriseCustomerUserDatatable = ({
- enterpriseId,
- learnerEmails,
- onHandleAddMembersBulkAction,
- onHandleRemoveMembersBulkAction,
-}) => {
- const {
- isLoading,
- enterpriseCustomerUserTableData,
- fetchEnterpriseLearnersData,
- } = useEnterpriseLearnersTableData(enterpriseId);
-
- return (
- ,
- ,
- ]}
- columns={[
- {
- Header: 'Member details',
- accessor: 'user.email',
- Cell: MemberDetailsCell,
- },
- {
- Header: 'Joined organization',
- accessor: 'created',
- Cell: MemberJoinedDateCell,
- disableFilters: true,
- },
- ]}
- initialState={{
- pageIndex: DEFAULT_PAGE,
- pageSize: MEMBERS_TABLE_PAGE_SIZE,
- }}
- data={enterpriseCustomerUserTableData.results}
- defaultColumnValues={{ Filter: TextFilter }}
- fetchData={fetchEnterpriseLearnersData}
- isFilterable
- isLoading={isLoading}
- isPaginated
- isSelectable
- itemCount={enterpriseCustomerUserTableData.itemCount}
- manualFilters
- manualPagination
- initialTableOptions={{
- getRowId: row => row.id.toString(),
- }}
- pageCount={enterpriseCustomerUserTableData.pageCount}
- SelectionStatusComponent={DataTable.ControlledSelectionStatus}
- manualSelectColumn={selectColumn}
- />
- );
-};
-
-MemberDetailsCell.propTypes = {
- row: PropTypes.shape({
- original: PropTypes.shape({
- user: PropTypes.shape({
- email: PropTypes.string.isRequired,
- username: PropTypes.string.isRequired,
- }).isRequired,
- }).isRequired,
- }).isRequired,
-};
-
-MemberJoinedDateCell.propTypes = {
- row: PropTypes.shape({
- original: PropTypes.shape({
- created: PropTypes.string.isRequired,
- }).isRequired,
- }).isRequired,
-};
-
-AddMembersBulkAction.propTypes = {
- isEntireTableSelected: PropTypes.bool.isRequired,
- selectedFlatRows: PropTypes.arrayOf(PropTypes.shape()).isRequired,
- enterpriseId: PropTypes.string.isRequired,
- onHandleAddMembersBulkAction: PropTypes.func.isRequired,
-};
-
-RemoveMembersBulkAction.propTypes = {
- isEntireTableSelected: PropTypes.bool.isRequired,
- learnerEmails: PropTypes.arrayOf(PropTypes.string).isRequired,
- selectedFlatRows: PropTypes.arrayOf(PropTypes.shape()).isRequired,
- onHandleRemoveMembersBulkAction: PropTypes.func.isRequired,
-};
-
-EnterpriseCustomerUserDatatable.propTypes = {
- enterpriseId: PropTypes.string.isRequired,
- learnerEmails: PropTypes.arrayOf(PropTypes.string).isRequired,
- onHandleRemoveMembersBulkAction: PropTypes.func.isRequired,
- onHandleAddMembersBulkAction: PropTypes.func.isRequired,
-};
-
-const mapStateToProps = state => ({
- enterpriseId: state.portalConfiguration.enterpriseId,
-});
-
-export default connect(mapStateToProps)(EnterpriseCustomerUserDatatable);
diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js
index c566be13d9..8734ae9a4e 100644
--- a/src/data/services/LmsApiService.js
+++ b/src/data/services/LmsApiService.js
@@ -478,6 +478,15 @@ class LmsApiService {
return LmsApiService.apiClient().get(enterpriseGroupLearnersEndpoint);
};
+ static fetchAllEnterpriseGroupLearners = async (groupUuid) => {
+ const queryParams = new URLSearchParams({
+ page: 1,
+ });
+ const url = `${LmsApiService.enterpriseGroupUrl}${groupUuid}/learners?${queryParams.toString()}`;
+ const response = await LmsApiService.fetchData(url);
+ return response;
+ };
+
static removeEnterpriseGroup = async (groupUuid) => {
const removeGroupEndpoint = `${LmsApiService.enterpriseGroupListUrl}${groupUuid}/`;
return LmsApiService.apiClient().delete(removeGroupEndpoint);