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 ( + + ); +}; + +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);