diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..765ce6bcb1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Grant all Enterprise/B2B squads merge permission to entire repo. +* @edx/enterprise-titans @edx/enterprise-markhors @edx/enterprise-sunrise @edx/enterprise-lakshy diff --git a/src/components/PeopleManagement/AdminActionsMenu.jsx b/src/components/PeopleManagement/AdminActionsMenu.jsx new file mode 100644 index 0000000000..c68302eec6 --- /dev/null +++ b/src/components/PeopleManagement/AdminActionsMenu.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, IconButton, Icon } from '@openedx/paragon'; +import { MoreVert, RemoveCircle, ContentCopy } from '@openedx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const AdminActionsMenu = ({ onRemove, onCopy }) => ( + + + + + + + + + + + + + + + +); + +AdminActionsMenu.propTypes = { + onRemove: PropTypes.func.isRequired, + onCopy: PropTypes.func.isRequired, +}; + +export default AdminActionsMenu; diff --git a/src/components/PeopleManagement/InviteAdminsTable.jsx b/src/components/PeopleManagement/InviteAdminsTable.jsx new file mode 100644 index 0000000000..9394da430f --- /dev/null +++ b/src/components/PeopleManagement/InviteAdminsTable.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { CardView, DataTable } from '@openedx/paragon'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import TableTextFilter from '../learner-credit-management/TableTextFilter'; +import CustomDataTableEmptyState from '../learner-credit-management/CustomDataTableEmptyState'; +import OrgInviteAdminCard from './OrgInviteAdminCard'; +import useEnterpriseAdminsTableData from './data/hooks/useEnterpriseAdminsTableData'; + +const FilterStatus = (rest) => ( + +); +const InviteAdminsTable = ({ enterpriseId }) => { + const { + isLoading: isTableLoading, + enterpriseAdminsTableData, + fetchEnterpriseAdminsTableData, + // fetchAllEnterpriseAdminsData, + } = useEnterpriseAdminsTableData({ enterpriseId }); + + const tableColumns = [ + { Header: 'admin details', accessor: 'name' }, + ]; + + return ( + <> + {/* ================= Header ================= */} +

+ +

+

+ +

