Skip to content

Commit

Permalink
feat: add members data table to group detail page (#1350)
Browse files Browse the repository at this point in the history
* feat: add members data table to group detail page
  • Loading branch information
katrinan029 authored Dec 4, 2024
1 parent 349cae2 commit 1a537e4
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 5 deletions.
40 changes: 40 additions & 0 deletions src/components/PeopleManagement/EnrollmentsTableColumnHeader.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Stack gap={1} direction="horizontal">
<span data-testid="members-table-enrollments-column-header">
<FormattedMessage
id="people.management.groups.detail.page.learnersTable.enrollmentsColumn"
defaultMessage="Enrollments"
description="Enrollments column header in the Members table"
/>
</span>
<OverlayTrigger
key="enrollments-column-tooltip"
placement="top"
overlay={(
<Tooltip id="enrollments-column-tooltip">
<div>
<FormattedMessage
id="people.management.groups.detail.page.learnersTable.enrollmentsColumn.tooltip"
defaultMessage="Total number of enrollment originated from the budget"
description="Tooltip for the Enrollments column header in the Group Members table"
/>
</div>
</Tooltip>
)}
>
<Icon size="xs" src={InfoOutline} className="ml-1 d-inline-flex" />
</OverlayTrigger>
</Stack>
);

export default EnrollmentsTableColumnHeader;
33 changes: 30 additions & 3 deletions src/components/PeopleManagement/GroupDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
};
Expand Down Expand Up @@ -119,7 +124,29 @@ const GroupDetailPage = () => {
</Card.Footer>
</Card>
</>
) : <Skeleton className="mt-3" height={200} count={1} /> }
) : <Skeleton className="mt-3" height={200} count={1} />}
<div className="mb-4 mt-5">
<h4 className="mt-1">
<FormattedMessage
id="people.management.group.details.page.label"
defaultMessage="Group members"
description="Label for the groups detail page with members"
/>
</h4>
<p className="font-weight-light">
<FormattedMessage
id="people.management.group.details.page.description"
defaultMessage="Add and remove group members."
description="Description for the members table in the Groups detail page"
/>
</p>
</div>
<GroupMembersTable
isLoading={isTableLoading}
tableData={enterpriseGroupLearnersTableData}
fetchTableData={fetchEnterpriseGroupLearnersTableData}
groupUuid={groupUuid}
/>
</div>
);
};
Expand Down
141 changes: 141 additions & 0 deletions src/components/PeopleManagement/GroupMembersTable.jsx
Original file line number Diff line number Diff line change
@@ -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) => <DataTable.FilterStatus showFilteredFields={false} {...rest} />;

const KabobMenu = () => (
<Dropdown drop="top">
<Dropdown.Toggle
id="kabob-menu-dropdown"
data-testid="kabob-menu-dropdown"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
/>
<Dropdown.Menu>
<Dropdown.Item>
<Icon src={RemoveCircle} className="mr-2 text-danger-500" />
<FormattedMessage
id="people.management.budgetDetail.membersTab.kabobMenu.removeMember"
defaultMessage="Remove member"
description="Remove member option in the kabob menu"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);

const selectColumn = {
id: 'selection',
Header: DataTable.ControlledSelectHeader,
Cell: DataTable.ControlledSelect,
disableSortBy: true,
};

const GroupMembersTable = ({
isLoading,
tableData,
fetchTableData,
groupUuid,
}) => {
const intl = useIntl();
return (
<span className="budget-detail-assignments">
<DataTable
isSortable
manualSortBy
isSelectable
SelectionStatusComponent={DataTable.ControlledSelectionStatus}
manualSelectColumn={selectColumn}
isPaginated
manualPagination
isFilterable
manualFilters
isLoading={isLoading}
defaultColumnValues={{ Filter: TableTextFilter }}
FilterStatusComponent={FilterStatus}
numBreakoutFilters={2}
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) => (
<KabobMenu {...props} groupUuid={groupUuid} />
),
},
]}
fetchData={fetchTableData}
data={tableData.results}
itemCount={tableData.itemCount}
pageCount={tableData.pageCount}
EmptyTableComponent={CustomDataTableEmptyState}
/>
</span>
);
};

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;
19 changes: 19 additions & 0 deletions src/components/PeopleManagement/RecentActionTableCell.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import formatDates from './utils';

const RecentActionTableCell = ({
row,
}) => (
<div>Added: {formatDates(row.original.activatedAt)}</div>
);

RecentActionTableCell.propTypes = {
row: PropTypes.shape({
original: PropTypes.shape({
activatedAt: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
};

export default RecentActionTableCell;
3 changes: 3 additions & 0 deletions src/components/PeopleManagement/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 43 additions & 2 deletions src/components/PeopleManagement/tests/GroupDetailPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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', () => ({
Expand Down Expand Up @@ -60,11 +62,50 @@ describe('<GroupDetailPageWrapper >', () => {
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: '[email protected]',
userName: 'Test 2u',
},
recentAction: 'Accepted: November 06, 2024',
status: 'accepted',
enrollments: 1,
}],
},
});
render(<GroupDetailPageWrapper />);
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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
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));
});
});
Loading

0 comments on commit 1a537e4

Please sign in to comment.