diff --git a/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx new file mode 100644 index 000000000..20ef7d173 --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx @@ -0,0 +1,125 @@ +import React, { useState, useEffect } from 'react'; +import { saveAs } from 'file-saver'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { + Toast, StatefulButton, Icon, Spinner, useToggle, +} from '@openedx/paragon'; +import { Download, Check } from '@openedx/paragon/icons'; +import { jsonToCsv } from '../utils'; +import GeneralErrorModal from '../GeneralErrorModal'; + +const DownloadCsvButton = ({ data, testId }) => { + const [buttonState, setButtonState] = useState('pageLoading'); + const [isOpen, open, close] = useToggle(false); + const [isErrorModalOpen, openErrorModal, closeErrorModal] = useToggle(false); + const intl = useIntl(); + + useEffect(() => { + if (data && data.length) { + setButtonState('default'); + } + }, [data]); + + const getCsvFileName = () => { + const currentDate = new Date(); + const year = currentDate.getUTCFullYear(); + const month = currentDate.getUTCMonth() + 1; + const day = currentDate.getUTCDate(); + return `${year}-${month}-${day}-group-detail-report.csv`; + }; + + const createCsvData = (jsonData) => jsonToCsv(jsonData.map(row => ({ + Email: row.memberDetails.userEmail, + Username: row.memberDetails.userName, + Enrollments: row.enrollments, + // we have to strip out the comma so it doesn't mess up the csv parsing + 'Recent action': row.recent_action.replace(/,/g, ''), + }))); + + const handleClick = async () => { + setButtonState('pending'); + try { + const csv = createCsvData(data); + const blob = new Blob([csv], { + type: 'text/csv', + }); + saveAs(blob, getCsvFileName()); + open(); + } catch { + openErrorModal(); + } finally { + setButtonState('complete'); + } + }; + + const toastText = intl.formatMessage({ + id: 'adminPortal.peopleManagement.groupDetail.downloadCsv.toast', + defaultMessage: 'Downloaded group members.', + description: 'Toast message for the download button on the group detail page.', + }); + return ( + <> + { isOpen + && ( + + {toastText} + + )} + + , + pending: , + complete: , + pageLoading: , + }} + disabledStates={['pending', 'pageLoading']} + onClick={handleClick} + /> + + ); +}; + +DownloadCsvButton.defaultProps = { + testId: 'download-csv-button', +}; + +DownloadCsvButton.propTypes = { + // eslint-disable-next-line react/forbid-prop-types + data: PropTypes.arrayOf( + PropTypes.object, + ), + testId: PropTypes.string, +}; + +export default DownloadCsvButton; diff --git a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx index 84a0f2d78..276274c60 100644 --- a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx @@ -10,8 +10,8 @@ import { useEnterpriseGroupLearnersTableData, useEnterpriseGroupUuid } from '../ import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; -import formatDates from '../utils'; -import GroupMembersTable from '../GroupMembersTable'; +import { formatDates } from '../utils'; +import GroupMembersTable from './GroupMembersTable'; const GroupDetailPage = () => { const intl = useIntl(); @@ -21,10 +21,13 @@ const GroupDetailPage = () => { const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false); const [isLoading, setIsLoading] = useState(true); const [groupName, setGroupName] = useState(enterpriseGroup?.name); + const { isLoading: isTableLoading, enterpriseGroupLearnersTableData, fetchEnterpriseGroupLearnersTableData, + refresh, + setRefresh, } = useEnterpriseGroupLearnersTableData({ groupUuid }); const handleNameUpdate = (name) => { setGroupName(name); @@ -146,6 +149,8 @@ const GroupDetailPage = () => { tableData={enterpriseGroupLearnersTableData} fetchTableData={fetchEnterpriseGroupLearnersTableData} groupUuid={groupUuid} + refresh={refresh} + setRefresh={setRefresh} /> ); diff --git a/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx new file mode 100644 index 000000000..95cdcfea3 --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + DataTable, Dropdown, Icon, IconButton, useToggle, +} from '@openedx/paragon'; +import { MoreVert, RemoveCircle } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; + +import TableTextFilter from '../../learner-credit-management/TableTextFilter'; +import CustomDataTableEmptyState from '../../learner-credit-management/CustomDataTableEmptyState'; +import MemberDetailsTableCell from '../../learner-credit-management/members-tab/MemberDetailsTableCell'; +import EnrollmentsTableColumnHeader from '../EnrollmentsTableColumnHeader'; +import { + GROUP_MEMBERS_TABLE_DEFAULT_PAGE, + GROUP_MEMBERS_TABLE_PAGE_SIZE, +} from '../constants'; +import RecentActionTableCell from '../RecentActionTableCell'; +import DownloadCsvButton from './DownloadCsvButton'; +import RemoveMemberModal from './RemoveMemberModal'; +import GeneralErrorModal from '../GeneralErrorModal'; + +const FilterStatus = (rest) => ( + +); + +const KabobMenu = ({ + row, groupUuid, refresh, setRefresh, +}) => { + const [isRemoveModalOpen, openRemoveModal, closeRemoveModal] = useToggle(false); + const [isErrorModalOpen, openErrorModal, closeErrorModal] = useToggle(false); + + return ( + <> + + + + + + + + + + + + + ); +}; + +KabobMenu.propTypes = { + row: PropTypes.shape({}).isRequired, + groupUuid: PropTypes.string.isRequired, + refresh: PropTypes.bool.isRequired, + setRefresh: PropTypes.func.isRequired, +}; + +const selectColumn = { + id: 'selection', + Header: DataTable.ControlledSelectHeader, + Cell: DataTable.ControlledSelect, + disableSortBy: true, +}; + +const GroupMembersTable = ({ + isLoading, + tableData, + fetchTableData, + groupUuid, + refresh, + setRefresh, +}) => { + const intl = useIntl(); + return ( + + 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) => ( + + ), + }, + ]} + tableActions={[ + , + ]} + 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, + refresh: PropTypes.bool.isRequired, + setRefresh: PropTypes.func.isRequired, +}; + +export default GroupMembersTable; diff --git a/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx b/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx new file mode 100644 index 000000000..0e0e9fe88 --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx @@ -0,0 +1,88 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import { + ActionRow, Button, ModalDialog, +} from '@openedx/paragon'; +import { RemoveCircle } from '@openedx/paragon/icons'; +import { logError } from '@edx/frontend-platform/logging'; + +import LmsApiService from '../../../data/services/LmsApiService'; + +const RemoveMemberModal = ({ + groupUuid, row, isOpen, close, openError, refresh, setRefresh, +}) => { + const removeEnterpriseGroupMember = async () => { + try { + const rowEmail = row.id; + const formData = new FormData(); + formData.append('learner_emails', rowEmail); + await LmsApiService.removeEnterpriseLearnersFromGroup(groupUuid, 'hello'); + setRefresh(!refresh); + close(); + } catch (error) { + close(); + logError(error); + openError(); + } + }; + return ( + + + + Remove member? + + + +

+ +

+

+ +

+
+ + + + + Go back + + + + +
+ ); +}; + +RemoveMemberModal.propTypes = { + groupUuid: PropTypes.string.isRequired, + row: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + openError: PropTypes.func.isRequired, + refresh: PropTypes.bool.isRequired, + setRefresh: PropTypes.func.isRequired, +}; + +export default RemoveMemberModal; diff --git a/src/components/PeopleManagement/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupMembersTable.jsx deleted file mode 100644 index be59163a0..000000000 --- a/src/components/PeopleManagement/GroupMembersTable.jsx +++ /dev/null @@ -1,141 +0,0 @@ -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'; - -const FilterStatus = (rest) => ; - -const KabobMenu = () => ( - - - - - - - - - -); - -const selectColumn = { - id: 'selection', - Header: DataTable.ControlledSelectHeader, - Cell: DataTable.ControlledSelect, - disableSortBy: true, -}; - -const GroupMembersTable = ({ - isLoading, - tableData, - fetchTableData, - groupUuid, -}) => { - const intl = useIntl(); - return ( - - 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, -}; - -export default GroupMembersTable; diff --git a/src/components/PeopleManagement/RecentActionTableCell.jsx b/src/components/PeopleManagement/RecentActionTableCell.jsx index cbc2fb75b..f76d04152 100644 --- a/src/components/PeopleManagement/RecentActionTableCell.jsx +++ b/src/components/PeopleManagement/RecentActionTableCell.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import formatDates from './utils'; +import { formatDates } from './utils'; const RecentActionTableCell = ({ row, diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js index ef2d298c3..fa98c28a4 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -13,4 +13,5 @@ export const GROUP_MEMBERS_TABLE_DEFAULT_PAGE = 0; // `DataTable` uses zero-inde export const peopleManagementQueryKeys = { all: ['people-management'], members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid], + removeMember: (groupUuid) => [...peopleManagementQueryKeys.all, 'removeMember', groupUuid], }; diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js index 2e5d6e926..7ae9113e9 100644 --- a/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js @@ -10,6 +10,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => { const [isLoading, setIsLoading] = useState(true); + const [refresh, setRefresh] = useState(false); const [enterpriseGroupLearnersTableData, setEnterpriseGroupLearnersTableData] = useState({ itemCount: 0, pageCount: 0, @@ -56,14 +57,16 @@ const useEnterpriseGroupLearnersTableData = ({ groupUuid }) => { const debouncedFetchEnterpriseGroupLearnersData = useMemo( () => debounce(fetchEnterpriseGroupLearnersData, 300), - [fetchEnterpriseGroupLearnersData], + // eslint-disable-next-line react-hooks/exhaustive-deps + [fetchEnterpriseGroupLearnersData, refresh], ); return { isLoading, enterpriseGroupLearnersTableData, fetchEnterpriseGroupLearnersTableData: debouncedFetchEnterpriseGroupLearnersData, + refresh, + setRefresh, }; }; - export default useEnterpriseGroupLearnersTableData; diff --git a/src/components/PeopleManagement/utils.js b/src/components/PeopleManagement/utils.js index 141d82600..93819161c 100644 --- a/src/components/PeopleManagement/utils.js +++ b/src/components/PeopleManagement/utils.js @@ -6,7 +6,19 @@ import dayjs from 'dayjs'; * @param {string} timestamp unformatted date timestamp * @returns Formatted date string for display. */ -export default function formatDates(timestamp) { +export function formatDates(timestamp) { const DATE_FORMAT = 'MMMM DD, YYYY'; return dayjs(timestamp).format(DATE_FORMAT); } + +export function jsonToCsv(data) { + let csv = ''; + const headers = Object.keys(data[0]); + csv += `${headers.join(',')}\n`; + + data.forEach((row) => { + const rows = headers.map(header => row[header]).join(','); + csv += `${rows}\n`; + }); + return csv; +}