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 {};