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;