diff --git a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx index 731b34ff9..02ac0d708 100644 --- a/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppRoutes.jsx @@ -19,7 +19,7 @@ import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; import ContentHighlights from '../ContentHighlights'; import LearnerCreditManagementRoutes from '../learner-credit-management'; import PeopleManagementPage from '../PeopleManagement'; -import GroupDetailPage from '../PeopleManagement/GroupDetailPage'; +import GroupDetailPage from '../PeopleManagement/GroupDetailPage/GroupDetailPage'; const EnterpriseAppRoutes = ({ email, diff --git a/src/components/PeopleManagement/DeleteGroupModal.jsx b/src/components/PeopleManagement/GroupDetailPage/DeleteGroupModal.jsx similarity index 93% rename from src/components/PeopleManagement/DeleteGroupModal.jsx rename to src/components/PeopleManagement/GroupDetailPage/DeleteGroupModal.jsx index c0f67802c..ccf98ee65 100644 --- a/src/components/PeopleManagement/DeleteGroupModal.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/DeleteGroupModal.jsx @@ -8,10 +8,10 @@ import { } from '@openedx/paragon'; import { RemoveCircleOutline } from '@openedx/paragon/icons'; -import GeneralErrorModal from './GeneralErrorModal'; -import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import GeneralErrorModal from '../GeneralErrorModal'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; -import LmsApiService from '../../data/services/LmsApiService'; +import LmsApiService from '../../../data/services/LmsApiService'; const DeleteGroupModal = ({ group, isOpen, close, diff --git a/src/components/PeopleManagement/EditGroupNameModal.jsx b/src/components/PeopleManagement/GroupDetailPage/EditGroupNameModal.jsx similarity index 95% rename from src/components/PeopleManagement/EditGroupNameModal.jsx rename to src/components/PeopleManagement/GroupDetailPage/EditGroupNameModal.jsx index ca43f78bd..b548cb22d 100644 --- a/src/components/PeopleManagement/EditGroupNameModal.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/EditGroupNameModal.jsx @@ -6,9 +6,9 @@ import { ActionRow, Form, ModalDialog, Spinner, StatefulButton, useToggle, } from '@openedx/paragon'; -import { MAX_LENGTH_GROUP_NAME } from './constants'; -import LmsApiService from '../../data/services/LmsApiService'; -import GeneralErrorModal from './GeneralErrorModal'; +import { MAX_LENGTH_GROUP_NAME } from '../constants'; +import LmsApiService from '../../../data/services/LmsApiService'; +import GeneralErrorModal from '../GeneralErrorModal'; const EditGroupNameModal = ({ group, isOpen, close, handleNameUpdate, diff --git a/src/components/PeopleManagement/GroupDetailPage.jsx b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx similarity index 96% rename from src/components/PeopleManagement/GroupDetailPage.jsx rename to src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx index d47f54303..84a0f2d78 100644 --- a/src/components/PeopleManagement/GroupDetailPage.jsx +++ b/src/components/PeopleManagement/GroupDetailPage/GroupDetailPage.jsx @@ -6,12 +6,12 @@ import { } from '@openedx/paragon'; import { Delete, Edit } from '@openedx/paragon/icons'; -import { useEnterpriseGroupLearnersTableData, useEnterpriseGroupUuid } from '../learner-credit-management/data'; -import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; +import { useEnterpriseGroupLearnersTableData, useEnterpriseGroupUuid } from '../data/hooks'; +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(); diff --git a/src/components/PeopleManagement/OrgMemberCard.jsx b/src/components/PeopleManagement/OrgMemberCard.jsx new file mode 100644 index 000000000..6ae9e4804 --- /dev/null +++ b/src/components/PeopleManagement/OrgMemberCard.jsx @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; + +import { + Avatar, Card, Col, Row, +} from '@openedx/paragon'; + +const OrgMemberCard = ({ original }) => { + const { enterpriseCustomerUser, enrollments } = original; + const { name, joinedOrg, email } = enterpriseCustomerUser; + + return ( + + + + + + + + + +

{name}

+
+ +

{email}

+
+ + +
Joined org
+ {joinedOrg} + + +
Enrollments
+ {enrollments} + +
+
+
+
+ ); +}; + +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 new file mode 100644 index 000000000..62d0d5745 --- /dev/null +++ b/src/components/PeopleManagement/PeopleManagementTable.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { CardView, DataTable } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +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 FilterStatus = (rest) => ; + +const PeopleManagementTable = ({ enterpriseId }) => { + const { + isLoading: isTableLoading, + enterpriseMembersTableData, + fetchEnterpriseMembersTableData, + } = useEnterpriseMembersTableData({ enterpriseId }); + + const tableColumns = [{ Header: 'Name', accessor: 'name' }]; + + return ( + + + + + + ); +}; + +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 2c8a9de50..ef2d298c3 100644 --- a/src/components/PeopleManagement/constants.js +++ b/src/components/PeopleManagement/constants.js @@ -2,7 +2,15 @@ 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 + +// 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/index.js b/src/components/PeopleManagement/data/hooks/index.js new file mode 100644 index 000000000..04bcc3b90 --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/index.js @@ -0,0 +1,3 @@ +export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid'; +export { default as useEnterpriseGroupLearnersTableData } from './useEnterpriseGroupLearnersTableData'; +export { default as useEnterpriseMembersTableData } from './useEnterpriseMembersTableData'; diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js similarity index 100% rename from src/components/learner-credit-management/data/hooks/useEnterpriseGroupLearnersTableData.js rename to src/components/PeopleManagement/data/hooks/useEnterpriseGroupLearnersTableData.js diff --git a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js similarity index 89% rename from src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js rename to src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js index c2a3dc91e..a8e97e495 100644 --- a/src/components/learner-credit-management/data/hooks/useEnterpriseGroupUuid.js +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseGroupUuid.js @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { learnerCreditManagementQueryKeys } from '../constants'; +import { learnerCreditManagementQueryKeys } from '../../../learner-credit-management/data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; /** diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js new file mode 100644 index 000000000..d2bccdc74 --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseMembersTableData.js @@ -0,0 +1,62 @@ +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); + } + }; + if (args.filters.length && args.filters[0].value.length > 2) { + fetch(); + } else if (!args.filters.length) { + 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 27e8c8858..7cf661418 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(); @@ -78,16 +79,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 e00d2fbd9..d93a06344 100644 --- a/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx +++ b/src/components/PeopleManagement/tests/GroupDetailPage.test.jsx @@ -8,8 +8,8 @@ import { Provider } from 'react-redux'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../../learner-credit-management/data'; -import GroupDetailPage from '../GroupDetailPage'; +import { useEnterpriseGroupUuid, useEnterpriseGroupLearnersTableData } from '../data/hooks'; +import GroupDetailPage from '../GroupDetailPage/GroupDetailPage'; import LmsApiService from '../../../data/services/LmsApiService'; const TEST_ENTERPRISE_SLUG = 'test-enterprise'; @@ -23,8 +23,8 @@ const TEST_GROUP = { const mockStore = configureMockStore([thunk]); const getMockStore = store => mockStore(store); -jest.mock('../../learner-credit-management/data', () => ({ - ...jest.requireActual('../../learner-credit-management/data'), +jest.mock('../data/hooks', () => ({ + ...jest.requireActual('../data/hooks'), useEnterpriseGroupUuid: jest.fn(), useEnterpriseGroupLearnersTableData: jest.fn(), })); diff --git a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx b/src/components/PeopleManagement/tests/useEnterpriseGroupLearnersTableData.test.jsx similarity index 92% rename from src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx rename to src/components/PeopleManagement/tests/useEnterpriseGroupLearnersTableData.test.jsx index ec49d431e..a8b923090 100644 --- a/src/components/learner-credit-management/data/hooks/tests/useEnterpriseGroupLearnersTableData.test.jsx +++ b/src/components/PeopleManagement/tests/useEnterpriseGroupLearnersTableData.test.jsx @@ -1,7 +1,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { camelCaseObject } from '@edx/frontend-platform/utils'; -import LmsApiService from '../../../../../data/services/LmsApiService'; -import { useEnterpriseGroupLearnersTableData } from '../..'; +import LmsApiService from '../../../data/services/LmsApiService'; +import { useEnterpriseGroupLearnersTableData } from '../data/hooks'; describe('useEnterpriseGroupLearnersTableData', () => { it('should fetch and return enterprise learners', async () => { 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/components/learner-credit-management/data/hooks/index.js b/src/components/learner-credit-management/data/hooks/index.js index cdd1006dd..da1e448ab 100644 --- a/src/components/learner-credit-management/data/hooks/index.js +++ b/src/components/learner-credit-management/data/hooks/index.js @@ -17,12 +17,10 @@ export { default as useEnterpriseGroupLearners } from './useEnterpriseGroupLearn export { default as useEnterpriseGroupMembersTableData } from './useEnterpriseGroupMembersTableData'; export { default as useEnterpriseCustomer } from './useEnterpriseCustomer'; export { default as useEnterpriseGroup } from './useEnterpriseGroup'; -export { default as useEnterpriseGroupUuid } from './useEnterpriseGroupUuid'; export { default as useAllEnterpriseGroups } from './useAllEnterpriseGroups'; 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'; export { default as useCatalogContainsContentItemsMultipleQueries } from './useCatalogContainsContentItemsMultipleQueries'; diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 40a388e1a..3ac99b3c8 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/`; @@ -353,6 +355,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