From 7b3605d05993842a45cc20059c4fe24ad4872249 Mon Sep 17 00:00:00 2001 From: Manny Date: Wed, 19 Jan 2022 16:12:34 -0500 Subject: [PATCH] Settings - Toggle Universal Link (#678) setting page: toggle universal link, deactivating individual links --- .../SettingsAccessTab/ActionsTableCell.jsx | 16 ++- .../DisableLinkManagementAlertModal.jsx | 64 ++++------ .../LinkDeactivationAlertModal.jsx | 10 +- .../SettingsAccessGenerateLinkButton.jsx | 6 + .../SettingsAccessLinkManagement.jsx | 96 ++++++++++++--- .../SettingsAccessTabSection.jsx | 22 +++- .../DisableLinkManagementAlertModal.test.jsx | 65 +++++++++++ .../tests/LinkDeactivationAlertModal.test.jsx | 57 +++++++++ .../SettingsAccessLinkManagement.test.jsx | 110 ++++++++++++++++++ .../SettingsAccessTab/tests/TestUtils.jsx | 68 +++++++++++ src/data/actions/portalConfiguration.js | 7 ++ src/data/constants/portalConfiguration.js | 2 + src/data/reducers/portalConfiguration.js | 10 ++ src/data/reducers/portalConfiguration.test.js | 3 + src/data/services/LmsApiService.js | 32 +++++ src/eventTracking.js | 10 ++ 16 files changed, 510 insertions(+), 68 deletions(-) create mode 100644 src/components/settings/SettingsAccessTab/tests/DisableLinkManagementAlertModal.test.jsx create mode 100644 src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx create mode 100644 src/components/settings/SettingsAccessTab/tests/SettingsAccessLinkManagement.test.jsx create mode 100644 src/components/settings/SettingsAccessTab/tests/TestUtils.jsx diff --git a/src/components/settings/SettingsAccessTab/ActionsTableCell.jsx b/src/components/settings/SettingsAccessTab/ActionsTableCell.jsx index 0bcd29ea13..bae57b8065 100644 --- a/src/components/settings/SettingsAccessTab/ActionsTableCell.jsx +++ b/src/components/settings/SettingsAccessTab/ActionsTableCell.jsx @@ -3,11 +3,13 @@ import { ActionRow, Button } from '@edx/paragon'; import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform/config'; import { logError } from '@edx/frontend-platform/logging'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import LinkDeactivationAlertModal from './LinkDeactivationAlertModal'; import LinkCopiedToast from './LinkCopiedToast'; +import { SETTINGS_ACCESS_EVENTS } from '../../../eventTracking'; -const ActionsTableCell = ({ row, onDeactivateLink }) => { +const ActionsTableCell = ({ row, onDeactivateLink, enterpriseUUID }) => { const [isLinkDeactivationModalOpen, setIsLinkDeactivationModalOpen] = useState(false); const [isCopyLinkToastOpen, setIsCopyLinkToastOpen] = useState(false); const { isValid, uuid: inviteKeyUUID } = row.original; @@ -29,12 +31,22 @@ const ActionsTableCell = ({ row, onDeactivateLink }) => { } catch (error) { logError(error); } + sendEnterpriseTrackEvent( + enterpriseUUID, + SETTINGS_ACCESS_EVENTS.UNIVERSAL_LINK_COPIED, + { invite_key_uuid: inviteKeyUUID }, + ); }; addToClipboard(); }; const handleDeactivateClick = () => { setIsLinkDeactivationModalOpen(true); + sendEnterpriseTrackEvent( + enterpriseUUID, + SETTINGS_ACCESS_EVENTS.UNIVERSAL_LINK_DEACTIVATE, + { invite_key_uuid: inviteKeyUUID }, + ); }; const closeLinkDeactivationModal = () => { @@ -66,6 +78,7 @@ const ActionsTableCell = ({ row, onDeactivateLink }) => { isOpen={isLinkDeactivationModalOpen} onClose={closeLinkDeactivationModal} onDeactivateLink={handleLinkDeactivated} + inviteKeyUUID={inviteKeyUUID} /> @@ -80,6 +93,7 @@ ActionsTableCell.propTypes = { }), }).isRequired, onDeactivateLink: PropTypes.func, + enterpriseUUID: PropTypes.string.isRequired, }; ActionsTableCell.defaultProps = { diff --git a/src/components/settings/SettingsAccessTab/DisableLinkManagementAlertModal.jsx b/src/components/settings/SettingsAccessTab/DisableLinkManagementAlertModal.jsx index 925067f5fe..740e02fbca 100644 --- a/src/components/settings/SettingsAccessTab/DisableLinkManagementAlertModal.jsx +++ b/src/components/settings/SettingsAccessTab/DisableLinkManagementAlertModal.jsx @@ -1,45 +1,22 @@ -import React, { useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { ActionRow, AlertModal, + Alert, Button, StatefulButton, } from '@edx/paragon'; -import { logError } from '@edx/frontend-platform/logging'; +import { Info } from '@edx/paragon/icons'; const DisableLinkManagementAlertModal = ({ isOpen, onClose, - onDisableLinkManagement, + onDisable, + isLoading, + error, }) => { - const [modalDisableButtonState, setModalDisableButtonState] = useState('default'); - - const handleClose = () => { - if (onClose) { - onClose(); - } - }; - - const handleDisableLinkManagement = () => { - const disableLinkManagement = async () => { - setModalDisableButtonState('pending'); - try { - // TODO: make legit API request - await new Promise((resolve) => { - setTimeout(() => resolve(), 2000); - }); - if (onDisableLinkManagement) { - onDisableLinkManagement(); - } - } catch (error) { - logError(error); - } finally { - setModalDisableButtonState('default'); - } - }; - disableLinkManagement(); - }; + const modalDisableButtonState = isLoading ? 'pending' : 'default'; const disableButtonProps = { labels: { @@ -48,21 +25,27 @@ const DisableLinkManagementAlertModal = ({ }, state: modalDisableButtonState, variant: 'primary', - onClick: handleDisableLinkManagement, + onClick: onDisable, }; return ( - - Disable + + Disable )} > + {error && ( + + Something went wrong + There was an issue with your request, please try again. + + )}

If you disable access via link, all links will be deactivated and your learners will no longer have access. Links cannot be reactivated. @@ -72,15 +55,16 @@ const DisableLinkManagementAlertModal = ({ }; DisableLinkManagementAlertModal.propTypes = { - isOpen: PropTypes.bool, - onClose: PropTypes.func, - onDisableLinkManagement: PropTypes.func, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onDisable: PropTypes.func.isRequired, + isLoading: PropTypes.bool, + error: PropTypes.bool, }; DisableLinkManagementAlertModal.defaultProps = { - isOpen: false, - onClose: undefined, - onDisableLinkManagement: undefined, + isLoading: false, + error: false, }; export default DisableLinkManagementAlertModal; diff --git a/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx b/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx index 0347354da1..b903bd4805 100644 --- a/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx +++ b/src/components/settings/SettingsAccessTab/LinkDeactivationAlertModal.jsx @@ -7,11 +7,13 @@ import { StatefulButton, } from '@edx/paragon'; import { logError } from '@edx/frontend-platform/logging'; +import LmsApiService from '../../../data/services/LmsApiService'; const LinkDeactivationAlertModal = ({ isOpen, onClose, onDeactivateLink, + inviteKeyUUID, }) => { const [deactivationState, setDeactivationState] = useState('default'); @@ -25,17 +27,12 @@ const LinkDeactivationAlertModal = ({ const deactivateLink = async () => { setDeactivationState('pending'); try { - // TODO: make legit API request - await new Promise((resolve) => { - setTimeout(() => resolve(), 2000); - }); + await LmsApiService.disableEnterpriseCustomerLink(inviteKeyUUID); if (onDeactivateLink) { onDeactivateLink(); } } catch (error) { logError(error); - } finally { - setDeactivationState('default'); } }; deactivateLink(); @@ -77,6 +74,7 @@ LinkDeactivationAlertModal.propTypes = { isOpen: PropTypes.bool, onClose: PropTypes.func, onDeactivateLink: PropTypes.func, + inviteKeyUUID: PropTypes.string.isRequired, }; LinkDeactivationAlertModal.defaultProps = { diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx index 588f626e36..b86219e123 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessGenerateLinkButton.jsx @@ -5,9 +5,11 @@ import { } from '@edx/paragon'; import moment from 'moment'; import { logError } from '@edx/frontend-platform/logging'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import LmsApiService from '../../../data/services/LmsApiService'; import { SettingsContext } from '../SettingsContext'; +import { SETTINGS_ACCESS_EVENTS } from '../../../eventTracking'; const BUTTON_PROPS = { labels: { @@ -44,6 +46,10 @@ const SettingsAccessGenerateLinkButton = ({ logError(error); } finally { setLoadingLinkCreation(false); + sendEnterpriseTrackEvent( + enterpriseId, + SETTINGS_ACCESS_EVENTS.UNIVERSAL_LINK_GENERATE, + ); } }; diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx index 5a67acea21..533b317f0f 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessLinkManagement.jsx @@ -1,9 +1,14 @@ -import React, { useState } from 'react'; +import React, { useState, useContext } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { DataTable, + Alert, } from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; +import moment from 'moment'; +import { logError } from '@edx/frontend-platform/logging'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { useLinkManagement } from '../data/hooks'; import SettingsAccessTabSection from './SettingsAccessTabSection'; @@ -14,15 +19,59 @@ import LinkTableCell from './LinkTableCell'; import UsageTableCell from './UsageTableCell'; import ActionsTableCell from './ActionsTableCell'; import DisableLinkManagementAlertModal from './DisableLinkManagementAlertModal'; +import { updatePortalConfigurationEvent } from '../../../data/actions/portalConfiguration'; +import LmsApiService from '../../../data/services/LmsApiService'; +import { SettingsContext } from '../SettingsContext'; +import { SETTINGS_ACCESS_EVENTS } from '../../../eventTracking'; -const SettingsAccessLinkManagement = ({ enterpriseUUID }) => { +const SettingsAccessLinkManagement = ({ + enterpriseUUID, + isUniversalLinkEnabled, + dispatch, +}) => { const { links, loadingLinks, refreshLinks, } = useLinkManagement(enterpriseUUID); - const [isLinkManagementEnabled, setIsLinkManagementEnabled] = useState(true); + + const { + customerAgreement: { netDaysUntilExpiration }, + } = useContext(SettingsContext); + const [isLinkManagementAlertModalOpen, setIsLinkManagementAlertModalOpen] = useState(false); + const [isLoadingLinkManagementEnabledChange, setIsLoadingLinkManagementEnabledChange] = useState(false); + const [hasLinkManagementEnabledChangeError, setHasLinkManagementEnabledChangeError] = useState(false); + + const toggleUniversalLink = async (newEnableUniversalLink) => { + setIsLoadingLinkManagementEnabledChange(true); + const args = { + enterpriseUUID, + enableUniversalLink: newEnableUniversalLink, + }; + + if (newEnableUniversalLink) { + args.expirationDate = moment().add(netDaysUntilExpiration, 'days').startOf('day').format(); + } + + try { + await LmsApiService.toggleEnterpriseCustomerUniversalLink(args); + dispatch(updatePortalConfigurationEvent({ enableUniversalLink: newEnableUniversalLink })); + setIsLinkManagementAlertModalOpen(false); + setHasLinkManagementEnabledChangeError(false); + refreshLinks(); + } catch (error) { + logError(error); + setHasLinkManagementEnabledChangeError(true); + } finally { + sendEnterpriseTrackEvent( + enterpriseUUID, + SETTINGS_ACCESS_EVENTS.UNIVERSAL_LINK_TOGGLE, + { toggle_to: newEnableUniversalLink }, + ); + setIsLoadingLinkManagementEnabledChange(false); + } + }; const handleLinkManagementCollapsibleToggled = (isOpen) => { if (isOpen) { @@ -38,20 +87,10 @@ const SettingsAccessLinkManagement = ({ enterpriseUUID }) => { refreshLinks(); }; - const handleLinkManagementAlertModalClose = () => { - setIsLinkManagementAlertModalOpen(false); - }; - - const handleLinkManagementDisabledSuccess = () => { - refreshLinks(); - setIsLinkManagementEnabled(false); - setIsLinkManagementAlertModalOpen(false); - }; - const handleLinkManagementFormSwitchChanged = (e) => { const isChecked = e.target.checked; if (isChecked) { - setIsLinkManagementEnabled(isChecked); + toggleUniversalLink(isChecked); } else { setIsLinkManagementAlertModalOpen(true); } @@ -59,11 +98,19 @@ const SettingsAccessLinkManagement = ({ enterpriseUUID }) => { return ( <> + {hasLinkManagementEnabledChangeError && !isLinkManagementAlertModalOpen && ( + + Something went wrong + There was an issue with your request, please try again. + + )}

Generate a link to share with your learners.

{ tableActions={() => ( )} columns={[ @@ -102,7 +149,13 @@ const SettingsAccessLinkManagement = ({ enterpriseUUID }) => { { id: 'action', Header: '', - Cell: props => , + Cell: props => ( + + ), }, ]} > @@ -113,8 +166,10 @@ const SettingsAccessLinkManagement = ({ enterpriseUUID }) => { { setIsLinkManagementAlertModalOpen(false); }} + onDisable={() => (toggleUniversalLink(false))} + isLoadingDisable={isLoadingLinkManagementEnabledChange} + error={hasLinkManagementEnabledChangeError} /> ); @@ -122,10 +177,13 @@ const SettingsAccessLinkManagement = ({ enterpriseUUID }) => { const mapStateToProps = (state) => ({ enterpriseUUID: state.portalConfiguration.enterpriseId, + isUniversalLinkEnabled: state.portalConfiguration.enableUniversalLink, }); SettingsAccessLinkManagement.propTypes = { enterpriseUUID: PropTypes.string.isRequired, + isUniversalLinkEnabled: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, }; export default connect(mapStateToProps)(SettingsAccessLinkManagement); diff --git a/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx b/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx index d43c99ff5b..1b53617c15 100644 --- a/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx +++ b/src/components/settings/SettingsAccessTab/SettingsAccessTabSection.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { Collapsible, Form, + Spinner, } from '@edx/paragon'; const SettingsAccessTabSection = ({ @@ -11,6 +12,8 @@ const SettingsAccessTabSection = ({ onFormSwitchChange, onCollapsibleToggle, children, + loading, + disabled, }) => { const [isExpanded, setExpanded] = useState(true); @@ -23,8 +26,19 @@ const SettingsAccessTabSection = ({ return (
-
- Enable +
+ {loading && ( + + )} + + Enable +
', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + test('Error message is displayed', () => { + render( {}} + onDisable={() => {}} + error + />); + const cancelButton = screen.getByText('Something went wrong'); + expect(cancelButton).toBeTruthy(); + }); + test('Buttons disabled if `isLoadingDisable`', () => { + render( {}} + onDisable={() => {}} + isLoading + />); + const disableButton = screen.queryByText('Disabling...').closest('button'); + expect(disableButton).toBeTruthy(); + expect(disableButton).toHaveProperty('disabled', true); + + const backButton = screen.queryByText('Go back').closest('button'); + expect(backButton).toBeTruthy(); + expect(backButton).toHaveProperty('disabled', true); + }); + test('`Disable` button calls `onDisable`', async () => { + const onDisableMock = jest.fn(); + render( {}} + onDisable={onDisableMock} + />); + const disableButton = screen.getByText('Disable'); + await act(async () => { userEvent.click(disableButton); }); + expect(onDisableMock).toHaveBeenCalledTimes(1); + }); + test('`Go back` button calls `onClose`', async () => { + const onCloseMock = jest.fn(); + render( {}} + />); + const backButton = screen.getByText('Go back'); + await act(async () => { userEvent.click(backButton); }); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx b/src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx new file mode 100644 index 0000000000..9da8043bfd --- /dev/null +++ b/src/components/settings/SettingsAccessTab/tests/LinkDeactivationAlertModal.test.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { + screen, + render, + cleanup, + act, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import LmsApiService from '../../../../data/services/LmsApiService'; +import LinkDeactivationAlertModal from '../LinkDeactivationAlertModal'; + +jest.mock('../../../../data/services/LmsApiService', () => ({ + __esModule: true, + default: { + disableEnterpriseCustomerLink: jest.fn(), + }, +})); + +const TEST_INVITE_KEY = 'test-invite-key'; + +describe('', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + test('`Deactivate` button calls api and `onDeactivateLink`', async () => { + const onDeactivateLinkMock = jest.fn(); + const mockPromiseResolve = Promise.resolve({ data: {} }); + LmsApiService.disableEnterpriseCustomerLink.mockReturnValue(mockPromiseResolve); + render(); + // Click `Deactivate` button + const deactivateButton = screen.getByText('Deactivate'); + await act(async () => { userEvent.click(deactivateButton); }); + // `onDeactivateLink` and api service should have been called + expect(LmsApiService.disableEnterpriseCustomerLink).toHaveBeenCalledWith( + TEST_INVITE_KEY, + ); + expect(onDeactivateLinkMock).toHaveBeenCalledTimes(1); + }); + test('`Go back` calls `onClose`', async () => { + const onCloseMock = jest.fn(); + render(); + const backButton = screen.getByText('Go back'); + await act(async () => { userEvent.click(backButton); }); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/settings/SettingsAccessTab/tests/SettingsAccessLinkManagement.test.jsx b/src/components/settings/SettingsAccessTab/tests/SettingsAccessLinkManagement.test.jsx new file mode 100644 index 0000000000..0b2c499e3d --- /dev/null +++ b/src/components/settings/SettingsAccessTab/tests/SettingsAccessLinkManagement.test.jsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { + screen, + render, + cleanup, + act, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import moment from 'moment'; + +import LmsApiService from '../../../../data/services/LmsApiService'; +import MockSettingsContext, { MOCK_CONSTANTS, generateStore } from './TestUtils'; +import SettingsAccessLinkManagement from '../SettingsAccessLinkManagement'; +import * as hooks from '../../data/hooks'; +import { SETTINGS_ACCESS_EVENTS } from '../../../../eventTracking'; + +jest.mock('../../../../data/services/LmsApiService', () => ({ + __esModule: true, + default: { + toggleEnterpriseCustomerUniversalLink: jest.fn(), + }, +})); + +jest.mock('@edx/frontend-enterprise-utils', () => ({ + sendEnterpriseTrackEvent: jest.fn(), +})); + +const mockRefreshLinks = jest.fn(); +const renderWithContext = (store = generateStore(), links = [], loadingLinks = false) => { + jest.spyOn(hooks, 'useLinkManagement').mockImplementation( + () => ({ + links, + loadingLinks, + refreshLinks: mockRefreshLinks, + }), + ); + return ( + + + + ); +}; + +describe('', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + test('Toggle Universal Link Off', async () => { + LmsApiService.toggleEnterpriseCustomerUniversalLink.mockReturnValue({ data: {} }); + render(renderWithContext()); + + // Clicking `Enable` opens modal + const switchInput = screen.queryByText('Enable'); + await act(async () => { userEvent.click(switchInput); }); + expect(screen.queryByText('Are you sure?')).toBeTruthy(); + + // Clicking disable calls api + const disableButton = screen.queryByText('Disable'); + await act(async () => { userEvent.click(disableButton); }); + expect(LmsApiService.toggleEnterpriseCustomerUniversalLink).toHaveBeenCalledTimes(1); + expect(LmsApiService.toggleEnterpriseCustomerUniversalLink).toHaveBeenCalledWith({ + enterpriseUUID: MOCK_CONSTANTS.ENTERPRISE_ID, + enableUniversalLink: false, + }); + + // Links are refreshed + expect(mockRefreshLinks).toHaveBeenCalledTimes(1); + + // Event is sent + expect(sendEnterpriseTrackEvent).toHaveBeenCalledWith( + MOCK_CONSTANTS.ENTERPRISE_ID, + SETTINGS_ACCESS_EVENTS.UNIVERSAL_LINK_TOGGLE, + { toggle_to: false }, + ); + + // Modal is closed + expect(screen.queryByText('Are you sure?')).toBeFalsy(); + }); + + test('Toggle Universal Link On', async () => { + LmsApiService.toggleEnterpriseCustomerUniversalLink.mockReturnValue({ data: {} }); + render(renderWithContext(generateStore({ enableUniversalLink: false }))); + + // Clicking `Enable` does not open modal + const switchInput = screen.queryByText('Enable'); + await act(async () => { userEvent.click(switchInput); }); + expect(screen.queryByText('Are you sure?')).toBeFalsy(); + + // It calls api + expect(LmsApiService.toggleEnterpriseCustomerUniversalLink).toHaveBeenCalledTimes(1); + expect(LmsApiService.toggleEnterpriseCustomerUniversalLink).toHaveBeenCalledWith({ + enterpriseUUID: MOCK_CONSTANTS.ENTERPRISE_ID, + enableUniversalLink: true, + expirationDate: moment().add(MOCK_CONSTANTS.NET_DAYS_UNTIL_EXPIRATION, 'days').startOf('day').format(), + }); + + // Links are refreshed + expect(mockRefreshLinks).toHaveBeenCalledTimes(1); + + // Event is sent + expect(sendEnterpriseTrackEvent).toHaveBeenCalledWith( + MOCK_CONSTANTS.ENTERPRISE_ID, + SETTINGS_ACCESS_EVENTS.UNIVERSAL_LINK_TOGGLE, + { toggle_to: true }, + ); + }); +}); diff --git a/src/components/settings/SettingsAccessTab/tests/TestUtils.jsx b/src/components/settings/SettingsAccessTab/tests/TestUtils.jsx new file mode 100644 index 0000000000..349667afae --- /dev/null +++ b/src/components/settings/SettingsAccessTab/tests/TestUtils.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import PropTypes from 'prop-types'; + +import SettingsContextProvider from '../../SettingsContext'; +import * as hooks from '../../data/hooks'; + +const mockStore = configureMockStore(); + +const ENTERPRISE_ID = 'test-enterprise'; +const NET_DAYS_UNTIL_EXPIRATION = 100; + +export const MOCK_CONSTANTS = { + ENTERPRISE_ID, + NET_DAYS_UNTIL_EXPIRATION, +}; + +const basicStore = { + portalConfiguration: { + enterpriseId: ENTERPRISE_ID, + enableUniversalLink: true, + }, +}; + +/** + * Generates Store from `basicStore` + * @param {Object} portalConfiguration + * @returns {Object} Generated store + */ +export const generateStore = (portalConfiguration) => (mockStore({ + ...basicStore, + portalConfiguration: { + ...basicStore.portalConfiguration, + ...portalConfiguration, + }, +})); + +const mockSettingsHooks = (loadingCustomerAgreement = false) => { + jest.spyOn(hooks, 'useCustomerAgreementData').mockImplementation( + () => ({ + customerAgreement: { netDaysUntilExpiration: NET_DAYS_UNTIL_EXPIRATION }, + loadingCustomerAgreement, + }), + ); +}; + +const MockSettingsContext = ({ store, children }) => { + mockSettingsHooks(); + return ( + + + {children} + + + ); +}; + +MockSettingsContext.propTypes = { + children: PropTypes.node.isRequired, + store: PropTypes.shape(), +}; + +MockSettingsContext.defaultProps = { + store: basicStore, +}; + +export default MockSettingsContext; diff --git a/src/data/actions/portalConfiguration.js b/src/data/actions/portalConfiguration.js index 5ef8321ac4..a578072bb1 100644 --- a/src/data/actions/portalConfiguration.js +++ b/src/data/actions/portalConfiguration.js @@ -4,6 +4,7 @@ import { FETCH_PORTAL_CONFIGURATION_SUCCESS, FETCH_PORTAL_CONFIGURATION_FAILURE, CLEAR_PORTAL_CONFIGURATION, + UPDATE_PORTAL_CONFIGURATION, } from '../constants/portalConfiguration'; import LmsApiService from '../services/LmsApiService'; @@ -21,6 +22,11 @@ const fetchPortalConfigurationFailure = error => ({ payload: { error }, }); +const updatePortalConfigurationEvent = data => ({ + type: UPDATE_PORTAL_CONFIGURATION, + payload: { data }, +}); + const clearPortalConfigurationEvent = () => ({ type: CLEAR_PORTAL_CONFIGURATION }); const fetchPortalConfiguration = slug => ( @@ -46,4 +52,5 @@ const clearPortalConfiguration = () => ( export { fetchPortalConfiguration, clearPortalConfiguration, + updatePortalConfigurationEvent, }; diff --git a/src/data/constants/portalConfiguration.js b/src/data/constants/portalConfiguration.js index fbb92fe926..20b80f0611 100644 --- a/src/data/constants/portalConfiguration.js +++ b/src/data/constants/portalConfiguration.js @@ -2,10 +2,12 @@ const FETCH_PORTAL_CONFIGURATION_REQUEST = 'FETCH_PORTAL_CONFIGURATION_REQUEST'; const FETCH_PORTAL_CONFIGURATION_SUCCESS = 'FETCH_PORTAL_CONFIGURATION_SUCCESS'; const FETCH_PORTAL_CONFIGURATION_FAILURE = 'FETCH_PORTAL_CONFIGURATION_FAILURE'; const CLEAR_PORTAL_CONFIGURATION = 'CLEAR_PORTAL_CONFIGURATION'; +const UPDATE_PORTAL_CONFIGURATION = 'UPDATE_PORTAL_CONFIGURATION'; export { FETCH_PORTAL_CONFIGURATION_REQUEST, FETCH_PORTAL_CONFIGURATION_SUCCESS, FETCH_PORTAL_CONFIGURATION_FAILURE, CLEAR_PORTAL_CONFIGURATION, + UPDATE_PORTAL_CONFIGURATION, }; diff --git a/src/data/reducers/portalConfiguration.js b/src/data/reducers/portalConfiguration.js index d5144c670c..59e32bde99 100644 --- a/src/data/reducers/portalConfiguration.js +++ b/src/data/reducers/portalConfiguration.js @@ -3,6 +3,7 @@ import { FETCH_PORTAL_CONFIGURATION_SUCCESS, FETCH_PORTAL_CONFIGURATION_FAILURE, CLEAR_PORTAL_CONFIGURATION, + UPDATE_PORTAL_CONFIGURATION, } from '../constants/portalConfiguration'; const initialState = { @@ -19,6 +20,7 @@ const initialState = { enableSamlConfigurationScreen: false, enableLmsConfigurationsScreen: false, enableAnalyticsScreen: false, + enableUniversalLink: false, }; const portalConfiguration = (state = initialState, action) => { @@ -48,6 +50,7 @@ const portalConfiguration = (state = initialState, action) => { enableAnalyticsScreen: action.payload.data.enable_analytics_screen, enableLearnerPortal: action.payload.data.enable_learner_portal, enableLmsConfigurationsScreen: action.payload.data.enable_portal_lms_configurations_screen, + enableUniversalLink: action.payload.data.enable_universal_link, }; case FETCH_PORTAL_CONFIGURATION_FAILURE: return { @@ -65,6 +68,7 @@ const portalConfiguration = (state = initialState, action) => { enableSamlConfigurationScreen: false, enableLmsConfigurationsScreen: false, enableAnalyticsScreen: false, + enableUniversalLink: false, }; case CLEAR_PORTAL_CONFIGURATION: return { @@ -80,6 +84,12 @@ const portalConfiguration = (state = initialState, action) => { enableSamlConfigurationScreen: false, enableLmsConfigurationsScreen: false, enableAnalyticsScreen: false, + enableUniversalLink: false, + }; + case UPDATE_PORTAL_CONFIGURATION: + return { + ...state, + ...action.payload.data, }; default: return state; diff --git a/src/data/reducers/portalConfiguration.test.js b/src/data/reducers/portalConfiguration.test.js index 665c26d033..27a2ba42c5 100644 --- a/src/data/reducers/portalConfiguration.test.js +++ b/src/data/reducers/portalConfiguration.test.js @@ -19,6 +19,7 @@ const initialState = { enableSamlConfigurationScreen: false, enableAnalyticsScreen: false, enableLmsConfigurationsScreen: false, + enableUniversalLink: false, }; const enterpriseData = { @@ -37,6 +38,7 @@ const enterpriseData = { enable_portal_saml_configuration_screen: true, enable_analytics_screen: true, enable_portal_lms_configurations_screen: true, + enable_universal_link: true, }; describe('portalConfiguration reducer', () => { @@ -61,6 +63,7 @@ describe('portalConfiguration reducer', () => { enableSamlConfigurationScreen: enterpriseData.enable_portal_saml_configuration_screen, enableAnalyticsScreen: enterpriseData.enable_analytics_screen, enableLmsConfigurationsScreen: enterpriseData.enable_portal_lms_configurations_screen, + enableUniversalLink: enterpriseData.enable_universal_link, }; expect(portalConfiguration(undefined, { type: FETCH_PORTAL_CONFIGURATION_SUCCESS, diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 72a7e0c481..da67b36a94 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -240,6 +240,38 @@ class LmsApiService { const url = `${LmsApiService.enterpriseCustomerInviteKeyListUrl}?${queryParams.toString()}`; return LmsApiService.apiClient().get(url); } + + /** + * Disables EnterpriseCustomerInviteKey + * @param {string} enterpriseCustomerInviteKeyUUID uuid EnterpriseCustomerInviteKey to disable + * @returns {Promise} + */ + static disableEnterpriseCustomerLink(enterpriseCustomerInviteKeyUUID) { + const formData = { + is_active: false, + }; + return LmsApiService.apiClient().patch( + `${LmsApiService.enterpriseCustomerInviteKeyUrl}${enterpriseCustomerInviteKeyUUID}/`, + formData, + ); + } + + /** + * Toggles enable_universal_link flag + * If `enable_universal_link` is true and `expiration_date` is passed, an EnterpriseCustomerInviteKey is created + * @param {Object} param0 Object with `enterpriseUUID`, `enableUniversalLink`, `expirationDate` (optional) + * @returns {Promise} + */ + static toggleEnterpriseCustomerUniversalLink({ enterpriseUUID, enableUniversalLink, expirationDate }) { + const formData = { + enable_universal_link: enableUniversalLink, + expiration_date: expirationDate, + }; + return LmsApiService.apiClient().patch( + `${LmsApiService.enterpriseCustomerUrl}${enterpriseUUID}/toggle_universal_link/`, + formData, + ); + } } export default LmsApiService; diff --git a/src/eventTracking.js b/src/eventTracking.js index 9c5d15f9d9..b83fb8494f 100644 --- a/src/eventTracking.js +++ b/src/eventTracking.js @@ -14,6 +14,7 @@ const PROJECT_NAME = 'edx.ui.enterprise.admin_portal'; const SUBSCRIPTION_PREFIX = `${PROJECT_NAME}.subscriptions`; +const SETTINGS_PREFIX = `${PROJECT_NAME}.settings`; const SUBSCRIPTION_TABLE_PREFIX = `${SUBSCRIPTION_PREFIX}.table`; @@ -40,6 +41,15 @@ export const SUBSCRIPTION_TABLE_EVENTS = { REVOKE_BULK_CANCEL: `${SUBSCRIPTION_TABLE_PREFIX}.revoke.bulk.canceled`, }; +const SETTINGS_ACCESS_PREFIX = `${SETTINGS_PREFIX}.ACCESS`; + +export const SETTINGS_ACCESS_EVENTS = { + UNIVERSAL_LINK_TOGGLE: `${SETTINGS_ACCESS_PREFIX}.universal-link.toggle.clicked`, + UNIVERSAL_LINK_GENERATE: `${SETTINGS_ACCESS_PREFIX}.universal-link.generate.clicked`, + UNIVERSAL_LINK_COPIED: `${SETTINGS_ACCESS_PREFIX}.universal-link.copied.clicked`, + UNIVERSAL_LINK_DEACTIVATE: `${SETTINGS_ACCESS_PREFIX}.universal-link.deactivate.clicked`, +}; + export const SUBSCRIPTION_EVENTS = { TABLE: SUBSCRIPTION_TABLE_EVENTS, };