From bef98e415aa9830d77806802552133a40f91b899 Mon Sep 17 00:00:00 2001 From: katrinan029 Date: Fri, 2 Aug 2024 17:39:00 +0000 Subject: [PATCH] feat: add enterprise customer list view --- .env | 2 + .env.development | 2 + .../CustomerDetailSubComponent.jsx | 124 ++++++++++++++++ .../CustomerDataTable/CustomerDetails.jsx | 140 ++++++++++++++++++ .../CustomerDataTable/CustomersPage.jsx | 108 ++++++++++++++ .../Customers/CustomerDataTable/index.js | 3 + .../tests/CustomerDetailSubComponent.test.jsx | 116 +++++++++++++++ .../tests/CustomerDetails.test.jsx | 117 +++++++++++++++ .../tests/CustomersPage.test.jsx | 57 +++++++ .../data/hooks/useActiveAssociatedPlans.js | 89 +++++++++++ src/Configuration/Customers/data/utils.js | 28 ++++ .../Customers/data/utils.test.js | 102 +++++++++++++ src/Configuration/index.scss | 28 ++++ src/data/constants/routes.js | 9 ++ src/data/services/EcommerceApiService.js | 29 ++++ .../services/EnterpriseAccessApiService.js | 17 +++ src/data/services/EnterpriseApiService.js | 11 ++ src/data/services/LicenseManagerApiService.js | 17 +++ .../tests/EcommerceApiService.test.js | 37 +++++ .../tests/EnterpriseAccessApiService.test.js | 30 ++++ .../tests/LicenseManagerApiService.test.js | 30 ++++ .../{ => tests}/SubsidyApiService.test.js | 2 +- src/index.jsx | 11 ++ src/index.scss | 2 + src/supportHeader/Header.jsx | 12 +- 25 files changed, 1118 insertions(+), 5 deletions(-) create mode 100644 src/Configuration/Customers/CustomerDataTable/CustomerDetailSubComponent.jsx create mode 100644 src/Configuration/Customers/CustomerDataTable/CustomerDetails.jsx create mode 100644 src/Configuration/Customers/CustomerDataTable/CustomersPage.jsx create mode 100644 src/Configuration/Customers/CustomerDataTable/index.js create mode 100644 src/Configuration/Customers/CustomerDataTable/tests/CustomerDetailSubComponent.test.jsx create mode 100644 src/Configuration/Customers/CustomerDataTable/tests/CustomerDetails.test.jsx create mode 100644 src/Configuration/Customers/CustomerDataTable/tests/CustomersPage.test.jsx create mode 100644 src/Configuration/Customers/data/hooks/useActiveAssociatedPlans.js create mode 100644 src/Configuration/Customers/data/utils.js create mode 100644 src/Configuration/Customers/data/utils.test.js create mode 100644 src/Configuration/index.scss create mode 100644 src/data/services/EcommerceApiService.js create mode 100644 src/data/services/EnterpriseAccessApiService.js create mode 100644 src/data/services/LicenseManagerApiService.js create mode 100644 src/data/services/tests/EcommerceApiService.test.js create mode 100644 src/data/services/tests/EnterpriseAccessApiService.test.js create mode 100644 src/data/services/tests/LicenseManagerApiService.test.js rename src/data/services/{ => tests}/SubsidyApiService.test.js (86%) 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);