diff --git a/src/components/PeopleManagement/AddMemberTableAction.jsx b/src/components/PeopleManagement/AddMemberTableAction.jsx
new file mode 100644
index 000000000..ffbce51dd
--- /dev/null
+++ b/src/components/PeopleManagement/AddMemberTableAction.jsx
@@ -0,0 +1,13 @@
+import { Button } from '@openedx/paragon';
+import { Add } from '@openedx/paragon/icons';
+import PropTypes from 'prop-types';
+
+const AddMemberTableAction = ({ openModal }) => (
+
+);
+
+AddMemberTableAction.propTypes = {
+ openModal: PropTypes.func.isRequired,
+};
+
+export default AddMemberTableAction;
diff --git a/src/components/PeopleManagement/AddMembersBulkAction.jsx b/src/components/PeopleManagement/AddMembersBulkAction.jsx
new file mode 100644
index 000000000..33ed1e4f8
--- /dev/null
+++ b/src/components/PeopleManagement/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/AddMembersModal/AddMemberModalSummaryDuplicate.jsx b/src/components/PeopleManagement/AddMembersModal/AddMemberModalSummaryDuplicate.jsx
new file mode 100644
index 000000000..29d355e9b
--- /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 000000000..f9157bfab
--- /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 000000000..9ffc2256e
--- /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 000000000..934953331
--- /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) && (
+
+ )}
+
+ );
+};
+
+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 000000000..b64c5e96e
--- /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 ? (
+
+
+
+
+
+
+ )}
+ >
+
+
+
+
+ ) : 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 000000000..4556fa8d3
--- /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 000000000..a74df3337
--- /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/CreateGroupModalContent.jsx b/src/components/PeopleManagement/CreateGroupModalContent.jsx
index 55abdfabf..7c9eab902 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 000000000..634c2f603
--- /dev/null
+++ b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx
@@ -0,0 +1,134 @@
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import {
+ DataTable,
+ TextFilter,
+ CheckboxControl,
+} from '@openedx/paragon';
+import { useEnterpriseLearnersTableData } from './data/hooks/useEnterpriseLearnersTableData';
+import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants';
+import MemberDetailsCell from './MemberDetailsCell';
+import AddMembersBulkAction from './AddMembersBulkAction';
+import RemoveMembersBulkAction from './RemoveMembersBulkAction';
+import MemberJoinedDateCell from './MemberJoinedDateCell';
+
+export const BaseSelectWithContext = ({ row, enterpriseGroupLearners }) => {
+ const {
+ indeterminate,
+ checked,
+ ...toggleRowSelectedProps
+ } = row.getToggleRowSelectedProps();
+ const isAddedMember = enterpriseGroupLearners.find(learner => learner.enterpriseCustomerUserId === Number(row.id));
+ return (
+
+
+
+ );
+};
+
+// TO-DO: add search functionality on member details once the learner endpoint is updated
+// to support search
+const EnterpriseCustomerUserDatatable = ({
+ enterpriseId,
+ learnerEmails,
+ onHandleAddMembersBulkAction,
+ onHandleRemoveMembersBulkAction,
+ enterpriseGroupLearners,
+}) => {
+ const {
+ isLoading,
+ enterpriseCustomerUserTableData,
+ fetchEnterpriseLearnersData,
+ } = useEnterpriseLearnersTableData(enterpriseId, enterpriseGroupLearners);
+
+ return (
+ ,
+ ,
+ ]}
+ columns={[
+ {
+ Header: 'Member details',
+ accessor: 'user.email',
+ Cell: MemberDetailsCell,
+ },
+ {
+ Header: 'Joined organization',
+ accessor: 'created',
+ Cell: MemberJoinedDateCell,
+ disableFilters: true,
+ },
+ ]}
+ initialState={{
+ pageIndex: GROUP_MEMBERS_TABLE_DEFAULT_PAGE,
+ pageSize: GROUP_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}
+ 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/GroupDetailPage/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx
index 84a0f2d78..97449859a 100644
--- a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx
+++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx
@@ -12,6 +12,7 @@ import DeleteGroupModal from './DeleteGroupModal';
import EditGroupNameModal from './EditGroupNameModal';
import formatDates from '../utils';
import GroupMembersTable from '../GroupMembersTable';
+import AddMembersModal from '../AddMembersModal/AddMembersModal';
const GroupDetailPage = () => {
const intl = useIntl();
@@ -21,11 +22,12 @@ 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 });
+ } = useEnterpriseGroupLearnersTableData({ groupUuid, isAddMembersModalOpen });
const handleNameUpdate = (name) => {
setGroupName(name);
};
@@ -92,7 +94,7 @@ const GroupDetailPage = () => {
data-testid="edit-modal-icon"
/>
>
- )}
+ )}
subtitle={`${enterpriseGroup.acceptedMembersCount} accepted members`}
/>
@@ -146,6 +148,13 @@ const GroupDetailPage = () => {
tableData={enterpriseGroupLearnersTableData}
fetchTableData={fetchEnterpriseGroupLearnersTableData}
groupUuid={groupUuid}
+ openAddMembersModal={openAddMembersModal}
+ />
+
);
diff --git a/src/components/PeopleManagement/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupMembersTable.jsx
index be59163a0..9ccd2e275 100644
--- a/src/components/PeopleManagement/GroupMembersTable.jsx
+++ b/src/components/PeopleManagement/GroupMembersTable.jsx
@@ -11,6 +11,7 @@ import MemberDetailsTableCell from '../learner-credit-management/members-tab/Mem
import EnrollmentsTableColumnHeader from './EnrollmentsTableColumnHeader';
import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants';
import RecentActionTableCell from './RecentActionTableCell';
+import AddMemberTableAction from './AddMemberTableAction';
const FilterStatus = (rest) => ;
@@ -49,6 +50,7 @@ const GroupMembersTable = ({
tableData,
fetchTableData,
groupUuid,
+ openAddMembersModal,
}) => {
const intl = useIntl();
return (
@@ -67,6 +69,9 @@ const GroupMembersTable = ({
defaultColumnValues={{ Filter: TableTextFilter }}
FilterStatusComponent={FilterStatus}
numBreakoutFilters={2}
+ tableActions={[
+ ,
+ ]}
columns={[
{
Header: intl.formatMessage({
@@ -136,6 +141,7 @@ GroupMembersTable.propTypes = {
}).isRequired,
fetchTableData: PropTypes.func.isRequired,
groupUuid: PropTypes.string.isRequired,
+ openAddMembersModal: PropTypes.func.isRequired,
};
export default GroupMembersTable;
diff --git a/src/components/PeopleManagement/MemberDetailsCell.jsx b/src/components/PeopleManagement/MemberDetailsCell.jsx
new file mode 100644
index 000000000..153061887
--- /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?.user?.username}
+
+
+ {row.original?.user?.email}
+
+
+);
+
+MemberDetailsCell.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ user: PropTypes.shape({
+ email: PropTypes.string.isRequired,
+ username: 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 000000000..a68c3b9ee
--- /dev/null
+++ b/src/components/PeopleManagement/MemberJoinedDateCell.jsx
@@ -0,0 +1,18 @@
+import PropTypes from 'prop-types';
+import { formatTimestamp } from '../../utils';
+
+const MemberJoinedDateCell = ({ row }) => (
+
+ {formatTimestamp({ timestamp: row.original.created, format: 'MMM DD, YYYY' })}
+
+);
+
+MemberJoinedDateCell.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ created: 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 000000000..99da09899
--- /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 (
+
+ );
+};
+
+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/constants.js b/src/components/PeopleManagement/constants.js
index ef2d298c3..5c9802d61 100644
--- a/src/components/PeopleManagement/constants.js
+++ b/src/components/PeopleManagement/constants.js
@@ -14,3 +14,5 @@ export const peopleManagementQueryKeys = {
all: ['people-management'],
members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid],
};
+
+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 04bcc3b90..e17ced260 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 000000000..43c3752c7
--- /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 2e5d6e926..d3161ef3f 100644
--- a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js
+++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js
@@ -8,7 +8,7 @@ import debounce from 'lodash.debounce';
import LmsApiService from '../../../../data/services/LmsApiService';
-const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => {
+const useEnterpriseGroupLearnersTableData = ({ groupUuid, isAddMembersModalOpen }) => {
const [isLoading, setIsLoading] = useState(true);
const [enterpriseGroupLearnersTableData, setEnterpriseGroupLearnersTableData] = useState({
itemCount: 0,
@@ -52,7 +52,8 @@ const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => {
}
};
fetch();
- }, [groupUuid]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [groupUuid, isAddMembersModalOpen]);
const debouncedFetchEnterpriseGroupLearnersData = useMemo(
() => debounce(fetchEnterpriseGroupLearnersData, 300),
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 78fd7df4c..824d2cc03 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/tests/AddMembersModal.test.jsx b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx
new file mode 100644
index 000000000..9029154ab
--- /dev/null
+++ b/src/components/PeopleManagement/tests/AddMembersModal.test.jsx
@@ -0,0 +1,288 @@
+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 {
+ useEnterpriseLearnersTableData,
+ useGetAllEnterpriseLearnerEmails,
+} from '../data/hooks/useEnterpriseLearnersTableData';
+import { useEnterpriseLearners } from '../../learner-credit-management/data';
+import { useAllEnterpriseGroupLearners } 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'),
+ useEnterpriseLearnersTableData: jest.fn(),
+ useGetAllEnterpriseLearnerEmails: jest.fn(),
+}));
+jest.mock('../data/hooks', () => ({
+ ...jest.requireActual('../data/hooks'),
+ useAllEnterpriseGroupLearners: 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: [
+ {
+ id: 1,
+ user: {
+ id: 1,
+ username: 'testuser-1',
+ firstName: '',
+ lastName: '',
+ email: 'testuser-1@2u.com',
+ dateJoined: '2023-05-09T16:18:22Z',
+ },
+ },
+ {
+ id: 2,
+ user: {
+ id: 2,
+ username: 'testuser-2',
+ firstName: '',
+ lastName: '',
+ email: 'testuser-2@2u.com',
+ dateJoined: '2023-05-09T16:18:22Z',
+ },
+ },
+ {
+ id: 3,
+ user: {
+ id: 3,
+ username: 'testuser-3',
+ firstName: '',
+ lastName: '',
+ email: 'testuser-3@2u.com',
+ dateJoined: '2023-05-09T16:18:22Z',
+ },
+ },
+ {
+ id: 4,
+ user: {
+ id: 4,
+ username: 'testuser-4',
+ firstName: '',
+ lastName: '',
+ email: 'testuser-4@2u.com',
+ dateJoined: '2023-05-09T16:18:22Z',
+ },
+ },
+ ],
+};
+const AddMembersModalWrapper = ({
+ initialState = initialStoreState,
+}) => {
+ const store = getMockStore({ ...initialState });
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+describe('', () => {
+ beforeEach(() => {
+ useEnterpriseLearnersTableData.mockReturnValue({
+ isLoading: false,
+ enterpriseCustomerUserTableData: mockTabledata,
+ fetchEnterpriseLearnersData: 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('testuser-1')).toBeInTheDocument();
+ expect(screen.getByText('testuser-1@2u.com')).toBeInTheDocument();
+ expect(screen.getByText('testuser-2')).toBeInTheDocument();
+ expect(screen.getByText('testuser-2@2u.com')).toBeInTheDocument();
+ expect(screen.getByText('testuser-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 ca0d39fe0..2fc23d40c 100644
--- a/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx
+++ b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx
@@ -16,7 +16,7 @@ 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';
jest.mock('@tanstack/react-query', () => ({
@@ -24,8 +24,8 @@ jest.mock('@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(),
}));
@@ -176,7 +176,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/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx
index d93a06344..9d6c3bd05 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 (
-
+
+
+
);
diff --git a/src/components/PeopleManagement/utils.js b/src/components/PeopleManagement/utils.js
index 141d82600..5012cd31c 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.user !== null) {
+ emails.push(original.user.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/invite-modal/EnterpriseCustomerUserDatatable.jsx b/src/components/learner-credit-management/invite-modal/EnterpriseCustomerUserDatatable.jsx
deleted file mode 100644
index eeee91067..000000000
--- 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 (
-
- );
-};
-
-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 c566be13d..8734ae9a4 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);