diff --git a/src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx b/src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx new file mode 100644 index 0000000000..939b0e0b74 --- /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/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage.jsx index 9f02b81e93..d47f543038 100644 --- a/src/components/PeopleManagement/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage.jsx @@ -6,11 +6,12 @@ import { } from '@openedx/paragon'; import { Delete, Edit } from '@openedx/paragon/icons'; -import { useEnterpriseGroupUuid } from '../learner-credit-management/data'; +import { useEnterpriseGroupLearnersTableData, useEnterpriseGroupUuid } from '../learner-credit-management/data'; import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import DeleteGroupModal from './DeleteGroupModal'; import EditGroupNameModal from './EditGroupNameModal'; import formatDates from './utils'; +import GroupMembersTable from './GroupMembersTable'; const GroupDetailPage = () => { const intl = useIntl(); @@ -20,7 +21,11 @@ const GroupDetailPage = () => { const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false); const [isLoading, setIsLoading] = useState(true); const [groupName, setGroupName] = useState(enterpriseGroup?.name); - + const { + isLoading: isTableLoading, + enterpriseGroupLearnersTableData, + fetchEnterpriseGroupLearnersTableData, + } = useEnterpriseGroupLearnersTableData({ groupUuid }); const handleNameUpdate = (name) => { setGroupName(name); }; @@ -119,7 +124,29 @@ const GroupDetailPage = () => { - ) : } + ) : } +
+

+ +

+

+ +

+
+ ); }; diff --git a/src/components/PeopleManagement/GroupMembersTable.jsx b/src/components/PeopleManagement/GroupMembersTable.jsx new file mode 100644 index 0000000000..be59163a0a --- /dev/null +++ b/src/components/PeopleManagement/GroupMembersTable.jsx @@ -0,0 +1,141 @@ +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 new file mode 100644 index 0000000000..cbc2fb75be --- /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/constants.js b/src/components/PeopleManagement/constants.js index abd76681b8..2c8a9de50f 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/tests/GroupDetailPage.test.jsx b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx index 7e41252303..e00d2fbd98 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -5,9 +5,10 @@ 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 { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../../learner-credit-management/data'; import GroupDetailPage from '../GroupDetailPage'; import LmsApiService from '../../../data/services/LmsApiService'; @@ -25,6 +26,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 +62,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/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index 21e5e65f8b..c8df3b7885 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -23,4 +23,5 @@ export { default as useContentMetadata } from './useContentMetadata'; export { default as useEnterpriseRemovedGroupMembers } from './useEnterpriseRemovedGroupMembers'; export { default as useEnterpriseFlexGroups } from './useEnterpriseFlexGroups'; export { default as useGroupDropdownToggle } from './useGroupDropdownToggle'; +export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData'; export { default as useEnterpriseLearners } from './useEnterpriseLearners'; 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 0000000000..ec49d431e3 --- /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 '../..'; + +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/data/hooks/useEnterpriseGroupLearnersTableData.js b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js new file mode 100644 index 0000000000..2e5d6e926e --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js @@ -0,0 +1,69 @@ +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 }) => { + 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(); + }, [groupUuid]); + + const debouncedFetchEnterpriseGroupLearnersData = useMemo( + () => debounce(fetchEnterpriseGroupLearnersData, 300), + [fetchEnterpriseGroupLearnersData], + ); + + return { + isLoading, + enterpriseGroupLearnersTableData, + fetchEnterpriseGroupLearnersTableData: debouncedFetchEnterpriseGroupLearnersData, + }; +}; + +export default useEnterpriseGroupLearnersTableData;