+ + {/* ================= Table ================= */} + + + + + + + ); +}; + +InviteAdminsTable.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = (state) => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(InviteAdminsTable); diff --git a/src/components/PeopleManagement/OrgInviteAdminCard.jsx b/src/components/PeopleManagement/OrgInviteAdminCard.jsx new file mode 100644 index 0000000000..68cbcf4891 --- /dev/null +++ b/src/components/PeopleManagement/OrgInviteAdminCard.jsx @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import { + Avatar, Card, Col, Row, +} from '@openedx/paragon'; + +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import AdminActionsMenu from './AdminActionsMenu'; + +const OrgInviteAdminCard = ({ + original, onRemoveAdmin, + onCopyInviteLink, +}) => { + const { enterpriseCustomerUser, inviteLink } = original; + const { + name, joinedOrg, email, role, + } = enterpriseCustomerUser; + + return ( + + + + + + + + + +

{name}

+
+ +

{email}

+
+ + +
+ +
+ {joinedOrg} + + +
+ +
+ {role} + +
+ onRemoveAdmin(original)} + onCopy={() => onCopyInviteLink(inviteLink)} + /> +
+ +
+
+
+
+ ); +}; + +OrgInviteAdminCard.propTypes = { + original: PropTypes.shape({ + enterpriseCustomerUser: PropTypes.shape({ + userId: PropTypes.number.isRequired, + email: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + joinedOrg: PropTypes.string.isRequired, + role: PropTypes.string.isRequired, + }).isRequired, + inviteLink: PropTypes.string, + }).isRequired, + onRemoveAdmin: PropTypes.func.isRequired, + onCopyInviteLink: PropTypes.func.isRequired, +}; + +export default OrgInviteAdminCard; diff --git a/src/components/PeopleManagement/data/hooks/useEnterpriseAdminsTableData.js b/src/components/PeopleManagement/data/hooks/useEnterpriseAdminsTableData.js new file mode 100644 index 0000000000..303cf6c8a3 --- /dev/null +++ b/src/components/PeopleManagement/data/hooks/useEnterpriseAdminsTableData.js @@ -0,0 +1,76 @@ +import { useCallback, useMemo, useState } from 'react'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { logError } from '@edx/frontend-platform/logging'; +import { debounce, snakeCase } from 'lodash-es'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +const useEnterpriseAdminsTableData = ({ enterpriseId }) => { + const [isLoading, setIsLoading] = useState(true); + const [enterpriseAdminsTableData, setEnterpriseAdminsTableData] = useState({ + itemCount: 0, + pageCount: 0, + results: [], + }); + /** Download records */ + // const fetchAllEnterpriseAdminsData = useCallback(async () => { + // const { options, itemCount } = enterpriseAdminsTableData; + // // Take the existing filters but specify we're taking all results on one page + // const fetchAllOptions = { ...options, page: 1, page_size: itemCount }; + // const response = await LmsApiService.fetchEnterpriseCustomerMembers(enterpriseId, fetchAllOptions); + // return camelCaseObject(response.data); + // }, [enterpriseId, enterpriseAdminsTableData]); + + const fetchEnterpriseAdminsData = 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; + } + }); + 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; + } + } + options.page = args.pageIndex + 1; + const response = await LmsApiService.fetchEnterpriseAdminMembers(enterpriseId, options); + const data = camelCaseObject(response.data); + setEnterpriseAdminsTableData({ + itemCount: data.count, + pageCount: data.numPages ?? Math.ceil(data.count / options.pageSize), + results: data.results, + options, + }); + } 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 debouncedFetchEnterpriseAdminsData = useMemo( + () => debounce(fetchEnterpriseAdminsData, 300), + [fetchEnterpriseAdminsData], + ); + + return { + isLoading, + enterpriseAdminsTableData, + fetchEnterpriseAdminsTableData: debouncedFetchEnterpriseAdminsData, + // fetchAllEnterpriseAdminsData, + }; +}; + +export default useEnterpriseAdminsTableData; diff --git a/src/components/PeopleManagement/index.jsx b/src/components/PeopleManagement/index.jsx index 3ab89a835e..888c63142a 100644 --- a/src/components/PeopleManagement/index.jsx +++ b/src/components/PeopleManagement/index.jsx @@ -5,10 +5,10 @@ import PropTypes from 'prop-types'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, Skeleton, Toast, useToggle, + Tabs, Tab, } from '@openedx/paragon'; import { Add } from '@openedx/paragon/icons'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; - import Hero from '../Hero'; import { SUBSIDY_TYPES } from '../../data/constants/subsidyTypes'; import { EnterpriseSubsidiesContext } from '../EnterpriseSubsidiesContext'; @@ -21,8 +21,9 @@ import EVENT_NAMES from '../../eventTracking'; import ValidatedEmailsContextProvider from './data/ValidatedEmailsContextProvider'; import GroupInviteErrorToast from './GroupInviteErrorToast'; import { ORGANIZE_LEARNER_TARGETS } from '../ProductTours/AdminOnboardingTours/constants'; +import InviteAdminsTable from './InviteAdminsTable'; -const PeopleManagementPage = ({ enterpriseId }) => { +const PeopleManagementPage = ({ enterpriseId, adminsTabEnabled }) => { const intl = useIntl(); const PAGE_TITLE = intl.formatMessage({ id: 'admin.portal.people.management.page', @@ -50,6 +51,7 @@ const PeopleManagementPage = ({ enterpriseId }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isModalOpen, openModal, closeModal] = useToggle(false); const [groups, setGroups] = useState(); + const [activeTab, setActiveTab] = useState('learners'); useEffect(() => { if (data !== undefined) { @@ -107,84 +109,114 @@ const PeopleManagementPage = ({ enterpriseId }) => { closeToast={closeGroupInviteErrorModal} />
- - - - -

+ setActiveTab(key)} + > + {/* ================= Learner Tab ================= */} + + + + + +

+ +

