diff --git a/.env.development b/.env.development index d74a56083c..03858d1ff4 100644 --- a/.env.development +++ b/.env.development @@ -40,6 +40,7 @@ FEATURE_SETTINGS_PAGE_LMS_TAB='true' FEATURE_SETTINGS_PAGE_APPEARANCE_TAB='true' FEATURE_LEARNER_CREDIT_MANAGEMENT='true' FEATURE_CONTENT_HIGHLIGHTS='true' +FEATURE_API_CREDENTIALS_TAB='true' HOTJAR_APP_ID='' HOTJAR_VERSION='6' HOTJAR_DEBUG='' diff --git a/src/components/ContactCustomerSupportButton/index.jsx b/src/components/ContactCustomerSupportButton/index.jsx index 8c2e1408ca..b0bdee42cf 100644 --- a/src/components/ContactCustomerSupportButton/index.jsx +++ b/src/components/ContactCustomerSupportButton/index.jsx @@ -31,7 +31,7 @@ ContactCustomerSupportButton.propTypes = { ContactCustomerSupportButton.defaultProps = { children: 'Contact support', - variant: 'btn-outline-primary', + variant: 'outline-primary', }; export default ContactCustomerSupportButton; diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index e87c12a931..f6c2a9d6da 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import type { Dispatch } from 'react'; import { - ActionRow, Button, FullscreenModal, Hyperlink, Stepper, useToggle, + ActionRow, Button, FullscreenModal, Stepper, useToggle, } from '@edx/paragon'; import { Launch } from '@edx/paragon/icons'; @@ -14,6 +14,7 @@ import { HELP_CENTER_LINK, SUBMIT_TOAST_MESSAGE } from '../settings/data/constan import UnsavedChangesModal from '../settings/SettingsLMSTab/UnsavedChangesModal'; import ConfigErrorModal from '../settings/ConfigErrorModal'; import { channelMapping, pollAsync } from '../../utils'; +import HelpCenterButton from '../settings/HelpCenterButton'; export const WAITING_FOR_ASYNC_OPERATION = 'WAITING FOR ASYNC OPERATION'; @@ -201,13 +202,9 @@ const FormWorkflow = ({ className="stepper-modal" footerNode={( - + Help Center: Integrations - + {nextButtonConfig && ( diff --git a/src/components/settings/HelpCenterButton.jsx b/src/components/settings/HelpCenterButton.jsx new file mode 100644 index 0000000000..50b9749abb --- /dev/null +++ b/src/components/settings/HelpCenterButton.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Hyperlink } from '@edx/paragon'; +import PropTypes from 'prop-types'; + +const HelpCenterButton = ({ + url, + children, + ...rest +}) => { + const destinationUrl = url; + + return ( + + {children} + + ); +}; + +HelpCenterButton.defaultProps = { + children: 'Help Center', +}; + +HelpCenterButton.propTypes = { + children: PropTypes.node, + url: PropTypes.string, +}; + +export default HelpCenterButton; diff --git a/src/components/settings/SettingsApiCredentialsTab/APICredentialsPage.jsx b/src/components/settings/SettingsApiCredentialsTab/APICredentialsPage.jsx new file mode 100644 index 0000000000..f86dc4aaaf --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/APICredentialsPage.jsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Form, Hyperlink } from '@edx/paragon'; +import { dataPropType } from './constants'; +import RegenerateCredentialWarningModal from './RegenerateCredentialWarningModal'; +import CopyButton from './CopyButton'; +import { API_CLIENT_DOCUMENTATION, HELP_CENTER_LINK } from '../data/constants'; + +const APICredentialsPage = ({ data, setData }) => { + const [formValue, setFormValue] = useState(''); + const handleFormChange = (e) => { + setFormValue(e.target.value); + }; + return ( +
+
+

Your API credentials

+

+ Copy and paste the following credential information and send it to your API developer(s). +

+
+
+

+ Application name: + {data?.name} +

+

+ Allowed URIs: + {data?.redirect_uris} +

+

+ API client ID: + {data?.client_id} +

+

+ API client secret: + {data?.client_secret} +

+

API client documentation: + {API_CLIENT_DOCUMENTATION} +

+

+ Last generated on: + {data?.updated} +

+
+ +
+
+
+

Redirect URIs (optional)

+

+ If you need additional redirect URIs, add them below and regenerate your API credentials. + You will need to communicate the new credentials to your API developers. +

+ +

+ Allowed URIs list, space separated +

+ +
+
+

Questions or modifications?

+

+ To troubleshoot your API credentialing, or to request additional API endpoints to your + credentials,  + + contact Enterprise Customer Support. + +

+
+
+ ); +}; + +APICredentialsPage.defaultProps = { + data: null, +}; + +APICredentialsPage.propTypes = { + data: PropTypes.shape(dataPropType), + setData: PropTypes.func.isRequired, +}; + +export default APICredentialsPage; diff --git a/src/components/settings/SettingsApiCredentialsTab/Context.jsx b/src/components/settings/SettingsApiCredentialsTab/Context.jsx new file mode 100644 index 0000000000..4682f99ef7 --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/Context.jsx @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +export const ErrorContext = createContext(null); +export const ShowSuccessToast = createContext(null); +export const EnterpriseId = createContext(null); diff --git a/src/components/settings/SettingsApiCredentialsTab/CopiedToast.jsx b/src/components/settings/SettingsApiCredentialsTab/CopiedToast.jsx new file mode 100644 index 0000000000..5506b7e14f --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/CopiedToast.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Toast } from '@edx/paragon'; + +const CopiedToast = ({ content, ...rest }) => ( + + {content} + +); +CopiedToast.propTypes = { + content: PropTypes.string.isRequired, +}; +export default CopiedToast; diff --git a/src/components/settings/SettingsApiCredentialsTab/CopyButton.jsx b/src/components/settings/SettingsApiCredentialsTab/CopyButton.jsx new file mode 100644 index 0000000000..eb3477e047 --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/CopyButton.jsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { Button } from '@edx/paragon'; +import { ContentCopy } from '@edx/paragon/icons'; +import CopiedToast from './CopiedToast'; +import { dataPropType } from './constants'; + +const CopyButton = ({ data }) => { + const [isCopyLinkToastOpen, setIsCopyLinkToastOpen] = useState(false); + const [copiedError, setCopiedError] = useState(false); + + const handleCopyLink = async () => { + try { + const jsonString = JSON.stringify(data); + await navigator.clipboard.writeText(jsonString); + } catch (error) { + setCopiedError(true); + } finally { + setIsCopyLinkToastOpen(true); + } + }; + const handleCloseLinkCopyToast = () => { + setIsCopyLinkToastOpen(false); + }; + return ( + <> + + + + ); +}; + +CopyButton.propTypes = { + data: PropTypes.shape(dataPropType), +}; + +export default CopyButton; diff --git a/src/components/settings/SettingsApiCredentialsTab/FailedAlert.jsx b/src/components/settings/SettingsApiCredentialsTab/FailedAlert.jsx new file mode 100644 index 0000000000..f37c285dc1 --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/FailedAlert.jsx @@ -0,0 +1,16 @@ +import { Alert } from '@edx/paragon'; +import { Error } from '@edx/paragon/icons'; +import { credentialErrorMessage } from './constants'; + +const FailedAlert = () => ( + + + Credential generation failed + +

+ {credentialErrorMessage} +

+
+); + +export default FailedAlert; diff --git a/src/components/settings/SettingsApiCredentialsTab/RegenerateCredentialWarningModal.jsx b/src/components/settings/SettingsApiCredentialsTab/RegenerateCredentialWarningModal.jsx new file mode 100644 index 0000000000..dd829012d5 --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/RegenerateCredentialWarningModal.jsx @@ -0,0 +1,98 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { + ActionRow, Button, Icon, ModalDialog, useToggle, +} from '@edx/paragon'; +import { Warning } from '@edx/paragon/icons'; + +import { + ErrorContext, + ShowSuccessToast, EnterpriseId, +} from './Context'; +import LmsApiService from '../../../data/services/LmsApiService'; +import { dataPropType } from './constants'; + +const RegenerateCredentialWarningModal = ({ + redirectURIs, + data, + setData, +}) => { + const [isOn, setOn, setOff] = useToggle(false); + const [, setHasError] = useContext(ErrorContext); + const [, setShowSuccessToast] = useContext(ShowSuccessToast); + const enterpriseId = useContext(EnterpriseId); + const handleOnClickRegeneration = async () => { + try { + const response = await LmsApiService.regenerateAPICredentials(redirectURIs, enterpriseId); + const newURIs = response.data.redirect_uris; + setShowSuccessToast(true); + const updatedData = data; + updatedData.redirect_uris = newURIs; + setData(updatedData); + } catch (error) { + setHasError(true); + } finally { + setOff(true); + } + }; + + return ( + <> + + + + +
+ + Regenerate API credentials? +
+
+
+ +

+ Any system, job, or script using the previous credentials will no + longer be able to authenticate with the edX API. +

+

+ If you do regenerate, you will need to send the new credentials to your developers. +

+
+ + + + Cancel + + + + +
+ + ); +}; + +RegenerateCredentialWarningModal.propTypes = { + redirectURIs: PropTypes.string.isRequired, + data: PropTypes.shape(dataPropType), + setData: PropTypes.func.isRequired, +}; + +export default RegenerateCredentialWarningModal; diff --git a/src/components/settings/SettingsApiCredentialsTab/ZeroStateCard.jsx b/src/components/settings/SettingsApiCredentialsTab/ZeroStateCard.jsx new file mode 100644 index 0000000000..a9e37a0b8f --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/ZeroStateCard.jsx @@ -0,0 +1,91 @@ +import React, { useState, useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, Card, Hyperlink, Icon, Spinner, +} from '@edx/paragon'; +import { Add, Error } from '@edx/paragon/icons'; + +import { credentialErrorMessage } from './constants'; +import cardImage from '../../../data/images/ZeroState.svg'; +import { EnterpriseId } from './Context'; +import LmsApiService from '../../../data/services/LmsApiService'; +import { + API_CLIENT_DOCUMENTATION, API_TERMS_OF_SERVICE, HELP_CENTER_LINK, +} from '../data/constants'; + +const ZeroStateCard = ({ setShowToast, setData }) => { + const [isLoading, setIsLoading] = useState(false); + const [displayFailureAlert, setFailureAlert] = useState(false); + const enterpriseId = useContext(EnterpriseId); + const handleClick = async () => { + setIsLoading(true); + try { + const response = await LmsApiService.createNewAPICredentials(enterpriseId); + const data = { ...response.data, api_client_documentation: API_CLIENT_DOCUMENTATION }; + setData(data); + setShowToast(true); + } catch (err) { + setFailureAlert(true); + } + }; + + return ( + + + +

You don't have API credentials yet.

+ { !displayFailureAlert && ( +

+ This page allows you to generate API credentials to send to  + your developers so they can work on integration projects. + If you believe you are seeing this page in error,  + + contact Enterprise Customer Support. + +

+ )} +

+ edX for Business API credentials credentials will provide access  + to the following edX API endpoints: reporting dashboard, dashboard, and catalog administration. +

+

+ By clicking the button below, you and your organization accept the {'\n'} + edX API terms of service. +

+
+ + { displayFailureAlert && ( +

+ + {credentialErrorMessage} +

+ )} + +
+
+ ); +}; + +ZeroStateCard.propTypes = { + setShowToast: PropTypes.func.isRequired, + setData: PropTypes.func.isRequired, +}; + +export default ZeroStateCard; diff --git a/src/components/settings/SettingsApiCredentialsTab/constants.jsx b/src/components/settings/SettingsApiCredentialsTab/constants.jsx new file mode 100644 index 0000000000..2132f801df --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/constants.jsx @@ -0,0 +1,14 @@ +import PropTypes from 'prop-types'; + +export const dataPropType = PropTypes.shape({ + name: PropTypes.string, + redirect_uris: PropTypes.string, + client_id: PropTypes.string, + client_secret: PropTypes.string, + api_client_documentation: PropTypes.string, + updated: PropTypes.bool, +}); + +export const credentialErrorMessage = 'Something went wrong while ' ++ 'generating your credentials. Please try again. ' ++ 'If the issue continues, contact Enterprise Customer Support.'; diff --git a/src/components/settings/SettingsApiCredentialsTab/index.jsx b/src/components/settings/SettingsApiCredentialsTab/index.jsx new file mode 100644 index 0000000000..2cdef4d762 --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/index.jsx @@ -0,0 +1,70 @@ +/* eslint-disable react/jsx-no-constructed-context-values */ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { logError } from '@edx/frontend-platform/logging'; +import { ActionRow, Toast } from '@edx/paragon'; +import ZeroStateCard from './ZeroStateCard'; +import APICredentialsPage from './APICredentialsPage'; +import FailedAlert from './FailedAlert'; +import { HELP_CENTER_API_GUIDE } from '../data/constants'; +import HelpCenterButton from '../HelpCenterButton'; +import { + EnterpriseId, ErrorContext, ShowSuccessToast, +} from './Context'; +import LmsApiService from '../../../data/services/LmsApiService'; + +const SettingsApiCredentialsTab = ({ + enterpriseId, +}) => { + const [data, setData] = useState(); + const [hasRegenerationError, setHasRegenerationError] = useState(false); + const [showToast, setShowToast] = useState(false); + + useEffect(() => { + const fetchExistingAPICredentials = async () => { + try { + const response = await LmsApiService.fetchAPICredentials(enterpriseId); + setData(response.data); + } catch (error) { + logError(error); + } + }; + fetchExistingAPICredentials(); + }, [enterpriseId]); + + return ( + + + + { hasRegenerationError && } + +

API credentials

+ + + Help Center: EdX Enterprise API Guide + +
+
+ {!data ? ( + + ) : ()} +
+
+ { showToast && ( + setShowToast(false)} + show={showToast} + > + API credentials successfully generated + + )} + + + + ); +}; +SettingsApiCredentialsTab.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; +export default SettingsApiCredentialsTab; diff --git a/src/components/settings/SettingsApiCredentialsTab/tests/SettingsAPICredentialsPage.test.jsx b/src/components/settings/SettingsApiCredentialsTab/tests/SettingsAPICredentialsPage.test.jsx new file mode 100644 index 0000000000..425d02b7ca --- /dev/null +++ b/src/components/settings/SettingsApiCredentialsTab/tests/SettingsAPICredentialsPage.test.jsx @@ -0,0 +1,281 @@ +import { + render, screen, waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import LmsApiService from '../../../../data/services/LmsApiService'; +import SettingsApiCredentialsTab from '../index'; +import { + API_CLIENT_DOCUMENTATION, HELP_CENTER_API_GUIDE, API_TERMS_OF_SERVICE, HELP_CENTER_LINK, +} from '../../data/constants'; + +jest.mock('../../../../data/services/LmsApiService', () => ({ + fetchAPICredentials: jest.fn(), + createNewAPICredentials: jest.fn(), + regenerateAPICredentials: jest.fn(), +})); + +const name = "edx's Enterprise Credentials"; +const clientId = 'y0TCvOEvvIs6ll95irirzCJ5EaF0RnSbBIIXuNJE'; +const clientSecret = '1G896sVeT67jtjHO6FNd5qFqayZPIV7BtnW01P8zaAd4mDfmBVVVsUP33u'; +const updated = '2023-07-28T04:28:20.909550Z'; +const redirectUris = 'www.customercourses.edx.com, www.customercourses.edx.stage.com'; + +const data = { + name, + client_id: clientId, + client_secret: clientSecret, + redirect_uris: redirectUris, + updated, +}; +const regenerationData = { + ...data, + redirect_uris: redirectUris, +}; +const copiedData = { + ...data, + api_client_documentation: API_CLIENT_DOCUMENTATION, +}; + +describe('API Credentials Tab', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const basicProps = { + enterpriseId: 'test-enterprise-uuid', + }; + + const enterpriseId = 'test-enterprise-uuid'; + + test('renders zero state page when having no api credentials', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + const mockCreateFn = jest.spyOn(LmsApiService, 'createNewAPICredentials'); + mockFetchFn.mockRejectedValue(); + mockCreateFn.mockResolvedValue(); + + render( + + + , + ); + expect(screen.getByText('API credentials')).toBeInTheDocument(); + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + expect(screen.getByText("You don't have API credentials yet.")).toBeInTheDocument(); + expect(screen.queryByText('Help Center: EdX Enterprise API Guide')).toBeInTheDocument(); + const helpLink = screen.getByText('Help Center: EdX Enterprise API Guide'); + expect(helpLink.getAttribute('href')).toBe(HELP_CENTER_API_GUIDE); + const serviceLink = screen.getByText('edX API terms of service'); + expect(serviceLink.getAttribute('href')).toBe(API_TERMS_OF_SERVICE); + + expect(screen.getByText('Generate API Credentials').toBeInTheDocument); + userEvent.click(screen.getByText('Generate API Credentials')); + await waitFor(() => expect(mockCreateFn).toHaveBeenCalled()); + }); + test('renders api credentials page when having existing api credentials', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + mockFetchFn.mockResolvedValue({ data }); + render( + + + , + ); + + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByText(name)).toBeInTheDocument); + + expect(screen.getByText(name).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `Allowed URIs: ${redirectUris}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client ID: ${clientId}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client secret: ${clientSecret}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client documentation: ${API_CLIENT_DOCUMENTATION}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `Last generated on: ${updated}` }).toBeInTheDocument); + const link = screen.getByText('contact Enterprise Customer Support.'); + expect(link.getAttribute('href')).toBe(HELP_CENTER_LINK); + }); + test('renders error stage while creating new api credentials through clicking generation button', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + mockFetchFn.mockRejectedValue(); + const mockCreatFn = jest.spyOn(LmsApiService, 'createNewAPICredentials'); + mockCreatFn.mockRejectedValue(); + + render( + + + , + ); + + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + userEvent.click(screen.getByText('Generate API Credentials')); + await waitFor(() => { expect(mockCreatFn).toHaveBeenCalled(); }); + expect( + screen.getByText( + 'Something went wrong while generating your credentials. Please try again. If the issue continues, contact Enterprise Customer Support.', + ), + ).toBeInTheDocument(); + }); + test('renders api credentials page after successfully creating api credentials through clicking generation button', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + mockFetchFn.mockRejectedValue(); + const mockCreatFn = jest.spyOn(LmsApiService, 'createNewAPICredentials'); + mockCreatFn.mockResolvedValue({ data }); + const writeText = jest.fn(); + Object.assign(navigator, { + clipboard: { + writeText, + }, + }); + const jsonString = JSON.stringify(copiedData); + navigator.clipboard.writeText.mockResolvedValue(jsonString); + + render( + + + , + ); + + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + + userEvent.click(screen.getByText('Generate API Credentials')); + await waitFor(() => expect(mockCreatFn).toHaveBeenCalled()); + expect(screen.getByText('API credentials successfully generated')).toBeInTheDocument(); + const closeButton = screen.getByLabelText('Close'); + userEvent.click(closeButton); + await waitFor(() => { + expect(screen.queryByText('API credentials successfully generated')).not.toBeInTheDocument(); + }); + + expect(screen.getByRole('heading', { name: `Application name: ${name}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `Allowed URIs: ${redirectUris}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client ID: ${clientId}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client secret: ${clientSecret}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client documentation: ${API_CLIENT_DOCUMENTATION}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `Last generated on: ${updated}` }).toBeInTheDocument); + + const copyBtn = screen.getByText('Copy credentials to clipboard'); + userEvent.click(copyBtn); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(jsonString)); + await waitFor(() => expect(screen.getByText('Copied Successfully')).toBeInTheDocument()); + }); + test('renders error message when failing to copying api credentials to clipboard', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + mockFetchFn.mockRejectedValue(); + const mockCreatFn = jest.spyOn(LmsApiService, 'createNewAPICredentials'); + mockCreatFn.mockResolvedValue({ data }); + const writeText = jest.fn(); + Object.assign(navigator, { + clipboard: { + writeText, + }, + }); + const jsonString = JSON.stringify(copiedData); + navigator.clipboard.writeText.mockRejectedValue(); + + render( + + + , + ); + + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + + userEvent.click(screen.getByText('Generate API Credentials')); + await waitFor(() => expect(mockCreatFn).toHaveBeenCalled()); + const copyBtn = screen.getByText('Copy credentials to clipboard'); + userEvent.click(copyBtn); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(jsonString)); + await waitFor(() => expect(screen.getByText('Cannot copied to the clipboard')).toBeInTheDocument()); + }); + test('renders api credentials page after successfully regenerating api credentials', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + mockFetchFn.mockResolvedValue({ data }); + const mockPatchFn = jest.spyOn(LmsApiService, 'regenerateAPICredentials'); + mockPatchFn.mockResolvedValue({ data: regenerationData }); + + render( + + + , + ); + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + const input = screen.getByTestId('form-control'); + expect(input).toHaveValue(''); + userEvent.type(input, redirectUris); + await waitFor(() => expect(input).toHaveValue(redirectUris)); + const button = screen.getByText('Regenerate API Credentials'); + userEvent.click(button); + + await waitFor(() => expect(screen.getByText('Regenerate API credentials?')).toBeInTheDocument()); + const confirmedButton = screen.getByText('Regenerate'); + userEvent.click(confirmedButton); + await waitFor(() => { + expect(mockPatchFn).toHaveBeenCalledWith(redirectUris, enterpriseId); + }); + expect(screen.getByRole('heading', { name: `Application name: ${name}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `Allowed URIs: ${redirectUris}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client ID: ${clientId}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client secret: ${clientSecret}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `API client documentation: ${API_CLIENT_DOCUMENTATION}` }).toBeInTheDocument); + expect(screen.getByRole('heading', { name: `Last generated on: ${updated}` }).toBeInTheDocument); + expect(screen.queryByText('Something went wrong while generating your credentials. Please try again. If the issue continues, contact Enterprise Customer Support.')) + .not.toBeInTheDocument(); + }); + test('renders error state when failing to regenerating api credentials', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + mockFetchFn.mockResolvedValue({ data }); + const mockPatchFn = jest.spyOn(LmsApiService, 'regenerateAPICredentials'); + mockPatchFn.mockRejectedValue(); + + render( + + + , + ); + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + const input = screen.getByTestId('form-control'); + expect(input).toHaveValue(''); + userEvent.type(input, redirectUris); + await waitFor(() => expect(input).toHaveValue(redirectUris)); + const button = screen.getByText('Regenerate API Credentials'); + userEvent.click(button); + + await waitFor(() => expect(screen.getByText('Regenerate API credentials?')).toBeInTheDocument()); + const confirmedButton = screen.getByText('Regenerate'); + userEvent.click(confirmedButton); + await waitFor(() => { + expect(mockPatchFn).toHaveBeenCalledWith(redirectUris, enterpriseId); + }); + expect(screen.getByRole('heading', { name: `Allowed URIs: ${redirectUris}` }).toBeInTheDocument); + expect(screen.getByText('Something went wrong while generating your credentials. Please try again. If the issue continues, contact Enterprise Customer Support.')) + .toBeInTheDocument(); + }); + test('renders api credentials when canceling regenerating api credentials', async () => { + const mockFetchFn = jest.spyOn(LmsApiService, 'fetchAPICredentials'); + mockFetchFn.mockResolvedValue({ data }); + const mockPatchFn = jest.spyOn(LmsApiService, 'regenerateAPICredentials'); + mockPatchFn.mockResolvedValue({ data: regenerationData }); + + render( + + + , + ); + + await waitFor(() => expect(mockFetchFn).toHaveBeenCalled()); + const input = screen.getByTestId('form-control'); + expect(input).toHaveValue(''); + userEvent.type(input, redirectUris); + await waitFor(() => expect(input).toHaveValue(redirectUris)); + const button = screen.getByText('Regenerate API Credentials'); + userEvent.click(button); + + await waitFor(() => expect(screen.getByText('Regenerate API credentials?')).toBeInTheDocument()); + const cancelButton = screen.getByText('Cancel'); + userEvent.click(cancelButton); + await waitFor(() => { + expect(mockPatchFn).not.toHaveBeenCalledWith(redirectUris, enterpriseId); + }); + expect(screen.getByRole('heading', { name: `Allowed URIs: ${redirectUris}` }).toBeInTheDocument); + }); +}); diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index 54c3c3fa1d..1da30d6a09 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -7,11 +7,12 @@ import PropTypes from 'prop-types'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { - Alert, Button, Hyperlink, Toast, Skeleton, useToggle, + Alert, Button, Toast, Skeleton, useToggle, } from '@edx/paragon'; import { Add, Info } from '@edx/paragon/icons'; import { logError } from '@edx/frontend-platform/logging'; +import HelpCenterButton from '../HelpCenterButton'; import { camelCaseDictArray, getChannelMap } from '../../../utils'; import LMSConfigPage from './LMSConfigPage'; import ExistingLMSCardDeck from './ExistingLMSCardDeck'; @@ -149,13 +150,9 @@ const SettingsLMSTab = ({ return (

Learning Platform Integrations - + Help Center: Integrations - +
{!configsLoading && !config && (