diff --git a/.env b/.env
index 73b56bd67..17e8b810b 100644
--- a/.env
+++ b/.env
@@ -4,6 +4,8 @@ BASE_URL=null
FEATURE_CONFIGURATION_MANAGEMENT=''
FEATURE_CONFIGURATION_ENTERPRISE_PROVISION=''
FEATURE_CONFIGURATION_EDIT_ENTERPRISE_PROVISION=''
+FEATURE_CUSTOMER_SUPPORT_VIEW=''
+ADMIN_PORTAL_BASE_URL=''
COMMERCE_COORDINATOR_ORDER_DETAILS_URL=''
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
diff --git a/.env.development b/.env.development
index 22d16951f..4971d8d93 100644
--- a/.env.development
+++ b/.env.development
@@ -5,6 +5,8 @@ BASE_URL='http://localhost:18450'
FEATURE_CONFIGURATION_MANAGEMENT='true'
FEATURE_CONFIGURATION_ENTERPRISE_PROVISION='true'
FEATURE_CONFIGURATION_EDIT_ENTERPRISE_PROVISION='true'
+FEATURE_CUSTOMER_SUPPORT_VIEW='true'
+ADMIN_PORTAL_BASE_URL='http://localhost:1991'
COMMERCE_COORDINATOR_ORDER_DETAILS_URL='http://localhost:8140/lms/order_details_page_redirect'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
diff --git a/src/Configuration/Customers/CustomerDataTable/CustomerDetailSubComponent.jsx b/src/Configuration/Customers/CustomerDataTable/CustomerDetailSubComponent.jsx
new file mode 100644
index 000000000..a6656a0a5
--- /dev/null
+++ b/src/Configuration/Customers/CustomerDataTable/CustomerDetailSubComponent.jsx
@@ -0,0 +1,124 @@
+import PropTypes from 'prop-types';
+import {
+ DataTable, Icon, OverlayTrigger, Stack, Tooltip,
+} from '@edx/paragon';
+import { Check, InfoOutline } from '@edx/paragon/icons';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import useActiveAssociatedPlans from '../data/hooks/useActiveAssociatedPlans';
+
+const SubscriptionCheckIcon = ({ row }) => {
+ if (row.original.hasActiveAgreements) {
+ return ;
+ }
+ return null;
+};
+
+const PolicyCheckIcon = ({ row }) => {
+ if (row.original.hasActivePolicies) {
+ return ;
+ }
+ return null;
+};
+
+const OtherSubsidiesCheckIcon = ({ row }) => {
+ if (row.original.hasActiveOtherSubsidies) {
+ return ;
+ }
+ return null;
+};
+
+export const OtherSubsidies = () => (
+
+
+
+
+
+
+
+
+
+ )}
+ >
+
+
+
+);
+
+const CustomerDetailRowSubComponent = ({ row }) => {
+ const enterpriseId = row.original.uuid;
+ const { data, isLoading } = useActiveAssociatedPlans(enterpriseId);
+ return (
+
+
+
+
+
+ );
+};
+
+CustomerDetailRowSubComponent.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ uuid: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+SubscriptionCheckIcon.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ hasActiveAgreements: PropTypes.bool,
+ }),
+ }).isRequired,
+};
+
+PolicyCheckIcon.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ hasActivePolicies: PropTypes.bool,
+ }),
+ }).isRequired,
+};
+
+OtherSubsidiesCheckIcon.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ hasActiveOtherSubsidies: PropTypes.bool,
+ }),
+ }).isRequired,
+};
+
+export default CustomerDetailRowSubComponent;
diff --git a/src/Configuration/Customers/CustomerDataTable/CustomerDetails.jsx b/src/Configuration/Customers/CustomerDataTable/CustomerDetails.jsx
new file mode 100644
index 000000000..f8fd4c5c9
--- /dev/null
+++ b/src/Configuration/Customers/CustomerDataTable/CustomerDetails.jsx
@@ -0,0 +1,140 @@
+import { useState } from 'react';
+import {
+ Hyperlink,
+ Icon,
+ Toast,
+} from '@edx/paragon';
+import { getConfig } from '@edx/frontend-platform';
+import { Check, ContentCopy } from '@edx/paragon/icons';
+import PropTypes from 'prop-types';
+import ROUTES from '../../../data/constants/routes';
+
+const { HOME } = ROUTES.CONFIGURATION.SUB_DIRECTORY.CUSTOMERS;
+
+export const CustomerDetailLink = ({ row }) => {
+ const [showToast, setShowToast] = useState(false);
+ const copyToClipboard = (id) => {
+ navigator.clipboard.writeText(id);
+ setShowToast(true);
+ };
+ const { ADMIN_PORTAL_BASE_URL } = getConfig();
+
+ return (
+
+
+
+ {row.original.name}
+
+
+
+
+ /{row.original.slug}/
+
+
+
{row.original.uuid}
+
copyToClipboard(row.original.uuid)}
+ />
+
+
+
setShowToast(false)}
+ show={showToast}
+ delay={2000}
+ >
+ Copied to clipboard!
+
+
+ );
+};
+
+export const LmsCheck = ({ row }) => {
+ if (row.original.activeIntegrations.length) {
+ return (
+
+ );
+ }
+ return null;
+};
+
+export const SSOCheck = ({ row }) => {
+ if (row.original.activeSsoConfigurations.length) {
+ return (
+
+ );
+ }
+ return null;
+};
+
+export const ApiCheck = ({ row }) => {
+ if (row.original.enableGenerationOfApiCredentials) {
+ return (
+
+ );
+ }
+ return null;
+};
+
+LmsCheck.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ activeIntegrations: PropTypes.arrayOf(PropTypes.shape({
+ channelCode: PropTypes.string,
+ created: PropTypes.string,
+ modified: PropTypes.string,
+ displayName: PropTypes.string,
+ active: PropTypes.bool,
+ })),
+ }),
+ }).isRequired,
+};
+
+SSOCheck.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ activeSsoConfigurations: PropTypes.arrayOf(PropTypes.shape({
+ created: PropTypes.string,
+ modified: PropTypes.string,
+ active: PropTypes.bool,
+ displayName: PropTypes.string,
+ })),
+ }),
+ }).isRequired,
+};
+
+ApiCheck.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ enableGenerationOfApiCredentials: PropTypes.bool,
+ }),
+ }).isRequired,
+};
+
+CustomerDetailLink.propTypes = {
+ row: PropTypes.shape({
+ original: PropTypes.shape({
+ name: PropTypes.string,
+ uuid: PropTypes.string,
+ slug: PropTypes.string,
+ }),
+ }).isRequired,
+};
diff --git a/src/Configuration/Customers/CustomerDataTable/CustomersPage.jsx b/src/Configuration/Customers/CustomerDataTable/CustomersPage.jsx
new file mode 100644
index 000000000..738f9cbdb
--- /dev/null
+++ b/src/Configuration/Customers/CustomerDataTable/CustomersPage.jsx
@@ -0,0 +1,108 @@
+import React, {
+ useMemo,
+ useState,
+ useCallback,
+ useEffect,
+} from 'react';
+import debounce from 'lodash.debounce';
+import {
+ Container, DataTable, TextFilter,
+} from '@edx/paragon';
+import { camelCaseObject } from '@edx/frontend-platform';
+import { logError } from '@edx/frontend-platform/logging';
+
+import {
+ CustomerDetailLink,
+ SSOCheck,
+ LmsCheck,
+ ApiCheck,
+} from './CustomerDetails';
+import LmsApiService from '../../../data/services/EnterpriseApiService';
+import CustomerDetailRowSubComponent from './CustomerDetailSubComponent';
+
+const CustomersPage = () => {
+ const [enterpriseList, setEnterpriseList] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const fetchData = useCallback(
+ async () => {
+ try {
+ const { data } = await LmsApiService.fetchEnterpriseCustomerSupportTool();
+ const result = camelCaseObject(data);
+ setEnterpriseList(result);
+ } catch (error) {
+ logError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [],
+ );
+
+ const debouncedFetchData = useMemo(() => debounce(
+ fetchData,
+ 300,
+ ), [fetchData]);
+
+ useEffect(() => {
+ debouncedFetchData();
+ }, [debouncedFetchData]);
+
+ return (
+
+ Customers
+
+ }
+ isPaginated
+ isSortable
+ isFilterable
+ defaultColumnValues={{ Filter: TextFilter }}
+ itemCount={enterpriseList?.length || 0}
+ data={enterpriseList || []}
+ columns={[
+ {
+ id: 'expander',
+ Header: DataTable.ExpandAll,
+ Cell: DataTable.ExpandRow,
+ },
+ {
+ id: 'customer details',
+ Header: 'Customer details',
+ accessor: 'name',
+ Cell: CustomerDetailLink,
+ },
+ {
+ id: 'sso',
+ Header: 'SSO',
+ accessor: 'activeSsoConfigurations',
+ disableFilters: true,
+ Cell: SSOCheck,
+ },
+ {
+ id: 'lms',
+ Header: 'LMS',
+ accessor: 'activeIntegrations',
+ disableFilters: true,
+ Cell: LmsCheck,
+ },
+ {
+ id: 'api',
+ Header: 'API',
+ accessor: 'enableGenerationOfApiCredentials',
+ disableFilters: true,
+ Cell: ApiCheck,
+ },
+ ]}
+ />
+
+
+ );
+};
+
+export default CustomersPage;
diff --git a/src/Configuration/Customers/CustomerDataTable/index.js b/src/Configuration/Customers/CustomerDataTable/index.js
new file mode 100644
index 000000000..80d513033
--- /dev/null
+++ b/src/Configuration/Customers/CustomerDataTable/index.js
@@ -0,0 +1,3 @@
+import CustomersPage from './CustomersPage';
+
+export default CustomersPage;
diff --git a/src/Configuration/Customers/CustomerDataTable/tests/CustomerDetailSubComponent.test.jsx b/src/Configuration/Customers/CustomerDataTable/tests/CustomerDetailSubComponent.test.jsx
new file mode 100644
index 000000000..782a952b5
--- /dev/null
+++ b/src/Configuration/Customers/CustomerDataTable/tests/CustomerDetailSubComponent.test.jsx
@@ -0,0 +1,116 @@
+/* eslint-disable react/prop-types */
+import { screen, render } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import useActiveAssociatedPlans from '../../data/hooks/useActiveAssociatedPlans';
+import CustomerDetailRowSubComponent from '../CustomerDetailSubComponent';
+
+jest.mock('../../data/hooks/useActiveAssociatedPlans');
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+
+jest.mock('@edx/frontend-platform', () => ({
+ ...jest.requireActual('@edx/frontend-platform'),
+ getConfig: jest.fn(() => ({
+ ECOMMERCE_BASE_URL: 'www.ecommerce.com',
+ })),
+}));
+
+describe('CustomerDetailRowSubComponent', () => {
+ const row = {
+ original: {
+ uuid: '123456789',
+ },
+ };
+
+ it('renders row with every checkmark', () => {
+ useActiveAssociatedPlans.mockReturnValue({
+ isLoading: false,
+ data: {
+ hasActiveAgreements: true,
+ hasActivePolicies: true,
+ hasActiveOtherSubsidies: true,
+ },
+ });
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Subscription')).toBeInTheDocument();
+ expect(screen.getByText('Learner Credit')).toBeInTheDocument();
+ expect(screen.getByText('Other Subsidies')).toBeInTheDocument();
+ expect(screen.getByText('subscription check')).toBeInTheDocument();
+ expect(screen.getByText('policy check')).toBeInTheDocument();
+ expect(screen.getByText('other subsidies check')).toBeInTheDocument();
+ });
+
+ it('does not render check mark for subscriptions', () => {
+ useActiveAssociatedPlans.mockReturnValue({
+ isLoading: false,
+ data: {
+ hasActiveAgreements: false,
+ hasActivePolicies: true,
+ hasActiveOtherSubsidies: true,
+ },
+ });
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Subscription')).toBeInTheDocument();
+ expect(screen.getByText('Learner Credit')).toBeInTheDocument();
+ expect(screen.getByText('Other Subsidies')).toBeInTheDocument();
+ expect(screen.queryByText('subscription check')).not.toBeInTheDocument();
+ expect(screen.getByText('policy check')).toBeInTheDocument();
+ expect(screen.getByText('other subsidies check')).toBeInTheDocument();
+ });
+
+ it('does not render check mark for policies', () => {
+ useActiveAssociatedPlans.mockReturnValue({
+ isLoading: false,
+ data: {
+ hasActiveAgreements: true,
+ hasActivePolicies: false,
+ hasActiveOtherSubsidies: true,
+ },
+ });
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Subscription')).toBeInTheDocument();
+ expect(screen.getByText('Learner Credit')).toBeInTheDocument();
+ expect(screen.getByText('Other Subsidies')).toBeInTheDocument();
+ expect(screen.queryByText('policy check')).not.toBeInTheDocument();
+ expect(screen.getByText('subscription check')).toBeInTheDocument();
+ expect(screen.getByText('other subsidies check')).toBeInTheDocument();
+ });
+
+ it('does not render check mark for other subsidies', () => {
+ useActiveAssociatedPlans.mockReturnValue({
+ isLoading: false,
+ data: {
+ hasActiveAgreements: true,
+ hasActivePolicies: true,
+ hasActiveOtherSubsidies: false,
+ },
+ });
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Subscription')).toBeInTheDocument();
+ expect(screen.getByText('Learner Credit')).toBeInTheDocument();
+ expect(screen.getByText('Other Subsidies')).toBeInTheDocument();
+ expect(screen.queryByText('other subsidies check')).not.toBeInTheDocument();
+ expect(screen.getByText('subscription check')).toBeInTheDocument();
+ expect(screen.getByText('policy check')).toBeInTheDocument();
+ });
+});
diff --git a/src/Configuration/Customers/CustomerDataTable/tests/CustomerDetails.test.jsx b/src/Configuration/Customers/CustomerDataTable/tests/CustomerDetails.test.jsx
new file mode 100644
index 000000000..94b31888b
--- /dev/null
+++ b/src/Configuration/Customers/CustomerDataTable/tests/CustomerDetails.test.jsx
@@ -0,0 +1,117 @@
+/* eslint-disable react/prop-types */
+import {
+ screen,
+ render,
+ waitFor,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+
+import { getConfig } from '@edx/frontend-platform';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import {
+ ApiCheck,
+ CustomerDetailLink,
+ LmsCheck,
+ SSOCheck,
+} from '../CustomerDetails';
+
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+
+Object.assign(navigator, {
+ clipboard: {
+ writeText: () => {},
+ },
+});
+
+describe('CustomerDetails', () => {
+ const row = {
+ original: {
+ uuid: '123456789',
+ slug: 'ash-ketchum',
+ name: 'Ash Ketchum',
+ activeIntegrations: [{
+ channelCode: 'test-channel',
+ created: 'jan 1, 1992',
+ modified: 'jan 2, 1992',
+ displayName: 'test channel',
+ active: true,
+ }],
+ activeSsoConfigurations: [{
+ created: 'jan 1, 1992',
+ modified: 'jan 2, 1992',
+ displayName: 'test channel',
+ active: true,
+ }],
+ enableGenerationOfApiCredentials: true,
+ },
+ };
+
+ it('renders LmsCheck when there are active integrations', () => {
+ render();
+ expect(screen.getByText('Lms Check')).toBeInTheDocument();
+ });
+
+ it('does not render LmsCheck when there are no active integrations', () => {
+ const noActiveIntegration = {
+ original: {
+ ...row.original,
+ activeIntegrations: [],
+ },
+ };
+ render();
+ expect(screen.queryByText('Lms Check')).not.toBeInTheDocument();
+ });
+
+ it('renders SSOCheck when there are active integrations', () => {
+ render();
+ expect(screen.getByText('SSO Check')).toBeInTheDocument();
+ });
+
+ it('does not render SSOCheck when there are no active integrations', () => {
+ const noActiveIntegration = {
+ original: {
+ ...row.original,
+ activeSsoConfigurations: [],
+ },
+ };
+ render();
+ expect(screen.queryByText('SSO Check')).not.toBeInTheDocument();
+ });
+
+ it('renders ApiCheck when there are active integrations', () => {
+ render();
+ expect(screen.getByText('API Check')).toBeInTheDocument();
+ });
+
+ it('does not render ApiCheck when there are no active integrations', () => {
+ const noActiveIntegration = {
+ original: {
+ ...row.original,
+ enableGenerationOfApiCredentials: false,
+ },
+ };
+ render();
+ expect(screen.queryByText('API Check')).not.toBeInTheDocument();
+ });
+
+ it('renders CustomerDetailLink', async () => {
+ getConfig.mockImplementation(() => ({
+ ADMIN_PORTAL_BASE_URL: 'http://www.testportal.com',
+ }));
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'Ash Ketchum' })).toHaveAttribute('href', '/enterprise-configuration/customers/ash-ketchum/view');
+ expect(screen.getByRole('link', { name: '/ash-ketchum/ in a new tab' })).toHaveAttribute('href', 'http://www.testportal.com/ash-ketchum/admin/learners');
+ expect(screen.getByText('123456789')).toBeInTheDocument();
+ const copy = screen.getByTestId('copy');
+ userEvent.click(copy);
+ await waitFor(() => expect(screen.getByText('Copied to clipboard!')).toBeInTheDocument());
+ });
+});
diff --git a/src/Configuration/Customers/CustomerDataTable/tests/CustomersPage.test.jsx b/src/Configuration/Customers/CustomerDataTable/tests/CustomersPage.test.jsx
new file mode 100644
index 000000000..6c5fc3859
--- /dev/null
+++ b/src/Configuration/Customers/CustomerDataTable/tests/CustomersPage.test.jsx
@@ -0,0 +1,57 @@
+/* eslint-disable react/prop-types */
+import { screen, render, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import CustomersPage from '../CustomersPage';
+import LmsApiService from '../../../../data/services/EnterpriseApiService';
+
+const mockData = [{
+ name: 'Ubuntu',
+ slug: 'test-ubuntu',
+ uuid: 'test-enterprise-uuid',
+ activeIntegrations: [{
+ channelCode: 'test-channel',
+ created: 'jan 1, 1992',
+ modified: 'jan 2, 1992',
+ displayName: 'test channel',
+ active: true,
+ }],
+ activeSsoConfigurations: [{
+ created: 'jan 1, 1992',
+ modified: 'jan 2, 1992',
+ displayName: 'test channel',
+ active: true,
+ }],
+ enableGenerationOfApiCredentials: true,
+}];
+
+jest.mock('lodash.debounce', () => jest.fn((fn) => fn));
+jest
+ .spyOn(LmsApiService, 'fetchEnterpriseCustomerSupportTool')
+ .mockImplementation(() => Promise.resolve({ data: mockData }));
+
+describe('CustomersPage', () => {
+ it('renders the datatable with data', async () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('loading')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('Ubuntu')).toBeInTheDocument();
+ expect(screen.getByText('/test-ubuntu/')).toBeInTheDocument();
+ expect(screen.getByText('test-enterprise-uuid')).toBeInTheDocument();
+ expect(screen.getByText('Lms Check')).toBeInTheDocument();
+ expect(screen.getByText('SSO Check')).toBeInTheDocument();
+ expect(screen.getByText('API Check')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Customers')).toBeInTheDocument();
+ expect(screen.getByText('Customer details')).toBeInTheDocument();
+ expect(screen.getByText('SSO')).toBeInTheDocument();
+ expect(screen.getByText('LMS')).toBeInTheDocument();
+ expect(screen.getByText('API')).toBeInTheDocument();
+ });
+});
diff --git a/src/Configuration/Customers/data/hooks/useActiveAssociatedPlans.js b/src/Configuration/Customers/data/hooks/useActiveAssociatedPlans.js
new file mode 100644
index 000000000..172f2fd49
--- /dev/null
+++ b/src/Configuration/Customers/data/hooks/useActiveAssociatedPlans.js
@@ -0,0 +1,89 @@
+import { useState, useEffect, useCallback } from 'react';
+import { logError } from '@edx/frontend-platform/logging';
+import {
+ getEnterpriseOffers,
+ getCouponOrders,
+ getCustomerAgreements,
+ getSubsidyAccessPolicies,
+} from '../utils';
+
+const useActiveAssociatedPlans = (enterpriseId) => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [data, setData] = useState({});
+ const fetchData = useCallback(
+ async () => {
+ try {
+ const [
+ customerAgreementsResponse,
+ policiesForCustomerResponse,
+ enterpriseOffersResponse,
+ couponOrdersResponse,
+ ] = await Promise.all([
+ getCustomerAgreements(enterpriseId),
+ getSubsidyAccessPolicies(enterpriseId),
+ getEnterpriseOffers(enterpriseId),
+ getCouponOrders(enterpriseId),
+ ]);
+
+ couponOrdersResponse.results.some(coupon => {
+ if (coupon.available) {
+ setData(prevState => ({
+ ...prevState,
+ hasActiveOtherSubsidies: true,
+ }));
+ }
+ return null;
+ });
+
+ policiesForCustomerResponse.results.some(policy => {
+ if (policy.active) {
+ setData(prevState => ({
+ ...prevState,
+ hasActivePolicies: true,
+ }));
+ }
+ return null;
+ });
+
+ customerAgreementsResponse.results.some(agreement => {
+ agreement.subscriptions.some(subscription => {
+ if (subscription.isActive) {
+ setData(prevState => ({
+ ...prevState,
+ hasActiveAgreements: true,
+ }));
+ }
+ return null;
+ });
+ return null;
+ });
+
+ enterpriseOffersResponse.results.some(offer => {
+ if (offer.isCurrent) {
+ setData(prevState => ({
+ ...prevState,
+ hasActiveOtherSubsidies: true,
+ }));
+ }
+ return null;
+ });
+ } catch (error) {
+ logError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [enterpriseId],
+ );
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ return {
+ data,
+ isLoading,
+ };
+};
+
+export default useActiveAssociatedPlans;
diff --git a/src/Configuration/Customers/data/utils.js b/src/Configuration/Customers/data/utils.js
new file mode 100644
index 000000000..9252e9864
--- /dev/null
+++ b/src/Configuration/Customers/data/utils.js
@@ -0,0 +1,28 @@
+import { camelCaseObject } from '@edx/frontend-platform/utils';
+import EcommerceApiService from '../../../data/services/EcommerceApiService';
+import LicenseManagerApiService from '../../../data/services/LicenseManagerApiService';
+import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService';
+
+export const getEnterpriseOffers = async (enterpriseId) => {
+ const response = await EcommerceApiService.fetchEnterpriseOffers(enterpriseId);
+ const enterpriseOffers = camelCaseObject(response.data);
+ return enterpriseOffers;
+};
+
+export const getCouponOrders = async (enterpriseId, options) => {
+ const response = await EcommerceApiService.fetchCouponOrders(enterpriseId, options);
+ const couponOrders = camelCaseObject(response.data);
+ return couponOrders;
+};
+
+export const getCustomerAgreements = async (enterpriseId) => {
+ const response = await LicenseManagerApiService.fetchCustomerAgreementData(enterpriseId);
+ const customerAgreements = camelCaseObject(response.data);
+ return customerAgreements;
+};
+
+export const getSubsidyAccessPolicies = async (enterpriseId) => {
+ const response = await EnterpriseAccessApiService.fetchSubsidyAccessPolicies(enterpriseId);
+ const subsidyAccessPolicies = camelCaseObject(response.data);
+ return subsidyAccessPolicies;
+};
diff --git a/src/Configuration/Customers/data/utils.test.js b/src/Configuration/Customers/data/utils.test.js
new file mode 100644
index 000000000..f5bcfe4b5
--- /dev/null
+++ b/src/Configuration/Customers/data/utils.test.js
@@ -0,0 +1,102 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import {
+ getEnterpriseOffers,
+ getCouponOrders,
+ getCustomerAgreements,
+ getSubsidyAccessPolicies,
+} from './utils';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+const TEST_ENTERPRISE_UUID = 'test-uuid';
+
+describe('getEnterpriseOffers', () => {
+ it('returns the correct data', async () => {
+ const offersResults = {
+ data: {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [{
+ isCurrent: true,
+ uuid: 'uuid',
+ }],
+ },
+ };
+ getAuthenticatedHttpClient.mockImplementation(() => ({
+ get: jest.fn().mockResolvedValue(offersResults),
+ }));
+ const results = await getEnterpriseOffers(TEST_ENTERPRISE_UUID);
+ expect(results).toEqual(offersResults.data);
+ });
+});
+
+describe('getSubsidyAccessPolicies', () => {
+ it('returns the correct data', async () => {
+ const policiesResults = {
+ data: {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [{
+ displayName: null,
+ description: 'testing policy 2',
+ active: true,
+ }],
+ },
+ };
+ getAuthenticatedHttpClient.mockImplementation(() => ({
+ get: jest.fn().mockResolvedValue(policiesResults),
+ }));
+ const results = await getSubsidyAccessPolicies(TEST_ENTERPRISE_UUID);
+ expect(results).toEqual(policiesResults.data);
+ });
+});
+
+describe('getCouponOrders', () => {
+ it('returns the correct data', async () => {
+ const couponsResults = {
+ data: {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [{
+ id: 1,
+ title: 'Enterprise Coupon',
+ startDate: '2022-03-16T00:00:00Z',
+ endDate: '2022-03-31T00:00:00Z',
+ available: false,
+ }],
+ },
+ };
+ getAuthenticatedHttpClient.mockImplementation(() => ({
+ get: jest.fn().mockResolvedValue(couponsResults),
+ }));
+ const results = await getCouponOrders(TEST_ENTERPRISE_UUID);
+ expect(results).toEqual(couponsResults.data);
+ });
+});
+
+describe('getCustomerAgreements', () => {
+ it('returns the correct data', async () => {
+ const agreementsResults = {
+ data: {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [{
+ subscriptions: {
+ isActive: true,
+ },
+ }],
+ },
+ };
+ getAuthenticatedHttpClient.mockImplementation(() => ({
+ get: jest.fn().mockResolvedValue(agreementsResults),
+ }));
+ const results = await getCustomerAgreements(TEST_ENTERPRISE_UUID);
+ expect(results).toEqual(agreementsResults.data);
+ });
+});
diff --git a/src/Configuration/index.scss b/src/Configuration/index.scss
new file mode 100644
index 000000000..e7ede0f54
--- /dev/null
+++ b/src/Configuration/index.scss
@@ -0,0 +1,28 @@
+.sub-component {
+ margin-left: 13rem;
+}
+.pgn-doc__icons-table__preview-footer {
+ display: flex;
+
+ .pgn__icon {
+ align-self: center;
+ margin-left: 8px;
+ opacity: .3;
+ width: 1rem;
+ height: 1rem;
+ }
+
+ &:hover {
+ cursor: pointer;
+
+ .pgn__icon {
+ opacity: 1;
+ }
+ }
+}
+
+.customer-name {
+ font-size: 15px;
+ color: black !important;
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/src/data/constants/routes.js b/src/data/constants/routes.js
index f48b582e2..248ce3f31 100644
--- a/src/data/constants/routes.js
+++ b/src/data/constants/routes.js
@@ -11,6 +11,15 @@ const ROUTES = {
CONFIGURATION: {
HOME: '/enterprise-configuration',
SUB_DIRECTORY: {
+ CUSTOMERS: {
+ HOME: '/enterprise-configuration/customers',
+ SUB_DIRECTORY: {
+ NEW: '/enterprise-configuration/customers/new',
+ VIEW: '/enterprise-configuration/customers/:id/view',
+ EDIT: '/enterprise-configuration/customers/:id/edit',
+ ERROR: '/enterprise-configuration/customers/error',
+ },
+ },
PROVISIONING: {
HOME: '/enterprise-configuration/learner-credit',
SUB_DIRECTORY: {
diff --git a/src/data/services/EcommerceApiService.js b/src/data/services/EcommerceApiService.js
new file mode 100644
index 000000000..a2a481e34
--- /dev/null
+++ b/src/data/services/EcommerceApiService.js
@@ -0,0 +1,29 @@
+import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+class EcommerceApiService {
+ static apiClient = getAuthenticatedHttpClient;
+
+ static baseUrl = getConfig().ECOMMERCE_BASE_URL;
+
+ static fetchCouponOrders(enterpriseId, options) {
+ const queryParams = new URLSearchParams({
+ page: 1,
+ page_size: 50,
+ ...options,
+ });
+ const url = `${EcommerceApiService.baseUrl}/api/v2/enterprise/coupons/${enterpriseId}/overview/?${queryParams.toString()}`;
+ return EcommerceApiService.apiClient().get(url);
+ }
+
+ static fetchEnterpriseOffers(enterpriseId, options) {
+ let url = `${EcommerceApiService.baseUrl}/api/v2/enterprise/${enterpriseId}/enterprise-admin-offers/`;
+ if (options) {
+ const queryParams = new URLSearchParams(snakeCaseObject(options));
+ url += `?${queryParams.toString()}`;
+ }
+ return EcommerceApiService.apiClient().get(url);
+ }
+}
+
+export default EcommerceApiService;
diff --git a/src/data/services/EnterpriseAccessApiService.js b/src/data/services/EnterpriseAccessApiService.js
new file mode 100644
index 000000000..6af03c84d
--- /dev/null
+++ b/src/data/services/EnterpriseAccessApiService.js
@@ -0,0 +1,17 @@
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+class EnterpriseAccessApiService {
+ static apiClient = getAuthenticatedHttpClient;
+
+ static fetchSubsidyAccessPolicies(enterpriseId, options) {
+ const queryParams = new URLSearchParams({
+ enterprise_customer_uuid: enterpriseId,
+ ...options,
+ });
+ const url = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/subsidy-access-policies/?${queryParams.toString()}`;
+ return EnterpriseAccessApiService.apiClient().get(url);
+ }
+}
+
+export default EnterpriseAccessApiService;
diff --git a/src/data/services/EnterpriseApiService.js b/src/data/services/EnterpriseApiService.js
index aec660c00..d185c386d 100644
--- a/src/data/services/EnterpriseApiService.js
+++ b/src/data/services/EnterpriseApiService.js
@@ -14,6 +14,8 @@ class LmsApiService {
static enterpriseCustomersBasicListUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-customer/basic_list/`;
+ static enterpriseCustomersSupportToolUrl = `${LmsApiService.baseUrl}/enterprise/api/v1/enterprise-customer/support_tool/`;
+
static enterpriseCatalogsUrl = `${LmsApiService.enterpriseAPIBaseUrl}enterprise_catalogs/`;
static fetchEnterpriseCatalogQueries = () => LmsApiService.apiClient().get(LmsApiService.enterpriseCatalogQueriesUrl);
@@ -40,6 +42,15 @@ class LmsApiService {
title,
});
+ static fetchEnterpriseCustomerSupportTool = (options) => {
+ const queryParams = new URLSearchParams({
+ ...options,
+ });
+ return LmsApiService.apiClient().get(
+ `${LmsApiService.enterpriseCustomersSupportToolUrl}?${queryParams.toString()}`,
+ );
+ };
+
/**
* Retrieve one catalog (the plurality of the function name is due to the fact that this is a list endpoint).
* @param {Number} catalogUuid - UUID of the single catalog to fetch.
diff --git a/src/data/services/LicenseManagerApiService.js b/src/data/services/LicenseManagerApiService.js
new file mode 100644
index 000000000..cd0942ad5
--- /dev/null
+++ b/src/data/services/LicenseManagerApiService.js
@@ -0,0 +1,17 @@
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+class LicenseManagerApiService {
+ static apiClient = getAuthenticatedHttpClient;
+
+ static fetchCustomerAgreementData(enterpriseId, options) {
+ const queryParams = new URLSearchParams({
+ enterprise_customer_uuid: enterpriseId,
+ ...options,
+ });
+ const url = `${getConfig().LICENSE_MANAGER_URL}/api/v1/customer-agreement/?${queryParams.toString()}`;
+ return LicenseManagerApiService.apiClient().get(url);
+ }
+}
+
+export default LicenseManagerApiService;
diff --git a/src/data/services/tests/EcommerceApiService.test.js b/src/data/services/tests/EcommerceApiService.test.js
new file mode 100644
index 000000000..96be3fb72
--- /dev/null
+++ b/src/data/services/tests/EcommerceApiService.test.js
@@ -0,0 +1,37 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { getConfig } from '@edx/frontend-platform';
+
+import EcommerceApiService from '../EcommerceApiService';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+const axiosMock = new MockAdapter(axios);
+getAuthenticatedHttpClient.mockReturnValue(axios);
+
+axiosMock.onAny().reply(200);
+axios.get = jest.fn();
+
+describe('EnterpriseAccessApiService', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('fetchCouponOrders calls the API to fetch coupons by enterprise customer UUID', () => {
+ const mockCustomerUUID = 'test-customer-uuid';
+ const expectedUrl = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/enterprise/coupons/${mockCustomerUUID}/overview/?page=1&page_size=50`;
+ EcommerceApiService.fetchCouponOrders(mockCustomerUUID);
+ expect(axios.get).toBeCalledWith(expectedUrl);
+ });
+
+ test('fetchEnterpriseOffers calls the API to fetch coupons by enterprise customer UUID', () => {
+ const mockCustomerUUID = 'test-customer-uuid';
+ const expectedUrl = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/enterprise/${mockCustomerUUID}/enterprise-admin-offers/`;
+ EcommerceApiService.fetchEnterpriseOffers(mockCustomerUUID);
+ expect(axios.get).toBeCalledWith(expectedUrl);
+ });
+});
diff --git a/src/data/services/tests/EnterpriseAccessApiService.test.js b/src/data/services/tests/EnterpriseAccessApiService.test.js
new file mode 100644
index 000000000..b5819bc47
--- /dev/null
+++ b/src/data/services/tests/EnterpriseAccessApiService.test.js
@@ -0,0 +1,30 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { getConfig } from '@edx/frontend-platform';
+
+import EnterpriseAccessApiService from '../EnterpriseAccessApiService';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+const axiosMock = new MockAdapter(axios);
+getAuthenticatedHttpClient.mockReturnValue(axios);
+
+axiosMock.onAny().reply(200);
+axios.get = jest.fn();
+
+describe('EnterpriseAccessApiService', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('fetchSubsidyAccessPolicies calls the API to fetch subsides by enterprise customer UUID', () => {
+ const mockCustomerUUID = 'test-customer-uuid';
+ const expectedUrl = `${getConfig().ENTERPRISE_ACCESS_BASE_URL}/api/v1/subsidy-access-policies/?enterprise_customer_uuid=${mockCustomerUUID}`;
+ EnterpriseAccessApiService.fetchSubsidyAccessPolicies(mockCustomerUUID);
+ expect(axios.get).toBeCalledWith(expectedUrl);
+ });
+});
diff --git a/src/data/services/tests/LicenseManagerApiService.test.js b/src/data/services/tests/LicenseManagerApiService.test.js
new file mode 100644
index 000000000..ec5e5f9bb
--- /dev/null
+++ b/src/data/services/tests/LicenseManagerApiService.test.js
@@ -0,0 +1,30 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { getConfig } from '@edx/frontend-platform';
+
+import LicenseManagerApiService from '../LicenseManagerApiService';
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedHttpClient: jest.fn(),
+}));
+
+const axiosMock = new MockAdapter(axios);
+getAuthenticatedHttpClient.mockReturnValue(axios);
+
+axiosMock.onAny().reply(200);
+axios.get = jest.fn();
+
+describe('LicenseManagerApiService', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('fetchCustomerAgreementData calls the API to fetch subscriptions by enterprise customer UUID', () => {
+ const mockCustomerUUID = 'test-customer-uuid';
+ const expectedUrl = `${getConfig().LICENSE_MANAGER_URL}/api/v1/customer-agreement/?enterprise_customer_uuid=${mockCustomerUUID}`;
+ LicenseManagerApiService.fetchCustomerAgreementData(mockCustomerUUID);
+ expect(axios.get).toBeCalledWith(expectedUrl);
+ });
+});
diff --git a/src/data/services/SubsidyApiService.test.js b/src/data/services/tests/SubsidyApiService.test.js
similarity index 86%
rename from src/data/services/SubsidyApiService.test.js
rename to src/data/services/tests/SubsidyApiService.test.js
index 477223e2b..300dde70a 100644
--- a/src/data/services/SubsidyApiService.test.js
+++ b/src/data/services/tests/SubsidyApiService.test.js
@@ -1,4 +1,4 @@
-import SubsidyApiService from './SubsidyApiService';
+import SubsidyApiService from '../SubsidyApiService';
describe('getAllSubsidies', () => {
it('returns a promise', () => {
diff --git a/src/index.jsx b/src/index.jsx
index 30e4ff626..1bed2bec7 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -19,6 +19,7 @@ import FBEIndexPage from './FeatureBasedEnrollments/FeatureBasedEnrollmentIndexP
import UserMessagesProvider from './userMessages/UserMessagesProvider';
import ProgramEnrollmentsIndexPage from './ProgramEnrollments/ProgramEnrollmentsIndexPage';
import Head from './head/Head';
+import CustomersPage from './Configuration/Customers/CustomerDataTable/CustomersPage';
import './index.scss';
import ProvisioningPage from './Configuration/Provisioning/ProvisioningPage';
@@ -69,6 +70,13 @@ subscribe(APP_READY, () => {
element={}
/>,
];
+ const customerRoutes = [
+ }
+ />,
+ ];
ReactDOM.render(
@@ -76,6 +84,7 @@ subscribe(APP_READY, () => {
{/* Start: Configuration Dropdown Routes */}
+ {getConfig().FEATURE_CUSTOMER_SUPPORT_VIEW === 'true' && customerRoutes}
{getConfig().FEATURE_CONFIGURATION_MANAGEMENT && configurationRoutes}
{/* End: Configuration Dropdown Routes */}
} />
@@ -102,10 +111,12 @@ initialize({
mergeConfig({
COMMERCE_COORDINATOR_ORDER_DETAILS_URL: process.env.COMMERCE_COORDINATOR_ORDER_DETAILS_URL || null,
LICENSE_MANAGER_URL: process.env.LICENSE_MANAGER_URL || null,
+ ADMIN_PORTAL_BASE_URL: process.env.ADMIN_PORTAL_BASE_URL || null,
ENTERPRISE_ACCESS_BASE_URL: process.env.ENTERPRISE_ACCESS_BASE_URL || null,
FEATURE_CONFIGURATION_MANAGEMENT: process.env.FEATURE_CONFIGURATION_MANAGEMENT || hasFeatureFlagEnabled('FEATURE_CONFIGURATION_MANAGEMENT') || null,
FEATURE_CONFIGURATION_ENTERPRISE_PROVISION: process.env.FEATURE_CONFIGURATION_ENTERPRISE_PROVISION || hasFeatureFlagEnabled('FEATURE_CONFIGURATION_ENTERPRISE_PROVISION') || null,
FEATURE_CONFIGURATION_EDIT_ENTERPRISE_PROVISION: process.env.FEATURE_CONFIGURATION_EDIT_ENTERPRISE_PROVISION || hasFeatureFlagEnabled('FEATURE_CONFIGURATION_EDIT_ENTERPRISE_PROVISION') || null,
+ FEATURE_CUSTOMER_SUPPORT_VIEW: process.env.FEATURE_CUSTOMER_SUPPORT_VIEW || hasFeatureFlagEnabled('FEATURE_CUSTOMER_SUPPORT_VIEW') || null,
SUBSIDY_BASE_URL: process.env.SUBSIDY_BASE_URL || null,
});
},
diff --git a/src/index.scss b/src/index.scss
index ce550a5c6..bf312585d 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -8,3 +8,5 @@
@import "./users/index.scss";
@import "./overrides.scss";
+
+@import "./Configuration/index.scss"
diff --git a/src/supportHeader/Header.jsx b/src/supportHeader/Header.jsx
index ca9d31a7d..7fcfc107d 100644
--- a/src/supportHeader/Header.jsx
+++ b/src/supportHeader/Header.jsx
@@ -113,10 +113,14 @@ export default function Header() {
const configurationDropdown = {
type: 'submenu',
content: 'Enterprise Setup',
- submenuContent:
- getConfig().FEATURE_CONFIGURATION_ENTERPRISE_PROVISION
- ? ()
- : null,
+ submenuContent: (
+ <>
+ {getConfig().FEATURE_CUSTOMER_SUPPORT_VIEW === 'true' ? (
+ ) : null}
+ {getConfig().FEATURE_CONFIGURATION_ENTERPRISE_PROVISION === 'true' ? (
+ ) : null}
+ >
+ ),
};
if (getConfig().FEATURE_CONFIGURATION_MANAGEMENT) {
mainMenu.push(configurationDropdown);