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.jsx b/src/components/PeopleManagement/AddMembersModal.jsx index 639db12a5..18afa8f1c 100644 --- a/src/components/PeopleManagement/AddMembersModal.jsx +++ b/src/components/PeopleManagement/AddMembersModal.jsx @@ -10,56 +10,47 @@ import { } from '@openedx/paragon'; import LmsApiService from '../../data/services/LmsApiService'; import SystemErrorAlertModal from '../learner-credit-management/cards/assignment-allocation-status-modals/SystemErrorAlertModal'; -import AddGroupModalContent from './AddGroupModalContent'; +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 [createButtonState, setCreateButtonState] = useState('default'); - const [canCreateGroup, setCanCreateGroup] = useState(false); - const [canInviteMembers, setCanInviteMembers] = useState(false); + const [addButtonState, setAddButtonState] = useState('default'); + const [canAddMembers, setCanAddMembersGroup] = useState(false); const [isSystemErrorModalOpen, openSystemErrorModal, closeSystemErrorModal] = useToggle(false); - const handleCloseCreateGroupModal = () => { + const handleCloseAddMembersModal = () => { closeModal(); - setCreateButtonState('default'); + setAddButtonState('default'); }; const queryClient = useQueryClient(); + const { + isLoading, + enterpriseGroupLearners, + } = useAllEnterpriseGroupLearners(groupUuid); - const handleCreateGroup = async () => { - setCreateButtonState('pending'); - const options = { - enterpriseUUID, - groupName, - }; - let groupCreationResponse; - - try { - groupCreationResponse = await LmsApiService.createEnterpriseGroup(options); - } catch (err) { - logError(err); - setCreateButtonState('error'); - openSystemErrorModal(); - } - + const handleAddMembers = async () => { + setAddButtonState('pending'); try { const requestBody = snakeCaseObject({ learnerEmails, }); - await LmsApiService.inviteEnterpriseLearnersToGroup(groupCreationResponse.data.uuid, requestBody); + await LmsApiService.inviteEnterpriseLearnersToGroup(groupUuid, requestBody); queryClient.invalidateQueries({ - queryKey: learnerCreditManagementQueryKeys.group(enterpriseUUID), + queryKey: learnerCreditManagementQueryKeys.group(groupUuid), }); - setCreateButtonState('complete'); - handleCloseCreateGroupModal(); + setAddButtonState('complete'); + handleCloseAddMembersModal(); } catch (err) { logError(err); - setCreateButtonState('error'); + setAddButtonState('error'); openSystemErrorModal(); } }; @@ -69,71 +60,78 @@ const AddMembersModal = ({ { canInvite = false } = {}, ) => { setLearnerEmails(value); - setCanInviteMembers(canInvite); + setCanAddMembersGroup(canInvite); }, []); useEffect(() => { - setCanCreateGroup(false); - if (canInviteMembers) { - setCanCreateGroup(true); + setCanAddMembersGroup(false); + if (canAddMembers) { + setCanAddMembersGroup(true); } - }, [canInviteMembers]); + }, [canAddMembers]); return ( - <> - - - - + {!isLoading ? ( +
+ + + + + + )} + > + - - )} - > - - - - + + +
+ ) : null} + ); }; -const mapStateToProps = state => ({ - enterpriseUUID: state.portalConfiguration.enterpriseId, -}); - 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/AddGroupModalContent.jsx b/src/components/PeopleManagement/AddMembersModalContent.jsx similarity index 92% rename from src/components/PeopleManagement/AddGroupModalContent.jsx rename to src/components/PeopleManagement/AddMembersModalContent.jsx index 81465c474..2cfb1c802 100644 --- a/src/components/PeopleManagement/AddGroupModalContent.jsx +++ b/src/components/PeopleManagement/AddMembersModalContent.jsx @@ -4,7 +4,7 @@ import React, { import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; import { - Col, Container, Form, Row, + Col, Container, Row, } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; @@ -12,15 +12,15 @@ import InviteModalSummary from '../learner-credit-management/invite-modal/Invite 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 { 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 AddGroupModalContent = ({ +const AddMembersModalContent = ({ onEmailAddressesChange, isGroupInvite, enterpriseUUID, groupName, + enterpriseGroupLearners, }) => { const [learnerEmails, setLearnerEmails] = useState([]); const [emailAddressesInputValue, setEmailAddressesInputValue] = useState(''); @@ -32,7 +32,6 @@ const AddGroupModalContent = ({ }); const { allEnterpriseLearners } = useEnterpriseLearners({ enterpriseUUID }); - const handleAddMembersBulkAction = useCallback((value) => { if (!value) { setLearnerEmails([]); @@ -129,15 +128,18 @@ const AddGroupModalContent = ({ onHandleAddMembersBulkAction={handleAddMembersBulkAction} onHandleRemoveMembersBulkAction={handleRemoveMembersBulkAction} learnerEmails={learnerEmails} + enterpriseGroupLearners={enterpriseGroupLearners} /> ); }; -AddGroupModalContent.propTypes = { +AddMembersModalContent.propTypes = { onEmailAddressesChange: PropTypes.func.isRequired, isGroupInvite: PropTypes.bool, enterpriseUUID: PropTypes.string.isRequired, + groupName: PropTypes.string, + enterpriseGroupLearners: PropTypes.arrayOf(PropTypes.shape({})), }; -export default AddGroupModalContent; +export default AddMembersModalContent; diff --git a/src/components/PeopleManagement/CreateGroupModalContent.jsx b/src/components/PeopleManagement/CreateGroupModalContent.jsx index a7b504450..827375e97 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/EnrollmentsTableColumnHeader.jsx b/src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx new file mode 100644 index 000000000..939b0e0b7 --- /dev/null +++ b/src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { + OverlayTrigger, + Tooltip, + Stack, + Icon, +} from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const EnrollmentsTableColumnHeader = () => ( + + + + + +
+ +
+ + )} + > + +
+
+); + +export default EnrollmentsTableColumnHeader; diff --git a/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx new file mode 100644 index 000000000..cc6e2a68c --- /dev/null +++ b/src/components/PeopleManagement/EnterpriseCustomerUserDatatable.jsx @@ -0,0 +1,135 @@ +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} + SelectionStatusComponent={DataTable.ControlledSelectionStatus} + 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.jsx b/src/components/PeopleManagement/GroupDetailPage.jsx index b0a099280..6a1ba25d3 100644 --- a/src/components/PeopleManagement/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage.jsx @@ -7,11 +7,13 @@ import { import { Delete, Edit } from '@openedx/paragon/icons'; import { useEnterpriseGroupUuid } from '../learner-credit-management/data'; +import { useEnterpriseGroupLearnersTableData } from './data/hooks'; import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; import formatDates from './utils'; import AddMembersModal from './AddMembersModal'; +import GroupMembersTable from './GroupMembersTable'; const GroupDetailPage = () => { const intl = useIntl(); @@ -22,6 +24,11 @@ const GroupDetailPage = () => { const [isLoading, setIsLoading] = useState(true); const [groupName, setGroupName] = useState(enterpriseGroup?.name); const [isAddMembersModalOpen, openAddMembersModal, closeAddMembersModal] = useToggle(false); + const { + isLoading: isTableLoading, + enterpriseGroupLearnersTableData, + fetchEnterpriseGroupLearnersTableData, + } = useEnterpriseGroupLearnersTableData({ groupUuid, isAddMembersModalOpen }); const handleNameUpdate = (name) => { setGroupName(name); }; @@ -117,21 +124,39 @@ const GroupDetailPage = () => { > View group progress - - ) : } +
+

+ +

+

+ +

+
+ + ); }; diff --git a/src/components/PeopleManagement/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupMembersTable.jsx new file mode 100644 index 000000000..9ccd2e275 --- /dev/null +++ b/src/components/PeopleManagement/GroupMembersTable.jsx @@ -0,0 +1,147 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + DataTable, Dropdown, Icon, IconButton, +} from '@openedx/paragon'; +import { MoreVert, RemoveCircle } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import TableTextFilter from '../learner-credit-management/TableTextFilter'; +import CustomDataTableEmptyState from '../learner-credit-management/CustomDataTableEmptyState'; +import MemberDetailsTableCell from '../learner-credit-management/members-tab/MemberDetailsTableCell'; +import EnrollmentsTableColumnHeader from './EnrollmentsTableColumnHeader'; +import { GROUP_MEMBERS_TABLE_DEFAULT_PAGE, GROUP_MEMBERS_TABLE_PAGE_SIZE } from './constants'; +import RecentActionTableCell from './RecentActionTableCell'; +import AddMemberTableAction from './AddMemberTableAction'; + +const FilterStatus = (rest) => ; + +const KabobMenu = () => ( + + + + + + + + + +); + +const selectColumn = { + id: 'selection', + Header: DataTable.ControlledSelectHeader, + Cell: DataTable.ControlledSelect, + disableSortBy: true, +}; + +const GroupMembersTable = ({ + isLoading, + tableData, + fetchTableData, + groupUuid, + openAddMembersModal, +}) => { + const intl = useIntl(); + return ( + + , + ]} + columns={[ + { + Header: intl.formatMessage({ + id: 'people.management.groups.detail.page.members.columns.memberDetails', + defaultMessage: 'Member details', + description: 'Column header for the Member details column in the People management Groups detail page', + }), + accessor: 'memberDetails', + Cell: MemberDetailsTableCell, + }, + { + Header: intl.formatMessage({ + id: 'people.management.groups.detail.page.members.columns.recentAction', + defaultMessage: 'Recent action', + description: 'Column header for the Recent action column in the People management Groups detail page', + }), + accessor: 'recentAction', + Cell: RecentActionTableCell, + disableFilters: true, + }, + { + Header: EnrollmentsTableColumnHeader, + accessor: 'enrollmentCount', + Cell: ({ row }) => row.original.enrollments, + disableFilters: true, + }, + ]} + initialTableOptions={{ + getRowId: row => row?.memberDetails.userEmail, + autoResetPage: true, + }} + initialState={{ + pageSize: GROUP_MEMBERS_TABLE_PAGE_SIZE, + pageIndex: GROUP_MEMBERS_TABLE_DEFAULT_PAGE, + sortBy: [ + { id: 'memberDetails', desc: true }, + ], + filters: [], + }} + additionalColumns={[ + { + id: 'action', + Header: '', + // eslint-disable-next-line react/no-unstable-nested-components + Cell: (props) => ( + + ), + }, + ]} + fetchData={fetchTableData} + data={tableData.results} + itemCount={tableData.itemCount} + pageCount={tableData.pageCount} + EmptyTableComponent={CustomDataTableEmptyState} + /> + + ); +}; + +GroupMembersTable.propTypes = { + isLoading: PropTypes.bool.isRequired, + tableData: PropTypes.shape({ + results: PropTypes.arrayOf(PropTypes.shape({ + })), + itemCount: PropTypes.number.isRequired, + pageCount: PropTypes.number.isRequired, + }).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/RecentActionTableCell.jsx b/src/components/PeopleManagement/RecentActionTableCell.jsx new file mode 100644 index 000000000..cbc2fb75b --- /dev/null +++ b/src/components/PeopleManagement/RecentActionTableCell.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import formatDates from './utils'; + +const RecentActionTableCell = ({ + row, +}) => ( +
Added: {formatDates(row.original.activatedAt)}
+); + +RecentActionTableCell.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + activatedAt: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default RecentActionTableCell; 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 abd76681b..2c8a9de50 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -3,3 +3,6 @@ export const MAX_LENGTH_GROUP_NAME = 60; export const GROUP_TYPE_BUDGET = 'budget'; export const GROUP_TYPE_FLEX = 'flex'; export const GROUP_DROPDOWN_TEXT = 'Select group'; + +export const GROUP_MEMBERS_TABLE_PAGE_SIZE = 10; +export const GROUP_MEMBERS_TABLE_DEFAULT_PAGE = 0; // `DataTable` uses zero-index array diff --git a/src/components/PeopleManagement/data/hooks/index.js b/src/components/PeopleManagement/data/hooks/index.js new file mode 100644 index 000000000..f2c42923e --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/index.js @@ -0,0 +1,2 @@ +export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData'; +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 new file mode 100644 index 000000000..d3161ef3f --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js @@ -0,0 +1,70 @@ +import { + useCallback, useMemo, useState, +} from 'react'; +import _ from 'lodash'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { logError } from '@edx/frontend-platform/logging'; +import debounce from 'lodash.debounce'; + +import LmsApiService from '../../../../data/services/LmsApiService'; + +const useEnterpriseGroupLearnersTableData = ({ groupUuid, isAddMembersModalOpen }) => { + const [isLoading, setIsLoading] = useState(true); + const [enterpriseGroupLearnersTableData, setEnterpriseGroupLearnersTableData] = useState({ + itemCount: 0, + pageCount: 0, + results: [], + }); + const fetchEnterpriseGroupLearnersData = useCallback((args) => { + const fetch = async () => { + try { + setIsLoading(true); + const options = {}; + if (args?.sortBy.length > 0) { + const sortByValue = args.sortBy[0].id; + options.sort_by = _.snakeCase(sortByValue); + if (!args.sortBy[0].desc) { + options.is_reversed = !args.sortBy[0].desc; + } + } + args.filters.forEach((filter) => { + const { id, value } = filter; + if (id === 'status') { + options.show_removed = value; + } else if (id === 'memberDetails') { + options.user_query = value; + } + }); + + options.page = args.pageIndex + 1; + const response = await LmsApiService.fetchEnterpriseGroupLearners(groupUuid, options); + const data = camelCaseObject(response.data); + + setEnterpriseGroupLearnersTableData({ + itemCount: data.count, + pageCount: data.numPages ?? Math.floor(data.count / options.pageSize), + results: data.results.filter(result => result.activatedAt), + }); + } catch (error) { + logError(error); + } finally { + setIsLoading(false); + } + }; + fetch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupUuid, isAddMembersModalOpen]); + + const debouncedFetchEnterpriseGroupLearnersData = useMemo( + () => debounce(fetchEnterpriseGroupLearnersData, 300), + [fetchEnterpriseGroupLearnersData], + ); + + return { + isLoading, + enterpriseGroupLearnersTableData, + fetchEnterpriseGroupLearnersTableData: debouncedFetchEnterpriseGroupLearnersData, + }; +}; + +export default useEnterpriseGroupLearnersTableData; 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/CreateGroupModal.test.jsx b/src/components/PeopleManagement/tests/CreateGroupModal.test.jsx index ca0d39fe0..384b2917e 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', () => ({ diff --git a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx index 7e4125230..2280b2df9 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -5,9 +5,11 @@ import '@testing-library/jest-dom/extend-expect'; import thunk from 'redux-thunk'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { useEnterpriseGroupUuid } from '../../learner-credit-management/data'; +import { useEnterpriseGroupLearnersTableData } from '../data/hooks'; import GroupDetailPage from '../GroupDetailPage'; import LmsApiService from '../../../data/services/LmsApiService'; @@ -25,6 +27,7 @@ const getMockStore = store => mockStore(store); jest.mock('../../learner-credit-management/data', () => ({ ...jest.requireActual('../../learner-credit-management/data'), useEnterpriseGroupUuid: jest.fn(), + useEnterpriseGroupLearnersTableData: jest.fn(), })); jest.mock('../../../data/services/LmsApiService'); jest.mock('react-router-dom', () => ({ @@ -60,11 +63,50 @@ describe('', () => { beforeEach(() => { useEnterpriseGroupUuid.mockReturnValue({ data: TEST_GROUP }); }); - it('renders the GroupDetailPage', () => { + it('renders the GroupDetailPage', async () => { + const mockFetchEnterpriseGroupLearnersTableData = jest.fn(); + useEnterpriseGroupLearnersTableData.mockReturnValue({ + fetchEnterpriseGroupLearnersTableData: mockFetchEnterpriseGroupLearnersTableData, + isLoading: false, + enterpriseGroupLearnersTableData: { + count: 1, + currentPage: 1, + next: null, + numPages: 1, + results: [{ + activatedAt: '2024-11-06T21:01:32.953901Z', + enterprise_group_membership_uuid: TEST_GROUP, + memberDetails: { + userEmail: 'test@2u.com', + userName: 'Test 2u', + }, + recentAction: 'Accepted: November 06, 2024', + status: 'accepted', + enrollments: 1, + }], + }, + }); render(); expect(screen.queryAllByText(TEST_GROUP.name)).toHaveLength(2); expect(screen.getByText('0 accepted members')).toBeInTheDocument(); expect(screen.getByText('View group progress')).toBeInTheDocument(); + expect(screen.getByText('Add and remove group members.')).toBeInTheDocument(); + expect(screen.getByText('Test 2u')).toBeInTheDocument(); + userEvent.click(screen.getByText('Member details')); + await waitFor(() => expect(mockFetchEnterpriseGroupLearnersTableData).toHaveBeenCalledWith({ + filters: [], + pageIndex: 0, + pageSize: 10, + sortBy: [{ desc: true, id: 'memberDetails' }], + })); + + userEvent.click(screen.getByTestId('members-table-enrollments-column-header')); + await waitFor(() => expect(mockFetchEnterpriseGroupLearnersTableData).toHaveBeenCalledWith({ + filters: [], + pageIndex: 0, + pageSize: 10, + sortBy: [{ desc: false, id: 'enrollmentCount' }], + })); }); it('edit flex group name', async () => { const spy = jest.spyOn(LmsApiService, 'updateEnterpriseGroup'); diff --git a/src/components/PeopleManagement/utils.js b/src/components/PeopleManagement/utils.js index 141d82600..eb8cfe4f6 100644 --- a/src/components/PeopleManagement/utils.js +++ b/src/components/PeopleManagement/utils.js @@ -10,3 +10,14 @@ 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; +}; diff --git a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx new file mode 100644 index 000000000..a7bdf78be --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import LmsApiService from '../../../../../data/services/LmsApiService'; +import { useEnterpriseGroupLearnersTableData } from '../../../../PeopleManagement/data/hooks'; + +describe('useEnterpriseGroupLearnersTableData', () => { + it('should fetch and return enterprise learners', async () => { + const mockGroupUUID = 'test-uuid'; + const mockData = { + count: 1, + current_page: 1, + next: null, + num_pages: 1, + previous: null, + results: [{ + activated_at: '2024-11-06T21:01:32.953901Z', + enterprise_customer_user_id: 1, + enterprise_group_membership_uuid: 'test-uuid', + member_details: { + user_email: 'test@2u.com', + user_name: 'Test 2u', + }, + recent_action: 'Accepted: November 06, 2024', + status: 'accepted', + enrollments: 1, + }], + }; + const mockEnterpriseGroupLearners = jest.spyOn(LmsApiService, 'fetchEnterpriseGroupLearners'); + mockEnterpriseGroupLearners.mockResolvedValue({ data: mockData }); + + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseGroupLearnersTableData({ groupUuid: mockGroupUUID }), + ); + result.current.fetchEnterpriseGroupLearnersTableData({ + pageIndex: 0, + pageSize: 10, + filters: [], + sortBy: [], + }); + await waitForNextUpdate(); + expect(LmsApiService.fetchEnterpriseGroupLearners).toHaveBeenCalledWith(mockGroupUUID, { page: 1 }); + expect(result.current.isLoading).toEqual(false); + expect(result.current.enterpriseGroupLearnersTableData.results).toEqual(camelCaseObject(mockData.results)); + }); +}); 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 40a388e1a..19955c52a 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -462,6 +462,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);