From 65ea07d193dc66c47120fb7ae5f2577683101a10 Mon Sep 17 00:00:00 2001 From: Kira Miller Date: Tue, 22 Oct 2024 06:09:42 +0000 Subject: [PATCH 1/5] fix: formatting without data --- .../Admin/tabs/ModuleActivityReport.jsx | 3 +- .../EnterpriseApp/EnterpriseAppRoutes.jsx | 2 +- .../DeleteGroupModal.jsx | 6 +- .../EditGroupNameModal.jsx | 6 +- .../{ => GroupDetailPage}/GroupDetailPage.jsx | 6 +- .../PeopleManagement/OrgMemberCard.jsx | 92 +++++++++++++++++++ .../PeopleManagementTable.jsx | 79 ++++++++++++++++ .../PeopleManagement/_PeopleManagement.scss | 8 ++ src/components/PeopleManagement/index.jsx | 29 +++++- .../tests/GroupDetailPage.test.jsx | 2 +- .../invite-modal/InviteModalContent.jsx | 2 +- 11 files changed, 218 insertions(+), 17 deletions(-) rename src/components/PeopleManagement/{ => GroupDetailPage}/DeleteGroupModal.jsx (93%) rename src/components/PeopleManagement/{ => GroupDetailPage}/EditGroupNameModal.jsx (95%) rename src/components/PeopleManagement/{ => GroupDetailPage}/GroupDetailPage.jsx (95%) create mode 100644 src/components/PeopleManagement/OrgMemberCard.jsx create mode 100644 src/components/PeopleManagement/PeopleManagementTable.jsx diff --git a/src/components/Admin/tabs/ModuleActivityReport.jsx b/src/components/Admin/tabs/ModuleActivityReport.jsx index 2b4c10fbb..cf34d7e3d 100644 --- a/src/components/Admin/tabs/ModuleActivityReport.jsx +++ b/src/components/Admin/tabs/ModuleActivityReport.jsx @@ -156,8 +156,7 @@ const ModuleActivityReport = ({ enterpriseId }) => { />, ]} > - - + { const intl = useIntl(); diff --git a/src/components/PeopleManagement/OrgMemberCard.jsx b/src/components/PeopleManagement/OrgMemberCard.jsx new file mode 100644 index 000000000..de52ea57a --- /dev/null +++ b/src/components/PeopleManagement/OrgMemberCard.jsx @@ -0,0 +1,92 @@ +import { Avatar, Card, Col, Row } from '@openedx/paragon'; + +const OrgMemberCard = ({ original }) => { + const { name, email, joinedOrg, enrollments } = original; + + return ( + + + + + + + + + +

{name}

+
+ +

{email}

+
+ + +
Joined org
+ {joinedOrg} + + +
Enrollments
+ {enrollments} + +
+
+
+
+ // + // + // + + + + // + //

+ // {name} + // {/* */} + //

+ //
+ //

+ // {/* */} + // {email} + //

+ // + // + //
+ // + // + // {/* */} + // {joinedOrg} + // + // + //
+ // {/* */} + // {enrollments} + //
+ // + //
+ //
+ // + //
+ //
+ //
+ ); +}; + +export default OrgMemberCard; \ No newline at end of file diff --git a/src/components/PeopleManagement/PeopleManagementTable.jsx b/src/components/PeopleManagement/PeopleManagementTable.jsx new file mode 100644 index 000000000..0217e8083 --- /dev/null +++ b/src/components/PeopleManagement/PeopleManagementTable.jsx @@ -0,0 +1,79 @@ +import { CardView, Container, DataTable, TextFilter } from "@openedx/paragon"; +import { useIntl } from "@edx/frontend-platform/i18n"; + +import OrgMemberCard from "./OrgMemberCard"; + +const PeopleManagementTable = () => { + const pageSize = 10; + const intl = useIntl(); + // const tableColumns = [ + // { + // Header: "Name", + // accessor: "name", + // }, + // { + // Header: "Email", + // accessor: "email", + // }, + // { + // Header: "Joined org", + // accessor: "joinedOrg", + // }, + // { + // Header: "Enrollments", + // accessor: "enrollments", + // }, + // ]; + + return ( + + + + {/* */} + {/* */} + + + ); +}; + +export default PeopleManagementTable; diff --git a/src/components/PeopleManagement/_PeopleManagement.scss b/src/components/PeopleManagement/_PeopleManagement.scss index f5a8caa1a..788da061c 100644 --- a/src/components/PeopleManagement/_PeopleManagement.scss +++ b/src/components/PeopleManagement/_PeopleManagement.scss @@ -10,4 +10,12 @@ .collapsible-basic .collapsible-trigger { justify-content: right; +} + +// .org-member-card { +// width: 100%; +// } + +.pgn__card-grid__card-item { + width: 100%; } \ No newline at end of file diff --git a/src/components/PeopleManagement/index.jsx b/src/components/PeopleManagement/index.jsx index 27e8c8858..6e32ba55d 100644 --- a/src/components/PeopleManagement/index.jsx +++ b/src/components/PeopleManagement/index.jsx @@ -13,6 +13,7 @@ import CreateGroupModal from './CreateGroupModal'; import { useAllEnterpriseGroups } from '../learner-credit-management/data'; import ZeroState from './ZeroState'; import GroupCardGrid from './GroupCardGrid'; +import PeopleManagementTable from './PeopleManagementTable'; const PeopleManagementPage = ({ enterpriseId }) => { const intl = useIntl(); @@ -39,6 +40,7 @@ const PeopleManagementPage = ({ enterpriseId }) => { } }, [data]); + return ( <> @@ -78,16 +80,37 @@ const PeopleManagementPage = ({ enterpriseId }) => { description="CTA button text to open new group modal." /> - + {groups && groups.length > 0 ? ( - ) : } + + ) : ( + + )} +

+ +

+ + ); }; -const mapStateToProps = state => ({ +const mapStateToProps = (state) => ({ enterpriseId: state.portalConfiguration.enterpriseId, }); diff --git a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx index 7e4125230..0f6f48809 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -8,7 +8,7 @@ import { Provider } from 'react-redux'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { useEnterpriseGroupUuid } from '../../learner-credit-management/data'; -import GroupDetailPage from '../GroupDetailPage'; +import GroupDetailPage from '../GroupDetailPage/GroupDetailPage'; import LmsApiService from '../../../data/services/LmsApiService'; const TEST_ENTERPRISE_SLUG = 'test-enterprise'; diff --git a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx index 82759159a..a8ab62bc4 100644 --- a/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx +++ b/src/components/learner-credit-management/invite-modal/InviteModalContent.jsx @@ -16,7 +16,7 @@ import InviteModalMembershipInfo from './InviteModalMembershipInfo'; import InviteModalBudgetCard from './InviteModalBudgetCard'; import InviteModalPermissions from './InviteModalPermissions'; import InviteSummaryCount from './InviteSummaryCount'; -import MAX_LENGTH_GROUP_NAME from '../../PeopleManagement/constants'; +import { MAX_LENGTH_GROUP_NAME } from '../../PeopleManagement/constants'; const InviteModalContent = ({ onEmailAddressesChange, From feebc260329b6bc74d78d0b54b72dd0e38e3197b Mon Sep 17 00:00:00 2001 From: Kira Miller Date: Wed, 4 Dec 2024 07:42:06 +0000 Subject: [PATCH 2/5] fix: adding in tests --- .../Admin/tabs/ModuleActivityReport.jsx | 2 +- .../PeopleManagement/OrgMemberCard.jsx | 119 ++++++------------ .../PeopleManagementTable.jsx | 103 +++++++-------- src/components/PeopleManagement/constants.js | 7 ++ .../hooks/useEnterpriseMembersTableData.js | 58 +++++++++ src/components/PeopleManagement/index.jsx | 1 - .../useEnterpriseMembersTableData.test.jsx | 42 +++++++ src/data/services/LmsApiService.js | 11 ++ 8 files changed, 205 insertions(+), 138 deletions(-) create mode 100644 src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js create mode 100644 src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx diff --git a/src/components/Admin/tabs/ModuleActivityReport.jsx b/src/components/Admin/tabs/ModuleActivityReport.jsx index cf34d7e3d..7ab116abd 100644 --- a/src/components/Admin/tabs/ModuleActivityReport.jsx +++ b/src/components/Admin/tabs/ModuleActivityReport.jsx @@ -156,7 +156,7 @@ const ModuleActivityReport = ({ enterpriseId }) => { />, ]} > - + { - const { name, email, joinedOrg, enrollments } = original; + const { enterpriseCustomerUser, enrollments } = original; + const { name, joinedOrg, email } = enterpriseCustomerUser; return ( - - - - - - -

{name}

-
- -

{email}

-
- - -
Joined org
- {joinedOrg} - - -
Enrollments
- {enrollments} - -
+ + + + + + +

{name}

+
+ +

{email}

+
+ + +
Joined org
+ {joinedOrg} + + +
Enrollments
+ {enrollments} + +
- // - // - // - - - - // - //

- // {name} - // {/* */} - //

- //
- //

- // {/* */} - // {email} - //

- // - // - //
- // - // - // {/* */} - // {joinedOrg} - // - // - //
- // {/* */} - // {enrollments} - //
- // - //
- //
- // - //
- //
- //
); }; -export default OrgMemberCard; \ No newline at end of file +OrgMemberCard.propTypes = { + original: PropTypes.shape({ + enterpriseCustomerUser: PropTypes.shape({ + email: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + joinedOrg: PropTypes.string.isRequired, + }), + enrollments: PropTypes.number.isRequired, + }), +}; + +export default OrgMemberCard; diff --git a/src/components/PeopleManagement/PeopleManagementTable.jsx b/src/components/PeopleManagement/PeopleManagementTable.jsx index 0217e8083..62d0d5745 100644 --- a/src/components/PeopleManagement/PeopleManagementTable.jsx +++ b/src/components/PeopleManagement/PeopleManagementTable.jsx @@ -1,79 +1,68 @@ -import { CardView, Container, DataTable, TextFilter } from "@openedx/paragon"; -import { useIntl } from "@edx/frontend-platform/i18n"; +import React from 'react'; +import { CardView, DataTable } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; -import OrgMemberCard from "./OrgMemberCard"; +import TableTextFilter from '../learner-credit-management/TableTextFilter'; +import CustomDataTableEmptyState from '../learner-credit-management/CustomDataTableEmptyState'; +import OrgMemberCard from './OrgMemberCard'; +import useEnterpriseMembersTableData from './data/hooks/useEnterpriseMembersTableData'; -const PeopleManagementTable = () => { - const pageSize = 10; - const intl = useIntl(); - // const tableColumns = [ - // { - // Header: "Name", - // accessor: "name", - // }, - // { - // Header: "Email", - // accessor: "email", - // }, - // { - // Header: "Joined org", - // accessor: "joinedOrg", - // }, - // { - // Header: "Enrollments", - // accessor: "enrollments", - // }, - // ]; +const FilterStatus = (rest) => ; + +const PeopleManagementTable = ({ enterpriseId }) => { + const { + isLoading: isTableLoading, + enterpriseMembersTableData, + fetchEnterpriseMembersTableData, + } = useEnterpriseMembersTableData({ enterpriseId }); + + const tableColumns = [{ Header: 'Name', accessor: 'name' }]; return ( + fetchData={fetchEnterpriseMembersTableData} + data={enterpriseMembersTableData.results} + itemCount={enterpriseMembersTableData.itemCount} + pageCount={enterpriseMembersTableData.pageCount} + EmptyTableComponent={CustomDataTableEmptyState} + > - {/* */} - {/* */} ); }; -export default PeopleManagementTable; +PeopleManagementTable.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(PeopleManagementTable); diff --git a/src/components/PeopleManagement/constants.js b/src/components/PeopleManagement/constants.js index c253692b5..5d98ac79d 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -2,3 +2,10 @@ export const MAX_LENGTH_GROUP_NAME = 60; export const GROUP_TYPE_BUDGET = 'budget'; export const GROUP_TYPE_FLEX = 'flex'; + +// Query Key factory for the people management module, intended to be used with `@tanstack/react-query`. +// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories. +export const peopleManagementQueryKeys = { + all: ['people-management'], + members: (enterpriseUuid) => [...peopleManagementQueryKeys.all, 'members', enterpriseUuid], +}; diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js new file mode 100644 index 000000000..70ed659c0 --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js @@ -0,0 +1,58 @@ +import { + useCallback, useMemo, useState, +} from 'react'; +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 useEnterpriseMembersTableData = ({ enterpriseId }) => { + const [isLoading, setIsLoading] = useState(true); + const [enterpriseMembersTableData, setEnterpriseMembersTableData] = useState({ + itemCount: 0, + pageCount: 0, + results: [], + }); + const fetchEnterpriseMembersData = useCallback((args) => { + const fetch = async () => { + try { + setIsLoading(true); + const options = {}; + args.filters.forEach((filter) => { + const { id, value } = filter; + if (id === 'name') { + options.user_query = value; + } + }); + + options.page = args.pageIndex + 1; + const response = await LmsApiService.fetchEnterpriseCustomerMembers(enterpriseId, options); + const data = camelCaseObject(response.data); + setEnterpriseMembersTableData({ + itemCount: data.count, + pageCount: data.numPages ?? Math.floor(data.count / options.pageSize), + results: data.results, + }); + } catch (error) { + logError(error); + } finally { + setIsLoading(false); + } + }; + fetch(); + }, [enterpriseId]); + + const debouncedFetchEnterpriseMembersData = useMemo( + () => debounce(fetchEnterpriseMembersData, 300), + [fetchEnterpriseMembersData], + ); + + return { + isLoading, + enterpriseMembersTableData, + fetchEnterpriseMembersTableData: debouncedFetchEnterpriseMembersData, + }; +}; + +export default useEnterpriseMembersTableData; diff --git a/src/components/PeopleManagement/index.jsx b/src/components/PeopleManagement/index.jsx index 6e32ba55d..7cf661418 100644 --- a/src/components/PeopleManagement/index.jsx +++ b/src/components/PeopleManagement/index.jsx @@ -40,7 +40,6 @@ const PeopleManagementPage = ({ enterpriseId }) => { } }, [data]); - return ( <> diff --git a/src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx b/src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx new file mode 100644 index 000000000..d12aca1e6 --- /dev/null +++ b/src/components/PeopleManagement/tests/useEnterpriseMembersTableData.test.jsx @@ -0,0 +1,42 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import LmsApiService from '../../../data/services/LmsApiService'; + +import useEnterpriseMembersTableData from '../data/hooks/useEnterpriseMembersTableData'; + +describe('useEnterpriseMembersTableData', () => { + it('should fetch and return members of an enterprise', async () => { + const mockEnterpriseUUID = 'uuid-bb'; + const mockData = { + count: 1, + current_page: 1, + next: null, + num_pages: 1, + previous: null, + results: [{ + enterprise_customer_user: { + email: 'jeez.louise@example.com', + joinedOrg: 'Sep 15, 2021', + name: 'Jeez Louise', + }, + enrollments: 11, + }], + }; + const mockEnterpriseMembers = jest.spyOn(LmsApiService, 'fetchEnterpriseCustomerMembers'); + mockEnterpriseMembers.mockResolvedValue({ data: mockData }); + + const { result, waitForNextUpdate } = renderHook( + () => useEnterpriseMembersTableData({ enterpriseId: mockEnterpriseUUID }), + ); + result.current.fetchEnterpriseMembersTableData({ + pageIndex: 0, + pageSize: 10, + filters: [], + sortBy: [], + }); + await waitForNextUpdate(); + expect(LmsApiService.fetchEnterpriseCustomerMembers).toHaveBeenCalledWith(mockEnterpriseUUID, { page: 1 }); + expect(result.current.isLoading).toEqual(false); + expect(result.current.enterpriseMembersTableData.results).toEqual(camelCaseObject(mockData.results)); + }); +}); diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 0fb9831dd..1dc057117 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -17,6 +17,8 @@ class LmsApiService { static enterpriseCustomerBrandingUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-customer-branding/update-branding/`; + static enterpriseCustomerMembersUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-customer-members/`; + static providerConfigUrl = `${LmsApiService.baseUrl}/auth/saml/v0/provider_config/`; static providerDataUrl = `${LmsApiService.baseUrl}/auth/saml/v0/provider_data/`; @@ -351,6 +353,15 @@ class LmsApiService { return LmsApiService.apiClient().patch(url, options); } + static fetchEnterpriseCustomerMembers(enterpriseUUID, options) { + let url = `${LmsApiService.enterpriseCustomerMembersUrl}${enterpriseUUID}/`; + if (options) { + const queryParams = new URLSearchParams(options); + url = `${LmsApiService.enterpriseCustomerMembersUrl}${enterpriseUUID}?${queryParams.toString()}`; + } + return LmsApiService.apiClient().get(url, options); + } + /** * Disables EnterpriseCustomerInviteKey * @param {string} enterpriseCustomerInviteKeyUUID uuid EnterpriseCustomerInviteKey to disable From 201367c4d55d4ccb5525c80c89de6a123e47984b Mon Sep 17 00:00:00 2001 From: Kira Miller Date: Wed, 4 Dec 2024 19:30:10 +0000 Subject: [PATCH 3/5] fix: teeny fix --- src/components/Admin/tabs/ModuleActivityReport.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Admin/tabs/ModuleActivityReport.jsx b/src/components/Admin/tabs/ModuleActivityReport.jsx index 7ab116abd..2b4c10fbb 100644 --- a/src/components/Admin/tabs/ModuleActivityReport.jsx +++ b/src/components/Admin/tabs/ModuleActivityReport.jsx @@ -157,6 +157,7 @@ const ModuleActivityReport = ({ enterpriseId }) => { ]} > + Date: Mon, 9 Dec 2024 19:48:06 +0000 Subject: [PATCH 4/5] feat: adding in remove member functionality --- .../GroupDetailPage/DownloadCsvButton.jsx | 110 ++++++++++ .../GroupDetailPage/GroupDetailPage.jsx | 7 +- .../GroupDetailPage/GroupMembersTable.jsx | 200 ++++++++++++++++++ .../GroupDetailPage/RemoveMemberModal.jsx | 88 ++++++++ .../PeopleManagement/GroupMembersTable.jsx | 141 ------------ src/components/PeopleManagement/constants.js | 1 + .../useEnterpriseGroupLearnersTableData.js | 7 +- 7 files changed, 410 insertions(+), 144 deletions(-) create mode 100644 src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx create mode 100644 src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx create mode 100644 src/components/PeopleManagement/GroupDetailPage/RemoveMemberModal.jsx delete mode 100644 src/components/PeopleManagement/GroupMembersTable.jsx diff --git a/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx new file mode 100644 index 000000000..568f1d52f --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx @@ -0,0 +1,110 @@ +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 { logError } from '@edx/frontend-platform/logging'; + +const DownloadCsvButton = ({ data, testId, fetchData }) => { + const [buttonState, setButtonState] = useState('pageLoading'); + const [isOpen, open, close] = 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 handleClick = async () => { + setButtonState('pending'); + fetchData().then((response) => { + const blob = new Blob([response.data.results], { + type: 'text/csv', + }); + saveAs(blob, getCsvFileName()); + open(); + setButtonState('complete'); + }).catch((err) => { + logError(err); + }); + }; + + 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, + ), + fetchData: PropTypes.func.isRequired, + testId: PropTypes.string, +}; + +export default DownloadCsvButton; diff --git a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx index 84a0f2d78..ca030cb35 100644 --- a/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx @@ -11,7 +11,7 @@ import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; import formatDates from '../utils'; -import GroupMembersTable from '../GroupMembersTable'; +import GroupMembersTable from './GroupMembersTable'; 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..d1320d18d --- /dev/null +++ b/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx @@ -0,0 +1,200 @@ +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 LmsApiService from '../../../data/services/LmsApiService'; +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 fetchCsvData = async () => LmsApiService.fetchEnterpriseGroupLearners( + groupUuid, + // { ...currentFilters, search: searchQuery }, + { csv: true }, + ); + 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/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; From 7cf109c75a2c66b1fdb09800ef1d2ee9fb7c1eb2 Mon Sep 17 00:00:00 2001 From: Kira Miller Date: Thu, 12 Dec 2024 21:33:09 +0000 Subject: [PATCH 5/5] fix: adding csv download --- .../GroupDetailPage/DownloadCsvButton.jsx | 31 ++++++++++++++----- .../GroupDetailPage/GroupDetailPage.jsx | 2 +- .../GroupDetailPage/GroupMembersTable.jsx | 7 ----- .../RecentActionTableCell.jsx | 2 +- src/components/PeopleManagement/utils.js | 14 ++++++++- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx index 568f1d52f..20ef7d173 100644 --- a/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/DownloadCsvButton.jsx @@ -7,11 +7,13 @@ import { Toast, StatefulButton, Icon, Spinner, useToggle, } from '@openedx/paragon'; import { Download, Check } from '@openedx/paragon/icons'; -import { logError } from '@edx/frontend-platform/logging'; +import { jsonToCsv } from '../utils'; +import GeneralErrorModal from '../GeneralErrorModal'; -const DownloadCsvButton = ({ data, testId, fetchData }) => { +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(() => { @@ -28,18 +30,28 @@ const DownloadCsvButton = ({ data, testId, fetchData }) => { 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'); - fetchData().then((response) => { - const blob = new Blob([response.data.results], { + try { + const csv = createCsvData(data); + const blob = new Blob([csv], { type: 'text/csv', }); saveAs(blob, getCsvFileName()); open(); + } catch { + openErrorModal(); + } finally { setButtonState('complete'); - }).catch((err) => { - logError(err); - }); + } }; const toastText = intl.formatMessage({ @@ -55,6 +67,10 @@ const DownloadCsvButton = ({ data, testId, fetchData }) => { {toastText} )} + { diff --git a/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx index d1320d18d..95cdcfea3 100644 --- a/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/GroupMembersTable.jsx @@ -16,7 +16,6 @@ import { } from '../constants'; import RecentActionTableCell from '../RecentActionTableCell'; import DownloadCsvButton from './DownloadCsvButton'; -import LmsApiService from '../../../data/services/LmsApiService'; import RemoveMemberModal from './RemoveMemberModal'; import GeneralErrorModal from '../GeneralErrorModal'; @@ -91,11 +90,6 @@ const GroupMembersTable = ({ refresh, setRefresh, }) => { - const fetchCsvData = async () => LmsApiService.fetchEnterpriseGroupLearners( - groupUuid, - // { ...currentFilters, search: searchQuery }, - { csv: true }, - ); const intl = useIntl(); return ( @@ -169,7 +163,6 @@ const GroupMembersTable = ({ ]} tableActions={[ , 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/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; +}