+
+ {hasLearnerCredit && ( + + )} + {!hasLearnerCredit && hasOtherSubsidyTypes && ( + + )} +
+ + + + -

-
- {hasLearnerCredit && ( + +
+ {groupsCardSection} +
+

- )} - {!hasLearnerCredit && hasOtherSubsidyTypes && ( +

+

- )} +

+ + - - - - - - - {groupsCardSection} - -

- -

-

- -

- - - + + + {/* ================= Admin Tab ================= */} + {adminsTabEnabled && ( + + + + )} +
+ ); }; const mapStateToProps = (state) => ({ enterpriseId: state.portalConfiguration.enterpriseId, + adminsTabEnabled: state.portalConfiguration.enterpriseFeatures?.enterprise_invite_admins_enabled, }); PeopleManagementPage.propTypes = { enterpriseId: PropTypes.string.isRequired, + adminsTabEnabled: PropTypes.bool.isRequired, }; export default connect(mapStateToProps)(PeopleManagementPage); diff --git a/src/components/PeopleManagement/tests/AdminActionsMenu.test.jsx b/src/components/PeopleManagement/tests/AdminActionsMenu.test.jsx new file mode 100644 index 0000000000..80df15d5d9 --- /dev/null +++ b/src/components/PeopleManagement/tests/AdminActionsMenu.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import AdminActionsMenu from '../AdminActionsMenu'; + +const renderWithIntl = (ui) => render( + + {ui} + , +); + +describe('AdminActionsMenu', () => { + const mockOnRemove = jest.fn(); + const mockOnCopy = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the dropdown menu', () => { + renderWithIntl( + , + ); + + expect(screen.getByTestId('admin-kabob-menu')).toBeInTheDocument(); + }); + + it('calls onRemove when the remove option is clicked', () => { + renderWithIntl( + , + ); + + fireEvent.click(screen.getByTestId('admin-kabob-menu')); + fireEvent.click(screen.getByText('Remove admin')); + + expect(mockOnRemove).toHaveBeenCalledTimes(1); + }); + + it('calls onCopy when the copy invite link option is clicked', () => { + renderWithIntl( + , + ); + + fireEvent.click(screen.getByTestId('admin-kabob-menu')); + fireEvent.click(screen.getByText('Copy invite link')); + + expect(mockOnCopy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/PeopleManagement/tests/InviteAdminsTable.test.jsx b/src/components/PeopleManagement/tests/InviteAdminsTable.test.jsx new file mode 100644 index 0000000000..212d4c5712 --- /dev/null +++ b/src/components/PeopleManagement/tests/InviteAdminsTable.test.jsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import InviteAdminsTable from '../InviteAdminsTable'; +import useEnterpriseAdminsTableData from '../data/hooks/useEnterpriseAdminsTableData'; + +/* ======================= + Mocks +======================= */ + +jest.mock('react-redux', () => ({ + connect: () => (Component) => Component, +})); + +jest.mock('../data/hooks/useEnterpriseAdminsTableData'); + +jest.mock('../OrgInviteAdminCard', () => function () { + return
Admin Card
; +}); + +jest.mock('@openedx/paragon', () => { + const actual = jest.requireActual('@openedx/paragon'); + + const MockDataTable = ({ children }) => ( +
{children}
+ ); + + MockDataTable.FilterStatus = actual.DataTable.FilterStatus; + MockDataTable.TableControlBar = function ({ children }) { + return
{children}
; + }; + MockDataTable.TableFooter = function ({ children }) { + return
{children}
; + }; + + return { + ...actual, + DataTable: MockDataTable, + CardView: ({ CardComponent }) => ( +
+ +
+ ), + }; +}); + +/* ======================= + Helpers +======================= */ + +const messages = { + 'adminPortal.peopleManagement.dataTable.title': + "Your organization's admins", + 'adminPortal.peopleManagement.dataTable.subtitle': + 'View all admins of your organization.', +}; + +const renderWithIntl = (ui) => render( + + {ui} + , +); + +describe('InviteAdminsTable', () => { + const defaultHookReturn = { + isLoading: false, + enterpriseAdminsTableData: { + results: [], + itemCount: 0, + pageCount: 0, + }, + fetchEnterpriseAdminsTableData: jest.fn(), + }; + + beforeEach(() => { + useEnterpriseAdminsTableData.mockReturnValue(defaultHookReturn); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders header and subtitle using intl messages', () => { + renderWithIntl(); + + expect( + screen.getByText("Your organization's admins"), + ).toBeInTheDocument(); + + expect( + screen.getByText('View all admins of your organization.'), + ).toBeInTheDocument(); + }); + + it('renders DataTable', () => { + renderWithIntl(); + + expect(screen.getByTestId('data-table')).toBeInTheDocument(); + }); + + it('renders loading state', () => { + useEnterpriseAdminsTableData.mockReturnValue({ + ...defaultHookReturn, + isLoading: true, + }); + + renderWithIntl(); + + expect(screen.getByTestId('data-table')).toBeInTheDocument(); + }); + + it('renders admin cards when data exists', () => { + useEnterpriseAdminsTableData.mockReturnValue({ + ...defaultHookReturn, + enterpriseAdminsTableData: { + results: [{ id: 1 }], + itemCount: 1, + pageCount: 1, + }, + }); + + renderWithIntl(); + + expect(screen.getByTestId('card-view')).toBeInTheDocument(); + expect(screen.getByTestId('admin-card')).toBeInTheDocument(); + }); + + it('renders empty table state when no data', () => { + renderWithIntl(); + + expect(screen.getByTestId('data-table')).toBeInTheDocument(); + }); +}); diff --git a/src/components/PeopleManagement/tests/OrgInviteAdminCard.test.jsx b/src/components/PeopleManagement/tests/OrgInviteAdminCard.test.jsx new file mode 100644 index 0000000000..b337a5cd50 --- /dev/null +++ b/src/components/PeopleManagement/tests/OrgInviteAdminCard.test.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import OrgInviteAdminCard from '../OrgInviteAdminCard'; + +/* ---------------- MOCK PARAGON ---------------- */ + +jest.mock('@openedx/paragon', () => { + const Card = ({ children }) =>
{children}
; + Card.Body = function CardBody({ children }) { + return
{children}
; + }; + Card.Section = function CardSection({ children }) { + return
{children}
; + }; + + return { + Avatar: () =>
, + Card, + Col: ({ children }) =>
{children}
, + Row: ({ children }) =>
{children}
, + }; +}); + +/* ------------- MOCK ACTION MENU --------------- */ + +jest.mock('../AdminActionsMenu', () => function AdminActionsMenuMock({ onRemove, onCopy }) { + return ( +
+ + +
+ ); +}); + +/* ---------------- TEST DATA ---------------- */ + +const mockOriginal = { + enterpriseCustomerUser: { + userId: 1, + name: 'John Doe', + email: 'john.doe@example.com', + joinedOrg: '2024-01-01', + role: 'Admin', + }, + inviteLink: 'https://invite.link', +}; + +const props = { + original: mockOriginal, + onRemoveAdmin: jest.fn(), + onCopyInviteLink: jest.fn(), +}; + +const renderWithIntl = (ui) => render( + + {ui} + , +); + +describe('OrgInviteAdminCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders admin details', () => { + renderWithIntl(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('john.doe@example.com')).toBeInTheDocument(); + expect(screen.getByText('Joined org')).toBeInTheDocument(); + expect(screen.getByText('2024-01-01')).toBeInTheDocument(); + expect(screen.getByText('Role')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('renders avatar', () => { + renderWithIntl(); + expect(screen.getByTestId('avatar')).toBeInTheDocument(); + }); + + it('renders admin actions menu', () => { + renderWithIntl(); + expect(screen.getByTestId('admin-actions-menu')).toBeInTheDocument(); + }); + + it('calls onRemoveAdmin when Remove is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByText('Remove')); + + expect(props.onRemoveAdmin).toHaveBeenCalledWith(mockOriginal); + }); + + it('calls onCopyInviteLink when Copy is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByText('Copy')); + + expect(props.onCopyInviteLink).toHaveBeenCalledWith( + mockOriginal.inviteLink, + ); + }); +}); diff --git a/src/components/PeopleManagement/tests/useEnterpriseAdminsTableData.test.js b/src/components/PeopleManagement/tests/useEnterpriseAdminsTableData.test.js new file mode 100644 index 0000000000..64303520a6 --- /dev/null +++ b/src/components/PeopleManagement/tests/useEnterpriseAdminsTableData.test.js @@ -0,0 +1,208 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { logError } from '@edx/frontend-platform/logging'; +import useEnterpriseAdminsTableData from '../data/hooks/useEnterpriseAdminsTableData'; +import LmsApiService from '../../../data/services/LmsApiService'; + +/* ---------------- MOCKS ---------------- */ + +jest.mock('lodash-es', () => ({ + ...jest.requireActual('lodash-es'), + debounce: (fn) => fn, +})); + +jest.mock('../../../data/services/LmsApiService'); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +/* ---------------- TESTS ---------------- */ + +describe('useEnterpriseAdminsTableData', () => { + const enterpriseId = 'test-enterprise-id'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('initializes with default state', () => { + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + expect(result.current.isLoading).toBe(true); + expect(result.current.enterpriseAdminsTableData).toEqual({ + itemCount: 0, + pageCount: 0, + results: [], + }); + }); + + it('fetches enterprise admins data successfully (no filters, no sort)', async () => { + const mockData = { + count: 1, + numPages: 1, + results: [ + { + id: 1, + name: 'Admin User', + email: 'admin@edx.com', + role: 'Admin', + }, + ], + }; + + LmsApiService.fetchEnterpriseAdminMembers.mockResolvedValueOnce({ + data: mockData, + }); + + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + await act(async () => { + result.current.fetchEnterpriseAdminsTableData({ + filters: [], + sortBy: [], + pageIndex: 0, + }); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.enterpriseAdminsTableData.results).toHaveLength(1); + expect(result.current.enterpriseAdminsTableData.results[0].name) + .toBe('Admin User'); + }); + + it('applies name filter when value length > 2', async () => { + LmsApiService.fetchEnterpriseAdminMembers.mockResolvedValueOnce({ + data: { count: 0, numPages: 0, results: [] }, + }); + + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + await act(async () => { + result.current.fetchEnterpriseAdminsTableData({ + filters: [{ id: 'name', value: 'Admin' }], + sortBy: [], + pageIndex: 0, + }); + }); + + expect(LmsApiService.fetchEnterpriseAdminMembers).toHaveBeenCalledWith( + enterpriseId, + expect.objectContaining({ + user_query: 'Admin', + }), + ); + }); + + it('does NOT fetch when name filter length <= 2', async () => { + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + await act(async () => { + result.current.fetchEnterpriseAdminsTableData({ + filters: [{ id: 'name', value: 'ab' }], + sortBy: [], + pageIndex: 0, + }); + }); + + expect(LmsApiService.fetchEnterpriseAdminMembers).not.toHaveBeenCalled(); + }); + + it('applies sorting when sortBy is provided (desc = false)', async () => { + LmsApiService.fetchEnterpriseAdminMembers.mockResolvedValueOnce({ + data: { count: 0, numPages: 0, results: [] }, + }); + + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + await act(async () => { + result.current.fetchEnterpriseAdminsTableData({ + filters: [], + sortBy: [{ id: 'email', desc: false }], + pageIndex: 0, + }); + }); + + expect(LmsApiService.fetchEnterpriseAdminMembers).toHaveBeenCalledWith( + enterpriseId, + expect.objectContaining({ + sort_by: 'email', + is_reversed: true, + }), + ); + }); + + it('applies sorting when sortBy is provided (desc = true)', async () => { + LmsApiService.fetchEnterpriseAdminMembers.mockResolvedValueOnce({ + data: { count: 0, numPages: 0, results: [] }, + }); + + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + await act(async () => { + result.current.fetchEnterpriseAdminsTableData({ + filters: [], + sortBy: [{ id: 'name', desc: true }], + pageIndex: 0, + }); + }); + + expect(LmsApiService.fetchEnterpriseAdminMembers).toHaveBeenCalledWith( + enterpriseId, + expect.objectContaining({ + sort_by: 'name', + }), + ); + }); + + it('calculates pageCount when numPages is missing', async () => { + LmsApiService.fetchEnterpriseAdminMembers.mockResolvedValueOnce({ + data: { + count: 10, + results: [{}], + }, + }); + + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + await act(async () => { + result.current.fetchEnterpriseAdminsTableData({ + filters: [], + sortBy: [], + pageIndex: 0, + }); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect( + Number.isNaN(result.current.enterpriseAdminsTableData.pageCount), + ).toBe(true); + }); + + it('logs error when API call fails', async () => { + LmsApiService.fetchEnterpriseAdminMembers.mockRejectedValueOnce( + new Error('API failed'), + ); + + const { result } = renderHook(() => useEnterpriseAdminsTableData({ enterpriseId })); + + await act(async () => { + result.current.fetchEnterpriseAdminsTableData({ + filters: [], + sortBy: [], + pageIndex: 0, + }); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(logError).toHaveBeenCalled(); + }); +}); diff --git a/src/data/services/LmsApiService.ts b/src/data/services/LmsApiService.ts index b107566410..31d2abc608 100644 --- a/src/data/services/LmsApiService.ts +++ b/src/data/services/LmsApiService.ts @@ -61,6 +61,8 @@ export interface EnterpriseAdminResponse { data: EnterpriseAdminPayload; } +export type EnterpriseAdminMemberListResponse = Promise>>; + class LmsApiService { static apiClient = getAuthenticatedHttpClient; @@ -110,6 +112,8 @@ class LmsApiService { static loginRefreshUrl = `${LmsApiService.baseUrl}/login_refresh`; + static enterpriseAdminMembersUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-admin-members/`; + static async createEnterpriseGroup( { groupName, @@ -646,6 +650,15 @@ class LmsApiService { const response = await LmsApiService.apiClient().post(url); return camelCaseObject(response.data); }; + + static fetchEnterpriseAdminMembers(enterpriseUUID: string, options: any) : EnterpriseAdminMemberListResponse { + let url = `${LmsApiService.enterpriseAdminMembersUrl}${enterpriseUUID}/`; + if (options) { + const queryParams = new URLSearchParams(options); + url = `${LmsApiService.enterpriseAdminMembersUrl}${enterpriseUUID}?${queryParams.toString()}`; + } + return LmsApiService.apiClient().get(url, options); + } } export default LmsApiService; diff --git a/src/data/services/tests/LmsApiService.test.js b/src/data/services/tests/LmsApiService.test.js index 4f72f5c985..222124b6c0 100644 --- a/src/data/services/tests/LmsApiService.test.js +++ b/src/data/services/tests/LmsApiService.test.js @@ -233,4 +233,32 @@ describe('LmsApiService', () => { ); expect(response).toEqual(mockPayload); }); + test('fetchEnterpriseAdminMembers calls the LMS to fetch enterprise admin members', () => { + const enterpriseUUID = 'test-enterprise-id'; + + axios.get.mockReturnValue({ + data: { + results: [ + { + id: 1, + name: 'Admin User', + email: 'admin@edx.com', + }, + ], + }, + }); + + LmsApiService.fetchEnterpriseAdminMembers(enterpriseUUID, { + page: 1, + page_size: 10, + }); + + expect(axios.get).toBeCalledWith( + `${lmsBaseUrl}/enterprise/api/v1/enterprise-admin-members/${enterpriseUUID}?page=1&page_size=10`, + { + page: 1, + page_size: 10, + }, + ); + }); }); diff --git a/src/types.d.ts b/src/types.d.ts index 5992db4441..12ef3da87c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -196,6 +196,15 @@ declare global { }, }; + type EnterpriseAdminMember = { + enterprise_admin_user: { + user_id: string + email: string + joined_org: string + name: string + role: string + } + }; } export {};