From d36bff4a081f07070490706e8184f2ca0bff70f1 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 1 Dec 2022 12:47:46 -0500 Subject: [PATCH 01/73] fix: Adding sync time data (#910) * fix: toast changes * feat: adding sync time to card * fix: PR requests --- .../ErrorReporting/ContentMetadataTable.jsx | 4 +- .../ErrorReporting/ErrorReportingModal.jsx | 12 ++++-- .../ErrorReporting/LearnerMetadataTable.jsx | 4 +- .../SettingsLMSTab/ErrorReporting/utils.jsx | 2 +- .../settings/SettingsLMSTab/ExistingCard.jsx | 33 +++++++++++++--- .../LMSConfigs/BlackboardConfig.jsx | 6 +-- .../LMSConfigs/CanvasConfig.jsx | 6 +-- .../LMSConfigs/CornerstoneConfig.jsx | 4 +- .../LMSConfigs/Degreed2Config.jsx | 4 +- .../LMSConfigs/DegreedConfig.jsx | 4 +- .../LMSConfigs/MoodleConfig.jsx | 4 +- .../SettingsLMSTab/LMSConfigs/SAPConfig.jsx | 4 +- .../settings/SettingsLMSTab/index.jsx | 28 ++++--------- .../tests/BlackboardConfig.test.jsx | 8 ++-- .../tests/CanvasConfig.test.jsx | 6 +-- .../tests/ExistingLMSCardDeck.test.jsx | 39 +++++++++++++++++++ src/components/settings/data/constants.js | 7 ++-- src/components/settings/settings.scss | 11 ++++++ 18 files changed, 125 insertions(+), 61 deletions(-) diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ContentMetadataTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ContentMetadataTable.jsx index 069c5565d6..d54c342dfa 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/ContentMetadataTable.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/ContentMetadataTable.jsx @@ -9,7 +9,7 @@ import { import { logError } from '@edx/frontend-platform/logging'; import LmsApiService from '../../../../data/services/LmsApiService'; import DownloadCsvButton from './DownloadCsvButton'; -import { createLookup, getSyncStatus, getSyncTime } from './utils'; +import { createLookup, getSyncStatus, getTimeAgo } from './utils'; const ContentMetadataTable = ({ config, enterpriseCustomerUuid }) => { const [currentPage, setCurrentPage] = useState(); @@ -108,7 +108,7 @@ const ContentMetadataTable = ({ config, enterpriseCustomerUuid }) => { { Header: 'Sync attempt time', accessor: 'sync_last_attempted_at', - Cell: ({ row }) => getSyncTime(row.original.sync_last_attempted_at), + Cell: ({ row }) => getTimeAgo(row.original.sync_last_attempted_at), sortable: true, disableFilters: true, }, diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx index e6db1e339e..ebf75b8f81 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx @@ -10,10 +10,12 @@ const ErrorReportingModal = ({ isOpen, close, config, enterpriseCustomerUuid, }) => { const [key, setKey] = useState('contentMetadata'); - + // notification for tab must be a non-empty string to appear + const contentError = config?.lastContentSyncErroredAt == null ? null : ' '; + const learnerError = config?.lastLearnerSyncErroredAt == null ? null : ' '; return ( setKey(k)} className="mb-3" > - +

Most recent data transmission

From edX for Business to {config?.displayName}
- +

Most recent data transmission

From edX for Business to {config?.displayName} @@ -68,6 +70,8 @@ ErrorReportingModal.propTypes = { id: PropTypes.number, channelCode: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired, + lastContentSyncErroredAt: PropTypes.string.isRequired, + lastLearnerSyncErroredAt: PropTypes.string.isRequired, }), enterpriseCustomerUuid: PropTypes.string.isRequired, }; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx index 63fcd6d598..41436121ee 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx @@ -8,7 +8,7 @@ import { } from '@edx/paragon'; import { logError } from '@edx/frontend-platform/logging'; import LmsApiService from '../../../../data/services/LmsApiService'; -import { createLookup, getSyncStatus, getSyncTime } from './utils'; +import { createLookup, getSyncStatus, getTimeAgo } from './utils'; import DownloadCsvButton from './DownloadCsvButton'; const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { @@ -113,7 +113,7 @@ const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { { Header: 'Sync attempt time', accessor: 'sync_last_attempted_at', - Cell: ({ row }) => getSyncTime(row.original.sync_last_attempted_at), + Cell: ({ row }) => getTimeAgo(row.original.sync_last_attempted_at), sortable: true, disableFilters: true, }, diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx index c958d90152..08b7eef71c 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx @@ -55,7 +55,7 @@ export function getSyncStatus(status, statusMessage) { ); } -export function getSyncTime(time) { +export function getTimeAgo(time) { if (!time) { return null; } diff --git a/src/components/settings/SettingsLMSTab/ExistingCard.jsx b/src/components/settings/SettingsLMSTab/ExistingCard.jsx index 805eae0831..4c47019ba3 100644 --- a/src/components/settings/SettingsLMSTab/ExistingCard.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingCard.jsx @@ -3,12 +3,16 @@ import PropTypes from 'prop-types'; import { ActionRow, AlertModal, Badge, Button, Card, Dropdown, Icon, IconButton, Image, OverlayTrigger, Popover, } from '@edx/paragon'; -import { MoreVert } from '@edx/paragon/icons'; +import { + CheckCircle, Error, MoreVert, Sync, +} from '@edx/paragon/icons'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { features } from '../../../config'; import { channelMapping } from '../../../utils'; import handleErrors from '../utils'; -import { TOGGLE_SUCCESS_LABEL, DELETE_SUCCESS_LABEL } from '../data/constants'; +import { getTimeAgo } from './ErrorReporting/utils'; + +import { ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE } from '../data/constants'; const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; const errorDeleteModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; @@ -50,7 +54,7 @@ const ExistingCard = ({ setErrorModalText(errorToggleModalText); openError(); } else { - onClick(TOGGLE_SUCCESS_LABEL); + onClick(toggle ? ACTIVATE_TOAST_MESSAGE : INACTIVATE_TOAST_MESSAGE); } }; @@ -65,7 +69,7 @@ const ExistingCard = ({ setErrorModalText(errorDeleteModalText); openError(); } else { - onClick(DELETE_SUCCESS_LABEL); + onClick(DELETE_TOAST_MESSAGE); setShowDeleteModal(false); } }; @@ -112,13 +116,24 @@ const ExistingCard = ({ } }; + const getLastSync = () => { + if (config.lastSyncErroredAt != null) { + const timeStamp = getTimeAgo(config.lastSyncErroredAt); + return <>Recent sync error:  {timeStamp}; + } + if (config.lastSyncAttemptedAt != null) { + const timeStamp = getTimeAgo(config.lastSyncAttemptedAt); + return <>Last sync:  {timeStamp}; + } + return <>Sync not yet attempted; + }; + const isActive = getStatus(config) === ACTIVE; const isInactive = getStatus(config) === INACTIVE; const isIncomplete = getStatus(config) === INCOMPLETE; return ( <> - {/* TODO: Figure out how to get rid of scroll bar */} )} /> - + +
+ + {getLastSync()} +
{getCardButton()}
@@ -267,6 +286,8 @@ ExistingCard.propTypes = { channelCode: PropTypes.string, id: PropTypes.number, displayName: PropTypes.string, + lastSyncAttemptedAt: PropTypes.string, + lastSyncErroredAt: PropTypes.string, }).isRequired, editExistingConfig: PropTypes.func.isRequired, enterpriseCustomerUuid: PropTypes.string.isRequired, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx index 627a488a96..b9652d5f02 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx @@ -14,7 +14,7 @@ import { BLACKBOARD_OAUTH_REDIRECT_URL, INVALID_LINK, INVALID_NAME, - SUCCESS_LABEL, + SUBMIT_TOAST_MESSAGE, LMS_CONFIG_OAUTH_POLLING_INTERVAL, LMS_CONFIG_OAUTH_POLLING_TIMEOUT, } from '../../data/constants'; @@ -54,7 +54,7 @@ const BlackboardConfig = ({ setOauthPollingTimeout(null); setOauthTimeout(false); // trigger a success call which will redirect the user back to the landing page - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } } catch (error) { err = handleErrors(error); @@ -202,7 +202,7 @@ const BlackboardConfig = ({ setErrCode(errCode); openError(); } else { - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx index 06f8357834..6994e5cb8d 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx @@ -15,7 +15,7 @@ import { CANVAS_OAUTH_REDIRECT_URL, INVALID_LINK, INVALID_NAME, - SUCCESS_LABEL, + SUBMIT_TOAST_MESSAGE, LMS_CONFIG_OAUTH_POLLING_INTERVAL, LMS_CONFIG_OAUTH_POLLING_TIMEOUT, } from '../../data/constants'; @@ -60,7 +60,7 @@ const CanvasConfig = ({ setOauthPollingTimeout(null); setOauthTimeout(false); // trigger a success call which will redirect the user back to the landing page - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } } catch (error) { err = handleErrors(error); @@ -191,7 +191,7 @@ const CanvasConfig = ({ if (err) { openError(); } else { - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/CornerstoneConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/CornerstoneConfig.jsx index 1f8022e751..8633e7a1ef 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/CornerstoneConfig.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/CornerstoneConfig.jsx @@ -8,7 +8,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; import { snakeCaseDict, urlValidation } from '../../../../utils'; import ConfigError from '../../ConfigError'; import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUCCESS_LABEL } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; const CornerstoneConfig = ({ enterpriseCustomerUuid, onClick, existingData, existingConfigs, @@ -65,7 +65,7 @@ const CornerstoneConfig = ({ if (err) { openError(); } else { - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx index 3d5996a6be..e65c78bad1 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx @@ -8,7 +8,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; import { snakeCaseDict, urlValidation } from '../../../../utils'; import ConfigError from '../../ConfigError'; import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUCCESS_LABEL } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; const Degreed2Config = ({ enterpriseCustomerUuid, onClick, existingData, existingConfigs, @@ -73,7 +73,7 @@ const Degreed2Config = ({ if (err) { openError(); } else { - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx index bb40093376..fa700f424a 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx @@ -9,7 +9,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; import { snakeCaseDict, urlValidation } from '../../../../utils'; import ConfigError from '../../ConfigError'; import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUCCESS_LABEL } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; const DegreedConfig = ({ enterpriseCustomerUuid, onClick, existingData, existingConfigs, @@ -79,7 +79,7 @@ const DegreedConfig = ({ if (err) { openError(); } else { - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx index 1c922d8ff6..cc52692158 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx @@ -8,7 +8,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; import { snakeCaseDict, urlValidation } from '../../../../utils'; import ConfigError from '../../ConfigError'; import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUCCESS_LABEL } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; const MoodleConfig = ({ enterpriseCustomerUuid, onClick, existingData, existingConfigs, @@ -87,7 +87,7 @@ const MoodleConfig = ({ if (err) { openError(); } else { - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAPConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAPConfig.jsx index 1c11fdb119..7ea3ba18ba 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/SAPConfig.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/SAPConfig.jsx @@ -8,7 +8,7 @@ import LmsApiService from '../../../../data/services/LmsApiService'; import { snakeCaseDict, urlValidation } from '../../../../utils'; import ConfigError from '../../ConfigError'; import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUCCESS_LABEL } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; const SAPConfig = ({ enterpriseCustomerUuid, onClick, existingData, existingConfigs, @@ -78,7 +78,7 @@ const SAPConfig = ({ if (err) { openError(); } else { - onClick(SUCCESS_LABEL); + onClick(SUBMIT_TOAST_MESSAGE); } }; diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index c4ee440eee..b5c809a992 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -18,16 +18,13 @@ import { HELP_CENTER_LINK, MOODLE_TYPE, SAP_TYPE, - SUCCESS_LABEL, - DELETE_SUCCESS_LABEL, - TOGGLE_SUCCESS_LABEL, + ACTIVATE_TOAST_MESSAGE, + DELETE_TOAST_MESSAGE, + INACTIVATE_TOAST_MESSAGE, + SUBMIT_TOAST_MESSAGE, } from '../data/constants'; import LmsApiService from '../../../data/services/LmsApiService'; -const SUBMIT_TOAST_MESSAGE = 'Configuration was submitted successfully.'; -const TOGGLE_TOAST_MESSAGE = 'Configuration was toggled successfully.'; -const DELETE_TOAST_MESSAGE = 'Configuration was successfully removed.'; - const SettingsLMSTab = ({ enterpriseId, enterpriseSlug, @@ -47,6 +44,7 @@ const SettingsLMSTab = ({ const [existingConfigFormData, setExistingConfigFormData] = useState({}); const [toastMessage, setToastMessage] = useState(); const [displayNeedsSSOAlert, setDisplayNeedsSSOAlert] = useState(false); + const toastMessages = [ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE, SUBMIT_TOAST_MESSAGE]; // onClick function for existing config cards' edit action const editExistingConfig = (configData, configType) => { @@ -89,26 +87,16 @@ const SettingsLMSTab = ({ // back to the landing state from a form (submit or cancel was hit on the forms). In both cases, // we want to clear existing config form data. setExistingConfigFormData({}); - // If either the user has submit or canceled - if (input === '' || [SUCCESS_LABEL, DELETE_SUCCESS_LABEL, TOGGLE_SUCCESS_LABEL].includes(input)) { + if (input === '' || toastMessages.includes(input)) { // Re-fetch existing configs to get newly created ones fetchExistingConfigs(); } - // If the user submitted - if (input === SUCCESS_LABEL) { + if (toastMessages.includes(input)) { // show the toast and reset the config state setShowToast(true); setConfig(''); - setToastMessage(SUBMIT_TOAST_MESSAGE); - } else if (input === TOGGLE_SUCCESS_LABEL) { - setShowToast(true); - setConfig(''); - setToastMessage(TOGGLE_TOAST_MESSAGE); - } else if (input === DELETE_SUCCESS_LABEL) { - setShowToast(true); - setConfig(''); - setToastMessage(DELETE_TOAST_MESSAGE); + setToastMessage(input); } else { // Otherwise the user has clicked a create card and we need to set existing config bool to // false and set the config type to the card that was clicked type diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx index ab91e0022e..b21476e859 100644 --- a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx @@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import BlackboardConfig from '../LMSConfigs/BlackboardConfig'; -import { INVALID_LINK, INVALID_NAME, SUCCESS_LABEL } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; jest.mock('../../data/constants', () => ({ @@ -232,7 +232,7 @@ describe('', () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUCCESS_LABEL); + expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); expect(window.open).toHaveBeenCalled(); expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); }); @@ -261,7 +261,7 @@ describe('', () => { await waitFor(() => userEvent.click(screen.getByText('Authorize'))); await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUCCESS_LABEL); + expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); expect(window.open).toHaveBeenCalled(); expect(mockUpdateConfigApi).toHaveBeenCalled(); expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); @@ -282,7 +282,7 @@ describe('', () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUCCESS_LABEL); + expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); expect(mockUpdateConfigApi).not.toHaveBeenCalled(); expect(window.open).toHaveBeenCalled(); expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx index 25add9518a..9229082622 100644 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx @@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import CanvasConfig from '../LMSConfigs/CanvasConfig'; -import { INVALID_LINK, INVALID_NAME, SUCCESS_LABEL } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; jest.mock('../../data/constants', () => ({ @@ -295,7 +295,7 @@ describe('', () => { expect(mockUpdateConfigApi).toHaveBeenCalled(); expect(window.open).toHaveBeenCalled(); expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); - await waitFor(() => expect(mockOnClick).toHaveBeenCalledWith(SUCCESS_LABEL)); + await waitFor(() => expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE)); }); test('Authorizing an existing config will not call update or create config endpoint', async () => { render( @@ -313,7 +313,7 @@ describe('', () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUCCESS_LABEL); + expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); expect(mockUpdateConfigApi).not.toHaveBeenCalled(); expect(window.open).toHaveBeenCalled(); expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); diff --git a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx index 1b3726b6c5..b728959a28 100644 --- a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx @@ -22,6 +22,12 @@ const configData = [ isValid: [{ missing: [] }, { incorrect: [] }], active: true, displayName: 'foobar', + lastSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastContentSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, }, ]; @@ -32,6 +38,12 @@ const inactiveConfigData = [ isValid: [{ missing: [] }, { incorrect: [] }], active: false, displayName: 'foobar', + lastSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastContentSyncAttemptedAt: null, + lastLearnerSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastSyncErroredAt: '2022-11-22T20:59:56Z', + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: '2022-11-22T20:59:56Z', }, ]; @@ -42,6 +54,12 @@ const disabledConfigData = [ isValid: [{ missing: [] }, { incorrect: [] }], active: false, displayName: 'foobar', + lastSyncAttemptedAt: null, + lastContentSyncAttemptedAt: null, + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, }, ]; @@ -52,6 +70,12 @@ const incompleteConfigData = [ isValid: [{ missing: ['client_id', 'refresh_token'] }, { incorrect: ['blackboard_base_url'] }], active: false, displayName: 'barfoo', + lastSyncAttemptedAt: null, + lastContentSyncAttemptedAt: null, + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, }, ]; @@ -62,6 +86,12 @@ const singleInvalidFieldConfigData = [ isValid: [{ missing: ['client_id', 'refresh_token'] }, { incorrect: [] }], active: false, displayName: 'barfoo', + lastSyncAttemptedAt: null, + lastContentSyncAttemptedAt: null, + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, }, ]; @@ -72,6 +102,12 @@ const needsRefreshTokenConfigData = [ isValid: [{ missing: ['refresh_token'] }, { incorrect: [] }], active: false, displayName: 'barfoo', + lastSyncAttemptedAt: null, + lastContentSyncAttemptedAt: null, + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, }, ]; @@ -96,6 +132,7 @@ describe('', () => { expect(screen.getByText('Active')).toBeInTheDocument(); expect(screen.getByText('foobar')).toBeInTheDocument(); expect(screen.getByText('View sync history')); + expect(screen.getByText('Last sync:')); userEvent.click(screen.getByTestId('existing-lms-config-card-dropdown-1')); expect(screen.getByText('Disable')); @@ -114,6 +151,7 @@ describe('', () => { expect(screen.getByText('Disabled')).toBeInTheDocument(); expect(screen.getByText('foobar')).toBeInTheDocument(); expect(screen.getByText('Enable')); + expect(screen.getByText('Recent sync error:')); userEvent.click(screen.getByTestId('existing-lms-config-card-dropdown-1')); expect(screen.getByText('Configure')); @@ -188,6 +226,7 @@ describe('', () => { expect(screen.getByText('Incomplete')).toBeInTheDocument(); expect(screen.getByText('barfoo')).toBeInTheDocument(); expect(screen.getByText('Configure')); + expect(screen.getByText('Sync not yet attempted')); await waitFor(() => userEvent.hover(screen.getByText('Incomplete'))); expect(screen.getByText('Next Steps')).toBeInTheDocument(); diff --git a/src/components/settings/data/constants.js b/src/components/settings/data/constants.js index e921798a16..0720742e41 100644 --- a/src/components/settings/data/constants.js +++ b/src/components/settings/data/constants.js @@ -15,9 +15,10 @@ export const HELP_CENTER_LINK = 'https://business-support.edx.org/hc/en-us/categ export const HELP_CENTER_SAML_LINK = 'https://business-support.edx.org/hc/en-us/articles/360005421073-5-Implementing-Single-Sign-on-SSO-with-edX'; export const HELP_CENTER_SAP_IDP_LINK = 'https://business-support.edx.org/hc/en-us/articles/360005205314'; export const HELP_CENTER_BRANDING_LINK = 'https://business-support.edx.org/hc/en-us/sections/8739219372183'; -export const SUCCESS_LABEL = 'success'; -export const TOGGLE_SUCCESS_LABEL = 'toggle success'; -export const DELETE_SUCCESS_LABEL = 'delete success'; +export const ACTIVATE_TOAST_MESSAGE = 'Learning platform integration successfully activated.'; +export const DELETE_TOAST_MESSAGE = 'Learning platform integration successfully removed.'; +export const INACTIVATE_TOAST_MESSAGE = 'Learning platform integration successfully disabled.'; +export const SUBMIT_TOAST_MESSAGE = 'Learning platform integration successfully submitted.'; export const BLACKBOARD_TYPE = 'BLACKBOARD'; export const CANVAS_TYPE = 'CANVAS'; diff --git a/src/components/settings/settings.scss b/src/components/settings/settings.scss index 941a884277..97b911c22e 100644 --- a/src/components/settings/settings.scss +++ b/src/components/settings/settings.scss @@ -112,3 +112,14 @@ width: auto; } +.small-icon { + height: 15px; + width: 15px; + margin: 0 4px 0 4px; +} + +.pgn__tabs .pgn__tab-notification { + min-height: 0.75rem; + min-width: 0.75rem; +} + From 5fa390e092f5653c68f3e3084daede882da22719 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 1 Dec 2022 16:58:06 -0500 Subject: [PATCH 02/73] feat: content highlights stepper updates, including refactor to use context selectors and connects to Algolia for course search results (#895) * feat: add basic validation on stepper title * feat: persist data through stepper and update UI * chore: updates * fix: moar updates * feat: lots of updates * fix: don't break the detail page for a specific highlight set * chore: rename some stuff * feat: create and publish new highlight set from stepper modal * chore: slight refactor to define interface for setting content highlight contex state value * chore: Refactored testing to updated context/stepper implementation Co-authored-by: Adam Stankiewicz --- .env.development | 1 + package-lock.json | 151 ++++- package.json | 9 +- .../ContentHighlightCardItem.jsx | 62 +- .../ContentHighlightRoutes.jsx | 25 +- .../ContentHighlightSetCard.jsx | 16 +- .../ContentHighlightsCardItemsContainer.jsx | 5 +- .../ContentHighlightsContext.jsx | 36 +- .../CurrentContentHighlightHeader.jsx | 46 +- .../ContentHighlights/HighlightSetSection.jsx | 2 +- .../ContentHighlightStepper.jsx | 214 +++++-- .../ContentSearchResultCard.jsx | 35 ++ .../HighlightStepperConfirmContent.jsx | 141 +++++ .../HighlightStepperConfirmCourses.jsx | 16 - .../HighlightStepperConfirmHighlight.jsx | 15 - .../HighlightStepperFooterHelpLink.jsx | 10 +- .../HighlightStepperSelectContent.jsx | 23 + ...HighlightStepperSelectContentDataTable.jsx | 177 ++++++ .../HighlightStepperSelectContentHeader.jsx | 30 + .../HighlightStepperSelectCourses.jsx | 16 - .../HighlightStepperTitle.jsx | 37 +- .../HighlightStepperTitleInput.jsx | 50 ++ .../SelectContentSearchPagination.jsx | 33 + .../SelectContentSelectionCheckbox.jsx | 43 ++ .../SelectContentSelectionStatus.jsx | 46 ++ .../tests/ContentHighlightStepper.test.jsx | 137 +++-- .../ContentHighlights/SkeletonContentCard.jsx | 12 + .../ZeroState/ZeroStateHighlights.jsx | 27 +- .../ContentHighlights/data/actions.js | 15 - .../ContentHighlights/data/constants.js | 563 +++++++++--------- .../ContentHighlights/data/hooks.js | 76 ++- .../ContentHighlights/data/reducer.js | 25 - .../tests/ContentHighlightSetCard.test.jsx | 69 +-- ...ntentHighlightsCardItemsContainer.test.jsx | 26 +- .../tests/ContentHighlightsDashboard.test.jsx | 38 +- .../tests/CurrentContentHighlights.test.jsx | 49 +- .../data/enterpriseCurationReducer.js | 2 +- src/components/settings/data/hooks.js | 3 + .../services/EnterpriseCatalogApiService.js | 11 + 39 files changed, 1587 insertions(+), 705 deletions(-) create mode 100644 src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx delete mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmCourses.jsx delete mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmHighlight.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx delete mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectCourses.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/SelectContentSelectionCheckbox.jsx create mode 100644 src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx create mode 100644 src/components/ContentHighlights/SkeletonContentCard.jsx delete mode 100644 src/components/ContentHighlights/data/actions.js delete mode 100644 src/components/ContentHighlights/data/reducer.js diff --git a/.env.development b/.env.development index 451be494f1..771588970c 100644 --- a/.env.development +++ b/.env.development @@ -15,6 +15,7 @@ ENTERPRISE_ACCESS_BASE_URL='http://localhost:18270' ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734' ENTERPRISE_SUPPORT_URL='https://edx.org' ENTERPRISE_SUPPORT_REVOKE_LICENSE_URL='https://edx.org' +ENTERPRISE_SUPPORT_PROGRAM_OPTIMIZATION_URL='https://edx.org' SEGMENT_KEY='' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' USER_INFO_COOKIE_NAME='edx-user-info' diff --git a/package-lock.json b/package-lock.json index 7e4009b045..545eba7975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.19.0", + "@edx/paragon": "20.21.0", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -29,7 +29,6 @@ "color-contrast-checker": "^2.1.0", "core-js": "3.7.0", "dash-embedded-component": "file:packages/dash-embedded-component-2.0.2.tgz", - "faker": "4.1.0", "file-saver": "1.3.8", "font-awesome": "4.7.0", "history": "4.10.1", @@ -47,6 +46,7 @@ "react-router": "5.2.0", "react-router-dom": "5.2.0", "react-textarea-autosize": "7.1.2", + "react-truncate": "^2.4.0", "redux": "4.0.4", "redux-devtools-extension": "2.13.8", "redux-form": "8.3.8", @@ -54,15 +54,18 @@ "redux-mock-store": "1.5.4", "redux-thunk": "2.3.0", "regenerator-runtime": "0.13.7", + "scheduler": "^0.23.0", "timeago.js": "^4.0.2", "universal-cookie": "4.0.4", "url": "0.11.0", + "use-context-selector": "^1.4.1", "uuid": "9.0.0", "validator": "10.11.0" }, "devDependencies": { "@edx/browserslist-config": "1.0.0", "@edx/frontend-build": "^12.3.0", + "@faker-js/faker": "^7.6.0", "@testing-library/dom": "7.31.2", "@testing-library/jest-dom": "5.11.9", "@testing-library/react": "11.2.7", @@ -3755,9 +3758,9 @@ } }, "node_modules/@edx/paragon": { - "version": "20.19.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.19.0.tgz", - "integrity": "sha512-N35cTPOrpacUKEfl8L2hryPzmPBGNbLRSgl/+BAIxyJuvn5etAjsAw6Etz3M91yRaTGLYH7+9HtExLES+qNfXw==", + "version": "20.21.0", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.0.tgz", + "integrity": "sha512-p9g/ONfWsZ6UxX7hNEoO2nUI0qhdGqQZYvLk3MpE0NMmTUYafpCBpRwOGYTXOXpIBzRENRiJUGJ/59XL0MelOw==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -3781,7 +3784,8 @@ "react-table": "^7.7.0", "react-transition-group": "^4.4.2", "tabbable": "^5.3.3", - "uncontrollable": "^7.2.1" + "uncontrollable": "^7.2.1", + "uuid": "^9.0.0" }, "peerDependencies": { "react": "^16.8.6 || ^17.0.0", @@ -3942,6 +3946,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@faker-js/faker": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", + "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", + "dev": true, + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "1.11.4", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", @@ -12337,11 +12351,6 @@ "node": ">=0.10.0" } }, - "node_modules/faker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/faker/-/faker-4.1.0.tgz", - "integrity": "sha512-ILKg69P6y/D8/wSmDXw35Ly0re8QzQ8pMfBCflsGiZG2ZjMUNLYNexA6lz5pkmJlepVdsiDFUxYAzPQ9/+iGLA==" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -20886,6 +20895,15 @@ "react": "^16.13.1" } }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "node_modules/react-dropzone": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", @@ -21347,6 +21365,16 @@ "react": "^16.13.1" } }, + "node_modules/react-test-renderer/node_modules/scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "node_modules/react-textarea-autosize": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-7.1.2.tgz", @@ -21374,6 +21402,15 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-truncate": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-truncate/-/react-truncate-2.4.0.tgz", + "integrity": "sha512-3QW11/COYwi6iPUaunUhl06DW5NJBJD1WkmxW5YxqqUu6kvP+msB3jfoLg8WRbu57JqgebjVW8Lknw6T5/QZdA==", + "peerDependencies": { + "prop-types": "<= 15.x.x", + "react": "<= 16.x.x" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -22548,12 +22585,11 @@ } }, "node_modules/scheduler": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", - "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/schema-utils": { @@ -25028,6 +25064,25 @@ } } }, + "node_modules/use-context-selector": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.1.tgz", + "integrity": "sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": "*", + "react-native": "*", + "scheduler": ">=0.19.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", @@ -28765,9 +28820,9 @@ } }, "@edx/paragon": { - "version": "20.19.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.19.0.tgz", - "integrity": "sha512-N35cTPOrpacUKEfl8L2hryPzmPBGNbLRSgl/+BAIxyJuvn5etAjsAw6Etz3M91yRaTGLYH7+9HtExLES+qNfXw==", + "version": "20.21.0", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.0.tgz", + "integrity": "sha512-p9g/ONfWsZ6UxX7hNEoO2nUI0qhdGqQZYvLk3MpE0NMmTUYafpCBpRwOGYTXOXpIBzRENRiJUGJ/59XL0MelOw==", "requires": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -28791,7 +28846,8 @@ "react-table": "^7.7.0", "react-transition-group": "^4.4.2", "tabbable": "^5.3.3", - "uncontrollable": "^7.2.1" + "uncontrollable": "^7.2.1", + "uuid": "^9.0.0" }, "dependencies": { "@fortawesome/fontawesome-common-types": { @@ -28909,6 +28965,12 @@ } } }, + "@faker-js/faker": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", + "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", + "dev": true + }, "@formatjs/ecma402-abstract": { "version": "1.11.4", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", @@ -35444,11 +35506,6 @@ } } }, - "faker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/faker/-/faker-4.1.0.tgz", - "integrity": "sha512-ILKg69P6y/D8/wSmDXw35Ly0re8QzQ8pMfBCflsGiZG2ZjMUNLYNexA6lz5pkmJlepVdsiDFUxYAzPQ9/+iGLA==" - }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -41915,6 +41972,17 @@ "object-assign": "^4.1.1", "prop-types": "^15.6.2", "scheduler": "^0.19.1" + }, + "dependencies": { + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + } } }, "react-dropzone": { @@ -42246,6 +42314,18 @@ "prop-types": "^15.6.2", "react-is": "^16.8.6", "scheduler": "^0.19.1" + }, + "dependencies": { + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + } } }, "react-textarea-autosize": { @@ -42268,6 +42348,12 @@ "prop-types": "^15.6.2" } }, + "react-truncate": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-truncate/-/react-truncate-2.4.0.tgz", + "integrity": "sha512-3QW11/COYwi6iPUaunUhl06DW5NJBJD1WkmxW5YxqqUu6kvP+msB3jfoLg8WRbu57JqgebjVW8Lknw6T5/QZdA==", + "requires": {} + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -43142,12 +43228,11 @@ } }, "scheduler": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", - "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "schema-utils": { @@ -45107,6 +45192,12 @@ "tslib": "^2.0.0" } }, + "use-context-selector": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.1.tgz", + "integrity": "sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA==", + "requires": {} + }, "use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/package.json b/package.json index 787fea7877..16ce109e2a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.19.0", + "@edx/paragon": "20.21.0", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -43,7 +43,6 @@ "color-contrast-checker": "^2.1.0", "core-js": "3.7.0", "dash-embedded-component": "file:packages/dash-embedded-component-2.0.2.tgz", - "faker": "4.1.0", "file-saver": "1.3.8", "font-awesome": "4.7.0", "history": "4.10.1", @@ -61,6 +60,7 @@ "react-router": "5.2.0", "react-router-dom": "5.2.0", "react-textarea-autosize": "7.1.2", + "react-truncate": "^2.4.0", "redux": "4.0.4", "redux-devtools-extension": "2.13.8", "redux-form": "8.3.8", @@ -68,9 +68,11 @@ "redux-mock-store": "1.5.4", "redux-thunk": "2.3.0", "regenerator-runtime": "0.13.7", + "scheduler": "^0.23.0", "timeago.js": "^4.0.2", "universal-cookie": "4.0.4", "url": "0.11.0", + "use-context-selector": "^1.4.1", "uuid": "9.0.0", "validator": "10.11.0" }, @@ -86,6 +88,7 @@ "devDependencies": { "@edx/browserslist-config": "1.0.0", "@edx/frontend-build": "^12.3.0", + "@faker-js/faker": "^7.6.0", "@testing-library/dom": "7.31.2", "@testing-library/jest-dom": "5.11.9", "@testing-library/react": "11.2.7", @@ -102,4 +105,4 @@ "react-test-renderer": "16.13.1", "resize-observer-polyfill": "1.5.1" } -} +} \ No newline at end of file diff --git a/src/components/ContentHighlights/ContentHighlightCardItem.jsx b/src/components/ContentHighlights/ContentHighlightCardItem.jsx index 0bdc67178b..bf677185f7 100644 --- a/src/components/ContentHighlights/ContentHighlightCardItem.jsx +++ b/src/components/ContentHighlights/ContentHighlightCardItem.jsx @@ -1,40 +1,56 @@ import React from 'react'; import { Card } from '@edx/paragon'; +import Truncate from 'react-truncate'; import PropTypes from 'prop-types'; import { FOOTER_TEXT_BY_CONTENT_TYPE } from './data/constants'; -const ContentHighlightCardItem = ({ title, type, authoringOrganizations }) => ( - - - - {/* footer for spacing purposes */} - - - - - - - -); +const ContentHighlightCardItem = ({ + title, + contentType, + partners, + cardImageUrl, +}) => { + const cardLogoSrc = partners?.length === 1 ? partners[0].logoImageUrl : undefined; + const cardLogoAlt = partners?.length === 1 ? `${partners[0].name}'s logo` : undefined; + const cardSubtitle = partners?.map(p => p.name).join(', '); + + return ( + + + {title}} + subtitle={{cardSubtitle}} + /> + {contentType && ( + <> + + + + )} + + ); +}; ContentHighlightCardItem.propTypes = { + cardImageUrl: PropTypes.string, title: PropTypes.string.isRequired, - type: PropTypes.oneOf(['course', 'program', 'learnerpathway']), - authoringOrganizations: PropTypes.arrayOf(PropTypes.shape({ + contentType: PropTypes.oneOf(['course', 'program', 'learnerpathway']).isRequired, + partners: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, uuid: PropTypes.string, logoImageUrl: PropTypes.string, - })), + })).isRequired, }; ContentHighlightCardItem.defaultProps = { - type: undefined, - authoringOrganizations: [], + cardImageUrl: undefined, }; export default ContentHighlightCardItem; diff --git a/src/components/ContentHighlights/ContentHighlightRoutes.jsx b/src/components/ContentHighlights/ContentHighlightRoutes.jsx index 584d723fda..1296cdd839 100644 --- a/src/components/ContentHighlights/ContentHighlightRoutes.jsx +++ b/src/components/ContentHighlights/ContentHighlightRoutes.jsx @@ -2,9 +2,22 @@ import React from 'react'; import { Route } from 'react-router-dom'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; + import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; import ContentHighlightSet from './ContentHighlightSet'; import ContentHighlightsDashboard from './ContentHighlightsDashboard'; +import ContentHighlightStepper from './HighlightStepper/ContentHighlightStepper'; + +const BaseContentHighlightRoute = ({ children }) => ( + <> + {children} + + +); + +BaseContentHighlightRoute.propTypes = { + children: PropTypes.node.isRequired, +}; const ContentHighlightRoutes = ({ enterpriseSlug }) => { const baseContentHighlightPath = `/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}`; @@ -12,12 +25,20 @@ const ContentHighlightRoutes = ({ enterpriseSlug }) => { <> ( + + + + )} exact /> ( + + + + )} exact /> diff --git a/src/components/ContentHighlights/ContentHighlightSetCard.jsx b/src/components/ContentHighlights/ContentHighlightSetCard.jsx index 3d57d40a16..6a2b212545 100644 --- a/src/components/ContentHighlights/ContentHighlightSetCard.jsx +++ b/src/components/ContentHighlights/ContentHighlightSetCard.jsx @@ -1,10 +1,11 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { Card } from '@edx/paragon'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { useHistory } from 'react-router-dom'; + import { ROUTE_NAMES } from '../EnterpriseApp/data/constants'; -import { ContentHighlightsContext } from './ContentHighlightsContext'; +import { useContentHighlightsContext } from './data/hooks'; const ContentHighlightSetCard = ({ imageCapSrc, @@ -16,16 +17,15 @@ const ContentHighlightSetCard = ({ }) => { const history = useHistory(); /* Stepper Draft Logic (See Hook) - Start */ - const { - setIsModalOpen, - } = useContext(ContentHighlightsContext); + const { openStepperModal } = useContentHighlightsContext(); /* Stepper Draft Logic (See Hook) - End */ const handleHighlightSetClick = () => { if (isPublished) { - // redirect to individual highlighted courses based on uuid - return history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}/${highlightSetUUID}`); + // redirect to individual highlighted set based on uuid + history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}/${highlightSetUUID}`); + return; } - return setIsModalOpen(true); + openStepperModal(); }; return ( diff --git a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx index 18681461d9..2ae62ea6dd 100644 --- a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx +++ b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx @@ -26,9 +26,10 @@ const ContentHighlightsCardItemsContainer = () => { }) => ( ))} diff --git a/src/components/ContentHighlights/ContentHighlightsContext.jsx b/src/components/ContentHighlights/ContentHighlightsContext.jsx index 3793cd5bbe..fa01c5bcb5 100644 --- a/src/components/ContentHighlights/ContentHighlightsContext.jsx +++ b/src/components/ContentHighlights/ContentHighlightsContext.jsx @@ -1,20 +1,34 @@ -import React, { createContext, useMemo } from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { createContext } from 'use-context-selector'; +import algoliasearch from 'algoliasearch/lite'; -import { - useStepperModalState, -} from './data/hooks'; +import { configuration } from '../../config'; -export const ContentHighlightsContext = createContext({}); +export const ContentHighlightsContext = createContext(null); + +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); const ContentHighlightsContextProvider = ({ children }) => { - const { setIsModalOpen, isModalOpen } = useStepperModalState(); - const value = useMemo(() => ({ - setIsModalOpen, - isModalOpen, - }), [setIsModalOpen, isModalOpen]); + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: {}, + }, + contentHighlights: [], + searchClient, + }); - return {children}; + return ( + + {children} + + ); }; ContentHighlightsContextProvider.propTypes = { diff --git a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx index 2164a6d787..e7ce380d35 100644 --- a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx +++ b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx @@ -1,40 +1,28 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { Button, ActionRow, } from '@edx/paragon'; import { Add } from '@edx/paragon/icons'; -import ContentHighlightStepper from './HighlightStepper/ContentHighlightStepper'; -import { ContentHighlightsContext } from './ContentHighlightsContext'; -const CurrentContentHighlightHeader = () => { - const { - isModalOpen, setIsModalOpen, - } = useContext(ContentHighlightsContext); +import { useContentHighlightsContext } from './data/hooks'; +import { BUTTON_TEXT, HEADER_TEXT } from './data/constants'; - const handleNewClick = () => { - setIsModalOpen(prevState => !prevState); - }; +const CurrentContentHighlightHeader = () => { + const { openStepperModal } = useContentHighlightsContext(); return ( - <> - -

- Highlight collections -

- - -
-

- Create up to 8 highlight collections for your learners. -

- - + +

+ {HEADER_TEXT.currentContent} +

+ + +
); }; - export default CurrentContentHighlightHeader; diff --git a/src/components/ContentHighlights/HighlightSetSection.jsx b/src/components/ContentHighlights/HighlightSetSection.jsx index 0c10667d28..a0c58776f4 100644 --- a/src/components/ContentHighlights/HighlightSetSection.jsx +++ b/src/components/ContentHighlights/HighlightSetSection.jsx @@ -34,7 +34,7 @@ const HighlightSetSection = ({ highlightSetUUID={uuid} isPublished={isPublished} itemCount={highlightedContentUuids.length} - imageCapSrc="https://source.unsplash.com/360x200/?cat,dog" + imageCapSrc="https://picsum.photos/360/200/" /> ))} diff --git a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx index 698bc95808..a22cf91a3f 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx @@ -1,100 +1,188 @@ import React, { - useState, useEffect, useContext, + useCallback, useState, useContext, } from 'react'; +import PropTypes from 'prop-types'; +import { useContextSelector } from 'use-context-selector'; +import { connect } from 'react-redux'; import { - Stepper, FullscreenModal, Button, + Stepper, FullscreenModal, Button, StatefulButton, } from '@edx/paragon'; -import PropTypes from 'prop-types'; +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform'; + +import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; import HighlightStepperTitle from './HighlightStepperTitle'; -import HighlightStepperSelectCourses from './HighlightStepperSelectCourses'; -import HighlightStepperConfirmCourses from './HighlightStepperConfirmCourses'; -import HighlightStepperConfirmHighlight from './HighlightStepperConfirmHighlight'; +import HighlightStepperSelectContent from './HighlightStepperSelectContent'; +import HighlightStepperConfirmContent from './HighlightStepperConfirmContent'; import HighlightStepperFooterHelpLink from './HighlightStepperFooterHelpLink'; -import { ContentHighlightsContext } from '../ContentHighlightsContext'; +import EnterpriseCatalogApiService from '../../../data/services/EnterpriseCatalogApiService'; +import { enterpriseCurationActions } from '../../EnterpriseApp/data/enterpriseCurationReducer'; +import { useContentHighlightsContext } from '../data/hooks'; + +const STEPPER_STEP_LABELS = { + CREATE_TITLE: 'Create a title', + SELECT_CONTENT: 'Select content', + CONFIRM_PUBLISH: 'Confirm and publish', +}; + +const steps = [ + STEPPER_STEP_LABELS.CREATE_TITLE, + STEPPER_STEP_LABELS.SELECT_CONTENT, + STEPPER_STEP_LABELS.CONFIRM_PUBLISH, +]; + /** - * Stepper Modal Currently accessible from: - * - ContentHighlightSetCard - * - CurrentContentHighlightHeader - * - ZeroStateHighlights - * - * @param {object} args Arugments - * @param {boolean} args.isOpen Whether the modal containing the stepper is currently open. - * @returns + * Stepper to support create user flow for a highlight set. */ -const ContentHighlightStepper = ({ isOpen }) => { - const { setIsModalOpen } = useContext(ContentHighlightsContext); - /* eslint-disable no-unused-vars */ - const steps = ['Title', 'Select courses', 'Confirm and Publish', 'All Set']; +const ContentHighlightStepper = ({ enterpriseId }) => { + const { + enterpriseCuration: { + dispatch: dispatchEnterpriseCuration, + }, + } = useContext(EnterpriseAppContext); const [currentStep, setCurrentStep] = useState(steps[0]); - const [modalState, setModalState] = useState(isOpen); - useEffect(() => { - setModalState(isOpen); - }, [isOpen]); - const submitAndReset = () => { - if (steps.indexOf(currentStep) === steps.length - 1) { - /* TODO: submit data to api if confirmed */ - setCurrentStep(steps[0]); + const [isPublishing, setIsPublishing] = useState(false); + + const { resetStepperModal } = useContentHighlightsContext(); + const isStepperModalOpen = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.isOpen); + const titleStepValidationError = useContextSelector( + ContentHighlightsContext, + v => v[0].stepperModal.titleStepValidationError, + ); + const highlightTitle = useContextSelector( + ContentHighlightsContext, + v => v[0].stepperModal.highlightTitle, + ); + const currentSelectedRowIds = useContextSelector( + ContentHighlightsContext, + v => v[0].stepperModal.currentSelectedRowIds, + ); + const closeStepperModal = useCallback(() => { + resetStepperModal(); + setCurrentStep(steps[0]); + }, [resetStepperModal]); + + const handlePublish = async () => { + setIsPublishing(true); + try { + const newHighlightSet = { + title: highlightTitle, + isPublished: true, + // TODO: pass along the selected content keys! + }; + const response = await EnterpriseCatalogApiService.createHighlightSet(enterpriseId, newHighlightSet); + const result = camelCaseObject(response.data); + const transformedHighlightSet = { + cardImageUrl: result.cardImageUrl, + isPublished: result.isPublished, + title: result.title, + uuid: result.uuid, + highlightedContentUuids: [], + }; + dispatchEnterpriseCuration(enterpriseCurationActions.addHighlightSet(transformedHighlightSet)); + closeStepperModal(); + } catch (error) { + logError(error); + } finally { + setIsPublishing(false); } - setIsModalOpen(false); }; + return ( { - submitAndReset(); - }} + isOpen={isStepperModalOpen} + onClose={closeStepperModal} beforeBodyNode={} footerNode={( <> - + - {/* Eventually would need a check to see if the user has made any changes - to the form before allowing them to close the modal without saving. Ln 58 onClick */} - - + {/* TODO: Eventually would need a check to see if the user has made any changes + to the form before allowing them to close the modal without saving. */} + + - + - - + + - + - - - - - - - - - + + )} > - + - - + + - - - - - - + + @@ -102,7 +190,11 @@ const ContentHighlightStepper = ({ isOpen }) => { }; ContentHighlightStepper.propTypes = { - isOpen: PropTypes.bool.isRequired, + enterpriseId: PropTypes.string.isRequired, }; -export default ContentHighlightStepper; +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(ContentHighlightStepper); diff --git a/src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx b/src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx new file mode 100644 index 0000000000..cab87be505 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import ContentHighlightCardItem from '../ContentHighlightCardItem'; + +const ContentSearchResultCard = ({ original }) => { + const { + title, + contentType, + partners, + cardImageUrl, + originalImageUrl, + } = original; + + return ( + + ); +}; + +ContentSearchResultCard.propTypes = { + original: PropTypes.shape({ + title: PropTypes.string, + contentType: PropTypes.string, + partners: PropTypes.arrayOf(PropTypes.shape()), + cardImageUrl: PropTypes.string, + originalImageUrl: PropTypes.string, + }).isRequired, +}; + +export default ContentSearchResultCard; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx new file mode 100644 index 0000000000..5309294ad4 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useContextSelector } from 'use-context-selector'; +import { + Container, + Row, + Col, + Icon, + CardGrid, +} from '@edx/paragon'; +import { Assignment } from '@edx/paragon/icons'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { Configure, InstantSearch, connectStateResults } from 'react-instantsearch-dom'; + +import { configuration } from '../../../config'; +import { STEPPER_STEP_TEXT, MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET } from '../data/constants'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; +import SkeletonContentCard from '../SkeletonContentCard'; + +const prodEnterpriseId = 'e783bb19-277f-479e-9c41-8b0ed31b4060'; + +const BaseReviewContentSelections = ({ + searchResults, + isSearchStalled, +}) => { + if (isSearchStalled) { + return ( + + {[...new Array(8)].map(() => )} + + ); + } + + if (!searchResults) { + return null; + } + + const { hits } = camelCaseObject(searchResults); + + return ( +
    + {hits.map((highlightedContent) => { + const { aggregationKey, title } = highlightedContent; + return ( +
  • + {title} +
  • + ); + })} +
+ ); +}; + +BaseReviewContentSelections.propTypes = { + searchResults: PropTypes.shape({ + hits: PropTypes.arrayOf(PropTypes.shape({ + title: PropTypes.string, + aggregationKey: PropTypes.string, + })).isRequired, + }), + isSearchStalled: PropTypes.bool.isRequired, +}; + +BaseReviewContentSelections.defaultProps = { + searchResults: null, +}; + +const ReviewContentSelections = connectStateResults(BaseReviewContentSelections); + +const SelectedContent = () => { + const searchClient = useContextSelector( + ContentHighlightsContext, + v => v[0].searchClient, + ); + const currentSelectedRowIdsRaw = useContextSelector( + ContentHighlightsContext, + v => v[0].stepperModal.currentSelectedRowIds, + ); + const currentSelectedRowIds = Object.keys(currentSelectedRowIdsRaw); + + /* eslint-disable max-len */ + /** + * Results in a string like: + * `enterprise_customer_uuids:e783bb19-277f-479e-9c41-8b0ed31b4060 AND (aggregation_key:'course:edX+DemoX' OR aggregation_key:'course:edX+DemoX2') + */ + /* eslint-enable max-len */ + const algoliaFilters = useMemo(() => { + let filterString = `enterprise_customer_uuids:${prodEnterpriseId}`; + if (currentSelectedRowIds.length > 0) { + filterString += ' AND ('; + currentSelectedRowIds.forEach((selectedRowId, index) => { + if (index !== 0) { + filterString += ' OR '; + } + filterString += `aggregation_key:'${selectedRowId}'`; + }); + filterString += ')'; + } + return filterString; + }, [currentSelectedRowIds]); + + if (currentSelectedRowIds.length === 0) { + return null; + } + + return ( + + + + + ); +}; + +const HighlightStepperConfirmContent = () => ( + + + +

+ + {STEPPER_STEP_TEXT.confirmContent} +

+ +
+ +
+); + +export default HighlightStepperConfirmContent; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmCourses.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmCourses.jsx deleted file mode 100644 index e6e4ca2d09..0000000000 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmCourses.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { Container, Stack, Col } from '@edx/paragon'; -import { STEPPER_STEP_TEXT } from '../data/constants'; - -const HighlightStepperConfirmCourses = () => ( - - -

{STEPPER_STEP_TEXT.confirmContent}

- - Search Data Table Card View here - -
-
-); - -export default HighlightStepperConfirmCourses; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmHighlight.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmHighlight.jsx deleted file mode 100644 index b70c53e437..0000000000 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmHighlight.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { Stack, Container } from '@edx/paragon'; -import { STEPPER_STEP_TEXT } from '../data/constants'; - -const HighlightStepperConfirmHighlight = () => ( - - -

{STEPPER_STEP_TEXT.confirmHighlight}

-

Yay

-
-
- -); - -export default HighlightStepperConfirmHighlight; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx index 840c5359f7..79814f0dda 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx @@ -4,9 +4,13 @@ import { } from '@edx/paragon'; const HighlightStepperFooterHelpLink = () => ( -
- - Help Center +
+ + Help Center: Program Optimization
); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx new file mode 100644 index 0000000000..8760f4996d --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { + Row, Col, Container, +} from '@edx/paragon'; +import HighlightStepperSelectContentDataTable from './HighlightStepperSelectContentDataTable'; +import HighlightStepperSelectContentHeader from './HighlightStepperSelectContentHeader'; + +const HighlightStepperSelectContent = () => ( + + + + + + + + + + + + +); + +export default HighlightStepperSelectContent; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx new file mode 100644 index 0000000000..d8e2c47a67 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx @@ -0,0 +1,177 @@ +import React, { useState, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useContextSelector } from 'use-context-selector'; +import { Configure, InstantSearch, connectStateResults } from 'react-instantsearch-dom'; +import { DataTable, CardView } from '@edx/paragon'; +import { camelCaseObject } from '@edx/frontend-platform'; + +import { configuration } from '../../../config'; +import { FOOTER_TEXT_BY_CONTENT_TYPE } from '../data/constants'; +import ContentSearchResultCard from './ContentSearchResultCard'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; +import SelectContentSelectionStatus from './SelectContentSelectionStatus'; +import SelectContentSelectionCheckbox from './SelectContentSelectionCheckbox'; +import SelectContentSearchPagination from './SelectContentSearchPagination'; +import SkeletonContentCard from '../SkeletonContentCard'; +import { useContentHighlightsContext } from '../data/hooks'; + +const defaultActiveStateValue = 'card'; +const pageSize = 24; + +const selectColumn = { + id: 'selection', + Header: () => null, + Cell: SelectContentSelectionCheckbox, + disableSortBy: true, +}; + +const prodEnterpriseId = 'e783bb19-277f-479e-9c41-8b0ed31b4060'; +const currentEpoch = Math.round((new Date()).getTime() / 1000); + +const HighlightStepperSelectContent = () => { + const { setCurrentSelectedRowIds } = useContentHighlightsContext(); + const currentSelectedRowIds = useContextSelector( + ContentHighlightsContext, + v => v[0].stepperModal.currentSelectedRowIds, + ); + const searchClient = useContextSelector( + ContentHighlightsContext, + v => v[0].searchClient, + ); + + const searchFilters = `enterprise_customer_uuids:${prodEnterpriseId} AND advertised_course_run.upgrade_deadline > ${currentEpoch} AND content_type:course`; + return ( + + + + + ); +}; + +const PriceTableCell = ({ row }) => { + const contentPrice = row.original.firstEnrollablePaidSeatPrice; + if (!contentPrice) { + return null; + } + return `$${contentPrice}`; +}; + +PriceTableCell.propTypes = { + row: PropTypes.shape({ + original: PropTypes.shape({ + firstEnrollablePaidSeatPrice: PropTypes.number, + }).isRequired, + }).isRequired, +}; + +const ContentTypeTableCell = ({ row }) => FOOTER_TEXT_BY_CONTENT_TYPE[row.original.contentType.toLowerCase()]; + +const BaseHighlightStepperSelectContentDataTable = ({ + selectedRowIds, + onSelectedRowsChanged, + isSearchStalled, + searchResults, +}) => { + const [currentView, setCurrentView] = useState(defaultActiveStateValue); + + const tableData = useMemo(() => camelCaseObject(searchResults?.hits || []), [searchResults]); + + const searchResultsItemCount = searchResults?.nbHits || 0; + const searchResultsPageCount = searchResults?.nbPages || 0; + + return ( + setCurrentView(val), + defaultActiveStateValue, + togglePlacement: 'left', + }} + isSelectable + isPaginated + manualPagination + initialState={{ + pageSize, + pageIndex: 0, + selectedRowIds, + }} + pageCount={searchResultsPageCount} + itemCount={searchResultsItemCount} + initialTableOptions={{ + getRowId: row => row.aggregationKey, + autoResetSelectedRows: false, + }} + data={tableData} + manualSelectColumn={selectColumn} + SelectionStatusComponent={SelectContentSelectionStatus} + columns={[ + { + Header: 'Content name', + accessor: 'title', + }, + { + Header: 'Partner', + accessor: 'partners[0].name', + }, + { + Header: 'Content type', + Cell: ContentTypeTableCell, + }, + { + Header: 'Price', + Cell: PriceTableCell, + }, + ]} + > + + {currentView === 'card' && ( + + )} + {currentView === 'list' && } + + + + + + + ); +}; + +BaseHighlightStepperSelectContentDataTable.propTypes = { + selectedRowIds: PropTypes.shape().isRequired, + onSelectedRowsChanged: PropTypes.func.isRequired, + isSearchStalled: PropTypes.bool.isRequired, + searchResults: PropTypes.shape({ + hits: PropTypes.arrayOf(PropTypes.shape()).isRequired, + nbHits: PropTypes.number.isRequired, + nbPages: PropTypes.number.isRequired, + }), +}; + +BaseHighlightStepperSelectContentDataTable.defaultProps = { + searchResults: null, +}; + +const HighlightStepperSelectContentDataTable = connectStateResults(BaseHighlightStepperSelectContentDataTable); + +export default HighlightStepperSelectContent; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx new file mode 100644 index 0000000000..bdd28d3af7 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useContextSelector } from 'use-context-selector'; +import { Icon } from '@edx/paragon'; +import { AddCircle } from '@edx/paragon/icons'; +import { MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET, STEPPER_STEP_TEXT } from '../data/constants'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; + +const HighlightStepperSelectContentTitle = () => { + const highlightTitle = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.highlightTitle); + return ( + <> +

+ + {STEPPER_STEP_TEXT.selectContent} +

+
+

+ Select up to {MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "{highlightTitle}". +

+

+ + Pro tip: a highlight can include courses similar to each other for your learners to choose from, + or courses that vary in subtopics to help your learners master a larger topic. + +

+
+ + ); +}; +export default HighlightStepperSelectContentTitle; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectCourses.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectCourses.jsx deleted file mode 100644 index 34ce2efe3d..0000000000 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectCourses.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { Stack, Col, Container } from '@edx/paragon'; -import { STEPPER_STEP_TEXT } from '../data/constants'; - -const HighlightStepperSelectCourses = () => ( - - -

{STEPPER_STEP_TEXT.selectCourses}

- - Search Data Table Card View here - -
-
-); - -export default HighlightStepperSelectCourses; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx index 2c54ec0c0c..557632c05f 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx @@ -1,38 +1,37 @@ import React from 'react'; import { - Stack, Col, Form, Icon, Container, + Row, Col, Icon, Container, } from '@edx/paragon'; import { AddCircle } from '@edx/paragon/icons'; + import { STEPPER_STEP_TEXT } from '../data/constants'; +import HighlightStepperTitleInput from './HighlightStepperTitleInput'; const HighlightStepperTitle = () => ( - - - - - - -

{STEPPER_STEP_TEXT.createTitle}

- -
-
+ + + +

+ + {STEPPER_STEP_TEXT.createTitle} +

+

- Create a unique title for your highlight collection. This title will - appear in your learner's portal together with the selected courses. + Create a unique title for your highlight. This title is visible + to your learners and helps them discovery relevant content.

- Pro tip: We recommend naming your highlight collection to reflect skills + Pro tip: we recommend naming your highlight collection to reflect skills it aims to develop, or to draw the attention of specific groups it targets. - For example, "Recommended for Marketing" or "Develop Leadership Skills" + For example, "Recommended for Marketing" or "Develop Leadership + Skills".

- - - + - +
); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx new file mode 100644 index 0000000000..1f2f302cc6 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { useContextSelector } from 'use-context-selector'; +import { Form } from '@edx/paragon'; + +import { ContentHighlightsContext } from '../ContentHighlightsContext'; +import { HIGHLIGHT_TITLE_MAX_LENGTH } from '../data/constants'; +import { useContentHighlightsContext } from '../data/hooks'; + +const HighlightStepperTitleInput = () => { + const { setHighlightTitle } = useContentHighlightsContext(); + const highlightTitle = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.highlightTitle); + const [titleLength, setTitleLength] = useState(highlightTitle?.length || 0); + const [isInvalid, setIsInvalid] = useState(false); + + const handleChange = (e) => { + if (e.target.value.length > 60) { + setIsInvalid(true); + setHighlightTitle({ + highlightTitle: e.target.value, + titleStepValidationError: 'Titles may only be 60 characters or less', + }); + } else { + setIsInvalid(false); + setHighlightTitle({ + highlightTitle: e.target.value, + titleStepValidationError: undefined, + }); + } + setTitleLength(e.target.value.length); + }; + + return ( + + + + {titleLength}/{HIGHLIGHT_TITLE_MAX_LENGTH} + + + ); +}; + +export default HighlightStepperTitleInput; diff --git a/src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx b/src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx new file mode 100644 index 0000000000..0bc5c48b65 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connectPagination } from 'react-instantsearch-dom'; +import { Pagination } from '@edx/paragon'; + +export const BaseSearchPagination = ({ + nbPages, + currentRefinement, + refine, +}) => ( + <> + refine(pageNum)} + pageCount={nbPages} + /> + refine(pageNum)} + /> + +); + +BaseSearchPagination.propTypes = { + nbPages: PropTypes.number.isRequired, + currentRefinement: PropTypes.number.isRequired, + refine: PropTypes.func.isRequired, +}; + +export default connectPagination(BaseSearchPagination); diff --git a/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionCheckbox.jsx b/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionCheckbox.jsx new file mode 100644 index 0000000000..10b4524cb0 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionCheckbox.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useContextSelector } from 'use-context-selector'; +import PropTypes from 'prop-types'; +import { CheckboxControl } from '@edx/paragon'; + +import { MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET } from '../data/constants'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; + +const SelectContentSelectionCheckbox = ({ row }) => { + const { + indeterminate, + checked, + ...toggleRowSelectedProps + } = row.getToggleRowSelectedProps(); + + const currentSelectedRowsCount = useContextSelector( + ContentHighlightsContext, + v => Object.keys(v[0].stepperModal.currentSelectedRowIds).length, + ); + + const isDisabled = !checked && currentSelectedRowsCount === MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET; + + return ( +
+ +
+ ); +}; + +SelectContentSelectionCheckbox.propTypes = { + row: PropTypes.shape({ + getToggleRowSelectedProps: PropTypes.func.isRequired, + }).isRequired, +}; + +export default SelectContentSelectionCheckbox; diff --git a/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx b/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx new file mode 100644 index 0000000000..489eba1606 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx @@ -0,0 +1,46 @@ +import React, { useContext } from 'react'; +import { useContextSelector } from 'use-context-selector'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { Button, DataTableContext } from '@edx/paragon'; + +import { ContentHighlightsContext } from '../ContentHighlightsContext'; + +const SelectContentSelectionStatus = ({ className }) => { + const { toggleAllRowsSelected } = useContext(DataTableContext); + const currentSelectedRowsCount = useContextSelector( + ContentHighlightsContext, + v => Object.keys(v[0].stepperModal.currentSelectedRowIds).length, + ); + + const handleClearSelection = () => { + toggleAllRowsSelected(false); + }; + + return ( +
+
+ {currentSelectedRowsCount} selected +
+ {currentSelectedRowsCount > 0 && ( + + )} +
+ ); +}; + +SelectContentSelectionStatus.propTypes = { + className: PropTypes.string, +}; + +SelectContentSelectionStatus.defaultProps = { + className: undefined, +}; + +export default SelectContentSelectionStatus; diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx index 4c3b75cc81..5e6f355ee8 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx @@ -1,103 +1,152 @@ -import { screen, render, fireEvent } from '@testing-library/react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { Button } from '@edx/paragon'; -import React, { useMemo } from 'react'; -import ContentHighlightStepper from '../ContentHighlightStepper'; +import { useState } from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import algoliasearch from 'algoliasearch/lite'; +import thunk from 'redux-thunk'; +import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; import { ContentHighlightsContext } from '../../ContentHighlightsContext'; -import { useStepperModalState } from '../../data/hooks'; -import { STEPPER_STEP_TEXT } from '../../data/constants'; +import { BUTTON_TEXT, STEPPER_STEP_TEXT } from '../../data/constants'; +import { configuration } from '../../../../config'; +import ContentHighlightsDashboard from '../../ContentHighlightsDashboard'; +import { EnterpriseAppContext } from '../../../EnterpriseApp/EnterpriseAppContextProvider'; -const ContentHighlightStepperWrapper = () => { - const { setIsModalOpen, isModalOpen } = useStepperModalState(); +const mockStore = configureMockStore([thunk]); - const defaultValue = useMemo(() => ({ - setIsModalOpen, - isModalOpen, - }), [setIsModalOpen, isModalOpen]); +const initialState = { + portalConfiguration: { + enterpriseSlug: 'test-enterprise', + }, +}; + +const initialEnterpriseAppContextValue = { + enterpriseCuration: { + enterpriseCuration: { + highlightSets: [], + }, + }, +}; + +const testCourses = { + 'course:HarvardX+CS50x': true, + 'course:HarvardX+CS50P': true, + 'course:HarvardX+CS50W': true, + 'course:HarvardX+CS50AI': true, +}; + +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); +/* eslint-disable react/prop-types */ +const ContentHighlightStepperWrapper = ({ + enterpriseAppContextValue = initialEnterpriseAppContextValue, + ...props +}) => { + /* eslint-enable react/prop-types */ + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: testCourses, + }, + contentHighlights: [], + searchClient, + }); return ( - - - - + + + + + + + + + ); }; describe('', () => { it('Displays the stepper', () => { - render(); + renderWithRouter(); - const stepper = screen.getByText('Click Me'); + const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); fireEvent.click(stepper); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); }); it('Displays the stepper and test all back and next buttons', () => { - render(); - - const stepper = screen.getByText('Click Me'); + renderWithRouter(); + // open stepper --> title + const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); fireEvent.click(stepper); + // title --> select content const nextButton1 = screen.getByText('Next'); + const input = screen.getByTestId('stepper-title-input'); + fireEvent.change(input, { target: { value: 'test-title' } }); fireEvent.click(nextButton1); + // select content --> confirm content const nextButton2 = screen.getByText('Next'); fireEvent.click(nextButton2); - const nextButton3 = screen.getByText('Next'); - fireEvent.click(nextButton3); - const backButton1 = screen.getByText('Back'); - fireEvent.click(backButton1); - expect(screen.getByText(STEPPER_STEP_TEXT.confirmContent)).toBeInTheDocument(); + // confirm content --> select content const backButton2 = screen.getByText('Back'); fireEvent.click(backButton2); - expect(screen.getByText(STEPPER_STEP_TEXT.selectCourses)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.selectContent)).toBeInTheDocument(); + // select content --> title const backButton3 = screen.getByText('Back'); fireEvent.click(backButton3); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + // title --> closed stepper const backButton4 = screen.getByText('Back'); fireEvent.click(backButton4); - expect(screen.getByText('Click Me')).toBeInTheDocument(); + expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); }); it('Displays the stepper and exits on the X button', () => { - render(); + renderWithRouter(); - const stepper = screen.getByText('Click Me'); + const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); fireEvent.click(stepper); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); fireEvent.click(closeButton); - expect(screen.getByText('Click Me')).toBeInTheDocument(); + expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); }); - it('Displays the stepper and closes the stepper on confirm', () => { - render(); + it('Displays the stepper and closes the stepper on confirm', async () => { + renderWithRouter(); - const stepper = screen.getByText('Click Me'); + const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); fireEvent.click(stepper); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); - + const input = screen.getByTestId('stepper-title-input'); + fireEvent.change(input, { target: { value: 'test-title' } }); const nextButton1 = screen.getByText('Next'); fireEvent.click(nextButton1); + expect(screen.getByText(STEPPER_STEP_TEXT.selectContent)).toBeInTheDocument(); const nextButton2 = screen.getByText('Next'); fireEvent.click(nextButton2); - const nextButton3 = screen.getByText('Next'); - fireEvent.click(nextButton3); - expect(screen.getByText(STEPPER_STEP_TEXT.confirmHighlight)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.confirmContent)).toBeInTheDocument(); - const confirmButton = screen.getByText('Confirm'); + const confirmButton = screen.getByText('Publish'); fireEvent.click(confirmButton); - expect(screen.getByText('Click Me')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('Publishing...')).toBeInTheDocument()); }); it('Displays the stepper, closes, then displays stepper again', () => { - render(); + renderWithRouter(); - const stepper1 = screen.getByText('Click Me'); + const stepper1 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); fireEvent.click(stepper1); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); fireEvent.click(closeButton); - expect(screen.getByText('Click Me')).toBeInTheDocument(); + expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); - const stepper2 = screen.getByText('Click Me'); + const stepper2 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); fireEvent.click(stepper2); expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); }); diff --git a/src/components/ContentHighlights/SkeletonContentCard.jsx b/src/components/ContentHighlights/SkeletonContentCard.jsx new file mode 100644 index 0000000000..913555428c --- /dev/null +++ b/src/components/ContentHighlights/SkeletonContentCard.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Card } from '@edx/paragon'; + +const SkeletonContentCard = () => ( + + + + + +); + +export default SkeletonContentCard; diff --git a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx index 5c0ca2f159..4b70df307d 100644 --- a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx +++ b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx @@ -1,4 +1,5 @@ -import React, { useContext } from 'react'; +import React from 'react'; +import { useContextSelector } from 'use-context-selector'; import { Card, Button, Col, Row, } from '@edx/paragon'; @@ -10,11 +11,13 @@ import ZeroStateCardText from './ZeroStateCardText'; import ZeroStateCardFooter from './ZeroStateCardFooter'; import ContentHighlightStepper from '../HighlightStepper/ContentHighlightStepper'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; +import { useContentHighlightsContext } from '../data/hooks'; +import { BUTTON_TEXT } from '../data/constants'; const ZeroStateHighlights = ({ cardClassName }) => { - const { - isModalOpen, setIsModalOpen, - } = useContext(ContentHighlightsContext); + const { openStepperModal } = useContentHighlightsContext(); + const isStepperModalOpen = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.isOpen); + return ( @@ -23,22 +26,30 @@ const ZeroStateHighlights = ({ cardClassName }) => {

You haven't created any highlights yet.

- Create and recommend course collections to your learners, - enable them to quickly locate relevant content. + Create and recommend content collections to your learners, + enabling them to quickly locate content relevant to them.

- + - +
); }; + ZeroStateHighlights.propTypes = { cardClassName: PropTypes.string, }; + ZeroStateHighlights.defaultProps = { cardClassName: undefined, }; diff --git a/src/components/ContentHighlights/data/actions.js b/src/components/ContentHighlights/data/actions.js deleted file mode 100644 index 8518372982..0000000000 --- a/src/components/ContentHighlights/data/actions.js +++ /dev/null @@ -1,15 +0,0 @@ -export const SET_HIGHLIGHT_STEPPER_MODAL = 'SET_HIGHLIGHT_STEPPER_MODAL'; -export const setHighlightStepperModal = isOpen => ({ - type: SET_HIGHLIGHT_STEPPER_MODAL, - payload: { - isOpen, - }, -}); - -export const SET_CURRENT_STEPPER_STEP = 'SET_CURRENT_STEPPER_STEP'; -export const setCurrentStepperStep = step => ({ - type: SET_CURRENT_STEPPER_STEP, - payload: { - step, - }, -}); diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index 01616bd95f..5e44ff074c 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -1,9 +1,24 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { faker } from '@faker-js/faker'; + +// Max number of content items per highlight set +export const MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET = 12; +// Max length of highlight title in stepper +export const HIGHLIGHT_TITLE_MAX_LENGTH = 60; // Stepper Step Text that match testing components export const STEPPER_STEP_TEXT = { - createTitle: 'Create a title for the highlight collection', - selectCourses: 'Select Courses to Add', - confirmContent: 'Confirm your Content', - confirmHighlight: 'Confirm your Highlight', + createTitle: 'Create a title for your highlight', + selectContent: 'Add content to your highlight', + confirmContent: 'Confirm your content selections', +}; +// Header text extracted into constant to maintain passing test on changes +export const HEADER_TEXT = { + currentContent: 'Highlights', +}; +// Button text extracted from constant to maintain passing test on changes +export const BUTTON_TEXT = { + createNewHighlight: 'New', + zeroStateCreateNewHighlight: 'New highlight', }; // Default footer values based on API response for ContentHighlightCardItem @@ -13,269 +28,281 @@ export const FOOTER_TEXT_BY_CONTENT_TYPE = { learnerpathway: 'Pathway', }; -/*eslint-disable*/ // Test Data for Content Highlights export const TEST_COURSE_HIGHLIGHTS_DATA = [ - { - uuid: '1', - title: 'Dire Core', - is_published: true, - enterprise_curation: "321123", - highlighted_content: - [ - { - uuid: '1', - content_type: 'Course', - content_key: 'edX+DemoX', - title: 'Math', - card_image_url: 'https://source.unsplash.com/360x200/?nature,flower', - authoring_organizations: - [ - { - uuid:'123', - name: 'General Studies 1', - logo_image_url: 'https://placekitten.com/200/100', - }, - { - uuid:'1234', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Super General Studies' - } - ] - }, - { - title: 'Science', - content_type: 'Learnerpathway', - uuid: '2', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'123', - logo_image_url: 'https://placekitten.com/200/100', - name: 'General Studies 2' - } - ] - }, - { - title: 'English', - content_type: 'Program', - uuid: '3', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'123', - logo_image_url: 'https://placekitten.com/200/100', - name: 'General Studies 3' - } - ] - }, - ], - }, - { - title: 'Dire Math', - uuid: '2', - is_published: true, - enterprise_curation: "321123", - highlighted_content: - [ - { - title: 'Math Xtreme', - content_type: 'Course', - uuid: '4', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'456', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Matheletes' - } - ] - }, - { - title: 'Science for Math Majors', - content_type: 'Course', - uuid: '5', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'456', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Matheletes' - } - ] - }, - { - title: 'English Divergence', - content_type: 'Course', - uuid: '6', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'456', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Matheletes' - } - ] - }, - ], - }, - { - title: 'Dire Science', - uuid: '3', - is_published: false, - enterprise_curation: "321123", - highlighted_content: - [ - { - title: 'Math for Science Majors', - content_type: 'Course', - uuid: '7', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'789', - logo_image_url: 'https://placekitten.com/200/100', - name: 'The Beakers' - } - ] - }, - { - title: 'Science Xtreme', - content_type: 'Course', - uuid: '8', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'789', - logo_image_url: 'https://placekitten.com/200/100', - name: 'The Beakers' - } - ] - }, - { - title: 'English Obfuscation', - content_type: 'Course', - uuid: '9', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'789', - logo_image_url: 'https://placekitten.com/200/100', - name: 'The Beakers' - } - ] - }, - ] - }, - { - title: 'Dire English', - uuid: '4', - is_published: true, - enterprise_curation: "321123", - highlighted_content: - [ - { - title: 'To Math or not Math', - content_type: 'Course', - uuid: '10', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'101112', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Extensive Etymology' - } - ] - }, - { - title: 'Science for English Majors', - content_type: 'Course', - uuid: '11', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'101112', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Extensive Etymology' - } - ] - }, - { - title: 'English Again', - content_type: 'Course', - uuid: '12', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'101112', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Extensive Etymology' - } - ] - }, - ], - }, - { - title: 'Dire Engineering', - uuid: '5', - is_published: true, - enterprise_curation: "321123", - highlighted_content: - [ - { - title: 'Math for Engineering Majors', - content_type: 'Course', - uuid: '13', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'131415', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Building Bridges' - } - ] - }, - { - title: 'Science for Engineering Majors', - content_type: 'Course', - uuid: '14', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'131415', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Building Bridges' - } - ] - }, - { - title: 'English Instantiation', - content_type: 'Course', - uuid: '15', - content_key: 'edX+DemoX', - authoring_organizations: - [ - { - uuid:'131415', - logo_image_url: 'https://placekitten.com/200/100', - name: 'Building Bridges' - } - ] - }, - ], - }, - ]; - /*es-lint-enable*/ \ No newline at end of file + { + uuid: faker.datatype.uuid(), + title: 'Dire Core', + is_published: true, + enterprise_curation: '321123', + highlighted_content: + [ + { + uuid: faker.datatype.uuid(), + content_type: 'Course', + content_key: 'edX+DemoX', + title: 'Math', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + name: 'General Studies 1', + logo_image_url: 'https://placekitten.com/200/100', + }, + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Super General Studies', + }, + ], + }, + { + title: 'Science', + content_type: 'Learnerpathway', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'General Studies 2', + }, + ], + }, + { + title: 'English', + content_type: 'Program', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'General Studies 3', + }, + ], + }, + ], + }, + { + title: 'Dire Math', + uuid: faker.datatype.uuid(), + is_published: true, + enterprise_curation: '321123', + highlighted_content: + [ + { + title: 'Math Xtreme', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Matheletes', + }, + ], + }, + { + title: 'Science for Math Majors', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Matheletes', + }, + ], + }, + { + title: 'English Divergence', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Matheletes', + }, + ], + }, + ], + }, + { + title: 'Dire Science', + uuid: faker.datatype.uuid(), + is_published: false, + enterprise_curation: '321123', + highlighted_content: + [ + { + title: 'Math for Science Majors', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'The Beakers', + }, + ], + }, + { + title: 'Science Xtreme', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'The Beakers', + }, + ], + }, + { + title: 'English Obfuscation', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'The Beakers', + }, + ], + }, + ], + }, + { + title: 'Dire English', + uuid: faker.datatype.uuid(), + is_published: true, + enterprise_curation: '321123', + highlighted_content: + [ + { + title: 'To Math or not Math', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Extensive Etymology', + }, + ], + }, + { + title: 'Science for English Majors', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Extensive Etymology', + }, + ], + }, + { + title: 'Moore English: Lawlessness Refined', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Extensive Etymology', + }, + ], + }, + ], + }, + { + title: 'Dire Engineering', + uuid: faker.datatype.uuid(), + is_published: true, + enterprise_curation: '321123', + highlighted_content: + [ + { + title: 'Math for Engineering Majors', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Building Bridges', + }, + ], + }, + { + title: 'Science for Engineering Majors', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Building Bridges', + }, + ], + }, + { + title: 'English Instantiation', + content_type: 'Course', + uuid: faker.datatype.uuid(), + content_key: 'edX+DemoX', + card_image_url: 'https://picsum.photos/360/200', + authoring_organizations: + [ + { + uuid: faker.datatype.uuid(), + logo_image_url: 'https://placekitten.com/200/100', + name: 'Building Bridges', + }, + ], + }, + ], + }, +]; diff --git a/src/components/ContentHighlights/data/hooks.js b/src/components/ContentHighlights/data/hooks.js index 003d2e8696..a691eb4be3 100644 --- a/src/components/ContentHighlights/data/hooks.js +++ b/src/components/ContentHighlights/data/hooks.js @@ -1,15 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useState, useEffect } from 'react'; +import { useContextSelector } from 'use-context-selector'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; -export const useStepperModalState = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - - return { - isModalOpen, - setIsModalOpen, - }; -}; - -export const useHighlightSetsForCuration = (enterpriseCuration) => { +export function useHighlightSetsForCuration(enterpriseCuration) { const [highlightSets, setHighlightSets] = useState({ draft: [], published: [], @@ -20,7 +13,7 @@ export const useHighlightSetsForCuration = (enterpriseCuration) => { const draftHighlightSets = []; const publishedHighlightSets = []; - highlightSetsForCuration.forEach((highlightSet) => { + highlightSetsForCuration?.forEach((highlightSet) => { if (highlightSet.isPublished) { publishedHighlightSets.push(highlightSet); } else { @@ -35,4 +28,61 @@ export const useHighlightSetsForCuration = (enterpriseCuration) => { }, [enterpriseCuration]); return highlightSets; -}; +} + +/** + * Defines an interface to mutate the `ContentHighlightsContext` context value. + */ +export function useContentHighlightsContext() { + const setState = useContextSelector(ContentHighlightsContext, v => v[1]); + + const openStepperModal = useCallback(() => { + setState(s => ({ + ...s, + stepperModal: { + ...s.stepperModal, + isOpen: true, + }, + })); + }, [setState]); + + const resetStepperModal = useCallback(() => { + setState(s => ({ + ...s, + stepperModal: { + ...s.stepperModal, + isOpen: false, + highlightTitle: null, + currentSelectedRowIds: {}, + }, + })); + }, [setState]); + + const setCurrentSelectedRowIds = useCallback((selectedRowIds) => { + setState(s => ({ + ...s, + stepperModal: { + ...s.stepperModal, + currentSelectedRowIds: selectedRowIds, + }, + })); + }, [setState]); + + const setHighlightTitle = useCallback(({ highlightTitle, titleStepValidationError }) => { + setState(s => ({ + ...s, + stepperModal: { + ...s.stepperModal, + highlightTitle, + titleStepValidationError, + }, + })); + }, [setState]); + + return { + openStepperModal, + resetStepperModal, + setCurrentSelectedRowIds, + setHighlightTitle, + }; +} diff --git a/src/components/ContentHighlights/data/reducer.js b/src/components/ContentHighlights/data/reducer.js deleted file mode 100644 index 5e5012ca70..0000000000 --- a/src/components/ContentHighlights/data/reducer.js +++ /dev/null @@ -1,25 +0,0 @@ -import { logError } from '@edx/frontend-platform/logging'; -import { - SET_HIGHLIGHT_STEPPER_MODAL, - SET_CURRENT_STEPPER_STEP, -} from './actions'; - -export const initialStepperModalState = { - isOpen: false, - step: 0, - highlight: null, -}; - -export const stepperModalReducer = (state = initialStepperModalState, action) => { - switch (action.type) { - case SET_HIGHLIGHT_STEPPER_MODAL: - return { ...state, isOpen: action.payload.data }; - case SET_CURRENT_STEPPER_STEP: - return { ...state, step: action.payload.data }; - default: { - const msg = `stepperModalReducer received an unexpected action type: ${action.type}`; - logError(msg); - throw new Error(msg); - } - } -}; diff --git a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx index 6907f3f673..b8b8234f54 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx @@ -1,15 +1,15 @@ -import { useMemo } from 'react'; -import { screen, fireEvent } from '@testing-library/react'; +import { useState } from 'react'; +import { screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import algoliasearch from 'algoliasearch/lite'; import ContentHighlightSetCard from '../ContentHighlightSetCard'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; -import { useStepperModalState } from '../data/hooks'; -import ContentHighlightStepper from '../HighlightStepper/ContentHighlightStepper'; -import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import CurrentContentHighlightHeader from '../CurrentContentHighlightHeader'; +import { configuration } from '../../../config'; const mockStore = configureMockStore([thunk]); @@ -29,37 +29,29 @@ const initialState = { highlightSetUUID: 'test-uuid', }; -const initialEnterpriseAppContextValue = { - enterpriseCuration: { - enterpriseCuration: { - highlightSets: [], - }, - }, -}; +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); -/* eslint-disable react/prop-types */ -const ContentHighlightSetCardWrapper = ({ - enterpriseAppContextValue = initialEnterpriseAppContextValue, - ...props -}) => { -/* eslint-enable react/prop-types */ - const { setIsModalOpen, isModalOpen } = useStepperModalState(); - const defaultValue = useMemo( - () => ({ - setIsModalOpen, - isModalOpen, - }), - [setIsModalOpen, isModalOpen], - ); +const ContentHighlightSetCardWrapper = (props) => { + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: {}, + }, + contentHighlights: [], + searchClient, + }); return ( - - - - - - - - + + + + + + ); }; @@ -68,13 +60,4 @@ describe('', () => { renderWithRouter(); expect(screen.getByText('Test Title')).toBeInTheDocument(); }); - it('Displays the stepper modal on click of the draft status', () => { - const props = { - ...mockData, - isPublished: false, - }; - renderWithRouter(); - fireEvent.click(screen.getByText('Test Title')); - expect(screen.getByText('Create a title for the highlight collection')).toBeInTheDocument(); - }); }); diff --git a/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx index 6421d80f38..c6226863ed 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx @@ -12,11 +12,7 @@ import { TEST_COURSE_HIGHLIGHTS_DATA } from '../data/constants'; const mockStore = configureMockStore([thunk]); -const highlightSetUUID = '1'; -const contentByUUID = camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA).filter( - highlight => highlight.uuid === highlightSetUUID, -)[0]?.highlightedContent; - +const testHighlightSet = camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA)[0]?.highlightedContent; const initialState = { portalConfiguration: { enterpriseSlug: 'test-enterprise', @@ -32,27 +28,27 @@ const ContentHighlightsCardItemsContainerWrapper = (props) => ( describe('', () => { it('Displays all content data titles', () => { renderWithRouter(); - const firstTitle = contentByUUID[0].title; - const lastTitle = contentByUUID[contentByUUID.length - 1].title; + const firstTitle = testHighlightSet[0].title; + const lastTitle = testHighlightSet[testHighlightSet.length - 1].title; expect(screen.getByText(firstTitle)).toBeInTheDocument(); expect(screen.getByText(lastTitle)).toBeInTheDocument(); }); it('Displays all content data content types', () => { renderWithRouter(); - const firstContentType = contentByUUID[0].contentType; - const lastContentType = contentByUUID[contentByUUID.length - 1].contentType; + const firstContentType = testHighlightSet[0].contentType; + const lastContentType = testHighlightSet[testHighlightSet.length - 1].contentType; expect(screen.getByText(firstContentType)).toBeInTheDocument(); expect(screen.getByText(lastContentType)).toBeInTheDocument(); }); - it('Displays only the first organization', () => { + it('Displays multiple organizations', () => { renderWithRouter(); - const firstContentType = contentByUUID[0] + const firstContentType = testHighlightSet[0] .authoringOrganizations[0].name; - const lastContentType = contentByUUID[0] - .authoringOrganizations[contentByUUID[0].authoringOrganizations.length - 1].name; - expect(screen.getByText(firstContentType)).toBeInTheDocument(); - expect(screen.queryByText(lastContentType)).not.toBeInTheDocument(); + const lastContentType = testHighlightSet[0] + .authoringOrganizations[testHighlightSet[0].authoringOrganizations.length - 1].name; + expect(screen.getByText(firstContentType, { exact: false })).toBeInTheDocument(); + expect(screen.getByText(lastContentType, { exact: false })).toBeInTheDocument(); }); }); diff --git a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx index a88cf56f5a..e9b2b12d0b 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useState } from 'react'; import { screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; @@ -7,10 +7,12 @@ import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import algoliasearch from 'algoliasearch/lite'; +import { BUTTON_TEXT, STEPPER_STEP_TEXT, HEADER_TEXT } from '../data/constants'; import ContentHighlightsDashboard from '../ContentHighlightsDashboard'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; -import { useStepperModalState } from '../data/hooks'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { configuration } from '../../../config'; const mockStore = configureMockStore([thunk]); @@ -28,6 +30,11 @@ const initialEnterpriseAppContextValue = { }, }; +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); + const exampleHighlightSet = { uuid: 'fake-uuid', title: 'Test Highlight Set', @@ -40,17 +47,22 @@ const ContentHighlightsDashboardWrapper = ({ enterpriseAppContextValue = initialEnterpriseAppContextValue, ...props }) => { -/* eslint-enable react/prop-types */ - const { setIsModalOpen, isModalOpen } = useStepperModalState(); - const defaultValue = useMemo(() => ({ - setIsModalOpen, - isModalOpen, - }), [setIsModalOpen, isModalOpen]); + /* eslint-enable react/prop-types */ + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: {}, + }, + contentHighlights: [], + searchClient, + }); return ( - + @@ -67,9 +79,9 @@ describe('', () => { it('Displays New highlight Modal on button click with no highlighted content list', () => { renderWithRouter(); - const newHighlight = screen.getByText('New highlight'); + const newHighlight = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); fireEvent.click(newHighlight); - expect(screen.getByText('Create a title for the highlight collection')).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); }); it('Displays current highlights when data is populated', () => { @@ -84,13 +96,13 @@ describe('', () => { }} />, ); - expect(screen.getByText('Highlight collections')).toBeInTheDocument(); + expect(screen.getByText(HEADER_TEXT.currentContent)).toBeInTheDocument(); }); it('Displays New highlight modal on button click with highlighted content list', () => { renderWithRouter(); const newHighlight = screen.getByText('New highlight'); fireEvent.click(newHighlight); - expect(screen.getByText('Create a title for the highlight collection')).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); }); }); diff --git a/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx b/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx index 9d388af471..2ab9bd3e94 100644 --- a/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx +++ b/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx @@ -1,14 +1,16 @@ -import { useMemo } from 'react'; -import { screen, fireEvent } from '@testing-library/react'; +import { useState } from 'react'; +import { screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import algoliasearch from 'algoliasearch/lite'; import CurrentContentHighlights from '../CurrentContentHighlights'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; -import { useStepperModalState } from '../data/hooks'; +import { BUTTON_TEXT, HEADER_TEXT } from '../data/constants'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { configuration } from '../../../config'; const mockStore = configureMockStore([thunk]); @@ -26,41 +28,46 @@ const initialEnterpriseAppContextValue = { }, }; +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); + /* eslint-disable react/prop-types */ const CurrentContentHighlightsWrapper = ({ enterpriseAppContextValue = initialEnterpriseAppContextValue, ...props }) => { /* eslint-enable react/prop-types */ - const { setIsModalOpen, isModalOpen } = useStepperModalState(); - const defaultValue = useMemo(() => ({ - setIsModalOpen, - isModalOpen, - }), [setIsModalOpen, isModalOpen]); + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: {}, + }, + contentHighlights: [], + searchClient, + }); return ( - - - + + + - - - + + + ); }; describe('', () => { it('Displays the header title', () => { renderWithRouter(); - expect(screen.getByText('Highlight collections')).toBeInTheDocument(); + expect(screen.getByText(HEADER_TEXT.currentContent)).toBeInTheDocument(); }); it('Displays the header button', () => { renderWithRouter(); - expect(screen.getByText('New')).toBeInTheDocument(); - }); - it('Displays the stepper modal on click of the header button', () => { - renderWithRouter(); - fireEvent.click(screen.getByText('New')); - expect(screen.getByText('Create a title for the highlight collection')).toBeInTheDocument(); + expect(screen.getByText(BUTTON_TEXT.createNewHighlight)).toBeInTheDocument(); }); describe('ContentHighlightSetCardContainer', () => { diff --git a/src/components/EnterpriseApp/data/enterpriseCurationReducer.js b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js index d5cb4f3994..c0db4b5eda 100644 --- a/src/components/EnterpriseApp/data/enterpriseCurationReducer.js +++ b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js @@ -66,7 +66,7 @@ function enterpriseCurationReducer(state, action) { ...state, enterpriseCuration: { ...state.enterpriseCuration, - highlightSets: [...existingHighlightSets, action.payload], + highlightSets: [action.payload, ...existingHighlightSets], }, }; } diff --git a/src/components/settings/data/hooks.js b/src/components/settings/data/hooks.js index dc15d4ee6c..1816a98cb4 100644 --- a/src/components/settings/data/hooks.js +++ b/src/components/settings/data/hooks.js @@ -159,6 +159,9 @@ export const useStylesForCustomBrandColors = (branding) => { .border-brand-primary { border-color: ${brandColors.primary.regular.hex()} !important; } + .color-brand-tertiary { + color: ${brandColors.tertiary.regular.hex()} !important; + } `), }); diff --git a/src/data/services/EnterpriseCatalogApiService.js b/src/data/services/EnterpriseCatalogApiService.js index a11a780725..3f20aeceab 100644 --- a/src/data/services/EnterpriseCatalogApiService.js +++ b/src/data/services/EnterpriseCatalogApiService.js @@ -53,6 +53,17 @@ class EnterpriseCatalogApiService { ); } + static createHighlightSet(enterpriseId, options = {}) { + const payload = { + enterprise_customer: enterpriseId, + ...snakeCaseObject(options), + }; + return EnterpriseCatalogApiService.apiClient().post( + EnterpriseCatalogApiService.highlightSetUrl, + payload, + ); + } + static deleteHighlightSet(highlightSetUUID) { return EnterpriseCatalogApiService.apiClient().delete(`${EnterpriseCatalogApiService.highlightSetUrl}${highlightSetUUID}/`); } From fddbe306cb4ede209ece212e785124851432880e Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Fri, 2 Dec 2022 11:02:20 -0500 Subject: [PATCH 03/73] fix: Replace hardcoded UUID with enterpriseID (#911) * feat: add basic validation on stepper title * feat: persist data through stepper and update UI * chore: updates * fix: moar updates * feat: lots of updates * fix: don't break the detail page for a specific highlight set * chore: rename some stuff * feat: create and publish new highlight set from stepper modal * chore: slight refactor to define interface for setting content highlight contex state value * chore: Refactored testing to updated context/stepper implementation * fix: Replace hardcoded UUID with enterpriseID Co-authored-by: Adam Stankiewicz --- .../HighlightStepper/ContentHighlightStepper.jsx | 2 +- .../HighlightStepper/HighlightStepperSelectContent.jsx | 9 +++++++-- .../HighlightStepperSelectContentDataTable.jsx | 9 ++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx index a22cf91a3f..79fc4a5c63 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx @@ -174,7 +174,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { title={STEPPER_STEP_LABELS.SELECT_CONTENT} index={steps.indexOf(STEPPER_STEP_LABELS.SELECT_CONTENT)} > - + ( +const HighlightStepperSelectContent = ({ enterpriseId }) => ( @@ -14,10 +15,14 @@ const HighlightStepperSelectContent = () => ( - + ); +HighlightStepperSelectContent.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + export default HighlightStepperSelectContent; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx index d8e2c47a67..4dac488365 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx @@ -25,10 +25,9 @@ const selectColumn = { disableSortBy: true, }; -const prodEnterpriseId = 'e783bb19-277f-479e-9c41-8b0ed31b4060'; const currentEpoch = Math.round((new Date()).getTime() / 1000); -const HighlightStepperSelectContent = () => { +const HighlightStepperSelectContent = ({ enterpriseId }) => { const { setCurrentSelectedRowIds } = useContentHighlightsContext(); const currentSelectedRowIds = useContextSelector( ContentHighlightsContext, @@ -39,7 +38,7 @@ const HighlightStepperSelectContent = () => { v => v[0].searchClient, ); - const searchFilters = `enterprise_customer_uuids:${prodEnterpriseId} AND advertised_course_run.upgrade_deadline > ${currentEpoch} AND content_type:course`; + const searchFilters = `enterprise_customer_uuids:${enterpriseId} AND advertised_course_run.upgrade_deadline > ${currentEpoch}`; return ( { ); }; +HighlightStepperSelectContent.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + const PriceTableCell = ({ row }) => { const contentPrice = row.original.firstEnrollablePaidSeatPrice; if (!contentPrice) { From 044da3a88ccbfe8623b4218b92153412d7eb3d01 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Mon, 5 Dec 2022 18:49:05 +0000 Subject: [PATCH 04/73] feat: Show more user-friendly error message for sync failures that don't come with an error code --- .../ErrorReporting/tests/ErrorReporting.test.jsx | 13 +++++++++---- .../SettingsLMSTab/ErrorReporting/utils.jsx | 2 +- .../settings/SettingsSSOTab/ExistingSSOConfigs.jsx | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx index 211155ca52..e6133d492f 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx @@ -59,9 +59,9 @@ const contentSyncData = { { content_title: 'Demo4', content_id: 'DemoX-4', - sync_status: 'pending', + sync_status: 'error', sync_last_attempted_at: '2022-09-26T19:27:18.127225Z', - friendly_status_message: 'The request was unauthorized, check your credentials.', + friendly_status_message: null, }, { content_title: 'Demo5', @@ -191,10 +191,15 @@ describe('', () => { expect(screen.getAllByText('Pending')[0]).toBeInTheDocument(); expect(screen.getByText('Demo3')).toBeInTheDocument(); - expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getAllByText('Error')[0]).toBeInTheDocument(); - await waitFor(() => userEvent.click(screen.queryByText('Read'))); + const readLinks = screen.queryAllByText('Read'); + await waitFor(() => userEvent.click(readLinks[0])); expect(screen.getByText('The request was unauthorized, check your credentials.')).toBeInTheDocument(); + // Click away to close message + await waitFor(() => userEvent.click(readLinks[0])); + await waitFor(() => userEvent.click(readLinks[1])); + expect(screen.getByText(/Something went wrong.*Please contact enterprise customer support/)).toBeInTheDocument(); }); it('paginates over data', async () => { const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx index c958d90152..7e44079df9 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx @@ -42,7 +42,7 @@ export function getSyncStatus(status, statusMessage) {
Error
- {statusMessage} + {statusMessage || 'Something went wrong. Please contact enterprise customer support.'}
)} diff --git a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx index cce503ada5..832564cdad 100644 --- a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx +++ b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx @@ -91,7 +91,7 @@ const ExistingSSOConfigs = ({ Enabling single sign-on for your edX account allows quick access to your organization’s learning catalog

-

Existing configurationss

+

Existing configurations

Date: Fri, 9 Dec 2022 12:15:03 -0500 Subject: [PATCH 05/73] fix: update highlights selectionstatus component to show page selections (#913) Also, refactors Bulk Enrollment to use controlled selections. --- __mocks__/react-instantsearch-dom.jsx | 6 +- package-lock.json | 81 +++++++++--- package.json | 6 +- .../CourseSearchResults.jsx | 49 +++++-- .../CourseSearchResults.test.jsx | 29 ++--- .../BulkEnrollmentPage/data/actions.js | 12 +- .../BulkEnrollmentPage/data/actions.test.js | 20 ++- .../BulkEnrollmentPage/data/reducer.js | 12 +- .../BulkEnrollmentPage/data/reducer.test.js | 51 ++++---- .../stepper/AddCoursesStep.jsx | 8 +- .../stepper/BulkEnrollmentStepper.jsx | 1 + .../BulkEnrollmentPage/stepper/ReviewList.jsx | 77 +++++++---- .../stepper/ReviewList.test.jsx | 12 +- .../BulkEnrollmentPage/stepper/ReviewStep.jsx | 20 +-- .../stepper/ReviewStepCourseList.jsx | 123 ++++++++++++++++++ .../stepper/ReviewStepCourseList.test.jsx | 59 +++++++++ .../BulkEnrollmentPage/stepper/constants.jsx | 1 + .../table/BaseSelectionStatus.jsx | 35 ++--- .../table/BaseSelectionStatus.test.jsx | 91 ++++++------- .../table/BulkEnrollSelect.jsx | 64 ++------- .../table/BulkEnrollSelect.test.jsx | 113 +++------------- .../table/CourseSearchResultsCells.jsx | 2 +- .../BulkEnrollmentPage/table/helpers.js | 2 - .../HighlightStepperSelectContent.jsx | 4 +- .../HighlightStepperSelectContentHeader.jsx | 20 ++- ...> HighlightStepperSelectContentSearch.jsx} | 35 ++--- .../SelectContentSelectionStatus.jsx | 5 +- 27 files changed, 530 insertions(+), 408 deletions(-) create mode 100644 src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.jsx create mode 100644 src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.test.jsx delete mode 100644 src/components/BulkEnrollmentPage/table/helpers.js rename src/components/ContentHighlights/HighlightStepper/{HighlightStepperSelectContentDataTable.jsx => HighlightStepperSelectContentSearch.jsx} (89%) diff --git a/__mocks__/react-instantsearch-dom.jsx b/__mocks__/react-instantsearch-dom.jsx index 9bd30c34fc..e7951f7179 100644 --- a/__mocks__/react-instantsearch-dom.jsx +++ b/__mocks__/react-instantsearch-dom.jsx @@ -74,10 +74,12 @@ MockReactInstantSearch.connectPagination = Component => function connectPaginati }; MockReactInstantSearch.InstantSearch = function InstantSearch({ children }) { - return children; + return ( +
{children}
+ ); }; MockReactInstantSearch.Configure = function Configure() { - return
CONFIGURED
; + return
CONFIGURED
; }; module.exports = MockReactInstantSearch; diff --git a/package-lock.json b/package-lock.json index 545eba7975..fa1b9efc27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,12 @@ "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", "@edx/brand": "npm:@edx/brand-edx.org@^2.0.7", - "@edx/frontend-enterprise-catalog-search": "3.1.0", + "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.21.0", + "@edx/paragon": "20.21.1", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -3602,11 +3602,11 @@ "dev": true }, "node_modules/@edx/frontend-enterprise-catalog-search": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-enterprise-catalog-search/-/frontend-enterprise-catalog-search-3.1.0.tgz", - "integrity": "sha512-4h7niCBltFHKJipdnb0CK0Tyxq40vmC8vsKxXjF6V1vQkuWIEf1FZ4qV+CqrqH1o7pK2uSS2sCEMRn4zG8h/Kg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@edx/frontend-enterprise-catalog-search/-/frontend-enterprise-catalog-search-3.1.5.tgz", + "integrity": "sha512-CDthbmT5PGbY7NfQ7+VZifr3NJEvFjARfpZP3p1Yxthxjy3dYxQCozC3BaZJBlozeO3xYf6g3a8BfZ7HepHMYA==", "dependencies": { - "@edx/frontend-enterprise-utils": "^2.1.0", + "@edx/frontend-enterprise-utils": "^2.2.0", "classnames": "2.2.5", "lodash.debounce": "4.0.8", "prop-types": "15.7.2" @@ -3622,6 +3622,37 @@ "react-router-dom": "^5.2.0" } }, + "node_modules/@edx/frontend-enterprise-catalog-search/node_modules/@edx/frontend-enterprise-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-enterprise-utils/-/frontend-enterprise-utils-2.2.0.tgz", + "integrity": "sha512-5cAFO0vk4RIbprdIqi6Fq1nJcZHC+1wH3APSj8R+yIrJW1oWY4z6lnqHjnbsmyFQdECs3WCMsA43bRTLPgdnOw==", + "dependencies": { + "@testing-library/react": "11.2.6", + "history": "4.10.1" + }, + "peerDependencies": { + "@edx/frontend-platform": "^1.9.6 || ^2.0.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "react-router-dom": "^5.2.0" + } + }, + "node_modules/@edx/frontend-enterprise-catalog-search/node_modules/@testing-library/react": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.6.tgz", + "integrity": "sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^7.28.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/@edx/frontend-enterprise-catalog-search/node_modules/classnames": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", @@ -3758,9 +3789,9 @@ } }, "node_modules/@edx/paragon": { - "version": "20.21.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.0.tgz", - "integrity": "sha512-p9g/ONfWsZ6UxX7hNEoO2nUI0qhdGqQZYvLk3MpE0NMmTUYafpCBpRwOGYTXOXpIBzRENRiJUGJ/59XL0MelOw==", + "version": "20.21.1", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.1.tgz", + "integrity": "sha512-bSpwdIfWZtipN3NH2eJ+8lWpA576vL/87iHpJCIcrOq5OiH49Fm0Q7kGI+t0s+fMZJboBxJ+eMtC2Nv7Op4Cmg==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -28709,16 +28740,34 @@ } }, "@edx/frontend-enterprise-catalog-search": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-enterprise-catalog-search/-/frontend-enterprise-catalog-search-3.1.0.tgz", - "integrity": "sha512-4h7niCBltFHKJipdnb0CK0Tyxq40vmC8vsKxXjF6V1vQkuWIEf1FZ4qV+CqrqH1o7pK2uSS2sCEMRn4zG8h/Kg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@edx/frontend-enterprise-catalog-search/-/frontend-enterprise-catalog-search-3.1.5.tgz", + "integrity": "sha512-CDthbmT5PGbY7NfQ7+VZifr3NJEvFjARfpZP3p1Yxthxjy3dYxQCozC3BaZJBlozeO3xYf6g3a8BfZ7HepHMYA==", "requires": { - "@edx/frontend-enterprise-utils": "^2.1.0", + "@edx/frontend-enterprise-utils": "^2.2.0", "classnames": "2.2.5", "lodash.debounce": "4.0.8", "prop-types": "15.7.2" }, "dependencies": { + "@edx/frontend-enterprise-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-enterprise-utils/-/frontend-enterprise-utils-2.2.0.tgz", + "integrity": "sha512-5cAFO0vk4RIbprdIqi6Fq1nJcZHC+1wH3APSj8R+yIrJW1oWY4z6lnqHjnbsmyFQdECs3WCMsA43bRTLPgdnOw==", + "requires": { + "@testing-library/react": "11.2.6", + "history": "4.10.1" + } + }, + "@testing-library/react": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.6.tgz", + "integrity": "sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==", + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^7.28.1" + } + }, "classnames": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", @@ -28820,9 +28869,9 @@ } }, "@edx/paragon": { - "version": "20.21.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.0.tgz", - "integrity": "sha512-p9g/ONfWsZ6UxX7hNEoO2nUI0qhdGqQZYvLk3MpE0NMmTUYafpCBpRwOGYTXOXpIBzRENRiJUGJ/59XL0MelOw==", + "version": "20.21.1", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.1.tgz", + "integrity": "sha512-bSpwdIfWZtipN3NH2eJ+8lWpA576vL/87iHpJCIcrOq5OiH49Fm0Q7kGI+t0s+fMZJboBxJ+eMtC2Nv7Op4Cmg==", "requires": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", diff --git a/package.json b/package.json index 16ce109e2a..bdedd8c414 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,12 @@ "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", "@edx/brand": "npm:@edx/brand-edx.org@^2.0.7", - "@edx/frontend-enterprise-catalog-search": "3.1.0", + "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.21.0", + "@edx/paragon": "20.21.1", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -105,4 +105,4 @@ "react-test-renderer": "16.13.1", "resize-observer-polyfill": "1.5.1" } -} \ No newline at end of file +} diff --git a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx index 808d4dcf35..673e7cbef9 100644 --- a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx +++ b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx @@ -1,4 +1,5 @@ import React, { + useCallback, useContext, useMemo, } from 'react'; @@ -12,7 +13,9 @@ import { CourseNameCell, FormattedDateCell } from './table/CourseSearchResultsCe import { BulkEnrollContext } from './BulkEnrollmentContext'; import BaseSelectionStatus from './table/BaseSelectionStatus'; -import { BaseSelectWithContext, BaseSelectWithContextHeader } from './table/BulkEnrollSelect'; +import { BaseSelectWithContext } from './table/BulkEnrollSelect'; +import { NUM_CONTENT_ITEMS_PER_PAGE } from './stepper/constants'; +import { setSelectedRowsAction } from './data/actions'; const ERROR_MESSAGE = 'An error occured while retrieving data'; export const NO_DATA_MESSAGE = 'There are no course results'; @@ -32,11 +35,9 @@ const AddCoursesSelectionStatus = (props) => { const SelectWithContext = (props) => ; -const SelectWithContextHeader = (props) => ; - const selectColumn = { id: 'selection', - Header: SelectWithContextHeader, + Header: () => null, Cell: SelectWithContext, disableSortBy: true, }; @@ -64,7 +65,6 @@ export const BaseCourseSearchResults = (props) => { const { refinements } = useContext(SearchContext); const columns = useMemo(() => [ - selectColumn, { Header: TABLE_HEADERS.courseName, accessor: 'title', @@ -99,7 +99,31 @@ export const BaseCourseSearchResults = (props) => { [searchState, refinements], ); - const { courses: [selectedCourses] } = useContext(BulkEnrollContext); + const { courses: [selectedCourses, coursesDispatch] } = useContext(BulkEnrollContext); + const transformedSelectedRowIds = useMemo( + () => { + const selectedRowIds = {}; + selectedCourses.forEach((row) => { + selectedRowIds[row.id] = true; + }); + return selectedRowIds; + }, + [selectedCourses], + ); + + const onSelectedRowsChanged = useCallback( + (selectedRowIds) => { + const selectedFlatRowIds = Object.keys(selectedRowIds).map(selectedRowId => selectedRowId); + const transformedSelectedFlatRowIds = selectedFlatRowIds.map(rowId => ({ + id: rowId, + values: { + aggregationKey: rowId, + }, + })); + coursesDispatch(setSelectedRowsAction(transformedSelectedFlatRowIds)); + }, + [coursesDispatch], + ); if (isSearchStalled) { return ( @@ -135,12 +159,19 @@ export const BaseCourseSearchResults = (props) => { columns={columns} data={searchResults?.hits || []} itemCount={searchResults?.nbHits} + isSelectable + isPaginated + manualSelectColumn={selectColumn} + onSelectedRowsChanged={onSelectedRowsChanged} SelectionStatusComponent={AddCoursesSelectionStatus} pageCount={searchResults?.nbPages || 1} - pageSize={searchResults?.hitsPerPage || 0} - selectedFlatRows={selectedCourses} + initialState={{ + pageIndex: 0, + pageSize: NUM_CONTENT_ITEMS_PER_PAGE, + selectedRowIds: transformedSelectedRowIds, + }} initialTableOptions={{ - getRowId: (row) => row.key, + getRowId: row => row.aggregation_key, }} > diff --git a/src/components/BulkEnrollmentPage/CourseSearchResults.test.jsx b/src/components/BulkEnrollmentPage/CourseSearchResults.test.jsx index 4fbb9b01aa..c33376dac5 100644 --- a/src/components/BulkEnrollmentPage/CourseSearchResults.test.jsx +++ b/src/components/BulkEnrollmentPage/CourseSearchResults.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { mount } from 'enzyme'; -import { act, screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import configureMockStore from 'redux-mock-store'; import userEvent from '@testing-library/user-event'; @@ -45,6 +45,7 @@ const searchResults = { start: testStartDate, end: testEndDate, }, + aggregation_key: 'course:foo', key: 'foo', short_description: testCourseDesc, partners: [{ name: 'edX' }, { name: 'another_unused' }], @@ -56,6 +57,7 @@ const searchResults = { start: testStartDate2, end: testEndDate2, }, + aggregation_key: 'course:foo2', key: 'foo2', short_description: testCourseDesc2, partners: [{ name: 'edX' }, { name: 'another_unused' }], @@ -111,14 +113,14 @@ describe('', () => { expect(tableCells.at(2).text()).toBe('edX'); expect(tableCells.at(3).text()).toBe('Sep 10, 2020 - Sep 10, 2030'); }); - it('renders popover with course description', () => { + it('renders popover with course description', async () => { renderWithRouter(); expect(screen.queryByText(/short description of course 1/)).not.toBeInTheDocument(); const courseTitle = screen.getByText(testCourseName); - act(() => { - userEvent.click(courseTitle); + userEvent.click(courseTitle); + await waitFor(() => { + expect(screen.getByText(/short description of course 1/)).toBeInTheDocument(); }); - expect(screen.getByText(/short description of course 1/)).toBeInTheDocument(); }); it('displays search pagination', () => { const wrapper = mount(); @@ -137,22 +139,7 @@ describe('', () => { renderWithRouter(); const rowToSelect = screen.getByText(testCourseName2).closest('tr'); userEvent.click(within(rowToSelect).getByTestId('selectOne')); - expect(screen.getByText('1 selected')).toBeInTheDocument(); - }); - it('shows all selected when courses on a page are selected', () => { - const onePageState = { page: 0 }; - const allSelectedProps = { - searchResults, - searchState: onePageState, - isSearchStalled: false, - enterpriseId: 'foo', - enterpriseSlug: 'fancyCompany', - }; - renderWithRouter(); - const selection = screen.getByTestId('selectAll'); - userEvent.click(selection); - - expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByText('1 selected (1 shown below)', { exact: false })).toBeInTheDocument(); }); it('renders a message when there are no results', () => { const wrapper = mount( ({ +export const setSelectedRowsAction = (selectedRowIds) => ({ type: SET_SELECTED_ROWS, - rows, + payload: selectedRowIds, }); export const DELETE_ROW = 'DELETE ROW'; export const deleteSelectedRowAction = (rowId) => ({ type: DELETE_ROW, - rowId, -}); - -export const ADD_ROW = 'ADD ROW'; -export const addSelectedRowAction = (row) => ({ - type: ADD_ROW, - row, + payload: rowId, }); export const CLEAR_SELECTION = 'CLEAR SELECTION'; diff --git a/src/components/BulkEnrollmentPage/data/actions.test.js b/src/components/BulkEnrollmentPage/data/actions.test.js index ce1a7ce148..a0b8dba561 100644 --- a/src/components/BulkEnrollmentPage/data/actions.test.js +++ b/src/components/BulkEnrollmentPage/data/actions.test.js @@ -1,19 +1,23 @@ import { - setSelectedRowsAction, deleteSelectedRowAction, SET_SELECTED_ROWS, DELETE_ROW, clearSelectionAction, CLEAR_SELECTION, - addSelectedRowAction, ADD_ROW, + setSelectedRowsAction, + deleteSelectedRowAction, + SET_SELECTED_ROWS, + DELETE_ROW, + clearSelectionAction, + CLEAR_SELECTION, } from './actions'; describe('selectedRows actions', () => { it('setSelectedRows returns an action with rows and the correct type', () => { const rows = [{ id: 1 }]; const result = setSelectedRowsAction(rows); - expect(result).toEqual({ type: SET_SELECTED_ROWS, rows }); + expect(result).toEqual({ type: SET_SELECTED_ROWS, payload: rows }); }); it('deleteSelectedRow returns an action with an id and the correct type', () => { const result = deleteSelectedRowAction(2); expect(result).toEqual({ type: DELETE_ROW, - rowId: 2, + payload: 2, }); }); it('clearSelection returns and action with the correct type', () => { @@ -22,12 +26,4 @@ describe('selectedRows actions', () => { type: CLEAR_SELECTION, }); }); - it('addSelectedRow returns and action with the type and the row', () => { - const row = { id: 1 }; - const result = addSelectedRowAction(row); - expect(result).toEqual({ - type: ADD_ROW, - row, - }); - }); }); diff --git a/src/components/BulkEnrollmentPage/data/reducer.js b/src/components/BulkEnrollmentPage/data/reducer.js index d27472608f..07fe3d94c3 100644 --- a/src/components/BulkEnrollmentPage/data/reducer.js +++ b/src/components/BulkEnrollmentPage/data/reducer.js @@ -1,17 +1,15 @@ -import { uniqBy } from 'lodash'; import { - SET_SELECTED_ROWS, DELETE_ROW, ADD_ROW, CLEAR_SELECTION, + SET_SELECTED_ROWS, + DELETE_ROW, + CLEAR_SELECTION, } from './actions'; const selectedRowsReducer = (state = [], action) => { switch (action.type) { case SET_SELECTED_ROWS: - return uniqBy([...state, ...action.rows], (row) => row.id); - + return action.payload; case DELETE_ROW: - return state.filter((row) => row.id !== action.rowId); - case ADD_ROW: - return [...state, action.row]; + return state.filter(row => row.id !== action.payload); case CLEAR_SELECTION: return []; default: diff --git a/src/components/BulkEnrollmentPage/data/reducer.test.js b/src/components/BulkEnrollmentPage/data/reducer.test.js index c6888acacc..b66b74e162 100644 --- a/src/components/BulkEnrollmentPage/data/reducer.test.js +++ b/src/components/BulkEnrollmentPage/data/reducer.test.js @@ -1,30 +1,36 @@ import selectedRowsReducer from './reducer'; import { - setSelectedRowsAction, deleteSelectedRowAction, clearSelectionAction, addSelectedRowAction, + setSelectedRowsAction, + deleteSelectedRowAction, + clearSelectionAction, } from './actions'; describe('selectedRowsReducer', () => { it('can set rows when there are no selected rows', () => { const selectedRows = []; const newSelectedRows = [{ id: 2 }, { id: 3 }]; - expect(selectedRowsReducer(selectedRows, setSelectedRowsAction(newSelectedRows))).toEqual(newSelectedRows); + expect(selectedRowsReducer( + selectedRows, + setSelectedRowsAction(newSelectedRows), + )).toEqual(newSelectedRows); }); - it('can add selected rows', () => { + it('can set selected rows', () => { const selectedRows = [{ id: 1 }]; const newSelectedRows = [{ id: 2 }, { id: 3 }]; - const expected = [{ id: 1 }, { id: 2 }, { id: 3 }]; - expect(selectedRowsReducer(selectedRows, setSelectedRowsAction(newSelectedRows))).toEqual(expected); - }); - it('dedupes added rows', () => { - const selectedRows = [{ id: 1 }]; - const newSelectedRows = [{ id: 2 }, { id: 3 }, { id: 1 }]; - const expected = [{ id: 1 }, { id: 2 }, { id: 3 }]; - expect(selectedRowsReducer(selectedRows, setSelectedRowsAction(newSelectedRows))).toEqual(expected); + const expected = [{ id: 2 }, { id: 3 }]; + const result = selectedRowsReducer( + selectedRows, + setSelectedRowsAction(newSelectedRows), + ); + expect(result).toEqual(expected); }); it('can delete a row', () => { const selectedRows = [{ id: 2 }, { id: 3 }]; const expected = [{ id: 3 }]; - const result = selectedRowsReducer(selectedRows, deleteSelectedRowAction(2)); + const result = selectedRowsReducer( + selectedRows, + deleteSelectedRowAction(2), + ); expect(result).toEqual(expected); }); test.each( @@ -34,18 +40,17 @@ describe('selectedRowsReducer', () => { [[]], ], )('can clear all rows %#', (selectedRows) => { - const result = selectedRowsReducer(selectedRows, clearSelectionAction()); + const result = selectedRowsReducer( + selectedRows, + clearSelectionAction(), + ); expect(result).toEqual([]); }); - test.each( - [ - [[{ id: 'foo' }]], - [[{ id: 'foo' }, { id: 'bar' }]], - [[]], - ], - )('can add one row %#', (selectedRows) => { - const newRow = { id: '1235' }; - const result = selectedRowsReducer(selectedRows, addSelectedRowAction(newRow)); - expect(result).toEqual([...selectedRows, newRow]); + it('handles unknown action', () => { + const reducerState = { id: 'foo' }; + expect(selectedRowsReducer( + reducerState, + { type: 'unknown' }, + )).toEqual(reducerState); }); }); diff --git a/src/components/BulkEnrollmentPage/stepper/AddCoursesStep.jsx b/src/components/BulkEnrollmentPage/stepper/AddCoursesStep.jsx index 2aee03fcb2..17048ab11d 100644 --- a/src/components/BulkEnrollmentPage/stepper/AddCoursesStep.jsx +++ b/src/components/BulkEnrollmentPage/stepper/AddCoursesStep.jsx @@ -6,7 +6,11 @@ import { SearchData, SearchHeader } from '@edx/frontend-enterprise-catalog-searc import DismissibleCourseWarning from './DismissibleCourseWarning'; import { configuration } from '../../../config'; -import { ADD_COURSES_TITLE, ADD_COURSE_DESCRIPTION } from './constants'; +import { + ADD_COURSES_TITLE, + ADD_COURSE_DESCRIPTION, + NUM_CONTENT_ITEMS_PER_PAGE, +} from './constants'; import { BulkEnrollContext } from '../BulkEnrollmentContext'; import CourseSearchResults from '../CourseSearchResults'; @@ -35,7 +39,7 @@ const AddCoursesStep = ({ > ${currentEpoch}`} - hitsPerPage={25} + hitsPerPage={NUM_CONTENT_ITEMS_PER_PAGE} /> { const [isShowingAll, showAll, show25] = useToggle(false); const displayRows = useMemo(() => { @@ -48,32 +53,46 @@ const ReviewList = ({

{subject.title}

{subject.title} selected: {rows.length}

-
    - {rows.length < 1 && ( - - At least one {subject.singular} must be selected to enroll learners. - - - )} - {displayRows.map((row) => ( - - ))} -
- + {isLoading ? ( +
+ +
+ ) : ( + <> +
    + {rows.length < 1 && ( + + At least one {subject.singular} must be selected to enroll learners. + + + )} + {displayRows.map((row) => ( + + ))} +
+ + + )}
); }; @@ -94,6 +113,8 @@ ReviewList.propTypes = { }).isRequired, /* Function to return the user to the table where these rows were selected */ returnToSelection: PropTypes.func.isRequired, + /* Whether the review list is loading */ + isLoading: PropTypes.bool.isRequired, }; export default ReviewList; diff --git a/src/components/BulkEnrollmentPage/stepper/ReviewList.test.jsx b/src/components/BulkEnrollmentPage/stepper/ReviewList.test.jsx index dfc804ef14..9946a88c55 100644 --- a/src/components/BulkEnrollmentPage/stepper/ReviewList.test.jsx +++ b/src/components/BulkEnrollmentPage/stepper/ReviewList.test.jsx @@ -7,6 +7,7 @@ import ReviewList, { ShowHideButton, MAX_ITEMS_DISPLAYED } from './ReviewList'; import { deleteSelectedRowAction } from '../data/actions'; const defaultProps = { + isLoading: false, rows: [ { id: '1234', @@ -47,8 +48,7 @@ const rowGenerator = (numRows) => { describe('ReviewList', () => { beforeEach(() => { - defaultProps.dispatch.mockClear(); - defaultProps.returnToSelection.mockClear(); + jest.clearAllMocks(); }); it('displays a title', () => { render(); @@ -120,7 +120,10 @@ describe('ReviewList', () => { expect(defaultProps.dispatch).toHaveBeenCalledTimes(1); expect(defaultProps.dispatch).toHaveBeenCalledWith(deleteSelectedRowAction(defaultProps.rows[0].id)); }); - + it('shows loading state', () => { + render(); + expect(screen.getByTestId('bulk-enrollment-review-list-loading-skeleton')).toBeInTheDocument(); + }); describe('ShowHideButton', () => { const buttonProps = { isShowingAll: false, @@ -135,8 +138,7 @@ describe('ReviewList', () => { 'data-testid': 'test-button', }; beforeEach(() => { - buttonProps.show25.mockClear(); - buttonProps.showAll.mockClear(); + jest.clearAllMocks(); }); it('returns null if there are less than MAX_ITEMS_DISPLAYED rows', () => { render(); diff --git a/src/components/BulkEnrollmentPage/stepper/ReviewStep.jsx b/src/components/BulkEnrollmentPage/stepper/ReviewStep.jsx index a5bd5d9116..914c9d5c56 100644 --- a/src/components/BulkEnrollmentPage/stepper/ReviewStep.jsx +++ b/src/components/BulkEnrollmentPage/stepper/ReviewStep.jsx @@ -5,6 +5,7 @@ import PropTypes from 'prop-types'; import { BulkEnrollContext } from '../BulkEnrollmentContext'; import { REVIEW_TITLE } from './constants'; import ReviewList from './ReviewList'; +import ReviewStepCourseList from './ReviewStepCourseList'; const LEARNERS = { singular: 'learner', @@ -13,38 +14,23 @@ const LEARNERS = { removal: 'Remove learner', }; -const COURSES = { - singular: 'course', - plural: 'courses', - title: 'Courses', - removal: 'Remove course', -}; - const ReviewStep = ({ returnToLearnerSelection, returnToCourseSelection }) => { const { emails: [selectedEmails, emailsDispatch], - courses: [selectedCourses, coursesDispatch], } = useContext(BulkEnrollContext); return ( <>

- {/* eslint-disable-next-line react/no-unescaped-entities */} - You're almost done! Review your selections and make any final changes before completing enrollment for + You're almost done! Review your selections and make any final changes before completing enrollment for your learners.

{REVIEW_TITLE}

- { + const { + courses: [, coursesDispatch], + } = useContext(BulkEnrollContext); + + const selectedRows = camelCaseObject(searchResults?.hits || []); + // NOTE: The current implementation of `ReviewItem` relies on the data schema + // from `DataTable` where each selected row has a `values` object containing + // the metadata about each row and an `id` field representing the + // `aggregationKey`. Transforming the data here allows us to avoid needing to + // modify `ReviewItem`. + const transformedSelectedRows = selectedRows.map((row) => ({ + values: row, + id: row.aggregationKey, + })); + + return ( + + ); +}; + +BaseContentSelections.propTypes = { + searchResults: PropTypes.shape({ + hits: PropTypes.arrayOf(PropTypes.shape({ + title: PropTypes.string, + aggregationKey: PropTypes.string, + })).isRequired, + }), + isSearchStalled: PropTypes.bool.isRequired, + returnToSelection: PropTypes.func.isRequired, +}; + +BaseContentSelections.defaultProps = { + searchResults: null, +}; + +const ContentSelections = connectStateResults(BaseContentSelections); + +/** + * Given a list of selected content, compute an Algolia search filter to return + * the metadata for the selected content. + * + * @param {array} selectedCourses A list of selected rows, representing by the row ID (aggregation key) + * @returns A filter string to pass to Algolia to query for the selected content. + */ +export const useSearchFiltersForSelectedCourses = (selectedCourses) => { + const searchFilters = useMemo( + () => { + const extractAggregationKey = row => row?.id; + const [firstSelectedCourse, ...restSelectedCourses] = selectedCourses; + let filter = `aggregation_key:'${extractAggregationKey(firstSelectedCourse)}'`; + restSelectedCourses.forEach((selectedRow) => { + const aggregationKey = extractAggregationKey(selectedRow); + filter += ` OR aggregation_key:'${aggregationKey}'`; + }); + return filter; + }, + [selectedCourses], + ); + + return searchFilters; +}; + +const ReviewStepCourseList = ({ + returnToSelection, +}) => { + const { + courses: [selectedCourses], + } = useContext(BulkEnrollContext); + const searchFilters = useSearchFiltersForSelectedCourses(selectedCourses); + + return ( + + + + + ); +}; + +ReviewStepCourseList.propTypes = { + returnToSelection: PropTypes.func.isRequired, +}; + +export default ReviewStepCourseList; diff --git a/src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.test.jsx b/src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.test.jsx new file mode 100644 index 0000000000..d19bf96195 --- /dev/null +++ b/src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.test.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks/dom'; +import '@testing-library/jest-dom/extend-expect'; + +import { BulkEnrollContext } from '../BulkEnrollmentContext'; +import ReviewStepCourseList, { + useSearchFiltersForSelectedCourses, +} from './ReviewStepCourseList'; + +jest.mock('./ReviewList', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + +const mockCoursesDispatch = jest.fn(); +const defaultBulkEnrollContext = { + courses: [[{ id: 'foo' }], mockCoursesDispatch], +}; + +const defaultProps = { + returnToSelection: jest.fn(), +}; + +/* eslint-disable react/prop-types */ +const ReviewStepCourseListWrapper = ({ + bulkEnrollContextValue = defaultBulkEnrollContext, + ...rest +}) => ( +/* eslint-enable react/prop-types */ + + + +); + +describe('ReviewStepCourseList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders expected subcomponents', () => { + render(); + expect(screen.getByTestId('algolia__InstantSearch')).toBeInTheDocument(); + expect(screen.getByTestId('algolia__Configure')).toBeInTheDocument(); + expect(screen.getByTestId('review-list')).toBeInTheDocument(); + }); +}); + +describe('useSearchFiltersForSelectedCourses', () => { + it('computes a valid Algolia search filter for selected courses', () => { + const args = [ + { id: 'course:edX+DemoX' }, + { id: 'course:edX+E2E' }, + ]; + const { result } = renderHook(() => useSearchFiltersForSelectedCourses(args)); + const expectedFilterString = args.map(r => `aggregation_key:'${r.id}'`).join(' OR '); + expect(result.current).toEqual(expectedFilterString); + }); +}); diff --git a/src/components/BulkEnrollmentPage/stepper/constants.jsx b/src/components/BulkEnrollmentPage/stepper/constants.jsx index a2dc47bb8a..16b5bed9c6 100644 --- a/src/components/BulkEnrollmentPage/stepper/constants.jsx +++ b/src/components/BulkEnrollmentPage/stepper/constants.jsx @@ -1,3 +1,4 @@ +export const NUM_CONTENT_ITEMS_PER_PAGE = 25; export const STEPPER_TITLE = 'Learner enrollment'; export const ADD_LEARNERS_TITLE = 'Add learners'; export const REVIEW_TITLE = 'Review selections'; diff --git a/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.jsx b/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.jsx index ddfaf59f52..8c12232c4d 100644 --- a/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.jsx +++ b/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.jsx @@ -1,42 +1,29 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { Button, DataTableContext } from '@edx/paragon'; -import { checkForSelectedRows } from './helpers'; - -import { - clearSelectionAction, - setSelectedRowsAction, -} from '../data/actions'; // This selection status component uses the BulkEnrollContext to show selection status rather than the data table state. const BaseSelectionStatus = ({ className, selectedRows, - dispatch, }) => { - const { rows } = useContext(DataTableContext); - const selectedRowIds = selectedRows.map((row) => row.id); - const areAllDisplayedRowsSelected = checkForSelectedRows(selectedRowIds, rows); + const { page, toggleAllRowsSelected } = useContext(DataTableContext); + const numSelectedRowsOnPage = page.filter(r => r.isSelected).length; const numSelectedRows = selectedRows.length; + const handleClearSelection = () => { + toggleAllRowsSelected(false); + }; + return (
- {numSelectedRows} selected - {!areAllDisplayedRowsSelected && ( - - )} + {numSelectedRows} selected ({numSelectedRowsOnPage} shown below) {numSelectedRows > 0 && ( @@ -51,8 +38,10 @@ BaseSelectionStatus.defaultProps = { BaseSelectionStatus.propTypes = { className: PropTypes.string, - selectedRows: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - dispatch: PropTypes.func.isRequired, + selectedRows: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + values: PropTypes.shape().isRequired, + })).isRequired, }; export default BaseSelectionStatus; diff --git a/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.test.jsx b/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.test.jsx index 164f5c61dc..2b7edea3ae 100644 --- a/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.test.jsx +++ b/src/components/BulkEnrollmentPage/table/BaseSelectionStatus.test.jsx @@ -4,20 +4,42 @@ import { screen, render } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { DataTableContext } from '@edx/paragon'; import userEvent from '@testing-library/user-event'; + import BaseSelectionStatus from './BaseSelectionStatus'; -import { clearSelectionAction, setSelectedRowsAction } from '../data/actions'; -const selectedRows = [{ id: 'foo' }, { id: 'bar' }]; +const mockToggleAllRowsSelected = jest.fn(); + const defaultProps = { className: 'classy', selectedRows: [], - dispatch: jest.fn(), }; - const defaultDataTableInfo = { itemCount: 3, - rows: [{ id: 'foo' }, { id: 'bar' }, { id: 'baz' }], + page: [ + { id: 'foo', isSelected: false }, + { id: 'bar', isSelected: false }, + { id: 'baz', isSelected: false }, + ], + toggleAllRowsSelected: mockToggleAllRowsSelected, }; + +const selectedRows = [ + { id: 'foo', values: { aggregationKey: 'foo' } }, + { id: 'bar', values: { aggregationKey: 'bar' } }, +]; +const dataTableInfoWithSelections = { + ...defaultDataTableInfo, + page: defaultDataTableInfo.page.map((row) => { + if (selectedRows.find(selectedRow => selectedRow.id === row.id)) { + return { + ...row, + isSelected: true, + }; + } + return row; + }), +}; + const SelectionStatusWrapper = ({ dataTableInfo, ...props }) => ( @@ -26,62 +48,29 @@ const SelectionStatusWrapper = ({ dataTableInfo, ...props }) => ( describe('BaseSelectionStatus', () => { beforeEach(() => { - defaultProps.dispatch.mockClear(); - }); - it('shows select all text when no rows are selected', () => { - render(); - expect(screen.getByText('Select 3')).toBeInTheDocument(); - }); - it('selects all when select all is clicked', () => { - render(); - const button = screen.getByText('Select 3'); - userEvent.click(button); - expect(defaultProps.dispatch).toHaveBeenCalledTimes(1); - expect(defaultProps.dispatch).toHaveBeenCalledWith(setSelectedRowsAction(defaultDataTableInfo.rows)); - }); - it('shows both buttons when there are some selected rows', () => { - render(); - expect(screen.getByText('Select 3')).toBeInTheDocument(); - expect(screen.getByText('Clear selection')).toBeInTheDocument(); + jest.clearAllMocks(); }); it('shows selection status when some rows are selected', () => { render(); - expect(screen.getByText(`${selectedRows.length} selected`)).toBeInTheDocument(); - }); - it('shows clear all text when all rows are selected', () => { - render(); - expect(screen.getByText('Clear selection')).toBeInTheDocument(); - expect(screen.queryByText('Select 3')).not.toBeInTheDocument(); + const expectedSelectionsOnPage = dataTableInfoWithSelections.page.filter(r => r.isSelected).length; + expect(screen.getByText( + `${selectedRows.length} selected (${expectedSelectionsOnPage} shown below)`, + { exact: false }, + )).toBeInTheDocument(); }); - it('clears all selections when clear all is clicked', () => { + it('handle clearing of selections', () => { render(); - const button = screen.getByText('Clear selection'); - userEvent.click(button); - expect(defaultProps.dispatch).toHaveBeenCalledTimes(1); - expect(defaultProps.dispatch).toHaveBeenCalledWith(clearSelectionAction()); - }); - it('shows all selected text if all rows are selected', () => { - render(); - expect(screen.getByText(`${defaultDataTableInfo.rows.length} selected`)).toBeInTheDocument(); + const clearSelection = screen.getByText('Clear selection'); + expect(clearSelection).toBeInTheDocument(); + userEvent.click(clearSelection); + expect(mockToggleAllRowsSelected).toHaveBeenCalledTimes(1); }); }); diff --git a/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.jsx b/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.jsx index c5b0cf0368..d697309d60 100644 --- a/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.jsx +++ b/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.jsx @@ -1,34 +1,24 @@ -import React, { useContext, useMemo } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { CheckboxControl } from '@edx/paragon'; -import { checkForSelectedRows } from './helpers'; - -import { - addSelectedRowAction, clearSelectionAction, deleteSelectedRowAction, setSelectedRowsAction, -} from '../data/actions'; -import { BulkEnrollContext } from '../BulkEnrollmentContext'; export const SELECT_ONE_TEST_ID = 'selectOne'; export const SELECT_ALL_TEST_ID = 'selectAll'; -export const BaseSelectWithContext = ({ row, contextKey }) => { - const { [contextKey]: [selectedRows, dispatch] } = useContext(BulkEnrollContext); - - const isSelected = useMemo(() => selectedRows.some((selection) => selection.id === row.id), [selectedRows, row]); - - const toggleSelected = isSelected - ? () => { dispatch(deleteSelectedRowAction(row.id)); } - : () => { dispatch(addSelectedRowAction(row)); }; +export const BaseSelectWithContext = ({ row }) => { + const { + indeterminate, + checked, + ...toggleRowSelectedProps + } = row.getToggleRowSelectedProps(); return (
- {/* eslint-disable-next-line react/prop-types */} @@ -38,42 +28,8 @@ export const BaseSelectWithContext = ({ row, contextKey }) => { BaseSelectWithContext.propTypes = { row: PropTypes.shape({ - id: PropTypes.string.isRequired, + getToggleRowSelectedProps: PropTypes.func.isRequired, }).isRequired, /* The key to get the required data from BulkEnrollContext */ contextKey: PropTypes.string.isRequired, }; - -export const BaseSelectWithContextHeader = ({ - rows, contextKey, -}) => { - const { [contextKey]: [selectedRows, dispatch] } = useContext(BulkEnrollContext); - - const selectedRowIds = selectedRows.map(row => row.id); - const isAllRowsSelected = checkForSelectedRows(selectedRows.map(row => row.id), rows); - const anyRowsSelected = rows.some((row) => selectedRowIds.includes(row.id)); - const toggleAllRowsSelectedBulkEn = isAllRowsSelected - ? () => dispatch(clearSelectionAction()) - : () => dispatch(setSelectedRowsAction(rows)); - - return ( -
- -
- ); -}; - -BaseSelectWithContextHeader.propTypes = { - rows: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - })).isRequired, - /* The key to get the required data from BulkEnrollContext */ - contextKey: PropTypes.string.isRequired, -}; diff --git a/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.test.jsx b/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.test.jsx index dbdc294f6c..0ba9c463d1 100644 --- a/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.test.jsx +++ b/src/components/BulkEnrollmentPage/table/BulkEnrollSelect.test.jsx @@ -4,53 +4,41 @@ import { screen, render } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import userEvent from '@testing-library/user-event'; import { - addSelectedRowAction, clearSelectionAction, deleteSelectedRowAction, setSelectedRowsAction, -} from '../data/actions'; -import { BulkEnrollContext } from '../BulkEnrollmentContext'; -import { - BaseSelectWithContext, BaseSelectWithContextHeader, SELECT_ONE_TEST_ID, SELECT_ALL_TEST_ID, + BaseSelectWithContext, SELECT_ONE_TEST_ID, } from './BulkEnrollSelect'; -const emailsDispatch = jest.fn(); -const coursesDispatch = jest.fn(); +const mockOnChange = jest.fn(); +const defaultToggleRowSelectedProps = { + indeterminate: false, + checked: false, + onChange: mockOnChange, +}; +const mockToggleRowSelectedProps = jest.fn(() => defaultToggleRowSelectedProps); const defaultRow = { id: 'foo', + getToggleRowSelectedProps: mockToggleRowSelectedProps, }; - -const defaultBulkEnrollInfo = { - emails: [[], emailsDispatch], - courses: [[], coursesDispatch], +const checkedRow = { + ...defaultRow, + getToggleRowSelectedProps: jest.fn(() => ({ + ...defaultToggleRowSelectedProps, + checked: true, + })), }; -const SelectWithContextWrapper = ({ bulkEnrollInfo = defaultBulkEnrollInfo, children }) => ( - - {children} - -); describe('BaseSelectWithContext', () => { beforeEach(() => { - emailsDispatch.mockClear(); - coursesDispatch.mockClear(); + jest.clearAllMocks(); }); it('renders a checkbox', () => { - render(); + render(); const checkbox = screen.getByTestId(SELECT_ONE_TEST_ID); expect(checkbox).toBeInTheDocument(); expect(checkbox).toHaveProperty('checked', false); }); - it('toggles the row selected when clicked', () => { - render(); - const checkbox = screen.getByTestId(SELECT_ONE_TEST_ID); - userEvent.click(checkbox); - expect(coursesDispatch).not.toHaveBeenCalled(); - expect(emailsDispatch).toHaveBeenCalledTimes(1); - expect(emailsDispatch).toHaveBeenCalledWith(addSelectedRowAction(defaultRow)); - }); it('renders a selected checkbox', () => { render( - - - , + , ); const checkbox = screen.getByTestId(SELECT_ONE_TEST_ID); expect(checkbox).toBeInTheDocument(); @@ -58,71 +46,10 @@ describe('BaseSelectWithContext', () => { }); it('deselects the row when selected checkbox is checked', () => { render( - - - , + , ); const checkbox = screen.getByTestId(SELECT_ONE_TEST_ID); userEvent.click(checkbox); - expect(coursesDispatch).not.toHaveBeenCalled(); - expect(emailsDispatch).toHaveBeenCalledTimes(1); - expect(emailsDispatch).toHaveBeenCalledWith(deleteSelectedRowAction(defaultRow.id)); - }); -}); - -describe('BaseSelectWithContextHeader', () => { - const rows = [{ id: 'foo' }, { id: 'bar' }, { id: 'baz' }]; - const defaultProps = { - rows, - isAllRowsSelected: false, - contextKey: 'emails', - }; - beforeEach(() => { - emailsDispatch.mockClear(); - coursesDispatch.mockClear(); - }); - it('renders a checkbox', () => { - render(); - const checkbox = screen.getByTestId(SELECT_ALL_TEST_ID); - expect(checkbox).toBeInTheDocument(); - expect(checkbox).toHaveProperty('checked', false); - }); - it('toggles all rows selected when clicked', () => { - render(); - const checkbox = screen.getByTestId(SELECT_ALL_TEST_ID); - userEvent.click(checkbox); - expect(emailsDispatch).toHaveBeenCalledTimes(1); - expect(emailsDispatch).toHaveBeenCalledWith(setSelectedRowsAction(rows)); - }); - it('renders a selected checkbox', () => { - render( - - - , - ); - const checkbox = screen.getByTestId(SELECT_ALL_TEST_ID); - expect(checkbox).toBeInTheDocument(); - expect(checkbox).toHaveProperty('checked', true); - }); - it('renders an indeterminate checkbox', () => { - render( - - - , - ); - const checkbox = screen.getByTestId(SELECT_ALL_TEST_ID); - expect(checkbox).toBeInTheDocument(); - expect(checkbox).toHaveProperty('indeterminate', true); - }); - it('deselects the row when selected checkbox is checked', () => { - render( - - - , - ); - const checkbox = screen.getByTestId(SELECT_ALL_TEST_ID); - userEvent.click(checkbox); - expect(emailsDispatch).toHaveBeenCalledTimes(1); - expect(emailsDispatch).toHaveBeenCalledWith(clearSelectionAction()); + expect(mockOnChange).toHaveBeenCalledTimes(1); }); }); diff --git a/src/components/BulkEnrollmentPage/table/CourseSearchResultsCells.jsx b/src/components/BulkEnrollmentPage/table/CourseSearchResultsCells.jsx index 3cb27a118b..a0a28d69a3 100644 --- a/src/components/BulkEnrollmentPage/table/CourseSearchResultsCells.jsx +++ b/src/components/BulkEnrollmentPage/table/CourseSearchResultsCells.jsx @@ -35,7 +35,7 @@ export const CourseNameCell = ({ value, row, enterpriseSlug }) => ( )} > - + ); diff --git a/src/components/BulkEnrollmentPage/table/helpers.js b/src/components/BulkEnrollmentPage/table/helpers.js deleted file mode 100644 index 19c9693bf9..0000000000 --- a/src/components/BulkEnrollmentPage/table/helpers.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable import/prefer-default-export */ -export const checkForSelectedRows = (selectedRows, currentRows) => currentRows.every(v => selectedRows.includes(v.id)); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx index 4a32220035..e8c678fdc8 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContent.jsx @@ -3,7 +3,7 @@ import { Row, Col, Container, } from '@edx/paragon'; import PropTypes from 'prop-types'; -import HighlightStepperSelectContentDataTable from './HighlightStepperSelectContentDataTable'; +import HighlightStepperSelectContentSearch from './HighlightStepperSelectContentSearch'; import HighlightStepperSelectContentHeader from './HighlightStepperSelectContentHeader'; const HighlightStepperSelectContent = ({ enterpriseId }) => ( @@ -15,7 +15,7 @@ const HighlightStepperSelectContent = ({ enterpriseId }) => ( - + diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx index bdd28d3af7..b6bdbf8a96 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx @@ -13,17 +13,15 @@ const HighlightStepperSelectContentTitle = () => { {STEPPER_STEP_TEXT.selectContent} -
-

- Select up to {MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "{highlightTitle}". -

-

- - Pro tip: a highlight can include courses similar to each other for your learners to choose from, - or courses that vary in subtopics to help your learners master a larger topic. - -

-
+

+ Select up to {MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "{highlightTitle}". +

+

+ + Pro tip: a highlight can include courses similar to each other for your learners to choose from, + or courses that vary in subtopics to help your learners master a larger topic. + +

); }; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx similarity index 89% rename from src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx rename to src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx index 4dac488365..75a68c2ef1 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentDataTable.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx @@ -4,6 +4,7 @@ import { useContextSelector } from 'use-context-selector'; import { Configure, InstantSearch, connectStateResults } from 'react-instantsearch-dom'; import { DataTable, CardView } from '@edx/paragon'; import { camelCaseObject } from '@edx/frontend-platform'; +import { SearchData, SearchHeader } from '@edx/frontend-enterprise-catalog-search'; import { configuration } from '../../../config'; import { FOOTER_TEXT_BY_CONTENT_TYPE } from '../data/constants'; @@ -39,20 +40,24 @@ const HighlightStepperSelectContent = ({ enterpriseId }) => { ); const searchFilters = `enterprise_customer_uuids:${enterpriseId} AND advertised_course_run.upgrade_deadline > ${currentEpoch}`; + return ( - - - - + + + + + + + ); }; @@ -105,14 +110,14 @@ const BaseHighlightStepperSelectContentDataTable = ({ isPaginated manualPagination initialState={{ - pageSize, pageIndex: 0, + pageSize, selectedRowIds, }} pageCount={searchResultsPageCount} itemCount={searchResultsItemCount} initialTableOptions={{ - getRowId: row => row.aggregationKey, + getRowId: row => row?.aggregationKey, autoResetSelectedRows: false, }} data={tableData} diff --git a/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx b/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx index 489eba1606..2b5d48546d 100644 --- a/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx +++ b/src/components/ContentHighlights/HighlightStepper/SelectContentSelectionStatus.jsx @@ -7,7 +7,8 @@ import { Button, DataTableContext } from '@edx/paragon'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; const SelectContentSelectionStatus = ({ className }) => { - const { toggleAllRowsSelected } = useContext(DataTableContext); + const { toggleAllRowsSelected, page } = useContext(DataTableContext); + const currentSelectedRowsOnPageCount = page.filter(r => r.isSelected).length; const currentSelectedRowsCount = useContextSelector( ContentHighlightsContext, v => Object.keys(v[0].stepperModal.currentSelectedRowIds).length, @@ -20,7 +21,7 @@ const SelectContentSelectionStatus = ({ className }) => { return (
- {currentSelectedRowsCount} selected + {currentSelectedRowsCount} selected ({currentSelectedRowsOnPageCount} shown below)
{currentSelectedRowsCount > 0 && ( + + { + forceRefresh(); + forceRefreshDetailView(); + setToastMessage(`${numAlreadyAssociated} email addresses were previously assigned. ${numSuccessfulAssignments} email addresses were successfully added.`); + setShowToast(true); + }} + disabled={subscription.isLockedForRenewalProcessing} + /> +
+ )} +
+ + + setShowToast(false)} + show={showToast} + > + {toastMessage} + + + ); +}; + +SubscriptionDetails.propTypes = { + enterpriseSlug: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseSlug: state.portalConfiguration.enterpriseSlug, +}); + +export default connect(mapStateToProps)(SubscriptionDetails); diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index 9bfe42a77d..8d2bdde92b 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -22,6 +22,9 @@ import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiServi import { formatTimestamp } from '../../utils'; import AdminCardsSkeleton from './AdminCardsSkeleton'; +import { SubscriptionData } from '../subscriptions'; +import EmbeddedSubscription from './EmbeddedSubscription'; +import { features } from '../../config'; class Admin extends React.Component { componentDidMount() { @@ -292,6 +295,7 @@ class Admin extends React.Component { searchCourseQuery: queryParams.get('search_course') || '', searchDateQuery: queryParams.get('search_start_date') || '', }; + const { SUBSCRIPTION_LPR } = features; return (
@@ -315,6 +319,18 @@ class Admin extends React.Component { )}
+ + {SUBSCRIPTION_LPR + && ( +
+
+ + + +
+
+ )} +
diff --git a/src/components/Admin/licenses/LicenseAllocationDetails.jsx b/src/components/Admin/licenses/LicenseAllocationDetails.jsx new file mode 100644 index 0000000000..7731d04b31 --- /dev/null +++ b/src/components/Admin/licenses/LicenseAllocationDetails.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import LicenseAllocationHeader from './LicenseAllocationHeader'; +import LicenseManagementTable from './LicenseManagementTable'; + +const LicenseAllocationDetails = () => ( +
+
+
+ +
+ +
+
+); + +export default LicenseAllocationDetails; diff --git a/src/components/Admin/licenses/LicenseAllocationHeader.jsx b/src/components/Admin/licenses/LicenseAllocationHeader.jsx new file mode 100644 index 0000000000..c24001e321 --- /dev/null +++ b/src/components/Admin/licenses/LicenseAllocationHeader.jsx @@ -0,0 +1,35 @@ +import React, { useContext } from 'react'; +import { Badge } from '@edx/paragon'; +import { SubscriptionDetailContext } from '../../subscriptions/SubscriptionDetailContextProvider'; +import { SubsidyRequestsContext } from '../../subsidy-requests'; +import NewFeatureAlertBrowseAndRequest from '../../NewFeatureAlertBrowseAndRequest'; +import { SUPPORTED_SUBSIDY_TYPES } from '../../../data/constants/subsidyRequests'; + +const LicenseAllocationHeader = () => { + const { + subscription, + } = useContext(SubscriptionDetailContext); + const { subsidyRequestConfiguration } = useContext(SubsidyRequestsContext); + + // don't show alert if the enterprise already has subsidy requests enabled + const isBrowseAndRequestFeatureAlertShown = subsidyRequestConfiguration?.subsidyType + === SUPPORTED_SUBSIDY_TYPES.license && !subsidyRequestConfiguration?.subsidyRequestsEnabled; + return ( + <> + {isBrowseAndRequestFeatureAlertShown && } +
+

Licenses:

+ Unassigned: {subscription.licenses?.unassigned} + {' of '} + {subscription.licenses?.total} total + + Activated: {subscription.licenses?.activated} + {' of '} + {subscription.licenses?.assigned} assigned + +
+ + ); +}; + +export default LicenseAllocationHeader; diff --git a/src/components/Admin/licenses/LicenseManagementTable/index.jsx b/src/components/Admin/licenses/LicenseManagementTable/index.jsx new file mode 100644 index 0000000000..5fad7170d3 --- /dev/null +++ b/src/components/Admin/licenses/LicenseManagementTable/index.jsx @@ -0,0 +1,311 @@ +import React, { + useCallback, useMemo, useContext, useState, +} from 'react'; +import { + DataTable, + TextFilter, + CheckboxFilter, + Toast, +} from '@edx/paragon'; +import debounce from 'lodash.debounce'; +import moment from 'moment'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; + +import { SubscriptionContext } from '../../../subscriptions/SubscriptionData'; +import { SubscriptionDetailContext, defaultStatusFilter } from '../../../subscriptions/SubscriptionDetailContextProvider'; +import { + DEFAULT_PAGE, ACTIVATED, REVOKED, ASSIGNED, +} from '../../../subscriptions/data/constants'; +import { DEBOUNCE_TIME_MILLIS } from '../../../../algoliaUtils'; +import { formatTimestamp } from '../../../../utils'; +import SubscriptionZeroStateMessage from '../../../subscriptions/SubscriptionZeroStateMessage'; +import DownloadCsvButton from '../../../subscriptions/buttons/DownloadCsvButton'; +import EnrollBulkAction from '../../../subscriptions/licenses/LicenseManagementTable/bulk-actions/EnrollBulkAction'; +import RemindBulkAction from '../../../subscriptions/licenses/LicenseManagementTable/bulk-actions/RemindBulkAction'; +import RevokeBulkAction from '../../../subscriptions/licenses/LicenseManagementTable/bulk-actions/RevokeBulkAction'; +import LicenseManagementTableActionColumn from '../../../subscriptions/licenses/LicenseManagementTable/LicenseManagementTableActionColumn'; +import LicenseManagementUserBadge from '../../../subscriptions/licenses/LicenseManagementTable/LicenseManagementUserBadge'; +import { SUBSCRIPTION_TABLE_EVENTS } from '../../../../eventTracking'; + +const userRecentAction = (user) => { + switch (user.status) { + case ACTIVATED: { + return `Activated: ${formatTimestamp({ timestamp: user.activationDate })}`; + } + case REVOKED: { + return `Revoked: ${formatTimestamp({ timestamp: user.revokedDate })}`; + } + case ASSIGNED: { + return `Invited: ${formatTimestamp({ timestamp: user.lastRemindDate })}`; + } + default: { + return null; + } + } +}; + +const selectColumn = { + id: 'selection', + Header: DataTable.ControlledSelectHeader, + Cell: DataTable.ControlledSelect, + disableSortBy: true, +}; + +const LicenseManagementTable = () => { + const [showToast, setShowToast] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + + const { + forceRefresh: forceRefreshSubscription, + } = useContext(SubscriptionContext); + + const { + currentPage, + overview, + forceRefreshDetailView, + setSearchQuery, + setCurrentPage, + subscription, + users, + forceRefreshUsers, + loadingUsers, + setUserStatusFilter, + } = useContext(SubscriptionDetailContext); + + const isExpired = moment().isAfter(subscription.expirationDate); + + const sendStatusFilterEvent = useCallback((statusFilter) => { + sendEnterpriseTrackEvent( + subscription.enterpriseCustomerUuid, + SUBSCRIPTION_TABLE_EVENTS.FILTER_STATUS, + { applied_filters: statusFilter }, + ); + }, [subscription.enterpriseCustomerUuid]); + + const sendEmailFilterEvent = useCallback(() => { + sendEnterpriseTrackEvent( + subscription.enterpriseCustomerUuid, + SUBSCRIPTION_TABLE_EVENTS.FILTER_EMAIL, + ); + }, [subscription.enterpriseCustomerUuid]); + + const sendPaginationEvent = useCallback((oldPage, newPage) => { + const eventName = newPage - oldPage > 0 + ? SUBSCRIPTION_TABLE_EVENTS.PAGINATION_NEXT + : SUBSCRIPTION_TABLE_EVENTS.PAGINATION_PREVIOUS; + + sendEnterpriseTrackEvent( + subscription.enterpriseCustomerUuid, + eventName, + { page: newPage }, + ); + }, [subscription.enterpriseCustomerUuid]); + + // Filtering and pagination + const updateFilters = useCallback((filters) => { + if (filters.length < 1) { + setSearchQuery(null); + setUserStatusFilter(defaultStatusFilter); + } else { + filters.forEach((filter) => { + switch (filter.id) { + case 'statusBadge': { + const newStatusFilter = filter.value.join(); + sendStatusFilterEvent(newStatusFilter); + setUserStatusFilter(newStatusFilter); + break; + } + case 'emailLabel': { + sendEmailFilterEvent(); + setSearchQuery(filter.value); + break; + } + default: break; + } + }); + } + }, [sendEmailFilterEvent, sendStatusFilterEvent, setSearchQuery, setUserStatusFilter]); + + const debouncedUpdateFilters = useMemo(() => debounce( + updateFilters, + DEBOUNCE_TIME_MILLIS, + ), [updateFilters]); + + const debouncedSetCurrentPage = useMemo(() => debounce( + setCurrentPage, + DEBOUNCE_TIME_MILLIS, + ), [setCurrentPage]); + + // Call back function, handles filters and page changes + const fetchData = useCallback( + (args) => { + // pages index from 1 in backend, DataTable component index from 0 + if (args.pageIndex !== currentPage - 1) { + debouncedSetCurrentPage(args.pageIndex + 1); + sendPaginationEvent(currentPage - 1, args.pageIndex); + } + debouncedUpdateFilters(args.filters); + }, + [currentPage, debouncedSetCurrentPage, debouncedUpdateFilters, sendPaginationEvent], + ); + + // Maps user to rows + const rows = useMemo( + () => users?.results?.map(user => ({ + id: user.uuid, + email: user.userEmail, + emailLabel: {user.userEmail}, + status: user.status, + statusBadge: , + recentAction: userRecentAction(user), + })), + [users], + ); + + const onEnrollSuccess = () => { + forceRefreshUsers(); + }; + + // Successful action modal callback + const onRemindSuccess = () => { + // Refresh users to get updated lastRemindDate + forceRefreshUsers(); + setToastMessage('Users successfully reminded'); + setShowToast(true); + }; + const onRevokeSuccess = () => { + // Refresh subscription and user data to get updated revoke count and revoked list of users + forceRefreshSubscription(); + forceRefreshDetailView(); + setToastMessage('Licenses successfully revoked'); + setShowToast(true); + }; + + const showSubscriptionZeroStateMessage = subscription.licenses.total === subscription.licenses.unassigned; + + const tableActions = useMemo(() => { + if (showSubscriptionZeroStateMessage) { + return []; + } + return []; + }, [showSubscriptionZeroStateMessage]); + + return ( + <> + {showSubscriptionZeroStateMessage && } + row.id, + }} + isSortable + EmptyTableComponent={ + /* eslint-disable react/no-unstable-nested-components */ + () => { + if (loadingUsers) { + return null; + } + return ; + } + /* eslint-enable react/no-unstable-nested-components */ + } + fetchData={fetchData} + data={rows} + columns={[ + { + Header: 'Email address', + accessor: 'emailLabel', + /* eslint-disable react/prop-types */ + /* eslint-disable react/no-unstable-nested-components */ + Cell: ({ row }) => {row.values.emailLabel}, + disableFilters: true, + /* eslint-enable react/prop-types */ + /* eslint-enable react/no-unstable-nested-components */ + }, + { + Header: 'Status', + accessor: 'statusBadge', + Filter: CheckboxFilter, + filter: 'includesValue', + filterChoices: [{ + name: 'Pending', + number: overview.assigned, + value: ASSIGNED, + }, { + name: 'Active', + number: overview.activated, + value: ACTIVATED, + }], + disableFilters: true, + }, + { + Header: 'Recent action', + accessor: 'recentAction', + disableFilters: true, + }, + ]} + additionalColumns={[ + { + id: 'action', + Header: '', + /* eslint-disable react/prop-types */ + /* eslint-disable react/no-unstable-nested-components */ + Cell: ({ row }) => ( + + /* eslint-enable */ + ), + /* eslint-enable react/prop-types */ + /* eslint-enable react/no-unstable-nested-components */ + }, + ]} + bulkActions={[ + , + , + , + ]} + /> + {toastMessage && ( + setShowToast(false)} show={showToast}>{toastMessage} + )} + + ); +}; + +export default LicenseManagementTable; diff --git a/src/config/index.js b/src/config/index.js index f6bf758f12..d2633e3a65 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -49,6 +49,7 @@ const features = { SETTINGS_PAGE_APPEARANCE_TAB: process.env.FEATURE_SETTINGS_PAGE_APPEARANCE_TAB || hasFeatureFlagEnabled('SETTINGS_PAGE_APPEARANCE_TAB'), FEATURE_SSO_SETTINGS_TAB: process.env.FEATURE_SSO_SETTINGS_TAB || hasFeatureFlagEnabled('SSO_SETTINGS_TAB'), FEATURE_INTEGRATION_REPORTING: process.env.FEATURE_INTEGRATION_REPORTING || hasFeatureFlagEnabled('FEATURE_INTEGRATION_REPORTING'), + SUBSCRIPTION_LPR: process.env.SUBSCRIPTION_LPR || hasFeatureFlagEnabled('SUBSCRIPTION_LPR'), }; export { configuration, features }; From ba888e941a5e64aaa1d0c58fadbe346907a789db Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Fri, 16 Dec 2022 16:51:38 +0500 Subject: [PATCH 15/73] feat: Add support for Optimizely experiments. --- public/index.html | 2 +- src/optimizely.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/optimizely.js diff --git a/public/index.html b/public/index.html index 31d6aa4e00..c3edd21879 100644 --- a/public/index.html +++ b/public/index.html @@ -6,7 +6,7 @@ - <% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %> + <% if (htmlWebpackPlugin.options.NODE_ENV === 'production' && htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %> <% } %> diff --git a/src/optimizely.js b/src/optimizely.js new file mode 100644 index 0000000000..0671c912df --- /dev/null +++ b/src/optimizely.js @@ -0,0 +1,53 @@ +export const EVENTS = {}; + +export const getActiveExperiments = () => { + if (!window.optimizely) { + return []; + } + return window.optimizely.get('state').getActiveExperimentIds(); +}; + +export const getVariationMap = () => { + if (!window.optimizely) { + return false; + } + return window.optimizely.get('state').getVariationMap(); +}; + +export const pushUserAttributes = (userAttributes) => { + if (!window.optimizely) { + return; + } + window.optimizely.push({ + type: 'user', + attributes: userAttributes, + }); +}; + +export const pushEvent = (eventName, eventMetadata) => { + if (!window.optimizely) { + return; + } + window.optimizely.push({ + type: 'event', + eventName, + tags: eventMetadata, + }); +}; + +export const pushUserCustomerAttributes = ({ uuid, slug }) => { + pushUserAttributes({ + enterpriseCustomerUuid: uuid, + enterpriseCustomerSlug: slug, + }); +}; + +export const isExperimentActive = (experimentId) => getActiveExperiments().includes(experimentId); + +export const isExperimentVariant = (experimentId, variantId) => { + if (!isExperimentActive(experimentId)) { + return false; + } + const selectedVariant = getVariationMap()[experimentId]; + return selectedVariant?.id === variantId; +}; From 2d435cc33fe4c42e4a439645854be9e6d65fd71e Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Wed, 21 Dec 2022 16:05:18 +0500 Subject: [PATCH 16/73] feat: Add optimizely experiment to measure Remind, Revoke engagement is happening on the LPR vs. the subsidy management pages --- public/index.html | 4 ++++ src/components/Admin/index.jsx | 9 ++++++++- .../Admin/licenses/LicenseManagementTable/index.jsx | 3 +++ .../licenses/LicenseManagementTable/index.jsx | 10 ++++++++++ src/index.jsx | 2 ++ src/optimizely.js | 7 ++++++- 6 files changed, 33 insertions(+), 2 deletions(-) diff --git a/public/index.html b/public/index.html index c3edd21879..498dd81876 100644 --- a/public/index.html +++ b/public/index.html @@ -10,6 +10,10 @@ <% } %> + <% if (htmlWebpackPlugin.options.NODE_ENV !== 'production' && htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %> + + <% } %> +
diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index 8d2bdde92b..6a95879f6b 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -4,6 +4,7 @@ import Helmet from 'react-helmet'; import { Icon } from '@edx/paragon'; import { Link } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform/config'; import Hero from '../Hero'; import StatusAlert from '../StatusAlert'; import EnrollmentsTable from '../EnrollmentsTable'; @@ -25,6 +26,7 @@ import AdminCardsSkeleton from './AdminCardsSkeleton'; import { SubscriptionData } from '../subscriptions'; import EmbeddedSubscription from './EmbeddedSubscription'; import { features } from '../../config'; +import { isExperimentVariant } from '../../optimizely'; class Admin extends React.Component { componentDidMount() { @@ -297,6 +299,11 @@ class Admin extends React.Component { }; const { SUBSCRIPTION_LPR } = features; + const config = getConfig(); + + // Only users buckted in `Variation 1` can see the Subscription Management UI on LPR. + const isExperimentVariation1 = isExperimentVariant(config.EXPERIMENT_1_ID, config.EXPERIMENT_1_VARIANT_1_ID); + return (
{!loading && !error && !this.hasAnalyticsData() ? : ( @@ -320,7 +327,7 @@ class Admin extends React.Component { )}
- {SUBSCRIPTION_LPR + {SUBSCRIPTION_LPR && isExperimentVariation1 && (
diff --git a/src/components/Admin/licenses/LicenseManagementTable/index.jsx b/src/components/Admin/licenses/LicenseManagementTable/index.jsx index 5fad7170d3..fb7d23acbf 100644 --- a/src/components/Admin/licenses/LicenseManagementTable/index.jsx +++ b/src/components/Admin/licenses/LicenseManagementTable/index.jsx @@ -26,6 +26,7 @@ import RevokeBulkAction from '../../../subscriptions/licenses/LicenseManagementT import LicenseManagementTableActionColumn from '../../../subscriptions/licenses/LicenseManagementTable/LicenseManagementTableActionColumn'; import LicenseManagementUserBadge from '../../../subscriptions/licenses/LicenseManagementTable/LicenseManagementUserBadge'; import { SUBSCRIPTION_TABLE_EVENTS } from '../../../../eventTracking'; +import { pushEvent, EVENTS } from '../../../../optimizely'; const userRecentAction = (user) => { switch (user.status) { @@ -168,12 +169,14 @@ const LicenseManagementTable = () => { // Successful action modal callback const onRemindSuccess = () => { + pushEvent(EVENTS.LPR_SUBSCRIPTION_LICENSE_REMIND, { enterpriseUUID: subscription.enterpriseCustomerUuid }); // Refresh users to get updated lastRemindDate forceRefreshUsers(); setToastMessage('Users successfully reminded'); setShowToast(true); }; const onRevokeSuccess = () => { + pushEvent(EVENTS.LPR_SUBSCRIPTION_LICENSE_REVOKE, { enterpriseUUID: subscription.enterpriseCustomerUuid }); // Refresh subscription and user data to get updated revoke count and revoked list of users forceRefreshSubscription(); forceRefreshDetailView(); diff --git a/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx b/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx index 71e2dd74bc..de5794e6a2 100644 --- a/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx +++ b/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx @@ -12,6 +12,7 @@ import { import debounce from 'lodash.debounce'; import moment from 'moment'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { getConfig } from '@edx/frontend-platform/config'; import { SubscriptionContext } from '../../SubscriptionData'; import { SubscriptionDetailContext, defaultStatusFilter } from '../../SubscriptionDetailContextProvider'; @@ -28,6 +29,7 @@ import RevokeBulkAction from './bulk-actions/RevokeBulkAction'; import LicenseManagementTableActionColumn from './LicenseManagementTableActionColumn'; import LicenseManagementUserBadge from './LicenseManagementUserBadge'; import { SUBSCRIPTION_TABLE_EVENTS } from '../../../../eventTracking'; +import { pushEvent, EVENTS, isExperimentActive } from '../../../../optimizely'; const userRecentAction = (user) => { switch (user.status) { @@ -59,6 +61,8 @@ const LicenseManagementTable = () => { const { width } = useWindowSize(); const showFiltersInSidebar = useMemo(() => width > breakpoints.medium.maxWidth, [width]); + const config = getConfig(); + const { forceRefresh: forceRefreshSubscription, } = useContext(SubscriptionContext); @@ -172,12 +176,18 @@ const LicenseManagementTable = () => { // Successful action modal callback const onRemindSuccess = () => { + if (isExperimentActive(config.EXPERIMENT_1_ID)) { + pushEvent(EVENTS.SUBSCRIPTION_LICENSE_REMIND, { enterpriseUUID: subscription.enterpriseCustomerUuid }); + } // Refresh users to get updated lastRemindDate forceRefreshUsers(); setToastMessage('Users successfully reminded'); setShowToast(true); }; const onRevokeSuccess = () => { + if (isExperimentActive(config.EXPERIMENT_1_ID)) { + pushEvent(EVENTS.SUBSCRIPTION_LICENSE_REVOKE, { enterpriseUUID: subscription.enterpriseCustomerUuid }); + } // Refresh subscription and user data to get updated revoke count and revoked list of users forceRefreshSubscription(); forceRefreshDetailView(); diff --git a/src/index.jsx b/src/index.jsx index 3544e333da..d913e10005 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -39,6 +39,8 @@ initialize({ ENTERPRISE_LEARNER_PORTAL_URL: process.env.ENTERPRISE_LEARNER_PORTAL_URL || null, FEATURE_LEARNER_CREDIT_MANAGEMENT: process.env.FEATURE_LEARNER_CREDIT_MANAGEMENT || hasFeatureFlagEnabled('LEARNER_CREDIT_MANAGEMENT') || null, FEATURE_CONTENT_HIGHLIGHTS: process.env.FEATURE_CONTENT_HIGHLIGHTS || hasFeatureFlagEnabled('CONTENT_HIGHLIGHTS') || null, + EXPERIMENT_1_ID: process.env.EXPERIMENT_1_ID || null, + EXPERIMENT_1_VARIANT_1_ID: process.env.EXPERIMENT_1_VARIANT_1_ID || null, }); }, }, diff --git a/src/optimizely.js b/src/optimizely.js index 0671c912df..0429a072a0 100644 --- a/src/optimizely.js +++ b/src/optimizely.js @@ -1,4 +1,9 @@ -export const EVENTS = {}; +export const EVENTS = { + SUBSCRIPTION_LICENSE_REMIND: 'enterprise_admin_portal_subscription_license_remind', + SUBSCRIPTION_LICENSE_REVOKE: 'enterprise_admin_portal_subscription_license_revoke', + LPR_SUBSCRIPTION_LICENSE_REMIND: 'enterprise_admin_portal_lpr_subscription_license_remind', + LPR_SUBSCRIPTION_LICENSE_REVOKE: 'enterprise_admin_portal_lpr_subscription_license_revoke', +}; export const getActiveExperiments = () => { if (!window.optimizely) { From a4b2331d2aec1cb1c3db829859c1534efe9aa5dc Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Wed, 21 Dec 2022 15:54:10 -0500 Subject: [PATCH 17/73] fix: reverted back to original working toast (#928) * fix: reverted back to original working toast * fix: PR review fix --- .../ContentHighlightToast.jsx | 2 +- .../ContentHighlights/ContentHighlights.jsx | 18 +++++++++++++----- .../ContentHighlightsDashboard.jsx | 2 -- .../ContentHighlights/DeleteHighlightSet.jsx | 8 +++----- .../ContentHighlightStepper.jsx | 2 +- .../tests/DeleteHighlightSet.test.jsx | 15 ++++++--------- .../data/enterpriseCurationReducer.js | 18 ++++++++++++++++++ .../data/enterpriseCurationReducer.test.js | 19 +++++++++++++++++++ 8 files changed, 61 insertions(+), 23 deletions(-) diff --git a/src/components/ContentHighlights/ContentHighlightToast.jsx b/src/components/ContentHighlights/ContentHighlightToast.jsx index 481b72d530..0559b09dff 100644 --- a/src/components/ContentHighlights/ContentHighlightToast.jsx +++ b/src/components/ContentHighlights/ContentHighlightToast.jsx @@ -14,7 +14,7 @@ const ContentHighlightToast = ({ toastText }) => { }, []); return ( handleClose()} show={showToast} > {toastText} diff --git a/src/components/ContentHighlights/ContentHighlights.jsx b/src/components/ContentHighlights/ContentHighlights.jsx index e793576bd4..106d18184e 100644 --- a/src/components/ContentHighlights/ContentHighlights.jsx +++ b/src/components/ContentHighlights/ContentHighlights.jsx @@ -1,35 +1,43 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import { useHistory } from 'react-router'; import { v4 as uuidv4 } from 'uuid'; import ContentHighlightRoutes from './ContentHighlightRoutes'; import Hero from '../Hero'; import ContentHighlightsContextProvider from './ContentHighlightsContext'; import ContentHighlightToast from './ContentHighlightToast'; +import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; const ContentHighlights = () => { const history = useHistory(); const { location } = history; const { state: locationState } = location; const [toasts, setToasts] = useState([]); + const { enterpriseCuration: { enterpriseCuration } } = useContext(EnterpriseAppContext); useEffect(() => { if (locationState?.deletedHighlightSet) { - setToasts((prevState) => [...prevState, `"${locationState?.toastText}" deleted.`]); + setToasts((prevState) => [...prevState, { + toastText: `"${enterpriseCuration?.toastText}" deleted.`, + uuid: uuidv4(), + }]); const newState = { ...locationState }; delete newState.deletedHighlightSet; history.replace({ ...location, state: newState }); } if (locationState?.addHighlightSet) { - setToasts((prevState) => [...prevState, `"${locationState?.toastText}" added.`]); + setToasts((prevState) => [...prevState, { + toastText: `"${enterpriseCuration?.toastText}" added.`, + uuid: uuidv4(), + }]); const newState = { ...locationState }; delete newState.addHighlightSet; history.replace({ ...location, state: newState }); } - }, [history, location, locationState, toasts]); + }, [enterpriseCuration?.toastText, history, location, locationState]); return ( - {toasts.map((element) => ())} + {toasts.map(({ toastText, uuid }) => ())} ); }; diff --git a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx index 962c17f5e2..6884c4a0cd 100644 --- a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx +++ b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx @@ -1,8 +1,6 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; - import { Container } from '@edx/paragon'; - import ZeroStateHighlights from './ZeroState'; import CurrentContentHighlights from './CurrentContentHighlights'; import ContentHighlightHelmet from './ContentHighlightHelmet'; diff --git a/src/components/ContentHighlights/DeleteHighlightSet.jsx b/src/components/ContentHighlights/DeleteHighlightSet.jsx index e7e61c5530..6eac3217ec 100644 --- a/src/components/ContentHighlights/DeleteHighlightSet.jsx +++ b/src/components/ContentHighlights/DeleteHighlightSet.jsx @@ -22,17 +22,16 @@ const DeleteHighlightSet = ({ enterpriseSlug }) => { const { highlightSetUUID } = useParams(); const [isOpen, open, close] = useToggle(false); const [deletionState, setDeletionState] = useState('default'); - const [deletedHighlightTitle, setDeletedHighlightTitle] = useState(''); const history = useHistory(); const { enterpriseCuration: { dispatch } } = useContext(EnterpriseAppContext); const [isDeleted, setIsDeleted] = useState(false); const [deletionError, setDeletionError] = useState(null); + const handleDeleteClick = () => { const deleteHighlightSet = async () => { setDeletionState('pending'); try { - const { data: { title } } = await EnterpriseCatalogApiService.fetchHighlightSet(highlightSetUUID); - setDeletedHighlightTitle(title); + dispatch(enterpriseCurationActions.setHighlightToast(highlightSetUUID)); await EnterpriseCatalogApiService.deleteHighlightSet(highlightSetUUID); dispatch(enterpriseCurationActions.deleteHighlightSet(highlightSetUUID)); setIsDeleted(true); @@ -50,10 +49,9 @@ const DeleteHighlightSet = ({ enterpriseSlug }) => { close(); history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}`, { deletedHighlightSet: true, - toastText: deletedHighlightTitle, }); } - }, [isDeleted, close, highlightSetUUID, enterpriseSlug, history, deletedHighlightTitle]); + }, [isDeleted, close, highlightSetUUID, enterpriseSlug, history]); return ( <> diff --git a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx index c8bd1f9c74..49fcdbae83 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx @@ -84,9 +84,9 @@ const ContentHighlightStepper = ({ enterpriseId }) => { highlightedContentUuids: result.highlightedContent || [], }; dispatchEnterpriseCuration(enterpriseCurationActions.addHighlightSet(transformedHighlightSet)); + dispatchEnterpriseCuration(enterpriseCurationActions.setHighlightToast(transformedHighlightSet.uuid)); history.push(location.pathname, { addHighlightSet: true, - toastText: transformedHighlightSet.title, }); closeStepperModal(); } catch (error) { diff --git a/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx b/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx index d6f354394c..bd9afbe749 100644 --- a/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx +++ b/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx @@ -10,17 +10,14 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; -import { camelCaseObject } from '@edx/frontend-platform'; import DeleteHighlightSet from '../DeleteHighlightSet'; import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; import { enterpriseCurationActions } from '../../EnterpriseApp/data/enterpriseCurationReducer'; import EnterpriseCatalogApiService from '../../../data/services/EnterpriseCatalogApiService'; -import { TEST_COURSE_HIGHLIGHTS_DATA } from '../data/constants'; jest.mock('../../../data/services/EnterpriseCatalogApiService'); -const mockHighlightSetResponse = camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA); const mockStore = configureMockStore([thunk]); const initialState = { portalConfiguration: @@ -102,10 +99,8 @@ describe('', () => { }); it('confirming deletion in confirmation modal deletes via API', async () => { - EnterpriseCatalogApiService.fetchHighlightSet.mockResolvedValueOnce({ - data: mockHighlightSetResponse, - }); EnterpriseCatalogApiService.deleteHighlightSet.mockResolvedValueOnce(); + const { history } = renderWithRouter( , { route: initialRouterEntry }, @@ -114,6 +109,11 @@ describe('', () => { userEvent.click(screen.getByTestId('delete-confirmation-button')); expect(screen.getByText('Deleting highlight...')).toBeInTheDocument(); + await waitFor(() => { + expect(mockDispatchFn).toHaveBeenCalledWith( + enterpriseCurationActions.setHighlightToast(highlightSetUUID), + ); + }); await waitFor(() => { expect(mockDispatchFn).toHaveBeenCalledWith( enterpriseCurationActions.deleteHighlightSet(highlightSetUUID), @@ -130,9 +130,6 @@ describe('', () => { }); it('confirming deletion in confirmation modal handles error via API', async () => { - EnterpriseCatalogApiService.fetchHighlightSet.mockResolvedValueOnce({ - data: mockHighlightSetResponse, - }); EnterpriseCatalogApiService.deleteHighlightSet.mockRejectedValueOnce(new Error('oh noes!')); renderWithRouter( diff --git a/src/components/EnterpriseApp/data/enterpriseCurationReducer.js b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js index c0db4b5eda..cc40d32683 100644 --- a/src/components/EnterpriseApp/data/enterpriseCurationReducer.js +++ b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js @@ -11,6 +11,7 @@ export const SET_ENTERPRISE_CURATION = 'SET_ENTERPRISE_CURATION'; export const SET_FETCH_ERROR = 'SET_FETCH_ERROR'; export const DELETE_HIGHLIGHT_SET = 'DELETE_HIGHLIGHT_SET'; export const ADD_HIGHLIGHT_SET = 'ADD_HIGHLIGHT_SET'; +export const SET_TOAST_TEXT = 'SET_TOAST_TEXT'; export const enterpriseCurationActions = { setIsLoading: (payload) => ({ @@ -25,6 +26,10 @@ export const enterpriseCurationActions = { type: SET_FETCH_ERROR, payload, }), + setHighlightToast: (payload) => ({ + type: SET_TOAST_TEXT, + payload, + }), deleteHighlightSet: (payload) => ({ type: DELETE_HIGHLIGHT_SET, payload, @@ -47,6 +52,19 @@ function enterpriseCurationReducer(state, action) { return { ...state, enterpriseCuration: action.payload }; case SET_FETCH_ERROR: return { ...state, fetchError: action.payload }; + case SET_TOAST_TEXT: { + const existingHighlightSets = getHighlightSetsFromState(state); + const filteredHighlightSets = existingHighlightSets.find( + highlightSet => highlightSet.uuid === action.payload, + ); + return { + ...state, + enterpriseCuration: { + ...state.enterpriseCuration, + toastText: filteredHighlightSets?.title, + }, + }; + } case DELETE_HIGHLIGHT_SET: { const existingHighlightSets = getHighlightSetsFromState(state); const filteredHighlightSets = existingHighlightSets.filter( diff --git a/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js b/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js index b3a57716d6..63e7a8e7d4 100644 --- a/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js +++ b/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js @@ -39,6 +39,25 @@ describe('enterpriseCurationReducer', () => { ).toMatchObject({ fetchError }); }); + it('should set toast text', () => { + const highlightSet = { + uuid: highlightSetUUID, + title: 'Hello World!', + }; + const initialStateWithHighlights = { + ...initialState, + enterpriseCuration: { + highlightSets: [highlightSet], + }, + }; + expect( + enterpriseCurationReducer( + initialStateWithHighlights, + enterpriseCurationActions.setHighlightToast(highlightSetUUID), + ), + ).toMatchObject({ enterpriseCuration: { toastText: 'Hello World!' } }); + }); + it('should delete highlight set', () => { const highlightSet = { uuid: highlightSetUUID }; const initialStateWithHighlights = { From c54b1a736d0b76a67d432218c5af556c691b5c6a Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Fri, 23 Dec 2022 10:47:48 -0500 Subject: [PATCH 18/73] fix: formats datatable footer into single component w/ actionRow (#931) * fix: formats datatable footer into single component w/ actionRow * feat: adds dark variant to programs and pathway cars --- .../ContentHighlights/ContentHighlightCardItem.jsx | 2 +- .../HighlightStepperSelectContentSearch.jsx | 1 - .../HighlightStepper/SelectContentSearchPagination.jsx | 9 ++++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/ContentHighlights/ContentHighlightCardItem.jsx b/src/components/ContentHighlights/ContentHighlightCardItem.jsx index be0ee8652e..d386bf13a8 100644 --- a/src/components/ContentHighlights/ContentHighlightCardItem.jsx +++ b/src/components/ContentHighlights/ContentHighlightCardItem.jsx @@ -29,7 +29,7 @@ const ContentHighlightCardItem = ({ ); } return ( - + } - diff --git a/src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx b/src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx index 0bc5c48b65..6085f535c6 100644 --- a/src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx +++ b/src/components/ContentHighlights/HighlightStepper/SelectContentSearchPagination.jsx @@ -1,19 +1,22 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connectPagination } from 'react-instantsearch-dom'; -import { Pagination } from '@edx/paragon'; +import { ActionRow, Pagination, DataTable } from '@edx/paragon'; export const BaseSearchPagination = ({ nbPages, currentRefinement, refine, }) => ( - <> + + + refine(pageNum)} pageCount={nbPages} /> + refine(pageNum)} /> - + ); BaseSearchPagination.propTypes = { From 9f1a963d88ec4a4318847006cadeb40cdc5baac5 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Fri, 23 Dec 2022 14:36:52 -0500 Subject: [PATCH 19/73] chore: upgrade to latest paragon, removed filter criteria and updated test Id constant (#932) * chore: upgrade to latest paragon, removed filter criteria and updated testID constant * chore: update snapshot --- package-lock.json | 14 +++++++------- package.json | 4 ++-- .../__snapshots__/ManageCodesTab.test.jsx.snap | 4 ++-- .../HighlightStepperSelectContentSearch.jsx | 4 +--- src/components/ContentHighlights/data/constants.js | 3 ++- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98aa00053d..b1c49f8496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.21.1", + "@edx/paragon": "20.24.1", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -3790,9 +3790,9 @@ } }, "node_modules/@edx/paragon": { - "version": "20.21.1", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.1.tgz", - "integrity": "sha512-bSpwdIfWZtipN3NH2eJ+8lWpA576vL/87iHpJCIcrOq5OiH49Fm0Q7kGI+t0s+fMZJboBxJ+eMtC2Nv7Op4Cmg==", + "version": "20.24.1", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.24.1.tgz", + "integrity": "sha512-FDkAiqfFR+dgxvKYIQs2C8p1ruYd2dy3aQrTtf1Ck6V2FDUywQrPeqmbmkcqUmQDcP4hBwwRCotPY32EX/Pe5g==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -28901,9 +28901,9 @@ } }, "@edx/paragon": { - "version": "20.21.1", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.21.1.tgz", - "integrity": "sha512-bSpwdIfWZtipN3NH2eJ+8lWpA576vL/87iHpJCIcrOq5OiH49Fm0Q7kGI+t0s+fMZJboBxJ+eMtC2Nv7Op4Cmg==", + "version": "20.24.1", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.24.1.tgz", + "integrity": "sha512-FDkAiqfFR+dgxvKYIQs2C8p1ruYd2dy3aQrTtf1Ck6V2FDUywQrPeqmbmkcqUmQDcP4hBwwRCotPY32EX/Pe5g==", "requires": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", diff --git a/package.json b/package.json index de91a46414..0dd77de0ab 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.21.1", + "@edx/paragon": "20.24.1", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -106,4 +106,4 @@ "react-test-renderer": "16.13.1", "resize-observer-polyfill": "1.5.1" } -} +} \ No newline at end of file diff --git a/src/components/CodeManagement/tests/__snapshots__/ManageCodesTab.test.jsx.snap b/src/components/CodeManagement/tests/__snapshots__/ManageCodesTab.test.jsx.snap index 79440d5380..7e568a0fdc 100644 --- a/src/components/CodeManagement/tests/__snapshots__/ManageCodesTab.test.jsx.snap +++ b/src/components/CodeManagement/tests/__snapshots__/ManageCodesTab.test.jsx.snap @@ -152,11 +152,11 @@ Array [ > diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx index 7c57373959..bae5b9bb4e 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx @@ -26,8 +26,6 @@ const selectColumn = { disableSortBy: true, }; -const currentEpoch = Math.round((new Date()).getTime() / 1000); - const HighlightStepperSelectContent = ({ enterpriseId }) => { const { setCurrentSelectedRowIds } = useContentHighlightsContext(); const currentSelectedRowIds = useContextSelector( @@ -40,7 +38,7 @@ const HighlightStepperSelectContent = ({ enterpriseId }) => { ); // TODO: replace testEnterpriseId with enterpriseID before push, // uncomment out import and replace with testEnterpriseId to test - const searchFilters = `enterprise_customer_uuids:${enterpriseId} AND advertised_course_run.upgrade_deadline > ${currentEpoch}`; + const searchFilters = `enterprise_customer_uuids:${enterpriseId}`; return ( diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index 0a369b1d08..2ec0c49617 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -46,7 +46,8 @@ export const FOOTER_TEXT_BY_CONTENT_TYPE = { // Test Data for Content Highlights From this point onwards // Test entepriseId for Content Highlights to display card selections and confirmation -export const testEnterpriseId = 'e783bb19-277f-479e-9c41-8b0ed31b4060'; +export const testEnterpriseId = 'f23ccd7d-fbbb-411a-824e-c2861942aac0'; + // Test Content Highlights data export const TEST_COURSE_HIGHLIGHTS_DATA = [ { From a0a346fd0769d194dfcbfda33c75db8a50f34c14 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Fri, 23 Dec 2022 11:19:09 +0500 Subject: [PATCH 20/73] feat: Get the plotly server URL from environment variable. --- .env.development | 1 + .env.test | 1 + src/config/index.js | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.env.development b/.env.development index 8dcbc547eb..453ee05271 100644 --- a/.env.development +++ b/.env.development @@ -48,3 +48,4 @@ MAINTENANCE_ALERT_MESSAGE='' MAINTENANCE_ALERT_START_TIMESTAMP='' USE_API_CACHE='true' SUBSCRIPTION_LPR='true' +PLOTLY_SERVER_URL='http://localhost:8050' diff --git a/.env.test b/.env.test index 3ba21dfdac..bd586ba243 100644 --- a/.env.test +++ b/.env.test @@ -13,3 +13,4 @@ FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' FEATURE_FILE_ATTACHMENT='true' ENTERPRISE_SUPPORT_URL = '' ENTERPRISE_SUPPORT_REVOKE_LICENSE_URL = '' +PLOTLY_SERVER_URL='http://localhost:8050' diff --git a/src/config/index.js b/src/config/index.js index d2633e3a65..ab7a561e12 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -32,7 +32,7 @@ const configuration = { LOGO_WHITE_URL: process.env.LOGO_WHITE_URL, LOGO_TRADEMARK_URL: process.env.LOGO_TRADEMARK_URL, USE_API_CACHE: process.env.USE_API_CACHE, - PLOTLY_SERVER_URL: 'https://enterprise-plotly.edx.org/enterprise-admin-analytics/', + PLOTLY_SERVER_URL: process.env.PLOTLY_SERVER_URL, }; const features = { From 52abf9f1ed4b2a0ff823eeb0a8fdb768b2520587 Mon Sep 17 00:00:00 2001 From: Ejaz Ahmad Date: Wed, 21 Dec 2022 17:46:53 +0500 Subject: [PATCH 21/73] fix: password is required when PGP is provided and test --- .../ReportingConfig/ReportingConfigForm.jsx | 9 +- .../ReportingConfigForm.test.jsx | 86 ++++++++++++++----- 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/components/ReportingConfig/ReportingConfigForm.jsx b/src/components/ReportingConfig/ReportingConfigForm.jsx index 05736bcac8..e4e1bb1ac1 100644 --- a/src/components/ReportingConfig/ReportingConfigForm.jsx +++ b/src/components/ReportingConfig/ReportingConfigForm.jsx @@ -33,7 +33,6 @@ const REQUIRED_NEW_SFTP_FEILDS = [ ]; const REQUIRED_NEW_EMAIL_FIELDS = [ ...REQUIRED_EMAIL_FIELDS, - 'encryptedPassword', ]; const MONTHLY_MAX = 31; const MONTHLY_MIN = 1; @@ -59,6 +58,14 @@ class ReportingConfigForm extends React.Component { const invalidFields = requiredFields .filter(field => !formData.get(field)) .reduce((prevFields, currField) => ({ ...prevFields, [currField]: true }), {}); + + // Password is conditionally required only when pgp key will not be present + // and delivery method is email + if (!formData.get('pgpEncryptionKey') && formData.get('deliveryMethod') === 'email') { + if (!formData.get('encryptedPassword')) { + invalidFields.encryptedPassword = true; + } + } return invalidFields; }; diff --git a/src/components/ReportingConfig/ReportingConfigForm.test.jsx b/src/components/ReportingConfig/ReportingConfigForm.test.jsx index dfe0874edc..9609300dc0 100644 --- a/src/components/ReportingConfig/ReportingConfigForm.test.jsx +++ b/src/components/ReportingConfig/ReportingConfigForm.test.jsx @@ -286,26 +286,6 @@ describe('', () => { wrapper.find('select#enterpriseCustomerCatalogs').instance().value, ).toEqual('test-enterprise-customer-catalog'); }); - it('Submit enterprise uuid upon report config creation', async () => { - const wrapper = mount(( - - )); - const flushPromises = () => new Promise(setImmediate); - const formData = new FormData(); - Object.entries(defaultConfig).forEach(([key, value]) => { - formData.append(key, value); - }); - wrapper.instance().handleSubmit(formData, null); - await act(() => flushPromises()); - expect(createConfig.mock.calls[0][0].get('enterprise_customer_id')).toEqual(enterpriseCustomerUuid); - }); it('handles API response errors correctly.', async () => { defaultConfig.pgpEncryptionKey = 'invalid-key'; const mock = jest.fn(); @@ -342,4 +322,70 @@ describe('', () => { wrapper.instance().handleAPIErrorResponse(null); expect(mock).not.toHaveBeenCalled(); }); + it('Submit if PGP key is present and password is empty and delivery method is email', async () => { + const config = { ...defaultConfig }; + config.deliveryMethod = 'email'; + config.pgpEncryptionKey = 'some-pgp-key'; + config.encryptedPassword = ''; + const wrapper = mount(( + + )); + const flushPromises = () => new Promise(setImmediate); + const formData = new FormData(); + Object.entries(config).forEach(([key, value]) => { + formData.append(key, value); + }); + wrapper.instance().handleSubmit(formData, null); + await act(() => flushPromises()); + expect(createConfig.mock.calls[0][0].get('enterprise_customer_id')).toEqual(enterpriseCustomerUuid); + }); + it('Do not Submit if PGP key and password is empty and delivery method is email', async () => { + const config = { ...defaultConfig }; + config.pgpEncryptionKey = ''; + config.encryptedPassword = ''; + const wrapper = mount(( + + )); + const formData = new FormData(); + Object.entries(config).forEach(([key, value]) => { + formData.append(key, value); + }); + wrapper.instance().handleSubmit(formData, null); + wrapper.find('.form-control').forEach(input => input.simulate('blur')); + expect(wrapper.find('input#encryptedPassword').hasClass('is-invalid')).toBeTruthy(); + }); + it('Submit enterprise uuid upon report config creation', async () => { + const wrapper = mount(( + + )); + const flushPromises = () => new Promise(setImmediate); + const formData = new FormData(); + Object.entries(defaultConfig).forEach(([key, value]) => { + formData.append(key, value); + }); + wrapper.instance().handleSubmit(formData, null); + await act(() => flushPromises()); + expect(createConfig.mock.calls[0][0].get('enterprise_customer_id')).toEqual(enterpriseCustomerUuid); + }); }); From 7b8fc6de0b884c09f83a6b5b7d8520dacdc04d7e Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Tue, 3 Jan 2023 23:28:28 +0500 Subject: [PATCH 22/73] feat: Make changes in license table in admin tab. (#934) --- src/components/Admin/EmbeddedSubscription.jsx | 2 +- src/components/Admin/SubscriptionDetailPage.jsx | 3 ++- .../licenses/LicenseManagementTable/index.jsx | 7 ++++--- .../SubscriptionDetailContextProvider.jsx | 9 ++++++++- src/components/subscriptions/data/constants.js | 1 + src/components/subscriptions/data/hooks.js | 16 ++++++++++++++-- src/data/services/LicenseManagerAPIService.js | 5 ++--- 7 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/components/Admin/EmbeddedSubscription.jsx b/src/components/Admin/EmbeddedSubscription.jsx index ba8af1b6d8..8561873617 100644 --- a/src/components/Admin/EmbeddedSubscription.jsx +++ b/src/components/Admin/EmbeddedSubscription.jsx @@ -54,7 +54,7 @@ const EmbeddedSubscription = () => {
) - :

{activeSubscriptions[0].title}

} + :

{activeSubscriptions[0].title}

}
diff --git a/src/components/Admin/SubscriptionDetailPage.jsx b/src/components/Admin/SubscriptionDetailPage.jsx index e4e0b078ef..e0781e93a9 100644 --- a/src/components/Admin/SubscriptionDetailPage.jsx +++ b/src/components/Admin/SubscriptionDetailPage.jsx @@ -8,6 +8,7 @@ import LicenseAllocationDetails from './licenses/LicenseAllocationDetails'; import SubscriptionDetailContextProvider from '../subscriptions/SubscriptionDetailContextProvider'; import { useSubscriptionFromParams } from '../subscriptions/data/contextHooks'; import SubscriptionDetailsSkeleton from '../subscriptions/SubscriptionDetailsSkeleton'; +import { LPR_SUBSCRIPTION_PAGE_SIZE } from '../subscriptions/data/constants'; // eslint-disable-next-line no-unused-vars export const SubscriptionDetailPage = ({ enterpriseSlug, match }) => { @@ -25,7 +26,7 @@ export const SubscriptionDetailPage = ({ enterpriseSlug, match }) => { ); } return ( - + diff --git a/src/components/Admin/licenses/LicenseManagementTable/index.jsx b/src/components/Admin/licenses/LicenseManagementTable/index.jsx index fb7d23acbf..7478d5fbca 100644 --- a/src/components/Admin/licenses/LicenseManagementTable/index.jsx +++ b/src/components/Admin/licenses/LicenseManagementTable/index.jsx @@ -12,7 +12,7 @@ import moment from 'moment'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { SubscriptionContext } from '../../../subscriptions/SubscriptionData'; -import { SubscriptionDetailContext, defaultStatusFilter } from '../../../subscriptions/SubscriptionDetailContextProvider'; +import { SubscriptionDetailContext } from '../../../subscriptions/SubscriptionDetailContextProvider'; import { DEFAULT_PAGE, ACTIVATED, REVOKED, ASSIGNED, } from '../../../subscriptions/data/constants'; @@ -45,6 +45,8 @@ const userRecentAction = (user) => { } }; +const defaultLPRStatusFilter = [ASSIGNED, ACTIVATED].join(); + const selectColumn = { id: 'selection', Header: DataTable.ControlledSelectHeader, @@ -106,7 +108,7 @@ const LicenseManagementTable = () => { const updateFilters = useCallback((filters) => { if (filters.length < 1) { setSearchQuery(null); - setUserStatusFilter(defaultStatusFilter); + setUserStatusFilter(defaultLPRStatusFilter); } else { filters.forEach((filter) => { switch (filter.id) { @@ -217,7 +219,6 @@ const LicenseManagementTable = () => { initialTableOptions={{ getRowId: row => row.id, }} - isSortable EmptyTableComponent={ /* eslint-disable react/no-unstable-nested-components */ () => { diff --git a/src/components/subscriptions/SubscriptionDetailContextProvider.jsx b/src/components/subscriptions/SubscriptionDetailContextProvider.jsx index 2312e62cbd..217cc2d9ee 100644 --- a/src/components/subscriptions/SubscriptionDetailContextProvider.jsx +++ b/src/components/subscriptions/SubscriptionDetailContextProvider.jsx @@ -4,6 +4,7 @@ import React, { import PropTypes from 'prop-types'; import { DEFAULT_PAGE, ACTIVATED, REVOKED, ASSIGNED, + PAGE_SIZE, } from './data/constants'; import { useSubscriptionUsersOverview, useSubscriptionUsers } from './data/hooks'; import { SubscriptionContext } from './SubscriptionData'; @@ -12,7 +13,7 @@ export const SubscriptionDetailContext = createContext({}); export const defaultStatusFilter = [ASSIGNED, ACTIVATED, REVOKED].join(); const SubscriptionDetailContextProvider = ({ - children, subscription, disableDataFetching, + children, subscription, disableDataFetching, pageSize, licenseStatusOrdering, }) => { // Initialize state needed for the subscription detail view and provide in SubscriptionDetailContext const { data: subscriptions, errors, setErrors } = useContext(SubscriptionContext); @@ -36,6 +37,8 @@ const SubscriptionDetailContextProvider = ({ setErrors, userStatusFilter, isDisabled: disableDataFetching, + pageSize, + licenseStatusOrdering, }); const forceRefreshDetailView = useCallback(() => { @@ -82,10 +85,14 @@ SubscriptionDetailContextProvider.propTypes = { uuid: PropTypes.string.isRequired, }).isRequired, disableDataFetching: PropTypes.bool, + pageSize: PropTypes.number, + licenseStatusOrdering: PropTypes.string, }; SubscriptionDetailContextProvider.defaultProps = { disableDataFetching: false, + pageSize: PAGE_SIZE, + licenseStatusOrdering: '', }; export default SubscriptionDetailContextProvider; diff --git a/src/components/subscriptions/data/constants.js b/src/components/subscriptions/data/constants.js index 19ca07703e..a182f72e95 100644 --- a/src/components/subscriptions/data/constants.js +++ b/src/components/subscriptions/data/constants.js @@ -1,4 +1,5 @@ export const PAGE_SIZE = 20; +export const LPR_SUBSCRIPTION_PAGE_SIZE = 5; // Subscription license statuses as defined on the backend export const ACTIVATED = 'activated'; diff --git a/src/components/subscriptions/data/hooks.js b/src/components/subscriptions/data/hooks.js index 727c0df122..2244d8481b 100644 --- a/src/components/subscriptions/data/hooks.js +++ b/src/components/subscriptions/data/hooks.js @@ -155,6 +155,8 @@ export const useSubscriptionUsers = ({ setErrors, userStatusFilter, isDisabled = false, + pageSize, + licenseStatusOrdering, }) => { const [subscriptionUsers, setSubscriptionUsers] = useState({ ...subscriptionInitState }); const [loadingUsers, setLoadingUsers] = useState(true); @@ -168,12 +170,13 @@ export const useSubscriptionUsers = ({ const options = { status: userStatusFilter, page: currentPage, + license_status_lpr_ordering: licenseStatusOrdering, }; if (searchQuery) { options.search = searchQuery; } try { - const response = await LicenseManagerApiService.fetchSubscriptionUsers(subscriptionUUID, options); + const response = await LicenseManagerApiService.fetchSubscriptionUsers(subscriptionUUID, options, pageSize); setSubscriptionUsers(camelCaseObject(response.data)); setLoadingUsers(false); } catch (err) { @@ -187,7 +190,16 @@ export const useSubscriptionUsers = ({ } }; fetchUsers(); - }, [currentPage, errors, searchQuery, setErrors, subscriptionUUID, userStatusFilter]); + }, [ + currentPage, + errors, + searchQuery, + setErrors, + subscriptionUUID, + userStatusFilter, + pageSize, + licenseStatusOrdering, + ]); const forceRefresh = useCallback(() => { loadSubscriptionUsers(); diff --git a/src/data/services/LicenseManagerAPIService.js b/src/data/services/LicenseManagerAPIService.js index 91fc2d10c9..31ce368432 100644 --- a/src/data/services/LicenseManagerAPIService.js +++ b/src/data/services/LicenseManagerAPIService.js @@ -1,6 +1,5 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { PAGE_SIZE } from '../../components/subscriptions/data/constants'; import { configuration } from '../../config'; class LicenseManagerApiService { @@ -29,9 +28,9 @@ class LicenseManagerApiService { return LicenseManagerApiService.apiClient().get(url); } - static fetchSubscriptionUsers(subscriptionUUID, options) { + static fetchSubscriptionUsers(subscriptionUUID, options, pageSize) { const queryParams = new URLSearchParams({ - page_size: PAGE_SIZE, + page_size: pageSize, ignore_null_emails: 1, ...options, }); From 6df566a5d9e60561abed9f2190226c5a95f54892 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Thu, 5 Jan 2023 15:01:51 +0000 Subject: [PATCH 23/73] feat: maintaining debug value on sso configs through self service --- src/components/settings/SettingsSSOTab/SSOStepper.jsx | 6 +++--- src/components/settings/SettingsSSOTab/hooks.js | 5 ++++- .../SettingsSSOTab/tests/NewSSOConfigForm.test.jsx | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/settings/SettingsSSOTab/SSOStepper.jsx b/src/components/settings/SettingsSSOTab/SSOStepper.jsx index 3ea0457a4d..2038d93b17 100644 --- a/src/components/settings/SettingsSSOTab/SSOStepper.jsx +++ b/src/components/settings/SettingsSSOTab/SSOStepper.jsx @@ -64,13 +64,13 @@ const SSOStepper = ({ enterpriseSlug, enterpriseId, enterpriseName }) => { const newConfigValues = { ...configValues }; // Values we want all provider configs to by default contain configFormData.append('enterprise_customer_uuid', enterpriseId); - configFormData.append('enabled', true); - configFormData.append('debug_mode', true); + configFormData.append('enabled', providerConfig?.enabled || true); + configFormData.append('debug_mode', providerConfig?.debug_mode || false); configFormData.append('skip_hinted_login_dialog', true); configFormData.append('skip_registration_form', true); configFormData.append('skip_email_verification', true); configFormData.append('send_to_registration_first', true); - configFormData.append('automatic_refresh_enabled', true); + configFormData.append('automatic_refresh_enabled', providerConfig?.automatic_refresh_enabled || false); // Add all our config values to the form data Object.keys(configValues).forEach(key => configFormData.append(key, configValues[key])); diff --git a/src/components/settings/SettingsSSOTab/hooks.js b/src/components/settings/SettingsSSOTab/hooks.js index 65f390bb99..04a2002af8 100644 --- a/src/components/settings/SettingsSSOTab/hooks.js +++ b/src/components/settings/SettingsSSOTab/hooks.js @@ -59,7 +59,6 @@ const useIdpState = () => { const formData = new FormData(); formData.append('name', enterpriseName); formData.append('slug', enterpriseSlug); - formData.append('enabled', true); formData.append('enterprise_customer_uuid', enterpriseId); formData.append('metadata_source', metadataURL); formData.append('entity_id', entityID); @@ -72,6 +71,10 @@ const useIdpState = () => { formData.append('sync_learner_profile_data', false); formData.append('enable_sso_id_verification', true); + formData.append('enabled', providerConfig?.enabled || true); + formData.append('automatic_refresh_enabled', providerConfig?.automatic_refresh_enabled || false); + formData.append('debug_mode', providerConfig?.debug_mode || false); + try { let response; if (!providerConfig) { diff --git a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx index da3cab2a64..891c102f0d 100644 --- a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx +++ b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx @@ -371,12 +371,12 @@ describe('SAML Config Tab', () => { attr_last_name: '', attr_email: 'NameID', enabled: 'true', - debug_mode: 'true', + debug_mode: 'false', skip_hinted_login_dialog: 'true', skip_registration_form: 'true', skip_email_verification: 'true', send_to_registration_first: 'true', - automatic_refresh_enabled: 'true', + automatic_refresh_enabled: 'false', attr_username: 'loggedinuserid', other_settings: { odata_api_root_url: 'foobar.com', @@ -459,12 +459,12 @@ describe('SAML Config Tab', () => { attr_username: 'foobar', other_settings: '', enabled: 'true', - debug_mode: 'true', + debug_mode: 'false', skip_hinted_login_dialog: 'true', skip_registration_form: 'true', skip_email_verification: 'true', send_to_registration_first: 'true', - automatic_refresh_enabled: 'true', + automatic_refresh_enabled: 'false', }; mockUpdateProviderConfig.mock.calls[0][0].forEach((value, key) => { expect(expectedConfigFormData[key]).toEqual(value); From 14ef9c92d9b5eb77c4e6aa73ee18013a404486b7 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Fri, 6 Jan 2023 13:53:30 -0500 Subject: [PATCH 24/73] fix: update copy, icons, and use imported fallback img from @edx/brand for highlights (#938) --- package-lock.json | 30 ++++++++-------- package.json | 4 +-- .../ContentHighlightCardItem.jsx | 5 ++- .../ContentHighlights/ContentHighlights.jsx | 4 +-- .../ContentHighlights/DeleteHighlightSet.jsx | 4 +-- .../HighlightStepperConfirmContent.jsx | 36 ++++++++++++------- .../HighlightStepperSelectContentHeader.jsx | 5 +-- .../HighlightStepperSelectContentSearch.jsx | 2 +- .../HighlightStepperTitle.jsx | 6 ++-- .../ContentHighlights/data/constants.js | 3 +- .../tests/ContentHighlights.test.jsx | 4 +-- 11 files changed, 59 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1c49f8496..b4a7f0e7d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,13 @@ "license": "AGPL-3.0", "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", - "@edx/brand": "npm:@edx/brand-edx.org@^2.0.7", + "@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.24.1", + "@edx/paragon": "20.26.3", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -2087,10 +2087,10 @@ } }, "node_modules/@edx/brand": { - "name": "@edx/brand-edx.org", - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@edx/brand-edx.org/-/brand-edx.org-2.0.8.tgz", - "integrity": "sha512-y/SFUebIsJN2PW+T4zUP0Jwe7T/YoK3bY3V44Aj7cF6ao8vBgt98oVKQJTPpy1ZhKiXILlFvbEMb2AwtSxaODQ==" + "name": "@edx/brand-openedx", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@edx/brand-openedx/-/brand-openedx-1.2.0.tgz", + "integrity": "sha512-r4PDN3rCgDsLovW44ayxoNNHgG5I4Rvss6MG5CrQEX4oW8YhQVEod+jJtwR5vi0mFLN2GIaMlDpd7iIy03VqXg==" }, "node_modules/@edx/browserslist-config": { "version": "1.0.0", @@ -3790,9 +3790,9 @@ } }, "node_modules/@edx/paragon": { - "version": "20.24.1", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.24.1.tgz", - "integrity": "sha512-FDkAiqfFR+dgxvKYIQs2C8p1ruYd2dy3aQrTtf1Ck6V2FDUywQrPeqmbmkcqUmQDcP4hBwwRCotPY32EX/Pe5g==", + "version": "20.26.3", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.26.3.tgz", + "integrity": "sha512-+N05050zBBGYohb0/CAOEzD7oRCQhTjmEW5ZOT4+JTa8JXdIfJ0+YGLc2ZZ3Nvz80r4o+og2hGA0oU1SA6UY2A==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -27710,9 +27710,9 @@ "dev": true }, "@edx/brand": { - "version": "npm:@edx/brand-edx.org@2.0.8", - "resolved": "https://registry.npmjs.org/@edx/brand-edx.org/-/brand-edx.org-2.0.8.tgz", - "integrity": "sha512-y/SFUebIsJN2PW+T4zUP0Jwe7T/YoK3bY3V44Aj7cF6ao8vBgt98oVKQJTPpy1ZhKiXILlFvbEMb2AwtSxaODQ==" + "version": "npm:@edx/brand-openedx@1.2.0", + "resolved": "https://registry.npmjs.org/@edx/brand-openedx/-/brand-openedx-1.2.0.tgz", + "integrity": "sha512-r4PDN3rCgDsLovW44ayxoNNHgG5I4Rvss6MG5CrQEX4oW8YhQVEod+jJtwR5vi0mFLN2GIaMlDpd7iIy03VqXg==" }, "@edx/browserslist-config": { "version": "1.0.0", @@ -28901,9 +28901,9 @@ } }, "@edx/paragon": { - "version": "20.24.1", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.24.1.tgz", - "integrity": "sha512-FDkAiqfFR+dgxvKYIQs2C8p1ruYd2dy3aQrTtf1Ck6V2FDUywQrPeqmbmkcqUmQDcP4hBwwRCotPY32EX/Pe5g==", + "version": "20.26.3", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.26.3.tgz", + "integrity": "sha512-+N05050zBBGYohb0/CAOEzD7oRCQhTjmEW5ZOT4+JTa8JXdIfJ0+YGLc2ZZ3Nvz80r4o+og2hGA0oU1SA6UY2A==", "requires": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", diff --git a/package.json b/package.json index 0dd77de0ab..590e401f39 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,13 @@ "license": "AGPL-3.0", "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", - "@edx/brand": "npm:@edx/brand-edx.org@^2.0.7", + "@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.24.1", + "@edx/paragon": "20.26.3", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", diff --git a/src/components/ContentHighlights/ContentHighlightCardItem.jsx b/src/components/ContentHighlights/ContentHighlightCardItem.jsx index d386bf13a8..03fe126106 100644 --- a/src/components/ContentHighlights/ContentHighlightCardItem.jsx +++ b/src/components/ContentHighlights/ContentHighlightCardItem.jsx @@ -1,7 +1,9 @@ import React from 'react'; -import { Card, Hyperlink } from '@edx/paragon'; import Truncate from 'react-truncate'; import PropTypes from 'prop-types'; +import { Card, Hyperlink } from '@edx/paragon'; +import cardImageCapFallbackSrc from '@edx/brand/paragon/images/card-imagecap-fallback.png'; + import { getContentHighlightCardFooter } from './data/utils'; const ContentHighlightCardItem = ({ @@ -32,6 +34,7 @@ const ContentHighlightCardItem = ({ { useEffect(() => { if (locationState?.deletedHighlightSet) { setToasts((prevState) => [...prevState, { - toastText: `"${enterpriseCuration?.toastText}" deleted.`, + toastText: `"${enterpriseCuration?.toastText}" deleted`, uuid: uuidv4(), }]); const newState = { ...locationState }; @@ -25,7 +25,7 @@ const ContentHighlights = () => { } if (locationState?.addHighlightSet) { setToasts((prevState) => [...prevState, { - toastText: `"${enterpriseCuration?.toastText}" added.`, + toastText: `"${enterpriseCuration?.toastText}" added`, uuid: uuidv4(), }]); const newState = { ...locationState }; diff --git a/src/components/ContentHighlights/DeleteHighlightSet.jsx b/src/components/ContentHighlights/DeleteHighlightSet.jsx index 6eac3217ec..bcf0f56890 100644 --- a/src/components/ContentHighlights/DeleteHighlightSet.jsx +++ b/src/components/ContentHighlights/DeleteHighlightSet.jsx @@ -88,8 +88,8 @@ const DeleteHighlightSet = ({ enterpriseSlug }) => {

- Deleting this highlight collection will remove it from your - learners. This action is permanent and cannot be undone. + Deleting this highlight will remove it from your + learners' "Find a Course" This action is permanent and cannot be undone.

diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx index 0c958a913b..6e6af1961a 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx @@ -130,19 +130,29 @@ SelectedContent.propTypes = { enterpriseId: PropTypes.string.isRequired, }; -const HighlightStepperConfirmContent = ({ enterpriseId }) => ( - - - -

- - {STEPPER_STEP_TEXT.confirmContent} -

- -
- -
-); +const HighlightStepperConfirmContent = ({ enterpriseId }) => { + const highlightTitle = useContextSelector( + ContentHighlightsContext, + v => v[0].stepperModal.highlightTitle, + ); + + return ( + + + +

+ + {STEPPER_STEP_TEXT.confirmContent} +

+

+ {STEPPER_STEP_TEXT.getConfirmContentSubtitle(highlightTitle)}. +

+ +
+ +
+ ); +}; HighlightStepperConfirmContent.propTypes = { enterpriseId: PropTypes.string.isRequired, diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx index b6bdbf8a96..086ee65f22 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx @@ -10,11 +10,12 @@ const HighlightStepperSelectContentTitle = () => { return ( <>

- + {STEPPER_STEP_TEXT.selectContent}

- Select up to {MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "{highlightTitle}". + Select up to {MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "{highlightTitle}". Courses + in learners' portal appear in the order of selection.

diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx index bae5b9bb4e..5c1c15f67a 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx @@ -36,7 +36,7 @@ const HighlightStepperSelectContent = ({ enterpriseId }) => { ContentHighlightsContext, v => v[0].searchClient, ); - // TODO: replace testEnterpriseId with enterpriseID before push, + // TODO: replace testEnterpriseId with enterpriseId before push, // uncomment out import and replace with testEnterpriseId to test const searchFilters = `enterprise_customer_uuids:${enterpriseId}`; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx index 557632c05f..8944ba1ad3 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Row, Col, Icon, Container, } from '@edx/paragon'; -import { AddCircle } from '@edx/paragon/icons'; +import { EditCircle } from '@edx/paragon/icons'; import { STEPPER_STEP_TEXT } from '../data/constants'; import HighlightStepperTitleInput from './HighlightStepperTitleInput'; @@ -12,13 +12,13 @@ const HighlightStepperTitle = () => (

- + {STEPPER_STEP_TEXT.createTitle}

Create a unique title for your highlight. This title is visible - to your learners and helps them discovery relevant content. + to your learners and helps them navigate to relevant content.

diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index 2ec0c49617..9a3965eb41 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -25,7 +25,8 @@ export const HIGHLIGHT_TITLE_MAX_LENGTH = 60; export const STEPPER_STEP_TEXT = { createTitle: 'Create a title for your highlight', selectContent: 'Add content to your highlight', - confirmContent: 'Confirm your content selections', + confirmContent: 'Confirm your selections', + getConfirmContentSubtitle: (highlightTitle) => `Review content selections for "${highlightTitle}"`, }; // Header text extracted into constant to maintain passing test on changes export const HEADER_TEXT = { diff --git a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx index f1b67b4280..ddd2b5a205 100644 --- a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx @@ -56,10 +56,10 @@ describe('', () => { }); it('Displays the toast addition', () => { renderWithRouter(); - expect(screen.getByText('added.', { exact: false })).toBeInTheDocument(); + expect(screen.getByText('added', { exact: false })).toBeInTheDocument(); }); it('Displays the toast deleted', () => { renderWithRouter(); - expect(screen.getByText('deleted.', { exact: false })).toBeInTheDocument(); + expect(screen.getByText('deleted', { exact: false })).toBeInTheDocument(); }); }); From 1c9b9ca1ec5416c28645c7292845ef9508dd979d Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Fri, 6 Jan 2023 15:06:15 -0500 Subject: [PATCH 25/73] fix: add period at end of sentence (#940) --- src/components/ContentHighlights/DeleteHighlightSet.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ContentHighlights/DeleteHighlightSet.jsx b/src/components/ContentHighlights/DeleteHighlightSet.jsx index bcf0f56890..3d1b55ec8d 100644 --- a/src/components/ContentHighlights/DeleteHighlightSet.jsx +++ b/src/components/ContentHighlights/DeleteHighlightSet.jsx @@ -89,7 +89,7 @@ const DeleteHighlightSet = ({ enterpriseSlug }) => {

Deleting this highlight will remove it from your - learners' "Find a Course" This action is permanent and cannot be undone. + learners' "Find a Course". This action is permanent and cannot be undone.

From 4038389b488194775e29fdc2443c5ae6b00a40a9 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Mon, 9 Jan 2023 15:05:58 -0700 Subject: [PATCH 26/73] feat: Sync history page (#935) * feat!: draft PR of sync history page * fix: fixing tests * fix: PR requests * fix: more little changes * fix: lint errors * fix: remove configure button --- package-lock.json | 2 +- package.json | 2 +- .../ErrorReporting/ErrorReportingModal.jsx | 79 ------ .../ErrorReporting/ErrorReportingTable.jsx | 50 ++++ .../ErrorReporting/LearnerMetadataTable.jsx | 4 +- .../ErrorReporting/SyncHistory.jsx | 228 +++++++++++++++ .../tests/ErrorReporting.test.jsx | 261 ++++++++++-------- .../settings/SettingsLMSTab/ExistingCard.jsx | 34 +-- .../SettingsLMSTab/ExistingLMSCardDeck.jsx | 36 +-- .../tests/ExistingLMSCardDeck.test.jsx | 22 ++ .../tests/LmsConfigPage.test.jsx | 19 +- .../settings/SettingsLMSTab/utils.js | 11 + .../SettingsSSOTab/ExistingSSOConfigs.jsx | 5 +- src/components/settings/data/constants.js | 4 + src/components/settings/index.jsx | 5 + src/data/services/LmsApiService.js | 92 +++--- src/setupTest.js | 1 - src/utils.js | 24 +- 18 files changed, 562 insertions(+), 317 deletions(-) delete mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx create mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx create mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx diff --git a/package-lock.json b/package-lock.json index b4a7f0e7d8..360fa9e0df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0", "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", - "@edx/brand": "npm:@edx/brand-openedx@1.2.0", + "@edx/brand": "npm:@edx/brand-openedx@2.1.0", "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", diff --git a/package.json b/package.json index 590e401f39..bf4e5b5a04 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "license": "AGPL-3.0", "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", - "@edx/brand": "npm:@edx/brand-openedx@1.2.0", + "@edx/brand": "npm:@edx/brand-openedx@2.1.0", "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx deleted file mode 100644 index ebf75b8f81..0000000000 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { - ActionRow, ModalDialog, Tab, Tabs, -} from '@edx/paragon'; -import ContentMetadataTable from './ContentMetadataTable'; -import LearnerMetadataTable from './LearnerMetadataTable'; - -const ErrorReportingModal = ({ - isOpen, close, config, enterpriseCustomerUuid, -}) => { - const [key, setKey] = useState('contentMetadata'); - // notification for tab must be a non-empty string to appear - const contentError = config?.lastContentSyncErroredAt == null ? null : ' '; - const learnerError = config?.lastLearnerSyncErroredAt == null ? null : ' '; - return ( - - - - {config?.displayName} Sync History - - - - - setKey(k)} - className="mb-3" - > - -

Most recent data transmission

- From edX for Business to {config?.displayName} - -
- -

Most recent data transmission

- From edX for Business to {config?.displayName} - -
-
-
- - - - - Close - - - -
- ); -}; - -ErrorReportingModal.defaultProps = { - config: null, -}; - -ErrorReportingModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - close: PropTypes.func.isRequired, - config: PropTypes.shape({ - id: PropTypes.number, - channelCode: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - lastContentSyncErroredAt: PropTypes.string.isRequired, - lastLearnerSyncErroredAt: PropTypes.string.isRequired, - }), - enterpriseCustomerUuid: PropTypes.string.isRequired, -}; - -export default ErrorReportingModal; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx new file mode 100644 index 0000000000..d2027111d8 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Tab, Tabs, +} from '@edx/paragon'; +import ContentMetadataTable from './ContentMetadataTable'; +import LearnerMetadataTable from './LearnerMetadataTable'; + +const ErrorReportingTable = ({ config }) => { + const [key, setKey] = useState('contentMetadata'); + // notification for tab must be a non-empty string to appear + const contentError = config.lastContentSyncErroredAt == null ? null : ' '; + const learnerError = config.lastLearnerSyncErroredAt == null ? null : ' '; + const enterpriseCustomerUuid = config.enterpriseCustomer; + return ( + <> +

Sync History

+ setKey(k)} + className="mb-3" + > + +

Most recent data transmission

+ From edX for Business to {config.displayName} + +
+ +

Most recent data transmission

+ From edX for Business to {config.displayName} + +
+
+ + ); +}; + +ErrorReportingTable.propTypes = { + config: PropTypes.shape({ + id: PropTypes.number, + channelCode: PropTypes.string, + displayName: PropTypes.string, + enterpriseCustomer: PropTypes.string, + lastContentSyncErroredAt: PropTypes.string, + lastLearnerSyncErroredAt: PropTypes.string, + }).isRequired, +}; + +export default ErrorReportingTable; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx index 285699477d..4570cafb27 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx @@ -6,6 +6,7 @@ import { logError } from '@edx/frontend-platform/logging'; import LmsApiService from '../../../../data/services/LmsApiService'; import { createLookup, getSyncStatus, getTimeAgo } from './utils'; import DownloadCsvButton from './DownloadCsvButton'; +import { CORNERSTONE_TYPE } from '../../data/constants'; const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { const [currentPage, setCurrentPage] = useState(); @@ -20,9 +21,10 @@ const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { useEffect(() => { const fetchData = async () => { + const correctedChannelCode = config.channelCode === CORNERSTONE_TYPE ? 'cornerstone' : config.channelCode; const response = await LmsApiService.fetchLearnerMetadataItemTransmission( enterpriseCustomerUuid, - config.channelCode, + correctedChannelCode, config.id, currentPage, currentFilters, diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx new file mode 100644 index 0000000000..fffddb2104 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx @@ -0,0 +1,228 @@ +import React, { useEffect, useState } from 'react'; + +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { + ActionRow, AlertModal, Breadcrumb, Button, Card, Icon, Image, Skeleton, Toast, useToggle, +} from '@edx/paragon'; +import { CheckCircle, Error, Sync } from '@edx/paragon/icons'; +import { getStatus } from '../utils'; +import { getTimeAgo } from './utils'; +import handleErrors from '../../utils'; +import ConfigError from '../../ConfigError'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +import { + ACTIVATE_TOAST_MESSAGE, BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED_TYPE, + DEGREED2_TYPE, errorToggleModalText, INACTIVATE_TOAST_MESSAGE, MOODLE_TYPE, SAP_TYPE, +} from '../../data/constants'; + +import { channelMapping } from '../../../../utils'; +import ErrorReportingTable from './ErrorReportingTable'; + +const SyncHistory = () => { + const vars = (window.location.pathname).split('lms/'); + const redirectPath = `${vars[0]}lms/`; + const configInfo = vars[1].split('/'); + const configChannel = configInfo[0]; + const configId = configInfo[1]; + + const [config, setConfig] = useState(); + const [errorModalText, setErrorModalText] = useState(); + const [errorIsOpen, openError, closeError] = useToggle(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [toastMessage, setToastMessage] = useState(null); + const [reloadPage, setReloadPage] = useState(false); + + const getActiveStatus = status => (status === 'Active' ? `${status} •` : ''); + + useEffect(() => { + const fetchData = async () => { + let response; + switch (configChannel) { + case BLACKBOARD_TYPE: + response = await LmsApiService.fetchSingleBlackboardConfig(configId); break; + case CANVAS_TYPE: + response = await LmsApiService.fetchSingleCanvasConfig(configId); break; + case CORNERSTONE_TYPE: + response = await LmsApiService.fetchSingleCornerstoneConfig(configId); break; + case DEGREED_TYPE: + response = await LmsApiService.fetchSingleDegreedConfig(configId); break; + case DEGREED2_TYPE: + response = await LmsApiService.fetchSingleDegreed2Config(configId); break; + case MOODLE_TYPE: + response = await LmsApiService.fetchSingleMoodleConfig(configId); break; + case SAP_TYPE: + response = await LmsApiService.fetchSingleSuccessFactorsConfig(configId); break; + default: + break; + } + return camelCaseObject(response.data); + }; + fetchData() + .then((response) => { + setConfig(response); + }) + .catch((error) => { + handleErrors(error); + }); + }, [configChannel, configId, reloadPage]); + + const getLastSync = () => { + if (config.lastSyncErroredAt != null) { + const timeStamp = getTimeAgo(config.lastSyncErroredAt); + return ( + + Recent sync error:  {timeStamp} + + + + ); + } + if (config.lastSyncAttemptedAt != null) { + const timeStamp = getTimeAgo(config.lastSyncAttemptedAt); + return ( + + Last sync:  {timeStamp} + + + + ); + } + return null; + }; + + const onClick = (input) => { + // if configuration is being toggled + if (input !== null) { + setReloadPage(true); + setToastMessage(input); + // if configuration is being deleted + } else { + window.location.href = redirectPath; + } + }; + + const toggleConfig = async (toggle) => { + const configOptions = { + active: toggle, + enterprise_customer: config.enterpriseCustomer, + }; + let err; + try { + await channelMapping[config.channelCode].update(configOptions, config.id); + } catch (error) { + err = handleErrors(error); + } + if (err) { + setErrorModalText(errorToggleModalText); + openError(); + } else { + onClick(toggle ? ACTIVATE_TOAST_MESSAGE : INACTIVATE_TOAST_MESSAGE); + } + }; + + const createActionRow = () => { + if (getStatus(config) === 'Active') { + return ( + + + {/* */} + + ); + } + if (getStatus(config) === 'Inactive') { + return ( + + + {/* */} + + + ); + } + return ( // if incomplete + + + {/* */} + + ); + }; + + return ( +
+ setShowDeleteModal(false)} + hasCloseButton + footerNode={( + + + + + )} + > +

+ Are you sure you want to delete this learning platform integration? + Once deleted, any saved integration data will be lost. +

+
+ + {!config && ( + + )} + {config && ( + <> + + + +

+ + {config.displayName} +

+

+ {getActiveStatus(getStatus(config))} {config.channelCode} +

+
+ {getLastSync()} +
+ + + )} + {toastMessage && ( + setToastMessage(null)} show={toastMessage !== null}>{toastMessage} + )} +
+ ); +}; + +export default SyncHistory; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx index da6973bb49..5ad88865d0 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { - act, fireEvent, render, screen, waitFor, + act, cleanup, fireEvent, render, screen, waitFor, waitForElementToBeRemoved, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -8,28 +8,33 @@ import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import ExistingLMSCardDeck from '../../ExistingLMSCardDeck'; import LmsApiService from '../../../../../data/services/LmsApiService'; import { features } from '../../../../../config'; +import SyncHistory from '../SyncHistory'; const enterpriseCustomerUuid = 'test-enterprise-id'; -const mockEditExistingConfigFn = jest.fn(); -const mockOnClick = jest.fn(); // file-saver mocks jest.mock('file-saver', () => ({ saveAs: jest.fn() })); // eslint-disable-next-line func-names global.Blob = function (content, options) { return ({ content, options }); }; -const configData = [ - { +const configData = { + data: { channelCode: 'BLACKBOARD', id: 1, isValid: [{ missing: [] }, { incorrect: [] }], active: true, displayName: 'foobar', + enterpriseCustomer: enterpriseCustomerUuid, + lastSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastContentSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, }, -]; +}; const contentSyncData = { data: { @@ -205,48 +210,142 @@ const learnerSyncData = { describe('', () => { beforeEach(() => { - jest.resetAllMocks(); getAuthenticatedUser.mockReturnValue({ administrator: true, }); features.FEATURE_INTEGRATION_REPORTING = true; + const url = 'http://dummy.com/test-enterprise/admin/settings/lms'; + Object.defineProperty(window, 'location', { + value: { + pathname: `${url}/${configData.data.channelCode}/${configData.data.id}`, + }, + writable: true, + }); }); - it('opens error reporting modal', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + it('basic lms config detail screen', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); render( - + , ); - userEvent.click(screen.queryByText('View sync history')); - expect(screen.getByText('foobar Sync History')).toBeInTheDocument(); - expect(screen.getAllByText('Course')).toHaveLength(2); - expect(screen.getByText('Course key')).toBeInTheDocument(); + + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); + expect(screen.getByText('LMS Detail Page')).toBeInTheDocument(); + expect(screen.getByText('Disable')).toBeInTheDocument(); + expect(screen.getByText('Configure')).toBeInTheDocument(); + expect(screen.getByText('Last sync:')).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText('Course key')).toBeInTheDocument()); expect(screen.getAllByText('Sync status')).toHaveLength(2); expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); expect(screen.getAllByText('No results found')).toHaveLength(2); }); - it('populates with content sync data', async () => { + it('populates with learner sync data', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + + expect(screen.getByText('Learner email')).toBeInTheDocument(); + expect(screen.getAllByText('Course')).toHaveLength(2); + expect(screen.getByText('Completion status')).toBeInTheDocument(); + expect(screen.getAllByText('Sync status')).toHaveLength(2); + expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); + + await waitFor(() => expect(screen.getByText('its LEARNING!')).toBeInTheDocument()); + expect(screen.getByText('In progress')).toBeInTheDocument(); + + expect(screen.getByText('spooooky')).toBeInTheDocument(); + expect(screen.getByText('Passed')).toBeInTheDocument(); + await waitFor(() => userEvent.click(screen.queryAllByText('Read')[1])); + expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument(); + }); + it('paginates over learner data', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + await waitFor(() => expect(screen.getByText('spooooky')).toBeInTheDocument()); + expect(screen.getAllByLabelText('Next, Page 2')[1]).not.toBeDisabled(); + act(() => { + fireEvent.click(screen.getAllByLabelText('Next, Page 2')[1]); + }); + await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, 1, {})); + }); + it('metadata data reporting modal calls fetchContentMetadataItemTransmission with extended page size', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); mockFetchCmits.mockResolvedValue(contentSyncData); render( - + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.getByTestId('content-download'))); + await waitFor(() => expect(mockFetchCmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: contentSyncData.data.count })); + }); + it('learner data reporting modal calls fetchLearnerMetadataItemTransmission with extended page size', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + , ); - userEvent.click(screen.queryByText('View sync history')); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + await waitFor(() => userEvent.click(screen.getByTestId('learner-download'))); + await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: learnerSyncData.data.count })); + }); + it('populates with content sync data', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); + mockFetchCmits.mockResolvedValue(contentSyncData); + render( + + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); expect(screen.getByText('Demo1')).toBeInTheDocument(); @@ -272,15 +371,11 @@ describe('', () => { render( - + , ); - userEvent.click(screen.queryByText('View sync history')); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); expect(screen.getAllByLabelText('Next, Page 2')[0]).not.toBeDisabled(); @@ -296,17 +391,12 @@ describe('', () => { render( - + , ); - userEvent.click(screen.queryByText('View sync history')); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); - await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); fireEvent.change(screen.getByLabelText('Search course key'), { target: { value: 'ayylmao' }, }); @@ -319,16 +409,11 @@ describe('', () => { render( - + , ); - userEvent.click(screen.queryByText('View sync history')); - await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); // Expect to be in the default state expect(screen.getAllByText('Download history')).toHaveLength(2); @@ -341,93 +426,29 @@ describe('', () => { // Expect to have updated the state to complete expect(screen.queryByText('Downloaded')).toBeInTheDocument(); }); - it('populates with learner sync data', async () => { - const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); - mockFetchLmits.mockResolvedValue(learnerSyncData); - - render( - - - , - ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); - await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); - - expect(screen.getByText('Learner email')).toBeInTheDocument(); - expect(screen.getAllByText('Course')).toHaveLength(2); - expect(screen.getByText('Completion status')).toBeInTheDocument(); - expect(screen.getAllByText('Sync status')).toHaveLength(2); - expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); - - await waitFor(() => expect(screen.getByText('its LEARNING!')).toBeInTheDocument()); - expect(screen.getByText('In progress')).toBeInTheDocument(); - - expect(screen.getByText('spooooky')).toBeInTheDocument(); - expect(screen.getByText('Passed')).toBeInTheDocument(); - await waitFor(() => userEvent.click(screen.queryAllByText('Read')[1])); - expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument(); - }); - it('paginates over learner data', async () => { - const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); - mockFetchLmits.mockResolvedValue(learnerSyncData); - - render( - - - , - ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); - await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); - await waitFor(() => expect(screen.getByText('spooooky')).toBeInTheDocument()); - expect(screen.getAllByLabelText('Next, Page 2')[1]).not.toBeDisabled(); - act(() => { - userEvent.click(screen.getAllByLabelText('Next, Page 2')[1]); - }); - await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, 1, {})); - }); it('metadata data reporting modal calls fetchContentMetadataItemTransmission with extended page size', async () => { const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); mockFetchCmits.mockResolvedValue(contentSyncData); - render( - + , ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); await waitFor(() => userEvent.click(screen.getByTestId('content-download'))); await waitFor(() => expect(mockFetchCmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: contentSyncData.data.count })); }); it('learner data reporting modal calls fetchLearnerMetadataItemTransmission with extended page size', async () => { const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); mockFetchLmits.mockResolvedValue(learnerSyncData); - render( - + , ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); await waitFor(() => userEvent.click(screen.getByTestId('learner-download'))); diff --git a/src/components/settings/SettingsLMSTab/ExistingCard.jsx b/src/components/settings/SettingsLMSTab/ExistingCard.jsx index 4c47019ba3..8b5c48c614 100644 --- a/src/components/settings/SettingsLMSTab/ExistingCard.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingCard.jsx @@ -1,5 +1,8 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { + useRouteMatch, +} from 'react-router-dom'; import { ActionRow, AlertModal, Badge, Button, Card, Dropdown, Icon, IconButton, Image, OverlayTrigger, Popover, } from '@edx/paragon'; @@ -12,13 +15,14 @@ import { channelMapping } from '../../../utils'; import handleErrors from '../utils'; import { getTimeAgo } from './ErrorReporting/utils'; -import { ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE } from '../data/constants'; +import { + ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE, + errorDeleteConfigModalText, errorToggleModalText, +} from '../data/constants'; -const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; -const errorDeleteModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; -const INCOMPLETE = 'incomplete'; -const ACTIVE = 'active'; -const INACTIVE = 'inactive'; +const INCOMPLETE = 'Incomplete'; +const ACTIVE = 'Active'; +const INACTIVE = 'Inactive'; const ExistingCard = ({ config, @@ -26,19 +30,13 @@ const ExistingCard = ({ enterpriseCustomerUuid, onClick, openError, - openReport, - setReportConfig, setErrorModalText, getStatus, }) => { + const redirectPath = `${useRouteMatch().url}`; const [showDeleteModal, setShowDeleteModal] = useState(false); const isEdxStaff = getAuthenticatedUser().administrator; - const openModalButton = () => { - setReportConfig(config); - openReport(); - }; - const toggleConfig = async (id, channelType, toggle) => { const configOptions = { active: toggle, @@ -66,7 +64,7 @@ const ExistingCard = ({ err = handleErrors(error); } if (err) { - setErrorModalText(errorDeleteModalText); + setErrorModalText(errorDeleteConfigModalText); openError(); } else { onClick(DELETE_TOAST_MESSAGE); @@ -104,7 +102,7 @@ const ExistingCard = ({ switch (getStatus(config)) { case ACTIVE: if (isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) { - return ; + return ; } return null; case INCOMPLETE: @@ -185,7 +183,7 @@ const ExistingCard = ({ {(isInactive && isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) && (
openModalButton(config)} + href={`${redirectPath}${config.channelCode}/${config.id}`} data-testid="dropdown-sync-history-item" > View sync history @@ -205,7 +203,7 @@ const ExistingCard = ({ {(isInactive || isIncomplete) && (
handleClickDelete(isInactive)} data-testid="dropdown-delete-item" > @@ -293,8 +291,6 @@ ExistingCard.propTypes = { enterpriseCustomerUuid: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, openError: PropTypes.func.isRequired, - openReport: PropTypes.func.isRequired, - setReportConfig: PropTypes.func.isRequired, setErrorModalText: PropTypes.func.isRequired, getStatus: PropTypes.func.isRequired, }; diff --git a/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx b/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx index 8c0e0d4689..a34236d0db 100644 --- a/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx @@ -1,11 +1,10 @@ import React, { useState } from 'react'; -import isEmpty from 'lodash/isEmpty'; import PropTypes from 'prop-types'; import { CardGrid, useToggle, } from '@edx/paragon'; +import { getStatus } from './utils'; import ConfigError from '../ConfigError'; -import ErrorReportingModal from './ErrorReporting/ErrorReportingModal'; import ExistingCard from './ExistingCard'; const ExistingLMSCardDeck = ({ @@ -15,29 +14,10 @@ const ExistingLMSCardDeck = ({ onClick, }) => { const [errorIsOpen, openError, closeError] = useToggle(false); - const [errorReportIsOpen, openReport, closeReport] = useToggle(false); - const [reportConfig, setReportConfig] = useState(); const [errorModalText, setErrorModalText] = useState(); // Map the existing config data to individual cards - - const getStatus = (config) => { - const INCOMPLETE = 'incomplete'; - const ACTIVE = 'active'; - const INACTIVE = 'inactive'; - if (!isEmpty(config.isValid[0].missing) - || !isEmpty(config.isValid[1].incorrect)) { - return INCOMPLETE; - } - - if (config.active) { - return ACTIVE; - } - return INACTIVE; - }; - - // const listItems = timeSort(configData).map((config) => ( - const listActive = configData.filter(config => getStatus(config) === 'active').map(config => ( + const listActive = configData.filter(config => getStatus(config) === 'Active').map(config => ( )); - const listInactive = configData.filter(config => getStatus(config) !== 'active').map(config => ( + const listInactive = configData.filter(config => getStatus(config) !== 'Active').map(config => ( @@ -73,12 +49,6 @@ const ExistingLMSCardDeck = ({ close={closeError} configTextOverride={errorModalText} /> - { listActive.length > 0 && ( <>

Active

diff --git a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx index b728959a28..6885627a5b 100644 --- a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx @@ -12,6 +12,13 @@ import { features } from '../../../../config'; jest.mock('../../../../data/services/LmsApiService'); +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useRouteMatch: () => ({ + url: 'https://www.test.com/', + }), +})); + const enterpriseCustomerUuid = 'test-enterprise-id'; const mockEditExistingConfigFn = jest.fn(); const mockOnClick = jest.fn(); @@ -356,4 +363,19 @@ describe('', () => { ); expect(screen.queryByText('View sync history')).not.toBeInTheDocument(); }); + it('viewing sync history redirects to detail page', () => { + getAuthenticatedUser.mockReturnValue({ + administrator: true, + }); + render( + , + ); + const link = 'https://www.test.com/BLACKBOARD/1'; + expect(screen.getByText('View sync history')).toHaveAttribute('href', link); + }); }); diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index 3400b30ed2..ad56bc5429 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -15,7 +15,6 @@ import { BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, - DEGREED_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, @@ -140,8 +139,8 @@ describe('', () => { expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - userEvent.click(degreedCard); - expect(screen.queryByText('Connect Degreed')).toBeTruthy(); + fireEvent.click(degreedCard); + expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -216,8 +215,8 @@ describe('', () => { expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - await waitFor(() => userEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed')).toBeTruthy(); + await waitFor(() => fireEvent.click(degreedCard)); + expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); @@ -228,15 +227,15 @@ describe('', () => { const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { - expect(screen.findByText(channelMapping[DEGREED_TYPE].displayName)); + expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); - const degreedCard = screen.getByText(channelMapping[DEGREED_TYPE].displayName); - await waitFor(() => userEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed')).toBeTruthy(); + const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); + await waitFor(() => fireEvent.click(degreedCard)); + expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Degreed')).toBeFalsy(); + expect(screen.queryByText('Connect Degreed2')).toBeFalsy(); }); test('No action Cornerstone card cancel flow', async () => { renderWithRouter(); diff --git a/src/components/settings/SettingsLMSTab/utils.js b/src/components/settings/SettingsLMSTab/utils.js index 4b8c226133..6781d80d44 100644 --- a/src/components/settings/SettingsLMSTab/utils.js +++ b/src/components/settings/SettingsLMSTab/utils.js @@ -1,3 +1,5 @@ +import isEmpty from 'lodash/isEmpty'; + export default function buttonBool(config) { let returnVal = true; Object.entries(config).forEach(entry => { @@ -18,3 +20,12 @@ export const isExistingConfig = (configs, value, existingInput) => { } return false; }; + +export const getStatus = (config) => { + // config.isValid has two arrays of missing and incorrect config fields + // which are required to resolve in order to complete the configuration + if (!isEmpty([...config.isValid[0].missing, ...config.isValid[1].incorrect])) { + return 'Incomplete'; + } + return config.active ? 'Active' : 'Inactive'; +}; diff --git a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx index 832564cdad..001ef8d47e 100644 --- a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx +++ b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx @@ -11,10 +11,7 @@ import { SSOConfigContext } from './SSOConfigContext'; import ConfigError from '../ConfigError'; import handleErrors from '../utils'; import LmsApiService from '../../../data/services/LmsApiService'; - -const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; -const errorDeleteConfigModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; -const errorDeleteDataModalText = 'We were unable to delete your provider data. Please try removing again or contact support for help.'; +import { errorToggleModalText, errorDeleteConfigModalText, errorDeleteDataModalText } from '../data/constants'; const ExistingSSOConfigs = ({ configs, refreshBool, setRefreshBool, enterpriseId, providerData, diff --git a/src/components/settings/data/constants.js b/src/components/settings/data/constants.js index 0720742e41..c2d99b0e71 100644 --- a/src/components/settings/data/constants.js +++ b/src/components/settings/data/constants.js @@ -20,6 +20,10 @@ export const DELETE_TOAST_MESSAGE = 'Learning platform integration successfully export const INACTIVATE_TOAST_MESSAGE = 'Learning platform integration successfully disabled.'; export const SUBMIT_TOAST_MESSAGE = 'Learning platform integration successfully submitted.'; +export const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; +export const errorDeleteConfigModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; +export const errorDeleteDataModalText = 'We were unable to delete your provider data. Please try removing again or contact support for help.'; + export const BLACKBOARD_TYPE = 'BLACKBOARD'; export const CANVAS_TYPE = 'CANVAS'; export const CORNERSTONE_TYPE = 'CSOD'; diff --git a/src/components/settings/index.jsx b/src/components/settings/index.jsx index fa94500dc0..5c48e8cc40 100644 --- a/src/components/settings/index.jsx +++ b/src/components/settings/index.jsx @@ -14,6 +14,7 @@ import { SETTINGS_PARAM_MATCH, } from './data/constants'; import SettingsTabs from './SettingsTabs'; +import SyncHistory from './SettingsLMSTab/ErrorReporting/SyncHistory'; const PAGE_TILE = 'Settings'; @@ -38,6 +39,10 @@ const SettingsPage = () => { path={`${path}/${SETTINGS_PARAM_MATCH}`} component={SettingsTabs} /> + diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index fb8772ce08..8a2fb6c96e 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -127,16 +127,24 @@ class LmsApiService { return LmsApiService.apiClient().post(LmsApiService.providerDataSyncUrl, formData); } - static postNewMoodleConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/`, formData); + static fetchBlackboardGlobalConfig() { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/global-configuration/`); } - static updateMoodleConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`, formData); + static fetchSingleBlackboardConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); } - static deleteMoodleConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); + static postNewBlackboardConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/`, formData); + } + + static updateBlackboardConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`, formData); + } + + static deleteBlackboardConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); } static fetchSingleCanvasConfig(configId) { @@ -155,42 +163,30 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/canvas/configuration/${configId}/`); } - static fetchBlackboardGlobalConfig() { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/global-configuration/`); - } - - static fetchSingleBlackboardConfig(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); - } - - static postNewBlackboardConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/`, formData); - } - - static updateBlackboardConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`, formData); - } - - static deleteBlackboardConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); + static fetchSingleCornerstoneConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); } - static postNewSuccessFactorsConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/`, formData); + static postNewCornerstoneConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/`, formData); } - static updateSuccessFactorsConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`, formData); + static updateCornerstoneConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`, formData); } - static deleteSuccessFactorsConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); + static deleteCornerstoneConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); } static postNewDegreedConfig(formData) { return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/`, formData); } + static fetchSingleDegreedConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`); + } + static updateDegreedConfig(formData, configId) { return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`, formData); } @@ -199,6 +195,10 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`); } + static fetchSingleDegreed2Config(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/${configId}/`); + } + static postNewDegreed2Config(formData) { return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/`, formData); } @@ -211,16 +211,36 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/${configId}/`); } - static postNewCornerstoneConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/`, formData); + static fetchSingleMoodleConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); } - static updateCornerstoneConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`, formData); + static postNewMoodleConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/`, formData); } - static deleteCornerstoneConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); + static updateMoodleConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`, formData); + } + + static deleteMoodleConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); + } + + static fetchSingleSuccessFactorsConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); + } + + static postNewSuccessFactorsConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/`, formData); + } + + static updateSuccessFactorsConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`, formData); + } + + static deleteSuccessFactorsConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); } static createPendingEnterpriseUsers(formData, uuid) { diff --git a/src/setupTest.js b/src/setupTest.js index 92011cdf9e..ba4024d5c1 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -6,7 +6,6 @@ import Adapter from 'enzyme-adapter-react-16'; import MockAdapter from 'axios-mock-adapter'; import ResizeObserverPolyfill from 'resize-observer-polyfill'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import 'jest-canvas-mock'; import 'jest-localstorage-mock'; Enzyme.configure({ adapter: new Adapter() }); diff --git a/src/utils.js b/src/utils.js index d427269ce2..b6892bdbcd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -260,6 +260,18 @@ const channelMapping = { update: LmsApiService.updateCornerstoneConfig, delete: LmsApiService.deleteCornerstoneConfig, }, + [DEGREED_TYPE]: { + displayName: 'Degreed', + icon: DegreedIcon, + update: LmsApiService.updateDegreedConfig, + delete: LmsApiService.deleteDegreedConfig, + }, + [DEGREED2_TYPE]: { + displayName: 'Degreed2', + icon: DegreedIcon, + update: LmsApiService.updateDegreed2Config, + delete: LmsApiService.deleteDegreed2Config, + }, [MOODLE_TYPE]: { displayName: 'Moodle', icon: MoodleIcon, @@ -272,18 +284,6 @@ const channelMapping = { update: LmsApiService.updateSuccessFactorsConfig, delete: LmsApiService.deleteSuccessFactorsConfig, }, - [DEGREED2_TYPE]: { - displayName: 'Degreed', - icon: DegreedIcon, - update: LmsApiService.updateDegreed2Config, - delete: LmsApiService.deleteDegreed2Config, - }, - [DEGREED_TYPE]: { - displayName: 'Degreed', - icon: DegreedIcon, - update: LmsApiService.updateDegreedConfig, - delete: LmsApiService.deleteDegreedConfig, - }, }; const capitalizeFirstLetter = string => string.charAt(0).toUpperCase() + string.slice(1); From 13a02b684692cf4528ba516d9c59933da1786134 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 9 Jan 2023 17:56:11 -0500 Subject: [PATCH 27/73] fix: change back to key instead of aggregationKey (#943) --- package-lock.json | 2 +- package.json | 2 +- src/components/BulkEnrollmentPage/CourseSearchResults.jsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 360fa9e0df..b4a7f0e7d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0", "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", - "@edx/brand": "npm:@edx/brand-openedx@2.1.0", + "@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", diff --git a/package.json b/package.json index bf4e5b5a04..590e401f39 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "license": "AGPL-3.0", "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", - "@edx/brand": "npm:@edx/brand-openedx@2.1.0", + "@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/frontend-enterprise-catalog-search": "3.1.5", "@edx/frontend-enterprise-hotjar": "1.2.0", "@edx/frontend-enterprise-logistration": "2.1.0", diff --git a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx index 673e7cbef9..b1dd9225e3 100644 --- a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx +++ b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx @@ -171,7 +171,7 @@ export const BaseCourseSearchResults = (props) => { selectedRowIds: transformedSelectedRowIds, }} initialTableOptions={{ - getRowId: row => row.aggregation_key, + getRowId: row => row.aggregation_key.split(':')[1], }} > From d9e4ceae77bde71cdf423e30e662a280245aa5bd Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 9 Jan 2023 19:12:11 -0500 Subject: [PATCH 28/73] Revert "feat: Sync history page (#935)" (#944) --- .../CourseSearchResults.jsx | 2 +- .../ErrorReporting/ErrorReportingModal.jsx | 79 ++++++ .../ErrorReporting/ErrorReportingTable.jsx | 50 ---- .../ErrorReporting/LearnerMetadataTable.jsx | 4 +- .../ErrorReporting/SyncHistory.jsx | 228 --------------- .../tests/ErrorReporting.test.jsx | 261 ++++++++---------- .../settings/SettingsLMSTab/ExistingCard.jsx | 34 ++- .../SettingsLMSTab/ExistingLMSCardDeck.jsx | 36 ++- .../tests/ExistingLMSCardDeck.test.jsx | 22 -- .../tests/LmsConfigPage.test.jsx | 19 +- .../settings/SettingsLMSTab/utils.js | 11 - .../SettingsSSOTab/ExistingSSOConfigs.jsx | 5 +- src/components/settings/data/constants.js | 4 - src/components/settings/index.jsx | 5 - src/data/services/LmsApiService.js | 92 +++--- src/setupTest.js | 1 + src/utils.js | 24 +- 17 files changed, 316 insertions(+), 561 deletions(-) create mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx delete mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx delete mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx diff --git a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx index b1dd9225e3..673e7cbef9 100644 --- a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx +++ b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx @@ -171,7 +171,7 @@ export const BaseCourseSearchResults = (props) => { selectedRowIds: transformedSelectedRowIds, }} initialTableOptions={{ - getRowId: row => row.aggregation_key.split(':')[1], + getRowId: row => row.aggregation_key, }} > diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx new file mode 100644 index 0000000000..ebf75b8f81 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + ActionRow, ModalDialog, Tab, Tabs, +} from '@edx/paragon'; +import ContentMetadataTable from './ContentMetadataTable'; +import LearnerMetadataTable from './LearnerMetadataTable'; + +const ErrorReportingModal = ({ + isOpen, close, config, enterpriseCustomerUuid, +}) => { + const [key, setKey] = useState('contentMetadata'); + // notification for tab must be a non-empty string to appear + const contentError = config?.lastContentSyncErroredAt == null ? null : ' '; + const learnerError = config?.lastLearnerSyncErroredAt == null ? null : ' '; + return ( + + + + {config?.displayName} Sync History + + + + + setKey(k)} + className="mb-3" + > + +

Most recent data transmission

+ From edX for Business to {config?.displayName} + +
+ +

Most recent data transmission

+ From edX for Business to {config?.displayName} + +
+
+
+ + + + + Close + + + +
+ ); +}; + +ErrorReportingModal.defaultProps = { + config: null, +}; + +ErrorReportingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + config: PropTypes.shape({ + id: PropTypes.number, + channelCode: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + lastContentSyncErroredAt: PropTypes.string.isRequired, + lastLearnerSyncErroredAt: PropTypes.string.isRequired, + }), + enterpriseCustomerUuid: PropTypes.string.isRequired, +}; + +export default ErrorReportingModal; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx deleted file mode 100644 index d2027111d8..0000000000 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { - Tab, Tabs, -} from '@edx/paragon'; -import ContentMetadataTable from './ContentMetadataTable'; -import LearnerMetadataTable from './LearnerMetadataTable'; - -const ErrorReportingTable = ({ config }) => { - const [key, setKey] = useState('contentMetadata'); - // notification for tab must be a non-empty string to appear - const contentError = config.lastContentSyncErroredAt == null ? null : ' '; - const learnerError = config.lastLearnerSyncErroredAt == null ? null : ' '; - const enterpriseCustomerUuid = config.enterpriseCustomer; - return ( - <> -

Sync History

- setKey(k)} - className="mb-3" - > - -

Most recent data transmission

- From edX for Business to {config.displayName} - -
- -

Most recent data transmission

- From edX for Business to {config.displayName} - -
-
- - ); -}; - -ErrorReportingTable.propTypes = { - config: PropTypes.shape({ - id: PropTypes.number, - channelCode: PropTypes.string, - displayName: PropTypes.string, - enterpriseCustomer: PropTypes.string, - lastContentSyncErroredAt: PropTypes.string, - lastLearnerSyncErroredAt: PropTypes.string, - }).isRequired, -}; - -export default ErrorReportingTable; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx index 4570cafb27..285699477d 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx @@ -6,7 +6,6 @@ import { logError } from '@edx/frontend-platform/logging'; import LmsApiService from '../../../../data/services/LmsApiService'; import { createLookup, getSyncStatus, getTimeAgo } from './utils'; import DownloadCsvButton from './DownloadCsvButton'; -import { CORNERSTONE_TYPE } from '../../data/constants'; const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { const [currentPage, setCurrentPage] = useState(); @@ -21,10 +20,9 @@ const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { useEffect(() => { const fetchData = async () => { - const correctedChannelCode = config.channelCode === CORNERSTONE_TYPE ? 'cornerstone' : config.channelCode; const response = await LmsApiService.fetchLearnerMetadataItemTransmission( enterpriseCustomerUuid, - correctedChannelCode, + config.channelCode, config.id, currentPage, currentFilters, diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx deleted file mode 100644 index fffddb2104..0000000000 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx +++ /dev/null @@ -1,228 +0,0 @@ -import React, { useEffect, useState } from 'react'; - -import { camelCaseObject } from '@edx/frontend-platform/utils'; -import { - ActionRow, AlertModal, Breadcrumb, Button, Card, Icon, Image, Skeleton, Toast, useToggle, -} from '@edx/paragon'; -import { CheckCircle, Error, Sync } from '@edx/paragon/icons'; -import { getStatus } from '../utils'; -import { getTimeAgo } from './utils'; -import handleErrors from '../../utils'; -import ConfigError from '../../ConfigError'; -import LmsApiService from '../../../../data/services/LmsApiService'; - -import { - ACTIVATE_TOAST_MESSAGE, BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED_TYPE, - DEGREED2_TYPE, errorToggleModalText, INACTIVATE_TOAST_MESSAGE, MOODLE_TYPE, SAP_TYPE, -} from '../../data/constants'; - -import { channelMapping } from '../../../../utils'; -import ErrorReportingTable from './ErrorReportingTable'; - -const SyncHistory = () => { - const vars = (window.location.pathname).split('lms/'); - const redirectPath = `${vars[0]}lms/`; - const configInfo = vars[1].split('/'); - const configChannel = configInfo[0]; - const configId = configInfo[1]; - - const [config, setConfig] = useState(); - const [errorModalText, setErrorModalText] = useState(); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [toastMessage, setToastMessage] = useState(null); - const [reloadPage, setReloadPage] = useState(false); - - const getActiveStatus = status => (status === 'Active' ? `${status} •` : ''); - - useEffect(() => { - const fetchData = async () => { - let response; - switch (configChannel) { - case BLACKBOARD_TYPE: - response = await LmsApiService.fetchSingleBlackboardConfig(configId); break; - case CANVAS_TYPE: - response = await LmsApiService.fetchSingleCanvasConfig(configId); break; - case CORNERSTONE_TYPE: - response = await LmsApiService.fetchSingleCornerstoneConfig(configId); break; - case DEGREED_TYPE: - response = await LmsApiService.fetchSingleDegreedConfig(configId); break; - case DEGREED2_TYPE: - response = await LmsApiService.fetchSingleDegreed2Config(configId); break; - case MOODLE_TYPE: - response = await LmsApiService.fetchSingleMoodleConfig(configId); break; - case SAP_TYPE: - response = await LmsApiService.fetchSingleSuccessFactorsConfig(configId); break; - default: - break; - } - return camelCaseObject(response.data); - }; - fetchData() - .then((response) => { - setConfig(response); - }) - .catch((error) => { - handleErrors(error); - }); - }, [configChannel, configId, reloadPage]); - - const getLastSync = () => { - if (config.lastSyncErroredAt != null) { - const timeStamp = getTimeAgo(config.lastSyncErroredAt); - return ( - - Recent sync error:  {timeStamp} - - - - ); - } - if (config.lastSyncAttemptedAt != null) { - const timeStamp = getTimeAgo(config.lastSyncAttemptedAt); - return ( - - Last sync:  {timeStamp} - - - - ); - } - return null; - }; - - const onClick = (input) => { - // if configuration is being toggled - if (input !== null) { - setReloadPage(true); - setToastMessage(input); - // if configuration is being deleted - } else { - window.location.href = redirectPath; - } - }; - - const toggleConfig = async (toggle) => { - const configOptions = { - active: toggle, - enterprise_customer: config.enterpriseCustomer, - }; - let err; - try { - await channelMapping[config.channelCode].update(configOptions, config.id); - } catch (error) { - err = handleErrors(error); - } - if (err) { - setErrorModalText(errorToggleModalText); - openError(); - } else { - onClick(toggle ? ACTIVATE_TOAST_MESSAGE : INACTIVATE_TOAST_MESSAGE); - } - }; - - const createActionRow = () => { - if (getStatus(config) === 'Active') { - return ( - - - {/* */} - - ); - } - if (getStatus(config) === 'Inactive') { - return ( - - - {/* */} - - - ); - } - return ( // if incomplete - - - {/* */} - - ); - }; - - return ( -
- setShowDeleteModal(false)} - hasCloseButton - footerNode={( - - - - - )} - > -

- Are you sure you want to delete this learning platform integration? - Once deleted, any saved integration data will be lost. -

-
- - {!config && ( - - )} - {config && ( - <> - - - -

- - {config.displayName} -

-

- {getActiveStatus(getStatus(config))} {config.channelCode} -

-
- {getLastSync()} -
- - - )} - {toastMessage && ( - setToastMessage(null)} show={toastMessage !== null}>{toastMessage} - )} -
- ); -}; - -export default SyncHistory; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx index 5ad88865d0..da6973bb49 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { - act, cleanup, fireEvent, render, screen, waitFor, waitForElementToBeRemoved, + act, fireEvent, render, screen, waitFor, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -8,33 +8,28 @@ import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import ExistingLMSCardDeck from '../../ExistingLMSCardDeck'; import LmsApiService from '../../../../../data/services/LmsApiService'; import { features } from '../../../../../config'; -import SyncHistory from '../SyncHistory'; const enterpriseCustomerUuid = 'test-enterprise-id'; +const mockEditExistingConfigFn = jest.fn(); +const mockOnClick = jest.fn(); // file-saver mocks jest.mock('file-saver', () => ({ saveAs: jest.fn() })); // eslint-disable-next-line func-names global.Blob = function (content, options) { return ({ content, options }); }; -const configData = { - data: { +const configData = [ + { channelCode: 'BLACKBOARD', id: 1, isValid: [{ missing: [] }, { incorrect: [] }], active: true, displayName: 'foobar', - enterpriseCustomer: enterpriseCustomerUuid, - lastSyncAttemptedAt: '2022-11-22T20:59:56Z', - lastContentSyncAttemptedAt: '2022-11-22T20:59:56Z', - lastLearnerSyncAttemptedAt: null, - lastSyncErroredAt: null, - lastContentSyncErroredAt: null, - lastLearnerSyncErroredAt: null, }, -}; +]; const contentSyncData = { data: { @@ -210,142 +205,48 @@ const learnerSyncData = { describe('', () => { beforeEach(() => { + jest.resetAllMocks(); getAuthenticatedUser.mockReturnValue({ administrator: true, }); features.FEATURE_INTEGRATION_REPORTING = true; - const url = 'http://dummy.com/test-enterprise/admin/settings/lms'; - Object.defineProperty(window, 'location', { - value: { - pathname: `${url}/${configData.data.channelCode}/${configData.data.id}`, - }, - writable: true, - }); }); - afterEach(() => { - cleanup(); - jest.clearAllMocks(); - }); - it('basic lms config detail screen', async () => { - const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); - mockFetchSingleConfig.mockResolvedValue(configData); + it('opens error reporting modal', () => { render( - + , ); - - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); - expect(screen.getByText('LMS Detail Page')).toBeInTheDocument(); - expect(screen.getByText('Disable')).toBeInTheDocument(); - expect(screen.getByText('Configure')).toBeInTheDocument(); - expect(screen.getByText('Last sync:')).toBeInTheDocument(); - - await waitFor(() => expect(screen.getByText('Course key')).toBeInTheDocument()); - expect(screen.getAllByText('Sync status')).toHaveLength(2); - expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); - - expect(screen.getAllByText('No results found')).toHaveLength(2); - }); - it('populates with learner sync data', async () => { - const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); - mockFetchSingleConfig.mockResolvedValue(configData); - const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); - mockFetchLmits.mockResolvedValue(learnerSyncData); - - render( - - - , - ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); - - expect(screen.getByText('Learner email')).toBeInTheDocument(); + userEvent.click(screen.queryByText('View sync history')); + expect(screen.getByText('foobar Sync History')).toBeInTheDocument(); expect(screen.getAllByText('Course')).toHaveLength(2); - expect(screen.getByText('Completion status')).toBeInTheDocument(); + expect(screen.getByText('Course key')).toBeInTheDocument(); expect(screen.getAllByText('Sync status')).toHaveLength(2); expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); - await waitFor(() => expect(screen.getByText('its LEARNING!')).toBeInTheDocument()); - expect(screen.getByText('In progress')).toBeInTheDocument(); - - expect(screen.getByText('spooooky')).toBeInTheDocument(); - expect(screen.getByText('Passed')).toBeInTheDocument(); - await waitFor(() => userEvent.click(screen.queryAllByText('Read')[1])); - expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument(); - }); - it('paginates over learner data', async () => { - const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); - mockFetchSingleConfig.mockResolvedValue(configData); - const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); - mockFetchLmits.mockResolvedValue(learnerSyncData); - - render( - - - , - ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); - await waitFor(() => expect(screen.getByText('spooooky')).toBeInTheDocument()); - expect(screen.getAllByLabelText('Next, Page 2')[1]).not.toBeDisabled(); - act(() => { - fireEvent.click(screen.getAllByLabelText('Next, Page 2')[1]); - }); - await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, 1, {})); + expect(screen.getAllByText('No results found')).toHaveLength(2); }); - it('metadata data reporting modal calls fetchContentMetadataItemTransmission with extended page size', async () => { - const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); - mockFetchSingleConfig.mockResolvedValue(configData); + it('populates with content sync data', async () => { const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); mockFetchCmits.mockResolvedValue(contentSyncData); render( - - , - ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => userEvent.click(screen.getByTestId('content-download'))); - await waitFor(() => expect(mockFetchCmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: contentSyncData.data.count })); - }); - it('learner data reporting modal calls fetchLearnerMetadataItemTransmission with extended page size', async () => { - const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); - mockFetchSingleConfig.mockResolvedValue(configData); - const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); - mockFetchLmits.mockResolvedValue(learnerSyncData); - - render( - - + , ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); - await waitFor(() => userEvent.click(screen.getByTestId('learner-download'))); + userEvent.click(screen.queryByText('View sync history')); - await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: learnerSyncData.data.count })); - }); - it('populates with content sync data', async () => { - const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); - mockFetchSingleConfig.mockResolvedValue(configData); - const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); - mockFetchCmits.mockResolvedValue(contentSyncData); - render( - - - , - ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); expect(screen.getByText('Demo1')).toBeInTheDocument(); @@ -371,11 +272,15 @@ describe('', () => { render( - + , ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); + userEvent.click(screen.queryByText('View sync history')); await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); expect(screen.getAllByLabelText('Next, Page 2')[0]).not.toBeDisabled(); @@ -391,12 +296,17 @@ describe('', () => { render( - + , ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); + userEvent.click(screen.queryByText('View sync history')); + await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); fireEvent.change(screen.getByLabelText('Search course key'), { target: { value: 'ayylmao' }, }); @@ -409,11 +319,16 @@ describe('', () => { render( - + , ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); + userEvent.click(screen.queryByText('View sync history')); + await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); // Expect to be in the default state expect(screen.getAllByText('Download history')).toHaveLength(2); @@ -426,29 +341,93 @@ describe('', () => { // Expect to have updated the state to complete expect(screen.queryByText('Downloaded')).toBeInTheDocument(); }); + it('populates with learner sync data', async () => { + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + + , + ); + await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + + expect(screen.getByText('Learner email')).toBeInTheDocument(); + expect(screen.getAllByText('Course')).toHaveLength(2); + expect(screen.getByText('Completion status')).toBeInTheDocument(); + expect(screen.getAllByText('Sync status')).toHaveLength(2); + expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); + + await waitFor(() => expect(screen.getByText('its LEARNING!')).toBeInTheDocument()); + expect(screen.getByText('In progress')).toBeInTheDocument(); + + expect(screen.getByText('spooooky')).toBeInTheDocument(); + expect(screen.getByText('Passed')).toBeInTheDocument(); + await waitFor(() => userEvent.click(screen.queryAllByText('Read')[1])); + expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument(); + }); + it('paginates over learner data', async () => { + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + + , + ); + await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + await waitFor(() => expect(screen.getByText('spooooky')).toBeInTheDocument()); + expect(screen.getAllByLabelText('Next, Page 2')[1]).not.toBeDisabled(); + act(() => { + userEvent.click(screen.getAllByLabelText('Next, Page 2')[1]); + }); + await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, 1, {})); + }); it('metadata data reporting modal calls fetchContentMetadataItemTransmission with extended page size', async () => { const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); mockFetchCmits.mockResolvedValue(contentSyncData); + render( - + , ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); await waitFor(() => userEvent.click(screen.getByTestId('content-download'))); await waitFor(() => expect(mockFetchCmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: contentSyncData.data.count })); }); it('learner data reporting modal calls fetchLearnerMetadataItemTransmission with extended page size', async () => { const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); mockFetchLmits.mockResolvedValue(learnerSyncData); + render( - + , ); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); await waitFor(() => userEvent.click(screen.getByTestId('learner-download'))); diff --git a/src/components/settings/SettingsLMSTab/ExistingCard.jsx b/src/components/settings/SettingsLMSTab/ExistingCard.jsx index 8b5c48c614..4c47019ba3 100644 --- a/src/components/settings/SettingsLMSTab/ExistingCard.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingCard.jsx @@ -1,8 +1,5 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { - useRouteMatch, -} from 'react-router-dom'; import { ActionRow, AlertModal, Badge, Button, Card, Dropdown, Icon, IconButton, Image, OverlayTrigger, Popover, } from '@edx/paragon'; @@ -15,14 +12,13 @@ import { channelMapping } from '../../../utils'; import handleErrors from '../utils'; import { getTimeAgo } from './ErrorReporting/utils'; -import { - ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE, - errorDeleteConfigModalText, errorToggleModalText, -} from '../data/constants'; +import { ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE } from '../data/constants'; -const INCOMPLETE = 'Incomplete'; -const ACTIVE = 'Active'; -const INACTIVE = 'Inactive'; +const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; +const errorDeleteModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; +const INCOMPLETE = 'incomplete'; +const ACTIVE = 'active'; +const INACTIVE = 'inactive'; const ExistingCard = ({ config, @@ -30,13 +26,19 @@ const ExistingCard = ({ enterpriseCustomerUuid, onClick, openError, + openReport, + setReportConfig, setErrorModalText, getStatus, }) => { - const redirectPath = `${useRouteMatch().url}`; const [showDeleteModal, setShowDeleteModal] = useState(false); const isEdxStaff = getAuthenticatedUser().administrator; + const openModalButton = () => { + setReportConfig(config); + openReport(); + }; + const toggleConfig = async (id, channelType, toggle) => { const configOptions = { active: toggle, @@ -64,7 +66,7 @@ const ExistingCard = ({ err = handleErrors(error); } if (err) { - setErrorModalText(errorDeleteConfigModalText); + setErrorModalText(errorDeleteModalText); openError(); } else { onClick(DELETE_TOAST_MESSAGE); @@ -102,7 +104,7 @@ const ExistingCard = ({ switch (getStatus(config)) { case ACTIVE: if (isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) { - return ; + return ; } return null; case INCOMPLETE: @@ -183,7 +185,7 @@ const ExistingCard = ({ {(isInactive && isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) && (
openModalButton(config)} data-testid="dropdown-sync-history-item" > View sync history @@ -203,7 +205,7 @@ const ExistingCard = ({ {(isInactive || isIncomplete) && (
handleClickDelete(isInactive)} data-testid="dropdown-delete-item" > @@ -291,6 +293,8 @@ ExistingCard.propTypes = { enterpriseCustomerUuid: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, openError: PropTypes.func.isRequired, + openReport: PropTypes.func.isRequired, + setReportConfig: PropTypes.func.isRequired, setErrorModalText: PropTypes.func.isRequired, getStatus: PropTypes.func.isRequired, }; diff --git a/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx b/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx index a34236d0db..8c0e0d4689 100644 --- a/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx @@ -1,10 +1,11 @@ import React, { useState } from 'react'; +import isEmpty from 'lodash/isEmpty'; import PropTypes from 'prop-types'; import { CardGrid, useToggle, } from '@edx/paragon'; -import { getStatus } from './utils'; import ConfigError from '../ConfigError'; +import ErrorReportingModal from './ErrorReporting/ErrorReportingModal'; import ExistingCard from './ExistingCard'; const ExistingLMSCardDeck = ({ @@ -14,10 +15,29 @@ const ExistingLMSCardDeck = ({ onClick, }) => { const [errorIsOpen, openError, closeError] = useToggle(false); + const [errorReportIsOpen, openReport, closeReport] = useToggle(false); + const [reportConfig, setReportConfig] = useState(); const [errorModalText, setErrorModalText] = useState(); // Map the existing config data to individual cards - const listActive = configData.filter(config => getStatus(config) === 'Active').map(config => ( + + const getStatus = (config) => { + const INCOMPLETE = 'incomplete'; + const ACTIVE = 'active'; + const INACTIVE = 'inactive'; + if (!isEmpty(config.isValid[0].missing) + || !isEmpty(config.isValid[1].incorrect)) { + return INCOMPLETE; + } + + if (config.active) { + return ACTIVE; + } + return INACTIVE; + }; + + // const listItems = timeSort(configData).map((config) => ( + const listActive = configData.filter(config => getStatus(config) === 'active').map(config => ( )); - const listInactive = configData.filter(config => getStatus(config) !== 'Active').map(config => ( + const listInactive = configData.filter(config => getStatus(config) !== 'active').map(config => ( @@ -49,6 +73,12 @@ const ExistingLMSCardDeck = ({ close={closeError} configTextOverride={errorModalText} /> + { listActive.length > 0 && ( <>

Active

diff --git a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx index 6885627a5b..b728959a28 100644 --- a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx @@ -12,13 +12,6 @@ import { features } from '../../../../config'; jest.mock('../../../../data/services/LmsApiService'); -jest.mock('react-router', () => ({ - ...jest.requireActual('react-router'), - useRouteMatch: () => ({ - url: 'https://www.test.com/', - }), -})); - const enterpriseCustomerUuid = 'test-enterprise-id'; const mockEditExistingConfigFn = jest.fn(); const mockOnClick = jest.fn(); @@ -363,19 +356,4 @@ describe('', () => { ); expect(screen.queryByText('View sync history')).not.toBeInTheDocument(); }); - it('viewing sync history redirects to detail page', () => { - getAuthenticatedUser.mockReturnValue({ - administrator: true, - }); - render( - , - ); - const link = 'https://www.test.com/BLACKBOARD/1'; - expect(screen.getByText('View sync history')).toHaveAttribute('href', link); - }); }); diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index ad56bc5429..3400b30ed2 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -15,6 +15,7 @@ import { BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, + DEGREED_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, @@ -139,8 +140,8 @@ describe('', () => { expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - fireEvent.click(degreedCard); - expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); + userEvent.click(degreedCard); + expect(screen.queryByText('Connect Degreed')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -215,8 +216,8 @@ describe('', () => { expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - await waitFor(() => fireEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); + await waitFor(() => userEvent.click(degreedCard)); + expect(screen.queryByText('Connect Degreed')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); @@ -227,15 +228,15 @@ describe('', () => { const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { - expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); + expect(screen.findByText(channelMapping[DEGREED_TYPE].displayName)); }); - const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - await waitFor(() => fireEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); + const degreedCard = screen.getByText(channelMapping[DEGREED_TYPE].displayName); + await waitFor(() => userEvent.click(degreedCard)); + expect(screen.queryByText('Connect Degreed')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Degreed2')).toBeFalsy(); + expect(screen.queryByText('Connect Degreed')).toBeFalsy(); }); test('No action Cornerstone card cancel flow', async () => { renderWithRouter(); diff --git a/src/components/settings/SettingsLMSTab/utils.js b/src/components/settings/SettingsLMSTab/utils.js index 6781d80d44..4b8c226133 100644 --- a/src/components/settings/SettingsLMSTab/utils.js +++ b/src/components/settings/SettingsLMSTab/utils.js @@ -1,5 +1,3 @@ -import isEmpty from 'lodash/isEmpty'; - export default function buttonBool(config) { let returnVal = true; Object.entries(config).forEach(entry => { @@ -20,12 +18,3 @@ export const isExistingConfig = (configs, value, existingInput) => { } return false; }; - -export const getStatus = (config) => { - // config.isValid has two arrays of missing and incorrect config fields - // which are required to resolve in order to complete the configuration - if (!isEmpty([...config.isValid[0].missing, ...config.isValid[1].incorrect])) { - return 'Incomplete'; - } - return config.active ? 'Active' : 'Inactive'; -}; diff --git a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx index 001ef8d47e..832564cdad 100644 --- a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx +++ b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx @@ -11,7 +11,10 @@ import { SSOConfigContext } from './SSOConfigContext'; import ConfigError from '../ConfigError'; import handleErrors from '../utils'; import LmsApiService from '../../../data/services/LmsApiService'; -import { errorToggleModalText, errorDeleteConfigModalText, errorDeleteDataModalText } from '../data/constants'; + +const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; +const errorDeleteConfigModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; +const errorDeleteDataModalText = 'We were unable to delete your provider data. Please try removing again or contact support for help.'; const ExistingSSOConfigs = ({ configs, refreshBool, setRefreshBool, enterpriseId, providerData, diff --git a/src/components/settings/data/constants.js b/src/components/settings/data/constants.js index c2d99b0e71..0720742e41 100644 --- a/src/components/settings/data/constants.js +++ b/src/components/settings/data/constants.js @@ -20,10 +20,6 @@ export const DELETE_TOAST_MESSAGE = 'Learning platform integration successfully export const INACTIVATE_TOAST_MESSAGE = 'Learning platform integration successfully disabled.'; export const SUBMIT_TOAST_MESSAGE = 'Learning platform integration successfully submitted.'; -export const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; -export const errorDeleteConfigModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; -export const errorDeleteDataModalText = 'We were unable to delete your provider data. Please try removing again or contact support for help.'; - export const BLACKBOARD_TYPE = 'BLACKBOARD'; export const CANVAS_TYPE = 'CANVAS'; export const CORNERSTONE_TYPE = 'CSOD'; diff --git a/src/components/settings/index.jsx b/src/components/settings/index.jsx index 5c48e8cc40..fa94500dc0 100644 --- a/src/components/settings/index.jsx +++ b/src/components/settings/index.jsx @@ -14,7 +14,6 @@ import { SETTINGS_PARAM_MATCH, } from './data/constants'; import SettingsTabs from './SettingsTabs'; -import SyncHistory from './SettingsLMSTab/ErrorReporting/SyncHistory'; const PAGE_TILE = 'Settings'; @@ -39,10 +38,6 @@ const SettingsPage = () => { path={`${path}/${SETTINGS_PARAM_MATCH}`} component={SettingsTabs} /> - diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 8a2fb6c96e..fb8772ce08 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -127,24 +127,16 @@ class LmsApiService { return LmsApiService.apiClient().post(LmsApiService.providerDataSyncUrl, formData); } - static fetchBlackboardGlobalConfig() { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/global-configuration/`); - } - - static fetchSingleBlackboardConfig(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); - } - - static postNewBlackboardConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/`, formData); + static postNewMoodleConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/`, formData); } - static updateBlackboardConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`, formData); + static updateMoodleConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`, formData); } - static deleteBlackboardConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); + static deleteMoodleConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); } static fetchSingleCanvasConfig(configId) { @@ -163,28 +155,40 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/canvas/configuration/${configId}/`); } - static fetchSingleCornerstoneConfig(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); + static fetchBlackboardGlobalConfig() { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/global-configuration/`); } - static postNewCornerstoneConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/`, formData); + static fetchSingleBlackboardConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); } - static updateCornerstoneConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`, formData); + static postNewBlackboardConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/`, formData); } - static deleteCornerstoneConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); + static updateBlackboardConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`, formData); } - static postNewDegreedConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/`, formData); + static deleteBlackboardConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); + } + + static postNewSuccessFactorsConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/`, formData); + } + + static updateSuccessFactorsConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`, formData); } - static fetchSingleDegreedConfig(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`); + static deleteSuccessFactorsConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); + } + + static postNewDegreedConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/`, formData); } static updateDegreedConfig(formData, configId) { @@ -195,10 +199,6 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`); } - static fetchSingleDegreed2Config(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/${configId}/`); - } - static postNewDegreed2Config(formData) { return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/`, formData); } @@ -211,36 +211,16 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/${configId}/`); } - static fetchSingleMoodleConfig(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); - } - - static postNewMoodleConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/`, formData); - } - - static updateMoodleConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`, formData); - } - - static deleteMoodleConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); - } - - static fetchSingleSuccessFactorsConfig(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); - } - - static postNewSuccessFactorsConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/`, formData); + static postNewCornerstoneConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/`, formData); } - static updateSuccessFactorsConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`, formData); + static updateCornerstoneConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`, formData); } - static deleteSuccessFactorsConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); + static deleteCornerstoneConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); } static createPendingEnterpriseUsers(formData, uuid) { diff --git a/src/setupTest.js b/src/setupTest.js index ba4024d5c1..92011cdf9e 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -6,6 +6,7 @@ import Adapter from 'enzyme-adapter-react-16'; import MockAdapter from 'axios-mock-adapter'; import ResizeObserverPolyfill from 'resize-observer-polyfill'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import 'jest-canvas-mock'; import 'jest-localstorage-mock'; Enzyme.configure({ adapter: new Adapter() }); diff --git a/src/utils.js b/src/utils.js index b6892bdbcd..d427269ce2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -260,18 +260,6 @@ const channelMapping = { update: LmsApiService.updateCornerstoneConfig, delete: LmsApiService.deleteCornerstoneConfig, }, - [DEGREED_TYPE]: { - displayName: 'Degreed', - icon: DegreedIcon, - update: LmsApiService.updateDegreedConfig, - delete: LmsApiService.deleteDegreedConfig, - }, - [DEGREED2_TYPE]: { - displayName: 'Degreed2', - icon: DegreedIcon, - update: LmsApiService.updateDegreed2Config, - delete: LmsApiService.deleteDegreed2Config, - }, [MOODLE_TYPE]: { displayName: 'Moodle', icon: MoodleIcon, @@ -284,6 +272,18 @@ const channelMapping = { update: LmsApiService.updateSuccessFactorsConfig, delete: LmsApiService.deleteSuccessFactorsConfig, }, + [DEGREED2_TYPE]: { + displayName: 'Degreed', + icon: DegreedIcon, + update: LmsApiService.updateDegreed2Config, + delete: LmsApiService.deleteDegreed2Config, + }, + [DEGREED_TYPE]: { + displayName: 'Degreed', + icon: DegreedIcon, + update: LmsApiService.updateDegreedConfig, + delete: LmsApiService.deleteDegreedConfig, + }, }; const capitalizeFirstLetter = string => string.charAt(0).toUpperCase() + string.slice(1); From 0db0d166926765fdb6efece996063588b256b683 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Mon, 9 Jan 2023 19:33:46 -0500 Subject: [PATCH 29/73] fix: bulk enrollment API call on submission (#945) --- src/components/BulkEnrollmentPage/CourseSearchResults.jsx | 1 + .../BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx | 2 +- .../BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx index 673e7cbef9..c96cf96dd8 100644 --- a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx +++ b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx @@ -118,6 +118,7 @@ export const BaseCourseSearchResults = (props) => { id: rowId, values: { aggregationKey: rowId, + contentKey: rowId.split(':')[1], }, })); coursesDispatch(setSelectedRowsAction(transformedSelectedFlatRowIds)); diff --git a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx index 09791d1464..1d5d523721 100644 --- a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx +++ b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx @@ -93,7 +93,7 @@ const BulkEnrollmentSubmit = ({ const [toastMessage, setToastMessage] = useState(''); const courseKeys = selectedCourses.map( - ({ original, id }) => original?.advertised_course_run?.key || id, + ({ values }) => values.contentKey, ); const emails = selectedEmails.map(({ values }) => values.userEmail); const [isErrorModalOpen, toggleErrorModalOpen, toggleErrorModalClose] = useToggle(); diff --git a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx index 92f0191f7c..423b8f6cf4 100644 --- a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx +++ b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx @@ -63,7 +63,7 @@ const selectedEmails = userEmails.map( (email, index) => ({ id: index, values: { userEmail: email } }), ); const selectedCourses = courseNames.map( - (course) => ({ id: course }), + (course) => ({ values: { contentKey: course } }), ); const bulkEnrollWithAllSelectedRows = { emails: [selectedEmails, emailsDispatch], From db54bbe5b98eb68bbca1a27f0fe953d4fa0eeb28 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Tue, 10 Jan 2023 11:24:16 -0500 Subject: [PATCH 30/73] fix: pass course run id instead of key for bulk enroll (#946) --- __mocks__/react-instantsearch-dom.jsx | 4 +- .../CourseSearchResults.jsx | 1 - .../stepper/BulkEnrollmentSubmit.jsx | 3 +- .../stepper/BulkEnrollmentSubmit.test.jsx | 95 ++++++++++--------- .../stepper/ReviewStepCourseList.jsx | 38 +++++--- .../stepper/ReviewStepCourseList.test.jsx | 22 +++++ 6 files changed, 101 insertions(+), 62 deletions(-) diff --git a/__mocks__/react-instantsearch-dom.jsx b/__mocks__/react-instantsearch-dom.jsx index e7951f7179..56f4d71b7c 100644 --- a/__mocks__/react-instantsearch-dom.jsx +++ b/__mocks__/react-instantsearch-dom.jsx @@ -15,8 +15,8 @@ const advertised_course_run = { /* eslint-disable camelcase */ const fakeHits = [ - { objectID: '1', title: 'bla', advertised_course_run, key: 'Bees101' }, - { objectID: '2', title: 'blp', advertised_course_run, key: 'Wasps200' }, + { objectID: '1', aggregation_key: 'course:Bees101', title: 'bla', advertised_course_run, key: 'Bees101' }, + { objectID: '2', aggregation_key: 'course:Wasps200', title: 'blp', advertised_course_run, key: 'Wasps200' }, ]; /* eslint-enable camelcase */ diff --git a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx index c96cf96dd8..673e7cbef9 100644 --- a/src/components/BulkEnrollmentPage/CourseSearchResults.jsx +++ b/src/components/BulkEnrollmentPage/CourseSearchResults.jsx @@ -118,7 +118,6 @@ export const BaseCourseSearchResults = (props) => { id: rowId, values: { aggregationKey: rowId, - contentKey: rowId.split(':')[1], }, })); coursesDispatch(setSelectedRowsAction(transformedSelectedFlatRowIds)); diff --git a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx index 1d5d523721..874d0ba662 100644 --- a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx +++ b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.jsx @@ -93,8 +93,9 @@ const BulkEnrollmentSubmit = ({ const [toastMessage, setToastMessage] = useState(''); const courseKeys = selectedCourses.map( - ({ values }) => values.contentKey, + ({ values, id }) => values.advertisedCourseRun?.key || id, ); + const emails = selectedEmails.map(({ values }) => values.userEmail); const [isErrorModalOpen, toggleErrorModalOpen, toggleErrorModalClose] = useToggle(); const hasSelectedCoursesAndEmails = selectedEmails.length > 0 && selectedCourses.length > 0; diff --git a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx index 423b8f6cf4..58a42934cf 100644 --- a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx +++ b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx @@ -63,7 +63,14 @@ const selectedEmails = userEmails.map( (email, index) => ({ id: index, values: { userEmail: email } }), ); const selectedCourses = courseNames.map( - (course) => ({ values: { contentKey: course } }), + (course) => ({ + values: { + contentKey: course, + advertisedCourseRun: { + key: course, + }, + }, + }), ); const bulkEnrollWithAllSelectedRows = { emails: [selectedEmails, emailsDispatch], @@ -165,8 +172,7 @@ describe('BulkEnrollmentSubmit', () => { }); it('tests button is disabled when courses are not selected but emails are', async () => { - const mockPromiseResolve = Promise.resolve({ data: {} }); - LicenseManagerApiService.licenseBulkEnroll.mockReturnValue(mockPromiseResolve); + LicenseManagerApiService.licenseBulkEnroll.mockResolvedValue({ data: {} }); render( { ); const button = screen.getByTestId(FINAL_BUTTON_TEST_ID); expect(button).toBeDisabled(); - await act(() => mockPromiseResolve); }); it('tests button is disabled when emails are not selected but courses are', async () => { - const mockPromiseResolve = Promise.resolve({ data: {} }); - LicenseManagerApiService.licenseBulkEnroll.mockReturnValue(mockPromiseResolve); + LicenseManagerApiService.licenseBulkEnroll.mockResolvedValue({ data: {} }); render( { ); const button = screen.getByTestId(FINAL_BUTTON_TEST_ID); expect(button).toBeDisabled(); - await act(() => mockPromiseResolve); }); it('tests passing correct data to api call', async () => { - const mockPromiseResolve = Promise.resolve({ data: {} }); - LicenseManagerApiService.licenseBulkEnroll.mockReturnValue(mockPromiseResolve); + LicenseManagerApiService.licenseBulkEnroll.mockResolvedValue({ data: {} }); render( { notify: true, }; - expect(LicenseManagerApiService.licenseBulkEnroll).toHaveBeenCalledWith( - defaultProps.enterpriseId, - defaultProps.subscription.uuid, - expectedParams, - ); - expect(logError).toBeCalledTimes(0); - await act(() => mockPromiseResolve); + await waitFor(() => { + expect(LicenseManagerApiService.licenseBulkEnroll).toHaveBeenCalledWith( + defaultProps.enterpriseId, + defaultProps.subscription.uuid, + expectedParams, + ); + expect(logError).toBeCalledTimes(0); + }); }); it('tests notify toggle disables param to api service', async () => { - const mockPromiseResolve = Promise.resolve({ data: {} }); - LicenseManagerApiService.licenseBulkEnroll.mockReturnValue(mockPromiseResolve); + LicenseManagerApiService.licenseBulkEnroll.mockResolvedValue({ data: {} }); render( { course_run_keys: courseNames, notify: false, }; - expect(LicenseManagerApiService.licenseBulkEnroll).toHaveBeenCalledWith( - defaultProps.enterpriseId, - defaultProps.subscription.uuid, - expectedParams, - ); - expect(logError).toBeCalledTimes(0); - await act(() => mockPromiseResolve); + + await waitFor(() => { + expect(LicenseManagerApiService.licenseBulkEnroll).toHaveBeenCalledWith( + defaultProps.enterpriseId, + defaultProps.subscription.uuid, + expectedParams, + ); + expect(logError).toBeCalledTimes(0); + }); }); it('test component clears selected emails and courses after successful submit', async () => { - const mockPromiseResolve = Promise.resolve({ data: {} }); - LicenseManagerApiService.licenseBulkEnroll.mockReturnValue(mockPromiseResolve); + LicenseManagerApiService.licenseBulkEnroll.mockResolvedValue({ data: {} }); render( { />, ); const button = screen.getByTestId(FINAL_BUTTON_TEST_ID); - await userEvent.click(button); - - expect(emailsDispatch).toBeCalledTimes(1); - expect(coursesDispatch).toBeCalledTimes(1); + userEvent.click(button); - expect(emailsDispatch).toHaveBeenCalledWith( - clearSelectionAction(), - ); - expect(coursesDispatch).toHaveBeenCalledWith( - clearSelectionAction(), - ); - expect(logError).toBeCalledTimes(0); - await act(() => mockPromiseResolve); + await waitFor(() => { + expect(emailsDispatch).toBeCalledTimes(1); + expect(coursesDispatch).toBeCalledTimes(1); + expect(emailsDispatch).toHaveBeenCalledWith( + clearSelectionAction(), + ); + expect(coursesDispatch).toHaveBeenCalledWith( + clearSelectionAction(), + ); + expect(logError).toBeCalledTimes(0); + }); }); it('tests component creates toast after successful submit', async () => { - const mockPromiseResolve = Promise.resolve({ data: {} }); - LicenseManagerApiService.licenseBulkEnroll.mockReturnValue(mockPromiseResolve); + LicenseManagerApiService.licenseBulkEnroll.mockResolvedValue({ data: {} }); render( { />, ); const button = screen.getByTestId(FINAL_BUTTON_TEST_ID); - await userEvent.click(button); + userEvent.click(button); - expect(logError).toBeCalledTimes(0); - expect(screen.getByText('been enrolled', { exact: false })).toBeInTheDocument(); - await act(() => mockPromiseResolve); + await waitFor(() => { + expect(logError).toBeCalledTimes(0); + expect(screen.getByText('been enrolled', { exact: false })).toBeInTheDocument(); + }); }); it('tests component logs error response on unsuccessful api call', async () => { @@ -361,7 +364,7 @@ describe('BulkEnrollmentSubmit', () => { />, ); const button = screen.getByTestId(FINAL_BUTTON_TEST_ID); - await userEvent.click(button); + userEvent.click(button); await waitFor(() => { expect(screen.getByText('been enrolled', { exact: false })).toBeInTheDocument(); expect(defaultProps.onEnrollComplete).toHaveBeenCalledTimes(1); diff --git a/src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.jsx b/src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.jsx index 95958a6922..0a8e218549 100644 --- a/src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.jsx +++ b/src/components/BulkEnrollmentPage/stepper/ReviewStepCourseList.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useMemo } from 'react'; +import React, { useContext, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import { InstantSearch, Configure, connectStateResults } from 'react-instantsearch-dom'; import algoliasearch from 'algoliasearch/lite'; @@ -7,6 +7,7 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { BulkEnrollContext } from '../BulkEnrollmentContext'; import ReviewList from './ReviewList'; import { configuration } from '../../../config'; +import { setSelectedRowsAction } from '../data/actions'; const COURSES = { singular: 'course', @@ -26,19 +27,32 @@ const BaseContentSelections = ({ returnToSelection, }) => { const { - courses: [, coursesDispatch], + courses: [selectedCourses, coursesDispatch], } = useContext(BulkEnrollContext); - const selectedRows = camelCaseObject(searchResults?.hits || []); - // NOTE: The current implementation of `ReviewItem` relies on the data schema - // from `DataTable` where each selected row has a `values` object containing - // the metadata about each row and an `id` field representing the - // `aggregationKey`. Transforming the data here allows us to avoid needing to - // modify `ReviewItem`. - const transformedSelectedRows = selectedRows.map((row) => ({ - values: row, - id: row.aggregationKey, - })); + const transformedSelectedRows = useMemo(() => { + const selectedRows = camelCaseObject(searchResults?.hits || []); + // NOTE: The current implementation of `ReviewItem` relies on the data schema + // from `DataTable` where each selected row has a `values` object containing + // the metadata about each row and an `id` field representing the + // `aggregationKey`. Transforming the data here allows us to avoid needing to + // modify `ReviewItem`. + return selectedRows.map((row) => ({ + values: row, + id: row.aggregationKey, + })); + }, [searchResults]); + + /** + * Update the data in the reducer keeping track of course selections + * with additional metadata for each selected row from Algolia. + */ + useEffect(() => { + if (transformedSelectedRows.length === 0) { + return; + } + coursesDispatch(setSelectedRowsAction(transformedSelectedRows)); + }, [coursesDispatch, transformedSelectedRows, selectedCourses]); return ( { expect(screen.getByTestId('algolia__Configure')).toBeInTheDocument(); expect(screen.getByTestId('review-list')).toBeInTheDocument(); }); + + it('updates `selectedCourses` in `BulkEnrollContext` with Algolia metadata', () => { + render(); + + expect(mockCoursesDispatch).toHaveBeenCalledTimes(1); + const expectedCourseSelection = { + id: expect.any(String), + values: expect.objectContaining({ + advertisedCourseRun: expect.any(Object), + aggregationKey: expect.any(String), + key: expect.any(String), + objectId: expect.any(String), + title: expect.any(String), + }), + }; + expect(mockCoursesDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: [expectedCourseSelection, expectedCourseSelection], + type: 'SET SELECTED ROWS', + }), + ); + }); }); describe('useSearchFiltersForSelectedCourses', () => { From 045a8269baa6984592ebc50e6e77d1df554559a1 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Tue, 10 Jan 2023 11:44:08 -0700 Subject: [PATCH 31/73] feat: Sync History page (#948) * feat!: draft PR of sync history page * fix: fixing tests * fix: PR requests * fix: more little changes * fix: lint errors * fix: remove configure button * fix: dependency fix --- .../ErrorReporting/ErrorReportingModal.jsx | 79 ------ .../ErrorReporting/ErrorReportingTable.jsx | 50 ++++ .../ErrorReporting/LearnerMetadataTable.jsx | 4 +- .../ErrorReporting/SyncHistory.jsx | 228 +++++++++++++++ .../tests/ErrorReporting.test.jsx | 259 ++++++++++-------- .../settings/SettingsLMSTab/ExistingCard.jsx | 34 +-- .../SettingsLMSTab/ExistingLMSCardDeck.jsx | 36 +-- .../tests/ExistingLMSCardDeck.test.jsx | 22 ++ .../tests/LmsConfigPage.test.jsx | 19 +- .../settings/SettingsLMSTab/utils.js | 11 + .../SettingsSSOTab/ExistingSSOConfigs.jsx | 5 +- src/components/settings/data/constants.js | 4 + src/components/settings/index.jsx | 5 + src/data/services/LmsApiService.js | 92 ++++--- src/setupTest.js | 1 - src/utils.js | 24 +- 16 files changed, 558 insertions(+), 315 deletions(-) delete mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx create mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx create mode 100644 src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx deleted file mode 100644 index ebf75b8f81..0000000000 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingModal.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { - ActionRow, ModalDialog, Tab, Tabs, -} from '@edx/paragon'; -import ContentMetadataTable from './ContentMetadataTable'; -import LearnerMetadataTable from './LearnerMetadataTable'; - -const ErrorReportingModal = ({ - isOpen, close, config, enterpriseCustomerUuid, -}) => { - const [key, setKey] = useState('contentMetadata'); - // notification for tab must be a non-empty string to appear - const contentError = config?.lastContentSyncErroredAt == null ? null : ' '; - const learnerError = config?.lastLearnerSyncErroredAt == null ? null : ' '; - return ( - - - - {config?.displayName} Sync History - - - - - setKey(k)} - className="mb-3" - > - -

Most recent data transmission

- From edX for Business to {config?.displayName} - -
- -

Most recent data transmission

- From edX for Business to {config?.displayName} - -
-
-
- - - - - Close - - - -
- ); -}; - -ErrorReportingModal.defaultProps = { - config: null, -}; - -ErrorReportingModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - close: PropTypes.func.isRequired, - config: PropTypes.shape({ - id: PropTypes.number, - channelCode: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - lastContentSyncErroredAt: PropTypes.string.isRequired, - lastLearnerSyncErroredAt: PropTypes.string.isRequired, - }), - enterpriseCustomerUuid: PropTypes.string.isRequired, -}; - -export default ErrorReportingModal; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx new file mode 100644 index 0000000000..d2027111d8 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/ErrorReportingTable.jsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Tab, Tabs, +} from '@edx/paragon'; +import ContentMetadataTable from './ContentMetadataTable'; +import LearnerMetadataTable from './LearnerMetadataTable'; + +const ErrorReportingTable = ({ config }) => { + const [key, setKey] = useState('contentMetadata'); + // notification for tab must be a non-empty string to appear + const contentError = config.lastContentSyncErroredAt == null ? null : ' '; + const learnerError = config.lastLearnerSyncErroredAt == null ? null : ' '; + const enterpriseCustomerUuid = config.enterpriseCustomer; + return ( + <> +

Sync History

+ setKey(k)} + className="mb-3" + > + +

Most recent data transmission

+ From edX for Business to {config.displayName} + +
+ +

Most recent data transmission

+ From edX for Business to {config.displayName} + +
+
+ + ); +}; + +ErrorReportingTable.propTypes = { + config: PropTypes.shape({ + id: PropTypes.number, + channelCode: PropTypes.string, + displayName: PropTypes.string, + enterpriseCustomer: PropTypes.string, + lastContentSyncErroredAt: PropTypes.string, + lastLearnerSyncErroredAt: PropTypes.string, + }).isRequired, +}; + +export default ErrorReportingTable; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx index 285699477d..4570cafb27 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/LearnerMetadataTable.jsx @@ -6,6 +6,7 @@ import { logError } from '@edx/frontend-platform/logging'; import LmsApiService from '../../../../data/services/LmsApiService'; import { createLookup, getSyncStatus, getTimeAgo } from './utils'; import DownloadCsvButton from './DownloadCsvButton'; +import { CORNERSTONE_TYPE } from '../../data/constants'; const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { const [currentPage, setCurrentPage] = useState(); @@ -20,9 +21,10 @@ const LearnerMetadataTable = ({ config, enterpriseCustomerUuid }) => { useEffect(() => { const fetchData = async () => { + const correctedChannelCode = config.channelCode === CORNERSTONE_TYPE ? 'cornerstone' : config.channelCode; const response = await LmsApiService.fetchLearnerMetadataItemTransmission( enterpriseCustomerUuid, - config.channelCode, + correctedChannelCode, config.id, currentPage, currentFilters, diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx new file mode 100644 index 0000000000..fffddb2104 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx @@ -0,0 +1,228 @@ +import React, { useEffect, useState } from 'react'; + +import { camelCaseObject } from '@edx/frontend-platform/utils'; +import { + ActionRow, AlertModal, Breadcrumb, Button, Card, Icon, Image, Skeleton, Toast, useToggle, +} from '@edx/paragon'; +import { CheckCircle, Error, Sync } from '@edx/paragon/icons'; +import { getStatus } from '../utils'; +import { getTimeAgo } from './utils'; +import handleErrors from '../../utils'; +import ConfigError from '../../ConfigError'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +import { + ACTIVATE_TOAST_MESSAGE, BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED_TYPE, + DEGREED2_TYPE, errorToggleModalText, INACTIVATE_TOAST_MESSAGE, MOODLE_TYPE, SAP_TYPE, +} from '../../data/constants'; + +import { channelMapping } from '../../../../utils'; +import ErrorReportingTable from './ErrorReportingTable'; + +const SyncHistory = () => { + const vars = (window.location.pathname).split('lms/'); + const redirectPath = `${vars[0]}lms/`; + const configInfo = vars[1].split('/'); + const configChannel = configInfo[0]; + const configId = configInfo[1]; + + const [config, setConfig] = useState(); + const [errorModalText, setErrorModalText] = useState(); + const [errorIsOpen, openError, closeError] = useToggle(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [toastMessage, setToastMessage] = useState(null); + const [reloadPage, setReloadPage] = useState(false); + + const getActiveStatus = status => (status === 'Active' ? `${status} •` : ''); + + useEffect(() => { + const fetchData = async () => { + let response; + switch (configChannel) { + case BLACKBOARD_TYPE: + response = await LmsApiService.fetchSingleBlackboardConfig(configId); break; + case CANVAS_TYPE: + response = await LmsApiService.fetchSingleCanvasConfig(configId); break; + case CORNERSTONE_TYPE: + response = await LmsApiService.fetchSingleCornerstoneConfig(configId); break; + case DEGREED_TYPE: + response = await LmsApiService.fetchSingleDegreedConfig(configId); break; + case DEGREED2_TYPE: + response = await LmsApiService.fetchSingleDegreed2Config(configId); break; + case MOODLE_TYPE: + response = await LmsApiService.fetchSingleMoodleConfig(configId); break; + case SAP_TYPE: + response = await LmsApiService.fetchSingleSuccessFactorsConfig(configId); break; + default: + break; + } + return camelCaseObject(response.data); + }; + fetchData() + .then((response) => { + setConfig(response); + }) + .catch((error) => { + handleErrors(error); + }); + }, [configChannel, configId, reloadPage]); + + const getLastSync = () => { + if (config.lastSyncErroredAt != null) { + const timeStamp = getTimeAgo(config.lastSyncErroredAt); + return ( + + Recent sync error:  {timeStamp} + + + + ); + } + if (config.lastSyncAttemptedAt != null) { + const timeStamp = getTimeAgo(config.lastSyncAttemptedAt); + return ( + + Last sync:  {timeStamp} + + + + ); + } + return null; + }; + + const onClick = (input) => { + // if configuration is being toggled + if (input !== null) { + setReloadPage(true); + setToastMessage(input); + // if configuration is being deleted + } else { + window.location.href = redirectPath; + } + }; + + const toggleConfig = async (toggle) => { + const configOptions = { + active: toggle, + enterprise_customer: config.enterpriseCustomer, + }; + let err; + try { + await channelMapping[config.channelCode].update(configOptions, config.id); + } catch (error) { + err = handleErrors(error); + } + if (err) { + setErrorModalText(errorToggleModalText); + openError(); + } else { + onClick(toggle ? ACTIVATE_TOAST_MESSAGE : INACTIVATE_TOAST_MESSAGE); + } + }; + + const createActionRow = () => { + if (getStatus(config) === 'Active') { + return ( + + + {/* */} + + ); + } + if (getStatus(config) === 'Inactive') { + return ( + + + {/* */} + + + ); + } + return ( // if incomplete + + + {/* */} + + ); + }; + + return ( +
+ setShowDeleteModal(false)} + hasCloseButton + footerNode={( + + + + + )} + > +

+ Are you sure you want to delete this learning platform integration? + Once deleted, any saved integration data will be lost. +

+
+ + {!config && ( + + )} + {config && ( + <> + + + +

+ + {config.displayName} +

+

+ {getActiveStatus(getStatus(config))} {config.channelCode} +

+
+ {getLastSync()} +
+ + + )} + {toastMessage && ( + setToastMessage(null)} show={toastMessage !== null}>{toastMessage} + )} +
+ ); +}; + +export default SyncHistory; diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx index da6973bb49..4db1165e77 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { - act, fireEvent, render, screen, waitFor, + act, cleanup, fireEvent, render, screen, waitFor, waitForElementToBeRemoved, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -8,28 +8,33 @@ import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import ExistingLMSCardDeck from '../../ExistingLMSCardDeck'; import LmsApiService from '../../../../../data/services/LmsApiService'; import { features } from '../../../../../config'; +import SyncHistory from '../SyncHistory'; const enterpriseCustomerUuid = 'test-enterprise-id'; -const mockEditExistingConfigFn = jest.fn(); -const mockOnClick = jest.fn(); // file-saver mocks jest.mock('file-saver', () => ({ saveAs: jest.fn() })); // eslint-disable-next-line func-names global.Blob = function (content, options) { return ({ content, options }); }; -const configData = [ - { +const configData = { + data: { channelCode: 'BLACKBOARD', id: 1, isValid: [{ missing: [] }, { incorrect: [] }], active: true, displayName: 'foobar', + enterpriseCustomer: enterpriseCustomerUuid, + lastSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastContentSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, }, -]; +}; const contentSyncData = { data: { @@ -205,48 +210,140 @@ const learnerSyncData = { describe('', () => { beforeEach(() => { - jest.resetAllMocks(); getAuthenticatedUser.mockReturnValue({ administrator: true, }); features.FEATURE_INTEGRATION_REPORTING = true; + const url = 'http://dummy.com/test-enterprise/admin/settings/lms'; + Object.defineProperty(window, 'location', { + value: { + pathname: `${url}/${configData.data.channelCode}/${configData.data.id}`, + }, + writable: true, + }); }); - it('opens error reporting modal', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + it('basic lms config detail screen', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); render( - + , ); - userEvent.click(screen.queryByText('View sync history')); - expect(screen.getByText('foobar Sync History')).toBeInTheDocument(); - expect(screen.getAllByText('Course')).toHaveLength(2); - expect(screen.getByText('Course key')).toBeInTheDocument(); + + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); + expect(screen.getByText('Disable')).toBeInTheDocument(); + expect(screen.getByText('Last sync:')).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText('Course key')).toBeInTheDocument()); expect(screen.getAllByText('Sync status')).toHaveLength(2); expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); expect(screen.getAllByText('No results found')).toHaveLength(2); }); - it('populates with content sync data', async () => { + it('populates with learner sync data', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + + expect(screen.getByText('Learner email')).toBeInTheDocument(); + expect(screen.getAllByText('Course')).toHaveLength(2); + expect(screen.getByText('Completion status')).toBeInTheDocument(); + expect(screen.getAllByText('Sync status')).toHaveLength(2); + expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); + + await waitFor(() => expect(screen.getByText('its LEARNING!')).toBeInTheDocument()); + expect(screen.getByText('In progress')).toBeInTheDocument(); + + expect(screen.getByText('spooooky')).toBeInTheDocument(); + expect(screen.getByText('Passed')).toBeInTheDocument(); + await waitFor(() => userEvent.click(screen.queryAllByText('Read')[1])); + expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument(); + }); + it('paginates over learner data', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + await waitFor(() => expect(screen.getByText('spooooky')).toBeInTheDocument()); + expect(screen.getAllByLabelText('Next, Page 2')[1]).not.toBeDisabled(); + act(() => { + fireEvent.click(screen.getAllByLabelText('Next, Page 2')[1]); + }); + await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, 1, {})); + }); + it('metadata data reporting modal calls fetchContentMetadataItemTransmission with extended page size', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); mockFetchCmits.mockResolvedValue(contentSyncData); render( - + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.getByTestId('content-download'))); + await waitFor(() => expect(mockFetchCmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: contentSyncData.data.count })); + }); + it('learner data reporting modal calls fetchLearnerMetadataItemTransmission with extended page size', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); + mockFetchLmits.mockResolvedValue(learnerSyncData); + + render( + + , ); - userEvent.click(screen.queryByText('View sync history')); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); + await waitFor(() => userEvent.click(screen.getByTestId('learner-download'))); + await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: learnerSyncData.data.count })); + }); + it('populates with content sync data', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configData); + const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); + mockFetchCmits.mockResolvedValue(contentSyncData); + render( + + + , + ); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); expect(screen.getByText('Demo1')).toBeInTheDocument(); @@ -272,15 +369,11 @@ describe('', () => { render( - + , ); - userEvent.click(screen.queryByText('View sync history')); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); expect(screen.getAllByLabelText('Next, Page 2')[0]).not.toBeDisabled(); @@ -296,17 +389,12 @@ describe('', () => { render( - + , ); - userEvent.click(screen.queryByText('View sync history')); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); - await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); fireEvent.change(screen.getByLabelText('Search course key'), { target: { value: 'ayylmao' }, }); @@ -319,16 +407,11 @@ describe('', () => { render( - + , ); - userEvent.click(screen.queryByText('View sync history')); - await waitFor(() => expect(screen.getByText('Demo1')).toBeInTheDocument()); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); // Expect to be in the default state expect(screen.getAllByText('Download history')).toHaveLength(2); @@ -341,93 +424,29 @@ describe('', () => { // Expect to have updated the state to complete expect(screen.queryByText('Downloaded')).toBeInTheDocument(); }); - it('populates with learner sync data', async () => { - const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); - mockFetchLmits.mockResolvedValue(learnerSyncData); - - render( - - - , - ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); - await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); - - expect(screen.getByText('Learner email')).toBeInTheDocument(); - expect(screen.getAllByText('Course')).toHaveLength(2); - expect(screen.getByText('Completion status')).toBeInTheDocument(); - expect(screen.getAllByText('Sync status')).toHaveLength(2); - expect(screen.getAllByText('Sync attempt time')).toHaveLength(2); - - await waitFor(() => expect(screen.getByText('its LEARNING!')).toBeInTheDocument()); - expect(screen.getByText('In progress')).toBeInTheDocument(); - - expect(screen.getByText('spooooky')).toBeInTheDocument(); - expect(screen.getByText('Passed')).toBeInTheDocument(); - await waitFor(() => userEvent.click(screen.queryAllByText('Read')[1])); - expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument(); - }); - it('paginates over learner data', async () => { - const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); - mockFetchLmits.mockResolvedValue(learnerSyncData); - - render( - - - , - ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); - await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); - await waitFor(() => expect(screen.getByText('spooooky')).toBeInTheDocument()); - expect(screen.getAllByLabelText('Next, Page 2')[1]).not.toBeDisabled(); - act(() => { - userEvent.click(screen.getAllByLabelText('Next, Page 2')[1]); - }); - await waitFor(() => expect(mockFetchLmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, 1, {})); - }); it('metadata data reporting modal calls fetchContentMetadataItemTransmission with extended page size', async () => { const mockFetchCmits = jest.spyOn(LmsApiService, 'fetchContentMetadataItemTransmission'); mockFetchCmits.mockResolvedValue(contentSyncData); - render( - + , ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); await waitFor(() => userEvent.click(screen.getByTestId('content-download'))); await waitFor(() => expect(mockFetchCmits).toBeCalledWith('test-enterprise-id', 'BLACKBOARD', 1, false, { page_size: contentSyncData.data.count })); }); it('learner data reporting modal calls fetchLearnerMetadataItemTransmission with extended page size', async () => { const mockFetchLmits = jest.spyOn(LmsApiService, 'fetchLearnerMetadataItemTransmission'); mockFetchLmits.mockResolvedValue(learnerSyncData); - render( - + , ); - await waitFor(() => userEvent.click(screen.queryByText('View sync history'))); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); await waitFor(() => userEvent.click(screen.queryByText('Learner Activity'))); await waitFor(() => userEvent.click(screen.getByTestId('learner-download'))); diff --git a/src/components/settings/SettingsLMSTab/ExistingCard.jsx b/src/components/settings/SettingsLMSTab/ExistingCard.jsx index 4c47019ba3..8b5c48c614 100644 --- a/src/components/settings/SettingsLMSTab/ExistingCard.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingCard.jsx @@ -1,5 +1,8 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { + useRouteMatch, +} from 'react-router-dom'; import { ActionRow, AlertModal, Badge, Button, Card, Dropdown, Icon, IconButton, Image, OverlayTrigger, Popover, } from '@edx/paragon'; @@ -12,13 +15,14 @@ import { channelMapping } from '../../../utils'; import handleErrors from '../utils'; import { getTimeAgo } from './ErrorReporting/utils'; -import { ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE } from '../data/constants'; +import { + ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE, + errorDeleteConfigModalText, errorToggleModalText, +} from '../data/constants'; -const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; -const errorDeleteModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; -const INCOMPLETE = 'incomplete'; -const ACTIVE = 'active'; -const INACTIVE = 'inactive'; +const INCOMPLETE = 'Incomplete'; +const ACTIVE = 'Active'; +const INACTIVE = 'Inactive'; const ExistingCard = ({ config, @@ -26,19 +30,13 @@ const ExistingCard = ({ enterpriseCustomerUuid, onClick, openError, - openReport, - setReportConfig, setErrorModalText, getStatus, }) => { + const redirectPath = `${useRouteMatch().url}`; const [showDeleteModal, setShowDeleteModal] = useState(false); const isEdxStaff = getAuthenticatedUser().administrator; - const openModalButton = () => { - setReportConfig(config); - openReport(); - }; - const toggleConfig = async (id, channelType, toggle) => { const configOptions = { active: toggle, @@ -66,7 +64,7 @@ const ExistingCard = ({ err = handleErrors(error); } if (err) { - setErrorModalText(errorDeleteModalText); + setErrorModalText(errorDeleteConfigModalText); openError(); } else { onClick(DELETE_TOAST_MESSAGE); @@ -104,7 +102,7 @@ const ExistingCard = ({ switch (getStatus(config)) { case ACTIVE: if (isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) { - return ; + return ; } return null; case INCOMPLETE: @@ -185,7 +183,7 @@ const ExistingCard = ({ {(isInactive && isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) && (
openModalButton(config)} + href={`${redirectPath}${config.channelCode}/${config.id}`} data-testid="dropdown-sync-history-item" > View sync history @@ -205,7 +203,7 @@ const ExistingCard = ({ {(isInactive || isIncomplete) && (
handleClickDelete(isInactive)} data-testid="dropdown-delete-item" > @@ -293,8 +291,6 @@ ExistingCard.propTypes = { enterpriseCustomerUuid: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, openError: PropTypes.func.isRequired, - openReport: PropTypes.func.isRequired, - setReportConfig: PropTypes.func.isRequired, setErrorModalText: PropTypes.func.isRequired, getStatus: PropTypes.func.isRequired, }; diff --git a/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx b/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx index 8c0e0d4689..a34236d0db 100644 --- a/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingLMSCardDeck.jsx @@ -1,11 +1,10 @@ import React, { useState } from 'react'; -import isEmpty from 'lodash/isEmpty'; import PropTypes from 'prop-types'; import { CardGrid, useToggle, } from '@edx/paragon'; +import { getStatus } from './utils'; import ConfigError from '../ConfigError'; -import ErrorReportingModal from './ErrorReporting/ErrorReportingModal'; import ExistingCard from './ExistingCard'; const ExistingLMSCardDeck = ({ @@ -15,29 +14,10 @@ const ExistingLMSCardDeck = ({ onClick, }) => { const [errorIsOpen, openError, closeError] = useToggle(false); - const [errorReportIsOpen, openReport, closeReport] = useToggle(false); - const [reportConfig, setReportConfig] = useState(); const [errorModalText, setErrorModalText] = useState(); // Map the existing config data to individual cards - - const getStatus = (config) => { - const INCOMPLETE = 'incomplete'; - const ACTIVE = 'active'; - const INACTIVE = 'inactive'; - if (!isEmpty(config.isValid[0].missing) - || !isEmpty(config.isValid[1].incorrect)) { - return INCOMPLETE; - } - - if (config.active) { - return ACTIVE; - } - return INACTIVE; - }; - - // const listItems = timeSort(configData).map((config) => ( - const listActive = configData.filter(config => getStatus(config) === 'active').map(config => ( + const listActive = configData.filter(config => getStatus(config) === 'Active').map(config => ( )); - const listInactive = configData.filter(config => getStatus(config) !== 'active').map(config => ( + const listInactive = configData.filter(config => getStatus(config) !== 'Active').map(config => ( @@ -73,12 +49,6 @@ const ExistingLMSCardDeck = ({ close={closeError} configTextOverride={errorModalText} /> - { listActive.length > 0 && ( <>

Active

diff --git a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx index b728959a28..6885627a5b 100644 --- a/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/ExistingLMSCardDeck.test.jsx @@ -12,6 +12,13 @@ import { features } from '../../../../config'; jest.mock('../../../../data/services/LmsApiService'); +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useRouteMatch: () => ({ + url: 'https://www.test.com/', + }), +})); + const enterpriseCustomerUuid = 'test-enterprise-id'; const mockEditExistingConfigFn = jest.fn(); const mockOnClick = jest.fn(); @@ -356,4 +363,19 @@ describe('', () => { ); expect(screen.queryByText('View sync history')).not.toBeInTheDocument(); }); + it('viewing sync history redirects to detail page', () => { + getAuthenticatedUser.mockReturnValue({ + administrator: true, + }); + render( + , + ); + const link = 'https://www.test.com/BLACKBOARD/1'; + expect(screen.getByText('View sync history')).toHaveAttribute('href', link); + }); }); diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index 3400b30ed2..ad56bc5429 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -15,7 +15,6 @@ import { BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, - DEGREED_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, @@ -140,8 +139,8 @@ describe('', () => { expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - userEvent.click(degreedCard); - expect(screen.queryByText('Connect Degreed')).toBeTruthy(); + fireEvent.click(degreedCard); + expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -216,8 +215,8 @@ describe('', () => { expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - await waitFor(() => userEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed')).toBeTruthy(); + await waitFor(() => fireEvent.click(degreedCard)); + expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); @@ -228,15 +227,15 @@ describe('', () => { const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { - expect(screen.findByText(channelMapping[DEGREED_TYPE].displayName)); + expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); - const degreedCard = screen.getByText(channelMapping[DEGREED_TYPE].displayName); - await waitFor(() => userEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed')).toBeTruthy(); + const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); + await waitFor(() => fireEvent.click(degreedCard)); + expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Degreed')).toBeFalsy(); + expect(screen.queryByText('Connect Degreed2')).toBeFalsy(); }); test('No action Cornerstone card cancel flow', async () => { renderWithRouter(); diff --git a/src/components/settings/SettingsLMSTab/utils.js b/src/components/settings/SettingsLMSTab/utils.js index 4b8c226133..6781d80d44 100644 --- a/src/components/settings/SettingsLMSTab/utils.js +++ b/src/components/settings/SettingsLMSTab/utils.js @@ -1,3 +1,5 @@ +import isEmpty from 'lodash/isEmpty'; + export default function buttonBool(config) { let returnVal = true; Object.entries(config).forEach(entry => { @@ -18,3 +20,12 @@ export const isExistingConfig = (configs, value, existingInput) => { } return false; }; + +export const getStatus = (config) => { + // config.isValid has two arrays of missing and incorrect config fields + // which are required to resolve in order to complete the configuration + if (!isEmpty([...config.isValid[0].missing, ...config.isValid[1].incorrect])) { + return 'Incomplete'; + } + return config.active ? 'Active' : 'Inactive'; +}; diff --git a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx index 832564cdad..001ef8d47e 100644 --- a/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx +++ b/src/components/settings/SettingsSSOTab/ExistingSSOConfigs.jsx @@ -11,10 +11,7 @@ import { SSOConfigContext } from './SSOConfigContext'; import ConfigError from '../ConfigError'; import handleErrors from '../utils'; import LmsApiService from '../../../data/services/LmsApiService'; - -const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; -const errorDeleteConfigModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; -const errorDeleteDataModalText = 'We were unable to delete your provider data. Please try removing again or contact support for help.'; +import { errorToggleModalText, errorDeleteConfigModalText, errorDeleteDataModalText } from '../data/constants'; const ExistingSSOConfigs = ({ configs, refreshBool, setRefreshBool, enterpriseId, providerData, diff --git a/src/components/settings/data/constants.js b/src/components/settings/data/constants.js index 0720742e41..c2d99b0e71 100644 --- a/src/components/settings/data/constants.js +++ b/src/components/settings/data/constants.js @@ -20,6 +20,10 @@ export const DELETE_TOAST_MESSAGE = 'Learning platform integration successfully export const INACTIVATE_TOAST_MESSAGE = 'Learning platform integration successfully disabled.'; export const SUBMIT_TOAST_MESSAGE = 'Learning platform integration successfully submitted.'; +export const errorToggleModalText = 'We were unable to toggle your configuration. Please try submitting again or contact support for help.'; +export const errorDeleteConfigModalText = 'We were unable to delete your configuration. Please try removing again or contact support for help.'; +export const errorDeleteDataModalText = 'We were unable to delete your provider data. Please try removing again or contact support for help.'; + export const BLACKBOARD_TYPE = 'BLACKBOARD'; export const CANVAS_TYPE = 'CANVAS'; export const CORNERSTONE_TYPE = 'CSOD'; diff --git a/src/components/settings/index.jsx b/src/components/settings/index.jsx index fa94500dc0..5c48e8cc40 100644 --- a/src/components/settings/index.jsx +++ b/src/components/settings/index.jsx @@ -14,6 +14,7 @@ import { SETTINGS_PARAM_MATCH, } from './data/constants'; import SettingsTabs from './SettingsTabs'; +import SyncHistory from './SettingsLMSTab/ErrorReporting/SyncHistory'; const PAGE_TILE = 'Settings'; @@ -38,6 +39,10 @@ const SettingsPage = () => { path={`${path}/${SETTINGS_PARAM_MATCH}`} component={SettingsTabs} /> + diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index fb8772ce08..8a2fb6c96e 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -127,16 +127,24 @@ class LmsApiService { return LmsApiService.apiClient().post(LmsApiService.providerDataSyncUrl, formData); } - static postNewMoodleConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/`, formData); + static fetchBlackboardGlobalConfig() { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/global-configuration/`); } - static updateMoodleConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`, formData); + static fetchSingleBlackboardConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); } - static deleteMoodleConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); + static postNewBlackboardConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/`, formData); + } + + static updateBlackboardConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`, formData); + } + + static deleteBlackboardConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); } static fetchSingleCanvasConfig(configId) { @@ -155,42 +163,30 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/canvas/configuration/${configId}/`); } - static fetchBlackboardGlobalConfig() { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/global-configuration/`); - } - - static fetchSingleBlackboardConfig(configId) { - return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); - } - - static postNewBlackboardConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/`, formData); - } - - static updateBlackboardConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`, formData); - } - - static deleteBlackboardConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/blackboard/configuration/${configId}/`); + static fetchSingleCornerstoneConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); } - static postNewSuccessFactorsConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/`, formData); + static postNewCornerstoneConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/`, formData); } - static updateSuccessFactorsConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`, formData); + static updateCornerstoneConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`, formData); } - static deleteSuccessFactorsConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); + static deleteCornerstoneConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); } static postNewDegreedConfig(formData) { return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/`, formData); } + static fetchSingleDegreedConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`); + } + static updateDegreedConfig(formData, configId) { return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`, formData); } @@ -199,6 +195,10 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/degreed/configuration/${configId}/`); } + static fetchSingleDegreed2Config(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/${configId}/`); + } + static postNewDegreed2Config(formData) { return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/`, formData); } @@ -211,16 +211,36 @@ class LmsApiService { return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/degreed2/configuration/${configId}/`); } - static postNewCornerstoneConfig(formData) { - return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/`, formData); + static fetchSingleMoodleConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); } - static updateCornerstoneConfig(formData, configId) { - return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`, formData); + static postNewMoodleConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/`, formData); } - static deleteCornerstoneConfig(configId) { - return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/cornerstone/configuration/${configId}/`); + static updateMoodleConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`, formData); + } + + static deleteMoodleConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/moodle/configuration/${configId}/`); + } + + static fetchSingleSuccessFactorsConfig(configId) { + return LmsApiService.apiClient().get(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); + } + + static postNewSuccessFactorsConfig(formData) { + return LmsApiService.apiClient().post(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/`, formData); + } + + static updateSuccessFactorsConfig(formData, configId) { + return LmsApiService.apiClient().put(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`, formData); + } + + static deleteSuccessFactorsConfig(configId) { + return LmsApiService.apiClient().delete(`${LmsApiService.lmsIntegrationUrl}/sap_success_factors/configuration/${configId}/`); } static createPendingEnterpriseUsers(formData, uuid) { diff --git a/src/setupTest.js b/src/setupTest.js index 92011cdf9e..ba4024d5c1 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -6,7 +6,6 @@ import Adapter from 'enzyme-adapter-react-16'; import MockAdapter from 'axios-mock-adapter'; import ResizeObserverPolyfill from 'resize-observer-polyfill'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import 'jest-canvas-mock'; import 'jest-localstorage-mock'; Enzyme.configure({ adapter: new Adapter() }); diff --git a/src/utils.js b/src/utils.js index d427269ce2..b6892bdbcd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -260,6 +260,18 @@ const channelMapping = { update: LmsApiService.updateCornerstoneConfig, delete: LmsApiService.deleteCornerstoneConfig, }, + [DEGREED_TYPE]: { + displayName: 'Degreed', + icon: DegreedIcon, + update: LmsApiService.updateDegreedConfig, + delete: LmsApiService.deleteDegreedConfig, + }, + [DEGREED2_TYPE]: { + displayName: 'Degreed2', + icon: DegreedIcon, + update: LmsApiService.updateDegreed2Config, + delete: LmsApiService.deleteDegreed2Config, + }, [MOODLE_TYPE]: { displayName: 'Moodle', icon: MoodleIcon, @@ -272,18 +284,6 @@ const channelMapping = { update: LmsApiService.updateSuccessFactorsConfig, delete: LmsApiService.deleteSuccessFactorsConfig, }, - [DEGREED2_TYPE]: { - displayName: 'Degreed', - icon: DegreedIcon, - update: LmsApiService.updateDegreed2Config, - delete: LmsApiService.deleteDegreed2Config, - }, - [DEGREED_TYPE]: { - displayName: 'Degreed', - icon: DegreedIcon, - update: LmsApiService.updateDegreedConfig, - delete: LmsApiService.deleteDegreedConfig, - }, }; const capitalizeFirstLetter = string => string.charAt(0).toUpperCase() + string.slice(1); From a0c3ff2a58c954c8cfbfa6bc11c127692a774217 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 10 Jan 2023 14:09:31 -0500 Subject: [PATCH 32/73] feat: Dismissible alert on max highlight sets reached (#947) * feat: Dismissable alert on max highlight sets reached * chore: PR fixes --- .../CurrentContentHighlightHeader.jsx | 80 +++++++++++++---- .../ContentHighlights/data/constants.js | 13 ++- .../tests/ContentHighlightSetCard.test.jsx | 88 ++++++++++++++++--- 3 files changed, 153 insertions(+), 28 deletions(-) diff --git a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx index e7ce380d35..4ef22659cd 100644 --- a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx +++ b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx @@ -1,28 +1,76 @@ -import React from 'react'; +import React, { useState, useEffect, useContext } from 'react'; import { - Button, ActionRow, + Button, ActionRow, Alert, } from '@edx/paragon'; -import { Add } from '@edx/paragon/icons'; - +import { Add, Info } from '@edx/paragon/icons'; +import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; import { useContentHighlightsContext } from './data/hooks'; -import { BUTTON_TEXT, HEADER_TEXT } from './data/constants'; +import { + BUTTON_TEXT, HEADER_TEXT, MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION, ALERT_TEXT, +} from './data/constants'; const CurrentContentHighlightHeader = () => { + const { + enterpriseCuration: { + enterpriseCuration: { + highlightSets, + }, + }, + } = useContext(EnterpriseAppContext); const { openStepperModal } = useContentHighlightsContext(); + // Preliminiary logic for the header text given max sets reached + const [maxHighlightsReached, setMaxHighlightsReached] = useState(false); + const [showMaxHighlightsAlert, setShowMaxHighlightsAlert] = useState(false); + + const createNewHighlight = () => { + if (maxHighlightsReached) { + setShowMaxHighlightsAlert(true); + } else { + openStepperModal(); + } + }; + useEffect(() => { + // using greater than or equal as an additional buffer as opposed to exactly equal + if (highlightSets.length >= MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION) { + setMaxHighlightsReached(true); + } else { + setMaxHighlightsReached(false); + } + }, [highlightSets]); return ( - -

- {HEADER_TEXT.currentContent} -

- - +
+

+ {HEADER_TEXT.SUB_TEXT.currentContent} +

+ setShowMaxHighlightsAlert(false)} > - {BUTTON_TEXT.createNewHighlight} - - + + {ALERT_TEXT.HEADER_TEXT.currentContent} + +

+ {ALERT_TEXT.SUB_TEXT.currentContent} +

+
+ ); }; export default CurrentContentHighlightHeader; diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index 9a3965eb41..5ad1c1e3c8 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -31,13 +31,24 @@ export const STEPPER_STEP_TEXT = { // Header text extracted into constant to maintain passing test on changes export const HEADER_TEXT = { currentContent: 'Highlights', + SUB_TEXT: { + currentContent: `Create up to ${MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION} highlights for your learners.`, + }, }; // Button text extracted from constant to maintain passing test on changes export const BUTTON_TEXT = { createNewHighlight: 'New', zeroStateCreateNewHighlight: 'New highlight', }; - +// Alert Text extracted from constant to maintain passing test on changes +export const ALERT_TEXT = { + HEADER_TEXT: { + currentContent: 'Highlight limit reached', + }, + SUB_TEXT: { + currentContent: 'Delete at least one highlight to create a new one.', + }, +}; // Default footer values based on API response for ContentHighlightCardItem export const FOOTER_TEXT_BY_CONTENT_TYPE = { course: 'Course', diff --git a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx index b8b8234f54..0b6435813b 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx @@ -1,29 +1,52 @@ +/* eslint-disable react/prop-types */ import { useState } from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; import algoliasearch from 'algoliasearch/lite'; +import userEvent from '@testing-library/user-event'; +import { v4 as uuidv4 } from 'uuid'; import ContentHighlightSetCard from '../ContentHighlightSetCard'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; import CurrentContentHighlightHeader from '../CurrentContentHighlightHeader'; import { configuration } from '../../../config'; +import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { + BUTTON_TEXT, HEADER_TEXT, MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION, ALERT_TEXT, STEPPER_STEP_TEXT, +} from '../data/constants'; const mockStore = configureMockStore([thunk]); -const mockData = { +const mockData = [{ title: 'Test Title', highlightSetUUID: 'test-uuid', enterpriseSlug: 'test-enterprise-slug', itemCount: 0, imageCapSrc: 'http://fake.image', isPublished: true, -}; +}]; +const initialEnterpriseAppContextValue = { + enterpriseCuration: { + enterpriseCuration: { + highlightSets: mockData, + }, + }, +}; +const mockMultipleData = []; +for (let i = 0; i < MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION; i++) { + mockMultipleData.push({ + ...mockData, + title: `Test Title ${i}`, + highlightSetUUID: `test-uuid-${i}`, + }); +} const initialState = { portalConfiguration: { + enterpriseId: 'test-enterprise-id', enterpriseSlug: 'test-enterprise', }, highlightSetUUID: 'test-uuid', @@ -34,7 +57,10 @@ const searchClient = algoliasearch( configuration.ALGOLIA.SEARCH_API_KEY, ); -const ContentHighlightSetCardWrapper = (props) => { +const ContentHighlightSetCardWrapper = ({ + enterpriseAppContextValue = initialEnterpriseAppContextValue, + data = mockData, +}) => { const contextValue = useState({ stepperModal: { isOpen: false, @@ -46,18 +72,58 @@ const ContentHighlightSetCardWrapper = (props) => { searchClient, }); return ( - - - - - - + + + + + {data.map((highlight) => ( + + ))} + + + ); }; describe('', () => { it('Displays the title of the highlight set', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getByText('Test Title')).toBeInTheDocument(); }); + it('renders correct text when less then max curations', () => { + renderWithRouter(); + expect(screen.getByText(BUTTON_TEXT.createNewHighlight)).toBeInTheDocument(); + expect(screen.getByText(HEADER_TEXT.SUB_TEXT.currentContent)).toBeInTheDocument(); + }); + it('renders correct text when more then or equal to max curations', async () => { + const updatedEnterpriseAppContextValue = { + enterpriseCuration: { + enterpriseCuration: { + highlightSets: mockMultipleData, + }, + }, + }; + renderWithRouter( + , + ); + const createNewHighlightButton = screen.getByText(BUTTON_TEXT.createNewHighlight); + expect(createNewHighlightButton).toBeInTheDocument(); + // Trigger Alert + userEvent.click(createNewHighlightButton); + // Verify Alert + expect(screen.queryByText(STEPPER_STEP_TEXT.createTitle)).not.toBeInTheDocument(); + expect(screen.getByText(ALERT_TEXT.HEADER_TEXT.currentContent)).toBeInTheDocument(); + expect(screen.getByText(ALERT_TEXT.SUB_TEXT.currentContent)).toBeInTheDocument(); + + const dismissButton = screen.getByText('Dismiss'); + expect(dismissButton).toBeInTheDocument(); + // Trigger Dismiss + userEvent.click(dismissButton); + // Verify Dismiss + await waitFor(() => { expect(screen.queryByText(ALERT_TEXT.HEADER_TEXT.currentContent)).not.toBeInTheDocument(); }); + expect(screen.queryByText(ALERT_TEXT.SUB_TEXT.currentContent)).not.toBeInTheDocument(); + }); }); From 22329eb7940ff9816ef2d7ba5ac28c102210aab8 Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Wed, 11 Jan 2023 17:59:27 +0500 Subject: [PATCH 33/73] refactor: Remove SUBSCRIPTION_LPR feature flag. (#950) --- src/components/Admin/Admin.test.jsx | 4 ---- src/components/Admin/index.jsx | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/Admin/Admin.test.jsx b/src/components/Admin/Admin.test.jsx index 42da661f57..30ca049b38 100644 --- a/src/components/Admin/Admin.test.jsx +++ b/src/components/Admin/Admin.test.jsx @@ -10,7 +10,6 @@ import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import EnterpriseDataApiService from '../../data/services/EnterpriseDataApiService'; import Admin from './index'; import { CSV_CLICK_SEGMENT_EVENT_NAME } from '../DownloadCsvButton'; -import { features } from '../../config'; jest.mock('@edx/frontend-enterprise-utils', () => { const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); @@ -66,9 +65,6 @@ const AdminWrapper = props => ( ); describe('', () => { - beforeEach(() => { - features.SUBSCRIPTION_LPR = false; - }); const baseProps = { activeLearners: { past_week: 1, diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index 6a95879f6b..7157b9b425 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -25,7 +25,6 @@ import { formatTimestamp } from '../../utils'; import AdminCardsSkeleton from './AdminCardsSkeleton'; import { SubscriptionData } from '../subscriptions'; import EmbeddedSubscription from './EmbeddedSubscription'; -import { features } from '../../config'; import { isExperimentVariant } from '../../optimizely'; class Admin extends React.Component { @@ -297,7 +296,6 @@ class Admin extends React.Component { searchCourseQuery: queryParams.get('search_course') || '', searchDateQuery: queryParams.get('search_start_date') || '', }; - const { SUBSCRIPTION_LPR } = features; const config = getConfig(); @@ -327,7 +325,7 @@ class Admin extends React.Component { )}
- {SUBSCRIPTION_LPR && isExperimentVariation1 + {isExperimentVariation1 && (
From dcc0578f2935028e15052f65156e2e6630dafb5c Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Wed, 11 Jan 2023 09:12:25 -0500 Subject: [PATCH 34/73] feat: Add browser/modal confirmation for stepper exist (#949) * feat: Add browser/modal confirmation for stepper exist * chore: test and refactor * chore: PR fixes --- .../ContentHighlightStepper.jsx | 244 +++++++++++------- .../HighlightStepperConfirmContent.jsx | 4 +- .../HighlightStepperSelectContentHeader.jsx | 2 +- .../HighlightStepperTitle.jsx | 2 +- .../tests/ContentHighlightStepper.test.jsx | 91 ++++++- .../ContentHighlights/data/constants.js | 20 +- .../tests/ContentHighlightSetCard.test.jsx | 2 +- .../tests/ContentHighlightsDashboard.test.jsx | 4 +- 8 files changed, 255 insertions(+), 114 deletions(-) diff --git a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx index 49fcdbae83..d4a4606a43 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx @@ -1,11 +1,17 @@ import React, { - useCallback, useState, useContext, + useCallback, useState, useContext, useEffect, } from 'react'; import PropTypes from 'prop-types'; import { useContextSelector } from 'use-context-selector'; import { connect } from 'react-redux'; import { - Stepper, FullscreenModal, Button, StatefulButton, + Stepper, + FullscreenModal, + Button, + StatefulButton, + useToggle, + AlertModal, + ActionRow, } from '@edx/paragon'; import { logError } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform'; @@ -20,6 +26,7 @@ import HighlightStepperFooterHelpLink from './HighlightStepperFooterHelpLink'; import EnterpriseCatalogApiService from '../../../data/services/EnterpriseCatalogApiService'; import { enterpriseCurationActions } from '../../EnterpriseApp/data/enterpriseCurationReducer'; import { useContentHighlightsContext } from '../data/hooks'; +import { STEPPER_STEP_TEXT } from '../data/constants'; const STEPPER_STEP_LABELS = { CREATE_TITLE: 'Create a title', @@ -46,6 +53,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { const { location } = history; const [currentStep, setCurrentStep] = useState(steps[0]); const [isPublishing, setIsPublishing] = useState(false); + const [isCloseAlertOpen, openCloseAlert, closeCloseAlert] = useToggle(false); const { resetStepperModal } = useContentHighlightsContext(); const isStepperModalOpen = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.isOpen); const titleStepValidationError = useContextSelector( @@ -62,9 +70,12 @@ const ContentHighlightStepper = ({ enterpriseId }) => { ); const closeStepperModal = useCallback(() => { + if (isCloseAlertOpen) { + closeCloseAlert(); + } resetStepperModal(); setCurrentStep(steps[0]); - }, [resetStepperModal]); + }, [resetStepperModal, isCloseAlertOpen, closeCloseAlert]); const handlePublish = async () => { setIsPublishing(true); @@ -95,103 +106,148 @@ const ContentHighlightStepper = ({ enterpriseId }) => { setIsPublishing(false); } }; + const closeStepper = () => { + openCloseAlert(); + }; + + /** + * This section triggers browser response to unsaved items when the stepper modal is open/active + * + * Mandatory requirements to trigger response by browser, event.preventDefault && event.returnValue + * A return value is required to trigger the browser unsaved data blocking modal response + * + * Conditional MUST be set on event listener initialization. + * Failure to provide conditional will trigger browser event on all elements + * within ContentHighlightRoutes.jsx (essentially all of highlights) + * */ + useEffect(() => { + const preventUnload = (e) => { + e.preventDefault(); + e.returnValue = 'Are you sure? Your data will not be saved.'; + }; + + if (isStepperModalOpen) { + global.addEventListener('beforeunload', preventUnload); + } + // Added safety to force remove the 'beforeunload' event on the global window + return () => { + global.removeEventListener('beforeunload', preventUnload); + }; + }, [isStepperModalOpen]); + return ( - - } - footerNode={( - <> - - - - {/* TODO: Eventually would need a check to see if the user has made any changes + <> + + } + footerNode={( + <> + + + + {/* TODO: Eventually would need a check to see if the user has made any changes to the form before allowing them to close the modal without saving. */} - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + )} - > - - - + + + - - - + + + - - - - - + + + + + + {/* Alert Modal for StepperModal Close Confirmation */} + +

+ {STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content} +

+ + + + +
+ ); }; diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx index 6e6af1961a..cfa7b9ffa8 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx @@ -142,10 +142,10 @@ const HighlightStepperConfirmContent = ({ enterpriseId }) => {

- {STEPPER_STEP_TEXT.confirmContent} + {STEPPER_STEP_TEXT.HEADER_TEXT.confirmContent}

- {STEPPER_STEP_TEXT.getConfirmContentSubtitle(highlightTitle)}. + {STEPPER_STEP_TEXT.SUB_TEXT.confirmContent(highlightTitle)}.

diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx index 086ee65f22..de36b1d2cd 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx @@ -11,7 +11,7 @@ const HighlightStepperSelectContentTitle = () => { <>

- {STEPPER_STEP_TEXT.selectContent} + {STEPPER_STEP_TEXT.HEADER_TEXT.selectContent}

Select up to {MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "{highlightTitle}". Courses diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx index 8944ba1ad3..48799ed929 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx @@ -13,7 +13,7 @@ const HighlightStepperTitle = () => (

- {STEPPER_STEP_TEXT.createTitle} + {STEPPER_STEP_TEXT.HEADER_TEXT.createTitle}

diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx index f3cb41f398..2475397f93 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx @@ -99,7 +99,7 @@ describe('', () => { const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(stepper); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); it('Displays the stepper and test all back and next buttons', () => { renderWithRouter(); @@ -118,14 +118,24 @@ describe('', () => { // confirm content --> select content const backButton2 = screen.getByText('Back'); userEvent.click(backButton2); - expect(screen.getByText(STEPPER_STEP_TEXT.selectContent)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.selectContent)).toBeInTheDocument(); // select content --> title const backButton3 = screen.getByText('Back'); userEvent.click(backButton3); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); // title --> closed stepper const backButton4 = screen.getByText('Back'); userEvent.click(backButton4); + + // Confirm stepper close confirmation modal + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).toBeInTheDocument(); + + const confirmCloseButton = screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit); + userEvent.click(confirmCloseButton); + expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); }); it('Displays the stepper and exits on the X button', () => { @@ -133,10 +143,27 @@ describe('', () => { const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(stepper); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); userEvent.click(closeButton); + + // Confirm stepper close confirmation modal + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).toBeInTheDocument(); + + const confirmCloseButton = screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit); + userEvent.click(confirmCloseButton); + + // Confirm stepper confirmation modal closed + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).not.toBeInTheDocument(); + + expect(screen.queryByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).not.toBeInTheDocument(); expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); }); it('Displays the stepper and closes the stepper on confirm', async () => { @@ -144,15 +171,15 @@ describe('', () => { const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(stepper); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const input = screen.getByTestId('stepper-title-input'); fireEvent.change(input, { target: { value: 'test-title' } }); const nextButton1 = screen.getByText('Next'); userEvent.click(nextButton1); - expect(screen.getByText(STEPPER_STEP_TEXT.selectContent)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.selectContent)).toBeInTheDocument(); const nextButton2 = screen.getByText('Next'); userEvent.click(nextButton2); - expect(screen.getByText(STEPPER_STEP_TEXT.confirmContent)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.confirmContent)).toBeInTheDocument(); const confirmButton = screen.getByText('Publish'); userEvent.click(confirmButton); @@ -163,14 +190,60 @@ describe('', () => { const stepper1 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(stepper1); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); userEvent.click(closeButton); + + // Confirm stepper close confirmation modal + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).toBeInTheDocument(); + + const confirmCloseButton = screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit); + userEvent.click(confirmCloseButton); + + // Confirm stepper confirmation modal closed + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).not.toBeInTheDocument(); + + expect(screen.queryByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).not.toBeInTheDocument(); expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); const stepper2 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(stepper2); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); + }); + it('opens the stepper modal close confirmation modal and cancels the modal', () => { + renderWithRouter(); + + const stepper1 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + userEvent.click(stepper1); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + userEvent.click(closeButton); + + // Confirm stepper close confirmation modal + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).toBeInTheDocument(); + + const confirmCancelButton = screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel); + userEvent.click(confirmCancelButton); + + // Confirm stepper confirmation modal closed + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).not.toBeInTheDocument(); + + // Confirm modal still open + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); }); diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index 5ad1c1e3c8..fc6790e453 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -23,10 +23,22 @@ export const MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET = 12; export const HIGHLIGHT_TITLE_MAX_LENGTH = 60; // Stepper Step Text that match testing components export const STEPPER_STEP_TEXT = { - createTitle: 'Create a title for your highlight', - selectContent: 'Add content to your highlight', - confirmContent: 'Confirm your selections', - getConfirmContentSubtitle: (highlightTitle) => `Review content selections for "${highlightTitle}"`, + HEADER_TEXT: { + createTitle: 'Create a title for your highlight', + selectContent: 'Add content to your highlight', + confirmContent: 'Confirm your selections', + }, + SUB_TEXT: { + confirmContent: (highlightTitle) => `Review content selections for "${highlightTitle}"`, + }, + ALERT_MODAL_TEXT: { + title: 'Lose Progress?', + content: 'If you exit now, any changes you\'ve made will be lost.', + buttons: { + exit: 'Exit', + cancel: 'Cancel', + }, + }, }; // Header text extracted into constant to maintain passing test on changes export const HEADER_TEXT = { diff --git a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx index 0b6435813b..b0e6514c42 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx @@ -114,7 +114,7 @@ describe('', () => { // Trigger Alert userEvent.click(createNewHighlightButton); // Verify Alert - expect(screen.queryByText(STEPPER_STEP_TEXT.createTitle)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).not.toBeInTheDocument(); expect(screen.getByText(ALERT_TEXT.HEADER_TEXT.currentContent)).toBeInTheDocument(); expect(screen.getByText(ALERT_TEXT.SUB_TEXT.currentContent)).toBeInTheDocument(); diff --git a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx index be0efa16f6..753bce4d80 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx @@ -83,7 +83,7 @@ describe('', () => { renderWithRouter(); const newHighlight = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(newHighlight); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); it('Displays current highlights when data is populated', () => { @@ -105,6 +105,6 @@ describe('', () => { renderWithRouter(); const newHighlight = screen.getByText('New highlight'); userEvent.click(newHighlight); - expect(screen.getByText(STEPPER_STEP_TEXT.createTitle)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); }); From 640459636abb747b6b5afd9cdec5eb4990369d88 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Fri, 13 Jan 2023 09:04:29 -0500 Subject: [PATCH 35/73] feat: Add segment tracking for highlights (#922) * feat: Add segment tracking for highlights * feat: Additional segments added * feat: more events * chore: disable test flag * feat: more segments and cleanup * chore: fix broken test * chore: Cleanup and upping test coverage * chore: More test * chore: More test pt 2 * chore: More test pt 3 * chore: merge conflicts * chore: PR fixes * chore: PR fixes 2 * chore: PR fixes 3 --- .../ContentHighlightCardItem.jsx | 14 +- .../ContentHighlightSetCard.jsx | 4 + .../ContentHighlightsCardItemsContainer.jsx | 42 ++++-- .../CurrentContentHighlightHeader.jsx | 49 +++++-- .../ContentHighlights/DeleteHighlightSet.jsx | 55 +++++++- .../ContentHighlights/HighlightSetSection.jsx | 33 ++++- .../ContentConfirmContentCard.jsx | 33 ++++- .../ContentHighlightStepper.jsx | 125 ++++++++++++++++-- .../ContentSearchResultCard.jsx | 30 ++++- .../HighlightStepperConfirmContent.jsx | 3 +- .../HighlightStepperFooterHelpLink.jsx | 59 +++++++-- .../HighlightStepperSelectContentSearch.jsx | 9 +- .../HighlightStepperTitleInput.jsx | 8 +- .../tests/ContentConfirmContentCard.test.jsx | 17 ++- .../tests/ContentHighlightStepper.test.jsx | 49 ++++++- ...ghlightStepperSelectContentSearch.test.jsx | 117 ++++++++++++++++ .../ZeroState/ZeroStateHighlights.jsx | 38 +++++- .../ContentHighlights/data/constants.js | 70 ++++++++-- .../data/tests/constants.test.js | 16 +++ .../tests/ContentHighlightCardItem.test.jsx | 67 ++++++++++ .../tests/ContentHighlightSetCard.test.jsx | 17 ++- ...ntentHighlightsCardItemsContainer.test.jsx | 20 ++- .../tests/DeleteHighlightSet.test.jsx | 16 ++- .../tests/HighlightSetSection.test.jsx | 92 +++++++++++++ src/eventTracking.js | 26 ++++ src/index.jsx | 1 + 26 files changed, 908 insertions(+), 102 deletions(-) create mode 100644 src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx create mode 100644 src/components/ContentHighlights/data/tests/constants.test.js create mode 100644 src/components/ContentHighlights/tests/ContentHighlightCardItem.test.jsx create mode 100644 src/components/ContentHighlights/tests/HighlightSetSection.test.jsx diff --git a/src/components/ContentHighlights/ContentHighlightCardItem.jsx b/src/components/ContentHighlights/ContentHighlightCardItem.jsx index 03fe126106..17147b7143 100644 --- a/src/components/ContentHighlights/ContentHighlightCardItem.jsx +++ b/src/components/ContentHighlights/ContentHighlightCardItem.jsx @@ -9,7 +9,7 @@ import { getContentHighlightCardFooter } from './data/utils'; const ContentHighlightCardItem = ({ isLoading, title, - href, + hyperlinkAttrs, contentType, partners, cardImageUrl, @@ -23,9 +23,9 @@ const ContentHighlightCardItem = ({ cardSubtitle: partners.map(p => p.name).join(', '), cardFooter: getContentHighlightCardFooter({ price, contentType }), }; - if (href) { + if (hyperlinkAttrs) { cardInfo.cardTitle = ( - + {title} ); @@ -59,7 +59,11 @@ ContentHighlightCardItem.propTypes = { isLoading: PropTypes.bool, cardImageUrl: PropTypes.string, title: PropTypes.string.isRequired, - href: PropTypes.string, + hyperlinkAttrs: PropTypes.shape({ + href: PropTypes.string, + target: PropTypes.string, + onClick: PropTypes.func, + }), contentType: PropTypes.oneOf(['course', 'program', 'learnerpathway']).isRequired, partners: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, @@ -71,7 +75,7 @@ ContentHighlightCardItem.propTypes = { ContentHighlightCardItem.defaultProps = { isLoading: false, - href: undefined, + hyperlinkAttrs: undefined, cardImageUrl: undefined, price: undefined, }; diff --git a/src/components/ContentHighlights/ContentHighlightSetCard.jsx b/src/components/ContentHighlights/ContentHighlightSetCard.jsx index 6a2b212545..734cf08216 100644 --- a/src/components/ContentHighlights/ContentHighlightSetCard.jsx +++ b/src/components/ContentHighlights/ContentHighlightSetCard.jsx @@ -14,6 +14,7 @@ const ContentHighlightSetCard = ({ isPublished, enterpriseSlug, itemCount, + onClick, }) => { const history = useHistory(); /* Stepper Draft Logic (See Hook) - Start */ @@ -21,6 +22,7 @@ const ContentHighlightSetCard = ({ /* Stepper Draft Logic (See Hook) - End */ const handleHighlightSetClick = () => { if (isPublished) { + onClick(); // redirect to individual highlighted set based on uuid history.push(`/${enterpriseSlug}/admin/${ROUTE_NAMES.contentHighlights}/${highlightSetUUID}`); return; @@ -32,6 +34,7 @@ const ContentHighlightSetCard = ({ @@ -49,6 +52,7 @@ ContentHighlightSetCard.propTypes = { isPublished: PropTypes.bool.isRequired, itemCount: PropTypes.number.isRequired, imageCapSrc: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, }; const mapStateToProps = state => ({ diff --git a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx index 80090638e7..d7e0451235 100644 --- a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx +++ b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx @@ -2,14 +2,20 @@ import React from 'react'; import { CardGrid, Alert } from '@edx/paragon'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import ContentHighlightCardItem from './ContentHighlightCardItem'; import { - DEFAULT_ERROR_MESSAGE, HIGHLIGHTS_CARD_GRID_COLUMN_SIZES, MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET, + DEFAULT_ERROR_MESSAGE, + HIGHLIGHTS_CARD_GRID_COLUMN_SIZES, + MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET, } from './data/constants'; import SkeletonContentCardContainer from './SkeletonContentCardContainer'; import { generateAboutPageUrl } from './data/utils'; +import EVENT_NAMES from '../../eventTracking'; -const ContentHighlightsCardItemsContainer = ({ enterpriseSlug, isLoading, highlightedContent }) => { +const ContentHighlightsCardItemsContainer = ({ + enterpriseId, enterpriseSlug, isLoading, highlightedContent, +}) => { if (isLoading) { return ( @@ -22,22 +28,38 @@ const ContentHighlightsCardItemsContainer = ({ enterpriseSlug, isLoading, highli ); } + const trackClickEvent = ({ aggregationKey }) => { + const trackInfo = { + aggregation_key: aggregationKey, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.HIGHLIGHT_DASHBOARD_SET_ABOUT_PAGE}`, + trackInfo, + ); + }; return ( {highlightedContent.map(({ - uuid, title, contentType, authoringOrganizations, contentKey, cardImageUrl, + uuid, title, contentType, authoringOrganizations, contentKey, cardImageUrl, aggregationKey, }) => ( trackClickEvent({ aggregationKey }), + } + } + contentType={contentType.toLowerCase()} partners={authoringOrganizations} /> ))} @@ -46,6 +68,7 @@ const ContentHighlightsCardItemsContainer = ({ enterpriseSlug, isLoading, highli }; ContentHighlightsCardItemsContainer.propTypes = { + enterpriseId: PropTypes.string.isRequired, enterpriseSlug: PropTypes.string.isRequired, isLoading: PropTypes.bool.isRequired, highlightedContent: PropTypes.arrayOf(PropTypes.shape({ @@ -62,6 +85,7 @@ ContentHighlightsCardItemsContainer.propTypes = { }; const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, enterpriseSlug: state.portalConfiguration.enterpriseSlug, }); diff --git a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx index 4ef22659cd..5846e9a64a 100644 --- a/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx +++ b/src/components/ContentHighlights/CurrentContentHighlightHeader.jsx @@ -1,15 +1,22 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; + import { Button, ActionRow, Alert, } from '@edx/paragon'; + +import PropTypes from 'prop-types'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { connect } from 'react-redux'; import { Add, Info } from '@edx/paragon/icons'; -import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; import { useContentHighlightsContext } from './data/hooks'; +import EVENT_NAMES from '../../eventTracking'; + +import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; import { BUTTON_TEXT, HEADER_TEXT, MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION, ALERT_TEXT, } from './data/constants'; -const CurrentContentHighlightHeader = () => { +const CurrentContentHighlightHeader = ({ enterpriseId }) => { const { enterpriseCuration: { enterpriseCuration: { @@ -18,17 +25,9 @@ const CurrentContentHighlightHeader = () => { }, } = useContext(EnterpriseAppContext); const { openStepperModal } = useContentHighlightsContext(); - // Preliminiary logic for the header text given max sets reached const [maxHighlightsReached, setMaxHighlightsReached] = useState(false); const [showMaxHighlightsAlert, setShowMaxHighlightsAlert] = useState(false); - const createNewHighlight = () => { - if (maxHighlightsReached) { - setShowMaxHighlightsAlert(true); - } else { - openStepperModal(); - } - }; useEffect(() => { // using greater than or equal as an additional buffer as opposed to exactly equal if (highlightSets.length >= MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION) { @@ -38,6 +37,22 @@ const CurrentContentHighlightHeader = () => { } }, [highlightSets]); + const createNewHighlight = () => { + if (maxHighlightsReached) { + setShowMaxHighlightsAlert(true); + } else { + openStepperModal(); + const trackInfo = { + existing_highlight_set_uuids: highlightSets.map(set => set.uuid), + existing_highlight_set_count: highlightSets.length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.NEW_HIGHLIGHT}`, + trackInfo, + ); + } + }; return ( <> @@ -45,6 +60,7 @@ const CurrentContentHighlightHeader = () => { {HEADER_TEXT.currentContent} + + - + { }; DeleteHighlightSet.propTypes = { + enterpriseId: PropTypes.string.isRequired, enterpriseSlug: PropTypes.string.isRequired, }; const mapStateToProps = (state) => ({ + enterpriseId: state.portalConfiguration.enterpriseId, enterpriseSlug: state.portalConfiguration.enterpriseSlug, }); diff --git a/src/components/ContentHighlights/HighlightSetSection.jsx b/src/components/ContentHighlights/HighlightSetSection.jsx index d1afcfe3a1..c9cb79128e 100644 --- a/src/components/ContentHighlights/HighlightSetSection.jsx +++ b/src/components/ContentHighlights/HighlightSetSection.jsx @@ -1,20 +1,37 @@ import React from 'react'; import PropTypes from 'prop-types'; import { CardGrid } from '@edx/paragon'; - +import { connect } from 'react-redux'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import ContentHighlightSetCard from './ContentHighlightSetCard'; import { HIGHLIGHTS_CARD_GRID_COLUMN_SIZES } from './data/constants'; +import EVENT_NAMES from '../../eventTracking'; const HighlightSetSection = ({ + enterpriseId, title: sectionTitle, highlightSets, }) => { if (highlightSets.length === 0) { return null; } - + const trackClickEvent = ({ + uuid, title, isPublished, highlightedContentUuids, + }) => { + const trackInfo = { + highlight_set_uuid: uuid, + highlight_set_title: title, + highlight_set_is_published: isPublished, + highlight_set_item_count: highlightedContentUuids.length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.HIGHLIGHT_DASHBOARD_PUBLISHED_HIGHLIGHT_SET_CARD}`, + trackInfo, + ); + }; return ( -

+

{sectionTitle}

{highlightSets.map(({ @@ -31,6 +48,9 @@ const HighlightSetSection = ({ isPublished={isPublished} itemCount={highlightedContentUuids.length} imageCapSrc={cardImageUrl} + onClick={() => trackClickEvent({ + uuid, title, isPublished, highlightedContentUuids, + })} /> ))} @@ -39,6 +59,7 @@ const HighlightSetSection = ({ }; HighlightSetSection.propTypes = { + enterpriseId: PropTypes.string.isRequired, title: PropTypes.string.isRequired, highlightSets: PropTypes.arrayOf(PropTypes.shape({ title: PropTypes.string.isRequired, @@ -48,4 +69,8 @@ HighlightSetSection.propTypes = { })).isRequired, }; -export default HighlightSetSection; +const mapStateToProps = (state) => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(HighlightSetSection); diff --git a/src/components/ContentHighlights/HighlightStepper/ContentConfirmContentCard.jsx b/src/components/ContentHighlights/HighlightStepper/ContentConfirmContentCard.jsx index a00ceedbd1..91dfa83281 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentConfirmContentCard.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentConfirmContentCard.jsx @@ -3,11 +3,13 @@ import PropTypes from 'prop-types'; import { Delete } from '@edx/paragon/icons'; import { IconButton, Icon } from '@edx/paragon'; import { connect } from 'react-redux'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import ContentHighlightCardItem from '../ContentHighlightCardItem'; import { useContentHighlightsContext } from '../data/hooks'; import { generateAboutPageUrl } from '../data/utils'; +import EVENT_NAMES from '../../../eventTracking'; -const ContentConfirmContentCard = ({ enterpriseSlug, original }) => { +const ContentConfirmContentCard = ({ enterpriseId, enterpriseSlug, original }) => { const { deleteSelectedRowId } = useContentHighlightsContext(); const { title, @@ -18,16 +20,31 @@ const ContentConfirmContentCard = ({ enterpriseSlug, original }) => { firstEnrollablePaidSeatPrice, aggregationKey, } = original; - + const trackClickEvent = () => { + const trackInfo = { + aggregation_key: aggregationKey, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_CONFIRM_CONTENT_ABOUT_PAGE}`, + trackInfo, + ); + }; return (
{ }; ContentConfirmContentCard.propTypes = { + enterpriseId: PropTypes.string.isRequired, enterpriseSlug: PropTypes.string.isRequired, original: PropTypes.shape({ title: PropTypes.string, @@ -59,6 +77,7 @@ ContentConfirmContentCard.propTypes = { }; export const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, enterpriseSlug: state.portalConfiguration.enterpriseSlug, }); diff --git a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx index d4a4606a43..c47e8d7672 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx @@ -16,6 +16,7 @@ import { import { logError } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import { useHistory } from 'react-router-dom'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; @@ -26,13 +27,8 @@ import HighlightStepperFooterHelpLink from './HighlightStepperFooterHelpLink'; import EnterpriseCatalogApiService from '../../../data/services/EnterpriseCatalogApiService'; import { enterpriseCurationActions } from '../../EnterpriseApp/data/enterpriseCurationReducer'; import { useContentHighlightsContext } from '../data/hooks'; -import { STEPPER_STEP_TEXT } from '../data/constants'; - -const STEPPER_STEP_LABELS = { - CREATE_TITLE: 'Create a title', - SELECT_CONTENT: 'Select content', - CONFIRM_PUBLISH: 'Confirm and publish', -}; +import EVENT_NAMES from '../../../eventTracking'; +import { STEPPER_STEP_LABELS, STEPPER_STEP_TEXT } from '../data/constants'; const steps = [ STEPPER_STEP_LABELS.CREATE_TITLE, @@ -100,14 +96,120 @@ const ContentHighlightStepper = ({ enterpriseId }) => { addHighlightSet: true, }); closeStepperModal(); + const handlePublishTrackEvent = () => { + const trackInfo = { + is_published: transformedHighlightSet.isPublished, + highlight_set_uuid: transformedHighlightSet.uuid, + highlighted_content_uuids: transformedHighlightSet.highlightedContentUuids, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_STEP_CONFIRM_CONTENT}`, + trackInfo, + ); + }; + handlePublishTrackEvent(); } catch (error) { logError(error); } finally { setIsPublishing(false); } }; + /** + * Handles the navigation to the next step in the stepper from the createTitle step of the stepper. + */ + const handleNavigateToSelectContent = () => { + const trackInfo = { + prev_step: currentStep, + prev_step_position: steps.indexOf(currentStep) + 1, + current_step: steps[steps.indexOf(currentStep) + 1], + current_step_position: steps.indexOf(currentStep) + 2, + highlight_title: highlightTitle, + current_selected_row_ids: currentSelectedRowIds, + current_selected_row_ids_length: Object.keys(currentSelectedRowIds).length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_STEP_CREATE_TITLE}.next.clicked`, + trackInfo, + ); + setCurrentStep(steps[steps.indexOf(currentStep) + 1]); + }; + /** + * Handles the navigation to the previous step in the stepper from the selectContent step of the stepper. + */ + const handleNavigateFromSelectContent = () => { + const trackInfo = { + prev_step: currentStep, + prev_step_position: steps.indexOf(currentStep) + 1, + current_step: steps[steps.indexOf(currentStep) - 1], + current_step_position: steps.indexOf(currentStep), + highlight_title: highlightTitle, + current_selected_row_ids: currentSelectedRowIds, + current_selected_row_ids_length: Object.keys(currentSelectedRowIds).length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_STEP_SELECT_CONTENT}.back.clicked`, + trackInfo, + ); + setCurrentStep(steps[steps.indexOf(currentStep) - 1]); + }; + /** + * Handles the navigation to the next step in the stepper from the selectContent step of the stepper. + */ + const handleNavigateToConfirmContent = () => { + const trackInfo = { + prev_step: currentStep, + prev_step_position: steps.indexOf(currentStep) + 1, + current_step: steps[steps.indexOf(currentStep) + 1], + current_step_position: steps.indexOf(currentStep) + 2, + highlight_title: highlightTitle, + current_selected_row_ids: currentSelectedRowIds, + current_selected_row_ids_length: Object.keys(currentSelectedRowIds).length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_STEP_SELECT_CONTENT}.next.clicked`, + trackInfo, + ); + setCurrentStep(steps[steps.indexOf(currentStep) + 1]); + }; + /** + * Handles the navigation to the previous step in the stepper from the confirmContent step of the stepper. + */ + const handleNavigateFromConfirmContent = () => { + const trackInfo = { + prev_step: currentStep, + prev_step_position: steps.indexOf(currentStep) + 1, + current_step: steps[steps.indexOf(currentStep) - 1], + current_step_position: steps.indexOf(currentStep), + highlight_title: highlightTitle, + current_selected_row_ids: currentSelectedRowIds, + current_selected_row_ids_length: Object.keys(currentSelectedRowIds).length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_STEP_CONFIRM_PUBLISH}.back.clicked`, + trackInfo, + ); + setCurrentStep(steps[steps.indexOf(currentStep) - 1]); + }; + const closeStepper = () => { openCloseAlert(); + const trackInfo = { + current_step: steps[steps.indexOf(currentStep)], + current_step_position: steps.indexOf(currentStep) + 1, + highlight_title: highlightTitle, + current_selected_row_ids: currentSelectedRowIds, + current_selected_row_ids_length: Object.keys(currentSelectedRowIds).length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_CLOSE_STEPPER_INCOMPLETE}`, + trackInfo, + ); }; /** @@ -159,7 +261,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { @@ -204,6 +306,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { /> + )} > { +const ContentSearchResultCard = ({ enterpriseId, enterpriseSlug, original }) => { const { aggregationKey, title, @@ -14,14 +16,28 @@ const ContentSearchResultCard = ({ enterpriseSlug, original }) => { originalImageUrl, firstEnrollablePaidSeatPrice, } = original; + const trackClickEvent = () => { + const trackInfo = { + aggregation_key: aggregationKey, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_SELECT_CONTENT_ABOUT_PAGE}`, + trackInfo, + ); + }; return ( { }; ContentSearchResultCard.propTypes = { + enterpriseId: PropTypes.string.isRequired, enterpriseSlug: PropTypes.string.isRequired, original: PropTypes.shape({ aggregationKey: PropTypes.string, @@ -44,6 +61,7 @@ ContentSearchResultCard.propTypes = { }; const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, enterpriseSlug: state.portalConfiguration.enterpriseSlug, }); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx index cfa7b9ffa8..cdc96306f4 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx @@ -21,6 +21,7 @@ import { MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET, HIGHLIGHTS_CARD_GRID_COLUMN_SIZES, DEFAULT_ERROR_MESSAGE, + ENABLE_TESTING, } from '../data/constants'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; import ContentConfirmContentCard from './ContentConfirmContentCard'; @@ -90,7 +91,7 @@ export const SelectedContent = ({ enterpriseId }) => { /* eslint-enable max-len */ const algoliaFilters = useMemo(() => { // import testEnterpriseId from the existing ../data/constants folder and replace with enterpriseId to test locally - let filterString = `enterprise_customer_uuids:${enterpriseId}`; + let filterString = `enterprise_customer_uuids:${ENABLE_TESTING(enterpriseId)}`; if (currentSelectedRowIds.length > 0) { filterString += ' AND ('; currentSelectedRowIds.forEach((selectedRowId, index) => { diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx index 79814f0dda..8f05b40055 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperFooterHelpLink.jsx @@ -2,17 +2,52 @@ import React from 'react'; import { Hyperlink, } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { useContextSelector } from 'use-context-selector'; +import { connect } from 'react-redux'; +import { getConfig } from '@edx/frontend-platform'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; +import EVENT_NAMES from '../../../eventTracking'; +import { STEPPER_HELP_CENTER_FOOTER_BUTTON_TEXT } from '../data/constants'; -const HighlightStepperFooterHelpLink = () => ( -
- - Help Center: Program Optimization - -
-); +const HighlightStepperFooterHelpLink = ({ enterpriseId }) => { + const stepperModal = useContextSelector( + ContentHighlightsContext, + v => v[0].stepperModal, + ); + const trackClickEvent = () => { + const trackInfo = { + highlight_title: stepperModal.highlightTitle, + current_selected_row_ids: stepperModal.currentSelectedRowIds, + current_selected_row_ids_length: Object.keys(stepperModal.currentSelectedRowIds).length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.STEPPER_HYPERLINK_CLICK}`, + trackInfo, + ); + }; + return ( +
+ + {STEPPER_HELP_CENTER_FOOTER_BUTTON_TEXT} + +
+ ); +}; -export default HighlightStepperFooterHelpLink; +HighlightStepperFooterHelpLink.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = (state) => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(HighlightStepperFooterHelpLink); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx index 5c1c15f67a..e29eb4dc12 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx @@ -7,7 +7,7 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { SearchData, SearchHeader } from '@edx/frontend-enterprise-catalog-search'; import { configuration } from '../../../config'; -import { FOOTER_TEXT_BY_CONTENT_TYPE } from '../data/constants'; +import { ENABLE_TESTING, FOOTER_TEXT_BY_CONTENT_TYPE, MAX_PAGE_SIZE } from '../data/constants'; import ContentSearchResultCard from './ContentSearchResultCard'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; import SelectContentSelectionStatus from './SelectContentSelectionStatus'; @@ -17,7 +17,6 @@ import SkeletonContentCard from '../SkeletonContentCard'; import { useContentHighlightsContext } from '../data/hooks'; const defaultActiveStateValue = 'card'; -const pageSize = 24; const selectColumn = { id: 'selection', @@ -38,7 +37,7 @@ const HighlightStepperSelectContent = ({ enterpriseId }) => { ); // TODO: replace testEnterpriseId with enterpriseId before push, // uncomment out import and replace with testEnterpriseId to test - const searchFilters = `enterprise_customer_uuids:${enterpriseId}`; + const searchFilters = `enterprise_customer_uuids:${ENABLE_TESTING(enterpriseId)}`; return ( @@ -48,7 +47,7 @@ const HighlightStepperSelectContent = ({ enterpriseId }) => { > { @@ -13,11 +13,11 @@ const HighlightStepperTitleInput = () => { const [isInvalid, setIsInvalid] = useState(false); const handleChange = (e) => { - if (e.target.value.length > 60) { + if (e.target.value.length > MAX_HIGHLIGHT_TITLE_LENGTH) { setIsInvalid(true); setHighlightTitle({ highlightTitle: e.target.value, - titleStepValidationError: 'Titles may only be 60 characters or less', + titleStepValidationError: DEFAULT_ERROR_MESSAGE.EXCEEDS_HIGHLIGHT_TITLE_LENGTH, }); } else { setIsInvalid(false); @@ -41,7 +41,7 @@ const HighlightStepperTitleInput = () => { autoComplete="off" /> - {titleLength}/{HIGHLIGHT_TITLE_MAX_LENGTH} + {titleLength}/{MAX_HIGHLIGHT_TITLE_LENGTH} ); diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx index 9cd0351b69..7d505984ee 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx @@ -5,7 +5,7 @@ import '@testing-library/jest-dom/extend-expect'; import algoliasearch from 'algoliasearch/lite'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; -import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import { renderWithRouter, sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import ContentConfirmContentCard from '../ContentConfirmContentCard'; import { testCourseData, testCourseAggregation, FOOTER_TEXT_BY_CONTENT_TYPE } from '../../data/constants'; import { ContentHighlightsContext } from '../../ContentHighlightsContext'; @@ -13,6 +13,15 @@ import { configuration } from '../../../../config'; import { useContentHighlightsContext } from '../../data/hooks'; const mockStore = configureMockStore(); + +jest.mock('@edx/frontend-enterprise-utils', () => { + const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); + return ({ + ...originalModule, + sendEnterpriseTrackEvent: jest.fn(), + }); +}); + const initialState = { portalConfiguration: { @@ -80,4 +89,10 @@ describe('', () => { userEvent.click(deleteButton[0]); expect(mockDeleteSelectedRowId).toHaveBeenCalledWith(testCourseData[0].aggregationKey); }); + it('sends track event on click', () => { + renderWithRouter(); + const hyperlinkTitle = screen.getAllByTestId('hyperlink-title')[0]; + userEvent.click(hyperlinkTitle); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx index 2475397f93..8e6f963037 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx @@ -5,12 +5,15 @@ import { useState } from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import algoliasearch from 'algoliasearch/lite'; import thunk from 'redux-thunk'; -import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import { renderWithRouter, sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import { ContentHighlightsContext } from '../../ContentHighlightsContext'; import { BUTTON_TEXT, + DEFAULT_ERROR_MESSAGE, + MAX_HIGHLIGHT_TITLE_LENGTH, + STEPPER_HELP_CENTER_FOOTER_BUTTON_TEXT, STEPPER_STEP_TEXT, testCourseAggregation, testCourseData, @@ -41,6 +44,14 @@ const searchClient = algoliasearch( configuration.ALGOLIA.SEARCH_API_KEY, ); +jest.mock('@edx/frontend-enterprise-utils', () => { + const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); + return ({ + ...originalModule, + sendEnterpriseTrackEvent: jest.fn(), + }); +}); + /* eslint-disable react/prop-types */ const ContentHighlightStepperWrapper = ({ enterpriseAppContextValue = initialEnterpriseAppContextValue, @@ -94,6 +105,10 @@ jest.mock('react-instantsearch-dom', () => ({ })); describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('Displays the stepper', () => { renderWithRouter(); @@ -106,26 +121,32 @@ describe('', () => { // open stepper --> title const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(stepper); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); // title --> select content const nextButton1 = screen.getByText('Next'); const input = screen.getByTestId('stepper-title-input'); fireEvent.change(input, { target: { value: 'test-title' } }); userEvent.click(nextButton1); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(2); // select content --> confirm content const nextButton2 = screen.getByText('Next'); userEvent.click(nextButton2); - + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(3); // confirm content --> select content const backButton2 = screen.getByText('Back'); userEvent.click(backButton2); + + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(4); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.selectContent)).toBeInTheDocument(); // select content --> title const backButton3 = screen.getByText('Back'); userEvent.click(backButton3); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(5); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); // title --> closed stepper const backButton4 = screen.getByText('Back'); userEvent.click(backButton4); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(6); // Confirm stepper close confirmation modal expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); @@ -143,10 +164,12 @@ describe('', () => { const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); userEvent.click(stepper); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); userEvent.click(closeButton); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(2); // Confirm stepper close confirmation modal expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); @@ -246,4 +269,26 @@ describe('', () => { // Confirm modal still open expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); + it('Displays error message in title page when highlight set name exceeds maximum value', () => { + renderWithRouter(); + const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + userEvent.click(stepper); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); + const input = screen.getByTestId('stepper-title-input'); + const reallyLongTitle = 'test-title-test-title-test-title-test-title-test-title-test-title'; + const reallyLongTitleLength = reallyLongTitle.length; + fireEvent.change(input, { target: { value: reallyLongTitle } }); + + expect(screen.getByText(`${reallyLongTitleLength}/${MAX_HIGHLIGHT_TITLE_LENGTH}`, { exact: false })).toBeInTheDocument(); + expect(screen.getByText(DEFAULT_ERROR_MESSAGE.EXCEEDS_HIGHLIGHT_TITLE_LENGTH)).toBeInTheDocument(); + }); + it('sends segment event from footer link', () => { + renderWithRouter(); + const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + userEvent.click(stepper); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); + const footerLink = screen.getByText(STEPPER_HELP_CENTER_FOOTER_BUTTON_TEXT); + userEvent.click(footerLink); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx new file mode 100644 index 0000000000..c4f329b011 --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import algoliasearch from 'algoliasearch/lite'; +import { renderWithRouter, sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import { + testCourseAggregation, + testCourseData, +} from '../../data/constants'; +import { ContentHighlightsContext } from '../../ContentHighlightsContext'; +import { configuration } from '../../../../config'; +import HighlightStepperSelectContent from '../HighlightStepperSelectContentSearch'; + +const mockStore = configureMockStore([thunk]); +jest.mock('@edx/frontend-enterprise-utils', () => { + const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); + return ({ + ...originalModule, + sendEnterpriseTrackEvent: jest.fn(), + }); +}); +const enterpriseId = 'test-enterprise-id'; +const initialState = { + portalConfiguration: + { + enterpriseSlug: 'test-enterprise', + enterpriseId, + }, +}; + +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); + +// eslint-disable-next-line react/prop-types +const HighlightStepperSelectContentSearchWrapper = ({ children, currentSelectedRowIds = [] }) => { + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds, + }, + contentHighlights: [], + searchClient, + }); + return ( + + + + {children} + + + + ); +}; + +const mockCourseData = [...testCourseData]; + +jest.mock('react-instantsearch-dom', () => ({ + ...jest.requireActual('react-instantsearch-dom'), + connectStateResults: Component => function connectStateResults(props) { + return ( + + ); + }, +})); + +describe('HighlightStepperSelectContentSearch', () => { + test('renders the search results with nothing selected', async () => { + renderWithRouter( + + + , + ); + expect(screen.getByText(`Showing ${mockCourseData.length} of ${mockCourseData.length}`, { exact: false })).toBeInTheDocument(); + expect(screen.getByText('Search courses')).toBeInTheDocument(); + }); + test('renders the search results with all selected', async () => { + renderWithRouter( + + + , + ); + expect(screen.getByText(`${mockCourseData.length} selected (${mockCourseData.length} shown below)`, { exact: false })).toBeInTheDocument(); + expect(screen.getByText('Clear selection')).toBeInTheDocument(); + }); + test('sends track event on click', async () => { + renderWithRouter( + + + , + ); + const hyperlinkTitle = screen.getAllByTestId('hyperlink-title')[0]; + userEvent.click(hyperlinkTitle); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx index 4b70df307d..afe7c271ee 100644 --- a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx +++ b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx @@ -1,10 +1,12 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { useContextSelector } from 'use-context-selector'; import { Card, Button, Col, Row, } from '@edx/paragon'; import PropTypes from 'prop-types'; import { Add } from '@edx/paragon/icons'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { connect } from 'react-redux'; import cardImage from '../data/images/ContentHighlightImage.svg'; import ZeroStateCardImage from './ZeroStateCardImage'; import ZeroStateCardText from './ZeroStateCardText'; @@ -13,11 +15,31 @@ import ContentHighlightStepper from '../HighlightStepper/ContentHighlightStepper import { ContentHighlightsContext } from '../ContentHighlightsContext'; import { useContentHighlightsContext } from '../data/hooks'; import { BUTTON_TEXT } from '../data/constants'; +import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import EVENT_NAMES from '../../../eventTracking'; -const ZeroStateHighlights = ({ cardClassName }) => { +const ZeroStateHighlights = ({ enterpriseId, cardClassName }) => { const { openStepperModal } = useContentHighlightsContext(); + const { + enterpriseCuration: { + enterpriseCuration: { + highlightSets, + }, + }, + } = useContext(EnterpriseAppContext); const isStepperModalOpen = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.isOpen); - + const handleNewHighlightClick = () => { + openStepperModal(); + const trackInfo = { + existing_highlight_set_uuids: highlightSets.map(set => set.uuid), + existing_highlight_set_count: highlightSets.length, + }; + sendEnterpriseTrackEvent( + enterpriseId, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.NEW_HIGHLIGHT}`, + trackInfo, + ); + }; return ( @@ -32,7 +54,7 @@ const ZeroStateHighlights = ({ cardClassName }) => { @@ -346,7 +362,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { {STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content}

- + diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx index 7d505984ee..1f8f8f3a7a 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx @@ -83,16 +83,17 @@ describe('', () => { expect(screen.queryAllByText(testCourseData[i].partners[0].name)).toBeTruthy(); } }); - it('deletes the correct content', () => { + it('deletes the correct content and sends first track event of the mock', () => { renderWithRouter(); const deleteButton = screen.getAllByRole('button', { 'aria-label': 'Delete' }); userEvent.click(deleteButton[0]); expect(mockDeleteSelectedRowId).toHaveBeenCalledWith(testCourseData[0].aggregationKey); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); }); - it('sends track event on click', () => { + it('sends second track event of the mock on click of hyperlink', () => { renderWithRouter(); const hyperlinkTitle = screen.getAllByTestId('hyperlink-title')[0]; userEvent.click(hyperlinkTitle); - expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(2); }); }); diff --git a/src/eventTracking.js b/src/eventTracking.js index 1ddbfdc41c..d8f1aaf1d2 100644 --- a/src/eventTracking.js +++ b/src/eventTracking.js @@ -54,21 +54,26 @@ export const SUBSCRIPTION_TABLE_EVENTS = { // Content Highlights, will eventually need to be refactored using same pattern as subscriptions export const CONTENT_HIGHLIGHTS_EVENTS = { // Deletion - DELETE_HIGHLIGHT_MODAL: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_DELETE_CONTENT_PREFIX}`, + DELETE_HIGHLIGHT_MODAL: `${CONTENT_HIGHLIGHTS_DELETE_CONTENT_PREFIX}`, // Stepper Actions - STEPPER_HYPERLINK_CLICK: `${PROJECT_NAME}.${CONTENT_HIGHLIGHT_STEPPER_BASE_PREFIX}.help_center_program_optimization_hyperlink.clicked`, - STEPPER_STEP_CREATE_TITLE: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.create_title`, - STEPPER_SELECT_CONTENT_ABOUT_PAGE: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.select_content_about_page.clicked`, - STEPPER_CONFIRM_CONTENT_ABOUT_PAGE: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.confirm_content_about_page.clicked`, - STEPPER_CLOSE_STEPPER_INCOMPLETE: `${PROJECT_NAME}.${CONTENT_HIGHLIGHT_STEPPER_BASE_PREFIX}.close_without_saving.clicked`, - STEPPER_STEP_SELECT_CONTENT: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.select_content`, - STEPPER_STEP_CONFIRM_CONTENT: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.confirm_content.publish_btn.clicked`, + STEPPER_HYPERLINK_CLICK: `${CONTENT_HIGHLIGHT_STEPPER_BASE_PREFIX}.help_center_program_optimization_hyperlink.clicked`, + STEPPER_CLOSE_HIGHLIGHT_MODAL: `${CONTENT_HIGHLIGHT_STEPPER_BASE_PREFIX}.close_stepper_modal.clicked`, + STEPPER_CLOSE_HIGHLIGHT_MODAL_CANCEL: `${CONTENT_HIGHLIGHT_STEPPER_BASE_PREFIX}.close_stepper_modal.cancel.clicked`, + STEPPER_CLOSE_STEPPER_INCOMPLETE: `${CONTENT_HIGHLIGHT_STEPPER_BASE_PREFIX}.close_without_saving.clicked`, + STEPPER_SELECT_CONTENT_ABOUT_PAGE: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.select_content_about_page.clicked`, + STEPPER_CONFIRM_CONTENT_ABOUT_PAGE: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.confirm_content_about_page.clicked`, + STEPPER_STEP_CREATE_TITLE_NEXT: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.create_title.next.clicked`, + STEPPER_STEP_SELECT_CONTENT_NEXT: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.select_content.next.clicked`, + STEPPER_STEP_SELECT_CONTENT_BACK: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.select_content.back.clicked`, + STEPPER_STEP_CONFIRM_CONTENT_BACK: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.confirm_content.back.clicked`, + STEPPER_STEP_CONFIRM_CONTENT_DELETE: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.confirm_content.content_delete.clicked`, + STEPPER_STEP_CONFIRM_CONTENT_PUBLISH: `${CONTENT_HIGHLIGHTS_STEPPER_STEP_PREFIX}.confirm_content.publish_button.clicked`, // Dashboard - HIGHLIGHT_DASHBOARD_SET_ABOUT_PAGE: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.set_item_about_page.clicked`, - HIGHLIGHT_DASHBOARD_PUBLISHED_HIGHLIGHT_SET_CARD: `${PROJECT_NAME}.${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.published_highlight_set_card.clicked`, + HIGHLIGHT_DASHBOARD_SET_ABOUT_PAGE: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.set_item_about_page.clicked`, + HIGHLIGHT_DASHBOARD_PUBLISHED_HIGHLIGHT_SET_CARD: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.published_highlight_set_card.clicked`, // Highlight Creation - NEW_HIGHLIGHT: `${PROJECT_NAME}.create_new_content_highlight.clicked`, - PUBLISH_HIGHLIGHT: `${PROJECT_NAME}.publish_content_highlight.clicked`, + NEW_HIGHLIHT_MAX_REACHED: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.create_new_content_highlight.max_reached.clicked`, + NEW_HIGHLIGHT: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.create_new_content_highlight.clicked`, }; const SETTINGS_ACCESS_PREFIX = `${SETTINGS_PREFIX}.ACCESS`; From 692b2b996ed72119bdeed5cfd9a39409dc766a66 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Fri, 13 Jan 2023 13:59:49 -0500 Subject: [PATCH 38/73] fix: prevent addtl infinite API calls to license-manager (#953) --- src/components/subscriptions/data/hooks.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/components/subscriptions/data/hooks.js b/src/components/subscriptions/data/hooks.js index fe452d4bfc..100e3b68b3 100644 --- a/src/components/subscriptions/data/hooks.js +++ b/src/components/subscriptions/data/hooks.js @@ -92,7 +92,6 @@ const initialSubscriptionUsersOverview = { export const useSubscriptionUsersOverview = ({ subscriptionUUID, search, - errors, setErrors, isDisabled = false, }) => { @@ -117,15 +116,15 @@ export const useSubscriptionUsersOverview = ({ setSubscriptionUsersOverview(camelCaseObject(subscriptionUsersOverviewData)); } catch (err) { logError(err); - setErrors({ - ...errors, + setErrors(s => ({ + ...s, [SUBSCRIPTION_USERS_OVERVIEW]: NETWORK_ERROR_MESSAGE, - }); + })); } } }; fetchOverview(); - }, [errors, search, setErrors, subscriptionUUID]); + }, [search, setErrors, subscriptionUUID]); const forceRefresh = useCallback(() => { loadSubscriptionUsersOverview(); @@ -151,7 +150,6 @@ export const useSubscriptionUsers = ({ currentPage, searchQuery, subscriptionUUID, - errors, setErrors, userStatusFilter, isDisabled = false, @@ -181,10 +179,10 @@ export const useSubscriptionUsers = ({ setLoadingUsers(false); } catch (err) { logError(err); - setErrors({ - ...errors, + setErrors(s => ({ + ...s, [SUBSCRIPTION_USERS]: NETWORK_ERROR_MESSAGE, - }); + })); } finally { setLoadingUsers(false); } @@ -192,7 +190,6 @@ export const useSubscriptionUsers = ({ fetchUsers(); }, [ currentPage, - errors, searchQuery, setErrors, subscriptionUUID, From b5b8adcfc02de2709584ad96e7df3ea1f905fa8e Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Tue, 17 Jan 2023 11:58:19 +0500 Subject: [PATCH 39/73] feat: Enable sorting for the license table on LPR page (#954) --- .../Admin/SubscriptionDetailPage.jsx | 8 +++- src/components/Admin/SubscriptionDetails.jsx | 2 - .../licenses/LicenseAllocationDetails.jsx | 10 ++-- .../licenses/LicenseManagementTable/index.jsx | 47 ++++++++++++++----- .../SubscriptionDetailContextProvider.jsx | 10 ++-- .../subscriptions/data/constants.js | 5 ++ src/components/subscriptions/data/hooks.js | 6 +-- 7 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/components/Admin/SubscriptionDetailPage.jsx b/src/components/Admin/SubscriptionDetailPage.jsx index e0781e93a9..a71af876f9 100644 --- a/src/components/Admin/SubscriptionDetailPage.jsx +++ b/src/components/Admin/SubscriptionDetailPage.jsx @@ -26,10 +26,14 @@ export const SubscriptionDetailPage = ({ enterpriseSlug, match }) => { ); } return ( - + - + ); }; diff --git a/src/components/Admin/SubscriptionDetails.jsx b/src/components/Admin/SubscriptionDetails.jsx index 1addf6bbc0..71d556ac3e 100644 --- a/src/components/Admin/SubscriptionDetails.jsx +++ b/src/components/Admin/SubscriptionDetails.jsx @@ -7,7 +7,6 @@ import { } from '@edx/paragon'; import { Link } from 'react-router-dom'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { SubscriptionDetailContext } from '../subscriptions/SubscriptionDetailContextProvider'; import InviteLearnersButton from '../subscriptions/buttons/InviteLearnersButton'; import { SubscriptionContext } from '../subscriptions/SubscriptionData'; @@ -47,7 +46,6 @@ const SubscriptionDetails = ({ enterpriseSlug }) => {
diff --git a/src/components/Admin/licenses/LicenseAllocationDetails.jsx b/src/components/Admin/licenses/LicenseAllocationDetails.jsx index 7731d04b31..8eec391029 100644 --- a/src/components/Admin/licenses/LicenseAllocationDetails.jsx +++ b/src/components/Admin/licenses/LicenseAllocationDetails.jsx @@ -1,16 +1,20 @@ import React from 'react'; +import PropTypes from 'prop-types'; import LicenseAllocationHeader from './LicenseAllocationHeader'; import LicenseManagementTable from './LicenseManagementTable'; -const LicenseAllocationDetails = () => ( -
+const LicenseAllocationDetails = ({ subscriptionUUID }) => ( +
- +
); +LicenseAllocationDetails.propTypes = { + subscriptionUUID: PropTypes.string.isRequired, +}; export default LicenseAllocationDetails; diff --git a/src/components/Admin/licenses/LicenseManagementTable/index.jsx b/src/components/Admin/licenses/LicenseManagementTable/index.jsx index 7478d5fbca..4e3b0e17fd 100644 --- a/src/components/Admin/licenses/LicenseManagementTable/index.jsx +++ b/src/components/Admin/licenses/LicenseManagementTable/index.jsx @@ -1,3 +1,4 @@ +import _ from 'lodash'; import React, { useCallback, useMemo, useContext, useState, } from 'react'; @@ -11,10 +12,11 @@ import debounce from 'lodash.debounce'; import moment from 'moment'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import PropTypes from 'prop-types'; import { SubscriptionContext } from '../../../subscriptions/SubscriptionData'; import { SubscriptionDetailContext } from '../../../subscriptions/SubscriptionDetailContextProvider'; import { - DEFAULT_PAGE, ACTIVATED, REVOKED, ASSIGNED, + DEFAULT_PAGE, ACTIVATED, REVOKED, ASSIGNED, API_FIELDS_BY_TABLE_COLUMN_ACCESSOR, } from '../../../subscriptions/data/constants'; import { DEBOUNCE_TIME_MILLIS } from '../../../../algoliaUtils'; import { formatTimestamp } from '../../../../utils'; @@ -54,20 +56,21 @@ const selectColumn = { disableSortBy: true, }; -const LicenseManagementTable = () => { +const LicenseManagementTable = ({ subscriptionUUID }) => { const [showToast, setShowToast] = useState(false); const [toastMessage, setToastMessage] = useState(''); - const { forceRefresh: forceRefreshSubscription, } = useContext(SubscriptionContext); const { currentPage, + sortBy, overview, forceRefreshDetailView, setSearchQuery, setCurrentPage, + setSortBy, subscription, users, forceRefreshUsers, @@ -104,6 +107,17 @@ const LicenseManagementTable = () => { ); }, [subscription.enterpriseCustomerUuid]); + const applySortBy = (requestedSortBy) => requestedSortBy.map(({ id, desc }) => { + const apiFieldForColumnAccessor = API_FIELDS_BY_TABLE_COLUMN_ACCESSOR[id]; + if (!apiFieldForColumnAccessor) { + return id; + } + if (desc) { + return `-${apiFieldForColumnAccessor}`; + } + return apiFieldForColumnAccessor; + }); + // Filtering and pagination const updateFilters = useCallback((filters) => { if (filters.length < 1) { @@ -134,22 +148,23 @@ const LicenseManagementTable = () => { DEBOUNCE_TIME_MILLIS, ), [updateFilters]); - const debouncedSetCurrentPage = useMemo(() => debounce( - setCurrentPage, - DEBOUNCE_TIME_MILLIS, - ), [setCurrentPage]); - // Call back function, handles filters and page changes const fetchData = useCallback( (args) => { + if (args.sortBy?.length > 0) { + const sortByString = applySortBy(args.sortBy); + if (!_.isEqual(sortByString, sortBy)) { + setSortBy(sortByString); + } + } // pages index from 1 in backend, DataTable component index from 0 if (args.pageIndex !== currentPage - 1) { - debouncedSetCurrentPage(args.pageIndex + 1); + setCurrentPage(args.pageIndex + 1); sendPaginationEvent(currentPage - 1, args.pageIndex); } debouncedUpdateFilters(args.filters); }, - [currentPage, debouncedSetCurrentPage, debouncedUpdateFilters, sendPaginationEvent], + [currentPage, setCurrentPage, debouncedUpdateFilters, sortBy, setSortBy, sendPaginationEvent], ); // Maps user to rows @@ -196,9 +211,11 @@ const LicenseManagementTable = () => { }, [showSubscriptionZeroStateMessage]); return ( - <> +
{showSubscriptionZeroStateMessage && } { initialState={{ pageSize: 5, pageIndex: DEFAULT_PAGE - 1, + sortBy: [ + { id: 'statusBadge', desc: true }, + ], }} initialTableOptions={{ getRowId: row => row.id, @@ -308,8 +328,11 @@ const LicenseManagementTable = () => { {toastMessage && ( setShowToast(false)} show={showToast}>{toastMessage} )} - +
); }; +LicenseManagementTable.propTypes = { + subscriptionUUID: PropTypes.string.isRequired, +}; export default LicenseManagementTable; diff --git a/src/components/subscriptions/SubscriptionDetailContextProvider.jsx b/src/components/subscriptions/SubscriptionDetailContextProvider.jsx index 217cc2d9ee..90552f4d43 100644 --- a/src/components/subscriptions/SubscriptionDetailContextProvider.jsx +++ b/src/components/subscriptions/SubscriptionDetailContextProvider.jsx @@ -13,13 +13,14 @@ export const SubscriptionDetailContext = createContext({}); export const defaultStatusFilter = [ASSIGNED, ACTIVATED, REVOKED].join(); const SubscriptionDetailContextProvider = ({ - children, subscription, disableDataFetching, pageSize, licenseStatusOrdering, + children, subscription, disableDataFetching, pageSize, }) => { // Initialize state needed for the subscription detail view and provide in SubscriptionDetailContext const { data: subscriptions, errors, setErrors } = useContext(SubscriptionContext); const hasMultipleSubscriptions = subscriptions.count > 1; const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE); const [searchQuery, setSearchQuery] = useState(null); + const [sortBy, setSortBy] = useState(null); const [overview, forceRefreshOverview] = useSubscriptionUsersOverview({ subscriptionUUID: subscription.uuid, search: searchQuery, @@ -31,6 +32,7 @@ const SubscriptionDetailContextProvider = ({ const [users, forceRefreshUsers, loadingUsers] = useSubscriptionUsers({ currentPage, + sortBy, searchQuery, subscriptionUUID: subscription.uuid, errors, @@ -38,7 +40,6 @@ const SubscriptionDetailContextProvider = ({ userStatusFilter, isDisabled: disableDataFetching, pageSize, - licenseStatusOrdering, }); const forceRefreshDetailView = useCallback(() => { @@ -48,11 +49,13 @@ const SubscriptionDetailContextProvider = ({ const context = useMemo(() => ({ currentPage, + sortBy, hasMultipleSubscriptions, forceRefreshOverview, overview, searchQuery, setCurrentPage, + setSortBy, setSearchQuery, subscription, users, @@ -62,6 +65,7 @@ const SubscriptionDetailContextProvider = ({ forceRefreshDetailView, }), [ currentPage, + sortBy, searchQuery, hasMultipleSubscriptions, overview, @@ -86,13 +90,11 @@ SubscriptionDetailContextProvider.propTypes = { }).isRequired, disableDataFetching: PropTypes.bool, pageSize: PropTypes.number, - licenseStatusOrdering: PropTypes.string, }; SubscriptionDetailContextProvider.defaultProps = { disableDataFetching: false, pageSize: PAGE_SIZE, - licenseStatusOrdering: '', }; export default SubscriptionDetailContextProvider; diff --git a/src/components/subscriptions/data/constants.js b/src/components/subscriptions/data/constants.js index a182f72e95..0ae766c94e 100644 --- a/src/components/subscriptions/data/constants.js +++ b/src/components/subscriptions/data/constants.js @@ -1,5 +1,10 @@ export const PAGE_SIZE = 20; export const LPR_SUBSCRIPTION_PAGE_SIZE = 5; +export const API_FIELDS_BY_TABLE_COLUMN_ACCESSOR = { + emailLabel: 'user_email', + statusBadge: 'status', + recentAction: 'activation_date', +}; // Subscription license statuses as defined on the backend export const ACTIVATED = 'activated'; diff --git a/src/components/subscriptions/data/hooks.js b/src/components/subscriptions/data/hooks.js index 100e3b68b3..f78472f92e 100644 --- a/src/components/subscriptions/data/hooks.js +++ b/src/components/subscriptions/data/hooks.js @@ -148,13 +148,13 @@ export const useSubscriptionUsersOverview = ({ */ export const useSubscriptionUsers = ({ currentPage, + sortBy, searchQuery, subscriptionUUID, setErrors, userStatusFilter, isDisabled = false, pageSize, - licenseStatusOrdering, }) => { const [subscriptionUsers, setSubscriptionUsers] = useState({ ...subscriptionInitState }); const [loadingUsers, setLoadingUsers] = useState(true); @@ -168,7 +168,7 @@ export const useSubscriptionUsers = ({ const options = { status: userStatusFilter, page: currentPage, - license_status_lpr_ordering: licenseStatusOrdering, + ordering: sortBy, }; if (searchQuery) { options.search = searchQuery; @@ -190,12 +190,12 @@ export const useSubscriptionUsers = ({ fetchUsers(); }, [ currentPage, + sortBy, searchQuery, setErrors, subscriptionUUID, userStatusFilter, pageSize, - licenseStatusOrdering, ]); const forceRefresh = useCallback(() => { From 0d940e91ffdd6e4878008fca5fd1c83d7d48fa61 Mon Sep 17 00:00:00 2001 From: Ejaz Ahmad Date: Mon, 9 Jan 2023 15:12:28 +0500 Subject: [PATCH 40/73] feat: add file compression check and test --- .../ReportingConfig/ReportingConfigForm.jsx | 21 +++++++++++++++++++ .../ReportingConfigForm.test.jsx | 20 ++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/components/ReportingConfig/ReportingConfigForm.jsx b/src/components/ReportingConfig/ReportingConfigForm.jsx index e4e1bb1ac1..9a5916b0f1 100644 --- a/src/components/ReportingConfig/ReportingConfigForm.jsx +++ b/src/components/ReportingConfig/ReportingConfigForm.jsx @@ -45,6 +45,7 @@ class ReportingConfigForm extends React.Component { invalidFields: {}, APIErrors: {}, active: this.props.config ? this.props.config.active : false, + enableCompression: this.props.config ? this.props.config.enableCompression : true, submitState: SUBMIT_STATES.DEFAULT, }; @@ -174,6 +175,7 @@ class ReportingConfigForm extends React.Component { APIErrors, deliveryMethod, active, + enableCompression, submitState, } = this.state; const selectedCatalogs = (config?.enterpriseCustomerCatalogs || []).map(item => item.uuid); @@ -351,6 +353,24 @@ class ReportingConfigForm extends React.Component { handleBlur={this.handleBlur} /> )} +
+ + + this.setState(prevState => ({ enableCompression: !prevState.enableCompression }))} + /> + +
', () => { wrapper.find('.btn-outline-danger').at(0).simulate('click'); expect(mock).toHaveBeenCalled(); }); + it('check the compression checkbox is render or not', () => { + const wrapper = mount(( + + )); + expect(wrapper.exists('#enableCompression')).toEqual(true); + expect(wrapper.find('#enableCompression').hostNodes().prop('checked')).toEqual(true); + wrapper.find('#enableCompression').hostNodes().simulate('change', { target: { checked: false } }); + wrapper.update(); + expect(wrapper.find('#enableCompression').hostNodes().prop('checked')).toEqual(false); + }); it('renders the proper fields when changing the delivery method', () => { const wrapper = mount(( ', () => { wrapper.find('textarea#email').simulate('blur'); expect(wrapper.find('textarea#email').hasClass('is-invalid')).toBeTruthy(); }); - it('Does not submit if hourOfDay is empty', () => { const config = { ...defaultConfig }; config.hourOfDay = undefined; @@ -193,7 +210,6 @@ describe('', () => { wrapper.find('input#hourOfDay').simulate('blur'); expect(wrapper.find('input#hourOfDay').hasClass('is-invalid')).toBeTruthy(); }); - it('Does not submit if sftp fields are empty and deliveryMethod is sftp', () => { const config = { ...defaultConfig }; config.deliveryMethod = 'sftp'; From f42fce84da676b33964b4a5ca8623b9a4d8b2bf8 Mon Sep 17 00:00:00 2001 From: Muhammad Abdullah Waheed <42172960+abdullahwaheed@users.noreply.github.com> Date: Tue, 17 Jan 2023 17:08:54 +0500 Subject: [PATCH 41/73] Automate Browserlist DB Update (#869) * feat: added cron github action to auto update brwoserlist DB periodically * refactor: used a shared script to update broswerslist DB, create PR and automerge it Co-authored-by: Adam Stankiewicz --- .github/workflows/update-browserslist-db.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/update-browserslist-db.yml diff --git a/.github/workflows/update-browserslist-db.yml b/.github/workflows/update-browserslist-db.yml new file mode 100644 index 0000000000..db4346efe0 --- /dev/null +++ b/.github/workflows/update-browserslist-db.yml @@ -0,0 +1,12 @@ +name: Update Browserslist DB +on: + schedule: + - cron: '0 0 * * 1' + workflow_dispatch: + +jobs: + update-browserslist: + uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master + + secrets: + requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }} From e2b7731ebc392f8f7935ea244ba86d8ea5fb9f38 Mon Sep 17 00:00:00 2001 From: Zaman Afzal Date: Mon, 23 Jan 2023 17:04:00 +0500 Subject: [PATCH 42/73] refactor: Fix link and remove download button (#956) --- src/components/Admin/EmbeddedSubscription.jsx | 19 +++++++++++++++---- .../Admin/SubscriptionDetailPage.jsx | 1 + .../licenses/LicenseManagementTable/index.jsx | 12 ------------ .../SubscriptionDetailContextProvider.jsx | 11 +++++++---- .../subscriptions/data/constants.js | 1 + 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/components/Admin/EmbeddedSubscription.jsx b/src/components/Admin/EmbeddedSubscription.jsx index 8561873617..22d73e3a28 100644 --- a/src/components/Admin/EmbeddedSubscription.jsx +++ b/src/components/Admin/EmbeddedSubscription.jsx @@ -1,8 +1,8 @@ import React, { useContext, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; import { Form, Icon } from '@edx/paragon'; import moment from 'moment'; -import { Lightbulb } from '@edx/paragon/icons'; +import { Lightbulb, ArrowOutward } from '@edx/paragon/icons'; import ConnectedSubscriptionDetailPage from './SubscriptionDetailPage'; import { SubscriptionContext } from '../subscriptions/SubscriptionData'; import { sortSubscriptionsByStatus } from '../subscriptions/data/utils'; @@ -29,6 +29,7 @@ const EmbeddedSubscription = () => { if (loading) { return ; } + const bestPracticesUrl = 'https://business.edx.org/hubfs/Onboarding and Engagement/Engagement Assets/Key Timelines & Best Practices.pdf'; return (
{ @@ -62,8 +63,18 @@ const EmbeddedSubscription = () => { performance.
- Learn more helpful tips in Best Practices. - +
+ Learn more helpful tips in + + + Best Practices + + + +
) } diff --git a/src/components/Admin/SubscriptionDetailPage.jsx b/src/components/Admin/SubscriptionDetailPage.jsx index a71af876f9..dde917dae8 100644 --- a/src/components/Admin/SubscriptionDetailPage.jsx +++ b/src/components/Admin/SubscriptionDetailPage.jsx @@ -30,6 +30,7 @@ export const SubscriptionDetailPage = ({ enterpriseSlug, match }) => { key={subscription.uuid} subscription={subscription} pageSize={LPR_SUBSCRIPTION_PAGE_SIZE} + lprSubscriptionPage > diff --git a/src/components/Admin/licenses/LicenseManagementTable/index.jsx b/src/components/Admin/licenses/LicenseManagementTable/index.jsx index 4e3b0e17fd..e39c4fa291 100644 --- a/src/components/Admin/licenses/LicenseManagementTable/index.jsx +++ b/src/components/Admin/licenses/LicenseManagementTable/index.jsx @@ -21,7 +21,6 @@ import { import { DEBOUNCE_TIME_MILLIS } from '../../../../algoliaUtils'; import { formatTimestamp } from '../../../../utils'; import SubscriptionZeroStateMessage from '../../../subscriptions/SubscriptionZeroStateMessage'; -import DownloadCsvButton from '../../../subscriptions/buttons/DownloadCsvButton'; import EnrollBulkAction from '../../../subscriptions/licenses/LicenseManagementTable/bulk-actions/EnrollBulkAction'; import RemindBulkAction from '../../../subscriptions/licenses/LicenseManagementTable/bulk-actions/RemindBulkAction'; import RevokeBulkAction from '../../../subscriptions/licenses/LicenseManagementTable/bulk-actions/RevokeBulkAction'; @@ -203,13 +202,6 @@ const LicenseManagementTable = ({ subscriptionUUID }) => { const showSubscriptionZeroStateMessage = subscription.licenses.total === subscription.licenses.unassigned; - const tableActions = useMemo(() => { - if (showSubscriptionZeroStateMessage) { - return []; - } - return []; - }, [showSubscriptionZeroStateMessage]); - return (
{showSubscriptionZeroStateMessage && } @@ -228,13 +220,9 @@ const LicenseManagementTable = ({ subscriptionUUID }) => { manualPagination itemCount={users.count} pageCount={users.numPages || 1} - tableActions={tableActions} initialState={{ pageSize: 5, pageIndex: DEFAULT_PAGE - 1, - sortBy: [ - { id: 'statusBadge', desc: true }, - ], }} initialTableOptions={{ getRowId: row => row.id, diff --git a/src/components/subscriptions/SubscriptionDetailContextProvider.jsx b/src/components/subscriptions/SubscriptionDetailContextProvider.jsx index 90552f4d43..cf5be42a7f 100644 --- a/src/components/subscriptions/SubscriptionDetailContextProvider.jsx +++ b/src/components/subscriptions/SubscriptionDetailContextProvider.jsx @@ -4,23 +4,24 @@ import React, { import PropTypes from 'prop-types'; import { DEFAULT_PAGE, ACTIVATED, REVOKED, ASSIGNED, - PAGE_SIZE, + PAGE_SIZE, LPR_DEFAULT_SORT, } from './data/constants'; import { useSubscriptionUsersOverview, useSubscriptionUsers } from './data/hooks'; import { SubscriptionContext } from './SubscriptionData'; export const SubscriptionDetailContext = createContext({}); export const defaultStatusFilter = [ASSIGNED, ACTIVATED, REVOKED].join(); +export const lprStatusFilter = [ASSIGNED, ACTIVATED].join(); const SubscriptionDetailContextProvider = ({ - children, subscription, disableDataFetching, pageSize, + children, subscription, disableDataFetching, pageSize, lprSubscriptionPage, }) => { // Initialize state needed for the subscription detail view and provide in SubscriptionDetailContext const { data: subscriptions, errors, setErrors } = useContext(SubscriptionContext); const hasMultipleSubscriptions = subscriptions.count > 1; const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE); const [searchQuery, setSearchQuery] = useState(null); - const [sortBy, setSortBy] = useState(null); + const [sortBy, setSortBy] = useState(lprSubscriptionPage ? LPR_DEFAULT_SORT : ''); const [overview, forceRefreshOverview] = useSubscriptionUsersOverview({ subscriptionUUID: subscription.uuid, search: searchQuery, @@ -28,7 +29,7 @@ const SubscriptionDetailContextProvider = ({ setErrors, isDisabled: disableDataFetching, }); - const [userStatusFilter, setUserStatusFilter] = useState(defaultStatusFilter); + const [userStatusFilter, setUserStatusFilter] = useState(lprSubscriptionPage ? lprStatusFilter : defaultStatusFilter); const [users, forceRefreshUsers, loadingUsers] = useSubscriptionUsers({ currentPage, @@ -90,11 +91,13 @@ SubscriptionDetailContextProvider.propTypes = { }).isRequired, disableDataFetching: PropTypes.bool, pageSize: PropTypes.number, + lprSubscriptionPage: PropTypes.bool, }; SubscriptionDetailContextProvider.defaultProps = { disableDataFetching: false, pageSize: PAGE_SIZE, + lprSubscriptionPage: false, }; export default SubscriptionDetailContextProvider; diff --git a/src/components/subscriptions/data/constants.js b/src/components/subscriptions/data/constants.js index 0ae766c94e..bb32bb99fe 100644 --- a/src/components/subscriptions/data/constants.js +++ b/src/components/subscriptions/data/constants.js @@ -5,6 +5,7 @@ export const API_FIELDS_BY_TABLE_COLUMN_ACCESSOR = { statusBadge: 'status', recentAction: 'activation_date', }; +export const LPR_DEFAULT_SORT = '-status'; // Subscription license statuses as defined on the backend export const ACTIVATED = 'activated'; From 3b2c5bf78d6a68344177967eb722a98159f0ca2b Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Wed, 25 Jan 2023 08:10:31 -0500 Subject: [PATCH 43/73] chore: update browserslist DB (#958) Co-authored-by: abdullahwaheed Co-authored-by: Adam Stankiewicz --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4a7f0e7d8..6002f13197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8798,9 +8798,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001431", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz", - "integrity": "sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ==", + "version": "1.0.30001446", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001446.tgz", + "integrity": "sha512-fEoga4PrImGcwUUGEol/PoFCSBnSkA9drgdkxXkJLsUBOnJ8rs3zDv6ApqYXGQFOyMPsjh79naWhF4DAxbF8rw==", "funding": [ { "type": "opencollective", @@ -32789,9 +32789,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001431", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz", - "integrity": "sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ==" + "version": "1.0.30001446", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001446.tgz", + "integrity": "sha512-fEoga4PrImGcwUUGEol/PoFCSBnSkA9drgdkxXkJLsUBOnJ8rs3zDv6ApqYXGQFOyMPsjh79naWhF4DAxbF8rw==" }, "capture-exit": { "version": "2.0.0", diff --git a/package.json b/package.json index 590e401f39..fbfeff0a32 100644 --- a/package.json +++ b/package.json @@ -106,4 +106,4 @@ "react-test-renderer": "16.13.1", "resize-observer-polyfill": "1.5.1" } -} \ No newline at end of file +} From c27e2295888eabdfe75455030dee0d3185d47b74 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 26 Jan 2023 09:18:13 -0500 Subject: [PATCH 44/73] fix: Input reload fix implemented (#962) * fix: Input reload fix implemented * chore: PR fixes * chore: final pr fixes --- .../HighlightStepperTitleInput.jsx | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx index 03b6f90e45..ab05422b3b 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitleInput.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useContextSelector } from 'use-context-selector'; import { Form } from '@edx/paragon'; @@ -11,37 +11,63 @@ const HighlightStepperTitleInput = () => { const highlightTitle = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.highlightTitle); const [titleLength, setTitleLength] = useState(highlightTitle?.length || 0); const [isInvalid, setIsInvalid] = useState(false); - + /** + * Create seperate useState for FormInput as to differenciate between + * the outer stepper context state and inner input useState. + * */ + const [highlightValue, setHighlightValue] = useState({ + initialized: false, + highlightTitle: highlightTitle || '', + titleStepValidationError: undefined, + highlightTitleLength: titleLength || 0, + }); const handleChange = (e) => { - if (e.target.value.length > MAX_HIGHLIGHT_TITLE_LENGTH) { + const eventTargetValue = e.target.value; + if (eventTargetValue.length > MAX_HIGHLIGHT_TITLE_LENGTH) { setIsInvalid(true); - setHighlightTitle({ - highlightTitle: e.target.value, + setHighlightValue({ + initialized: true, + highlightTitle: eventTargetValue, titleStepValidationError: DEFAULT_ERROR_MESSAGE.EXCEEDS_HIGHLIGHT_TITLE_LENGTH, + highlightTitleLength: eventTargetValue.length, }); } else { setIsInvalid(false); - setHighlightTitle({ - highlightTitle: e.target.value, + setHighlightValue({ + initialized: true, + highlightTitle: eventTargetValue, titleStepValidationError: undefined, + highlightTitleLength: eventTargetValue.length, }); } - setTitleLength(e.target.value.length); }; + useEffect(() => { + if (highlightValue.initialized) { + setHighlightTitle({ + highlightTitle: highlightValue.highlightTitle, + titleStepValidationError: highlightValue.titleStepValidationError, + }); + setTitleLength(highlightValue.highlightTitleLength); + setHighlightValue(prevState => ({ + ...prevState, + initialized: false, + })); + } + }, [highlightTitle, setHighlightTitle, highlightValue]); return ( - {titleLength}/{MAX_HIGHLIGHT_TITLE_LENGTH} + {highlightValue.highlightTitleLength}/{MAX_HIGHLIGHT_TITLE_LENGTH} ); From 788ba3311a7dab28561767ad96a5367f8360407e Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 26 Jan 2023 10:57:25 -0500 Subject: [PATCH 45/73] feat: Add tab layout for highlight dashboard (#937) * feat: Add tab layout for highlight dashboard * chore: test * chore: formatting * chore: sanitizedHTML package and styling on alert button * chore: reintroduce zerostate * feat: Added api response from enterprise catalog * feat: API integration * chore: update remote * feat: toast/segment event on catalog visibility update * feat: Added API response failure alert * chore: file cleanup and ui update * chore: test pt1 * chore: test pt 2 * chore: test pt ? * chore: test * fix: adds conditional * chore: fix wrong commit * chore: PR fix * chore: cleanup * chore: update tests * chore: test fix --- package-lock.json | 213 ++++++++++++++++-- package.json | 2 + .../ContentHighlightCatalogVisibility.jsx | 13 ++ ...ContentHighlightCatalogVisibilityAlert.jsx | 93 ++++++++ ...ontentHighlightCatalogVisibilityHeader.jsx | 19 ++ ...ntHighlightCatalogVisibilityRadioInput.jsx | 170 ++++++++++++++ .../CatalogVisibility/index.js | 3 + ...ntHighlightCatalogVisibilityAlert.test.jsx | 120 ++++++++++ ...hlightCatalogVisibilityRadioInput.test.jsx | 159 +++++++++++++ .../ContentHighlights/ContentHighlights.jsx | 9 + .../ContentHighlightsDashboard.jsx | 45 ++-- .../CurrentContentHighlights.jsx | 2 +- .../ContentHighlights/DeleteHighlightSet.jsx | 2 +- .../ContentHighlightStepper.jsx | 2 +- .../HighlightStepperSelectContentHeader.jsx | 8 +- .../HighlightStepperTitle.jsx | 8 +- .../tests/ContentHighlightStepper.test.jsx | 33 +-- .../ZeroState/ZeroStateHighlights.jsx | 18 +- .../ContentHighlights/data/constants.js | 53 +++++ .../ContentHighlights/data/hooks.js | 8 + .../data/tests/constants.test.js | 42 +++- .../tests/ContentHighlightSetCard.test.jsx | 5 +- .../tests/ContentHighlights.test.jsx | 17 +- .../tests/ContentHighlightsDashboard.test.jsx | 36 ++- .../tests/CurrentContentHighlights.test.jsx | 4 +- .../tests/DeleteHighlightSet.test.jsx | 2 +- .../EnterpriseAppContextProvider.jsx | 1 + .../data/enterpriseCurationReducer.js | 20 +- .../data/enterpriseCurationReducer.test.js | 21 +- .../data/hooks/useEnterpriseCuration.js | 19 ++ .../data/hooks/useEnterpriseCuration.test.js | 91 +++++++- .../hooks/useEnterpriseCurationContext.js | 4 +- .../services/EnterpriseCatalogApiService.js | 10 + src/eventTracking.js | 1 + 34 files changed, 1162 insertions(+), 91 deletions(-) create mode 100644 src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibility.jsx create mode 100644 src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityAlert.jsx create mode 100644 src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityHeader.jsx create mode 100644 src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityRadioInput.jsx create mode 100644 src/components/ContentHighlights/CatalogVisibility/index.js create mode 100644 src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityAlert.test.jsx create mode 100644 src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityRadioInput.test.jsx diff --git a/package-lock.json b/package-lock.json index 6002f13197..de173d2cfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "file-saver": "1.3.8", "font-awesome": "4.7.0", "history": "4.10.1", + "html-react-parser": "3.0.7", "jest-environment-jsdom": "26.6.1", "lodash": "4.17.21", "lodash.debounce": "4.0.8", @@ -54,6 +55,7 @@ "redux-mock-store": "1.5.4", "redux-thunk": "2.3.0", "regenerator-runtime": "0.13.7", + "sanitize-html": "2.8.1", "scheduler": "^0.23.0", "timeago.js": "^4.0.2", "universal-cookie": "4.0.4", @@ -10137,7 +10139,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -10466,7 +10467,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -10510,7 +10510,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, "dependencies": { "domelementtype": "^2.3.0" }, @@ -10525,7 +10524,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", - "dev": true, "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -10746,7 +10744,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==", - "dev": true, "engines": { "node": ">=0.12" }, @@ -13701,6 +13698,15 @@ "wbuf": "^1.1.0" } }, + "node_modules/html-dom-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-3.1.2.tgz", + "integrity": "sha512-mLTtl3pVn3HnqZSZzW3xVs/mJAKrG1yIw3wlp+9bdoZHHLaBRvELdpfShiPVLyjPypq1Fugv2KMDoGHW4lVXnw==", + "dependencies": { + "domhandler": "5.0.3", + "htmlparser2": "8.0.1" + } + }, "node_modules/html-element-map": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", @@ -13758,6 +13764,20 @@ "node": ">=6" } }, + "node_modules/html-react-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-3.0.7.tgz", + "integrity": "sha512-4Nzpp1Lsd6ngOJR8T+Vc4u+Z77OddOgKL3KvwbtA0/U0Yv8v5JF+yewQxIudrdOWGYuO0Borc0vQ2y53pzBAwA==", + "dependencies": { + "domhandler": "5.0.3", + "html-dom-parser": "3.1.2", + "react-property": "2.0.0", + "style-to-js": "1.1.2" + }, + "peerDependencies": { + "react": "0.14 || 15 || 16 || 17 || 18" + } + }, "node_modules/html-webpack-plugin": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.0.tgz", @@ -13811,7 +13831,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", - "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -18759,7 +18778,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", - "dev": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -19704,6 +19722,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "node_modules/parse5": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.1.tgz", @@ -21238,6 +21261,11 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, + "node_modules/react-property": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz", + "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==" + }, "node_modules/react-proptype-conditional-require": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz", @@ -22581,6 +22609,61 @@ "node": ">=0.10.0" } }, + "node_modules/sanitize-html": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.8.1.tgz", + "integrity": "sha512-qK5neD0SaMxGwVv5txOYv05huC3o6ZAA4h5+7nJJgWMNFUNRjcjLO6FpwAtKzfKCZ0jrG6xTk6eVFskbvOGblg==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sanitize-html/node_modules/postcss": { + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/sass": { "version": "1.49.9", "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", @@ -23347,7 +23430,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -23908,6 +23990,22 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/style-to-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.2.tgz", + "integrity": "sha512-aMG8jJpEF0SCGbQFY8W8CT+EjQ9ubp35FOZG3prWkNjxW/a1bEeSod0tkWiP+6iiOCDIIrQykUDkPY5LbNF87g==", + "dependencies": { + "style-to-object": "0.4.0" + } + }, + "node_modules/style-to-js/node_modules/style-to-object": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.0.tgz", + "integrity": "sha512-dAjq2m87tPn/TcYTeqMhXJRhu96WYWcxMFQxs3Y9jfYpq2jG+38u4tj0Lst6DOiYXmDuNxVJ2b1Z2uPC6wTEeg==", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, "node_modules/style-to-object": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", @@ -33829,8 +33927,7 @@ "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" }, "default-gateway": { "version": "6.0.3", @@ -34085,7 +34182,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, "requires": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -34116,7 +34212,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, "requires": { "domelementtype": "^2.3.0" } @@ -34125,7 +34220,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", - "dev": true, "requires": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -34304,8 +34398,7 @@ "entities": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", - "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==", - "dev": true + "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==" }, "envinfo": { "version": "7.8.1", @@ -36597,6 +36690,15 @@ "wbuf": "^1.1.0" } }, + "html-dom-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-3.1.2.tgz", + "integrity": "sha512-mLTtl3pVn3HnqZSZzW3xVs/mJAKrG1yIw3wlp+9bdoZHHLaBRvELdpfShiPVLyjPypq1Fugv2KMDoGHW4lVXnw==", + "requires": { + "domhandler": "5.0.3", + "htmlparser2": "8.0.1" + } + }, "html-element-map": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", @@ -36642,6 +36744,17 @@ "terser": "^4.6.3" } }, + "html-react-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-3.0.7.tgz", + "integrity": "sha512-4Nzpp1Lsd6ngOJR8T+Vc4u+Z77OddOgKL3KvwbtA0/U0Yv8v5JF+yewQxIudrdOWGYuO0Borc0vQ2y53pzBAwA==", + "requires": { + "domhandler": "5.0.3", + "html-dom-parser": "3.1.2", + "react-property": "2.0.0", + "style-to-js": "1.1.2" + } + }, "html-webpack-plugin": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.0.tgz", @@ -36685,7 +36798,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", - "dev": true, "requires": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -40432,8 +40544,7 @@ "nanoid": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", - "dev": true + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" }, "nanomatch": { "version": "1.2.13", @@ -41142,6 +41253,11 @@ "lines-and-columns": "^1.1.6" } }, + "parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "parse5": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.1.tgz", @@ -42307,6 +42423,11 @@ } } }, + "react-property": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz", + "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==" + }, "react-proptype-conditional-require": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz", @@ -43312,6 +43433,41 @@ } } }, + "sanitize-html": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.8.1.tgz", + "integrity": "sha512-qK5neD0SaMxGwVv5txOYv05huC3o6ZAA4h5+7nJJgWMNFUNRjcjLO6FpwAtKzfKCZ0jrG6xTk6eVFskbvOGblg==", + "requires": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "dependencies": { + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + }, + "postcss": { + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "requires": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + } + } + }, "sass": { "version": "1.49.9", "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", @@ -43937,8 +44093,7 @@ "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" }, "source-map-loader": { "version": "0.2.4", @@ -44392,6 +44547,24 @@ "schema-utils": "^3.0.0" } }, + "style-to-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.2.tgz", + "integrity": "sha512-aMG8jJpEF0SCGbQFY8W8CT+EjQ9ubp35FOZG3prWkNjxW/a1bEeSod0tkWiP+6iiOCDIIrQykUDkPY5LbNF87g==", + "requires": { + "style-to-object": "0.4.0" + }, + "dependencies": { + "style-to-object": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.0.tgz", + "integrity": "sha512-dAjq2m87tPn/TcYTeqMhXJRhu96WYWcxMFQxs3Y9jfYpq2jG+38u4tj0Lst6DOiYXmDuNxVJ2b1Z2uPC6wTEeg==", + "requires": { + "inline-style-parser": "0.1.1" + } + } + } + }, "style-to-object": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", diff --git a/package.json b/package.json index fbfeff0a32..d4eab248fd 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "file-saver": "1.3.8", "font-awesome": "4.7.0", "history": "4.10.1", + "html-react-parser": "3.0.7", "jest-environment-jsdom": "26.6.1", "lodash": "4.17.21", "lodash.debounce": "4.0.8", @@ -68,6 +69,7 @@ "redux-mock-store": "1.5.4", "redux-thunk": "2.3.0", "regenerator-runtime": "0.13.7", + "sanitize-html": "2.8.1", "scheduler": "^0.23.0", "timeago.js": "^4.0.2", "universal-cookie": "4.0.4", diff --git a/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibility.jsx b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibility.jsx new file mode 100644 index 0000000000..45bda16bae --- /dev/null +++ b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibility.jsx @@ -0,0 +1,13 @@ +import ContentHighlightCatalogVisibilityAlert from './ContentHighlightCatalogVisibilityAlert'; +import ContentHighlightCatalogVisibilityHeader from './ContentHighlightCatalogVisibilityHeader'; +import ContentHighlightCatalogVisibilityRadioInput from './ContentHighlightCatalogVisibilityRadioInput'; + +const ContentHighlightCatalogVisibility = () => ( + <> + + + + +); + +export default ContentHighlightCatalogVisibility; diff --git a/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityAlert.jsx b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityAlert.jsx new file mode 100644 index 0000000000..97d3dde8ee --- /dev/null +++ b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityAlert.jsx @@ -0,0 +1,93 @@ +import React, { useContext } from 'react'; +import { + Alert, Button, Row, Col, +} from '@edx/paragon'; +import { Info, Add } from '@edx/paragon/icons'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { useContextSelector } from 'use-context-selector'; +import { ALERT_TEXT, BUTTON_TEXT } from '../data/constants'; +import { useContentHighlightsContext } from '../data/hooks'; +import EVENT_NAMES from '../../../eventTracking'; +import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; + +const ContentHighlightCatalogVisibilityAlert = () => { + const { openStepperModal } = useContentHighlightsContext(); + const { + enterpriseCuration: { + enterpriseCuration: { + highlightSets, enterpriseCustomer, + }, + }, + } = useContext(EnterpriseAppContext); + const catalogVisibilityAlertOpen = useContextSelector( + ContentHighlightsContext, + v => v[0].catalogVisibilityAlertOpen, + ); + const handleNewHighlightClick = () => { + const trackInfo = { + existing_highlight_set_uuids: highlightSets.map(set => set.uuid), + existing_highlight_set_count: highlightSets.length, + }; + sendEnterpriseTrackEvent( + enterpriseCustomer, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.NEW_HIGHLIGHT}`, + trackInfo, + ); + + openStepperModal(); + }; + + if (catalogVisibilityAlertOpen) { + return ( + + + + + {ALERT_TEXT.HEADER_TEXT.catalogVisibilityAPI} + +

+ {ALERT_TEXT.SUB_TEXT.catalogVisibilityAPI} +

+
+ +
+ ); + } + + if (highlightSets.length > 0) { + return null; + } + + return ( + + + + {BUTTON_TEXT.catalogVisibility} + , + ]} + > + + {ALERT_TEXT.HEADER_TEXT.catalogVisibility} + +

+ {ALERT_TEXT.SUB_TEXT.catalogVisibility} +

+
+ +
+ ); +}; + +export default ContentHighlightCatalogVisibilityAlert; diff --git a/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityHeader.jsx b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityHeader.jsx new file mode 100644 index 0000000000..c63f8f676d --- /dev/null +++ b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityHeader.jsx @@ -0,0 +1,19 @@ +import { HEADER_TEXT } from '../data/constants'; + +const ContentHighlightCatalogVisibilityHeader = () => ( +
+

+ {HEADER_TEXT.catalogVisibility} +

+

+ {HEADER_TEXT.SUB_TEXT.catalogVisibility} +

+

+ + {HEADER_TEXT.PRO_TIP_TEXT.catalogVisibility} + +

+
+); + +export default ContentHighlightCatalogVisibilityHeader; diff --git a/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityRadioInput.jsx b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityRadioInput.jsx new file mode 100644 index 0000000000..45c320f148 --- /dev/null +++ b/src/components/ContentHighlights/CatalogVisibility/ContentHighlightCatalogVisibilityRadioInput.jsx @@ -0,0 +1,170 @@ +import { + Form, Container, Spinner, +} from '@edx/paragon'; +import { useState, useContext, useEffect } from 'react'; +import { ActionRowSpacer } from '@edx/paragon/dist/ActionRow'; +import { logError } from '@edx/frontend-platform/logging'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { useHistory } from 'react-router-dom'; +import { ALERT_TEXT, BUTTON_TEXT, LEARNER_PORTAL_CATALOG_VISIBILITY } from '../data/constants'; +import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { enterpriseCurationActions } from '../../EnterpriseApp/data/enterpriseCurationReducer'; +import EVENT_NAMES from '../../../eventTracking'; +import { useContentHighlightsContext } from '../data/hooks'; + +const ContentHighlightCatalogVisibilityRadioInput = () => { + const { setCatalogVisibilityAlert } = useContentHighlightsContext(); + const { + enterpriseCuration: { + enterpriseCuration, + updateEnterpriseCuration, + dispatch, + }, + } = useContext(EnterpriseAppContext); + const { highlightSets, canOnlyViewHighlightSets } = enterpriseCuration; + const [radioGroupVisibility, setRadioGroupVisibility] = useState(true); + const history = useHistory(); + const { location } = history; + const [isEntireCatalogSelectionLoading, setIsEntireCatalogSelectionLoading] = useState(false); + const [isHighlightsCatalogSelectionLoading, setIsHighlightsCatalogSelectionLoading] = useState(false); + + /** + * Sets enterpriseCuration.canOnlyViewHighlightSets to false if there are no highlight sets + * when the user enters content highlights dashboard. + */ + useEffect(() => { + if (highlightSets.length < 1 && canOnlyViewHighlightSets) { + const setDefault = async () => { + try { + await updateEnterpriseCuration({ + canOnlyViewHighlightSets: false, + }); + } catch (error) { + logError(`${error}: Error updating enterprise curation setting with no highlight sets, + ContentHighlightCatalogVsibiilityRadioInput`); + } + }; + setDefault(); + } + }, [canOnlyViewHighlightSets, highlightSets.length, updateEnterpriseCuration]); + // Sets default radio button based on number of highlight sets && catalog visibility setting + const catalogVisibilityValue = !canOnlyViewHighlightSets || highlightSets.length < 1 + ? LEARNER_PORTAL_CATALOG_VISIBILITY.ALL_CONTENT.value + : LEARNER_PORTAL_CATALOG_VISIBILITY.HIGHLIGHTED_CONTENT.value; + const [value, setValue] = useState(catalogVisibilityValue); + + const handleChange = async (e) => { + const newTabValue = e.target.value; + try { + // Show loading spinner + if (newTabValue === LEARNER_PORTAL_CATALOG_VISIBILITY.ALL_CONTENT.value) { + setIsEntireCatalogSelectionLoading(true); + setIsHighlightsCatalogSelectionLoading(false); + } + if (newTabValue === LEARNER_PORTAL_CATALOG_VISIBILITY.HIGHLIGHTED_CONTENT.value) { + setIsHighlightsCatalogSelectionLoading(true); + setIsEntireCatalogSelectionLoading(false); + } + const data = await updateEnterpriseCuration({ + canOnlyViewHighlightSets: LEARNER_PORTAL_CATALOG_VISIBILITY[newTabValue].canOnlyViewHighlightSets, + }); + // Send Track Event + const trackInfo = { + can_only_view_highlight_sets: LEARNER_PORTAL_CATALOG_VISIBILITY[newTabValue].canOnlyViewHighlightSets, + }; + sendEnterpriseTrackEvent( + enterpriseCuration.enterpriseCustomer, + EVENT_NAMES.CONTENT_HIGHLIGHTS.HIGHLIGHT_DASHBOARD_SET_CATALOG_VISIBILITY, + trackInfo, + ); + // Set toast and closes alert if open + if (data) { + setCatalogVisibilityAlert({ + isOpen: false, + }); + dispatch(enterpriseCurationActions.setHighlightToast(ALERT_TEXT.TOAST_TEXT.catalogVisibility)); + history.push(location.pathname, { + highlightToast: true, + }); + setValue(newTabValue); + } + } catch (error) { + logError(error); + setCatalogVisibilityAlert({ + isOpen: true, + }); + } finally { + // Hide loading spinner + setIsEntireCatalogSelectionLoading(false); + setIsHighlightsCatalogSelectionLoading(false); + } + }; + useEffect(() => { + if (highlightSets.length > 0) { + setRadioGroupVisibility(false); + } + }, [highlightSets]); + + return ( + + + +
+ {isEntireCatalogSelectionLoading && ( + + )} + + + {BUTTON_TEXT.catalogVisibilityRadio1} + +
+
+ {isHighlightsCatalogSelectionLoading && ( + + )} + + + {BUTTON_TEXT.catalogVisibilityRadio2} + +
+
+
+
+ ); +}; + +export default ContentHighlightCatalogVisibilityRadioInput; diff --git a/src/components/ContentHighlights/CatalogVisibility/index.js b/src/components/ContentHighlights/CatalogVisibility/index.js new file mode 100644 index 0000000000..930bea19a0 --- /dev/null +++ b/src/components/ContentHighlights/CatalogVisibility/index.js @@ -0,0 +1,3 @@ +import ContentHighlightCatalogVisibility from './ContentHighlightCatalogVisibility'; + +export default ContentHighlightCatalogVisibility; diff --git a/src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityAlert.test.jsx b/src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityAlert.test.jsx new file mode 100644 index 0000000000..55c843c065 --- /dev/null +++ b/src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityAlert.test.jsx @@ -0,0 +1,120 @@ +/* eslint-disable react/prop-types */ +import { screen } from '@testing-library/dom'; +import { renderWithRouter, sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import React, { useState } from 'react'; +import thunk from 'redux-thunk'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { camelCaseObject } from '@edx/frontend-platform'; +import userEvent from '@testing-library/user-event'; +import { EnterpriseAppContext } from '../../../EnterpriseApp/EnterpriseAppContextProvider'; +import { ContentHighlightsContext } from '../../ContentHighlightsContext'; +import { + BUTTON_TEXT, ALERT_TEXT, TEST_COURSE_HIGHLIGHTS_DATA, STEPPER_STEP_TEXT, +} from '../../data/constants'; +import ContentHighlightCatalogVisibilityAlert from '../ContentHighlightCatalogVisibilityAlert'; +import ContentHighlightStepper from '../../HighlightStepper/ContentHighlightStepper'; + +const mockStore = configureMockStore([thunk]); +const mockHighlightSetResponse = camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA); + +const initialState = { + portalConfiguration: { + enterpriseSlug: 'test-enterprise', + enterpriseId: 'test-enterprise-id', + }, +}; + +const initialEnterpriseAppContextValue = { + enterpriseCuration: { + enterpriseCuration: { + highlightSets: mockHighlightSetResponse, + canOnlyViewHighlightSets: false, + }, + }, +}; +const noHighlightsAppContext = { + ...initialEnterpriseAppContextValue, + enterpriseCuration: { + enterpriseCuration: { + highlightSets: [], + }, + }, +}; + +const ContentHighlightCatalogVisibilityAlertWrapper = ({ + enterpriseAppContextValue = initialEnterpriseAppContextValue, + highlightSets = [], + catalogVisibility = false, +}) => { + const contextValue = useState({ + contentHighlights: highlightSets, + catalogVisibilityAlertOpen: catalogVisibility, + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: {}, + }, + }); + + return ( + + + + + + + + + + + ); +}; + +jest.mock('@edx/frontend-enterprise-utils', () => { + const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); + return ({ + ...originalModule, + sendEnterpriseTrackEvent: jest.fn(), + }); +}); + +describe('ContentHighlightCatalogVisibilityAlert', () => { + it('renders API response failure when catalogVisibilityAlertOpen context true', () => { + renderWithRouter( + , + ); + expect(screen.getByText(ALERT_TEXT.HEADER_TEXT.catalogVisibilityAPI)).toBeTruthy(); + expect(screen.getByText(ALERT_TEXT.SUB_TEXT.catalogVisibilityAPI)).toBeTruthy(); + }); + it('renders no highlights alert when highlight sets length is 0', () => { + renderWithRouter( + , + ); + expect(screen.getByText(ALERT_TEXT.HEADER_TEXT.catalogVisibility)).toBeTruthy(); + expect(screen.getByText(ALERT_TEXT.SUB_TEXT.catalogVisibility)).toBeTruthy(); + expect(screen.getByText(BUTTON_TEXT.catalogVisibility)).toBeTruthy(); + }); + it('renders null when nothing is triggering it', () => { + renderWithRouter(); + expect(screen.queryByText(ALERT_TEXT.HEADER_TEXT.catalogVisibility)).toBeNull(); + expect(screen.queryByText(ALERT_TEXT.HEADER_TEXT.catalogVisibilityAPI)).toBeNull(); + }); + it('renders no highlight sets alert and opens stepper modal', () => { + renderWithRouter( + , + ); + expect(screen.getByText(ALERT_TEXT.HEADER_TEXT.catalogVisibility)).toBeTruthy(); + expect(screen.getByText(ALERT_TEXT.SUB_TEXT.catalogVisibility)).toBeTruthy(); + expect(screen.getByText(BUTTON_TEXT.catalogVisibility)).toBeTruthy(); + const openStepperModalButton = screen.getByText(BUTTON_TEXT.catalogVisibility); + expect(screen.queryByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeFalsy(); + + userEvent.click(openStepperModalButton); + + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeTruthy(); + }); +}); diff --git a/src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityRadioInput.test.jsx b/src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityRadioInput.test.jsx new file mode 100644 index 0000000000..eb7bdb2174 --- /dev/null +++ b/src/components/ContentHighlights/CatalogVisibility/tests/ContentHighlightCatalogVisibilityRadioInput.test.jsx @@ -0,0 +1,159 @@ +/* eslint-disable react/prop-types */ +import { screen, waitFor } from '@testing-library/dom'; +import { renderWithRouter, sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import React, { useState } from 'react'; +import thunk from 'redux-thunk'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { camelCaseObject } from '@edx/frontend-platform'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react-test-renderer'; +import { useContentHighlightsContext } from '../../data/hooks'; +import ContentHighlightCatalogVisibilityRadioInput from '../ContentHighlightCatalogVisibilityRadioInput'; +import { EnterpriseAppContext } from '../../../EnterpriseApp/EnterpriseAppContextProvider'; +import { ContentHighlightsContext } from '../../ContentHighlightsContext'; +import { BUTTON_TEXT, TEST_COURSE_HIGHLIGHTS_DATA, LEARNER_PORTAL_CATALOG_VISIBILITY } from '../../data/constants'; +import EnterpriseCatalogApiService from '../../../../data/services/EnterpriseCatalogApiService'; + +const mockStore = configureMockStore([thunk]); +const mockHighlightSetResponse = camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA); +const initialState = { + portalConfiguration: { + enterpriseSlug: 'test-enterprise', + enterpriseId: 'test-enterprise-id', + }, +}; + +const initialEnterpriseAppContextValue = { + enterpriseCuration: { + enterpriseCuration: { + highlightSets: mockHighlightSetResponse, + canOnlyViewHighlightSets: false, + }, + updateEnterpriseCuration: jest.fn(), + dispatch: jest.fn(), + }, + +}; + +const ContentHighlightCatalogVisibilityRadioInputWrapper = ({ + enterpriseAppContextValue = initialEnterpriseAppContextValue, + highlightSets = [], +}) => { + const contextValue = useState({ + contentHighlights: highlightSets, + }); + + return ( + + + + + + + + + + ); +}; + +jest.mock('../../../../data/services/EnterpriseCatalogApiService'); + +jest.mock('@edx/frontend-enterprise-utils', () => { + const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); + return ({ + ...originalModule, + sendEnterpriseTrackEvent: jest.fn(), + }); +}); + +jest.mock('../../data/hooks'); +useContentHighlightsContext.mockReturnValue({ + setCatalogVisibilityAlert: false, + enterpriseCuration: { + enterpriseCuration: { + highlightSets: [], + canOnlyViewHighlightSets: false, + }, + updateEnterpriseCuration: jest.fn(), + dispatch: jest.fn(), + }, +}); + +describe('ContentHighlightCatalogVisibilityRadioInput1', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders', () => { + renderWithRouter(); + expect(screen.getByText(BUTTON_TEXT.catalogVisibilityRadio1)).toBeTruthy(); + expect(screen.getByText(BUTTON_TEXT.catalogVisibilityRadio2)).toBeTruthy(); + }); + it('Spinner 2 shows on radio 2 click', async () => { + EnterpriseCatalogApiService.updateEnterpriseCurationConfig.mockResolvedValue({ + data: { + canOnlyViewHighlightSets: true, + }, + }); + renderWithRouter(); + + const viewHighlightedContentButton = screen.getByText(BUTTON_TEXT.catalogVisibilityRadio2); + const radio2LoadingStateInitial = screen.queryByTestId(`${LEARNER_PORTAL_CATALOG_VISIBILITY.HIGHLIGHTED_CONTENT.value}-form-control`); + const radio1CheckedState = screen.getByTestId(`${LEARNER_PORTAL_CATALOG_VISIBILITY.ALL_CONTENT.value}-form-control-button`).checked; + + expect(radio2LoadingStateInitial).toBeFalsy(); + expect(radio1CheckedState).toBeTruthy(); + + await act(() => { + userEvent.click(viewHighlightedContentButton); + }); + + await waitFor(() => EnterpriseCatalogApiService.updateEnterpriseCurationConfig({ + canOnlyViewHighlightSets: true, + }).then(data => data)); + + expect(EnterpriseCatalogApiService.updateEnterpriseCurationConfig).toHaveBeenCalledTimes(1); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + }); + it('Spinner 1 shows on radio 1 click', async () => { + EnterpriseCatalogApiService.updateEnterpriseCurationConfig.mockResolvedValue({ + data: { + canOnlyViewHighlightSets: false, + }, + }); + const viewingOnlyHighlightedContentContext = { + ...initialEnterpriseAppContextValue, + enterpriseCuration: { + ...initialEnterpriseAppContextValue.enterpriseCuration, + enterpriseCuration: { + ...initialEnterpriseAppContextValue.enterpriseCuration.enterpriseCuration, + canOnlyViewHighlightSets: true, + }, + }, + }; + renderWithRouter( + , + ); + const viewAllContentButton = screen.getByText(BUTTON_TEXT.catalogVisibilityRadio1); + const radio1LoadingStateInitial = screen.queryByTestId(`${LEARNER_PORTAL_CATALOG_VISIBILITY.ALL_CONTENT.value}-form-control`); + const radio2CheckedState = screen.getByTestId(`${LEARNER_PORTAL_CATALOG_VISIBILITY.HIGHLIGHTED_CONTENT.value}-form-control-button`).checked; + + expect(radio1LoadingStateInitial).toBeFalsy(); + expect(radio2CheckedState).toBeTruthy(); + + await act(() => { + userEvent.click(viewAllContentButton); + }); + + await waitFor(() => EnterpriseCatalogApiService.updateEnterpriseCurationConfig({ + canOnlyViewHighlightSets: false, + }).then(data => data)); + + expect(EnterpriseCatalogApiService.updateEnterpriseCurationConfig).toHaveBeenCalledTimes(1); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ContentHighlights/ContentHighlights.jsx b/src/components/ContentHighlights/ContentHighlights.jsx index f6fdc373e2..68b750d5ca 100644 --- a/src/components/ContentHighlights/ContentHighlights.jsx +++ b/src/components/ContentHighlights/ContentHighlights.jsx @@ -14,6 +14,15 @@ const ContentHighlights = () => { const [toasts, setToasts] = useState([]); const { enterpriseCuration: { enterpriseCuration } } = useContext(EnterpriseAppContext); useEffect(() => { + if (locationState?.highlightToast) { + setToasts((prevState) => [...prevState, { + toastText: enterpriseCuration?.toastText, + uuid: uuidv4(), + }]); + const newState = { ...locationState }; + delete newState.highlightToast; + history.replace({ ...location, state: newState }); + } if (locationState?.deletedHighlightSet) { setToasts((prevState) => [...prevState, { toastText: `"${enterpriseCuration?.toastText}" deleted`, diff --git a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx index 6884c4a0cd..8b968d5f8f 100644 --- a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx +++ b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx @@ -1,10 +1,13 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Container } from '@edx/paragon'; -import ZeroStateHighlights from './ZeroState'; +import { Container, Tabs, Tab } from '@edx/paragon'; +import { camelCaseObject } from '@edx/frontend-platform'; import CurrentContentHighlights from './CurrentContentHighlights'; import ContentHighlightHelmet from './ContentHighlightHelmet'; import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; +import { TAB_TITLES } from './data/constants'; +import ContentHighlightCatalogVisibility from './CatalogVisibility/ContentHighlightCatalogVisibility'; +import ZeroStateHighlights from './ZeroState'; const ContentHighlightsDashboardBase = ({ children }) => ( @@ -19,20 +22,34 @@ ContentHighlightsDashboardBase.propTypes = { const ContentHighlightsDashboard = () => { const { enterpriseCuration: { enterpriseCuration } } = useContext(EnterpriseAppContext); - const highlightSets = enterpriseCuration?.highlightSets; - const hasContentHighlights = highlightSets?.length > 0; - if (!hasContentHighlights) { - return ( - - - - ); - } - + const [activeTab, setActiveTab] = useState(TAB_TITLES.highlights); + const [isHighlightSetCreated, setIsHighlightSetCreated] = useState(false); + useEffect(() => { + if (highlightSets.length > 0) { + setIsHighlightSetCreated(true); + } + }, [highlightSets]); return ( - + + + {isHighlightSetCreated ? : } + + + + + ); }; diff --git a/src/components/ContentHighlights/CurrentContentHighlights.jsx b/src/components/ContentHighlights/CurrentContentHighlights.jsx index de7ace6133..7fb565e7ec 100644 --- a/src/components/ContentHighlights/CurrentContentHighlights.jsx +++ b/src/components/ContentHighlights/CurrentContentHighlights.jsx @@ -7,7 +7,7 @@ import ContentHighlightCardContainer from './ContentHighlightCardContainer'; import CurrentContentHighlightHeader from './CurrentContentHighlightHeader'; const CurrentContentHighlights = () => ( - + diff --git a/src/components/ContentHighlights/DeleteHighlightSet.jsx b/src/components/ContentHighlights/DeleteHighlightSet.jsx index b3aa596f72..3629c73c3f 100644 --- a/src/components/ContentHighlights/DeleteHighlightSet.jsx +++ b/src/components/ContentHighlights/DeleteHighlightSet.jsx @@ -77,7 +77,7 @@ const DeleteHighlightSet = ({ enterpriseId, enterpriseSlug }) => { const deleteHighlightSet = async () => { setDeletionState('pending'); try { - dispatch(enterpriseCurationActions.setHighlightToast(highlightSetUUID)); + dispatch(enterpriseCurationActions.setHighlightSetToast(highlightSetUUID)); await EnterpriseCatalogApiService.deleteHighlightSet(highlightSetUUID); dispatch(enterpriseCurationActions.deleteHighlightSet(highlightSetUUID)); setIsDeleted(true); diff --git a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx index a98d7d80d2..f4f65cb77c 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx @@ -96,7 +96,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { highlightedContentUuids: result.highlightedContent || [], }; dispatchEnterpriseCuration(enterpriseCurationActions.addHighlightSet(transformedHighlightSet)); - dispatchEnterpriseCuration(enterpriseCurationActions.setHighlightToast(transformedHighlightSet.uuid)); + dispatchEnterpriseCuration(enterpriseCurationActions.setHighlightSetToast(transformedHighlightSet.uuid)); history.push(location.pathname, { addHighlightSet: true, }); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx index de36b1d2cd..1c8b1b30d0 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentHeader.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useContextSelector } from 'use-context-selector'; import { Icon } from '@edx/paragon'; import { AddCircle } from '@edx/paragon/icons'; -import { MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET, STEPPER_STEP_TEXT } from '../data/constants'; +import { STEPPER_STEP_TEXT } from '../data/constants'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; const HighlightStepperSelectContentTitle = () => { @@ -14,13 +14,11 @@ const HighlightStepperSelectContentTitle = () => { {STEPPER_STEP_TEXT.HEADER_TEXT.selectContent}

- Select up to {MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "{highlightTitle}". Courses - in learners' portal appear in the order of selection. + {STEPPER_STEP_TEXT.SUB_TEXT.selectContent(highlightTitle)}

- Pro tip: a highlight can include courses similar to each other for your learners to choose from, - or courses that vary in subtopics to help your learners master a larger topic. + {STEPPER_STEP_TEXT.PRO_TIP_TEXT.selectContent}

diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx index 48799ed929..b7c1d5f9f9 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperTitle.jsx @@ -17,15 +17,11 @@ const HighlightStepperTitle = () => (

- Create a unique title for your highlight. This title is visible - to your learners and helps them navigate to relevant content. + {STEPPER_STEP_TEXT.SUB_TEXT.createTitle}

- Pro tip: we recommend naming your highlight collection to reflect skills - it aims to develop, or to draw the attention of specific groups it targets. - For example, "Recommended for Marketing" or "Develop Leadership - Skills". + {STEPPER_STEP_TEXT.PRO_TIP_TEXT.createTitle}

diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx index 8e6f963037..c32dcbf850 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx @@ -21,6 +21,7 @@ import { import { configuration } from '../../../../config'; import ContentHighlightsDashboard from '../../ContentHighlightsDashboard'; import { EnterpriseAppContext } from '../../../EnterpriseApp/EnterpriseAppContextProvider'; +import ContentHighlightStepper from '../ContentHighlightStepper'; const mockStore = configureMockStore([thunk]); @@ -74,6 +75,7 @@ const ContentHighlightStepperWrapper = ({ + @@ -105,21 +107,21 @@ jest.mock('react-instantsearch-dom', () => ({ })); describe('', () => { - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); it('Displays the stepper', () => { renderWithRouter(); - const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); it('Displays the stepper and test all back and next buttons', () => { renderWithRouter(); // open stepper --> title - const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper); expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); // title --> select content @@ -146,6 +148,8 @@ describe('', () => { // title --> closed stepper const backButton4 = screen.getByText('Back'); userEvent.click(backButton4); + + expect(screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`)).toBeInTheDocument(); expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(6); // Confirm stepper close confirmation modal @@ -157,18 +161,20 @@ describe('', () => { const confirmCloseButton = screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit); userEvent.click(confirmCloseButton); - expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); + expect(screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`)).toBeInTheDocument(); }); it('Displays the stepper and exits on the X button', () => { renderWithRouter(); - const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper); expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); userEvent.click(closeButton); + + expect(screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`)).toBeInTheDocument(); expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(2); // Confirm stepper close confirmation modal @@ -187,12 +193,12 @@ describe('', () => { expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).not.toBeInTheDocument(); expect(screen.queryByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).not.toBeInTheDocument(); - expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); + expect(screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`)).toBeInTheDocument(); }); it('Displays the stepper and closes the stepper on confirm', async () => { renderWithRouter(); - const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const input = screen.getByTestId('stepper-title-input'); @@ -211,12 +217,13 @@ describe('', () => { it('Displays the stepper, closes, then displays stepper again', () => { renderWithRouter(); - const stepper1 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper1 = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper1); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); userEvent.click(closeButton); + expect(screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`)).toBeInTheDocument(); // Confirm stepper close confirmation modal expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); @@ -234,9 +241,9 @@ describe('', () => { expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).not.toBeInTheDocument(); expect(screen.queryByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).not.toBeInTheDocument(); - expect(screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight)).toBeInTheDocument(); + expect(screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`)).toBeInTheDocument(); - const stepper2 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper2 = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper2); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); @@ -244,7 +251,7 @@ describe('', () => { it('opens the stepper modal close confirmation modal and cancels the modal', () => { renderWithRouter(); - const stepper1 = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper1 = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper1); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); @@ -271,7 +278,7 @@ describe('', () => { }); it('Displays error message in title page when highlight set name exceeds maximum value', () => { renderWithRouter(); - const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const input = screen.getByTestId('stepper-title-input'); @@ -284,7 +291,7 @@ describe('', () => { }); it('sends segment event from footer link', () => { renderWithRouter(); - const stepper = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const stepper = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(stepper); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); const footerLink = screen.getByText(STEPPER_HELP_CENTER_FOOTER_BUTTON_TEXT); diff --git a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx index afe7c271ee..287bce5e38 100644 --- a/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx +++ b/src/components/ContentHighlights/ZeroState/ZeroStateHighlights.jsx @@ -1,5 +1,4 @@ import React, { useContext } from 'react'; -import { useContextSelector } from 'use-context-selector'; import { Card, Button, Col, Row, } from '@edx/paragon'; @@ -11,10 +10,8 @@ import cardImage from '../data/images/ContentHighlightImage.svg'; import ZeroStateCardImage from './ZeroStateCardImage'; import ZeroStateCardText from './ZeroStateCardText'; import ZeroStateCardFooter from './ZeroStateCardFooter'; -import ContentHighlightStepper from '../HighlightStepper/ContentHighlightStepper'; -import { ContentHighlightsContext } from '../ContentHighlightsContext'; import { useContentHighlightsContext } from '../data/hooks'; -import { BUTTON_TEXT } from '../data/constants'; +import { BUTTON_TEXT, HEADER_TEXT } from '../data/constants'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; import EVENT_NAMES from '../../../eventTracking'; @@ -27,7 +24,7 @@ const ZeroStateHighlights = ({ enterpriseId, cardClassName }) => { }, }, } = useContext(EnterpriseAppContext); - const isStepperModalOpen = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.isOpen); + const handleNewHighlightClick = () => { openStepperModal(); const trackInfo = { @@ -41,15 +38,16 @@ const ZeroStateHighlights = ({ enterpriseId, cardClassName }) => { ); }; return ( - + -

You haven't created any highlights yet.

+

+ {HEADER_TEXT.zeroStateHighlights} +

- Create and recommend content collections to your learners, - enabling them to quickly locate content relevant to them. + {HEADER_TEXT.SUB_TEXT.zeroStateHighlights}

@@ -57,13 +55,13 @@ const ZeroStateHighlights = ({ enterpriseId, cardClassName }) => { onClick={handleNewHighlightClick} iconBefore={Add} block + data-testid={`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`} > {BUTTON_TEXT.zeroStateCreateNewHighlight}
-
); }; diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index db21685904..a0a4ebab64 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -1,7 +1,15 @@ /* eslint-disable import/no-extraneous-dependencies */ import { faker } from '@faker-js/faker'; +import parse from 'html-react-parser'; +import sanitizeHTML from 'sanitize-html'; /* eslint-enable import/no-extraneous-dependencies */ +// Sanitizes HTML and parses the string as HTML +export const sanitizeAndParseHTML = (htmlString) => { + const sanitizedHTML = sanitizeHTML(htmlString); + return parse(sanitizedHTML); +}; + /* START LOCAL TESTING CONSTANTS */ // Set to false before pushing PR!! otherwise set to true to enable local testing of ContentHighlights components // Test will fail as additional check to ensure this is set to false before pushing PR @@ -25,6 +33,12 @@ export const HIGHLIGHTS_CARD_GRID_COLUMN_SIZES = { xl: 3, }; +// Tab titles +export const TAB_TITLES = { + highlights: 'Highlights', + catalogVisibility: 'Catalog Visibility', +}; + // Max length of highlight title in stepper export const MAX_HIGHLIGHT_TITLE_LENGTH = 60; @@ -52,8 +66,19 @@ export const STEPPER_STEP_TEXT = { confirmContent: 'Confirm your selections', }, SUB_TEXT: { + createTitle: `Create a unique title for your highlight. This title is visible + to your learners and helps them navigate to relevant content.`, + selectContent: (highlightTitle) => sanitizeAndParseHTML(`Select up to ${MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET} items for "${highlightTitle}". + Courses in learners' portal appear in the order of selection.`), confirmContent: (highlightTitle) => `Review content selections for "${highlightTitle}"`, }, + PRO_TIP_TEXT: { + createTitle: `Pro tip: we recommend naming your highlight collection to reflect skills + it aims to develop, or to draw the attention of specific groups it targets. + For example, "Recommended for Marketing" or "Develop Leadership Skills".`, + selectContent: `Pro tip: a highlight can include courses similar to each other for your learners to choose from, + or courses that vary in subtopics to help your learners master a larger topic`, + }, ALERT_MODAL_TEXT: { title: 'Lose Progress?', content: 'If you exit now, any changes you\'ve made will be lost.', @@ -67,15 +92,25 @@ export const STEPPER_STEP_TEXT = { // Header text extracted into constant to maintain passing test on changes export const HEADER_TEXT = { currentContent: 'Highlights', + catalogVisibility: 'Catalog Visibility', + zeroStateHighlights: 'You haven\'t created any highlights yet.', SUB_TEXT: { + catalogVisibility: 'Choose a visibility for your catalog.', currentContent: `Create up to ${MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION} highlights for your learners.`, + zeroStateHighlights: 'Create and recommend content collections to your learners, enabling them to quickly locate content relevant to them.', + }, + PRO_TIP_TEXT: { + catalogVisibility: 'Pro tip: regardless of your choice, learners will be able to see all highlight collections.', }, }; // Button text extracted from constant to maintain passing test on changes export const BUTTON_TEXT = { createNewHighlight: 'New', + catalogVisibility: 'New highlight', zeroStateCreateNewHighlight: 'New highlight', + catalogVisibilityRadio1: 'Learners can view and enroll into all courses in your catalog', + catalogVisibilityRadio2: 'Learners can only view and enroll into highlighted courses', }; // Button text for stepper help center button @@ -84,11 +119,18 @@ export const STEPPER_HELP_CENTER_FOOTER_BUTTON_TEXT = 'Help Center: Program Opti // Alert Text extracted from constant to maintain passing test on changes export const ALERT_TEXT = { HEADER_TEXT: { + catalogVisibility: 'No highlights created', + catalogVisibilityAPI: 'Catalog visibility not updated', currentContent: 'Highlight limit reached', }, SUB_TEXT: { + catalogVisibility: 'At least one highlight has to be created to make a selection', + catalogVisibilityAPI: 'Something went wrong when updating your setting. Please try again.', currentContent: 'Delete at least one highlight to create a new one.', }, + TOAST_TEXT: { + catalogVisibility: 'Catalog visibility settings updated.', + }, }; // Default footer values based on API response for ContentHighlightCardItem @@ -98,6 +140,17 @@ export const FOOTER_TEXT_BY_CONTENT_TYPE = { learnerpathway: 'Pathway', }; +export const LEARNER_PORTAL_CATALOG_VISIBILITY = { + ALL_CONTENT: { + value: 'ALL_CONTENT', + canOnlyViewHighlightSets: false, + }, + HIGHLIGHTED_CONTENT: { + value: 'HIGHLIGHTED_CONTENT', + canOnlyViewHighlightSets: true, + }, +}; + // Empty Content and Error Messages export const DEFAULT_ERROR_MESSAGE = { EMPTY_HIGHLIGHT_SET: 'There is no highlighted content for this highlight collection.', diff --git a/src/components/ContentHighlights/data/hooks.js b/src/components/ContentHighlights/data/hooks.js index f1136fe703..23bcd29e8c 100644 --- a/src/components/ContentHighlights/data/hooks.js +++ b/src/components/ContentHighlights/data/hooks.js @@ -126,11 +126,19 @@ export function useContentHighlightsContext() { })); }, [setState]); + const setCatalogVisibilityAlert = useCallback(({ isOpen }) => { + setState(s => ({ + ...s, + catalogVisibilityAlertOpen: isOpen, + })); + }, [setState]); + return { openStepperModal, resetStepperModal, deleteSelectedRowId, setCurrentSelectedRowIds, setHighlightTitle, + setCatalogVisibilityAlert, }; } diff --git a/src/components/ContentHighlights/data/tests/constants.test.js b/src/components/ContentHighlights/data/tests/constants.test.js index b10afee8e2..b12f3f1982 100644 --- a/src/components/ContentHighlights/data/tests/constants.test.js +++ b/src/components/ContentHighlights/data/tests/constants.test.js @@ -1,4 +1,12 @@ -import { TEST_FLAG, ENABLE_TESTING, testEnterpriseId } from '../constants'; +/* eslint-disable react/jsx-filename-extension */ +import { render, screen } from '@testing-library/react'; +import { + TEST_FLAG, + ENABLE_TESTING, + testEnterpriseId, + sanitizeAndParseHTML, + STEPPER_STEP_TEXT, +} from '../constants'; const enterpriseId = 'test-enterprise-id'; @@ -13,4 +21,36 @@ describe('constants', () => { it('ENABLE_TESTING should return the testEnterpriseId when passing true parameter', () => { expect(ENABLE_TESTING(enterpriseId, true)).toBe(testEnterpriseId); }); + it('sanitizeAndParseHTML should return a string', () => { + const testElement = sanitizeAndParseHTML('

'test'

'); + render(testElement); + expect(screen.getByText("'test'")).toBeTruthy(); + }); + it('unprocessed string to fail', () => { + const testElement = '

'test'

'; + render(testElement); + expect(screen.queryByText("'test'")).toBeFalsy(); + }); + it('renders title name in string functions', () => { + const highlightTitle = 'test-title'; + // eslint-disable-next-line react/prop-types + const TestComponent = ({ children }) => ( +

+ {children} +

+ ); + render( + + {STEPPER_STEP_TEXT.SUB_TEXT.selectContent(highlightTitle)} + , + ); + expect(screen.getByText(`items for "${highlightTitle}"`, { exact: false })).toBeTruthy(); + + render( + + {STEPPER_STEP_TEXT.SUB_TEXT.confirmContent(highlightTitle)} + , + ); + expect(screen.getByText(STEPPER_STEP_TEXT.SUB_TEXT.confirmContent(highlightTitle))).toBeTruthy(); + }); }); diff --git a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx index 043690efd1..dd79570035 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightSetCard.test.jsx @@ -15,7 +15,7 @@ import CurrentContentHighlightHeader from '../CurrentContentHighlightHeader'; import { configuration } from '../../../config'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; import { - BUTTON_TEXT, HEADER_TEXT, MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION, ALERT_TEXT, STEPPER_STEP_TEXT, + BUTTON_TEXT, MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION, ALERT_TEXT, STEPPER_STEP_TEXT, } from '../data/constants'; const mockStore = configureMockStore([thunk]); @@ -108,7 +108,8 @@ describe('', () => { it('renders correct text when less then max curations', () => { renderWithRouter(); expect(screen.getByText(BUTTON_TEXT.createNewHighlight)).toBeInTheDocument(); - expect(screen.getByText(HEADER_TEXT.SUB_TEXT.currentContent)).toBeInTheDocument(); + expect(screen.queryByText(ALERT_TEXT.HEADER_TEXT.currentContent)).not.toBeInTheDocument(); + expect(screen.queryByText(ALERT_TEXT.SUB_TEXT.currentContent)).not.toBeInTheDocument(); }); it('renders correct text when more then or equal to max curations', async () => { const updatedEnterpriseAppContextValue = { diff --git a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx index ddd2b5a205..6fc9ca0b38 100644 --- a/src/components/ContentHighlights/tests/ContentHighlights.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlights.test.jsx @@ -27,11 +27,15 @@ const initialState = { const ContentHighlightsWrapper = ({ enterpriseAppContextValue = initialEnterpriseAppContextValue, + highlightToast = false, addToast = false, deleteToast = false, }) => { const history = useHistory(); const { location } = history; + if (highlightToast) { + history.push(location.pathname, { highlightToast: true }); + } if (addToast) { history.push(location.pathname, { addHighlightSet: true }); } @@ -49,7 +53,7 @@ const ContentHighlightsWrapper = ({ ); }; -describe('', () => { +describe('', () => { it('Displays the Hero', () => { renderWithRouter(); expect(screen.getByText('Highlights')).toBeInTheDocument(); @@ -62,4 +66,15 @@ describe('', () => { renderWithRouter(); expect(screen.getByText('deleted', { exact: false })).toBeInTheDocument(); }); + it('Displays the toast highlight', () => { + const toastMessage = { + enterpriseCuration: { + enterpriseCuration: { + toastText: 'highlighted', + }, + }, + }; + renderWithRouter(); + expect(screen.getByText('highlighted', { exact: false })).toBeInTheDocument(); + }); }); diff --git a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx index 753bce4d80..6fa9b2e330 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx @@ -9,11 +9,14 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; import algoliasearch from 'algoliasearch/lite'; -import { BUTTON_TEXT, STEPPER_STEP_TEXT, HEADER_TEXT } from '../data/constants'; +import { + BUTTON_TEXT, STEPPER_STEP_TEXT, HEADER_TEXT, +} from '../data/constants'; import ContentHighlightsDashboard from '../ContentHighlightsDashboard'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; import { configuration } from '../../../config'; +import ContentHighlightStepper from '../HighlightStepper/ContentHighlightStepper'; const mockStore = configureMockStore([thunk]); @@ -66,6 +69,7 @@ const ContentHighlightsDashboardWrapper = ({ + @@ -76,16 +80,16 @@ const ContentHighlightsDashboardWrapper = ({ describe('', () => { it('Displays ZeroState on empty highlighted content list', () => { renderWithRouter(); - expect(screen.getByText('You haven\'t created any highlights yet.')).toBeTruthy(); + expect(screen.getByText(HEADER_TEXT.zeroStateHighlights)).toBeInTheDocument(); + expect(screen.getByText(HEADER_TEXT.SUB_TEXT.zeroStateHighlights)).toBeInTheDocument(); }); it('Displays New highlight Modal on button click with no highlighted content list', () => { renderWithRouter(); - const newHighlight = screen.getByText(BUTTON_TEXT.zeroStateCreateNewHighlight); + const newHighlight = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(newHighlight); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); - it('Displays current highlights when data is populated', () => { renderWithRouter( ', () => { }} />, ); - expect(screen.getByText(HEADER_TEXT.currentContent)).toBeInTheDocument(); + expect(screen.getByText(exampleHighlightSet.title)).toBeInTheDocument(); }); + it('Allows selection between tabs of dashboard, when highlight set exist', () => { + renderWithRouter( + , + ); + const [highlightTab, catalogVisibilityTab] = screen.getAllByRole('tab'); + + expect(highlightTab.classList.contains('active')).toBeTruthy(); + expect(catalogVisibilityTab.classList.contains('active')).toBeFalsy(); + userEvent.click(catalogVisibilityTab); + expect(catalogVisibilityTab.classList.contains('active')).toBeTruthy(); + expect(highlightTab.classList.contains('active')).toBeFalsy(); + }); it('Displays New highlight modal on button click with highlighted content list', () => { renderWithRouter(); - const newHighlight = screen.getByText('New highlight'); + const newHighlight = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(newHighlight); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); diff --git a/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx b/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx index 2ab9bd3e94..2574451339 100644 --- a/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx +++ b/src/components/ContentHighlights/tests/CurrentContentHighlights.test.jsx @@ -8,7 +8,7 @@ import { renderWithRouter } from '@edx/frontend-enterprise-utils'; import algoliasearch from 'algoliasearch/lite'; import CurrentContentHighlights from '../CurrentContentHighlights'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; -import { BUTTON_TEXT, HEADER_TEXT } from '../data/constants'; +import { BUTTON_TEXT } from '../data/constants'; import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; import { configuration } from '../../../config'; @@ -63,7 +63,7 @@ const CurrentContentHighlightsWrapper = ({ describe('', () => { it('Displays the header title', () => { renderWithRouter(); - expect(screen.getByText(HEADER_TEXT.currentContent)).toBeInTheDocument(); + expect(screen.getByText(BUTTON_TEXT.createNewHighlight)).toBeInTheDocument(); }); it('Displays the header button', () => { renderWithRouter(); diff --git a/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx b/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx index ee3646b7d7..336cb008c0 100644 --- a/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx +++ b/src/components/ContentHighlights/tests/DeleteHighlightSet.test.jsx @@ -123,7 +123,7 @@ describe('', () => { await waitFor(() => { expect(mockDispatchFn).toHaveBeenCalledWith( - enterpriseCurationActions.setHighlightToast(highlightSetUUID), + enterpriseCurationActions.setHighlightSetToast(highlightSetUUID), ); }); await waitFor(() => { diff --git a/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx b/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx index 7c84d1e9f6..b197cf81fb 100644 --- a/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx +++ b/src/components/EnterpriseApp/EnterpriseAppContextProvider.jsx @@ -21,6 +21,7 @@ import EnterpriseAppSkeleton from './EnterpriseAppSkeleton'; * @property {String} uuid * @property {String} title * @property {Boolean} isHighlightFeatureActive + * @property {Boolean} canOnlyViewHighlightSets * @property {THighlightSet[]} highlightSets * @property {String} created * @property {String} modified diff --git a/src/components/EnterpriseApp/data/enterpriseCurationReducer.js b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js index cc40d32683..c2a5642b1c 100644 --- a/src/components/EnterpriseApp/data/enterpriseCurationReducer.js +++ b/src/components/EnterpriseApp/data/enterpriseCurationReducer.js @@ -11,7 +11,8 @@ export const SET_ENTERPRISE_CURATION = 'SET_ENTERPRISE_CURATION'; export const SET_FETCH_ERROR = 'SET_FETCH_ERROR'; export const DELETE_HIGHLIGHT_SET = 'DELETE_HIGHLIGHT_SET'; export const ADD_HIGHLIGHT_SET = 'ADD_HIGHLIGHT_SET'; -export const SET_TOAST_TEXT = 'SET_TOAST_TEXT'; +export const SET_HIGHLIGHT_SET_TOAST_TEXT = 'SET_HIGHLIGHT_SET_TOAST_TEXT'; +export const SET_HIGHLIGHT_TOAST_TEXT = 'SET_HIGHLIGHT_TOAST_TEXT'; export const enterpriseCurationActions = { setIsLoading: (payload) => ({ @@ -27,7 +28,11 @@ export const enterpriseCurationActions = { payload, }), setHighlightToast: (payload) => ({ - type: SET_TOAST_TEXT, + type: SET_HIGHLIGHT_TOAST_TEXT, + payload, + }), + setHighlightSetToast: (payload) => ({ + type: SET_HIGHLIGHT_SET_TOAST_TEXT, payload, }), deleteHighlightSet: (payload) => ({ @@ -52,7 +57,16 @@ function enterpriseCurationReducer(state, action) { return { ...state, enterpriseCuration: action.payload }; case SET_FETCH_ERROR: return { ...state, fetchError: action.payload }; - case SET_TOAST_TEXT: { + case SET_HIGHLIGHT_TOAST_TEXT: { + return { + ...state, + enterpriseCuration: { + ...state.enterpriseCuration, + toastText: action.payload, + }, + }; + } + case SET_HIGHLIGHT_SET_TOAST_TEXT: { const existingHighlightSets = getHighlightSetsFromState(state); const filteredHighlightSets = existingHighlightSets.find( highlightSet => highlightSet.uuid === action.payload, diff --git a/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js b/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js index 63e7a8e7d4..44777b0e31 100644 --- a/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js +++ b/src/components/EnterpriseApp/data/enterpriseCurationReducer.test.js @@ -39,7 +39,7 @@ describe('enterpriseCurationReducer', () => { ).toMatchObject({ fetchError }); }); - it('should set toast text', () => { + it('should set toast text for highlight set', () => { const highlightSet = { uuid: highlightSetUUID, title: 'Hello World!', @@ -53,11 +53,28 @@ describe('enterpriseCurationReducer', () => { expect( enterpriseCurationReducer( initialStateWithHighlights, - enterpriseCurationActions.setHighlightToast(highlightSetUUID), + enterpriseCurationActions.setHighlightSetToast(highlightSetUUID), ), ).toMatchObject({ enterpriseCuration: { toastText: 'Hello World!' } }); }); + it('should set general toast text for highlights', () => { + const highlightSet = { uuid: highlightSetUUID }; + const highlightMessage = 'Hello World!'; + const initialStateWithHighlights = { + ...initialState, + enterpriseCuration: { + highlightSets: [highlightSet], + }, + }; + expect( + enterpriseCurationReducer( + initialStateWithHighlights, + enterpriseCurationActions.setHighlightToast(highlightMessage), + ), + ).toMatchObject({ enterpriseCuration: { toastText: highlightMessage } }); + }); + it('should delete highlight set', () => { const highlightSet = { uuid: highlightSetUUID }; const initialStateWithHighlights = { diff --git a/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.js b/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.js index 23248e4f03..dcc24fa9e8 100644 --- a/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.js +++ b/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.js @@ -44,6 +44,24 @@ function useEnterpriseCuration({ enterpriseId, curationTitleForCreation }) { } }, [enterpriseId]); + const updateEnterpriseCuration = useCallback(async (options) => { + try { + const response = await EnterpriseCatalogApiService.updateEnterpriseCurationConfig( + enterpriseCuration.uuid, + options, + ); + const result = camelCaseObject(response?.data); + setEnterpriseCuration(result); + return result; + } catch (error) { + logError(error); + setFetchError(error); + throw error; + } finally { + setIsLoading(false); + } + }, [enterpriseCuration]); + useEffect(() => { if (!enterpriseId || !config.FEATURE_CONTENT_HIGHLIGHTS) { setIsLoading(false); @@ -75,6 +93,7 @@ function useEnterpriseCuration({ enterpriseId, curationTitleForCreation }) { isLoading, enterpriseCuration, fetchError, + updateEnterpriseCuration, }; } diff --git a/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.test.js b/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.test.js index c1f49bad76..e379a2b87f 100644 --- a/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.test.js +++ b/src/components/EnterpriseApp/data/hooks/useEnterpriseCuration.test.js @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react-hooks/dom'; - +import { act, waitFor } from '@testing-library/react'; import { mergeConfig } from '@edx/frontend-platform/config'; import useEnterpriseCuration from './useEnterpriseCuration'; import EnterpriseCatalogApiService from '../../../../data/services/EnterpriseCatalogApiService'; @@ -13,6 +13,7 @@ const mockEnterpriseCurationConfig = { uuid: 'fake-uuid', title: TEST_ENTERPRISE_NAME, isHighlightFeatureActive: true, + canOnlyViewHighlightSets: false, highlightSets: [], created: '2022-10-31', modified: '2022-10-31', @@ -40,6 +41,7 @@ describe('useEnterpriseCuration', () => { isLoading: false, fetchError: null, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), }); expect(EnterpriseCatalogApiService.getEnterpriseCurationConfig).not.toHaveBeenCalled(); }); @@ -59,6 +61,7 @@ describe('useEnterpriseCuration', () => { isLoading: false, fetchError: null, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), }); expect(EnterpriseCatalogApiService.getEnterpriseCurationConfig).not.toHaveBeenCalled(); }); @@ -78,6 +81,7 @@ describe('useEnterpriseCuration', () => { isLoading: true, fetchError: null, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), }); await waitForNextUpdate(); @@ -93,6 +97,49 @@ describe('useEnterpriseCuration', () => { isLoading: false, fetchError: null, enterpriseCuration: expect.objectContaining(mockEnterpriseCurationConfig), + updateEnterpriseCuration: expect.any(Function), + }); + }); + + it('should update enterprise configuration', async () => { + const updatedEnterpriseCuration = { + ...mockEnterpriseCurationConfig, + canOnlyViewHighlightSets: true, + }; + EnterpriseCatalogApiService.getEnterpriseCurationConfig.mockResolvedValueOnce({ + data: mockEnterpriseCurationConfigResponse, + }); + EnterpriseCatalogApiService.updateEnterpriseCurationConfig.mockResolvedValueOnce({ + data: updatedEnterpriseCuration, + }); + + const args = { + enterpriseId: TEST_ENTERPRISE_UUID, + curationTitleForCreation: TEST_ENTERPRISE_NAME, + }; + + const { result, waitForNextUpdate } = renderHook(() => useEnterpriseCuration(args)); + + await waitForNextUpdate(); + + const { updateEnterpriseCuration } = result.current; + + expect( + EnterpriseCatalogApiService.getEnterpriseCurationConfig, + ).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); + + updateEnterpriseCuration(updatedEnterpriseCuration); + await waitFor(() => { + expect( + EnterpriseCatalogApiService.updateEnterpriseCurationConfig, + ).toHaveBeenCalledWith(mockEnterpriseCurationConfig.uuid, updatedEnterpriseCuration); + }); + + expect(result.current).toEqual({ + isLoading: false, + fetchError: null, + enterpriseCuration: updatedEnterpriseCuration, + updateEnterpriseCuration: expect.any(Function), }); }); @@ -117,6 +164,7 @@ describe('useEnterpriseCuration', () => { isLoading: true, fetchError: null, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), }); await waitForNextUpdate(); @@ -132,6 +180,7 @@ describe('useEnterpriseCuration', () => { isLoading: false, fetchError: null, enterpriseCuration: expect.objectContaining(mockEnterpriseCurationConfig), + updateEnterpriseCuration: expect.any(Function), }); }); @@ -149,6 +198,7 @@ describe('useEnterpriseCuration', () => { isLoading: true, fetchError: null, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), }); await waitForNextUpdate(); @@ -161,6 +211,7 @@ describe('useEnterpriseCuration', () => { isLoading: false, fetchError: mockErrorMessage, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), }); }); @@ -184,6 +235,7 @@ describe('useEnterpriseCuration', () => { isLoading: true, fetchError: null, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), }); await waitForNextUpdate(); @@ -196,6 +248,43 @@ describe('useEnterpriseCuration', () => { isLoading: false, fetchError: mockErrorMessage, enterpriseCuration: null, + updateEnterpriseCuration: expect.any(Function), + }); + }); + + it('should handle fetch error while updating enterprise curation config', async () => { + const mockErrorMessage = 'oh noes!'; + EnterpriseCatalogApiService.getEnterpriseCurationConfig.mockResolvedValueOnce({ + data: mockEnterpriseCurationConfigResponse, + }); + EnterpriseCatalogApiService.updateEnterpriseCurationConfig.mockRejectedValueOnce(mockErrorMessage); + + const args = { + enterpriseId: TEST_ENTERPRISE_UUID, + curationTitleForCreation: TEST_ENTERPRISE_NAME, + }; + + const { result, waitForNextUpdate } = renderHook(() => useEnterpriseCuration(args)); + + await waitForNextUpdate(); + + const { updateEnterpriseCuration } = result.current; + + expect( + EnterpriseCatalogApiService.getEnterpriseCurationConfig, + ).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); + + await waitFor(() => act(() => updateEnterpriseCuration(mockEnterpriseCurationConfig))); + + expect( + EnterpriseCatalogApiService.updateEnterpriseCurationConfig, + ).toHaveBeenCalledWith(mockEnterpriseCurationConfig.uuid, mockEnterpriseCurationConfig); + + expect(result.current).toEqual({ + isLoading: false, + fetchError: mockErrorMessage, + enterpriseCuration: undefined, + updateEnterpriseCuration: expect.any(Function), }); }); }); diff --git a/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js b/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js index b7086d2c92..f13b8c1252 100644 --- a/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js +++ b/src/components/EnterpriseApp/data/hooks/useEnterpriseCurationContext.js @@ -21,6 +21,7 @@ function useEnterpriseCurationContext({ isLoading, enterpriseCuration, fetchError, + updateEnterpriseCuration, } = useEnterpriseCuration({ enterpriseId, curationTitleForCreation, @@ -41,7 +42,8 @@ function useEnterpriseCurationContext({ const contextValue = useMemo(() => ({ ...enterpriseCurationState, dispatch, - }), [enterpriseCurationState]); + updateEnterpriseCuration, + }), [enterpriseCurationState, updateEnterpriseCuration]); return contextValue; } diff --git a/src/data/services/EnterpriseCatalogApiService.js b/src/data/services/EnterpriseCatalogApiService.js index ed2b94313c..8a7f356728 100644 --- a/src/data/services/EnterpriseCatalogApiService.js +++ b/src/data/services/EnterpriseCatalogApiService.js @@ -57,6 +57,16 @@ class EnterpriseCatalogApiService { ); } + static updateEnterpriseCurationConfig(enterpriseCurationUUID, options = {}) { + const payload = { + ...snakeCaseObject(options), + }; + return EnterpriseCatalogApiService.apiClient().patch( + `${EnterpriseCatalogApiService.enterpriseCurationUrl}${enterpriseCurationUUID}/`, + payload, + ); + } + static createHighlightSet(enterpriseId, options = {}) { const payload = { enterprise_customer: enterpriseId, diff --git a/src/eventTracking.js b/src/eventTracking.js index d8f1aaf1d2..e5d7b9a035 100644 --- a/src/eventTracking.js +++ b/src/eventTracking.js @@ -71,6 +71,7 @@ export const CONTENT_HIGHLIGHTS_EVENTS = { // Dashboard HIGHLIGHT_DASHBOARD_SET_ABOUT_PAGE: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.set_item_about_page.clicked`, HIGHLIGHT_DASHBOARD_PUBLISHED_HIGHLIGHT_SET_CARD: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.published_highlight_set_card.clicked`, + HIGHLIGHT_DASHBOARD_SET_CATALOG_VISIBILITY: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.set_catalog_visibility.clicked`, // Highlight Creation NEW_HIGHLIHT_MAX_REACHED: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.create_new_content_highlight.max_reached.clicked`, NEW_HIGHLIGHT: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.create_new_content_highlight.clicked`, From 43f1d410507608297714516a1a28376274db9f8f Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 26 Jan 2023 12:39:13 -0500 Subject: [PATCH 46/73] fix: Remove updating width creating additional padding (#920) * fix: Remove updating width creating additional padding * fix: PR fixes * chore: PR fixes 2 --- src/components/Sidebar/index.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx index 1135066ce1..f0515898c5 100644 --- a/src/components/Sidebar/index.jsx +++ b/src/components/Sidebar/index.jsx @@ -40,7 +40,6 @@ const Sidebar = ({ const { enterpriseCuration: { enterpriseCuration } } = useContext(EnterpriseAppContext); const { subsidyRequestsCounts } = useContext(SubsidyRequestsContext); const { canManageLearnerCredit } = useContext(EnterpriseSubsidiesContext); - const { FEATURE_CONTENT_HIGHLIGHTS } = getConfig(); const getSidebarWidth = useCallback(() => { @@ -63,10 +62,12 @@ const Sidebar = ({ useEffect(() => { const sideBarWidth = getSidebarWidth(); if (widthRef.current !== sideBarWidth) { - onWidthChange(sideBarWidth); + if (!isExpanded) { + onWidthChange(sideBarWidth); + } widthRef.current = sideBarWidth; } - }, [getSidebarWidth, isExpandedByToggle, isMobile, onWidthChange]); + }, [getSidebarWidth, isExpanded, isExpandedByToggle, isMobile, onWidthChange]); const getMenuItems = () => [ { From 990259f3337587b64e7c4d74517d520b613f4597 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 26 Jan 2023 17:29:18 -0500 Subject: [PATCH 47/73] fix: add trackevent on tab switch (#963) * fix: add trackevent on tab switch * chore: PR fix --- .../ContentHighlightsDashboard.jsx | 17 ++++++++++++++++- .../tests/ContentHighlightsDashboard.test.jsx | 14 +++++++++++++- src/eventTracking.js | 1 + 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx index 8b968d5f8f..656c759acd 100644 --- a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx +++ b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx @@ -2,12 +2,14 @@ import React, { useContext, useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Container, Tabs, Tab } from '@edx/paragon'; import { camelCaseObject } from '@edx/frontend-platform'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; import CurrentContentHighlights from './CurrentContentHighlights'; import ContentHighlightHelmet from './ContentHighlightHelmet'; import { EnterpriseAppContext } from '../EnterpriseApp/EnterpriseAppContextProvider'; import { TAB_TITLES } from './data/constants'; import ContentHighlightCatalogVisibility from './CatalogVisibility/ContentHighlightCatalogVisibility'; import ZeroStateHighlights from './ZeroState'; +import EVENT_NAMES from '../../eventTracking'; const ContentHighlightsDashboardBase = ({ children }) => ( @@ -25,6 +27,16 @@ const ContentHighlightsDashboard = () => { const highlightSets = enterpriseCuration?.highlightSets; const [activeTab, setActiveTab] = useState(TAB_TITLES.highlights); const [isHighlightSetCreated, setIsHighlightSetCreated] = useState(false); + const sendTrackEventTabSwitch = (tab) => { + const trackInfo = { + active_tab: tab, + }; + sendEnterpriseTrackEvent( + enterpriseCuration.enterpriseCustomer, + `${EVENT_NAMES.CONTENT_HIGHLIGHTS.HIGHLIGHT_DASHBOARD_SELECT_TAB}`, + trackInfo, + ); + }; useEffect(() => { if (highlightSets.length > 0) { setIsHighlightSetCreated(true); @@ -35,7 +47,10 @@ const ContentHighlightsDashboard = () => { { + setActiveTab(tab); + sendTrackEventTabSwitch(tab); + }} > { + const originalModule = jest.requireActual('@edx/frontend-enterprise-utils'); + return { + ...originalModule, + sendEnterpriseTrackEvent: jest.fn(), + }; +}); + describe('', () => { it('Displays ZeroState on empty highlighted content list', () => { renderWithRouter(); @@ -88,6 +96,7 @@ describe('', () => { renderWithRouter(); const newHighlight = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); userEvent.click(newHighlight); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); }); it('Displays current highlights when data is populated', () => { @@ -121,7 +130,10 @@ describe('', () => { expect(highlightTab.classList.contains('active')).toBeTruthy(); expect(catalogVisibilityTab.classList.contains('active')).toBeFalsy(); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(1); userEvent.click(catalogVisibilityTab); + expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(2); + expect(catalogVisibilityTab.classList.contains('active')).toBeTruthy(); expect(highlightTab.classList.contains('active')).toBeFalsy(); }); diff --git a/src/eventTracking.js b/src/eventTracking.js index e5d7b9a035..b0a9e7bd6f 100644 --- a/src/eventTracking.js +++ b/src/eventTracking.js @@ -72,6 +72,7 @@ export const CONTENT_HIGHLIGHTS_EVENTS = { HIGHLIGHT_DASHBOARD_SET_ABOUT_PAGE: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.set_item_about_page.clicked`, HIGHLIGHT_DASHBOARD_PUBLISHED_HIGHLIGHT_SET_CARD: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.published_highlight_set_card.clicked`, HIGHLIGHT_DASHBOARD_SET_CATALOG_VISIBILITY: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.set_catalog_visibility.clicked`, + HIGHLIGHT_DASHBOARD_SELECT_TAB: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.tab.clicked`, // Highlight Creation NEW_HIGHLIHT_MAX_REACHED: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.create_new_content_highlight.max_reached.clicked`, NEW_HIGHLIGHT: `${CONTENT_HIGHLIGHTS_DASHBOARD_PREFIX}.create_new_content_highlight.clicked`, From fbbb6ebb61de53975867e32e37cf9b405af4c70e Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Tue, 31 Jan 2023 13:01:20 -0700 Subject: [PATCH 48/73] fix: FF error reporting fixes (#965) --- src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx | 2 +- src/components/settings/SettingsLMSTab/index.jsx | 2 +- src/components/settings/settings.scss | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx index 60491f64c5..b0c807d277 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/utils.jsx @@ -46,7 +46,7 @@ export function getSyncStatus(status, statusMessage) { )} - >Read + >Read )} diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index b5c809a992..0c205f5e53 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -154,7 +154,7 @@ const SettingsLMSTab = ({ {displayNeedsSSOAlert && !hasSSOConfig && ( Date: Tue, 24 Jan 2023 18:41:06 +0500 Subject: [PATCH 49/73] fix: make sftpPassword required while updating report --- src/components/ReportingConfig/ReportingConfigForm.jsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/ReportingConfig/ReportingConfigForm.jsx b/src/components/ReportingConfig/ReportingConfigForm.jsx index 9a5916b0f1..c2c9d8c804 100644 --- a/src/components/ReportingConfig/ReportingConfigForm.jsx +++ b/src/components/ReportingConfig/ReportingConfigForm.jsx @@ -242,6 +242,8 @@ class ReportingConfigForm extends React.Component { ({ label: item[1], value: item[0] }))} onChange={e => this.setState({ deliveryMethod: e.target.value })} + disabled={config} + /> + Date: Thu, 2 Feb 2023 12:24:21 -0500 Subject: [PATCH 50/73] fix: reset title validation (#966) * fix: reset title validation * chore: PR fix and test --- .../tests/ContentHighlightStepper.test.jsx | 40 +++++++++++++++++++ .../ContentHighlights/data/hooks.js | 1 + 2 files changed, 41 insertions(+) diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx index c32dcbf850..4c7d99aa91 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx @@ -298,4 +298,44 @@ describe('', () => { userEvent.click(footerLink); expect(sendEnterpriseTrackEvent).toHaveBeenCalledTimes(2); }); + it('removes title validation after exiting the stepper and revisiting', () => { + renderWithRouter(); + const stepper1 = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); + userEvent.click(stepper1); + expect(screen.getByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).toBeInTheDocument(); + const input = screen.getByTestId('stepper-title-input'); + const reallyLongTitle = 'test-title-test-title-test-title-test-title-test-title-test-title'; + const reallyLongTitleLength = reallyLongTitle.length; + fireEvent.change(input, { target: { value: reallyLongTitle } }); + + expect(screen.getByText(`${reallyLongTitleLength}/${MAX_HIGHLIGHT_TITLE_LENGTH}`, { exact: false })).toBeInTheDocument(); + expect(screen.getByText(DEFAULT_ERROR_MESSAGE.EXCEEDS_HIGHLIGHT_TITLE_LENGTH)).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + userEvent.click(closeButton); + + // Confirm stepper close confirmation modal + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).toBeInTheDocument(); + expect(screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).toBeInTheDocument(); + + const confirmCloseButton = screen.getByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit); + userEvent.click(confirmCloseButton); + + // Confirm stepper confirmation modal closed + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.title)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.content)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.exit)).not.toBeInTheDocument(); + expect(screen.queryByText(STEPPER_STEP_TEXT.ALERT_MODAL_TEXT.buttons.cancel)).not.toBeInTheDocument(); + + // Confirm stepper closed + expect(screen.queryByText(STEPPER_STEP_TEXT.HEADER_TEXT.createTitle)).not.toBeInTheDocument(); + + const stepper2 = screen.getByTestId(`zero-state-card-${BUTTON_TEXT.zeroStateCreateNewHighlight}`); + userEvent.click(stepper2); + + expect(screen.getByText(`0/${MAX_HIGHLIGHT_TITLE_LENGTH}`, { exact: false })).toBeInTheDocument(); + expect(screen.queryByText(DEFAULT_ERROR_MESSAGE.EXCEEDS_HIGHLIGHT_TITLE_LENGTH)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/ContentHighlights/data/hooks.js b/src/components/ContentHighlights/data/hooks.js index 23bcd29e8c..88c3c0c65b 100644 --- a/src/components/ContentHighlights/data/hooks.js +++ b/src/components/ContentHighlights/data/hooks.js @@ -86,6 +86,7 @@ export function useContentHighlightsContext() { ...s.stepperModal, isOpen: false, highlightTitle: null, + titleStepValidationError: null, currentSelectedRowIds: {}, }, })); From a7088b6bbe49354aa1a9e746120038c0cc2026da Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Fri, 10 Feb 2023 16:26:58 -0700 Subject: [PATCH 51/73] feat: adding no config helper card (#968) * feat!: adding no config helper card * fix: adding tests --- .../settings/SettingsLMSTab/NoConfigCard.jsx | 59 ++++++++++++++ .../settings/SettingsLMSTab/index.jsx | 23 +++++- .../tests/LmsConfigPage.test.jsx | 75 ++++++++++++++---- src/components/settings/data/hooks.js | 3 + src/components/settings/settings.scss | 18 +++++ src/data/images/NoConfig.svg | 76 +++++++++++++++++++ 6 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 src/components/settings/SettingsLMSTab/NoConfigCard.jsx create mode 100644 src/data/images/NoConfig.svg diff --git a/src/components/settings/SettingsLMSTab/NoConfigCard.jsx b/src/components/settings/SettingsLMSTab/NoConfigCard.jsx new file mode 100644 index 0000000000..0918087457 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/NoConfigCard.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; + +import { Button, Card, Icon } from '@edx/paragon'; + +import { Add, Error } from '@edx/paragon/icons'; +import cardImage from '../../../data/images/NoConfig.svg'; + +const NoConfigCard = ({ + enterpriseSlug, setShowNoConfigCard, createNewConfig, hasSSOConfig, +}) => { + const onClick = () => { + setShowNoConfigCard(false); + createNewConfig(true); + }; + + return ( + + + +

You don't have any learning platforms integrated yet.

+

edX For Business seamlessly integrates with many of the most popular Learning Management Systems + (LMSes) and Learning Experience Platforms (LXPs), enabling your learners to discover and access all + the same world-class edX content from a single, centralized location. +

+
+ { hasSSOConfig && ( + + + + )} + { !hasSSOConfig && ( + +

At least one active Single Sign-On (SSO) integration is required to configure a new learning platform integration.

+ + New SSO integration + +
+ )} +
+ ); +}; + +NoConfigCard.propTypes = { + enterpriseSlug: PropTypes.string.isRequired, + setShowNoConfigCard: PropTypes.func.isRequired, + createNewConfig: PropTypes.func.isRequired, + hasSSOConfig: PropTypes.bool.isRequired, +}; + +export default NoConfigCard; diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index 0c205f5e53..066ea8e9ea 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -1,15 +1,18 @@ import React, { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; + import { Alert, Button, Hyperlink, CardGrid, Toast, Skeleton, } from '@edx/paragon'; -import { Link } from 'react-router-dom'; import { Add, Info } from '@edx/paragon/icons'; import { logError } from '@edx/frontend-platform/logging'; -import PropTypes from 'prop-types'; + import { camelCaseDictArray } from '../../../utils'; import LMSCard from './LMSCard'; import LMSConfigPage from './LMSConfigPage'; import ExistingLMSCardDeck from './ExistingLMSCardDeck'; +import NoConfigCard from './NoConfigCard'; import { BLACKBOARD_TYPE, CANVAS_TYPE, @@ -37,7 +40,8 @@ const SettingsLMSTab = ({ const [existingConfigsData, setExistingConfigsData] = useState({}); const [configsExist, setConfigsExist] = useState(false); - const [showNewConfigButtons, setShowNewConfigButtons] = useState(true); + const [showNewConfigButtons, setShowNewConfigButtons] = useState(false); + const [showNoConfigCard, setShowNoConfigCard] = useState(true); const [configsLoading, setConfigsLoading] = useState(true); const [displayNames, setDisplayNames] = useState([]); @@ -55,6 +59,7 @@ const SettingsLMSTab = ({ setConfig(configType); // Hide the create new configs button setShowNewConfigButtons(false); + setShowNoConfigCard(false); // Since the user is editing, hide the existing config cards setConfigsExist(false); }; @@ -63,16 +68,18 @@ const SettingsLMSTab = ({ const options = { enterprise_customer: enterpriseId }; LmsApiService.fetchEnterpriseCustomerIntegrationConfigs(options) .then((response) => { - setShowNewConfigButtons(true); setConfigsLoading(false); // Save all existing configs setExistingConfigsData(camelCaseDictArray(response.data)); // If the enterprise has existing configs if (response.data.length !== 0) { + setShowNoConfigCard(false); // toggle the existing configs bool setConfigsExist(true); // Hide the create cards and show the create button setShowNewConfigButtons(false); + } else { + setShowNoConfigCard(true); } }) .catch((error) => { @@ -180,6 +187,14 @@ const SettingsLMSTab = ({ )} {configsLoading && ()} + {showNoConfigCard && !configsLoading && ( + + )} {showNewConfigButtons && !configsLoading && (

New configurations

diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index ad56bc5429..fe20086565 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -56,14 +56,48 @@ const SettingsLMSWrapper = () => ( enterpriseSlug={enterpriseSlug} enableSamlConfigurationScreen={enableSamlConfigurationScreen} identityProvider={identityProvider} + hasSSOConfig={false} + /> + +); + +const SettingsLMSWrapperWithSSO = () => ( + + ); describe('', () => { - it('Renders with all LMS cards present', async () => { + it('Renders with no config card present w/o sso', async () => { renderWithRouter(); await waitFor(() => { + expect(screen.queryByText('You don\'t have any learning platforms integrated yet.')).toBeTruthy(); + expect(screen.queryByText('At least one active Single Sign-On (SSO) integration is required to configure a new learning platform integration.')).toBeTruthy(); + expect(screen.queryByText('New SSO integration')).toBeTruthy(); + expect(screen.getByText('New SSO integration').closest('a')).toHaveAttribute('href', '/test-slug/admin/settings/sso'); + }); + }); + it('Renders with no config card present w/ sso', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.queryByText('You don\'t have any learning platforms integrated yet.')).toBeTruthy(); + expect(screen.queryByText('At least one active Single Sign-On (SSO) integration is required to configure a new learning platform integration.')).toBeFalsy(); + expect(screen.queryByText('New learning platform integration')).toBeTruthy(); + userEvent.click(screen.getByText('New learning platform integration')); + expect(screen.queryByText('New configurations')).toBeTruthy(); + }); + }); + + it('Renders with all LMS cards present', async () => { + renderWithRouter(); + await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.queryByText(channelMapping[BLACKBOARD_TYPE].displayName)).toBeTruthy(); expect(screen.queryByText(channelMapping[CANVAS_TYPE].displayName)).toBeTruthy(); expect(screen.queryByText(channelMapping[CORNERSTONE_TYPE].displayName)).toBeTruthy(); @@ -73,10 +107,11 @@ describe('', () => { }); }); test('Blackboard card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[BLACKBOARD_TYPE].displayName)); }); userEvent.click(screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName)); @@ -92,10 +127,11 @@ describe('', () => { expect(screen.queryByText('Connect Blackboard')).toBeFalsy(); }); test('Canvas card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[CANVAS_TYPE].displayName)); }); const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); @@ -112,10 +148,11 @@ describe('', () => { expect(screen.queryByText('Connect Canvas')).toBeFalsy(); }); test('Cornerstone card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[CORNERSTONE_TYPE].displayName)); }); const cornerstoneCard = screen.getByText(channelMapping[CORNERSTONE_TYPE].displayName); @@ -132,10 +169,11 @@ describe('', () => { expect(screen.queryByText('Connect Cornerstone')).toBeFalsy(); }); test('Degreed card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); @@ -152,10 +190,11 @@ describe('', () => { expect(screen.queryByText('Connect Degreed')).toBeFalsy(); }); test('Moodle card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[MOODLE_TYPE].displayName)); }); const moodleCard = screen.getByText(channelMapping[MOODLE_TYPE].displayName); @@ -172,10 +211,11 @@ describe('', () => { expect(screen.queryByText('Connect Moodle')).toBeFalsy(); }); test('SAP card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[SAP_TYPE].displayName)); }); const sapCard = screen.getByText(channelMapping[SAP_TYPE].displayName); @@ -192,10 +232,11 @@ describe('', () => { expect(screen.queryByText('Connect SAP')).toBeFalsy(); }); test('No action Moodle card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[MOODLE_TYPE].displayName)); }); const moodleCard = screen.getByText(channelMapping[MOODLE_TYPE].displayName); @@ -208,10 +249,11 @@ describe('', () => { await waitFor(() => expect(screen.queryByText('Connect Moodle')).toBeFalsy()); }); test('No action Degreed2 card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); @@ -223,10 +265,11 @@ describe('', () => { expect(screen.queryByText('Connect Degreed')).toBeFalsy(); }); test('No action Degreed card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); @@ -238,10 +281,11 @@ describe('', () => { expect(screen.queryByText('Connect Degreed2')).toBeFalsy(); }); test('No action Cornerstone card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[CORNERSTONE_TYPE].displayName)); }); const cornerstoneCard = screen.getByText(channelMapping[CORNERSTONE_TYPE].displayName); @@ -253,10 +297,11 @@ describe('', () => { expect(screen.queryByText('Connect Cornerstone')).toBeFalsy(); }); test('No action Canvas card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[CANVAS_TYPE].displayName)); }); const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); @@ -268,10 +313,11 @@ describe('', () => { expect(screen.queryByText('Connect Canvas')).toBeFalsy(); }); test('No action Blackboard card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[BLACKBOARD_TYPE].displayName)); }); const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); @@ -283,10 +329,11 @@ describe('', () => { expect(screen.queryByText('Connect Blackbard')).toBeFalsy(); }); test('No action SAP card cancel flow', async () => { - renderWithRouter(); + renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[SAP_TYPE].displayName)); }); const sapCard = screen.getByText(channelMapping[SAP_TYPE].displayName); diff --git a/src/components/settings/data/hooks.js b/src/components/settings/data/hooks.js index 1816a98cb4..96e8a54003 100644 --- a/src/components/settings/data/hooks.js +++ b/src/components/settings/data/hooks.js @@ -162,6 +162,9 @@ export const useStylesForCustomBrandColors = (branding) => { .color-brand-tertiary { color: ${brandColors.tertiary.regular.hex()} !important; } + .secondary-background { + background: ${brandColors.secondary.regular.hex()} !important; + } `), }); diff --git a/src/components/settings/settings.scss b/src/components/settings/settings.scss index 5750824cb9..42c26adfec 100644 --- a/src/components/settings/settings.scss +++ b/src/components/settings/settings.scss @@ -124,3 +124,21 @@ min-width: 0.75rem; } +.no-config { + max-height: 12rem !important; + .pgn__card-image-cap { + padding: 1rem; + object-fit: fill; + } +} + +.error-footer { + background: $danger-100; + display: block; + text-align: center; + padding: 1rem 9rem !important; + .error-icon { + color: $danger-600; + + } +} diff --git a/src/data/images/NoConfig.svg b/src/data/images/NoConfig.svg new file mode 100644 index 0000000000..6eaa766c8f --- /dev/null +++ b/src/data/images/NoConfig.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From bd0f96b3a9e28a4f37a4bc280a61b3628a4f5a89 Mon Sep 17 00:00:00 2001 From: Muhammad Bilal Tahir <106396899+bilaltahir21@users.noreply.github.com> Date: Wed, 18 Jan 2023 16:56:09 +0500 Subject: [PATCH 52/73] feat: tests for the learner progress report --- .../Admin/EmbeddedSubscription.test.jsx | 71 ++++++++++++++ .../Admin/SubscriptionDetailPage.test.jsx | 96 +++++++++++++++++++ .../Admin/SubscriptionDetails.test.jsx | 96 +++++++++++++++++++ .../LicenseAllocationDetails.test.jsx | 71 ++++++++++++++ .../licenses/LicenseAllocationHeader.test.jsx | 80 ++++++++++++++++ .../LicenseManagementTable/index.test.jsx | 91 ++++++++++++++++++ .../LicenseAllocationHeader.test.jsx.snap | 35 +++++++ 7 files changed, 540 insertions(+) create mode 100644 src/components/Admin/EmbeddedSubscription.test.jsx create mode 100644 src/components/Admin/SubscriptionDetailPage.test.jsx create mode 100644 src/components/Admin/SubscriptionDetails.test.jsx create mode 100644 src/components/Admin/licenses/LicenseAllocationDetails.test.jsx create mode 100644 src/components/Admin/licenses/LicenseAllocationHeader.test.jsx create mode 100644 src/components/Admin/licenses/LicenseManagementTable/index.test.jsx create mode 100644 src/components/Admin/licenses/__snapshots__/LicenseAllocationHeader.test.jsx.snap diff --git a/src/components/Admin/EmbeddedSubscription.test.jsx b/src/components/Admin/EmbeddedSubscription.test.jsx new file mode 100644 index 0000000000..16e36f5f97 --- /dev/null +++ b/src/components/Admin/EmbeddedSubscription.test.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { SubsidyRequestsContext } from '../subsidy-requests'; +import { + generateSubscriptionPlan, + mockSubscriptionHooks, + MockSubscriptionContext, +} from '../subscriptions/tests/TestUtilities'; + +import EmbeddedSubscription from './EmbeddedSubscription'; + +const subscriptionPlan = generateSubscriptionPlan({ + licenses: { + allocated: 1, + revoked: 0, + total: 10, + }, +}, 2, 10); + +const defaultAppContext = { + enterpriseSlug: 'test-enterprise', + enterpriseConfig: { + slug: 'test-enterprise', + }, + match: { + subscription: { + uuid: '1234', + }, + params: { + subscriptionUUID: '28d4dcdc-c026-4c02-a263-82dd9c0d8b43', + }, + loadingSubscription: false, + }, +}; + +// eslint-disable-next-line react/prop-types +const AppContextProvider = ({ children }) => ( + + {children} + +); + +const initialSubsidyRequestContextValue = { + subsidyRequestConfiguration: { + isRequestSubsidyEnabled: true, + }, +}; + +const EmbeddedSubscriptionWrapper = () => ( + + + + + + + + + + + +); + +describe('EmbeddedSubscription', () => { + it('renders without crashing', () => { + mockSubscriptionHooks(subscriptionPlan); + render(); + }); +}); diff --git a/src/components/Admin/SubscriptionDetailPage.test.jsx b/src/components/Admin/SubscriptionDetailPage.test.jsx new file mode 100644 index 0000000000..82d6b48369 --- /dev/null +++ b/src/components/Admin/SubscriptionDetailPage.test.jsx @@ -0,0 +1,96 @@ +/* eslint-disable react/jsx-no-constructed-context-values */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { SubscriptionDetailPage } from './SubscriptionDetailPage'; +import { SubsidyRequestsContext } from '../subsidy-requests'; +import { + generateSubscriptionPlan, + mockSubscriptionHooks, + MockSubscriptionContext, +} from '../subscriptions/tests/TestUtilities'; + +const subscriptionPlan = generateSubscriptionPlan({ + licenses: { + allocated: 1, + revoked: 0, + total: 10, + }, +}, 2, 10); + +// eslint-disable-next-line no-console +console.error = jest.fn(); + +const defaultAppContext = { + enterpriseSlug: 'test-enterprise', + enterpriseConfig: { + slug: 'test-enterprise', + }, + match: { + subscription: { + uuid: '1234', + }, + params: { + subscriptionUUID: '28d4dcdc-c026-4c02-a263-82dd9c0d8b43', + }, + loadingSubscription: false, + }, +}; + +const initialSubsidyRequestContextValue = { + subsidyRequestConfiguration: { + isRequestSubsidyEnabled: true, + }, +}; + +// eslint-disable-next-line react/prop-types +const AppContextProvider = ({ children }) => ( + + {children} + +); + +const SubscriptionDetailPageWrapper = ({ context = defaultAppContext }) => ( + + + + + + + + + + + +); +SubscriptionDetailPageWrapper.propTypes = { + context: PropTypes.shape({ + enterpriseSlug: PropTypes.string, + match: PropTypes.shape({ + subscription: PropTypes.shape({ + uuid: PropTypes.string, + }), + params: PropTypes.shape({ + subscriptionUUID: PropTypes.string, + }), + loadingSubscription: PropTypes.bool, + }), + }).isRequired, +}; + +describe('SubscriptionDetailPage', () => { + it('renders the SubscriptionDetailPage component', () => { + mockSubscriptionHooks(subscriptionPlan); + render(); + + expect(document.querySelector('h2').textContent).toEqual( + 'Get Started', + ); + }); +}); diff --git a/src/components/Admin/SubscriptionDetails.test.jsx b/src/components/Admin/SubscriptionDetails.test.jsx new file mode 100644 index 0000000000..ef22792e88 --- /dev/null +++ b/src/components/Admin/SubscriptionDetails.test.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import configureMockStore from 'redux-mock-store'; +import { SubscriptionDetailContext } from '../subscriptions/SubscriptionDetailContextProvider'; +import SubscriptionDetails from './SubscriptionDetails'; +import { SubscriptionContext } from '../subscriptions/SubscriptionData'; + +const mockStore = configureMockStore(); +const defaultStore = mockStore({ + forceRefresh: jest.fn(), + portalConfiguration: { + enterpriseSlug: 'test-enterprise-slug', + }, +}); + +const defaultSubscriptionContextValue = { + forceRefresh: jest.fn(), +}; + +const defaultSubscriptionDetailContextValue = { + startDate: '2021-01-01', + expirationDate: '2021-12-31', + subscription: { + uuid: 'test-uuid', + licenses: { + allocated: 3, + revoked: 1, + unassigned: 1, + total: 5, + showToast: true, + }, + daysUntilExpiration: 10, + shouldShowInviteLearnersButton: true, + isLockedForRenewalProcessing: false, + forceRefreshDetailView: jest.fn(), + }, + overview: { + unassigned: 1, + }, +}; + +const SubscriptionDetailsWrapper = ({ + initialSubscriptionDetailContextValue = defaultSubscriptionDetailContextValue, +}) => ( + + + + + + + + + + + +); + +SubscriptionDetailsWrapper.propTypes = { + initialSubscriptionDetailContextValue: PropTypes.shape({ + startDate: PropTypes.string, + expirationDate: PropTypes.string, + subscription: PropTypes.shape({ + licenses: PropTypes.shape({ + allocated: PropTypes.number, + total: PropTypes.number, + revoked: PropTypes.number, + showToast: PropTypes.bool, + }), + isLockedForRenewalProcessing: PropTypes.bool, + daysUntilExpiration: PropTypes.number, + }), + forceRefreshDetailView: PropTypes.func, + }).isRequired, +}; + +describe('SubscriptionDetails', () => { + it('renders correctly', () => { + render(); + + const manageLearnersButton = screen.getByText('Manage All Learners'); + manageLearnersButton.click(); + + const inviteLearnersButton = screen.getByText('Invite learners'); + inviteLearnersButton.click(); + }); +}); diff --git a/src/components/Admin/licenses/LicenseAllocationDetails.test.jsx b/src/components/Admin/licenses/LicenseAllocationDetails.test.jsx new file mode 100644 index 0000000000..ce94e29dd6 --- /dev/null +++ b/src/components/Admin/licenses/LicenseAllocationDetails.test.jsx @@ -0,0 +1,71 @@ +/* eslint-disable react/prop-types */ +import { screen, render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import React from 'react'; +import { SubscriptionDetailContext } from '../../subscriptions/SubscriptionDetailContextProvider'; +import { SubsidyRequestsContext } from '../../subsidy-requests'; + +import LicenseAllocationDetails from '../../subscriptions/licenses/LicenseAllocationDetails'; + +const BNR_NEW_FEATURE_ALERT_TEXT = 'browse and request new feature alert!'; +jest.mock('../../NewFeatureAlertBrowseAndRequest', () => ({ + __esModule: true, + default: () => BNR_NEW_FEATURE_ALERT_TEXT, +})); + +jest.mock('../../subscriptions/licenses/LicenseManagementTable', () => ({ + __esModule: true, + default: () => 'LicenseManagementTable', +})); + +const mockSubscription = { + licenses: { + allocated: 3, + total: 5, + }, +}; +const mockSubsidyRequestConfiguration = {}; + +const defaultSubscriptionDetailContextValue = { + subscription: mockSubscription, +}; +const defaultSubsidyRequestContextValue = { + subsidyRequestConfiguration: mockSubsidyRequestConfiguration, +}; + +const LicenseAllocationDetailsWrapper = ({ + initialSubscriptionDetailContextValue = defaultSubscriptionDetailContextValue, + initialSubsidyRequestContextValue = defaultSubsidyRequestContextValue, +}) => ( + + + + + +); + +describe('LicenseAllocationDetails', () => { + it('renders the correct number of licenses allocated', () => { + render(); + expect(screen.getByText('3 of 5 licenses allocated')).toBeInTheDocument(); + }); + + it('renders the correct number of licenses allocated when the subscription has no licenses', () => { + const subscriptionWithNoLicenses = { + licenses: { + allocated: 0, + total: 0, + }, + }; + render( + , + ); + expect(screen.getByText('0 of 0 licenses allocated')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Admin/licenses/LicenseAllocationHeader.test.jsx b/src/components/Admin/licenses/LicenseAllocationHeader.test.jsx new file mode 100644 index 0000000000..bba9f04e4f --- /dev/null +++ b/src/components/Admin/licenses/LicenseAllocationHeader.test.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import configureMockStore from 'redux-mock-store'; +import LicenseAllocationHeader from './LicenseAllocationHeader'; +import { SubscriptionDetailContext } from '../../subscriptions/SubscriptionDetailContextProvider'; +import { SubsidyRequestsContext } from '../../subsidy-requests'; + +// eslint-disable-next-line react/jsx-no-constructed-context-values +describe('LicenseAllocationHeader', () => { + const mockStore = configureMockStore(); + + // eslint-disable-next-line react/prop-types + const SubscriptionDetailContextWrapper = ({ children }) => ( + // eslint-disable-next-line react/jsx-no-constructed-context-values + + {children} + + ); + + // eslint-disable-next-line react/prop-types + const SubsidyRequestsContextWrapper = ({ children }) => ( + // eslint-disable-next-line react/jsx-no-constructed-context-values + + {children} + + ); + + const LicenseAllocationHeaderWrapper = () => ( + + + + + + + + + + ); + + it('renders without crashing', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/Admin/licenses/LicenseManagementTable/index.test.jsx b/src/components/Admin/licenses/LicenseManagementTable/index.test.jsx new file mode 100644 index 0000000000..ce381dc124 --- /dev/null +++ b/src/components/Admin/licenses/LicenseManagementTable/index.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { MemoryRouter } from 'react-router-dom'; +import { + MockSubscriptionContext, + generateSubscriptionPlan, + generateSubscriptionUser, + mockSubscriptionHooks, +} from '../../../subscriptions/tests/TestUtilities'; + +import LicenseManagementTable from '.'; + +const mockStore = configureMockStore(); +const store = mockStore({ + forceRefresh: jest.fn(), +}); + +const defaultSubscriptionPlan = generateSubscriptionPlan( + { + licenses: { + total: 1, + allocated: 1, + unassigned: 0, + }, + }, +); + +const LicenseManagementTableWrapper = ({ subscriptionPlan, ...props }) => ( + + + + + + + + + +); +LicenseManagementTableWrapper.propTypes = { + subscriptionPlan: PropTypes.shape({ + uuid: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + enterpriseCustomerName: PropTypes.string.isRequired, + enterpriseCustomerUuid: PropTypes.string.isRequired, + startDate: PropTypes.string.isRequired, + expirationDate: PropTypes.string.isRequired, + licenses: PropTypes.shape({ + total: PropTypes.number.isRequired, + allocated: PropTypes.number.isRequired, + unassigned: PropTypes.number.isRequired, + }).isRequired, + revocationDate: PropTypes.string, + }).isRequired, +}; + +const usersSetup = ( + status1 = 'assigned', + status2 = 'activated', + status3 = 'revoked', + status4 = 'assigned', +) => { + const refreshFunctions = mockSubscriptionHooks(defaultSubscriptionPlan, [ + generateSubscriptionUser({ status1 }), + generateSubscriptionUser({ status2 }), + generateSubscriptionUser({ status3 }), + generateSubscriptionUser({ status4 }), + ]); + return refreshFunctions; +}; + +describe('', () => { + it('renders the license management table', () => { + usersSetup(); + render(); + + // Revoke a license + screen.getByLabelText('Revoke license').click(); // Click on the revoke license button + screen.getByLabelText('Revoke License').click(); // Confirm license revocation + + // Check Pagination + screen.getByLabelText('Next, Page 2').click(); + screen.getByLabelText('Previous, Page 1').click(); + }); +}); diff --git a/src/components/Admin/licenses/__snapshots__/LicenseAllocationHeader.test.jsx.snap b/src/components/Admin/licenses/__snapshots__/LicenseAllocationHeader.test.jsx.snap new file mode 100644 index 0000000000..04f310158d --- /dev/null +++ b/src/components/Admin/licenses/__snapshots__/LicenseAllocationHeader.test.jsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LicenseAllocationHeader renders without crashing 1`] = ` +
+

+ Licenses: +

+ + Unassigned: + 1 + of + 2 + total + + + Activated: + 1 + of + 1 + assigned + +
+`; From aafbdfcbd5a145470298d3d5fa44cce785305577 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Tue, 14 Feb 2023 12:05:34 -0700 Subject: [PATCH 53/73] feat: adding no sso config card (#970) * feat: adding no sso config card * fix: bug fix * fix: Update src/components/settings/SettingsSSOTab/index.jsx Co-authored-by: Marlon Keating <322346+marlonkeating@users.noreply.github.com> --------- Co-authored-by: Marlon Keating <322346+marlonkeating@users.noreply.github.com> --- .../settings/SettingsSSOTab/NoSSOCard.jsx | 43 ++++++++++++++++++ .../settings/SettingsSSOTab/index.jsx | 14 ++++-- .../tests/SSOConfigPage.test.jsx | 45 +++++++++++++++++++ src/data/images/NoSSO.svg | 40 +++++++++++++++++ 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/components/settings/SettingsSSOTab/NoSSOCard.jsx create mode 100644 src/components/settings/SettingsSSOTab/tests/SSOConfigPage.test.jsx create mode 100644 src/data/images/NoSSO.svg diff --git a/src/components/settings/SettingsSSOTab/NoSSOCard.jsx b/src/components/settings/SettingsSSOTab/NoSSOCard.jsx new file mode 100644 index 0000000000..0cad49a922 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/NoSSOCard.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, Card } from '@edx/paragon'; +import { Add } from '@edx/paragon/icons'; +import cardImage from '../../../data/images/NoSSO.svg'; + +const NoSSOCard = ({ + setShowNoSSOCard, setShowNewSSOForm, +}) => { + const onClick = () => { + setShowNoSSOCard(false); + setShowNewSSOForm(true); + }; + + return ( + + + +

You don't have any SSOs integrated yet.

+

SSO enables learners who are signed in to their enterprise LMS + or other system to easily register and enroll in courses on edX without needing to + sign in again. edX for Business uses SAML 2.0 to implement SSO between an enterprise + system and edX.org. +

+
+ + + +
+ ); +}; + +NoSSOCard.propTypes = { + setShowNoSSOCard: PropTypes.func.isRequired, + setShowNewSSOForm: PropTypes.func.isRequired, +}; + +export default NoSSOCard; diff --git a/src/components/settings/SettingsSSOTab/index.jsx b/src/components/settings/SettingsSSOTab/index.jsx index 3b4ced6f66..ff17bfe254 100644 --- a/src/components/settings/SettingsSSOTab/index.jsx +++ b/src/components/settings/SettingsSSOTab/index.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { Alert, Hyperlink, Toast, Skeleton, @@ -6,6 +6,7 @@ import { import { WarningFilled } from '@edx/paragon/icons'; import { HELP_CENTER_SAML_LINK } from '../data/constants'; import { useExistingSSOConfigs, useExistingProviderData } from './hooks'; +import NoSSOCard from './NoSSOCard'; import ExistingSSOConfigs from './ExistingSSOConfigs'; import NewSSOConfigForm from './NewSSOConfigForm'; import { SSOConfigContext, SSOConfigContextProvider } from './SSOConfigContext'; @@ -18,6 +19,8 @@ const SettingsSSOTab = ({ enterpriseId, setHasSSOConfig }) => { } = useContext(SSOConfigContext); const [existingConfigs, error, isLoading] = useExistingSSOConfigs(enterpriseId, refreshBool); const [existingProviderData, pdError, pdIsLoading] = useExistingProviderData(enterpriseId, refreshBool); + const [showNewSSOForm, setShowNewSSOForm] = useState(false); + const [showNoSSOCard, setShowNoSSOCard] = useState(false); useEffect(() => { let validConfigExists = false; @@ -26,6 +29,11 @@ const SettingsSSOTab = ({ enterpriseId, setHasSSOConfig }) => { validConfigExists = true; } }); + if (!existingConfigs || existingConfigs?.length < 1) { + setShowNoSSOCard(true); + } else { + setShowNoSSOCard(false); + } setHasSSOConfig(validConfigExists); }, [existingConfigs, setHasSSOConfig]); @@ -55,10 +63,10 @@ const SettingsSSOTab = ({ enterpriseId, setHasSSOConfig }) => { /> )} {/* Nothing found so guide user to creation/edit form */} - {existingConfigs?.length < 1 && } + {showNoSSOCard && } {/* Since we found a selected providerConfig we know we are in editing mode and can safely render the create/edit form */} - {existingConfigs?.length > 0 && (providerConfig !== null) && } + {((existingConfigs?.length > 0 && providerConfig !== null) || showNewSSOForm) && ()} {error && ( An error occurred loading the SAML configs:

{error?.message}

diff --git a/src/components/settings/SettingsSSOTab/tests/SSOConfigPage.test.jsx b/src/components/settings/SettingsSSOTab/tests/SSOConfigPage.test.jsx new file mode 100644 index 0000000000..8de811e66e --- /dev/null +++ b/src/components/settings/SettingsSSOTab/tests/SSOConfigPage.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; + +import { renderWithRouter } from '../../../test/testUtils'; +import SettingsSSOTab from '../index'; + +const enterpriseId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +const enterpriseSlug = 'test-slug'; +const enterpriseName = 'name'; +const enableSamlConfigurationScreen = true; +const identityProvider = ''; + +const initialState = { + portalConfiguration: { + enterpriseId, enterpriseSlug, enterpriseName, enableSamlConfigurationScreen, identityProvider, + }, +}; + +const mockStore = configureMockStore([thunk]); +const setHasSSOConfig = jest.fn(); +const SettingsSSOWrapper = () => ( + + + +); + +describe('', () => { + it('Renders with no config card present', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.queryByText('You don\'t have any SSOs integrated yet.')).toBeTruthy(); + expect(screen.queryByText('New SSO integration')).toBeTruthy(); + userEvent.click(screen.getByText('New SSO integration')); + expect(screen.queryByText('First provide your Identity Provider Metadata and fill out the corresponding fields.')).toBeTruthy(); + }); + }); +}); diff --git a/src/data/images/NoSSO.svg b/src/data/images/NoSSO.svg new file mode 100644 index 0000000000..3e1c745bf2 --- /dev/null +++ b/src/data/images/NoSSO.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 64ced96b90a0b81b5f72d6a2bb570aa3fe5b8f20 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Thu, 19 Jan 2023 22:53:31 +0000 Subject: [PATCH 54/73] feat: New LMS Config Workflow Skeleton + Typescript fix: Add Display name field entry back to canvas config chore: Update frontend-build version and add tsconfig.json fix: Canvas lms update fix fix: Form workflow navigating to next step fix: Workflow stepper steps feat: LMS config workflow new unsaved changes modal --- package-lock.json | 6599 +++-------------- package.json | 2 +- src/components/forms/FormContext.tsx | 53 + src/components/forms/FormContextWrapper.tsx | 45 + src/components/forms/FormWorkflow.tsx | 150 + src/components/forms/ValidatedFormControl.tsx | 55 + src/components/forms/data/actions.ts | 32 + src/components/forms/data/reducer.ts | 85 + .../settings/SettingsLMSTab/ConfigModal.jsx | 1 + .../settings/SettingsLMSTab/LMSConfigPage.jsx | 78 +- .../LMSConfigs/Canvas/CanvasConfig.tsx | 150 + .../Canvas/CanvasConfigActivatePage.tsx | 24 + .../Canvas/CanvasConfigAuthorizePage.tsx | 98 + .../LMSConfigs/CanvasConfig.jsx | 347 - .../SettingsLMSTab/UnsavedChangesModal.tsx | 53 + .../settings/SettingsLMSTab/index.jsx | 1 + .../tests/CanvasConfig.test.jsx | 3 +- tsconfig.json | 27 + 18 files changed, 2039 insertions(+), 5764 deletions(-) create mode 100644 src/components/forms/FormContext.tsx create mode 100644 src/components/forms/FormContextWrapper.tsx create mode 100644 src/components/forms/FormWorkflow.tsx create mode 100644 src/components/forms/ValidatedFormControl.tsx create mode 100644 src/components/forms/data/actions.ts create mode 100644 src/components/forms/data/reducer.ts create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx delete mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx create mode 100644 src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json index fa1b9efc27..ea3ef511b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ }, "devDependencies": { "@edx/browserslist-config": "1.0.0", - "@edx/frontend-build": "^12.3.0", + "@edx/frontend-build": "^12.5.0", "@faker-js/faker": "^7.6.0", "@testing-library/dom": "7.31.2", "@testing-library/jest-dom": "5.11.9", @@ -302,12 +302,12 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.18.2.tgz", - "integrity": "sha512-oFQYkE8SuH14+uR51JVAmdqwKYXGRjEXx7s+WiagVjqQ+HPE+nnwyF2qlVG8evUsUHmPcA+6YXMEDbIhEyQc5A==", + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz", + "integrity": "sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==", "dev": true, "dependencies": { - "eslint-scope": "^5.1.1", + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", "semver": "^6.3.0" }, @@ -2098,9 +2098,9 @@ "dev": true }, "node_modules/@edx/eslint-config": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-3.1.0.tgz", - "integrity": "sha512-Okv8vkmX+qe+joD7h9DcT9JdRIyy6jJSVWbIHr2dAHKuk5swVFO92JvhC2pYtMg2EPKA1P1Hmz8cmmfw6QoTZw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-3.1.1.tgz", + "integrity": "sha512-RqaC5h+VYdq5DwJsEbdJCruibsKWakx/zImuypmwC7odXJ2ls/yMvWzY/Z4k/Xd2QGPhow3y7yQzUsJPb4eheQ==", "dev": true, "peerDependencies": { "eslint": "^6.8.0 || ^7.0.0 || ^8.0.0", @@ -2112,24 +2112,24 @@ } }, "node_modules/@edx/frontend-build": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-build/-/frontend-build-12.3.0.tgz", - "integrity": "sha512-GewVd5qD59d8hHZFnXyS5jWkDY8TWYENqO5dd+/Kmu7cmjKrp69PKmNyVs+QgZEADodYI5UT48A2LhsDZjDx7A==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-build/-/frontend-build-12.5.0.tgz", + "integrity": "sha512-2jQJ9SaJWPspsozvFeHYdnp5m9tq2nhv1EK/ypaojA/+rzZt73Dgl6DVFTzTYRFTuKcMvVuORgyn9Z39AMr5ew==", "dev": true, "dependencies": { "@babel/cli": "7.16.0", "@babel/core": "7.16.0", - "@babel/eslint-parser": "7.18.2", + "@babel/eslint-parser": "7.19.1", "@babel/plugin-proposal-class-properties": "7.16.0", "@babel/plugin-proposal-object-rest-spread": "7.16.0", "@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/preset-env": "7.16.4", "@babel/preset-react": "7.16.0", - "@edx/eslint-config": "3.1.0", - "@edx/new-relic-source-map-webpack-plugin": "1.0.1", - "@pmmmwh/react-refresh-webpack-plugin": "0.5.3", + "@edx/eslint-config": "3.1.1", + "@edx/new-relic-source-map-webpack-plugin": "1.0.2", + "@pmmmwh/react-refresh-webpack-plugin": "0.5.10", "@svgr/webpack": "6.2.1", - "autoprefixer": "10.2.6", + "autoprefixer": "10.4.13", "babel-jest": "26.6.3", "babel-loader": "8.2.3", "babel-plugin-react-intl": "7.9.4", @@ -2138,36 +2138,37 @@ "clean-webpack-plugin": "3.0.0", "css-loader": "5.2.7", "cssnano": "5.0.12", - "dotenv": "8.2.0", + "dotenv": "8.6.0", "dotenv-webpack": "7.0.3", - "eslint": "8.18.0", + "eslint": "8.29.0", "eslint-config-airbnb": "19.0.4", "eslint-plugin-import": "2.26.0", - "eslint-plugin-jsx-a11y": "6.5.1", - "eslint-plugin-react": "7.30.0", + "eslint-plugin-jsx-a11y": "6.6.1", + "eslint-plugin-react": "7.31.11", "eslint-plugin-react-hooks": "4.6.0", "file-loader": "6.2.0", "html-webpack-plugin": "5.5.0", "identity-obj-proxy": "3.0.0", - "image-webpack-loader": "8.1.0", + "image-minimizer-webpack-plugin": "3.3.0", "jest": "26.6.3", "mini-css-extract-plugin": "1.6.2", - "postcss": "8.4.5", - "postcss-loader": "6.1.1", + "postcss": "8.4.21", + "postcss-loader": "6.2.1", "postcss-rtlcss": "3.5.1", - "react-dev-utils": "12.0.0", - "react-refresh": "0.10.0", - "resolve-url-loader": "5.0.0-beta.1", - "sass": "1.49.9", + "react-dev-utils": "12.0.1", + "react-refresh": "0.14.0", + "resolve-url-loader": "5.0.0", + "sass": "1.56.1", "sass-loader": "12.6.0", + "sharp": "^0.31.0", "source-map-loader": "0.2.4", - "style-loader": "2.0.0", + "style-loader": "3.3.1", "url-loader": "4.1.1", - "webpack": "5.50.0", + "webpack": "5.75.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0", "webpack-dev-server": "4.7.3", - "webpack-merge": "5.2.0" + "webpack-merge": "5.8.0" }, "bin": { "fedx-scripts": "bin/fedx-scripts.js" @@ -2206,26 +2207,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@edx/frontend-build/node_modules/@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@edx/frontend-build/node_modules/@types/estree": { - "version": "0.0.50", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz", - "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==", - "dev": true - }, "node_modules/@edx/frontend-build/node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -2247,12 +2228,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@edx/frontend-build/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/@edx/frontend-build/node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -2521,6 +2496,15 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/@edx/frontend-build/node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/@edx/frontend-build/node_modules/dotenv-webpack": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-7.0.3.tgz", @@ -2545,12 +2529,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/@edx/frontend-build/node_modules/es-module-lexer": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.7.1.tgz", - "integrity": "sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw==", - "dev": true - }, "node_modules/@edx/frontend-build/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2563,80 +2541,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@edx/frontend-build/node_modules/eslint": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz", - "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==", - "dev": true, - "dependencies": { - "@eslint/eslintrc": "^1.3.0", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.2", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.15.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@edx/frontend-build/node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@edx/frontend-build/node_modules/eslint/node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/@edx/frontend-build/node_modules/filesize": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", @@ -2646,114 +2550,6 @@ "node": ">= 0.4.0" } }, - "node_modules/@edx/frontend-build/node_modules/fork-ts-checker-webpack-plugin": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", - "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "chokidar": "^3.4.2", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "glob": "^7.1.6", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "engines": { - "node": ">=10", - "yarn": ">=1.0.0" - }, - "peerDependencies": { - "eslint": ">= 6", - "typescript": ">= 2.7", - "vue-template-compiler": "*", - "webpack": ">= 4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - }, - "vue-template-compiler": { - "optional": true - } - } - }, - "node_modules/@edx/frontend-build/node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@edx/frontend-build/node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@edx/frontend-build/node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", - "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@edx/frontend-build/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@edx/frontend-build/node_modules/globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@edx/frontend-build/node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -2862,39 +2658,15 @@ } }, "node_modules/@edx/frontend-build/node_modules/immer": { - "version": "9.0.16", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz", - "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==", + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.19.tgz", + "integrity": "sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ==", "dev": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" } }, - "node_modules/@edx/frontend-build/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@edx/frontend-build/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@edx/frontend-build/node_modules/mini-css-extract-plugin": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.6.2.tgz", @@ -2946,21 +2718,27 @@ } }, "node_modules/@edx/frontend-build/node_modules/postcss": { - "version": "8.4.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", - "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], "dependencies": { - "nanoid": "^3.1.30", + "nanoid": "^3.3.4", "picocolors": "^1.0.0", - "source-map-js": "^1.0.1" + "source-map-js": "^1.0.2" }, "engines": { "node": "^10 || ^12 || >=14" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" } }, "node_modules/@edx/frontend-build/node_modules/postcss-calc": { @@ -3379,9 +3157,9 @@ } }, "node_modules/@edx/frontend-build/node_modules/react-dev-utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0.tgz", - "integrity": "sha512-xBQkitdxozPxt1YZ9O1097EJiVpwHr9FoAuEVURCKV0Av8NBERovJauzP7bo1ThvuhZ4shsQ1AJiu4vQpoT1AQ==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.16.0", @@ -3403,7 +3181,7 @@ "open": "^8.4.0", "pkg-up": "^3.1.0", "prompts": "^2.4.2", - "react-error-overlay": "^6.0.10", + "react-error-overlay": "^6.0.11", "recursive-readdir": "^2.2.2", "shell-quote": "^1.7.3", "strip-ansi": "^6.0.1", @@ -3413,6 +3191,45 @@ "node": ">=14" } }, + "node_modules/@edx/frontend-build/node_modules/react-dev-utils/node_modules/fork-ts-checker-webpack-plugin": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", + "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=10", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, "node_modules/@edx/frontend-build/node_modules/react-dev-utils/node_modules/loader-utils": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", @@ -3422,6 +3239,48 @@ "node": ">= 12.13.0" } }, + "node_modules/@edx/frontend-build/node_modules/react-dev-utils/node_modules/schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@edx/frontend-build/node_modules/react-dev-utils/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@edx/frontend-build/node_modules/react-dev-utils/node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/@edx/frontend-build/node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -3514,69 +3373,10 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, - "node_modules/@edx/frontend-build/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@edx/frontend-build/node_modules/webpack": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.50.0.tgz", - "integrity": "sha512-hqxI7t/KVygs0WRv/kTgUW8Kl3YC81uyWQSo/7WUs5LsuRw0htH/fCwbVBGCuiX/t4s7qzjXFcf41O8Reiypag==", - "dev": true, - "dependencies": { - "@types/eslint-scope": "^3.7.0", - "@types/estree": "^0.0.50", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.4.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.8.0", - "es-module-lexer": "^0.7.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.4", - "json-parse-better-errors": "^1.0.2", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.2.0", - "webpack-sources": "^3.2.0" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/@edx/frontend-build/node_modules/webpack-merge": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.2.0.tgz", - "integrity": "sha512-QBglJBg5+lItm3/Lopv8KDDK01+hjdg2azEwi/4vKJ8ZmGPdtJsTpjtNNOW3a4WiqzXdCATtTudOZJngE7RKkA==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.8.0.tgz", + "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", "dev": true, "dependencies": { "clone-deep": "^4.0.1", @@ -3586,21 +3386,6 @@ "node": ">=10.0.0" } }, - "node_modules/@edx/frontend-build/node_modules/webpack/node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@edx/frontend-build/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@edx/frontend-enterprise-catalog-search": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@edx/frontend-enterprise-catalog-search/-/frontend-enterprise-catalog-search-3.1.5.tgz", @@ -3780,9 +3565,9 @@ } }, "node_modules/@edx/new-relic-source-map-webpack-plugin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@edx/new-relic-source-map-webpack-plugin/-/new-relic-source-map-webpack-plugin-1.0.1.tgz", - "integrity": "sha512-SAwugqBvDUg7ANdu1uBCkpPp0MnBlfyYAvKwO6Jh6Q4tMIPN6U35IF7MAymn1EZmi28UV6sLpSQPLggQjVQknA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@edx/new-relic-source-map-webpack-plugin/-/new-relic-source-map-webpack-plugin-1.0.2.tgz", + "integrity": "sha512-jwu9WjRtEbv0rvPHGCnhbQbbv6+DTADShj43NQVsuAyD823znjutgoHnlc+9HIOiYiIxjz/wIMcGVwjrTnMceQ==", "dev": true, "dependencies": { "@newrelic/publish-sourcemap": "^5.0.1" @@ -3910,15 +3695,15 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", - "integrity": "sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", + "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.4.0", - "globals": "^13.15.0", + "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -3939,9 +3724,9 @@ "dev": true }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", + "version": "13.19.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", + "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -4221,11 +4006,10 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.7", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz", - "integrity": "sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==", + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", + "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", "dev": true, - "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -4240,7 +4024,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "peer": true, "engines": { "node": ">=12.22" }, @@ -5407,6 +5190,15 @@ "dev": true, "optional": true }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "dependencies": { + "eslint-scope": "5.1.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5443,18 +5235,18 @@ } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.3.tgz", - "integrity": "sha512-OoTnFb8XEYaOuMNhVDsLRnAO6MCYHNs1g6d8pBcHhDFsi1P3lPbq/IklwtbAx9cG0W4J9KswxZtwGnejrnxp+g==", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", + "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==", "dev": true, "dependencies": { "ansi-html-community": "^0.0.8", "common-path-prefix": "^3.0.0", - "core-js-pure": "^3.8.1", + "core-js-pure": "^3.23.3", "error-stack-parser": "^2.0.6", "find-up": "^5.0.0", "html-entities": "^2.1.0", - "loader-utils": "^2.0.0", + "loader-utils": "^2.0.4", "schema-utils": "^3.0.0", "source-map": "^0.7.3" }, @@ -5465,7 +5257,7 @@ "@types/webpack": "4.x || 5.x", "react-refresh": ">=0.10.0 <1.0.0", "sockjs-client": "^1.4.0", - "type-fest": ">=0.17.0 <3.0.0", + "type-fest": ">=0.17.0 <4.0.0", "webpack": ">=4.43.0 <6.0.0", "webpack-dev-server": "3.x || 4.x", "webpack-hot-middleware": "2.x", @@ -5541,16 +5333,6 @@ "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", "dev": true }, - "node_modules/@sindresorhus/is": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz", - "integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/@sinonjs/commons": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.5.tgz", @@ -6184,21 +5966,21 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "node_modules/@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", + "version": "4.17.16", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.16.tgz", + "integrity": "sha512-LkKpqRZ7zqXJuvoELakaFYuETHjZkSol8EV6cNnyishutDBCCdv6+dsKPbKkCcIk57qRphOLY5sEgClw1bO3gA==", "dev": true, "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", + "@types/express-serve-static-core": "^4.17.31", "@types/qs": "*", "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.31", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", - "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", + "version": "4.17.33", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz", + "integrity": "sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==", "dev": true, "dependencies": { "@types/node": "*", @@ -6589,9 +6371,9 @@ } }, "node_modules/@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", + "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", "dev": true, "dependencies": { "@types/node": "*" @@ -6972,9 +6754,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", @@ -7091,50 +6873,6 @@ "node": ">= 8" } }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "optional": true - }, - "node_modules/archive-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/archive-type/-/archive-type-4.0.0.tgz", - "integrity": "sha512-zV4Ky0v1F8dBrdYElwTvQhweQ0P7Kwc1aluqJsYtOBP01jXcWCyW2IEfI1YiqsG+Iy7ZR+o5LF1N+PGECBxHWA==", - "dev": true, - "optional": true, - "dependencies": { - "file-type": "^4.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/archive-type/node_modules/file-type": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", - "integrity": "sha512-f2UbFQEk7LXgWpi5ntcO86OeA/cC80fuDDDaX/fZ2ZGel+AF7leRQqBBW1eJNiiQkrZlAoM6P+VYP5P6bOlDEQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -7345,6 +7083,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", + "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.1.3" + } + }, "node_modules/assert-ok": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-ok/-/assert-ok-1.0.0.tgz", @@ -7409,17 +7160,27 @@ } }, "node_modules/autoprefixer": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.2.6.tgz", - "integrity": "sha512-8lChSmdU6dCNMCQopIf4Pe5kipkAGj/fvTMslCsih0uHpOrXOPUEVOmYMMqmw3cekQkSD7EhIeuYl5y0BLdKqg==", + "version": "10.4.13", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz", + "integrity": "sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + } + ], "dependencies": { - "browserslist": "^4.16.6", - "caniuse-lite": "^1.0.30001230", - "colorette": "^1.2.2", - "fraction.js": "^4.1.1", + "browserslist": "^4.21.4", + "caniuse-lite": "^1.0.30001426", + "fraction.js": "^4.2.0", "normalize-range": "^0.1.2", - "postcss-value-parser": "^4.1.0" + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" }, "bin": { "autoprefixer": "bin/autoprefixer" @@ -7427,18 +7188,14 @@ "engines": { "node": "^10 || ^12 || >=14" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.1.0" } }, "node_modules/axe-core": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.5.2.tgz", - "integrity": "sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz", + "integrity": "sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==", "dev": true, "engines": { "node": ">=4" @@ -7938,8 +7695,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "optional": true + ] }, "node_modules/batch": { "version": "0.6.1", @@ -7955,441 +7711,38 @@ "node": "*" } }, - "node_modules/bin-build": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bin-build/-/bin-build-3.0.0.tgz", - "integrity": "sha512-jcUOof71/TNAI2uM5uoUaDq2ePcVBQ3R/qhxAz1rX7UfvduAL/RXD3jXzvn8cVcDJdGVkiR1shal3OH0ImpuhA==", - "dev": true, - "optional": true, - "dependencies": { - "decompress": "^4.0.0", - "download": "^6.2.2", - "execa": "^0.7.0", - "p-map-series": "^1.0.0", - "tempfile": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-build/node_modules/cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "dependencies": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "node_modules/bin-build/node_modules/execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-build/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-build/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/bin-build/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/bin-build/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/bin-check": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bin-check/-/bin-check-4.1.0.tgz", - "integrity": "sha512-b6weQyEUKsDGFlACWSIOfveEnImkJyK/FGW6FAG42loyoquvjdtOIqO6yBFzHyqyVVhNgNkQxxx09SFLK28YnA==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^0.7.0", - "executable": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-check/node_modules/cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "dependencies": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "node_modules/bin-check/node_modules/execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-check/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-check/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/bin-check/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/bin-check/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/bin-version": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-3.1.0.tgz", - "integrity": "sha512-Mkfm4iE1VFt4xd4vH+gx+0/71esbfus2LsnCGe8Pi4mndSPyT+NGES/Eg99jx8/lUGWfu3z2yuB/bt5UB+iVbQ==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^1.0.0", - "find-versions": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-version-check": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/bin-version-check/-/bin-version-check-4.0.0.tgz", - "integrity": "sha512-sR631OrhC+1f8Cvs8WyVWOA33Y8tgwjETNPyyD/myRBXLkfS/vl74FmH/lFcRl9KY3zwGh7jFhvyk9vV3/3ilQ==", + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true, - "optional": true, - "dependencies": { - "bin-version": "^3.0.0", - "semver": "^5.6.0", - "semver-truncate": "^1.1.2" - }, "engines": { - "node": ">=6" - } - }, - "node_modules/bin-version-check/node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, - "optional": true, - "bin": { - "semver": "bin/semver" + "node": ">=8" } }, - "node_modules/bin-wrapper": { + "node_modules/bl": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bin-wrapper/-/bin-wrapper-4.1.0.tgz", - "integrity": "sha512-hfRmo7hWIXPkbpi0ZltboCMVrU+0ClXR/JgbCKKjlDjQf6igXa7OwdqNcFWQZPZTgiY7ZpzE3+LjjkLiTN2T7Q==", - "dev": true, - "optional": true, - "dependencies": { - "bin-check": "^4.1.0", - "bin-version-check": "^4.0.0", - "download": "^7.1.0", - "import-lazy": "^3.1.0", - "os-filter-obj": "^2.0.0", - "pify": "^4.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/download": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/download/-/download-7.1.0.tgz", - "integrity": "sha512-xqnBTVd/E+GxJVrX5/eUJiLYjCGPwMpdL+jGhGU57BvtcA7wwhtHVbXBeUk51kOpW3S7Jn3BQbN9Q1R1Km2qDQ==", - "dev": true, - "optional": true, - "dependencies": { - "archive-type": "^4.0.0", - "caw": "^2.0.1", - "content-disposition": "^0.5.2", - "decompress": "^4.2.0", - "ext-name": "^5.0.0", - "file-type": "^8.1.0", - "filenamify": "^2.0.0", - "get-stream": "^3.0.0", - "got": "^8.3.1", - "make-dir": "^1.2.0", - "p-event": "^2.1.0", - "pify": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/download/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/file-type": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz", - "integrity": "sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/got": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/got/-/got-8.3.2.tgz", - "integrity": "sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw==", - "dev": true, - "optional": true, - "dependencies": { - "@sindresorhus/is": "^0.7.0", - "cacheable-request": "^2.1.1", - "decompress-response": "^3.3.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "into-stream": "^3.1.0", - "is-retry-allowed": "^1.1.0", - "isurl": "^1.0.0-alpha5", - "lowercase-keys": "^1.0.0", - "mimic-response": "^1.0.0", - "p-cancelable": "^0.4.0", - "p-timeout": "^2.0.1", - "pify": "^3.0.0", - "safe-buffer": "^5.1.1", - "timed-out": "^4.0.1", - "url-parse-lax": "^3.0.0", - "url-to-options": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/got/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/make-dir/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/p-cancelable": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", - "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/p-event": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-2.3.1.tgz", - "integrity": "sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==", - "dev": true, - "optional": true, - "dependencies": { - "p-timeout": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/p-timeout": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", - "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, - "optional": true, "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" } }, - "node_modules/bin-wrapper/node_modules/url-parse-lax": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", - "integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==", + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, - "optional": true, "dependencies": { - "prepend-http": "^2.0.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=4" - } - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", - "dev": true, - "optional": true, - "dependencies": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" + "node": ">= 6" } }, "node_modules/body-parser": { @@ -8558,47 +7911,11 @@ "url": "https://feross.org/support" } ], - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "optional": true, - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true, - "optional": true - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "optional": true, - "engines": { - "node": "*" - } - }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "dev": true, - "optional": true - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8653,95 +7970,6 @@ "resolved": "https://registry.npmjs.org/cache-control-esm/-/cache-control-esm-1.0.0.tgz", "integrity": "sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g==" }, - "node_modules/cacheable-request": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-2.1.4.tgz", - "integrity": "sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==", - "dev": true, - "optional": true, - "dependencies": { - "clone-response": "1.0.2", - "get-stream": "3.0.0", - "http-cache-semantics": "3.8.1", - "keyv": "3.0.0", - "lowercase-keys": "1.0.0", - "normalize-url": "2.0.1", - "responselike": "1.0.2" - } - }, - "node_modules/cacheable-request/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/cacheable-request/node_modules/lowercase-keys": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.0.tgz", - "integrity": "sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cacheable-request/node_modules/normalize-url": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-2.0.1.tgz", - "integrity": "sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==", - "dev": true, - "optional": true, - "dependencies": { - "prepend-http": "^2.0.0", - "query-string": "^5.0.1", - "sort-keys": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cacheable-request/node_modules/prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/cacheable-request/node_modules/query-string": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", - "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", - "dev": true, - "optional": true, - "dependencies": { - "decode-uri-component": "^0.2.0", - "object-assign": "^4.1.0", - "strict-uri-encode": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cacheable-request/node_modules/sort-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", - "integrity": "sha512-/dPCrG1s3ePpWm6yBbxZq5Be1dXGLyLn9Z791chDC3NFrpkVbWGzkBwPN1knaciexFXgRJ7hzdnwZ4stHSDmjg==", - "dev": true, - "optional": true, - "dependencies": { - "is-plain-obj": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -8831,22 +8059,6 @@ "isarray": "0.0.1" } }, - "node_modules/caw": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz", - "integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==", - "dev": true, - "optional": true, - "dependencies": { - "get-proxy": "^2.0.0", - "isurl": "^1.0.0-alpha5", - "tunnel-agent": "^0.6.0", - "url-to-options": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -8969,6 +8181,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -9176,16 +8394,6 @@ "node": ">=6" } }, - "node_modules/clone-response": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", - "integrity": "sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==", - "dev": true, - "optional": true, - "dependencies": { - "mimic-response": "^1.0.0" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -9365,17 +8573,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "optional": true, - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -9432,9 +8629,9 @@ "dev": true }, "node_modules/cookiejar": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", - "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, "node_modules/copy-descriptor": { @@ -9575,18 +8772,6 @@ "webpack": "^4.27.0 || ^5.0.0" } }, - "node_modules/css-loader/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/css-loader/node_modules/postcss": { "version": "8.4.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz", @@ -9626,12 +8811,6 @@ "node": ">=10" } }, - "node_modules/css-loader/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/css-mediaquery": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", @@ -9751,27 +8930,6 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, - "node_modules/cwebp-bin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cwebp-bin/-/cwebp-bin-7.0.1.tgz", - "integrity": "sha512-Ko5ADY74/dbfd8xG0+f+MUP9UKjCe1TG4ehpW0E5y4YlPdwDJlGrSzSR4/Yonxpm9QmZE1RratkIxFlKeyo3FA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.1" - }, - "bin": { - "cwebp": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/imagemin/cwebp-bin?sponsor=1" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -9906,197 +9064,19 @@ "node": ">=0.10" } }, - "node_modules/decompress": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", - "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", - "dev": true, - "optional": true, - "dependencies": { - "decompress-tar": "^4.0.0", - "decompress-tarbz2": "^4.0.0", - "decompress-targz": "^4.0.0", - "decompress-unzip": "^4.0.1", - "graceful-fs": "^4.1.10", - "make-dir": "^1.0.0", - "pify": "^2.3.0", - "strip-dirs": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/decompress-response": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", - "integrity": "sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==", - "dev": true, - "optional": true, - "dependencies": { - "mimic-response": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tar": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", - "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", - "dev": true, - "optional": true, - "dependencies": { - "file-type": "^5.2.0", - "is-stream": "^1.1.0", - "tar-stream": "^1.5.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tar/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tarbz2": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", - "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", - "dev": true, - "optional": true, - "dependencies": { - "decompress-tar": "^4.1.0", - "file-type": "^6.1.0", - "is-stream": "^1.1.0", - "seek-bzip": "^1.0.5", - "unbzip2-stream": "^1.0.9" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tarbz2/node_modules/file-type": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", - "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", - "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", - "dev": true, - "optional": true, - "dependencies": { - "decompress-tar": "^4.1.1", - "file-type": "^5.2.0", - "is-stream": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", - "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", - "dev": true, - "optional": true, - "dependencies": { - "file-type": "^3.8.0", - "get-stream": "^2.2.0", - "pify": "^2.3.0", - "yauzl": "^2.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip/node_modules/file-type": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", - "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress-unzip/node_modules/get-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", - "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, - "optional": true, "dependencies": { - "object-assign": "^4.0.1", - "pinkie-promise": "^2.0.0" + "mimic-response": "^3.1.0" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress-unzip/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress/node_modules/make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^3.0.0" + "node": ">=10" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress/node_modules/make-dir/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/deep-diff": { @@ -10121,6 +9101,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -10312,6 +9301,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -10565,85 +9563,12 @@ "webpack": "^1 || ^2 || ^3 || ^4 || ^5" } }, - "node_modules/download": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/download/-/download-6.2.5.tgz", - "integrity": "sha512-DpO9K1sXAST8Cpzb7kmEhogJxymyVUd5qz/vCOSyvwtp2Klj2XcDt5YUuasgxka44SxF0q5RriKIwJmQHG2AuA==", - "dev": true, - "optional": true, - "dependencies": { - "caw": "^2.0.0", - "content-disposition": "^0.5.2", - "decompress": "^4.0.0", - "ext-name": "^5.0.0", - "file-type": "5.2.0", - "filenamify": "^2.0.0", - "get-stream": "^3.0.0", - "got": "^7.0.0", - "make-dir": "^1.0.0", - "p-event": "^1.0.0", - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/download/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/download/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/download/node_modules/make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/download/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, - "node_modules/duplexer3": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", - "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==", - "dev": true, - "optional": true - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -10943,8 +9868,7 @@ "node_modules/es-module-lexer": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "peer": true + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" }, "node_modules/es-shim-unscopables": { "version": "1.0.0", @@ -11076,11 +10000,10 @@ } }, "node_modules/eslint": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.28.0.tgz", - "integrity": "sha512-S27Di+EVyMxcHiwDrFzk8dJYAaD+/5SoWKxL1ri/71CRHsnJnRDPNt2Kzj24+MT9FDupf4aqqyqPrvI8MvQ4VQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.29.0.tgz", + "integrity": "sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==", "dev": true, - "peer": true, "dependencies": { "@eslint/eslintrc": "^1.3.3", "@humanwhocodes/config-array": "^0.11.6", @@ -11173,13 +10096,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz", + "integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==", "dev": true, "dependencies": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.11.0", + "resolve": "^1.22.1" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -11272,23 +10196,24 @@ "dev": true }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", - "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", + "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", "dev": true, "dependencies": { - "@babel/runtime": "^7.16.3", + "@babel/runtime": "^7.18.9", "aria-query": "^4.2.2", - "array-includes": "^3.1.4", + "array-includes": "^3.1.5", "ast-types-flow": "^0.0.7", - "axe-core": "^4.3.5", + "axe-core": "^4.4.3", "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.7", + "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "has": "^1.0.3", - "jsx-ast-utils": "^3.2.1", + "jsx-ast-utils": "^3.3.2", "language-tags": "^1.0.5", - "minimatch": "^3.0.4" + "minimatch": "^3.1.2", + "semver": "^6.3.0" }, "engines": { "node": ">=4.0" @@ -11298,25 +10223,26 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.30.0.tgz", - "integrity": "sha512-RgwH7hjW48BleKsYyHK5vUAvxtE9SMPDKmcPRQgtRCYaZA0XQPt5FSkrU3nhz5ifzMZcA8opwmRJ2cmOO8tr5A==", + "version": "7.31.11", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.11.tgz", + "integrity": "sha512-TTvq5JsT5v56wPa9OYHzsrOlHzKZKjV+aLgS+55NJP/cuzdiQPC7PfYoUjMoxlffKtvijpk7vA/jmuqRb9nohw==", "dev": true, "dependencies": { - "array-includes": "^3.1.5", - "array.prototype.flatmap": "^1.3.0", + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.5", - "object.fromentries": "^2.0.5", - "object.hasown": "^1.1.1", - "object.values": "^1.1.5", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.3", "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.7" + "string.prototype.matchall": "^4.0.8" }, "engines": { "node": ">=4" @@ -11429,7 +10355,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -11444,15 +10369,13 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -11469,7 +10392,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -11481,15 +10403,13 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -11502,7 +10422,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", "dev": true, - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -11516,7 +10435,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", "dev": true, - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -11526,7 +10444,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -11535,11 +10452,10 @@ } }, "node_modules/eslint/node_modules/globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", + "version": "13.19.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", + "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", "dev": true, - "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -11555,7 +10471,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -11565,7 +10480,6 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -11578,7 +10492,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11591,7 +10504,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -11699,110 +10611,6 @@ "node": ">=0.8.x" } }, - "node_modules/exec-buffer": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/exec-buffer/-/exec-buffer-3.2.0.tgz", - "integrity": "sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^0.7.0", - "p-finally": "^1.0.0", - "pify": "^3.0.0", - "rimraf": "^2.5.4", - "tempfile": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/exec-buffer/node_modules/cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "dependencies": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "node_modules/exec-buffer/node_modules/execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/exec-buffer/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/exec-buffer/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/exec-buffer/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/exec-buffer/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/exec-buffer/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/exec-sh": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz", @@ -11894,29 +10702,6 @@ "which": "bin/which" } }, - "node_modules/executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^2.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/executable/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -12069,6 +10854,15 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/expect/-/expect-29.3.1.tgz", @@ -12285,33 +11079,6 @@ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", "dev": true }, - "node_modules/ext-list": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", - "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", - "dev": true, - "optional": true, - "dependencies": { - "mime-db": "^1.28.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ext-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", - "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", - "dev": true, - "optional": true, - "dependencies": { - "ext-list": "^2.0.0", - "sort-keys-length": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -12413,23 +11180,6 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, - "node_modules/fast-xml-parser": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz", - "integrity": "sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==", - "dev": true, - "optional": true, - "dependencies": { - "strnum": "^1.0.4" - }, - "bin": { - "xml2js": "cli.js" - }, - "funding": { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - } - }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -12469,16 +11219,6 @@ "bser": "2.1.1" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "optional": true, - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -12527,40 +11267,6 @@ "node": ">= 12" } }, - "node_modules/file-type": { - "version": "12.4.2", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz", - "integrity": "sha512-UssQP5ZgIOKelfsaB5CuGAL+Y+q7EmONuiwF3N5HAH0t27rvrttgi6Ra9k/+DVaY9UF6+ybxu5pOXLUdA8N7Vg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/filename-reserved-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", - "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/filenamify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz", - "integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==", - "dev": true, - "optional": true, - "dependencies": { - "filename-reserved-regex": "^2.0.0", - "strip-outer": "^1.0.0", - "trim-repeated": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/filesize": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", @@ -12679,19 +11385,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-versions": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz", - "integrity": "sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==", - "dev": true, - "optional": true, - "dependencies": { - "semver-regex": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -13017,23 +11710,11 @@ "node": ">= 0.6" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "optional": true, - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "optional": true + "dev": true }, "node_modules/fs-extra": { "version": "9.1.0", @@ -13103,12 +11784,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true - }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -13164,19 +11839,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proxy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz", - "integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==", - "dev": true, - "optional": true, - "dependencies": { - "npm-conf": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", @@ -13213,90 +11875,11 @@ "node": ">=0.10.0" } }, - "node_modules/gifsicle": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gifsicle/-/gifsicle-5.3.0.tgz", - "integrity": "sha512-FJTpgdj1Ow/FITB7SVza5HlzXa+/lqEY0tHQazAJbuAdvyJtkH4wIdsR2K414oaTwRXHFLLF+tYbipj+OpYg+Q==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.0", - "execa": "^5.0.0" - }, - "bin": { - "gifsicle": "cli.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/imagemin/gisicle-bin?sponsor=1" - } - }, - "node_modules/gifsicle/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/gifsicle/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gifsicle/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gifsicle/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true }, "node_modules/glob": { "version": "7.2.3", @@ -13403,42 +11986,6 @@ "node": ">=0.10.0" } }, - "node_modules/got": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/got/-/got-7.1.0.tgz", - "integrity": "sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==", - "dev": true, - "optional": true, - "dependencies": { - "decompress-response": "^3.2.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "is-plain-obj": "^1.1.0", - "is-retry-allowed": "^1.0.0", - "is-stream": "^1.0.0", - "isurl": "^1.0.0-alpha5", - "lowercase-keys": "^1.0.0", - "p-cancelable": "^0.3.0", - "p-timeout": "^1.1.1", - "safe-buffer": "^5.0.1", - "timed-out": "^4.0.0", - "url-parse-lax": "^1.0.0", - "url-to-options": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/got/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -13448,8 +11995,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/growly": { "version": "1.3.0", @@ -13521,16 +12067,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbol-support-x": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz", - "integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==", - "dev": true, - "optional": true, - "engines": { - "node": "*" - } - }, "node_modules/has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", @@ -13542,19 +12078,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-to-string-tag-x": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz", - "integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==", - "dev": true, - "optional": true, - "dependencies": { - "has-symbol-support-x": "^1.4.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/has-tostringtag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", @@ -13819,13 +12342,6 @@ "entities": "^4.3.0" } }, - "node_modules/http-cache-semantics": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz", - "integrity": "sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w==", - "dev": true, - "optional": true - }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -14031,8 +12547,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "optional": true + ] }, "node_modules/ignore": { "version": "5.2.0", @@ -14043,361 +12558,88 @@ "node": ">= 4" } }, - "node_modules/image-webpack-loader": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/image-webpack-loader/-/image-webpack-loader-8.1.0.tgz", - "integrity": "sha512-bxzMIBNu42KGo6Bc9YMB0QEUt+XuVTl2ZSX3oGAlbsqYOkxkT4TEWvVsnwUkCRCYISJrMCEc/s0y8OYrmEfUOg==", - "dev": true, - "dependencies": { - "imagemin": "^7.0.1", - "loader-utils": "^2.0.0", - "object-assign": "^4.1.1", - "schema-utils": "^2.7.1" - }, - "optionalDependencies": { - "imagemin-gifsicle": "^7.0.0", - "imagemin-mozjpeg": "^9.0.0", - "imagemin-optipng": "^8.0.0", - "imagemin-pngquant": "^9.0.2", - "imagemin-svgo": "^9.0.0", - "imagemin-webp": "^7.0.0" - } - }, - "node_modules/image-webpack-loader/node_modules/schema-utils": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", - "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "node_modules/image-minimizer-webpack-plugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/image-minimizer-webpack-plugin/-/image-minimizer-webpack-plugin-3.3.0.tgz", + "integrity": "sha512-WFLJoOhF2f3c2K4NqpqnJdsBkGcBz/i9+qnhS50qQvC+5FEPQZxcsyKaYrUjvIpox6d3kIoEdip3GbxXwHAEpA==", "dev": true, "dependencies": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" }, "engines": { - "node": ">= 8.9.0" + "node": ">= 12.13.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" - } - }, - "node_modules/imagemin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/imagemin/-/imagemin-7.0.1.tgz", - "integrity": "sha512-33AmZ+xjZhg2JMCe+vDf6a9mzWukE7l+wAtesjE7KyteqqKjzxv7aVQeWnul1Ve26mWvEQqyPwl0OctNBfSR9w==", - "dev": true, - "dependencies": { - "file-type": "^12.0.0", - "globby": "^10.0.0", - "graceful-fs": "^4.2.2", - "junk": "^3.1.0", - "make-dir": "^3.0.0", - "p-pipe": "^3.0.0", - "replace-ext": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/imagemin-gifsicle": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/imagemin-gifsicle/-/imagemin-gifsicle-7.0.0.tgz", - "integrity": "sha512-LaP38xhxAwS3W8PFh4y5iQ6feoTSF+dTAXFRUEYQWYst6Xd+9L/iPk34QGgK/VO/objmIlmq9TStGfVY2IcHIA==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^1.0.0", - "gifsicle": "^5.0.0", - "is-gif": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/imagemin/imagemin-gifsicle?sponsor=1" - } - }, - "node_modules/imagemin-mozjpeg": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/imagemin-mozjpeg/-/imagemin-mozjpeg-9.0.0.tgz", - "integrity": "sha512-TwOjTzYqCFRgROTWpVSt5UTT0JeCuzF1jswPLKALDd89+PmrJ2PdMMYeDLYZ1fs9cTovI9GJd68mRSnuVt691w==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^4.0.0", - "is-jpg": "^2.0.0", - "mozjpeg": "^7.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/imagemin-mozjpeg/node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/imagemin-mozjpeg/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imagemin-mozjpeg/node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/imagemin-mozjpeg/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imagemin-mozjpeg/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/imagemin-optipng": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/imagemin-optipng/-/imagemin-optipng-8.0.0.tgz", - "integrity": "sha512-CUGfhfwqlPjAC0rm8Fy+R2DJDBGjzy2SkfyT09L8rasnF9jSoHFqJ1xxSZWK6HVPZBMhGPMxCTL70OgTHlLF5A==", - "dev": true, - "optional": true, - "dependencies": { - "exec-buffer": "^3.0.0", - "is-png": "^2.0.0", - "optipng-bin": "^7.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/imagemin-pngquant": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/imagemin-pngquant/-/imagemin-pngquant-9.0.2.tgz", - "integrity": "sha512-cj//bKo8+Frd/DM8l6Pg9pws1pnDUjgb7ae++sUX1kUVdv2nrngPykhiUOgFeE0LGY/LmUbCf4egCHC4YUcZSg==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^4.0.0", - "is-png": "^2.0.0", - "is-stream": "^2.0.0", - "ow": "^0.17.0", - "pngquant-bin": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/imagemin-pngquant/node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/imagemin-pngquant/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imagemin-pngquant/node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/imagemin-pngquant/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imagemin-pngquant/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "dependencies": { - "path-key": "^3.0.0" + "peerDependencies": { + "webpack": "^5.1.0" }, - "engines": { - "node": ">=8" + "peerDependenciesMeta": { + "@squoosh/lib": { + "optional": true + }, + "imagemin": { + "optional": true + }, + "sharp": { + "optional": true + } } }, - "node_modules/imagemin-svgo": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/imagemin-svgo/-/imagemin-svgo-9.0.0.tgz", - "integrity": "sha512-uNgXpKHd99C0WODkrJ8OO/3zW3qjgS4pW7hcuII0RcHN3tnKxDjJWcitdVC/TZyfIqSricU8WfrHn26bdSW62g==", + "node_modules/image-minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "optional": true, "dependencies": { - "is-svg": "^4.2.1", - "svgo": "^2.1.0" - }, - "engines": { - "node": ">=10" + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://github.com/sindresorhus/imagemin-svgo?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/imagemin-webp": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/imagemin-webp/-/imagemin-webp-7.0.0.tgz", - "integrity": "sha512-JoYjvHNgBLgrQAkeCO7T5iNc8XVpiBmMPZmiXMhalC7K6gwY/3DCEUfNxVPOmNJ+NIJlJFvzcMR9RBxIE74Xxw==", + "node_modules/image-minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, - "optional": true, "dependencies": { - "cwebp-bin": "^7.0.1", - "exec-buffer": "^3.2.0", - "is-cwebp-readable": "^3.0.0" + "fast-deep-equal": "^3.1.3" }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/imagemin/node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/imagemin/node_modules/globby": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", - "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", - "dev": true, - "dependencies": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/image-minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, - "node_modules/imagemin/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "node_modules/image-minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", "dev": true, "dependencies": { - "semver": "^6.0.0" + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">= 12.13.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imagemin/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/immediate": { @@ -14446,16 +12688,6 @@ "node": ">=4" } }, - "node_modules/import-lazy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-3.1.0.tgz", - "integrity": "sha512-8/gvXvX2JMn0F+CDlSC4l6kOmVaLOO3XLkksI7CI3Ud95KDYJuYur2b9P/PUt/i/pDAMd/DulQsNbbbmRRsDIQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -14561,20 +12793,6 @@ "@formatjs/intl-numberformat": "^5.5.2" } }, - "node_modules/into-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", - "integrity": "sha512-TcdjPibTksa1NQximqep2r17ISRiNE9fwlfbg3F8ANdvP5/yrFTew86VcO//jk4QTaMlbjypPBq76HN2zaKfZQ==", - "dev": true, - "optional": true, - "dependencies": { - "from2": "^2.1.1", - "p-is-promise": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -14748,26 +12966,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-cwebp-readable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-cwebp-readable/-/is-cwebp-readable-3.0.0.tgz", - "integrity": "sha512-bpELc7/Q1/U5MWHn4NdHI44R3jxk0h9ew9ljzabiRl70/UIjL/ZAqRMb52F5+eke/VC8yTiv4Ewryo1fPWidvA==", - "dev": true, - "optional": true, - "dependencies": { - "file-type": "^10.5.0" - } - }, - "node_modules/is-cwebp-readable/node_modules/file-type": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", - "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/is-data-descriptor": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", @@ -14871,29 +13069,6 @@ "node": ">=6" } }, - "node_modules/is-gif": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-gif/-/is-gif-3.0.0.tgz", - "integrity": "sha512-IqJ/jlbw5WJSNfwQ/lHEDXF8rxhRgF6ythk2oiEvhpG29F704eX9NO6TvPfMiq9DrbwgcEDnETYNcZDPewQoVw==", - "dev": true, - "optional": true, - "dependencies": { - "file-type": "^10.4.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/is-gif/node_modules/file-type": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", - "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -14948,23 +13123,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-jpg": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-jpg/-/is-jpg-2.0.0.tgz", - "integrity": "sha512-ODlO0ruzhkzD3sdynIainVP5eoOFNN85rxA1+cwwnPe4dKyX0r5+hxNO5XpCrxlHcmb9vkOit9mhRD2JVuimHg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/is-natural-number": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", - "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", - "dev": true, - "optional": true - }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -14998,16 +13156,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-object": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", - "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", - "dev": true, - "optional": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -15051,6 +13199,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15074,16 +13223,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-png": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-png/-/is-png-2.0.0.tgz", - "integrity": "sha512-4KPGizaVGj2LK7xwJIz8o5B2ubu1D/vcQsgOGFEDlpcvgZHto4gBnyd0ig7Ws+67ixmwKoNmu0hYnpo6AaKb5g==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -15115,16 +13254,6 @@ "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", "dev": true }, - "node_modules/is-retry-allowed": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", - "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-root": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", @@ -15174,22 +13303,6 @@ "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", "dev": true }, - "node_modules/is-svg": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-4.3.2.tgz", - "integrity": "sha512-mM90duy00JGMyjqIVHu9gNTjywdZV+8qNasX8cm/EEYZ53PHDgajvbBwNVvty5dwSAxLUD3p3bdo+7sR/UMrpw==", - "dev": true, - "optional": true, - "dependencies": { - "fast-xml-parser": "^3.19.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-symbol": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", @@ -15394,20 +13507,6 @@ "node": ">=8" } }, - "node_modules/isurl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz", - "integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==", - "dev": true, - "optional": true, - "dependencies": { - "has-to-string-tag-x": "^1.2.0", - "is-object": "^1.0.1" - }, - "engines": { - "node": ">= 4" - } - }, "node_modules/jest": { "version": "26.6.3", "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", @@ -17408,18 +15507,6 @@ "node": ">= 10.14.2" } }, - "node_modules/jest-snapshot/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-snapshot/node_modules/semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -17456,12 +15543,6 @@ "node": ">=8" } }, - "node_modules/jest-snapshot/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/jest-util": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz", @@ -17784,11 +15865,14 @@ "peer": true }, "node_modules/js-sdsl": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz", - "integrity": "sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", "dev": true, - "peer": true + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } }, "node_modules/js-tokens": { "version": "4.0.0", @@ -17869,19 +15953,6 @@ "node": ">=4" } }, - "node_modules/json-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==", - "dev": true, - "optional": true - }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -17934,15 +16005,6 @@ "node": ">=4.0" } }, - "node_modules/junk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", - "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/just-curry-it": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-3.2.1.tgz", @@ -17953,16 +16015,6 @@ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" }, - "node_modules/keyv": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.0.0.tgz", - "integrity": "sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA==", - "dev": true, - "optional": true, - "dependencies": { - "json-buffer": "3.0.0" - } - }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -17996,12 +16048,12 @@ "dev": true }, "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.7.tgz", + "integrity": "sha512-bSytju1/657hFjgUzPAPqszxH62ouE8nQFoFaVlIQfne4wO/wXC9A4+m8jYve7YBBvi59eq0SUpcshvG8h5Usw==", "dev": true, "dependencies": { - "language-subtag-registry": "~0.3.2" + "language-subtag-registry": "^0.3.20" } }, "node_modules/leven": { @@ -18201,25 +16253,16 @@ "tslib": "^2.0.3" } }, - "node_modules/lowercase-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, - "optional": true, "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, "node_modules/lz-string": { @@ -18396,9 +16439,9 @@ } }, "node_modules/memfs": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.12.tgz", - "integrity": "sha512-BcjuQn6vfqP+k100e0E9m61Hyqa//Brp+I3f0OBmN0ATHlFA8vx3Lt8z57R3u2bPqe3WGDBC+nF72fTH7isyEw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.13.tgz", + "integrity": "sha512-omTM41g3Skpvx5dSYeZIbXKcXoAVc/AoMNwn9TKx++L/gaen/+4TTttmu8ZSch5vfVJ8uJvGbroTsIlslRg6lg==", "dev": true, "dependencies": { "fs-monkey": "^1.0.3" @@ -18514,13 +16557,15 @@ } }, "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, - "optional": true, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/min-indent": { @@ -18658,6 +16703,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "node_modules/moment": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz", @@ -18672,24 +16723,6 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, - "node_modules/mozjpeg": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/mozjpeg/-/mozjpeg-7.1.1.tgz", - "integrity": "sha512-iIDxWvzhWvLC9mcRJ1uSkiKaj4drF58oCqK2bITm5c2Jt6cJ8qQjSSru2PCaysG+hLIinryj8mgz5ZJzOYTv1A==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.0" - }, - "bin": { - "mozjpeg": "cli.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mrmime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", @@ -18757,6 +16790,12 @@ "node": ">=0.10.0" } }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "dev": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -18820,6 +16859,39 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.31.0.tgz", + "integrity": "sha512-eSKV6s+APenqVh8ubJyiu/YhZgxQpGP66ntzUb3lY1xB9ukSRaGnx0AIxI+IM+1+IVYC1oWobgG5L3Lt9ARykQ==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "dev": true + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -18850,19 +16922,6 @@ "which": "^2.0.2" } }, - "node_modules/node-notifier/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-notifier/node_modules/semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -18889,13 +16948,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/node-notifier/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "optional": true - }, "node_modules/node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", @@ -18967,30 +17019,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-conf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", - "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", - "dev": true, - "optional": true, - "dependencies": { - "config-chain": "^1.1.11", - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm-conf/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -19394,76 +17422,6 @@ "node": ">= 0.8.0" } }, - "node_modules/optipng-bin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/optipng-bin/-/optipng-bin-7.0.1.tgz", - "integrity": "sha512-W99mpdW7Nt2PpFiaO+74pkht7KEqkXkeRomdWXfEz3SALZ6hns81y/pm1dsGZ6ItUIfchiNIP6ORDr1zETU1jA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.0" - }, - "bin": { - "optipng": "cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/os-filter-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/os-filter-obj/-/os-filter-obj-2.0.0.tgz", - "integrity": "sha512-uksVLsqG3pVdzzPvmAHpBK0wKxYItuzZr7SziusRPoz67tGV8rL1szZ6IdeUrbqLjGDwApBtN29eEE3IqGHOjg==", - "dev": true, - "optional": true, - "dependencies": { - "arch": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ow": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/ow/-/ow-0.17.0.tgz", - "integrity": "sha512-i3keDzDQP5lWIe4oODyDFey1qVrq2hXKTuTH2VpqwpYtzPiKZt2ziRI4NBQmgW40AnV5Euz17OyWweCb+bNEQA==", - "dev": true, - "optional": true, - "dependencies": { - "type-fest": "^0.11.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ow/node_modules/type-fest": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", - "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-cancelable": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.3.0.tgz", - "integrity": "sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -19476,19 +17434,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-event": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-1.3.0.tgz", - "integrity": "sha512-hV1zbA7gwqPVFcapfeATaNjQ3J0NuzorHPyG8GPL9g/Y/TplWVBVoCKCXL6Ej2zscrCEv195QNWJXuBH6XZuzA==", - "dev": true, - "optional": true, - "dependencies": { - "p-timeout": "^1.1.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -19498,16 +17443,6 @@ "node": ">=4" } }, - "node_modules/p-is-promise": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", - "integrity": "sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -19546,41 +17481,6 @@ "node": ">=6" } }, - "node_modules/p-map-series": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-map-series/-/p-map-series-1.0.0.tgz", - "integrity": "sha512-4k9LlvY6Bo/1FcIdV33wqZQES0Py+iKISU9Uc8p8AjWoZPnFKMpVIVD3s0EYn4jzLh1I+WeUZkJ0Yoa4Qfw3Kg==", - "dev": true, - "optional": true, - "dependencies": { - "p-reduce": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/p-pipe": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz", - "integrity": "sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-reduce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-1.0.0.tgz", - "integrity": "sha512-3Tx1T3oM1xO/Y8Gj0sWyE78EIJZ+t+aEmXUdvQgvGmSMri7aPTHoovbXEreWKkL5j21Er60XAWLTzKbAKYOujQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -19594,19 +17494,6 @@ "node": ">=8" } }, - "node_modules/p-timeout": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-1.2.1.tgz", - "integrity": "sha512-gb0ryzr+K2qFqFv6qi3khoeqMZF/+ajxQipEF6NteZVnvz9tzdsfAVj3lYtn1gAXvH5lfLwfxEII799gt/mRIA==", - "dev": true, - "optional": true, - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -19777,13 +17664,6 @@ "node": ">=8" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "optional": true - }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -19979,101 +17859,6 @@ "node": ">=4" } }, - "node_modules/pngquant-bin": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/pngquant-bin/-/pngquant-bin-6.0.1.tgz", - "integrity": "sha512-Q3PUyolfktf+hYio6wsg3SanQzEU/v8aICg/WpzxXcuCMRb7H2Q81okfpcEztbMvw25ILjd3a87doj2N9kvbpQ==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.1", - "execa": "^4.0.0" - }, - "bin": { - "pngquant": "cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pngquant-bin/node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/pngquant-bin/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pngquant-bin/node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/pngquant-bin/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pngquant-bin/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -20137,13 +17922,13 @@ } }, "node_modules/postcss-loader": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.1.1.tgz", - "integrity": "sha512-lBmJMvRh1D40dqpWKr9Rpygwxn8M74U9uaCSeYGNKLGInbk9mXBt1ultHf2dH9Ghk6Ue4UXlXWwGMH9QdUJ5ug==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", "dev": true, "dependencies": { "cosmiconfig": "^7.0.0", - "klona": "^2.0.4", + "klona": "^2.0.5", "semver": "^7.3.5" }, "engines": { @@ -20158,18 +17943,6 @@ "webpack": "^5.0.0" } }, - "node_modules/postcss-loader/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/postcss-loader/node_modules/semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -20185,12 +17958,6 @@ "node": ">=10" } }, - "node_modules/postcss-loader/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/postcss-modules-extract-imports": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", @@ -20293,6 +18060,32 @@ "node": ">=0.10.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", + "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -20306,6 +18099,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", "integrity": "sha512-PhmXi5XmoyKw1Un4E+opM2KcsJInDvKyuOumcjjw3waw86ZNjHwVUOOWLc4bCzLdcKNaWBH9e99sbWzDQsVaYg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -20433,13 +18227,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "optional": true - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -20462,13 +18249,6 @@ "node": ">= 0.10" } }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true, - "optional": true - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -20645,6 +18425,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", @@ -21229,9 +19033,9 @@ } }, "node_modules/react-refresh": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz", - "integrity": "sha512-PgidR3wST3dDYKr6b4pJoqQFpPGNKDSCDx4cZoshjXipw3LzO7mG1My2pwEzz2JVkF+inx3xRpDeQLFQGH/hsQ==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", "dev": true, "engines": { "node": ">=0.10.0" @@ -22039,15 +19843,6 @@ "node": ">=0.10" } }, - "node_modules/replace-ext": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", - "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -22133,10 +19928,9 @@ "dev": true }, "node_modules/resolve-url-loader": { - "version": "5.0.0-beta.1", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0-beta.1.tgz", - "integrity": "sha512-MXkuP7+etG/4H96tZnbUOX9g2KiJaEpJ7cnjmVZUYkIXWKh2soTZF/ghczKVw/qKZZplDDjZwR5xGztD9ex7KQ==", - "deprecated": "version 5 is now released", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", "dev": true, "dependencies": { "adjust-sourcemap-loader": "^4.0.0", @@ -22150,9 +19944,9 @@ } }, "node_modules/resolve-url-loader/node_modules/postcss": { - "version": "8.4.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz", - "integrity": "sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, "funding": [ { @@ -22182,16 +19976,6 @@ "node": ">=0.10.0" } }, - "node_modules/responselike": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", - "integrity": "sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==", - "dev": true, - "optional": true, - "dependencies": { - "lowercase-keys": "^1.0.0" - } - }, "node_modules/ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -22550,9 +20334,9 @@ } }, "node_modules/sass": { - "version": "1.49.9", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", - "integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", + "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -22640,27 +20424,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/seek-bzip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", - "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", - "dev": true, - "optional": true, - "dependencies": { - "commander": "^2.8.1" - }, - "bin": { - "seek-bunzip": "bin/seek-bunzip", - "seek-table": "bin/seek-bzip-table" - } - }, - "node_modules/seek-bzip/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -22687,39 +20450,6 @@ "semver": "bin/semver.js" } }, - "node_modules/semver-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz", - "integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/semver-truncate": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/semver-truncate/-/semver-truncate-1.1.2.tgz", - "integrity": "sha512-V1fGg9i4CL3qesB6U0L6XAm4xOJiHmt4QAacazumuasc03BvtFGIMCduv01JWQ69Nv+JST9TqhSCiJoxoY031w==", - "dev": true, - "optional": true, - "dependencies": { - "semver": "^5.3.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/semver-truncate/node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, - "optional": true, - "bin": { - "semver": "bin/semver" - } - }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -22935,6 +20665,75 @@ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, + "node_modules/sharp": { + "version": "0.31.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.31.3.tgz", + "integrity": "sha512-XcR4+FCLBFKw1bdB+GEhnUNXNXvnt0tDo4WsBsraKymuo/IAuPuCBVAL2wIkUw2r/dwFW5Q5+g66Kwl2dgDFVg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.1", + "node-addon-api": "^5.0.0", + "prebuild-install": "^7.1.1", + "semver": "^7.3.8", + "simple-get": "^4.0.1", + "tar-fs": "^2.1.1", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/sharp/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/sharp/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -22988,6 +20787,51 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -23277,6 +21121,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "peer": true, "dependencies": { "is-plain-obj": "^1.0.0" }, @@ -23284,19 +21129,6 @@ "node": ">=0.10.0" } }, - "node_modules/sort-keys-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", - "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", - "dev": true, - "optional": true, - "dependencies": { - "sort-keys": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -23652,6 +21484,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -23787,16 +21620,6 @@ "node": ">=8" } }, - "node_modules/strip-dirs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", - "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", - "dev": true, - "optional": true, - "dependencies": { - "is-natural-number": "^4.0.1" - } - }, "node_modules/strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -23836,44 +21659,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-outer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", - "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", - "dev": true, - "optional": true, - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "dev": true, - "optional": true - }, "node_modules/style-loader": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", - "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", + "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", "dev": true, - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, "engines": { - "node": ">= 10.13.0" + "node": ">= 12.13.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" + "webpack": "^5.0.0" } }, "node_modules/style-to-object": { @@ -24115,58 +21914,46 @@ "node": ">=6" } }, - "node_modules/tar-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", - "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", "dev": true, - "optional": true, "dependencies": { - "bl": "^1.0.0", - "buffer-alloc": "^1.2.0", - "end-of-stream": "^1.0.0", - "fs-constants": "^1.0.0", - "readable-stream": "^2.3.0", - "to-buffer": "^1.1.1", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 0.8.0" + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" } }, - "node_modules/temp-dir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, - "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, "engines": { - "node": ">=4" + "node": ">=6" } }, - "node_modules/tempfile": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz", - "integrity": "sha512-ZOn6nJUgvgC09+doCEF3oB+r3ag7kUvlsXEGX069QRD60p+P3uP7XG9N2/at+EyIRGSN//ZY3LyEotA1YpmjuA==", + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, - "optional": true, "dependencies": { - "temp-dir": "^1.0.0", - "uuid": "^3.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=4" - } - }, - "node_modules/tempfile/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "dev": true, - "optional": true, - "bin": { - "uuid": "bin/uuid" + "node": ">= 6" } }, "node_modules/terminal-link": { @@ -24333,13 +22120,6 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "dev": true }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "optional": true - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -24351,16 +22131,6 @@ "resolved": "https://registry.npmjs.org/timeago.js/-/timeago.js-4.0.2.tgz", "integrity": "sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==" }, - "node_modules/timed-out": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", - "integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/tiny-invariant": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", @@ -24377,13 +22147,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "node_modules/to-buffer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", - "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", - "dev": true, - "optional": true - }, "node_modules/to-camel-case": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-camel-case/-/to-camel-case-1.0.0.tgz", @@ -24520,19 +22283,6 @@ "node": ">=8" } }, - "node_modules/trim-repeated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", - "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", - "dev": true, - "optional": true, - "dependencies": { - "escape-string-regexp": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/trough": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", @@ -24555,9 +22305,9 @@ } }, "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "dependencies": { "minimist": "^1.2.0" @@ -24585,7 +22335,6 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, - "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -24674,17 +22423,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dev": true, - "optional": true, - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/uncontrollable": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", @@ -25038,29 +22776,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/url-parse-lax": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", - "integrity": "sha512-BVA4lR5PIviy2PMseNd2jbFQ+jwSwQGdJejf5ctd1rEXt0Ypd7yanUK9+lYechVlN5VaTJGsu2U/3MDDu6KgBA==", - "dev": true, - "optional": true, - "dependencies": { - "prepend-http": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/url-to-options": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", - "integrity": "sha512-0kQLIzG4fdk/G5NONku64rSH/x32NOA39LVQqlK8Le6lvTF6GGRJpqaQFGgU+CLwySIqBSMdwYM0sYcW9f6P4A==", - "dev": true, - "optional": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/url/node_modules/punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", @@ -25173,12 +22888,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, "node_modules/v8-to-istanbul": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz", @@ -25338,7 +23047,6 @@ "version": "5.75.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^0.0.51", @@ -25606,9 +23314,9 @@ } }, "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", @@ -25716,9 +23424,9 @@ } }, "node_modules/webpack-dev-server/node_modules/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", @@ -25909,16 +23617,16 @@ } }, "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", "dev": true, "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -25963,7 +23671,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "peer": true, "engines": { "node": ">=6" } @@ -25972,7 +23679,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -26199,11 +23905,10 @@ } }, "node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true, - "optional": true + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/yaml": { "version": "1.10.2", @@ -26241,17 +23946,6 @@ "node": ">=10" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "optional": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -26448,12 +24142,12 @@ } }, "@babel/eslint-parser": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.18.2.tgz", - "integrity": "sha512-oFQYkE8SuH14+uR51JVAmdqwKYXGRjEXx7s+WiagVjqQ+HPE+nnwyF2qlVG8evUsUHmPcA+6YXMEDbIhEyQc5A==", + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz", + "integrity": "sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==", "dev": true, "requires": { - "eslint-scope": "^5.1.1", + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", "semver": "^6.3.0" } @@ -27689,31 +25383,31 @@ "dev": true }, "@edx/eslint-config": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-3.1.0.tgz", - "integrity": "sha512-Okv8vkmX+qe+joD7h9DcT9JdRIyy6jJSVWbIHr2dAHKuk5swVFO92JvhC2pYtMg2EPKA1P1Hmz8cmmfw6QoTZw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-3.1.1.tgz", + "integrity": "sha512-RqaC5h+VYdq5DwJsEbdJCruibsKWakx/zImuypmwC7odXJ2ls/yMvWzY/Z4k/Xd2QGPhow3y7yQzUsJPb4eheQ==", "dev": true, "requires": {} }, "@edx/frontend-build": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-build/-/frontend-build-12.3.0.tgz", - "integrity": "sha512-GewVd5qD59d8hHZFnXyS5jWkDY8TWYENqO5dd+/Kmu7cmjKrp69PKmNyVs+QgZEADodYI5UT48A2LhsDZjDx7A==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@edx/frontend-build/-/frontend-build-12.5.0.tgz", + "integrity": "sha512-2jQJ9SaJWPspsozvFeHYdnp5m9tq2nhv1EK/ypaojA/+rzZt73Dgl6DVFTzTYRFTuKcMvVuORgyn9Z39AMr5ew==", "dev": true, "requires": { "@babel/cli": "7.16.0", "@babel/core": "7.16.0", - "@babel/eslint-parser": "7.18.2", + "@babel/eslint-parser": "7.19.1", "@babel/plugin-proposal-class-properties": "7.16.0", "@babel/plugin-proposal-object-rest-spread": "7.16.0", "@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/preset-env": "7.16.4", "@babel/preset-react": "7.16.0", - "@edx/eslint-config": "3.1.0", - "@edx/new-relic-source-map-webpack-plugin": "1.0.1", - "@pmmmwh/react-refresh-webpack-plugin": "0.5.3", + "@edx/eslint-config": "3.1.1", + "@edx/new-relic-source-map-webpack-plugin": "1.0.2", + "@pmmmwh/react-refresh-webpack-plugin": "0.5.10", "@svgr/webpack": "6.2.1", - "autoprefixer": "10.2.6", + "autoprefixer": "10.4.13", "babel-jest": "26.6.3", "babel-loader": "8.2.3", "babel-plugin-react-intl": "7.9.4", @@ -27722,36 +25416,37 @@ "clean-webpack-plugin": "3.0.0", "css-loader": "5.2.7", "cssnano": "5.0.12", - "dotenv": "8.2.0", + "dotenv": "8.6.0", "dotenv-webpack": "7.0.3", - "eslint": "8.18.0", + "eslint": "8.29.0", "eslint-config-airbnb": "19.0.4", "eslint-plugin-import": "2.26.0", - "eslint-plugin-jsx-a11y": "6.5.1", - "eslint-plugin-react": "7.30.0", + "eslint-plugin-jsx-a11y": "6.6.1", + "eslint-plugin-react": "7.31.11", "eslint-plugin-react-hooks": "4.6.0", "file-loader": "6.2.0", "html-webpack-plugin": "5.5.0", "identity-obj-proxy": "3.0.0", - "image-webpack-loader": "8.1.0", + "image-minimizer-webpack-plugin": "3.3.0", "jest": "26.6.3", "mini-css-extract-plugin": "1.6.2", - "postcss": "8.4.5", - "postcss-loader": "6.1.1", + "postcss": "8.4.21", + "postcss-loader": "6.2.1", "postcss-rtlcss": "3.5.1", - "react-dev-utils": "12.0.0", - "react-refresh": "0.10.0", - "resolve-url-loader": "5.0.0-beta.1", - "sass": "1.49.9", + "react-dev-utils": "12.0.1", + "react-refresh": "0.14.0", + "resolve-url-loader": "5.0.0", + "sass": "1.56.1", "sass-loader": "12.6.0", + "sharp": "^0.31.0", "source-map-loader": "0.2.4", - "style-loader": "2.0.0", + "style-loader": "3.3.1", "url-loader": "4.1.1", - "webpack": "5.50.0", + "webpack": "5.75.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0", "webpack-dev-server": "4.7.3", - "webpack-merge": "5.2.0" + "webpack-merge": "5.8.0" }, "dependencies": { "@babel/core": { @@ -27777,23 +25472,6 @@ "source-map": "^0.5.0" } }, - "@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - } - }, - "@types/estree": { - "version": "0.0.50", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz", - "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==", - "dev": true - }, "@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -27809,12 +25487,6 @@ "color-convert": "^2.0.1" } }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -28011,6 +25683,12 @@ "domhandler": "^4.2.0" } }, + "dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true + }, "dotenv-webpack": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-7.0.3.tgz", @@ -28026,152 +25704,18 @@ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", "dev": true }, - "es-module-lexer": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.7.1.tgz", - "integrity": "sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw==", - "dev": true - }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true }, - "eslint": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz", - "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==", - "dev": true, - "requires": { - "@eslint/eslintrc": "^1.3.0", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.2", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.15.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "dependencies": { - "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - } - } - }, - "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true - }, "filesize": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", "dev": true }, - "fork-ts-checker-webpack-plugin": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", - "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "chokidar": "^3.4.2", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "glob": "^7.1.6", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - }, - "dependencies": { - "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - } - }, - "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "tapable": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", - "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", - "dev": true - } - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, "globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -28242,29 +25786,11 @@ } }, "immer": { - "version": "9.0.16", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz", - "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==", + "version": "9.0.19", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.19.tgz", + "integrity": "sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ==", "dev": true }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, "mini-css-extract-plugin": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.6.2.tgz", @@ -28294,14 +25820,14 @@ } }, "postcss": { - "version": "8.4.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", - "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, "requires": { - "nanoid": "^3.1.30", + "nanoid": "^3.3.4", "picocolors": "^1.0.0", - "source-map-js": "^1.0.1" + "source-map-js": "^1.0.2" } }, "postcss-calc": { @@ -28566,9 +26092,9 @@ } }, "react-dev-utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0.tgz", - "integrity": "sha512-xBQkitdxozPxt1YZ9O1097EJiVpwHr9FoAuEVURCKV0Av8NBERovJauzP7bo1ThvuhZ4shsQ1AJiu4vQpoT1AQ==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", "dev": true, "requires": { "@babel/code-frame": "^7.16.0", @@ -28590,18 +26116,65 @@ "open": "^8.4.0", "pkg-up": "^3.1.0", "prompts": "^2.4.2", - "react-error-overlay": "^6.0.10", + "react-error-overlay": "^6.0.11", "recursive-readdir": "^2.2.2", "shell-quote": "^1.7.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "dependencies": { + "fork-ts-checker-webpack-plugin": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", + "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + } + }, "loader-utils": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", "dev": true + }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true } } }, @@ -28675,67 +26248,15 @@ } } }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "webpack": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.50.0.tgz", - "integrity": "sha512-hqxI7t/KVygs0WRv/kTgUW8Kl3YC81uyWQSo/7WUs5LsuRw0htH/fCwbVBGCuiX/t4s7qzjXFcf41O8Reiypag==", - "dev": true, - "requires": { - "@types/eslint-scope": "^3.7.0", - "@types/estree": "^0.0.50", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.4.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.8.0", - "es-module-lexer": "^0.7.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.4", - "json-parse-better-errors": "^1.0.2", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.2.0", - "webpack-sources": "^3.2.0" - }, - "dependencies": { - "webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true - } - } - }, "webpack-merge": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.2.0.tgz", - "integrity": "sha512-QBglJBg5+lItm3/Lopv8KDDK01+hjdg2azEwi/4vKJ8ZmGPdtJsTpjtNNOW3a4WiqzXdCATtTudOZJngE7RKkA==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.8.0.tgz", + "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", "dev": true, "requires": { "clone-deep": "^4.0.1", "wildcard": "^2.0.0" } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true } } }, @@ -28860,9 +26381,9 @@ } }, "@edx/new-relic-source-map-webpack-plugin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@edx/new-relic-source-map-webpack-plugin/-/new-relic-source-map-webpack-plugin-1.0.1.tgz", - "integrity": "sha512-SAwugqBvDUg7ANdu1uBCkpPp0MnBlfyYAvKwO6Jh6Q4tMIPN6U35IF7MAymn1EZmi28UV6sLpSQPLggQjVQknA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@edx/new-relic-source-map-webpack-plugin/-/new-relic-source-map-webpack-plugin-1.0.2.tgz", + "integrity": "sha512-jwu9WjRtEbv0rvPHGCnhbQbbv6+DTADShj43NQVsuAyD823znjutgoHnlc+9HIOiYiIxjz/wIMcGVwjrTnMceQ==", "dev": true, "requires": { "@newrelic/publish-sourcemap": "^5.0.1" @@ -28966,15 +26487,15 @@ } }, "@eslint/eslintrc": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", - "integrity": "sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", + "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.4.0", - "globals": "^13.15.0", + "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -28989,9 +26510,9 @@ "dev": true }, "globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", + "version": "13.19.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", + "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", "dev": true, "requires": { "type-fest": "^0.20.2" @@ -29217,11 +26738,10 @@ } }, "@humanwhocodes/config-array": { - "version": "0.11.7", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz", - "integrity": "sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==", + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", + "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", "dev": true, - "peer": true, "requires": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -29232,8 +26752,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "peer": true + "dev": true }, "@humanwhocodes/object-schema": { "version": "1.2.1", @@ -30121,6 +27640,15 @@ "dev": true, "optional": true }, + "@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "requires": { + "eslint-scope": "5.1.1" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -30148,18 +27676,18 @@ } }, "@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.3.tgz", - "integrity": "sha512-OoTnFb8XEYaOuMNhVDsLRnAO6MCYHNs1g6d8pBcHhDFsi1P3lPbq/IklwtbAx9cG0W4J9KswxZtwGnejrnxp+g==", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", + "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==", "dev": true, "requires": { "ansi-html-community": "^0.0.8", "common-path-prefix": "^3.0.0", - "core-js-pure": "^3.8.1", + "core-js-pure": "^3.23.3", "error-stack-parser": "^2.0.6", "find-up": "^5.0.0", "html-entities": "^2.1.0", - "loader-utils": "^2.0.0", + "loader-utils": "^2.0.4", "schema-utils": "^3.0.0", "source-map": "^0.7.3" }, @@ -30203,13 +27731,6 @@ "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", "dev": true }, - "@sindresorhus/is": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz", - "integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==", - "dev": true, - "optional": true - }, "@sinonjs/commons": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.5.tgz", @@ -30644,21 +28165,21 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", + "version": "4.17.16", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.16.tgz", + "integrity": "sha512-LkKpqRZ7zqXJuvoELakaFYuETHjZkSol8EV6cNnyishutDBCCdv6+dsKPbKkCcIk57qRphOLY5sEgClw1bO3gA==", "dev": true, "requires": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", + "@types/express-serve-static-core": "^4.17.31", "@types/qs": "*", "@types/serve-static": "*" } }, "@types/express-serve-static-core": { - "version": "4.17.31", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", - "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", + "version": "4.17.33", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz", + "integrity": "sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==", "dev": true, "requires": { "@types/node": "*", @@ -31038,9 +28559,9 @@ } }, "@types/ws": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", - "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", + "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", "dev": true, "requires": { "@types/node": "*" @@ -31356,9 +28877,9 @@ }, "dependencies": { "ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -31447,32 +28968,6 @@ "picomatch": "^2.0.4" } }, - "arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "dev": true, - "optional": true - }, - "archive-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/archive-type/-/archive-type-4.0.0.tgz", - "integrity": "sha512-zV4Ky0v1F8dBrdYElwTvQhweQ0P7Kwc1aluqJsYtOBP01jXcWCyW2IEfI1YiqsG+Iy7ZR+o5LF1N+PGECBxHWA==", - "dev": true, - "optional": true, - "requires": { - "file-type": "^4.2.0" - }, - "dependencies": { - "file-type": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", - "integrity": "sha512-f2UbFQEk7LXgWpi5ntcO86OeA/cC80fuDDDaX/fZ2ZGel+AF7leRQqBBW1eJNiiQkrZlAoM6P+VYP5P6bOlDEQ==", - "dev": true, - "optional": true - } - } - }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -31617,6 +29112,19 @@ "is-string": "^1.0.7" } }, + "array.prototype.tosorted": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", + "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.1.3" + } + }, "assert-ok": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-ok/-/assert-ok-1.0.0.tgz", @@ -31666,23 +29174,23 @@ "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" }, "autoprefixer": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.2.6.tgz", - "integrity": "sha512-8lChSmdU6dCNMCQopIf4Pe5kipkAGj/fvTMslCsih0uHpOrXOPUEVOmYMMqmw3cekQkSD7EhIeuYl5y0BLdKqg==", + "version": "10.4.13", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz", + "integrity": "sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==", "dev": true, "requires": { - "browserslist": "^4.16.6", - "caniuse-lite": "^1.0.30001230", - "colorette": "^1.2.2", - "fraction.js": "^4.1.1", + "browserslist": "^4.21.4", + "caniuse-lite": "^1.0.30001426", + "fraction.js": "^4.2.0", "normalize-range": "^0.1.2", - "postcss-value-parser": "^4.1.0" + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" } }, "axe-core": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.5.2.tgz", - "integrity": "sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz", + "integrity": "sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==", "dev": true }, "axios": { @@ -32066,8 +29574,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "optional": true + "dev": true }, "batch": { "version": "0.6.1", @@ -32080,367 +29587,36 @@ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" }, - "bin-build": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bin-build/-/bin-build-3.0.0.tgz", - "integrity": "sha512-jcUOof71/TNAI2uM5uoUaDq2ePcVBQ3R/qhxAz1rX7UfvduAL/RXD3jXzvn8cVcDJdGVkiR1shal3OH0ImpuhA==", - "dev": true, - "optional": true, - "requires": { - "decompress": "^4.0.0", - "download": "^6.2.2", - "execa": "^0.7.0", - "p-map-series": "^1.0.0", - "tempfile": "^2.0.0" - }, - "dependencies": { - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "bin-check": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bin-check/-/bin-check-4.1.0.tgz", - "integrity": "sha512-b6weQyEUKsDGFlACWSIOfveEnImkJyK/FGW6FAG42loyoquvjdtOIqO6yBFzHyqyVVhNgNkQxxx09SFLK28YnA==", - "dev": true, - "optional": true, - "requires": { - "execa": "^0.7.0", - "executable": "^4.1.0" - }, - "dependencies": { - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "bin-version": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-3.1.0.tgz", - "integrity": "sha512-Mkfm4iE1VFt4xd4vH+gx+0/71esbfus2LsnCGe8Pi4mndSPyT+NGES/Eg99jx8/lUGWfu3z2yuB/bt5UB+iVbQ==", - "dev": true, - "optional": true, - "requires": { - "execa": "^1.0.0", - "find-versions": "^3.0.0" - } - }, - "bin-version-check": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/bin-version-check/-/bin-version-check-4.0.0.tgz", - "integrity": "sha512-sR631OrhC+1f8Cvs8WyVWOA33Y8tgwjETNPyyD/myRBXLkfS/vl74FmH/lFcRl9KY3zwGh7jFhvyk9vV3/3ilQ==", - "dev": true, - "optional": true, - "requires": { - "bin-version": "^3.0.0", - "semver": "^5.6.0", - "semver-truncate": "^1.1.2" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, - "optional": true - } - } + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true }, - "bin-wrapper": { + "bl": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bin-wrapper/-/bin-wrapper-4.1.0.tgz", - "integrity": "sha512-hfRmo7hWIXPkbpi0ZltboCMVrU+0ClXR/JgbCKKjlDjQf6igXa7OwdqNcFWQZPZTgiY7ZpzE3+LjjkLiTN2T7Q==", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, - "optional": true, "requires": { - "bin-check": "^4.1.0", - "bin-version-check": "^4.0.0", - "download": "^7.1.0", - "import-lazy": "^3.1.0", - "os-filter-obj": "^2.0.0", - "pify": "^4.0.1" + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" }, "dependencies": { - "download": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/download/-/download-7.1.0.tgz", - "integrity": "sha512-xqnBTVd/E+GxJVrX5/eUJiLYjCGPwMpdL+jGhGU57BvtcA7wwhtHVbXBeUk51kOpW3S7Jn3BQbN9Q1R1Km2qDQ==", - "dev": true, - "optional": true, - "requires": { - "archive-type": "^4.0.0", - "caw": "^2.0.1", - "content-disposition": "^0.5.2", - "decompress": "^4.2.0", - "ext-name": "^5.0.0", - "file-type": "^8.1.0", - "filenamify": "^2.0.0", - "get-stream": "^3.0.0", - "got": "^8.3.1", - "make-dir": "^1.2.0", - "p-event": "^2.1.0", - "pify": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, - "file-type": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz", - "integrity": "sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ==", - "dev": true, - "optional": true - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "got": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/got/-/got-8.3.2.tgz", - "integrity": "sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw==", - "dev": true, - "optional": true, - "requires": { - "@sindresorhus/is": "^0.7.0", - "cacheable-request": "^2.1.1", - "decompress-response": "^3.3.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "into-stream": "^3.1.0", - "is-retry-allowed": "^1.1.0", - "isurl": "^1.0.0-alpha5", - "lowercase-keys": "^1.0.0", - "mimic-response": "^1.0.0", - "p-cancelable": "^0.4.0", - "p-timeout": "^2.0.1", - "pify": "^3.0.0", - "safe-buffer": "^5.1.1", - "timed-out": "^4.0.1", - "url-parse-lax": "^3.0.0", - "url-to-options": "^1.0.1" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, - "make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "requires": { - "pify": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, - "p-cancelable": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", - "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==", - "dev": true, - "optional": true - }, - "p-event": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-2.3.1.tgz", - "integrity": "sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==", - "dev": true, - "optional": true, - "requires": { - "p-timeout": "^2.0.1" - } - }, - "p-timeout": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", - "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", - "dev": true, - "optional": true, - "requires": { - "p-finally": "^1.0.0" - } - }, - "prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", - "dev": true, - "optional": true - }, - "url-parse-lax": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", - "integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==", + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, - "optional": true, "requires": { - "prepend-http": "^2.0.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" } } } }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true - }, - "bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", - "dev": true, - "optional": true, - "requires": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, "body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -32556,44 +29732,11 @@ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, - "optional": true, "requires": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, - "buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "dev": true, - "optional": true, - "requires": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true, - "optional": true - }, - "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "optional": true - }, - "buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "dev": true, - "optional": true - }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -32641,79 +29784,6 @@ "resolved": "https://registry.npmjs.org/cache-control-esm/-/cache-control-esm-1.0.0.tgz", "integrity": "sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g==" }, - "cacheable-request": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-2.1.4.tgz", - "integrity": "sha512-vag0O2LKZ/najSoUwDbVlnlCFvhBE/7mGTY2B5FgCBDcRD+oVV1HYTOwM6JZfMg/hIcM6IwnTZ1uQQL5/X3xIQ==", - "dev": true, - "optional": true, - "requires": { - "clone-response": "1.0.2", - "get-stream": "3.0.0", - "http-cache-semantics": "3.8.1", - "keyv": "3.0.0", - "lowercase-keys": "1.0.0", - "normalize-url": "2.0.1", - "responselike": "1.0.2" - }, - "dependencies": { - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "lowercase-keys": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.0.tgz", - "integrity": "sha512-RPlX0+PHuvxVDZ7xX+EBVAp4RsVxP/TdDSN2mJYdiq1Lc4Hz7EUSjUI7RZrKKlmrIzVhf6Jo2stj7++gVarS0A==", - "dev": true, - "optional": true - }, - "normalize-url": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-2.0.1.tgz", - "integrity": "sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==", - "dev": true, - "optional": true, - "requires": { - "prepend-http": "^2.0.0", - "query-string": "^5.0.1", - "sort-keys": "^2.0.0" - } - }, - "prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", - "dev": true, - "optional": true - }, - "query-string": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", - "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", - "dev": true, - "optional": true, - "requires": { - "decode-uri-component": "^0.2.0", - "object-assign": "^4.1.0", - "strict-uri-encode": "^1.0.0" - } - }, - "sort-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", - "integrity": "sha512-/dPCrG1s3ePpWm6yBbxZq5Be1dXGLyLn9Z791chDC3NFrpkVbWGzkBwPN1knaciexFXgRJ7hzdnwZ4stHSDmjg==", - "dev": true, - "optional": true, - "requires": { - "is-plain-obj": "^1.0.0" - } - } - } - }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -32778,19 +29848,6 @@ "isarray": "0.0.1" } }, - "caw": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz", - "integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==", - "dev": true, - "optional": true, - "requires": { - "get-proxy": "^2.0.0", - "isurl": "^1.0.0-alpha5", - "tunnel-agent": "^0.6.0", - "url-to-options": "^1.0.1" - } - }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -32872,6 +29929,12 @@ "readdirp": "~3.6.0" } }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -33039,16 +30102,6 @@ "shallow-clone": "^3.0.0" } }, - "clone-response": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", - "integrity": "sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==", - "dev": true, - "optional": true, - "requires": { - "mimic-response": "^1.0.0" - } - }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -33207,17 +30260,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "optional": true, - "requires": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, "confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -33262,9 +30304,9 @@ "dev": true }, "cookiejar": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", - "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, "copy-descriptor": { @@ -33371,15 +30413,6 @@ "semver": "^7.3.5" }, "dependencies": { - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, "postcss": { "version": "8.4.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz", @@ -33399,12 +30432,6 @@ "requires": { "lru-cache": "^6.0.0" } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true } } }, @@ -33495,17 +30522,6 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, - "cwebp-bin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cwebp-bin/-/cwebp-bin-7.0.1.tgz", - "integrity": "sha512-Ko5ADY74/dbfd8xG0+f+MUP9UKjCe1TG4ehpW0E5y4YlPdwDJlGrSzSR4/Yonxpm9QmZE1RratkIxFlKeyo3FA==", - "dev": true, - "optional": true, - "requires": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.1" - } - }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -33604,164 +30620,13 @@ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==" }, - "decompress": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", - "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", - "dev": true, - "optional": true, - "requires": { - "decompress-tar": "^4.0.0", - "decompress-tarbz2": "^4.0.0", - "decompress-targz": "^4.0.0", - "decompress-unzip": "^4.0.1", - "graceful-fs": "^4.1.10", - "make-dir": "^1.0.0", - "pify": "^2.3.0", - "strip-dirs": "^2.0.0" - }, - "dependencies": { - "make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "requires": { - "pify": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true - } - } - }, "decompress-response": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", - "integrity": "sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==", - "dev": true, - "optional": true, - "requires": { - "mimic-response": "^1.0.0" - } - }, - "decompress-tar": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", - "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", - "dev": true, - "optional": true, - "requires": { - "file-type": "^5.2.0", - "is-stream": "^1.1.0", - "tar-stream": "^1.5.2" - }, - "dependencies": { - "file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true - } - } - }, - "decompress-tarbz2": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", - "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", - "dev": true, - "optional": true, - "requires": { - "decompress-tar": "^4.1.0", - "file-type": "^6.1.0", - "is-stream": "^1.1.0", - "seek-bzip": "^1.0.5", - "unbzip2-stream": "^1.0.9" - }, - "dependencies": { - "file-type": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", - "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", - "dev": true, - "optional": true - } - } - }, - "decompress-targz": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", - "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, - "optional": true, "requires": { - "decompress-tar": "^4.1.1", - "file-type": "^5.2.0", - "is-stream": "^1.1.0" - }, - "dependencies": { - "file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true - } - } - }, - "decompress-unzip": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", - "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", - "dev": true, - "optional": true, - "requires": { - "file-type": "^3.8.0", - "get-stream": "^2.2.0", - "pify": "^2.3.0", - "yauzl": "^2.4.2" - }, - "dependencies": { - "file-type": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", - "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", - "dev": true, - "optional": true - }, - "get-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", - "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", - "dev": true, - "optional": true, - "requires": { - "object-assign": "^4.0.1", - "pinkie-promise": "^2.0.0" - } - }, - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true - } + "mimic-response": "^3.1.0" } }, "deep-diff": { @@ -33783,6 +30648,12 @@ "regexp.prototype.flags": "^1.2.0" } }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -33917,6 +30788,12 @@ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true }, + "detect-libc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "dev": true + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -34125,72 +31002,12 @@ "dotenv-defaults": "^2.0.1" } }, - "download": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/download/-/download-6.2.5.tgz", - "integrity": "sha512-DpO9K1sXAST8Cpzb7kmEhogJxymyVUd5qz/vCOSyvwtp2Klj2XcDt5YUuasgxka44SxF0q5RriKIwJmQHG2AuA==", - "dev": true, - "optional": true, - "requires": { - "caw": "^2.0.0", - "content-disposition": "^0.5.2", - "decompress": "^4.0.0", - "ext-name": "^5.0.0", - "file-type": "5.2.0", - "filenamify": "^2.0.0", - "get-stream": "^3.0.0", - "got": "^7.0.0", - "make-dir": "^1.0.0", - "p-event": "^1.0.0", - "pify": "^3.0.0" - }, - "dependencies": { - "file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "requires": { - "pify": "^3.0.0" - } - }, - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, "duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, - "duplexer3": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", - "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==", - "dev": true, - "optional": true - }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -34431,8 +31248,7 @@ "es-module-lexer": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "peer": true + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" }, "es-shim-unscopables": { "version": "1.0.0", @@ -34530,11 +31346,10 @@ } }, "eslint": { - "version": "8.28.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.28.0.tgz", - "integrity": "sha512-S27Di+EVyMxcHiwDrFzk8dJYAaD+/5SoWKxL1ri/71CRHsnJnRDPNt2Kzj24+MT9FDupf4aqqyqPrvI8MvQ4VQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.29.0.tgz", + "integrity": "sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==", "dev": true, - "peer": true, "requires": { "@eslint/eslintrc": "^1.3.3", "@humanwhocodes/config-array": "^0.11.6", @@ -34582,7 +31397,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "peer": true, "requires": { "color-convert": "^2.0.1" } @@ -34591,15 +31405,13 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true + "dev": true }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "peer": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -34610,7 +31422,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "peer": true, "requires": { "color-name": "~1.1.4" } @@ -34619,22 +31430,19 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "peer": true + "dev": true }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "peer": true + "dev": true }, "eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", "dev": true, - "peer": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -34644,25 +31452,22 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true, - "peer": true + "dev": true }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "peer": true, "requires": { "is-glob": "^4.0.3" } }, "globals": { - "version": "13.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.18.0.tgz", - "integrity": "sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==", + "version": "13.19.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", + "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", "dev": true, - "peer": true, "requires": { "type-fest": "^0.20.2" } @@ -34671,15 +31476,13 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "peer": true + "dev": true }, "js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "peer": true, "requires": { "argparse": "^2.0.1" } @@ -34689,7 +31492,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "peer": true, "requires": { "has-flag": "^4.0.0" } @@ -34698,8 +31500,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "peer": true + "dev": true } } }, @@ -34727,13 +31528,14 @@ } }, "eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz", + "integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==", "dev": true, "requires": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.11.0", + "resolve": "^1.22.1" }, "dependencies": { "debug": { @@ -34815,45 +31617,47 @@ } }, "eslint-plugin-jsx-a11y": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", - "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", + "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", "dev": true, "requires": { - "@babel/runtime": "^7.16.3", + "@babel/runtime": "^7.18.9", "aria-query": "^4.2.2", - "array-includes": "^3.1.4", + "array-includes": "^3.1.5", "ast-types-flow": "^0.0.7", - "axe-core": "^4.3.5", + "axe-core": "^4.4.3", "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.7", + "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "has": "^1.0.3", - "jsx-ast-utils": "^3.2.1", + "jsx-ast-utils": "^3.3.2", "language-tags": "^1.0.5", - "minimatch": "^3.0.4" + "minimatch": "^3.1.2", + "semver": "^6.3.0" } }, "eslint-plugin-react": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.30.0.tgz", - "integrity": "sha512-RgwH7hjW48BleKsYyHK5vUAvxtE9SMPDKmcPRQgtRCYaZA0XQPt5FSkrU3nhz5ifzMZcA8opwmRJ2cmOO8tr5A==", + "version": "7.31.11", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.11.tgz", + "integrity": "sha512-TTvq5JsT5v56wPa9OYHzsrOlHzKZKjV+aLgS+55NJP/cuzdiQPC7PfYoUjMoxlffKtvijpk7vA/jmuqRb9nohw==", "dev": true, "requires": { - "array-includes": "^3.1.5", - "array.prototype.flatmap": "^1.3.0", + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.5", - "object.fromentries": "^2.0.5", - "object.hasown": "^1.1.1", - "object.values": "^1.1.5", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.3", "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.7" + "string.prototype.matchall": "^4.0.8" }, "dependencies": { "doctrine": { @@ -34995,91 +31799,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, - "exec-buffer": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/exec-buffer/-/exec-buffer-3.2.0.tgz", - "integrity": "sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==", - "dev": true, - "optional": true, - "requires": { - "execa": "^0.7.0", - "p-finally": "^1.0.0", - "pify": "^3.0.0", - "rimraf": "^2.5.4", - "tempfile": "^2.0.0" - }, - "dependencies": { - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, "exec-sh": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz", @@ -35152,25 +31871,6 @@ } } }, - "executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dev": true, - "optional": true, - "requires": { - "pify": "^2.2.0" - }, - "dependencies": { - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true - } - } - }, "exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -35296,6 +31996,12 @@ } } }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true + }, "expect": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/expect/-/expect-29.3.1.tgz", @@ -35477,27 +32183,6 @@ } } }, - "ext-list": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", - "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", - "dev": true, - "optional": true, - "requires": { - "mime-db": "^1.28.0" - } - }, - "ext-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", - "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", - "dev": true, - "optional": true, - "requires": { - "ext-list": "^2.0.0", - "sort-keys-length": "^1.0.0" - } - }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -35583,16 +32268,6 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, - "fast-xml-parser": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz", - "integrity": "sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==", - "dev": true, - "optional": true, - "requires": { - "strnum": "^1.0.4" - } - }, "fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -35626,16 +32301,6 @@ "bser": "2.1.1" } }, - "fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "optional": true, - "requires": { - "pend": "~1.2.0" - } - }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -35668,31 +32333,6 @@ "tslib": "^2.4.0" } }, - "file-type": { - "version": "12.4.2", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz", - "integrity": "sha512-UssQP5ZgIOKelfsaB5CuGAL+Y+q7EmONuiwF3N5HAH0t27rvrttgi6Ra9k/+DVaY9UF6+ybxu5pOXLUdA8N7Vg==", - "dev": true - }, - "filename-reserved-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", - "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", - "dev": true, - "optional": true - }, - "filenamify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz", - "integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==", - "dev": true, - "optional": true, - "requires": { - "filename-reserved-regex": "^2.0.0", - "strip-outer": "^1.0.0", - "trim-repeated": "^1.0.0" - } - }, "filesize": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", @@ -35782,16 +32422,6 @@ "path-exists": "^4.0.0" } }, - "find-versions": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz", - "integrity": "sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==", - "dev": true, - "optional": true, - "requires": { - "semver-regex": "^2.0.0" - } - }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -36035,23 +32665,11 @@ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "dev": true }, - "from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "optional": true, - "requires": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "optional": true + "dev": true }, "fs-extra": { "version": "9.1.0", @@ -36105,12 +32723,6 @@ "functions-have-names": "^1.2.2" } }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true - }, "functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -36148,16 +32760,6 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, - "get-proxy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz", - "integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==", - "dev": true, - "optional": true, - "requires": { - "npm-conf": "^1.1.0" - } - }, "get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", @@ -36182,61 +32784,11 @@ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", "dev": true }, - "gifsicle": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gifsicle/-/gifsicle-5.3.0.tgz", - "integrity": "sha512-FJTpgdj1Ow/FITB7SVza5HlzXa+/lqEY0tHQazAJbuAdvyJtkH4wIdsR2K414oaTwRXHFLLF+tYbipj+OpYg+Q==", - "dev": true, - "optional": true, - "requires": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.0", - "execa": "^5.0.0" - }, - "dependencies": { - "execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - } - }, - "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "optional": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "requires": { - "path-key": "^3.0.0" - } - } - } + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true }, "glob": { "version": "7.2.3", @@ -36320,38 +32872,6 @@ } } }, - "got": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/got/-/got-7.1.0.tgz", - "integrity": "sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==", - "dev": true, - "optional": true, - "requires": { - "decompress-response": "^3.2.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "is-plain-obj": "^1.1.0", - "is-retry-allowed": "^1.0.0", - "is-stream": "^1.0.0", - "isurl": "^1.0.0-alpha5", - "lowercase-keys": "^1.0.0", - "p-cancelable": "^0.3.0", - "p-timeout": "^1.1.1", - "safe-buffer": "^5.0.1", - "timed-out": "^4.0.0", - "url-parse-lax": "^1.0.0", - "url-to-options": "^1.0.1" - }, - "dependencies": { - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - } - } - }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -36361,8 +32881,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true, - "peer": true + "dev": true }, "growly": { "version": "1.3.0", @@ -36419,28 +32938,11 @@ "get-intrinsic": "^1.1.1" } }, - "has-symbol-support-x": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz", - "integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==", - "dev": true, - "optional": true - }, "has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, - "has-to-string-tag-x": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz", - "integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==", - "dev": true, - "optional": true, - "requires": { - "has-symbol-support-x": "^1.4.1" - } - }, "has-tostringtag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", @@ -36655,13 +33157,6 @@ "entities": "^4.3.0" } }, - "http-cache-semantics": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz", - "integrity": "sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w==", - "dev": true, - "optional": true - }, "http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -36804,8 +33299,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "optional": true + "dev": true }, "ignore": { "version": "5.2.0", @@ -36813,272 +33307,57 @@ "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", "dev": true }, - "image-webpack-loader": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/image-webpack-loader/-/image-webpack-loader-8.1.0.tgz", - "integrity": "sha512-bxzMIBNu42KGo6Bc9YMB0QEUt+XuVTl2ZSX3oGAlbsqYOkxkT4TEWvVsnwUkCRCYISJrMCEc/s0y8OYrmEfUOg==", - "dev": true, - "requires": { - "imagemin": "^7.0.1", - "imagemin-gifsicle": "^7.0.0", - "imagemin-mozjpeg": "^9.0.0", - "imagemin-optipng": "^8.0.0", - "imagemin-pngquant": "^9.0.2", - "imagemin-svgo": "^9.0.0", - "imagemin-webp": "^7.0.0", - "loader-utils": "^2.0.0", - "object-assign": "^4.1.1", - "schema-utils": "^2.7.1" - }, - "dependencies": { - "schema-utils": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", - "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" - } - } - } - }, - "imagemin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/imagemin/-/imagemin-7.0.1.tgz", - "integrity": "sha512-33AmZ+xjZhg2JMCe+vDf6a9mzWukE7l+wAtesjE7KyteqqKjzxv7aVQeWnul1Ve26mWvEQqyPwl0OctNBfSR9w==", + "image-minimizer-webpack-plugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/image-minimizer-webpack-plugin/-/image-minimizer-webpack-plugin-3.3.0.tgz", + "integrity": "sha512-WFLJoOhF2f3c2K4NqpqnJdsBkGcBz/i9+qnhS50qQvC+5FEPQZxcsyKaYrUjvIpox6d3kIoEdip3GbxXwHAEpA==", "dev": true, "requires": { - "file-type": "^12.0.0", - "globby": "^10.0.0", - "graceful-fs": "^4.2.2", - "junk": "^3.1.0", - "make-dir": "^3.0.0", - "p-pipe": "^3.0.0", - "replace-ext": "^1.0.0" + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" }, "dependencies": { - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "globby": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", - "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "requires": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" } }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "requires": { - "semver": "^6.0.0" + "fast-deep-equal": "^3.1.3" } }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true - } - } - }, - "imagemin-gifsicle": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/imagemin-gifsicle/-/imagemin-gifsicle-7.0.0.tgz", - "integrity": "sha512-LaP38xhxAwS3W8PFh4y5iQ6feoTSF+dTAXFRUEYQWYst6Xd+9L/iPk34QGgK/VO/objmIlmq9TStGfVY2IcHIA==", - "dev": true, - "optional": true, - "requires": { - "execa": "^1.0.0", - "gifsicle": "^5.0.0", - "is-gif": "^3.0.0" - } - }, - "imagemin-mozjpeg": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/imagemin-mozjpeg/-/imagemin-mozjpeg-9.0.0.tgz", - "integrity": "sha512-TwOjTzYqCFRgROTWpVSt5UTT0JeCuzF1jswPLKALDd89+PmrJ2PdMMYeDLYZ1fs9cTovI9GJd68mRSnuVt691w==", - "dev": true, - "optional": true, - "requires": { - "execa": "^4.0.0", - "is-jpg": "^2.0.0", - "mozjpeg": "^7.0.0" - }, - "dependencies": { - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "requires": { - "pump": "^3.0.0" - } - }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "requires": { - "path-key": "^3.0.0" - } - } - } - }, - "imagemin-optipng": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/imagemin-optipng/-/imagemin-optipng-8.0.0.tgz", - "integrity": "sha512-CUGfhfwqlPjAC0rm8Fy+R2DJDBGjzy2SkfyT09L8rasnF9jSoHFqJ1xxSZWK6HVPZBMhGPMxCTL70OgTHlLF5A==", - "dev": true, - "optional": true, - "requires": { - "exec-buffer": "^3.0.0", - "is-png": "^2.0.0", - "optipng-bin": "^7.0.0" - } - }, - "imagemin-pngquant": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/imagemin-pngquant/-/imagemin-pngquant-9.0.2.tgz", - "integrity": "sha512-cj//bKo8+Frd/DM8l6Pg9pws1pnDUjgb7ae++sUX1kUVdv2nrngPykhiUOgFeE0LGY/LmUbCf4egCHC4YUcZSg==", - "dev": true, - "optional": true, - "requires": { - "execa": "^4.0.0", - "is-png": "^2.0.0", - "is-stream": "^2.0.0", - "ow": "^0.17.0", - "pngquant-bin": "^6.0.0" - }, - "dependencies": { - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "requires": { - "pump": "^3.0.0" - } - }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", "dev": true, - "optional": true, "requires": { - "path-key": "^3.0.0" + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" } } } }, - "imagemin-svgo": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/imagemin-svgo/-/imagemin-svgo-9.0.0.tgz", - "integrity": "sha512-uNgXpKHd99C0WODkrJ8OO/3zW3qjgS4pW7hcuII0RcHN3tnKxDjJWcitdVC/TZyfIqSricU8WfrHn26bdSW62g==", - "dev": true, - "optional": true, - "requires": { - "is-svg": "^4.2.1", - "svgo": "^2.1.0" - } - }, - "imagemin-webp": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/imagemin-webp/-/imagemin-webp-7.0.0.tgz", - "integrity": "sha512-JoYjvHNgBLgrQAkeCO7T5iNc8XVpiBmMPZmiXMhalC7K6gwY/3DCEUfNxVPOmNJ+NIJlJFvzcMR9RBxIE74Xxw==", - "dev": true, - "optional": true, - "requires": { - "cwebp-bin": "^7.0.1", - "exec-buffer": "^3.2.0", - "is-cwebp-readable": "^3.0.0" - } - }, "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -37114,13 +33393,6 @@ } } }, - "import-lazy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-3.1.0.tgz", - "integrity": "sha512-8/gvXvX2JMn0F+CDlSC4l6kOmVaLOO3XLkksI7CI3Ud95KDYJuYur2b9P/PUt/i/pDAMd/DulQsNbbbmRRsDIQ==", - "dev": true, - "optional": true - }, "import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -37204,17 +33476,6 @@ "@formatjs/intl-numberformat": "^5.5.2" } }, - "into-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", - "integrity": "sha512-TcdjPibTksa1NQximqep2r17ISRiNE9fwlfbg3F8ANdvP5/yrFTew86VcO//jk4QTaMlbjypPBq76HN2zaKfZQ==", - "dev": true, - "optional": true, - "requires": { - "from2": "^2.1.1", - "p-is-promise": "^1.1.0" - } - }, "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -37327,25 +33588,6 @@ "has": "^1.0.3" } }, - "is-cwebp-readable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-cwebp-readable/-/is-cwebp-readable-3.0.0.tgz", - "integrity": "sha512-bpELc7/Q1/U5MWHn4NdHI44R3jxk0h9ew9ljzabiRl70/UIjL/ZAqRMb52F5+eke/VC8yTiv4Ewryo1fPWidvA==", - "dev": true, - "optional": true, - "requires": { - "file-type": "^10.5.0" - }, - "dependencies": { - "file-type": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", - "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", - "dev": true, - "optional": true - } - } - }, "is-data-descriptor": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", @@ -37412,25 +33654,6 @@ "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true }, - "is-gif": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-gif/-/is-gif-3.0.0.tgz", - "integrity": "sha512-IqJ/jlbw5WJSNfwQ/lHEDXF8rxhRgF6ythk2oiEvhpG29F704eX9NO6TvPfMiq9DrbwgcEDnETYNcZDPewQoVw==", - "dev": true, - "optional": true, - "requires": { - "file-type": "^10.4.0" - }, - "dependencies": { - "file-type": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", - "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", - "dev": true, - "optional": true - } - } - }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -37471,20 +33694,6 @@ } } }, - "is-jpg": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-jpg/-/is-jpg-2.0.0.tgz", - "integrity": "sha512-ODlO0ruzhkzD3sdynIainVP5eoOFNN85rxA1+cwwnPe4dKyX0r5+hxNO5XpCrxlHcmb9vkOit9mhRD2JVuimHg==", - "dev": true, - "optional": true - }, - "is-natural-number": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", - "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", - "dev": true, - "optional": true - }, "is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -37503,13 +33712,6 @@ "has-tostringtag": "^1.0.0" } }, - "is-object": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", - "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", - "dev": true, - "optional": true - }, "is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -37542,7 +33744,8 @@ "is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==" + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "peer": true }, "is-plain-object": { "version": "2.0.4", @@ -37559,13 +33762,6 @@ } } }, - "is-png": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-png/-/is-png-2.0.0.tgz", - "integrity": "sha512-4KPGizaVGj2LK7xwJIz8o5B2ubu1D/vcQsgOGFEDlpcvgZHto4gBnyd0ig7Ws+67ixmwKoNmu0hYnpo6AaKb5g==", - "dev": true, - "optional": true - }, "is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -37591,13 +33787,6 @@ "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", "dev": true }, - "is-retry-allowed": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", - "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", - "dev": true, - "optional": true - }, "is-root": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", @@ -37632,16 +33821,6 @@ "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", "dev": true }, - "is-svg": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-4.3.2.tgz", - "integrity": "sha512-mM90duy00JGMyjqIVHu9gNTjywdZV+8qNasX8cm/EEYZ53PHDgajvbBwNVvty5dwSAxLUD3p3bdo+7sR/UMrpw==", - "dev": true, - "optional": true, - "requires": { - "fast-xml-parser": "^3.19.0" - } - }, "is-symbol": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", @@ -37801,17 +33980,6 @@ "istanbul-lib-report": "^3.0.0" } }, - "isurl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz", - "integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==", - "dev": true, - "optional": true, - "requires": { - "has-to-string-tag-x": "^1.2.0", - "is-object": "^1.0.1" - } - }, "jest": { "version": "26.6.3", "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", @@ -39333,15 +35501,6 @@ "stack-utils": "^2.0.2" } }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, "semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -39365,12 +35524,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true } } }, @@ -39617,11 +35770,10 @@ "peer": true }, "js-sdsl": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz", - "integrity": "sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==", - "dev": true, - "peer": true + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "dev": true }, "js-tokens": { "version": "4.0.0", @@ -39684,19 +35836,6 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" }, - "json-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==", - "dev": true, - "optional": true - }, - "json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true - }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -39738,12 +35877,6 @@ "object.assign": "^4.1.3" } }, - "junk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", - "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", - "dev": true - }, "just-curry-it": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-3.2.1.tgz", @@ -39754,16 +35887,6 @@ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" }, - "keyv": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.0.0.tgz", - "integrity": "sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA==", - "dev": true, - "optional": true, - "requires": { - "json-buffer": "3.0.0" - } - }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -39788,12 +35911,12 @@ "dev": true }, "language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.7.tgz", + "integrity": "sha512-bSytju1/657hFjgUzPAPqszxH62ouE8nQFoFaVlIQfne4wO/wXC9A4+m8jYve7YBBvi59eq0SUpcshvG8h5Usw==", "dev": true, "requires": { - "language-subtag-registry": "~0.3.2" + "language-subtag-registry": "^0.3.20" } }, "leven": { @@ -39971,22 +36094,13 @@ "tslib": "^2.0.3" } }, - "lowercase-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", - "dev": true, - "optional": true - }, "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, - "optional": true, "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" + "yallist": "^4.0.0" } }, "lz-string": { @@ -40130,9 +36244,9 @@ "dev": true }, "memfs": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.12.tgz", - "integrity": "sha512-BcjuQn6vfqP+k100e0E9m61Hyqa//Brp+I3f0OBmN0ATHlFA8vx3Lt8z57R3u2bPqe3WGDBC+nF72fTH7isyEw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.13.tgz", + "integrity": "sha512-omTM41g3Skpvx5dSYeZIbXKcXoAVc/AoMNwn9TKx++L/gaen/+4TTttmu8ZSch5vfVJ8uJvGbroTsIlslRg6lg==", "dev": true, "requires": { "fs-monkey": "^1.0.3" @@ -40211,11 +36325,10 @@ "dev": true }, "mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "optional": true + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true }, "min-indent": { "version": "1.0.1", @@ -40315,6 +36428,12 @@ "minimist": "^1.2.6" } }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "moment": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz", @@ -40326,17 +36445,6 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, - "mozjpeg": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/mozjpeg/-/mozjpeg-7.1.1.tgz", - "integrity": "sha512-iIDxWvzhWvLC9mcRJ1uSkiKaj4drF58oCqK2bITm5c2Jt6cJ8qQjSSru2PCaysG+hLIinryj8mgz5ZJzOYTv1A==", - "dev": true, - "optional": true, - "requires": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.0" - } - }, "mrmime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", @@ -40389,6 +36497,12 @@ "to-regex": "^3.0.1" } }, + "napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "dev": true + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -40441,6 +36555,32 @@ "tslib": "^2.0.3" } }, + "node-abi": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.31.0.tgz", + "integrity": "sha512-eSKV6s+APenqVh8ubJyiu/YhZgxQpGP66ntzUb3lY1xB9ukSRaGnx0AIxI+IM+1+IVYC1oWobgG5L3Lt9ARykQ==", + "dev": true, + "requires": { + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "dev": true + }, "node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -40468,16 +36608,6 @@ "which": "^2.0.2" }, "dependencies": { - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - }, "semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -40494,13 +36624,6 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, "optional": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "optional": true } } }, @@ -40564,26 +36687,6 @@ } } }, - "npm-conf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", - "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", - "dev": true, - "optional": true, - "requires": { - "config-chain": "^1.1.11", - "pify": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, "npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -40886,82 +36989,18 @@ "word-wrap": "^1.2.3" } }, - "optipng-bin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/optipng-bin/-/optipng-bin-7.0.1.tgz", - "integrity": "sha512-W99mpdW7Nt2PpFiaO+74pkht7KEqkXkeRomdWXfEz3SALZ6hns81y/pm1dsGZ6ItUIfchiNIP6ORDr1zETU1jA==", - "dev": true, - "optional": true, - "requires": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.0" - } - }, - "os-filter-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/os-filter-obj/-/os-filter-obj-2.0.0.tgz", - "integrity": "sha512-uksVLsqG3pVdzzPvmAHpBK0wKxYItuzZr7SziusRPoz67tGV8rL1szZ6IdeUrbqLjGDwApBtN29eEE3IqGHOjg==", - "dev": true, - "optional": true, - "requires": { - "arch": "^2.1.0" - } - }, - "ow": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/ow/-/ow-0.17.0.tgz", - "integrity": "sha512-i3keDzDQP5lWIe4oODyDFey1qVrq2hXKTuTH2VpqwpYtzPiKZt2ziRI4NBQmgW40AnV5Euz17OyWweCb+bNEQA==", - "dev": true, - "optional": true, - "requires": { - "type-fest": "^0.11.0" - }, - "dependencies": { - "type-fest": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", - "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", - "dev": true, - "optional": true - } - } - }, - "p-cancelable": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.3.0.tgz", - "integrity": "sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==", - "dev": true, - "optional": true - }, "p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", "integrity": "sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==", "dev": true }, - "p-event": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-1.3.0.tgz", - "integrity": "sha512-hV1zbA7gwqPVFcapfeATaNjQ3J0NuzorHPyG8GPL9g/Y/TplWVBVoCKCXL6Ej2zscrCEv195QNWJXuBH6XZuzA==", - "dev": true, - "optional": true, - "requires": { - "p-timeout": "^1.1.1" - } - }, "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", "dev": true }, - "p-is-promise": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", - "integrity": "sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==", - "dev": true, - "optional": true - }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -40985,29 +37024,6 @@ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==" }, - "p-map-series": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-map-series/-/p-map-series-1.0.0.tgz", - "integrity": "sha512-4k9LlvY6Bo/1FcIdV33wqZQES0Py+iKISU9Uc8p8AjWoZPnFKMpVIVD3s0EYn4jzLh1I+WeUZkJ0Yoa4Qfw3Kg==", - "dev": true, - "optional": true, - "requires": { - "p-reduce": "^1.0.0" - } - }, - "p-pipe": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz", - "integrity": "sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw==", - "dev": true - }, - "p-reduce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-1.0.0.tgz", - "integrity": "sha512-3Tx1T3oM1xO/Y8Gj0sWyE78EIJZ+t+aEmXUdvQgvGmSMri7aPTHoovbXEreWKkL5j21Er60XAWLTzKbAKYOujQ==", - "dev": true, - "optional": true - }, "p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -41018,16 +37034,6 @@ "retry": "^0.13.1" } }, - "p-timeout": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-1.2.1.tgz", - "integrity": "sha512-gb0ryzr+K2qFqFv6qi3khoeqMZF/+ajxQipEF6NteZVnvz9tzdsfAVj3lYtn1gAXvH5lfLwfxEII799gt/mRIA==", - "dev": true, - "optional": true, - "requires": { - "p-finally": "^1.0.0" - } - }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -41158,13 +37164,6 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, - "pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "optional": true - }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -41307,72 +37306,6 @@ } } }, - "pngquant-bin": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/pngquant-bin/-/pngquant-bin-6.0.1.tgz", - "integrity": "sha512-Q3PUyolfktf+hYio6wsg3SanQzEU/v8aICg/WpzxXcuCMRb7H2Q81okfpcEztbMvw25ILjd3a87doj2N9kvbpQ==", - "dev": true, - "optional": true, - "requires": { - "bin-build": "^3.0.0", - "bin-wrapper": "^4.0.1", - "execa": "^4.0.0" - }, - "dependencies": { - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "requires": { - "pump": "^3.0.0" - } - }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "requires": { - "path-key": "^3.0.0" - } - } - } - }, "popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -41428,25 +37361,16 @@ } }, "postcss-loader": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.1.1.tgz", - "integrity": "sha512-lBmJMvRh1D40dqpWKr9Rpygwxn8M74U9uaCSeYGNKLGInbk9mXBt1ultHf2dH9Ghk6Ue4UXlXWwGMH9QdUJ5ug==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", "dev": true, "requires": { "cosmiconfig": "^7.0.0", - "klona": "^2.0.4", + "klona": "^2.0.5", "semver": "^7.3.5" }, "dependencies": { - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, "semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -41455,12 +37379,6 @@ "requires": { "lru-cache": "^6.0.0" } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true } } }, @@ -41525,6 +37443,26 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "prebuild-install": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", + "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "dev": true, + "requires": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -41534,7 +37472,8 @@ "prepend-http": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", - "integrity": "sha512-PhmXi5XmoyKw1Un4E+opM2KcsJInDvKyuOumcjjw3waw86ZNjHwVUOOWLc4bCzLdcKNaWBH9e99sbWzDQsVaYg==" + "integrity": "sha512-PhmXi5XmoyKw1Un4E+opM2KcsJInDvKyuOumcjjw3waw86ZNjHwVUOOWLc4bCzLdcKNaWBH9e99sbWzDQsVaYg==", + "peer": true }, "pretty-error": { "version": "2.1.2", @@ -41639,13 +37578,6 @@ "xtend": "^4.0.0" } }, - "proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "optional": true - }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -41664,13 +37596,6 @@ } } }, - "pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true, - "optional": true - }, "psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -41803,6 +37728,26 @@ } } }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true + } + } + }, "react": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", @@ -42261,9 +38206,9 @@ } }, "react-refresh": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz", - "integrity": "sha512-PgidR3wST3dDYKr6b4pJoqQFpPGNKDSCDx4cZoshjXipw3LzO7mG1My2pwEzz2JVkF+inx3xRpDeQLFQGH/hsQ==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", "dev": true }, "react-remove-scroll": { @@ -42870,12 +38815,6 @@ "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", "dev": true }, - "replace-ext": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", - "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", - "dev": true - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -42942,9 +38881,9 @@ "dev": true }, "resolve-url-loader": { - "version": "5.0.0-beta.1", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0-beta.1.tgz", - "integrity": "sha512-MXkuP7+etG/4H96tZnbUOX9g2KiJaEpJ7cnjmVZUYkIXWKh2soTZF/ghczKVw/qKZZplDDjZwR5xGztD9ex7KQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", "dev": true, "requires": { "adjust-sourcemap-loader": "^4.0.0", @@ -42955,9 +38894,9 @@ }, "dependencies": { "postcss": { - "version": "8.4.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz", - "integrity": "sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, "requires": { "nanoid": "^3.3.4", @@ -42973,16 +38912,6 @@ } } }, - "responselike": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", - "integrity": "sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==", - "dev": true, - "optional": true, - "requires": { - "lowercase-keys": "^1.0.0" - } - }, "ret": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", @@ -43248,9 +39177,9 @@ } }, "sass": { - "version": "1.49.9", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", - "integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", + "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", "dev": true, "requires": { "chokidar": ">=3.0.0 <4.0.0", @@ -43294,25 +39223,6 @@ "ajv-keywords": "^3.5.2" } }, - "seek-bzip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", - "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", - "dev": true, - "optional": true, - "requires": { - "commander": "^2.8.1" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true - } - } - }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -43333,32 +39243,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" }, - "semver-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz", - "integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==", - "dev": true, - "optional": true - }, - "semver-truncate": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/semver-truncate/-/semver-truncate-1.1.2.tgz", - "integrity": "sha512-V1fGg9i4CL3qesB6U0L6XAm4xOJiHmt4QAacazumuasc03BvtFGIMCduv01JWQ69Nv+JST9TqhSCiJoxoY031w==", - "dev": true, - "optional": true, - "requires": { - "semver": "^5.3.0" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, - "optional": true - } - } - }, "send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -43552,6 +39436,58 @@ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, + "sharp": { + "version": "0.31.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.31.3.tgz", + "integrity": "sha512-XcR4+FCLBFKw1bdB+GEhnUNXNXvnt0tDo4WsBsraKymuo/IAuPuCBVAL2wIkUw2r/dwFW5Q5+g66Kwl2dgDFVg==", + "dev": true, + "requires": { + "color": "^4.2.3", + "detect-libc": "^2.0.1", + "node-addon-api": "^5.0.0", + "prebuild-install": "^7.1.1", + "semver": "^7.3.8", + "simple-get": "^4.0.1", + "tar-fs": "^2.1.1", + "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "requires": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -43596,6 +39532,23 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "requires": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -43844,20 +39797,11 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", + "peer": true, "requires": { "is-plain-obj": "^1.0.0" } }, - "sort-keys-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", - "integrity": "sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==", - "dev": true, - "optional": true, - "requires": { - "sort-keys": "^1.0.0" - } - }, "source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -44156,7 +40100,8 @@ "strict-uri-encode": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", - "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==" + "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", + "peer": true }, "string_decoder": { "version": "1.1.1", @@ -44266,16 +40211,6 @@ "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true }, - "strip-dirs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", - "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", - "dev": true, - "optional": true, - "requires": { - "is-natural-number": "^4.0.1" - } - }, "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -44300,32 +40235,12 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, - "strip-outer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", - "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", - "dev": true, - "optional": true, - "requires": { - "escape-string-regexp": "^1.0.2" - } - }, - "strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "dev": true, - "optional": true - }, "style-loader": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", - "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", + "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", "dev": true, - "requires": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - } + "requires": {} }, "style-to-object": { "version": "0.3.0", @@ -44514,46 +40429,41 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" }, - "tar-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", - "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", "dev": true, - "optional": true, "requires": { - "bl": "^1.0.0", - "buffer-alloc": "^1.2.0", - "end-of-stream": "^1.0.0", - "fs-constants": "^1.0.0", - "readable-stream": "^2.3.0", - "to-buffer": "^1.1.1", - "xtend": "^4.0.0" + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" } }, - "temp-dir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", - "dev": true, - "optional": true - }, - "tempfile": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz", - "integrity": "sha512-ZOn6nJUgvgC09+doCEF3oB+r3ag7kUvlsXEGX069QRD60p+P3uP7XG9N2/at+EyIRGSN//ZY3LyEotA1YpmjuA==", + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, - "optional": true, "requires": { - "temp-dir": "^1.0.0", - "uuid": "^3.0.1" + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" }, "dependencies": { - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, - "optional": true + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } } } }, @@ -44668,13 +40578,6 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "dev": true }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "optional": true - }, "thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -44686,13 +40589,6 @@ "resolved": "https://registry.npmjs.org/timeago.js/-/timeago.js-4.0.2.tgz", "integrity": "sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==" }, - "timed-out": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", - "integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==", - "dev": true, - "optional": true - }, "tiny-invariant": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", @@ -44709,13 +40605,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "to-buffer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", - "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", - "dev": true, - "optional": true - }, "to-camel-case": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-camel-case/-/to-camel-case-1.0.0.tgz", @@ -44826,16 +40715,6 @@ "punycode": "^2.1.1" } }, - "trim-repeated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", - "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", - "dev": true, - "optional": true, - "requires": { - "escape-string-regexp": "^1.0.2" - } - }, "trough": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", @@ -44854,9 +40733,9 @@ }, "dependencies": { "json5": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", - "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "requires": { "minimist": "^1.2.0" @@ -44880,7 +40759,6 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.0.1" } @@ -44941,17 +40819,6 @@ "which-boxed-primitive": "^1.0.2" } }, - "unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dev": true, - "optional": true, - "requires": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "uncontrollable": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", @@ -45210,23 +41077,6 @@ "requires-port": "^1.0.0" } }, - "url-parse-lax": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", - "integrity": "sha512-BVA4lR5PIviy2PMseNd2jbFQ+jwSwQGdJejf5ctd1rEXt0Ypd7yanUK9+lYechVlN5VaTJGsu2U/3MDDu6KgBA==", - "dev": true, - "optional": true, - "requires": { - "prepend-http": "^1.0.1" - } - }, - "url-to-options": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", - "integrity": "sha512-0kQLIzG4fdk/G5NONku64rSH/x32NOA39LVQqlK8Le6lvTF6GGRJpqaQFGgU+CLwySIqBSMdwYM0sYcW9f6P4A==", - "dev": true, - "optional": true - }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -45288,12 +41138,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" }, - "v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, "v8-to-istanbul": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz", @@ -45424,7 +41268,6 @@ "version": "5.75.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", - "peer": true, "requires": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^0.0.51", @@ -45455,14 +41298,12 @@ "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "peer": true + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" }, "webpack-sources": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "peer": true + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==" } } }, @@ -45613,9 +41454,9 @@ }, "dependencies": { "ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -45697,9 +41538,9 @@ }, "dependencies": { "ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -45828,9 +41669,9 @@ } }, "ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", "dev": true, "requires": {} } @@ -46030,11 +41871,10 @@ "dev": true }, "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true, - "optional": true + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "yaml": { "version": "1.10.2", @@ -46063,17 +41903,6 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true }, - "yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "optional": true, - "requires": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index bdedd8c414..53d7cd6211 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ }, "devDependencies": { "@edx/browserslist-config": "1.0.0", - "@edx/frontend-build": "^12.3.0", + "@edx/frontend-build": "^12.5.0", "@faker-js/faker": "^7.6.0", "@testing-library/dom": "7.31.2", "@testing-library/jest-dom": "5.11.9", diff --git a/src/components/forms/FormContext.tsx b/src/components/forms/FormContext.tsx new file mode 100644 index 0000000000..3d72ab88fb --- /dev/null +++ b/src/components/forms/FormContext.tsx @@ -0,0 +1,53 @@ +import React, { + createContext, + useContext, + Context, + ReactNode, + Dispatch, +} from "react"; +import type { FormActionArguments } from "./data/actions"; +import type { FormWorkflowStep } from "./FormWorkflow"; + +export type FormFields = { [name: string]: any }; +export type FormValidatorResult = false | string; +export type FormValidator = (formFields: FormFields) => FormValidatorResult; +export type FormFieldValidation = { + formFieldId: string; + validator: FormValidator; +}; + +export type FormContext = { + dispatch?: Dispatch; + formFields?: FormFields; + isEdited?: boolean; + hasErrors?: boolean; + errorMap?: { [name: string]: string[] }; + currentStep?: FormWorkflowStep; +}; + +export const FormContextObject: Context = createContext({}); + +export function useFormContext(): FormContext { + return useContext(FormContextObject); +} + +type FormContextProps = { + children: ReactNode; + formContext: FormContext; + dispatch?: Dispatch; +}; + +// Context wrapper for all form components +const FormContextProvider = ({ + children, + dispatch, + formContext, +}: FormContextProps) => { + return ( + + {children} + + ); +}; + +export default FormContextProvider; diff --git a/src/components/forms/FormContextWrapper.tsx b/src/components/forms/FormContextWrapper.tsx new file mode 100644 index 0000000000..392aaeac1e --- /dev/null +++ b/src/components/forms/FormContextWrapper.tsx @@ -0,0 +1,45 @@ +import React, { useReducer } from "react"; +// @ts-ignore +import FormContextProvider from "./FormContext.tsx"; +import type { FormFields } from "./FormContext"; +// @ts-ignore +import FormWorkflow from "./FormWorkflow.tsx"; +import type { FormWorkflowProps } from "./FormWorkflow"; +// @ts-ignore +import FormReducer, { initializeForm } from "./data/reducer.ts"; +import type { FormActionArguments } from "./data/actions"; + +// Context wrapper for multi-step form container +function FormContextWrapper({ + formWorkflowConfig, + onClickOut, + onSubmit, + formData, +}: FormWorkflowProps) { + const [formFieldsState, dispatch] = useReducer< + FormReducer, + FormActionArguments + >( + FormReducer, + initializeForm( + {}, + { + formFields: formData as FormFields, + currentStep: formWorkflowConfig.getCurrentStep(), + } + ), + initializeForm + ); + return ( + + + + ); +} + +export default FormContextWrapper; diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx new file mode 100644 index 0000000000..559fdc6b0f --- /dev/null +++ b/src/components/forms/FormWorkflow.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import type { SyntheticEvent, Dispatch } from "react"; +import { Button, Form, Stepper, useToggle } from "@edx/paragon"; + +import ConfigError from "../settings/ConfigError"; +// @ts-ignore +import { useFormContext } from "./FormContext.tsx"; +import type { + FormFields, + FormFieldValidation, + FormContext, +} from "./FormContext"; +// @ts-ignore +import { setStepAction } from "./data/actions.ts"; +import { SUBMIT_TOAST_MESSAGE } from "../settings/data/constants"; +import { FormActionArguments } from "./data/actions"; +// @ts-ignore +import UnsavedChangesModal from "../settings/SettingsLMSTab/UnsavedChangesModal.tsx"; + +export type FormWorkflowButtonConfig = { + buttonText: string; + onClick: (formFields: FormData) => Promise; +}; + +type DynamicComponent = React.FunctionComponent | React.ComponentClass; + +export type FormWorkflowStep = { + index: number; + stepName: string; + formComponent: DynamicComponent; + validations: FormFieldValidation[]; + saveChanges: (FormData) => Promise; + nextButtonConfig: FormWorkflowButtonConfig; +}; + +export type FormWorkflowConfig = { + steps: FormWorkflowStep[]; + getCurrentStep: () => FormWorkflowStep; +}; + +export type FormWorkflowProps = { + formWorkflowConfig: FormWorkflowConfig; + onClickOut: (edited: boolean, msg?: string) => null; + formData: FormData; + dispatch: Dispatch; +}; + +// Modal container for multi-step forms +function FormWorkflow({ + formWorkflowConfig, + onClickOut, + dispatch, +}: FormWorkflowProps) { + const { + formFields, + currentStep: step, + hasErrors, + isEdited, + }: FormContext = useFormContext(); + const [errorIsOpen, openError, closeError] = useToggle(false); + const [savedChangesModalIsOpen, openModal, closeModal] = useToggle(false); + + const onCancel = () => { + if (isEdited) { + openModal(); + } else { + onClickOut(false); + } + }; + + const onNext = async (event: SyntheticEvent) => { + await step?.nextButtonConfig.onClick(formFields as FormFields); + const nextStep: number = step.index + 1; + if (nextStep < formWorkflowConfig.steps.length) { + dispatch(setStepAction({ step: formWorkflowConfig.steps[nextStep] })); + } else { + // TODO: Fix + onClickOut(true, SUBMIT_TOAST_MESSAGE); + } + }; + + const stepBody = (step: FormWorkflowStep) => { + const FormComponent: DynamicComponent = step?.formComponent; + return ( + + + {step && step?.formComponent && ( + <> + + + )} + + + ); + }; + + const stepActionRow = (step: FormWorkflowStep) => { + return ( + + + {/* TODO: Help Link */} + {/* TODO: Fix typescript issue with Paragon Button */} + { + // @ts-ignore + + } + {step.nextButtonConfig && ( + // @ts-ignore + + )} + + + ); + }; + + return ( + <> + + onClickOut(false)} + saveDraft={async () => { + await step?.saveChanges(formFields as FormData); + onClickOut(true, SUBMIT_TOAST_MESSAGE); + }} + /> + + {formWorkflowConfig.steps && ( + + + {formWorkflowConfig.steps.map((stepConfig) => stepBody(stepConfig))} + {formWorkflowConfig.steps.map((stepConfig) => + stepActionRow(stepConfig) + )} + + )} + + ); +} + +export default FormWorkflow; diff --git a/src/components/forms/ValidatedFormControl.tsx b/src/components/forms/ValidatedFormControl.tsx new file mode 100644 index 0000000000..ce8f6af653 --- /dev/null +++ b/src/components/forms/ValidatedFormControl.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import omit from "lodash/omit"; + +import { Form } from "@edx/paragon"; + +// @ts-ignore +import { setFormFieldAction } from "./data/actions.ts"; +// @ts-ignore +import { useFormContext } from "./FormContext.tsx"; + +// TODO: Add Form.Control props. Does Paragon export? +type InheritedParagonControlProps = { + className?: string; + type: string; + maxLength?: number; + floatingLabel: string; +}; + +type ValidatedFormControlProps = { + // Field id, required to map to field in FormContext + formId: string; + // Inline Instructions inside form field when blank + fieldInstructions?: string; +} & InheritedParagonControlProps; + +// Control that reads from/writes to form context store +const ValidatedFormControl = (props: ValidatedFormControlProps) => { + const { formFields, errorMap, dispatch } = useFormContext(); + const onChange = (e: React.ChangeEvent) => { + dispatch && dispatch( + setFormFieldAction({ fieldId: props.formId, value: e.target.value }) + ); + }; + const error = errorMap && errorMap[props.formId]; + const formControlProps = { + ...omit(props, ["formId"]), + onChange, + isInvalid: !!error, + id: props.formId, + value: formFields && formFields[props.formId], + }; + return ( + <> + + {props.fieldInstructions && ( + {props.fieldInstructions} + )} + {error && ( + {error} + )} + + ); +}; + +export default ValidatedFormControl; diff --git a/src/components/forms/data/actions.ts b/src/components/forms/data/actions.ts new file mode 100644 index 0000000000..95654cd33a --- /dev/null +++ b/src/components/forms/data/actions.ts @@ -0,0 +1,32 @@ +import type { FormWorkflowStep } from "../FormWorkflow"; + +export type FormActionArguments = { + type?: string; +}; + +export const SET_FORM_FIELD = "SET FORM FIELD"; +export type SetFormFieldArguments = { + // Id of form field + fieldId: string; + // Value to set form field to + value: any; +} & FormActionArguments; +// Construct action for setting a form field value +export const setFormFieldAction = ({ + fieldId, + value, +}: SetFormFieldArguments) => ({ + type: SET_FORM_FIELD, + fieldId, + value, +}); + +export const SET_STEP = "SET STEP"; +export type SetStepArguments = { + step: FormWorkflowStep; +} & FormActionArguments; +// Construct action for setting a form field value +export const setStepAction = ({ step }: SetStepArguments) => ({ + type: SET_STEP, + step, +}); diff --git a/src/components/forms/data/reducer.ts b/src/components/forms/data/reducer.ts new file mode 100644 index 0000000000..ea6523ee7c --- /dev/null +++ b/src/components/forms/data/reducer.ts @@ -0,0 +1,85 @@ +import groupBy from "lodash/groupBy"; +import isEmpty from "lodash/isEmpty"; +// @ts-ignore +import { SET_FORM_FIELD, SET_STEP } from "./actions.ts"; +import type { FormActionArguments, SetFormFieldArguments, SetStepArguments } from "./actions"; +import type { + FormContext, + FormFields, + FormFieldValidation, +} from "../FormContext"; +import type { FormWorkflowStep } from "../FormWorkflow"; + +export type InitializeFormArguments = { + formFields: FormFields; + validations: FormFieldValidation[]; + currentStep: FormWorkflowStep +}; +export const initializeForm = ( + state: FormContext, + action: InitializeFormArguments +) => { + const additions: Pick = { isEdited: false }; + if (action?.formFields) { + additions.formFields = action.formFields; + } + if (action?.currentStep) { + additions.currentStep = action.currentStep; + } + return { + ...state, + ...additions + }; +}; + +const processFormErrors = (state: FormContext): FormContext => { + // Get all form errors + // TODO: Get validations from step + let errorState: Pick = { + hasErrors: false, + errorMap: {}, + }; + if (state.formFields) { + const errors = state.currentStep?.validations + ?.map((validation: FormFieldValidation) => [ + validation.formFieldId, + state.formFields && validation.validator(state.formFields), + ]) + .filter((err) => !!err[1]); + if (!isEmpty(errors)) { + // Generate index of errors + errorState = { + hasErrors: true, + errorMap: groupBy(errors, (error) => error[0]), + }; + } + } + + return { + ...state, + ...errorState, + }; +}; + +const FormReducer = (state: FormContext = {formFields: {}}, action: FormActionArguments) => { + switch (action.type) { + case SET_FORM_FIELD: + const setFormFieldArgs = action as SetFormFieldArguments; + let newState = state ? { ...state } : { formFields: {} }; + if (newState.formFields) { + newState.formFields[setFormFieldArgs.fieldId] = setFormFieldArgs.value; + } + newState = processFormErrors({ + ...state, + isEdited: true, + }); + return newState; + case SET_STEP: + const setStepArgs = action as SetStepArguments; + return {...state, currentStep: setStepArgs.step}; + default: + return state; + } +}; + +export default FormReducer; diff --git a/src/components/settings/SettingsLMSTab/ConfigModal.jsx b/src/components/settings/SettingsLMSTab/ConfigModal.jsx index e28e049684..fcc4fbbbef 100644 --- a/src/components/settings/SettingsLMSTab/ConfigModal.jsx +++ b/src/components/settings/SettingsLMSTab/ConfigModal.jsx @@ -7,6 +7,7 @@ const MODAL_TEXT = 'Your changes will be lost without saving.'; // will have to pass in individual saveDraft method and config when // drafting is allowed +// TODO: Delete once UnsavedChangesModal fully replaces it const ConfigModal = ({ isOpen, close, onClick, saveDraft, }) => ( diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 28852c04c6..674b924294 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -14,13 +14,20 @@ import { SAP_TYPE, } from '../data/constants'; import BlackboardConfig from './LMSConfigs/BlackboardConfig'; -import CanvasConfig from './LMSConfigs/CanvasConfig'; +import { CanvasFormConfig } from './LMSConfigs/Canvas/CanvasConfig.tsx'; import CornerstoneConfig from './LMSConfigs/CornerstoneConfig'; import DegreedConfig from './LMSConfigs/DegreedConfig'; import Degreed2Config from './LMSConfigs/Degreed2Config'; import MoodleConfig from './LMSConfigs/MoodleConfig'; import SAPConfig from './LMSConfigs/SAPConfig'; +import FormContextWrapper from '../../forms/FormContextWrapper.tsx'; +// TODO: Add remaining configs +const flowConfigs = { + [CANVAS_TYPE]: CanvasFormConfig, +}; + +// TODO: Convert to TypeScript const LMSConfigPage = ({ LMSType, onClick, @@ -28,13 +35,20 @@ const LMSConfigPage = ({ existingConfigFormData, existingConfigs, setExistingConfigFormData, -}) => ( - -

- - Connect {channelMapping[LMSType].displayName} -

- {LMSType === BLACKBOARD_TYPE && ( +}) => { + const handleCloseWorkflow = (submitted, msg) => { + onClick(submitted ? msg : ''); + }; + return ( + +

+ + + Connect {channelMapping[LMSType].displayName} + +

+ {/* TODO: Replace giant switch */} + {LMSType === BLACKBOARD_TYPE && ( - )} - {LMSType === CANVAS_TYPE && ( - - )} - {LMSType === CORNERSTONE_TYPE && ( + )} + {LMSType === CORNERSTONE_TYPE && ( - )} - {LMSType === DEGREED2_TYPE && ( + )} + {LMSType === DEGREED2_TYPE && ( - )} - {LMSType === DEGREED_TYPE && ( + )} + {LMSType === DEGREED_TYPE && ( - )} - {LMSType === MOODLE_TYPE && ( + )} + {LMSType === MOODLE_TYPE && ( - )} - {LMSType === SAP_TYPE && ( + )} + {LMSType === SAP_TYPE && ( - )} -
-); - + )} +
+ ); +}; const mapStateToProps = (state) => ({ enterpriseCustomerUuid: state.portalConfiguration.enterpriseId, }); diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx new file mode 100644 index 0000000000..fa1a97702c --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx @@ -0,0 +1,150 @@ +import isEmpty from "lodash/isEmpty"; + +import handleErrors from "../../../utils"; +import LmsApiService from "../../../../../data/services/LmsApiService"; +import { snakeCaseDict } from "../../../../../utils"; +import { SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +// @ts-ignore +import CanvasConfigActivatePage from "./CanvasConfigActivatePage.tsx"; +import CanvasConfigAuthorizePage, { + validations, + // @ts-ignore +} from "./CanvasConfigAuthorizePage.tsx"; +import type { FormWorkflowConfig, FormWorkflowStep } from "../../../../forms/FormWorkflow"; + +export type CanvasConfigCamelCase = { + canvasAccountId: string; + canvasBaseUrl: string; + displayName: string; + clientId: string; + clientSecret: string; + id: string; + active: boolean; + uuid: string; +}; + +// TODO: Can we generate this dynamically? +// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html +export type CanvasConfigSnakeCase = { + canvas_account_id: string; + canvas_base_url: string; + display_name: string; + client_id: string; + client_secret: string; + id: string; + active: boolean; + uuid: string; + enterprise_customer: string; +}; + +// TODO: Make this a generic type usable by all lms configs +export type CanvasFormConfigProps = { + enterpriseCustomerUuid: string; + existingData: CanvasConfigCamelCase; + onSubmit: (canvasConfig: CanvasConfigCamelCase) => CanvasConfigSnakeCase; + onClickCancel: (submitted: boolean, status: string) => Promise; +}; + +export const CanvasFormConfig = ({ + enterpriseCustomerUuid, + onSubmit, + onClickCancel, + existingData, +}: CanvasFormConfigProps): FormWorkflowConfig => { + const saveChanges = async (formFields: CanvasConfigCamelCase) => { + const transformedConfig: CanvasConfigSnakeCase = snakeCaseDict( + formFields + ) as CanvasConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err; + + if (!isEmpty(existingData)) { + try { + transformedConfig.active = existingData.active; + await LmsApiService.updateCanvasConfig( + transformedConfig, + existingData.id + ); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } else { + // TODO: Don't expose option if object not created yet + } + }; + + const handleSubmit = async ( + formFields: CanvasConfigCamelCase + ) => { + const transformedConfig: CanvasConfigSnakeCase = snakeCaseDict( + formFields + ) as CanvasConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err; + + if (formFields.id) { + try { + transformedConfig.active = existingData.active; + await LmsApiService.updateCanvasConfig( + transformedConfig, + existingData.id + ); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + await LmsApiService.postNewCanvasConfig(transformedConfig); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } + if (err) { + // TODO: Do something to open error model elsewhere? + // openError(); + } else { + // TODO: Make this happen on final step + // onClickCancel(SUBMIT_TOAST_MESSAGE); + } + }; + + // TODO: Fix handleSubmit + const steps: FormWorkflowStep[] = [ + { + index: 0, + formComponent: CanvasConfigAuthorizePage, + validations, + stepName: "Authorize", + saveChanges, + nextButtonConfig: { + buttonText: "Authorize", + onClick: handleSubmit, + }, + }, + { + index: 1, + formComponent: CanvasConfigActivatePage, + validations: [], + stepName: "Activate", + saveChanges, + nextButtonConfig: { + buttonText: "Activate", + onClick: () => onClickCancel(true, SUBMIT_TOAST_MESSAGE), + }, + }, + ]; + + // Go to authorize step for now + const getCurrentStep = () => steps[0]; + + return { + getCurrentStep, + steps, + }; +}; + +export default CanvasFormConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx new file mode 100644 index 0000000000..6412f00f77 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Form } from '@edx/paragon'; + +// Page 3 of Canvas LMS config workflow +const CanvasConfigActivatePage = () => ( + +
+

Activate your Canvas integration

+ +

+ Your Canvas integration has been successfully authorized and is ready to + activate! +

+ +

+ Once activated, edX For Business will begin syncing content metadata and + learner activity with Canvas. +

+
+
+); + +export default CanvasConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx new file mode 100644 index 0000000000..abce91b513 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx @@ -0,0 +1,98 @@ +import React from "react"; + +import { Form } from "@edx/paragon"; + +// @ts-ignore +import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; +import { urlValidation } from "../../../../../utils"; +import type { FormFieldValidation } from "../../../../forms/FormContext"; + +const formFieldNames = { + DISPLAY_NAME: "displayName", + CLIENT_ID: "clientId", + CLIENT_SECRET: "clientSecret", + ACCOUNT_ID: "canvasAccountId", + CANVAS_BASE_URL: "canvasBaseUrl", +}; + +export const validations: FormFieldValidation[] = [ + { + formFieldId: formFieldNames.CANVAS_BASE_URL, + validator: (fields) => { + const error = !urlValidation(fields[formFieldNames.CANVAS_BASE_URL]); + return error && "Please enter a valid URL"; + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + // TODO: Check for duplicate display names + const displayName = fields[formFieldNames.DISPLAY_NAME]; + const error = displayName?.length > 20; + return error && "Display Name should be 20 characters or less"; + }, + }, +]; + +// Page 2 of Canvas LMS config workflow +const CanvasConfigAuthorizePage = () => ( + +

Authorize connection to Canvas

+ +
+ + + + + + + + + + + + + + + + {/* TODO: Style panel */} +
+

Action in Canvas required to complete authorization

+ Advancing to the next step will open a new window to complete the + authorization process in Canvas. Return to this window following + authorization to finish configuring your new integration. +
+
+
+); + +export default CanvasConfigAuthorizePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx deleted file mode 100644 index 6994e5cb8d..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/CanvasConfig.jsx +++ /dev/null @@ -1,347 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Button, Form, useToggle } from '@edx/paragon'; -import { Error } from '@edx/paragon/icons'; -import isEmpty from 'lodash/isEmpty'; -import buttonBool, { isExistingConfig } from '../utils'; -import handleErrors from '../../utils'; - -import LmsApiService from '../../../../data/services/LmsApiService'; -import { useTimeout, useInterval } from '../../../../data/hooks'; -import { snakeCaseDict, urlValidation } from '../../../../utils'; -import ConfigError from '../../ConfigError'; -import ConfigModal from '../ConfigModal'; -import { - CANVAS_OAUTH_REDIRECT_URL, - INVALID_LINK, - INVALID_NAME, - SUBMIT_TOAST_MESSAGE, - LMS_CONFIG_OAUTH_POLLING_INTERVAL, - LMS_CONFIG_OAUTH_POLLING_TIMEOUT, -} from '../../data/constants'; - -const CanvasConfig = ({ - enterpriseCustomerUuid, onClick, existingData, existingConfigs, setExistingConfigFormData, -}) => { - const [displayName, setDisplayName] = React.useState(''); - const [nameValid, setNameValid] = React.useState(true); - const [clientId, setClientId] = React.useState(''); - const [clientSecret, setClientSecret] = React.useState(''); - const [canvasAccountId, setCanvasAccountId] = React.useState(''); - const [canvasBaseUrl, setCanvasBaseUrl] = React.useState(''); - const [urlValid, setUrlValid] = React.useState(true); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [modalIsOpen, openModal, closeModal] = useToggle(false); - const [edited, setEdited] = React.useState(false); - const [authorized, setAuthorized] = React.useState(false); - const [oauthPollingInterval, setOauthPollingInterval] = React.useState(null); - const [oauthPollingTimeout, setOauthPollingTimeout] = React.useState(null); - const [oauthTimeout, setOauthTimeout] = React.useState(false); - const [configId, setConfigId] = React.useState(); - const config = { - displayName, - clientId, - clientSecret, - canvasAccountId, - canvasBaseUrl, - }; - - // Polling method to determine if the user has authorized their config - useInterval(async () => { - if (configId) { - let err; - try { - const response = await LmsApiService.fetchSingleCanvasConfig(configId); - if (response.data.refresh_token) { - // Config has been authorized - setAuthorized(true); - // Stop both the backend polling and the timeout timer - setOauthPollingInterval(null); - setOauthPollingTimeout(null); - setOauthTimeout(false); - // trigger a success call which will redirect the user back to the landing page - onClick(SUBMIT_TOAST_MESSAGE); - } - } catch (error) { - err = handleErrors(error); - } - if (err) { - openError(); - } - } - }, oauthPollingInterval); - - // Polling timeout which stops the requests to LMS and toggles the timeout alert - useTimeout(async () => { - setOauthTimeout(true); - setOauthPollingInterval(null); - }, oauthPollingTimeout); - - useEffect(() => { - setDisplayName(existingData.displayName); - setClientId(existingData.clientId); - setClientSecret(existingData.clientSecret); - setCanvasAccountId(existingData.canvasAccountId); - setCanvasBaseUrl(existingData.canvasBaseUrl); - // Check if the config has been authorized - if (existingData.refreshToken) { - setAuthorized(true); - } - }, [existingData]); - - // Cancel button onclick - const handleCancel = () => { - if (edited) { - openModal(); - } else { - onClick(''); - } - }; - - const formatConfigResponseData = (responseData) => { - const formattedConfig = {}; - formattedConfig.canvasAccountId = responseData.canvas_account_id; - formattedConfig.canvasBaseUrl = responseData.canvas_base_url; - formattedConfig.displayName = responseData.display_name; - formattedConfig.clientId = responseData.client_id; - formattedConfig.clientSecret = responseData.client_secret; - formattedConfig.id = responseData.id; - formattedConfig.active = responseData.active; - formattedConfig.uuid = responseData.uuid; - return formattedConfig; - }; - - const handleAuthorization = async (event) => { - event.preventDefault(); - const transformedConfig = snakeCaseDict(config); - - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - let fetchedConfigId; - let fetchedConfigUuid; - // First either submit the new config or update the existing one before attempting to authorize - // If the config exists but has been edited, update it - if (!isEmpty(existingData) && edited) { - try { - transformedConfig.active = existingData.active; - const response = await LmsApiService.updateCanvasConfig(transformedConfig, existingData.id); - fetchedConfigUuid = response.data.uuid; - fetchedConfigId = response.data.id; - setExistingConfigFormData(formatConfigResponseData(response.data)); - } catch (error) { - err = handleErrors(error); - } - // If the config didn't previously exist, create it - } else if (isEmpty(existingData)) { - try { - transformedConfig.active = false; - const response = await LmsApiService.postNewCanvasConfig(transformedConfig); - fetchedConfigUuid = response.data.uuid; - fetchedConfigId = response.data.id; - setExistingConfigFormData(formatConfigResponseData(response.data)); - } catch (error) { - err = handleErrors(error); - } - // else we can retrieve the unedited, existing form's UUID and ID - } else { - fetchedConfigUuid = existingData.uuid; - fetchedConfigId = existingData.id; - } - if (err) { - openError(); - } else { - setConfigId(fetchedConfigId); - // Reset config polling timeout - setOauthTimeout(false); - // Start the config polling - setOauthPollingInterval(LMS_CONFIG_OAUTH_POLLING_INTERVAL); - // Start the polling timeout timer - setOauthPollingTimeout(LMS_CONFIG_OAUTH_POLLING_TIMEOUT); - - const oauthUrl = `${canvasBaseUrl}/login/oauth2/auth?client_id=${clientId}&` - + `state=${fetchedConfigUuid}&response_type=code&` - + `redirect_uri=${CANVAS_OAUTH_REDIRECT_URL}`; - - // Open the oauth window for the user - window.open(oauthUrl); - } - }; - - const handleSubmit = async (event) => { - event.preventDefault(); - const transformedConfig = snakeCaseDict(config); - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - - if (!isEmpty(existingData) || configId) { - try { - transformedConfig.active = existingData.active; - await LmsApiService.updateCanvasConfig(transformedConfig, existingData.id); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewCanvasConfig(transformedConfig); - } catch (error) { - err = handleErrors(error); - } - } - if (err) { - openError(); - } else { - onClick(SUBMIT_TOAST_MESSAGE); - } - }; - - const validateField = useCallback((field, input) => { - switch (field) { - case 'Canvas Base URL': - setCanvasBaseUrl(input); - setUrlValid(urlValidation(input) || input?.length === 0); - break; - case 'Display Name': - setDisplayName(input); - if (isExistingConfig(existingConfigs, input, existingData.displayName)) { - setNameValid(input?.length <= 20); - } else { - setNameValid(input?.length <= 20 && !Object.values(existingConfigs).includes(input)); - } - break; - default: - break; - } - }, [existingConfigs, existingData.displayName]); - - useEffect(() => { - if (!isEmpty(existingData)) { - validateField('Canvas Base URL', existingData.canvasBaseUrl); - validateField('Display Name', existingData.displayName); - } - }, [existingConfigs, existingData, validateField]); - - return ( - - - -
- - { - setEdited(true); - validateField('Display Name', e.target.value); - }} - floatingLabel="Display Name" - defaultValue={existingData.displayName} - /> - Create a custom name for this LMS. - {!nameValid && ( - - {INVALID_NAME} - - )} - - - { - setAuthorized(false); - setEdited(true); - setClientId(e.target.value); - }} - floatingLabel="API Client ID" - defaultValue={existingData.clientId} - /> - - - { - setAuthorized(false); - setEdited(true); - setClientSecret(e.target.value); - }} - floatingLabel="API Client Secret" - defaultValue={existingData.clientSecret} - /> - - - { - setEdited(true); - setAuthorized(false); - setCanvasAccountId(e.target.value); - }} - floatingLabel="Canvas Account Number" - defaultValue={existingData.canvasAccountId} - /> - - - { - setEdited(true); - setAuthorized(false); - validateField('Canvas Base URL', e.target.value); - }} - floatingLabel="Canvas Base URL" - defaultValue={existingData.canvasBaseUrl} - /> - {!urlValid && ( - - {INVALID_LINK} - - )} - - {oauthTimeout && ( -
- - We were unable to confirm your authorization. Please return to your LMS to authorize edX as an integration. -
- )} - - - {!authorized && ( - - )} - -
-
- ); -}; - -CanvasConfig.propTypes = { - enterpriseCustomerUuid: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - existingData: PropTypes.shape({ - active: PropTypes.bool, - displayName: PropTypes.string, - clientId: PropTypes.string, - canvasAccountId: PropTypes.number, - id: PropTypes.number, - clientSecret: PropTypes.string, - canvasBaseUrl: PropTypes.string, - refreshToken: PropTypes.string, - uuid: PropTypes.string, - }).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string).isRequired, - setExistingConfigFormData: PropTypes.func.isRequired, -}; -export default CanvasConfig; diff --git a/src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx b/src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx new file mode 100644 index 0000000000..054a1ac889 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { ModalDialog, ActionRow, Button } from "@edx/paragon"; + +const MODAL_TITLE = "Exit configuration"; +const MODAL_TEXT = + "Your configuration data will be saved under your Learning Platform settings"; + +type UnsavedChangesModalProps = { + isOpen: boolean; + close: () => void; + exitWithoutSaving: () => void; + saveDraft: () => void +}; + +// will have to pass in individual saveDraft method and config when +// drafting is allowed +const UnsavedChangesModal = ({ + isOpen, + close, + exitWithoutSaving, + saveDraft, +}: UnsavedChangesModalProps) => ( + + + {MODAL_TITLE} + + {MODAL_TEXT} + + + {/* TODO: Fix typescript issue with Paragon Button */} + {/* @ts-ignore */} + + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + + +); + +export default UnsavedChangesModal; diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index b5c809a992..8dad0cb213 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -82,6 +82,7 @@ const SettingsLMSTab = ({ }); }, [enterpriseId]); + // TODO: Rewrite with more descriptive parameters once all lms configs are refactored const onClick = (input) => { // Either we're creating a new config (a create config card was clicked), or we're navigating // back to the landing state from a form (submit or cancel was hit on the forms). In both cases, diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx index 9229082622..2d92177709 100644 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx @@ -5,7 +5,8 @@ import { import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; -import CanvasConfig from '../LMSConfigs/CanvasConfig'; +// TODO: Rewrite for new Canvas workflow +import CanvasConfig from '../LMSConfigs/Canvas/CanvasConfig.tsx'; import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..bfec231544 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "lib": ["dom", "es6", "dom.iterable"], + "isolatedModules": true, + "module": "ES6", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": false, + "noImplicitThis": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "strictFunctionTypes": false, + "target": "ES6", + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*", "__mocks__/**/*"], + "exclude": ["dist", "src/icons/*"] +} From a7299e981ca1df1a1eb535895f9019b5c357ee49 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Tue, 14 Feb 2023 19:37:48 +0000 Subject: [PATCH 55/73] merge: changes from master --- package-lock.json | 1318 +-------------------------------------------- 1 file changed, 22 insertions(+), 1296 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6da314ddcd..afbdfe7cf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7680,26 +7680,6 @@ "node": ">=0.10.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -7714,32 +7694,11 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/bin-check/node_modules/cross-spawn": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, + "extraneous": true, "dependencies": { "lru-cache": "^4.0.1", "shebang-command": "^1.2.0", @@ -7750,8 +7709,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, + "extraneous": true, "dependencies": { "cross-spawn": "^5.0.1", "get-stream": "^3.0.0", @@ -7769,8 +7727,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, + "extraneous": true, "engines": { "node": ">=4" } @@ -7779,8 +7736,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, + "extraneous": true, "dependencies": { "shebang-regex": "^1.0.0" }, @@ -7792,8 +7748,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true, + "extraneous": true, "engines": { "node": ">=0.10.0" } @@ -7802,8 +7757,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, + "extraneous": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7811,238 +7765,6 @@ "which": "bin/which" } }, - "node_modules/bin-version": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-3.1.0.tgz", - "integrity": "sha512-Mkfm4iE1VFt4xd4vH+gx+0/71esbfus2LsnCGe8Pi4mndSPyT+NGES/Eg99jx8/lUGWfu3z2yuB/bt5UB+iVbQ==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^1.0.0", - "find-versions": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-version-check": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/bin-version-check/-/bin-version-check-4.0.0.tgz", - "integrity": "sha512-sR631OrhC+1f8Cvs8WyVWOA33Y8tgwjETNPyyD/myRBXLkfS/vl74FmH/lFcRl9KY3zwGh7jFhvyk9vV3/3ilQ==", - "dev": true, - "optional": true, - "dependencies": { - "bin-version": "^3.0.0", - "semver": "^5.6.0", - "semver-truncate": "^1.1.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-version-check/node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, - "optional": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/bin-wrapper": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bin-wrapper/-/bin-wrapper-4.1.0.tgz", - "integrity": "sha512-hfRmo7hWIXPkbpi0ZltboCMVrU+0ClXR/JgbCKKjlDjQf6igXa7OwdqNcFWQZPZTgiY7ZpzE3+LjjkLiTN2T7Q==", - "dev": true, - "optional": true, - "dependencies": { - "bin-check": "^4.1.0", - "bin-version-check": "^4.0.0", - "download": "^7.1.0", - "import-lazy": "^3.1.0", - "os-filter-obj": "^2.0.0", - "pify": "^4.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/download": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/download/-/download-7.1.0.tgz", - "integrity": "sha512-xqnBTVd/E+GxJVrX5/eUJiLYjCGPwMpdL+jGhGU57BvtcA7wwhtHVbXBeUk51kOpW3S7Jn3BQbN9Q1R1Km2qDQ==", - "dev": true, - "optional": true, - "dependencies": { - "archive-type": "^4.0.0", - "caw": "^2.0.1", - "content-disposition": "^0.5.2", - "decompress": "^4.2.0", - "ext-name": "^5.0.0", - "file-type": "^8.1.0", - "filenamify": "^2.0.0", - "get-stream": "^3.0.0", - "got": "^8.3.1", - "make-dir": "^1.2.0", - "p-event": "^2.1.0", - "pify": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/download/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/file-type": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz", - "integrity": "sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/got": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/got/-/got-8.3.2.tgz", - "integrity": "sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw==", - "dev": true, - "optional": true, - "dependencies": { - "@sindresorhus/is": "^0.7.0", - "cacheable-request": "^2.1.1", - "decompress-response": "^3.3.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "into-stream": "^3.1.0", - "is-retry-allowed": "^1.1.0", - "isurl": "^1.0.0-alpha5", - "lowercase-keys": "^1.0.0", - "mimic-response": "^1.0.0", - "p-cancelable": "^0.4.0", - "p-timeout": "^2.0.1", - "pify": "^3.0.0", - "safe-buffer": "^5.1.1", - "timed-out": "^4.0.1", - "url-parse-lax": "^3.0.0", - "url-to-options": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/got/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/make-dir/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/p-cancelable": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", - "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/p-event": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-2.3.1.tgz", - "integrity": "sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==", - "dev": true, - "optional": true, - "dependencies": { - "p-timeout": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/bin-wrapper/node_modules/p-timeout": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", - "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", - "dev": true, - "optional": true, - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/bin-wrapper/node_modules/url-parse-lax": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", - "integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==", - "dev": true, - "optional": true, - "dependencies": { - "prepend-http": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -8057,7 +7779,6 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", "dev": true, - "optional": true, "dependencies": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" @@ -8210,30 +7931,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -9393,7 +9090,6 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, - "optional": true, "dependencies": { "mimic-response": "^1.0.0" }, @@ -9401,139 +9097,11 @@ "node": ">=4" } }, - "node_modules/decompress-tar": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", - "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", - "dev": true, - "optional": true, - "dependencies": { - "file-type": "^5.2.0", - "is-stream": "^1.1.0", - "tar-stream": "^1.5.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tar/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tarbz2": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", - "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", - "dev": true, - "optional": true, - "dependencies": { - "decompress-tar": "^4.1.0", - "file-type": "^6.1.0", - "is-stream": "^1.1.0", - "seek-bzip": "^1.0.5", - "unbzip2-stream": "^1.0.9" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-tarbz2/node_modules/file-type": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", - "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", - "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", - "dev": true, - "optional": true, - "dependencies": { - "decompress-tar": "^4.1.1", - "file-type": "^5.2.0", - "is-stream": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-targz/node_modules/file-type": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", - "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", - "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", - "dev": true, - "optional": true, - "dependencies": { - "file-type": "^3.8.0", - "get-stream": "^2.2.0", - "pify": "^2.3.0", - "yauzl": "^2.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/decompress-unzip/node_modules/file-type": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", - "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress-unzip/node_modules/get-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", - "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", - "dev": true, - "optional": true, - "dependencies": { - "object-assign": "^4.0.1", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decompress-unzip/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decompress/node_modules/make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, + "extraneous": true, "dependencies": { "pify": "^3.0.0" }, @@ -9541,22 +9109,11 @@ "node": ">=4" } }, - "node_modules/decompress/node_modules/make-dir/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/decompress/node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "optional": true, + "extraneous": true, "engines": { "node": ">=0.10.0" } @@ -13028,26 +12585,6 @@ "node": ">=4" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -13088,43 +12625,11 @@ } } }, - "node_modules/image-minimizer-webpack-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/image-minimizer-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imagemin-mozjpeg/node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true, + "extraneous": true, "engines": { "node": ">=8.12.0" } @@ -13133,8 +12638,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true, + "extraneous": true, "engines": { "node": ">=8" }, @@ -13146,8 +12650,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, + "extraneous": true, "dependencies": { "path-key": "^3.0.0" }, @@ -13155,151 +12658,11 @@ "node": ">=8" } }, - "node_modules/imagemin-optipng": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/imagemin-optipng/-/imagemin-optipng-8.0.0.tgz", - "integrity": "sha512-CUGfhfwqlPjAC0rm8Fy+R2DJDBGjzy2SkfyT09L8rasnF9jSoHFqJ1xxSZWK6HVPZBMhGPMxCTL70OgTHlLF5A==", - "dev": true, - "optional": true, - "dependencies": { - "exec-buffer": "^3.0.0", - "is-png": "^2.0.0", - "optipng-bin": "^7.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/imagemin-pngquant": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/imagemin-pngquant/-/imagemin-pngquant-9.0.2.tgz", - "integrity": "sha512-cj//bKo8+Frd/DM8l6Pg9pws1pnDUjgb7ae++sUX1kUVdv2nrngPykhiUOgFeE0LGY/LmUbCf4egCHC4YUcZSg==", - "dev": true, - "optional": true, - "dependencies": { - "execa": "^4.0.0", - "is-png": "^2.0.0", - "is-stream": "^2.0.0", - "ow": "^0.17.0", - "pngquant-bin": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/imagemin-pngquant/node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/imagemin-pngquant/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imagemin-pngquant/node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/imagemin-pngquant/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imagemin-pngquant/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/imagemin-svgo": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/imagemin-svgo/-/imagemin-svgo-9.0.0.tgz", - "integrity": "sha512-uNgXpKHd99C0WODkrJ8OO/3zW3qjgS4pW7hcuII0RcHN3tnKxDjJWcitdVC/TZyfIqSricU8WfrHn26bdSW62g==", - "dev": true, - "optional": true, - "dependencies": { - "is-svg": "^4.2.1", - "svgo": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/imagemin-svgo?sponsor=1" - } - }, - "node_modules/imagemin-webp": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/imagemin-webp/-/imagemin-webp-7.0.0.tgz", - "integrity": "sha512-JoYjvHNgBLgrQAkeCO7T5iNc8XVpiBmMPZmiXMhalC7K6gwY/3DCEUfNxVPOmNJ+NIJlJFvzcMR9RBxIE74Xxw==", - "dev": true, - "optional": true, - "dependencies": { - "cwebp-bin": "^7.0.1", - "exec-buffer": "^3.2.0", - "is-cwebp-readable": "^3.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/imagemin/node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, + "extraneous": true, "engines": { "node": ">=8" } @@ -13308,7 +12671,7 @@ "version": "10.0.2", "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", - "dev": true, + "extraneous": true, "dependencies": { "@types/glob": "^7.1.1", "array-union": "^2.1.0", @@ -13327,7 +12690,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, + "extraneous": true, "dependencies": { "semver": "^6.0.0" }, @@ -13342,7 +12705,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, + "extraneous": true, "engines": { "node": ">=8" } @@ -30379,12 +29742,6 @@ } } }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true - }, "batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -30396,350 +29753,6 @@ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" }, - "bin-build": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bin-build/-/bin-build-3.0.0.tgz", - "integrity": "sha512-jcUOof71/TNAI2uM5uoUaDq2ePcVBQ3R/qhxAz1rX7UfvduAL/RXD3jXzvn8cVcDJdGVkiR1shal3OH0ImpuhA==", - "dev": true, - "optional": true, - "requires": { - "decompress": "^4.0.0", - "download": "^6.2.2", - "execa": "^0.7.0", - "p-map-series": "^1.0.0", - "tempfile": "^2.0.0" - }, - "dependencies": { - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "bin-check": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bin-check/-/bin-check-4.1.0.tgz", - "integrity": "sha512-b6weQyEUKsDGFlACWSIOfveEnImkJyK/FGW6FAG42loyoquvjdtOIqO6yBFzHyqyVVhNgNkQxxx09SFLK28YnA==", - "dev": true, - "optional": true, - "requires": { - "execa": "^0.7.0", - "executable": "^4.1.0" - }, - "dependencies": { - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "optional": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "execa": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", - "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - } - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "optional": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "optional": true - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "optional": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "bin-version": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-3.1.0.tgz", - "integrity": "sha512-Mkfm4iE1VFt4xd4vH+gx+0/71esbfus2LsnCGe8Pi4mndSPyT+NGES/Eg99jx8/lUGWfu3z2yuB/bt5UB+iVbQ==", - "dev": true, - "optional": true, - "requires": { - "execa": "^1.0.0", - "find-versions": "^3.0.0" - } - }, - "bin-version-check": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/bin-version-check/-/bin-version-check-4.0.0.tgz", - "integrity": "sha512-sR631OrhC+1f8Cvs8WyVWOA33Y8tgwjETNPyyD/myRBXLkfS/vl74FmH/lFcRl9KY3zwGh7jFhvyk9vV3/3ilQ==", - "dev": true, - "optional": true, - "requires": { - "bin-version": "^3.0.0", - "semver": "^5.6.0", - "semver-truncate": "^1.1.2" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, - "optional": true - } - } - }, - "bin-wrapper": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bin-wrapper/-/bin-wrapper-4.1.0.tgz", - "integrity": "sha512-hfRmo7hWIXPkbpi0ZltboCMVrU+0ClXR/JgbCKKjlDjQf6igXa7OwdqNcFWQZPZTgiY7ZpzE3+LjjkLiTN2T7Q==", - "dev": true, - "optional": true, - "requires": { - "bin-check": "^4.1.0", - "bin-version-check": "^4.0.0", - "download": "^7.1.0", - "import-lazy": "^3.1.0", - "os-filter-obj": "^2.0.0", - "pify": "^4.0.1" - }, - "dependencies": { - "download": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/download/-/download-7.1.0.tgz", - "integrity": "sha512-xqnBTVd/E+GxJVrX5/eUJiLYjCGPwMpdL+jGhGU57BvtcA7wwhtHVbXBeUk51kOpW3S7Jn3BQbN9Q1R1Km2qDQ==", - "dev": true, - "optional": true, - "requires": { - "archive-type": "^4.0.0", - "caw": "^2.0.1", - "content-disposition": "^0.5.2", - "decompress": "^4.2.0", - "ext-name": "^5.0.0", - "file-type": "^8.1.0", - "filenamify": "^2.0.0", - "get-stream": "^3.0.0", - "got": "^8.3.1", - "make-dir": "^1.2.0", - "p-event": "^2.1.0", - "pify": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, - "file-type": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz", - "integrity": "sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ==", - "dev": true, - "optional": true - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", - "dev": true, - "optional": true - }, - "got": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/got/-/got-8.3.2.tgz", - "integrity": "sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw==", - "dev": true, - "optional": true, - "requires": { - "@sindresorhus/is": "^0.7.0", - "cacheable-request": "^2.1.1", - "decompress-response": "^3.3.0", - "duplexer3": "^0.1.4", - "get-stream": "^3.0.0", - "into-stream": "^3.1.0", - "is-retry-allowed": "^1.1.0", - "isurl": "^1.0.0-alpha5", - "lowercase-keys": "^1.0.0", - "mimic-response": "^1.0.0", - "p-cancelable": "^0.4.0", - "p-timeout": "^2.0.1", - "pify": "^3.0.0", - "safe-buffer": "^5.1.1", - "timed-out": "^4.0.1", - "url-parse-lax": "^3.0.0", - "url-to-options": "^1.0.1" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, - "make-dir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", - "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", - "dev": true, - "optional": true, - "requires": { - "pify": "^3.0.0" - }, - "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "optional": true - } - } - }, - "p-cancelable": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", - "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==", - "dev": true, - "optional": true - }, - "p-event": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/p-event/-/p-event-2.3.1.tgz", - "integrity": "sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==", - "dev": true, - "optional": true, - "requires": { - "p-timeout": "^2.0.1" - } - }, - "p-timeout": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", - "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", - "dev": true, - "optional": true, - "requires": { - "p-finally": "^1.0.0" - } - }, - "prepend-http": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", - "dev": true, - "optional": true - }, - "url-parse-lax": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", - "integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==", - "dev": true, - "optional": true, - "requires": { - "prepend-http": "^2.0.0" - } - } - } - }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -30747,11 +29760,9 @@ "dev": true }, "bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "version": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", "dev": true, - "optional": true, "requires": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" @@ -30867,16 +29878,6 @@ "node-int64": "^0.4.0" } }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -31772,7 +30773,7 @@ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, "requires": { - "mimic-response": "^3.1.0" + "mimic-response": "^1.0.0" } }, "deep-diff": { @@ -34455,12 +33456,6 @@ "harmony-reflect": "^1.4.6" } }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true - }, "ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -34475,254 +33470,6 @@ "requires": { "schema-utils": "^4.0.0", "serialize-javascript": "^6.0.0" - }, - "dependencies": { - "ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" - } - } - } - }, - "imagemin": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/imagemin/-/imagemin-7.0.1.tgz", - "integrity": "sha512-33AmZ+xjZhg2JMCe+vDf6a9mzWukE7l+wAtesjE7KyteqqKjzxv7aVQeWnul1Ve26mWvEQqyPwl0OctNBfSR9w==", - "dev": true, - "requires": { - "file-type": "^12.0.0", - "globby": "^10.0.0", - "graceful-fs": "^4.2.2", - "junk": "^3.1.0", - "make-dir": "^3.0.0", - "p-pipe": "^3.0.0", - "replace-ext": "^1.0.0" - }, - "dependencies": { - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "globby": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", - "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", - "dev": true, - "requires": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - } - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - } - } - }, - "imagemin-gifsicle": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/imagemin-gifsicle/-/imagemin-gifsicle-7.0.0.tgz", - "integrity": "sha512-LaP38xhxAwS3W8PFh4y5iQ6feoTSF+dTAXFRUEYQWYst6Xd+9L/iPk34QGgK/VO/objmIlmq9TStGfVY2IcHIA==", - "dev": true, - "optional": true, - "requires": { - "execa": "^1.0.0", - "gifsicle": "^5.0.0", - "is-gif": "^3.0.0" - } - }, - "imagemin-mozjpeg": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/imagemin-mozjpeg/-/imagemin-mozjpeg-9.0.0.tgz", - "integrity": "sha512-TwOjTzYqCFRgROTWpVSt5UTT0JeCuzF1jswPLKALDd89+PmrJ2PdMMYeDLYZ1fs9cTovI9GJd68mRSnuVt691w==", - "dev": true, - "optional": true, - "requires": { - "execa": "^4.0.0", - "is-jpg": "^2.0.0", - "mozjpeg": "^7.0.0" - }, - "dependencies": { - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "requires": { - "pump": "^3.0.0" - } - }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "requires": { - "path-key": "^3.0.0" - } - } - } - }, - "imagemin-optipng": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/imagemin-optipng/-/imagemin-optipng-8.0.0.tgz", - "integrity": "sha512-CUGfhfwqlPjAC0rm8Fy+R2DJDBGjzy2SkfyT09L8rasnF9jSoHFqJ1xxSZWK6HVPZBMhGPMxCTL70OgTHlLF5A==", - "dev": true, - "optional": true, - "requires": { - "exec-buffer": "^3.0.0", - "is-png": "^2.0.0", - "optipng-bin": "^7.0.0" - } - }, - "imagemin-pngquant": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/imagemin-pngquant/-/imagemin-pngquant-9.0.2.tgz", - "integrity": "sha512-cj//bKo8+Frd/DM8l6Pg9pws1pnDUjgb7ae++sUX1kUVdv2nrngPykhiUOgFeE0LGY/LmUbCf4egCHC4YUcZSg==", - "dev": true, - "optional": true, - "requires": { - "execa": "^4.0.0", - "is-png": "^2.0.0", - "is-stream": "^2.0.0", - "ow": "^0.17.0", - "pngquant-bin": "^6.0.0" - }, - "dependencies": { - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "optional": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "optional": true, - "requires": { - "pump": "^3.0.0" - } - }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "optional": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "optional": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "optional": true, - "requires": { - "path-key": "^3.0.0" - } - } - } - }, - "imagemin-svgo": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/imagemin-svgo/-/imagemin-svgo-9.0.0.tgz", - "integrity": "sha512-uNgXpKHd99C0WODkrJ8OO/3zW3qjgS4pW7hcuII0RcHN3tnKxDjJWcitdVC/TZyfIqSricU8WfrHn26bdSW62g==", - "dev": true, - "optional": true, - "requires": { - "is-svg": "^4.2.1", - "svgo": "^2.1.0" - } - }, - "imagemin-webp": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/imagemin-webp/-/imagemin-webp-7.0.0.tgz", - "integrity": "sha512-JoYjvHNgBLgrQAkeCO7T5iNc8XVpiBmMPZmiXMhalC7K6gwY/3DCEUfNxVPOmNJ+NIJlJFvzcMR9RBxIE74Xxw==", - "dev": true, - "optional": true, - "requires": { - "cwebp-bin": "^7.0.1", - "exec-buffer": "^3.2.0", - "is-cwebp-readable": "^3.0.0" } }, "immediate": { @@ -37702,8 +36449,7 @@ "dev": true }, "mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "version": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true }, @@ -41672,32 +40418,12 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, - "strip-outer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", - "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", - "dev": true, - "optional": true, - "requires": { - "escape-string-regexp": "^1.0.2" - } - }, - "strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "dev": true, - "optional": true - }, "style-loader": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", - "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", + "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", "dev": true, - "requires": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - } + "requires": {} }, "style-to-js": { "version": "1.1.2", From a43c8d68d399b4819d845b14652fe91d8ba844e7 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 23 Feb 2023 14:05:23 -0500 Subject: [PATCH 56/73] build: Creating a missing workflow file `self-assign-issue.yml`. The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in sync with the current standard for this workflow as defined in the `.github` repo of the `openedx` GitHub org. --- .github/workflows/self-assign-issue.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/self-assign-issue.yml diff --git a/.github/workflows/self-assign-issue.yml b/.github/workflows/self-assign-issue.yml new file mode 100644 index 0000000000..37522fd57b --- /dev/null +++ b/.github/workflows/self-assign-issue.yml @@ -0,0 +1,12 @@ +# This workflow runs when a comment is made on the ticket +# If the comment starts with "assign me" it assigns the author to the +# ticket (case insensitive) + +name: Assign comment author to ticket if they say "assign me" +on: + issue_comment: + types: [created] + +jobs: + self_assign_by_comment: + uses: openedx/.github/.github/workflows/self-assign-issue.yml@master From 6df9eb9d70a09289082b2ce4a5ed50ab830eb67f Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 23 Feb 2023 14:05:23 -0500 Subject: [PATCH 57/73] build: Creating a missing workflow file `add-remove-label-on-comment.yml`. The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in sync with the current standard for this workflow as defined in the `.github` repo of the `openedx` GitHub org. --- .../workflows/add-remove-label-on-comment.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/add-remove-label-on-comment.yml diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml new file mode 100644 index 0000000000..0f369db7d2 --- /dev/null +++ b/.github/workflows/add-remove-label-on-comment.yml @@ -0,0 +1,20 @@ +# This workflow runs when a comment is made on the ticket +# If the comment starts with "label: " it tries to apply +# the label indicated in rest of comment. +# If the comment starts with "remove label: ", it tries +# to remove the indicated label. +# Note: Labels are allowed to have spaces and this script does +# not parse spaces (as often a space is legitimate), so the command +# "label: really long lots of words label" will apply the +# label "really long lots of words label" + +name: Allows for the adding and removing of labels via comment + +on: + issue_comment: + types: [created] + +jobs: + add_remove_labels: + uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master + From fa5361077174c68a62075c638e9d173e029b77b3 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 23 Feb 2023 14:05:24 -0500 Subject: [PATCH 58/73] build: Updating a missing workflow file `add-depr-ticket-to-depr-board.yml`. The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in sync with the current standard for this workflow as defined in the `.github` repo of the `openedx` GitHub org. --- .github/workflows/add-depr-ticket-to-depr-board.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/add-depr-ticket-to-depr-board.yml b/.github/workflows/add-depr-ticket-to-depr-board.yml index 73ca4c5c6e..250e394abc 100644 --- a/.github/workflows/add-depr-ticket-to-depr-board.yml +++ b/.github/workflows/add-depr-ticket-to-depr-board.yml @@ -16,4 +16,4 @@ jobs: secrets: GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} - SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} \ No newline at end of file + SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} From 54d34dfa587deda2009c3645c3b1bcd29fef5e51 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 23 Feb 2023 14:05:25 -0500 Subject: [PATCH 59/73] build: Updating a missing workflow file `commitlint.yml`. The .github/workflows/commitlint.yml workflow is missing or needs an update to stay in sync with the current standard for this workflow as defined in the `.github` repo of the `openedx` GitHub org. --- .github/workflows/commitlint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index e2b066153f..fec11d6c25 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -7,4 +7,4 @@ on: jobs: commitlint: - uses: edx/.github/.github/workflows/commitlint.yml@master + uses: openedx/.github/.github/workflows/commitlint.yml@master From 8c32f687893e80be91fcf7cb8d72f1f187665c49 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Thu, 16 Feb 2023 19:11:31 +0000 Subject: [PATCH 60/73] feat: New LMS Config Workflow Skeleton + Typescript pt. 2 feat: Spinner modal for awaiting Canvas authorization feat: Show error panel when Canvas authorization fails docs: More detailed variable names + comments test: Add unit tests for form components test: Convert unit tests for typescript components to typescript test: Form Reducer unit tests --- package-lock.json | 126 ++++++++++++- package.json | 3 +- src/components/forms/FormContext.tsx | 3 +- src/components/forms/FormContextWrapper.tsx | 2 +- src/components/forms/FormWaitModal.tsx | 41 +++++ src/components/forms/FormWorkflow.tsx | 135 ++++++++++---- src/components/forms/ValidatedFormControl.tsx | 2 +- src/components/forms/data/actions.ts | 40 +++- src/components/forms/data/reducer.test.ts | 157 ++++++++++++++++ src/components/forms/data/reducer.ts | 72 +++++--- .../forms/tests/FormWaitModal.test.tsx | 68 +++++++ .../forms/tests/ValidatedFormControl.test.tsx | 91 +++++++++ src/components/settings/ConfigError.jsx | 1 + src/components/settings/ConfigErrorModal.tsx | 50 +++++ .../settings/SettingsLMSTab/LMSConfigPage.jsx | 1 + .../LMSConfigs/Canvas/CanvasConfig.tsx | 174 ++++++++++++++---- .../Canvas/CanvasConfigAuthorizePage.tsx | 149 +++++++++------ src/utils.js | 15 ++ src/utils.test.js | 59 +++++- 19 files changed, 1027 insertions(+), 162 deletions(-) create mode 100644 src/components/forms/FormWaitModal.tsx create mode 100644 src/components/forms/data/reducer.test.ts create mode 100644 src/components/forms/tests/FormWaitModal.test.tsx create mode 100644 src/components/forms/tests/ValidatedFormControl.test.tsx create mode 100644 src/components/settings/ConfigErrorModal.tsx diff --git a/package-lock.json b/package-lock.json index afbdfe7cf1..ef10b94c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,7 +83,8 @@ "postcss": "8.1.0", "react-dev-utils": "11.0.4", "react-test-renderer": "16.13.1", - "resize-observer-polyfill": "1.5.1" + "resize-observer-polyfill": "1.5.1", + "ts-jest": "^26.5.0" }, "peerDependencies": { "clean-webpack-plugin": "3.0.0", @@ -7922,6 +7923,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -16387,6 +16400,12 @@ "semver": "bin/semver" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -22464,6 +22483,61 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-jest": { + "version": "26.5.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.6.tgz", + "integrity": "sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^26.1.0", + "json5": "2.x", + "lodash": "4.x", + "make-error": "1.x", + "mkdirp": "1.x", + "semver": "7.x", + "yargs-parser": "20.x" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "jest": ">=26 <27", + "typescript": ">=3.8 <5.0" + } + }, + "node_modules/ts-jest/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", @@ -29869,6 +29943,15 @@ "update-browserslist-db": "^1.0.9" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -36261,6 +36344,12 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -40921,6 +41010,41 @@ "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==" }, + "ts-jest": { + "version": "26.5.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.6.tgz", + "integrity": "sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^26.1.0", + "json5": "2.x", + "lodash": "4.x", + "make-error": "1.x", + "mkdirp": "1.x", + "semver": "7.x", + "yargs-parser": "20.x" + }, + "dependencies": { + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", diff --git a/package.json b/package.json index e27d50c744..f2a953e269 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "postcss": "8.1.0", "react-dev-utils": "11.0.4", "react-test-renderer": "16.13.1", - "resize-observer-polyfill": "1.5.1" + "resize-observer-polyfill": "1.5.1", + "ts-jest": "^26.5.0" } } diff --git a/src/components/forms/FormContext.tsx b/src/components/forms/FormContext.tsx index 3d72ab88fb..b131233f74 100644 --- a/src/components/forms/FormContext.tsx +++ b/src/components/forms/FormContext.tsx @@ -22,7 +22,8 @@ export type FormContext = { isEdited?: boolean; hasErrors?: boolean; errorMap?: { [name: string]: string[] }; - currentStep?: FormWorkflowStep; + stateMap?: { [name: string]: any }; + currentStep?: FormWorkflowStep; }; export const FormContextObject: Context = createContext({}); diff --git a/src/components/forms/FormContextWrapper.tsx b/src/components/forms/FormContextWrapper.tsx index 392aaeac1e..2126e9839c 100644 --- a/src/components/forms/FormContextWrapper.tsx +++ b/src/components/forms/FormContextWrapper.tsx @@ -6,7 +6,7 @@ import type { FormFields } from "./FormContext"; import FormWorkflow from "./FormWorkflow.tsx"; import type { FormWorkflowProps } from "./FormWorkflow"; // @ts-ignore -import FormReducer, { initializeForm } from "./data/reducer.ts"; +import {FormReducer, initializeForm } from "./data/reducer.ts"; import type { FormActionArguments } from "./data/actions"; // Context wrapper for multi-step form container diff --git a/src/components/forms/FormWaitModal.tsx b/src/components/forms/FormWaitModal.tsx new file mode 100644 index 0000000000..8f1b25b603 --- /dev/null +++ b/src/components/forms/FormWaitModal.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { AlertModal, Spinner } from "@edx/paragon"; +// @ts-ignore +import { useFormContext } from "./FormContext.tsx"; +import type { FormContext } from "./FormContext"; + +type FormWaitModal = { + // FormContext state that when truthy, shows the modal + triggerState: string; + onClose: () => void; + header: string; + text: string; +}; + +// Modal shown when waiting for a background operation to complete in forms +const FormWaitModal = ({ + triggerState, + onClose, + header, + text, +}: FormWaitModal) => { + const { stateMap }: FormContext = useFormContext(); + + const isOpen = stateMap && stateMap[triggerState]; + + return ( + +
+ +
+

{text}

+
+ ); +}; + +export default FormWaitModal; diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index 559fdc6b0f..8780dc6df9 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -1,8 +1,8 @@ import React from "react"; -import type { SyntheticEvent, Dispatch } from "react"; -import { Button, Form, Stepper, useToggle } from "@edx/paragon"; +import type { Dispatch } from "react"; +import { Button, Stepper, useToggle } from "@edx/paragon"; +import { Launch } from "@edx/paragon/icons"; -import ConfigError from "../settings/ConfigError"; // @ts-ignore import { useFormContext } from "./FormContext.tsx"; import type { @@ -10,16 +10,43 @@ import type { FormFieldValidation, FormContext, } from "./FormContext"; -// @ts-ignore -import { setStepAction } from "./data/actions.ts"; + +import { + setStepAction, + setWorkflowStateAction, + FORM_ERROR_MESSAGE, + // @ts-ignore +} from "./data/actions.ts"; import { SUBMIT_TOAST_MESSAGE } from "../settings/data/constants"; import { FormActionArguments } from "./data/actions"; // @ts-ignore import UnsavedChangesModal from "../settings/SettingsLMSTab/UnsavedChangesModal.tsx"; +// @ts-ignore +import ConfigErrorModal from "../settings/ConfigErrorModal.tsx"; +import { pollAsync } from "../../utils"; + +export const WAITING_FOR_ASYNC_OPERATION = "WAITING FOR ASYNC OPERATION"; + +export type FormWorkflowErrorHandler = (errMsg: string) => void; + +export type FormWorkflowHandlerArgs = { + formFields?: FormData; + errHandler?: FormWorkflowErrorHandler; + dispatch?: Dispatch; +}; + +export type FormWorkflowAwaitHandler = { + awaitCondition: (args: FormWorkflowHandlerArgs) => Promise; + awaitInterval: number; + awaitTimeout: number; + onAwaitTimeout?: (args: FormWorkflowHandlerArgs) => void; +}; export type FormWorkflowButtonConfig = { buttonText: string; - onClick: (formFields: FormData) => Promise; + opensNewWindow: boolean; + onClick: (args: FormWorkflowHandlerArgs) => Promise; + awaitSuccess?: FormWorkflowAwaitHandler; }; type DynamicComponent = React.FunctionComponent | React.ComponentClass; @@ -29,8 +56,11 @@ export type FormWorkflowStep = { stepName: string; formComponent: DynamicComponent; validations: FormFieldValidation[]; - saveChanges: (FormData) => Promise; - nextButtonConfig: FormWorkflowButtonConfig; + saveChanges: ( + formData: FormData, + errHandler: FormWorkflowErrorHandler + ) => Promise; + nextButtonConfig: (FormData) => FormWorkflowButtonConfig; }; export type FormWorkflowConfig = { @@ -43,6 +73,7 @@ export type FormWorkflowProps = { onClickOut: (edited: boolean, msg?: string) => null; formData: FormData; dispatch: Dispatch; + onSubmit: (FormData) => void; }; // Modal container for multi-step forms @@ -56,26 +87,64 @@ function FormWorkflow({ currentStep: step, hasErrors, isEdited, + stateMap, }: FormContext = useFormContext(); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [savedChangesModalIsOpen, openModal, closeModal] = useToggle(false); + const [ + savedChangesModalIsOpen, + openSavedChangesModal, + closeSavedChangesModal, + ] = useToggle(false); + const nextButtonConfig = step?.nextButtonConfig(formFields); + const awaitingAsyncAction = stateMap && stateMap[WAITING_FOR_ASYNC_OPERATION]; + + const setFormError = (msg: string) => { + dispatch(setWorkflowStateAction(FORM_ERROR_MESSAGE, msg)); + }; + const clearFormError = () => setFormError(""); const onCancel = () => { if (isEdited) { - openModal(); + openSavedChangesModal(); } else { onClickOut(false); } }; - const onNext = async (event: SyntheticEvent) => { - await step?.nextButtonConfig.onClick(formFields as FormFields); - const nextStep: number = step.index + 1; - if (nextStep < formWorkflowConfig.steps.length) { - dispatch(setStepAction({ step: formWorkflowConfig.steps[nextStep] })); - } else { - // TODO: Fix - onClickOut(true, SUBMIT_TOAST_MESSAGE); + const onNext = async () => { + let advance = true; + if (nextButtonConfig) { + let newFormFields: FormData = await nextButtonConfig.onClick({ + formFields, + errHandler: setFormError, + dispatch, + }); + if (nextButtonConfig?.awaitSuccess) { + advance = await pollAsync( + () => + nextButtonConfig.awaitSuccess?.awaitCondition?.({ + formFields: newFormFields, + errHandler: setFormError, + dispatch, + }), + nextButtonConfig.awaitSuccess.awaitTimeout, + nextButtonConfig.awaitSuccess.awaitInterval + ); + if (!advance && nextButtonConfig?.awaitSuccess) { + await nextButtonConfig.awaitSuccess?.onAwaitTimeout?.({ + formFields: newFormFields, + errHandler: setFormError, + dispatch, + }); + } + } + if (advance && step) { + const nextStep: number = step.index + 1; + if (nextStep < formWorkflowConfig.steps.length) { + dispatch(setStepAction({ step: formWorkflowConfig.steps[nextStep] })); + } else { + onClickOut(true, SUBMIT_TOAST_MESSAGE); + } + } } }; @@ -83,13 +152,7 @@ function FormWorkflow({ const FormComponent: DynamicComponent = step?.formComponent; return ( - - {step && step?.formComponent && ( - <> - - - )} - + {step && step?.formComponent && } ); }; @@ -110,10 +173,14 @@ function FormWorkflow({ Cancel } - {step.nextButtonConfig && ( + {nextButtonConfig && ( // @ts-ignore - )}
@@ -123,13 +190,17 @@ function FormWorkflow({ return ( <> - + onClickOut(false)} saveDraft={async () => { - await step?.saveChanges(formFields as FormData); + await step?.saveChanges(formFields as FormData, setFormError); onClickOut(true, SUBMIT_TOAST_MESSAGE); }} /> diff --git a/src/components/forms/ValidatedFormControl.tsx b/src/components/forms/ValidatedFormControl.tsx index ce8f6af653..c7f65b427d 100644 --- a/src/components/forms/ValidatedFormControl.tsx +++ b/src/components/forms/ValidatedFormControl.tsx @@ -16,7 +16,7 @@ type InheritedParagonControlProps = { floatingLabel: string; }; -type ValidatedFormControlProps = { +export type ValidatedFormControlProps = { // Field id, required to map to field in FormContext formId: string; // Inline Instructions inside form field when blank diff --git a/src/components/forms/data/actions.ts b/src/components/forms/data/actions.ts index 95654cd33a..2307c58932 100644 --- a/src/components/forms/data/actions.ts +++ b/src/components/forms/data/actions.ts @@ -21,12 +21,46 @@ export const setFormFieldAction = ({ value, }); +export const UPDATE_FORM_FIELDS = "UPDATE FORM FIELDS"; +export type UpdateFormFieldArguments = { + formFields: FormData; +} & FormActionArguments; +// Construct action for updating form fields +export function updateFormFieldsAction({ + formFields, +}: UpdateFormFieldArguments) { + return { + type: UPDATE_FORM_FIELDS, + formFields, + }; +} + export const SET_STEP = "SET STEP"; -export type SetStepArguments = { - step: FormWorkflowStep; +export type SetStepArguments = { + step: FormWorkflowStep; } & FormActionArguments; // Construct action for setting a form field value -export const setStepAction = ({ step }: SetStepArguments) => ({ +export const setStepAction = ({ step }: SetStepArguments) => ({ type: SET_STEP, step, }); + +// Global Workflow state keys +export const FORM_ERROR_MESSAGE = "FORM ERROR MESSAGE"; + +export const SET_WORKFLOW_STATE = "SET WORKFLOW STATE"; +export type SetWorkflowStateArguments = { + name: string; + state: StateType; +} & FormActionArguments; +// Construct action for setting a flag for the workflow +export function setWorkflowStateAction( + name: string, + state: StateType +): SetWorkflowStateArguments { + return { + type: SET_WORKFLOW_STATE, + name, + state, + }; +} diff --git a/src/components/forms/data/reducer.test.ts b/src/components/forms/data/reducer.test.ts new file mode 100644 index 0000000000..e2fd81d60c --- /dev/null +++ b/src/components/forms/data/reducer.test.ts @@ -0,0 +1,157 @@ +import { Component } from "react"; +import type { FormContext, FormFieldValidation } from "../FormContext"; +import { + FormWorkflowButtonConfig, + FormWorkflowHandlerArgs, + FormWorkflowStep, +} from "../FormWorkflow"; +import { + setFormFieldAction, + updateFormFieldsAction, + setStepAction, + setWorkflowStateAction, + // @ts-ignore +} from "./actions.ts"; +import type { InitializeFormArguments } from "./reducer"; +// @ts-ignore +import { FormReducer, initializeForm } from "./reducer.ts"; + +type DummyFormFields = { + address: string; + zip: number; +}; + +const dummyButtonConfig: FormWorkflowButtonConfig = { + buttonText: "Unimportant", + onClick: ({ formFields }: FormWorkflowHandlerArgs) => + Promise.resolve(formFields as DummyFormFields), + opensNewWindow: false, +}; + +const createDummyStep = ( + index: number, + stepName: string, + validations: FormFieldValidation[] +): FormWorkflowStep => ({ + index, + stepName, + validations, + formComponent: Component, + saveChanges: () => Promise.resolve(true), + nextButtonConfig: () => dummyButtonConfig, +}); + +const dummyFormFieldsValidations: FormFieldValidation[] = [ + { + formFieldId: "address", + validator: (fields) => { + const address = fields.address; + const error = address?.length > 20; + return error && "Address should be 20 characters or less"; + }, + }, + { + formFieldId: "zip", + validator: (fields) => { + const zip = fields.zip; + const error = zip <= 0; + return error && "Zip code should be positive nonzero number"; + }, + }, +]; + +const steps: FormWorkflowStep[] = [ + createDummyStep(0, "Fill Form", dummyFormFieldsValidations), + createDummyStep(1, "Review Form", []), +]; + +const testFormFields = { address: "123 45th st", zip: 12345 }; + +const getTestInitializeFormArguments = () => ({ + formFields: testFormFields, + validations: dummyFormFieldsValidations, + currentStep: steps[0], +}); + +const getTestExpectedState = () => ({ + formFields: testFormFields, + validations: dummyFormFieldsValidations, + currentStep: steps[0], + isEdited: false, +}); + +describe("Form reducer tests", () => { + test("Initialize Workflow State", () => { + const formFields: DummyFormFields = { address: "123 45th st", zip: 12345 }; + + const initializeFormArguments: InitializeFormArguments = { + formFields: { ...formFields }, + validations: dummyFormFieldsValidations, + currentStep: steps[0], + }; + expect(initializeForm({}, initializeFormArguments)).toEqual({ + formFields, + currentStep: steps[0], + isEdited: false, + }); + }); + + test("Set form field with errors", () => { + const action = setFormFieldAction({ fieldId: "zip", value: 0 }); + const expected = { + ...getTestExpectedState(), + formFields: { address: "123 45th st", zip: 0 }, + isEdited: true, + hasErrors: true, + errorMap: { + zip: [["zip", "Zip code should be positive nonzero number"]], + }, + }; + + expect( + FormReducer(initializeForm(getTestInitializeFormArguments()), action) + ).toStrictEqual(expected); + }); + + test("Update form fields", () => { + const action = updateFormFieldsAction({ + formFields: { zip: 54321, address: "543 21st st" }, + }); + + const expected = { + ...getTestExpectedState(), + formFields: { zip: 54321, address: "543 21st st"}, + hasErrors: false, + }; + + expect( + FormReducer(initializeForm(getTestInitializeFormArguments()), action) + ).toStrictEqual(expected); + }); + + test("Set workflow state", () => { + const action = setWorkflowStateAction("TEST_STATE", "Test State"); + + const expected = { + ...getTestExpectedState(), + stateMap: { TEST_STATE: "Test State" }, + }; + + expect( + FormReducer(initializeForm(getTestInitializeFormArguments()), action) + ).toStrictEqual(expected); + }); + + test("Set workflow step", () => { + const action = setStepAction({ step: steps[1] }); + + const expected = { + ...getTestExpectedState(), + currentStep: steps[1], + }; + + expect( + FormReducer(initializeForm(getTestInitializeFormArguments()), action) + ).toStrictEqual(expected); + }); +}); diff --git a/src/components/forms/data/reducer.ts b/src/components/forms/data/reducer.ts index ea6523ee7c..a46f893d2a 100644 --- a/src/components/forms/data/reducer.ts +++ b/src/components/forms/data/reducer.ts @@ -1,25 +1,35 @@ import groupBy from "lodash/groupBy"; import isEmpty from "lodash/isEmpty"; +import { + SET_FORM_FIELD, + SET_STEP, + SET_WORKFLOW_STATE, + UPDATE_FORM_FIELDS, // @ts-ignore -import { SET_FORM_FIELD, SET_STEP } from "./actions.ts"; -import type { FormActionArguments, SetFormFieldArguments, SetStepArguments } from "./actions"; +} from "./actions.ts"; import type { - FormContext, - FormFields, - FormFieldValidation, -} from "../FormContext"; + FormActionArguments, + SetFormFieldArguments, + SetStepArguments, + SetWorkflowStateArguments, + UpdateFormFieldArguments, +} from "./actions"; +import type { FormContext, FormFieldValidation } from "../FormContext"; import type { FormWorkflowStep } from "../FormWorkflow"; -export type InitializeFormArguments = { +export type InitializeFormArguments = { formFields: FormFields; validations: FormFieldValidation[]; - currentStep: FormWorkflowStep + currentStep: FormWorkflowStep; }; -export const initializeForm = ( +export function initializeForm( state: FormContext, - action: InitializeFormArguments -) => { - const additions: Pick = { isEdited: false }; + action: InitializeFormArguments +) { + const additions: Pick< + FormContext, + "isEdited" | "formFields" | "currentStep" + > = { isEdited: false }; if (action?.formFields) { additions.formFields = action.formFields; } @@ -28,18 +38,18 @@ export const initializeForm = ( } return { ...state, - ...additions + ...additions, }; -}; +} const processFormErrors = (state: FormContext): FormContext => { // Get all form errors - // TODO: Get validations from step let errorState: Pick = { hasErrors: false, errorMap: {}, }; if (state.formFields) { + // Generate list of errors with their formFieldIds const errors = state.currentStep?.validations ?.map((validation: FormFieldValidation) => [ validation.formFieldId, @@ -47,7 +57,7 @@ const processFormErrors = (state: FormContext): FormContext => { ]) .filter((err) => !!err[1]); if (!isEmpty(errors)) { - // Generate index of errors + // Convert to map of errors indexed by formFieldId errorState = { hasErrors: true, errorMap: groupBy(errors, (error) => error[0]), @@ -61,7 +71,10 @@ const processFormErrors = (state: FormContext): FormContext => { }; }; -const FormReducer = (state: FormContext = {formFields: {}}, action: FormActionArguments) => { +export function FormReducer( + state: FormContext = { formFields: {} }, + action: FormActionArguments +) { switch (action.type) { case SET_FORM_FIELD: const setFormFieldArgs = action as SetFormFieldArguments; @@ -74,12 +87,27 @@ const FormReducer = (state: FormContext = {formFields: {}}, action: FormActionAr isEdited: true, }); return newState; + case UPDATE_FORM_FIELDS: + const updateFormFieldsArgs = + action as UpdateFormFieldArguments; + return { + ...state, + formFields: updateFormFieldsArgs.formFields, + isEdited: false, + hasErrors: false, + }; case SET_STEP: - const setStepArgs = action as SetStepArguments; - return {...state, currentStep: setStepArgs.step}; + const setStepArgs = action as SetStepArguments; + return { ...state, currentStep: setStepArgs.step }; + case SET_WORKFLOW_STATE: + const setStateArgs = action as SetWorkflowStateArguments; + const oldStateMap = state.stateMap || {}; + const newStateMap = { + ...oldStateMap, + [setStateArgs.name]: setStateArgs.state, + }; + return { ...state, stateMap: newStateMap }; default: return state; } -}; - -export default FormReducer; +} diff --git a/src/components/forms/tests/FormWaitModal.test.tsx b/src/components/forms/tests/FormWaitModal.test.tsx new file mode 100644 index 0000000000..5cbebe888f --- /dev/null +++ b/src/components/forms/tests/FormWaitModal.test.tsx @@ -0,0 +1,68 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { screen, render } from '@testing-library/react'; + +// @ts-ignore +import FormWaitModal from '../FormWaitModal.tsx'; +// @ts-ignore +import FormContextProvider from '../FormContext.tsx'; + +const FormWaitModalWrapper = ({ + mockDispatch, + showModal, + triggerState, + header, + text, +}) => { + const contextValue = { + stateMap: { SHOW_MODAL: showModal }, + }; + return ( + + + + ); +}; + +describe('', () => { + it('renders if flag set', () => { + const mockDispatch = jest.fn(); + render( + , + ); + + expect(screen.getByText('Test FormWaitModal')).toBeInTheDocument(); + expect(screen.getAllByText('Some text to test with')).toHaveLength(2); + }); + it('does not render if flag not set', () => { + const mockDispatch = jest.fn(); + render( + , + ); + + expect(screen.queryByText('Test FormWaitModal')).not.toBeInTheDocument(); + expect(screen.queryByText('Some text to test with')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/tests/ValidatedFormControl.test.tsx b/src/components/forms/tests/ValidatedFormControl.test.tsx new file mode 100644 index 0000000000..941920a4aa --- /dev/null +++ b/src/components/forms/tests/ValidatedFormControl.test.tsx @@ -0,0 +1,91 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { screen, render } from '@testing-library/react'; +import userEvent, { TargetElement } from '@testing-library/user-event'; + +// @ts-ignore +import FormContextProvider from '../FormContext.tsx'; +import type { FormContext } from '../FormContext'; +// @ts-ignore +import ValidatedFormControl from '../ValidatedFormControl.tsx'; +import type {ValidatedFormControlProps} from '../ValidatedFormControl'; + +type ValidatedFormControlWrapperProps = { + mockDispatch: () => void; + formValue?: string; + formError?: string; + formId: string; +} & Partial; + +const ValidatedFormControlWrapper = ({ + mockDispatch, + formId, + formValue, + floatingLabel, + fieldInstructions, + formError, +}: ValidatedFormControlWrapperProps) => { + let contextValue: FormContext = { + formFields: { [formId]: formValue }, + }; + if (formError) { + contextValue = { ...contextValue, errorMap: { [formId]: [formError] } }; + } + return ( + + + + ); +}; + +describe('', () => { + it('renders with field populated from context', () => { + const mockDispatch = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector('input'); + expect(input).toHaveAttribute('value', 'Test Value'); + expect(screen.getByText('Test Label')).toBeInTheDocument(); + expect(screen.getByText('Test Instructions')).toBeInTheDocument(); + }); + it('sends change action when field is updated', () => { + const mockDispatch = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector('input'); + userEvent.type(input as TargetElement, 'x'); + expect(mockDispatch).toBeCalledWith({ type: 'SET FORM FIELD', fieldId: 'TEST_FORM_FIELD', value: 'x' }); + }); + it('renders with error populated from context', () => { + const mockDispatch = jest.fn(); + render( + , + ); + expect(screen.getByText('Something is wrong with this field')).toBeInTheDocument(); + }); +}); diff --git a/src/components/settings/ConfigError.jsx b/src/components/settings/ConfigError.jsx index a3726d6b26..1a7162f87c 100644 --- a/src/components/settings/ConfigError.jsx +++ b/src/components/settings/ConfigError.jsx @@ -7,6 +7,7 @@ import { HELP_CENTER_LINK } from './data/constants'; const cardText = 'We were unable to process your request to submit a new LMS configuration. Please try submitting again or contact support for help.'; +// TODO: Remove once use has been completely supplanted by ConfigErrorModal const ConfigError = ({ isOpen, close, diff --git a/src/components/settings/ConfigErrorModal.tsx b/src/components/settings/ConfigErrorModal.tsx new file mode 100644 index 0000000000..834f786e23 --- /dev/null +++ b/src/components/settings/ConfigErrorModal.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { + AlertModal, ActionRow, Button, Hyperlink, +} from '@edx/paragon'; +import { HELP_CENTER_LINK } from './data/constants'; + +const cardText = 'We were unable to process your request to submit a new LMS configuration. Please try submitting again or contact support for help.'; + +type ConfigErrorProps = { + isOpen: boolean; + close: () => void; + configTextOverride?: string; +}; + +// Display error message for LMS configuration issue +const ConfigErrorModal = ({ + isOpen, + close, + configTextOverride, +}: ConfigErrorProps) => { + return ( + + + {/* @ts-ignore */} + + + )} + > + {configTextOverride && ( +

+ {configTextOverride || ''} +

+ )} + {!configTextOverride && ( +

+ {cardText} +

+ )} +
+)}; + +export default ConfigErrorModal; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 674b924294..83646d178b 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -38,6 +38,7 @@ const LMSConfigPage = ({ }) => { const handleCloseWorkflow = (submitted, msg) => { onClick(submitted ? msg : ''); + return true; }; return ( diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx index fa1a97702c..1930f73472 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx @@ -1,16 +1,34 @@ -import isEmpty from "lodash/isEmpty"; +import type { Dispatch } from "react"; import handleErrors from "../../../utils"; import LmsApiService from "../../../../../data/services/LmsApiService"; -import { snakeCaseDict } from "../../../../../utils"; -import { SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; +import { + CANVAS_OAUTH_REDIRECT_URL, + LMS_CONFIG_OAUTH_POLLING_INTERVAL, + LMS_CONFIG_OAUTH_POLLING_TIMEOUT, + SUBMIT_TOAST_MESSAGE, +} from "../../../data/constants"; // @ts-ignore import CanvasConfigActivatePage from "./CanvasConfigActivatePage.tsx"; import CanvasConfigAuthorizePage, { validations, // @ts-ignore } from "./CanvasConfigAuthorizePage.tsx"; -import type { FormWorkflowConfig, FormWorkflowStep } from "../../../../forms/FormWorkflow"; +import type { + FormWorkflowButtonConfig, + FormWorkflowConfig, + FormWorkflowStep, + FormWorkflowHandlerArgs, + FormWorkflowErrorHandler, +} from "../../../../forms/FormWorkflow"; +// @ts-ignore +import { WAITING_FOR_ASYNC_OPERATION } from "../../../../forms/FormWorkflow.tsx"; +import { + setWorkflowStateAction, + updateFormFieldsAction, + // @ts-ignore +} from "../../../../forms/data/actions.ts"; export type CanvasConfigCamelCase = { canvasAccountId: string; @@ -21,6 +39,7 @@ export type CanvasConfigCamelCase = { id: string; active: boolean; uuid: string; + refreshToken: string; }; // TODO: Can we generate this dynamically? @@ -35,84 +54,153 @@ export type CanvasConfigSnakeCase = { active: boolean; uuid: string; enterprise_customer: string; + refresh_token: string; }; // TODO: Make this a generic type usable by all lms configs export type CanvasFormConfigProps = { enterpriseCustomerUuid: string; existingData: CanvasConfigCamelCase; - onSubmit: (canvasConfig: CanvasConfigCamelCase) => CanvasConfigSnakeCase; - onClickCancel: (submitted: boolean, status: string) => Promise; + onSubmit: ( + canvasConfig: CanvasConfigCamelCase, + errHandler?: FormWorkflowErrorHandler + ) => void; + onClickCancel: (submitted: boolean, status: string) => Promise; }; +export const LMS_AUTHORIZATION_FAILED = "LMS AUTHORIZATION FAILED"; + export const CanvasFormConfig = ({ enterpriseCustomerUuid, onSubmit, onClickCancel, existingData, }: CanvasFormConfigProps): FormWorkflowConfig => { - const saveChanges = async (formFields: CanvasConfigCamelCase) => { + const saveChanges = async ( + formFields: CanvasConfigCamelCase, + errHandler: (errMsg: string) => void + ) => { const transformedConfig: CanvasConfigSnakeCase = snakeCaseDict( formFields ) as CanvasConfigSnakeCase; transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; + let err = ""; - if (!isEmpty(existingData)) { + if (formFields.id) { try { transformedConfig.active = existingData.active; await LmsApiService.updateCanvasConfig( transformedConfig, existingData.id ); - onSubmit(formFields); + onSubmit(formFields, errHandler); } catch (error) { err = handleErrors(error); } } else { // TODO: Don't expose option if object not created yet } + + if (err) { + errHandler(err); + } + return !err; }; - - const handleSubmit = async ( - formFields: CanvasConfigCamelCase - ) => { + + const handleSubmit = async ({ + formFields, + errHandler, + dispatch, + }: FormWorkflowHandlerArgs) => { + let currentFormFields = formFields; const transformedConfig: CanvasConfigSnakeCase = snakeCaseDict( formFields ) as CanvasConfigSnakeCase; transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; + let err = ""; - if (formFields.id) { + if (currentFormFields?.id) { try { transformedConfig.active = existingData.active; - await LmsApiService.updateCanvasConfig( + const response = await LmsApiService.updateCanvasConfig( transformedConfig, existingData.id ); - onSubmit(formFields); + currentFormFields = camelCaseDict( + response.data + ) as CanvasConfigCamelCase; + onSubmit(currentFormFields, errHandler); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); } catch (error) { err = handleErrors(error); } } else { try { transformedConfig.active = false; - await LmsApiService.postNewCanvasConfig(transformedConfig); - onSubmit(formFields); + const response = await LmsApiService.postNewCanvasConfig( + transformedConfig + ); + currentFormFields = camelCaseDict( + response.data + ) as CanvasConfigCamelCase; + onSubmit(currentFormFields, errHandler); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); } catch (error) { err = handleErrors(error); } } if (err) { - // TODO: Do something to open error model elsewhere? - // openError(); - } else { - // TODO: Make this happen on final step - // onClickCancel(SUBMIT_TOAST_MESSAGE); + errHandler?.(err); + } else if (currentFormFields && !currentFormFields?.refreshToken) { + const oauthUrl = + `${currentFormFields.canvasBaseUrl}/login/oauth2/auth?client_id=${currentFormFields.clientId}&` + + `state=${currentFormFields.uuid}&response_type=code&` + + `redirect_uri=${CANVAS_OAUTH_REDIRECT_URL}`; + + // Open the oauth window for the user + window.open(oauthUrl); + dispatch?.(setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, true)); + } + return currentFormFields; + }; + + const awaitAfterSubmit = async ({ + formFields, + errHandler, + dispatch, + }: FormWorkflowHandlerArgs) => { + // Return immediately if already authorized + if (formFields?.id) { + let err = ""; + try { + const response = await LmsApiService.fetchSingleCanvasConfig( + formFields.id + ); + if (response.data.refresh_token) { + dispatch?.( + setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false) + ); + return true; + } + } catch (error) { + err = handleErrors(error); + } + if (err) { + errHandler?.(err); + return false; + } } + + return false; + }; + + const onAwaitTimeout = async ({ + dispatch, + }: FormWorkflowHandlerArgs) => { + dispatch?.(setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false)); + dispatch?.(setWorkflowStateAction(LMS_AUTHORIZATION_FAILED, true)); }; - // TODO: Fix handleSubmit const steps: FormWorkflowStep[] = [ { index: 0, @@ -120,9 +208,27 @@ export const CanvasFormConfig = ({ validations, stepName: "Authorize", saveChanges, - nextButtonConfig: { - buttonText: "Authorize", - onClick: handleSubmit, + nextButtonConfig: (formFields: CanvasConfigCamelCase) => { + let config = { + buttonText: "Authorize", + opensNewWindow: false, + onClick: handleSubmit, + }; + if (!formFields.refreshToken) { + config = { + ...config, + ...{ + opensNewWindow: true, + awaitSuccess: { + awaitCondition: awaitAfterSubmit, + awaitInterval: LMS_CONFIG_OAUTH_POLLING_INTERVAL, + awaitTimeout: LMS_CONFIG_OAUTH_POLLING_TIMEOUT, + onAwaitTimeout: onAwaitTimeout, + }, + }, + }; + } + return config as FormWorkflowButtonConfig; }, }, { @@ -131,10 +237,14 @@ export const CanvasFormConfig = ({ validations: [], stepName: "Activate", saveChanges, - nextButtonConfig: { + nextButtonConfig: () => ({ buttonText: "Activate", - onClick: () => onClickCancel(true, SUBMIT_TOAST_MESSAGE), - }, + opensNewWindow: false, + onClick: () => { + onClickCancel(true, SUBMIT_TOAST_MESSAGE); + return Promise.resolve(existingData); + }, + }), }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx index abce91b513..c7cf9f5f24 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx @@ -1,11 +1,23 @@ import React from "react"; -import { Form } from "@edx/paragon"; +import { Form, Alert } from "@edx/paragon"; +import { Info } from "@edx/paragon/icons"; // @ts-ignore import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; import { urlValidation } from "../../../../../utils"; -import type { FormFieldValidation } from "../../../../forms/FormContext"; +import { + FormFieldValidation, + useFormContext, +} from "../../../../forms/FormContext"; +// @ts-ignore +import FormWaitModal from "../../../../forms/FormWaitModal.tsx"; +// @ts-ignore +import { WAITING_FOR_ASYNC_OPERATION } from "../../../../forms/FormWorkflow.tsx"; +// @ts-ignore +import { setWorkflowStateAction } from "../../../../forms/data/actions.ts"; +// @ts-ignore +import { LMS_AUTHORIZATION_FAILED } from "./CanvasConfig.tsx"; const formFieldNames = { DISPLAY_NAME: "displayName", @@ -29,70 +41,85 @@ export const validations: FormFieldValidation[] = [ // TODO: Check for duplicate display names const displayName = fields[formFieldNames.DISPLAY_NAME]; const error = displayName?.length > 20; - return error && "Display Name should be 20 characters or less"; + return error && "Display name should be 20 characters or less"; }, }, ]; -// Page 2 of Canvas LMS config workflow -const CanvasConfigAuthorizePage = () => ( - -

Authorize connection to Canvas

+// Settings page of Canvas LMS config workflow +const CanvasConfigAuthorizePage = () => { + const { dispatch, stateMap } = useFormContext(); + return ( + +

Authorize connection to Canvas

-
- - - - - - - - - - - - - - + {/* TODO: Add vertical spacing between fields */} + {stateMap?.[LMS_AUTHORIZATION_FAILED] && ( + +

Enablement failed

+ We were unable to enable your Canvas integration. Please try again + or contact enterprise customer support. +
+ )} + + + + + + + + + + + + + + + + + + dispatch?.( + setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false) + ) + } + header="Authorization in progress" + text="Please confirm authorization through Canvas and return to this window once complete." /> -
- {/* TODO: Style panel */} -
-

Action in Canvas required to complete authorization

- Advancing to the next step will open a new window to complete the - authorization process in Canvas. Return to this window following - authorization to finish configuring your new integration. -
-
-
-); + +
+ ); +}; export default CanvasConfigAuthorizePage; diff --git a/src/utils.js b/src/utils.js index b6892bdbcd..ae4ba3e011 100644 --- a/src/utils.js +++ b/src/utils.js @@ -312,6 +312,20 @@ const isDefinedAndNotNull = (value) => { return values.every(item => isDefined(item) && !isNull(item)); }; +const pollAsync = async (pollFunc, timeout, interval, checkFunc) => { + const startTime = new Date().getTime(); + while (new Date().getTime() - startTime < timeout) { + // eslint-disable-next-line no-await-in-loop + const result = await pollFunc(); + if (checkFunc ? checkFunc(result) : !!result) { + return result; + } + // eslint-disable-next-line no-await-in-loop, no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, interval)); + } + return false; +}; + export { camelCaseDict, camelCaseDictArray, @@ -343,4 +357,5 @@ export { urlValidation, normalizeFileUpload, capitalizeFirstLetter, + pollAsync, }; diff --git a/src/utils.test.js b/src/utils.test.js index 1d66716609..bc03907cb8 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -1,5 +1,9 @@ import { - camelCaseDict, camelCaseDictArray, snakeCaseDict, snakeCaseFormData, + camelCaseDict, + camelCaseDictArray, + snakeCaseDict, + snakeCaseFormData, + pollAsync, } from './utils'; describe('utils', () => { @@ -7,16 +11,26 @@ describe('utils', () => { it('formats dictionaries into camel case', () => { const startingSnakeCaseDict = { snake_case_key: 'foobar' }; const expectedCamelCaseDict = { snakeCaseKey: 'foobar' }; - expect(camelCaseDict(startingSnakeCaseDict)).toEqual(expectedCamelCaseDict); + expect(camelCaseDict(startingSnakeCaseDict)).toEqual( + expectedCamelCaseDict, + ); }); it('does not format dictionary value', () => { const startingDict = { fooBar: 'example_value' }; expect(camelCaseDict(startingDict)).toEqual(startingDict); }); it('formats an array of dictionaries into camel case', () => { - const snakeCaseDictArray = [{ foo_bar: 'example_value' }, { ayy_lmao: 'example_value' }]; - const expectedCamelCaseArray = [{ fooBar: 'example_value' }, { ayyLmao: 'example_value' }]; - expect(camelCaseDictArray(snakeCaseDictArray)).toEqual(expectedCamelCaseArray); + const snakeCaseDictArray = [ + { foo_bar: 'example_value' }, + { ayy_lmao: 'example_value' }, + ]; + const expectedCamelCaseArray = [ + { fooBar: 'example_value' }, + { ayyLmao: 'example_value' }, + ]; + expect(camelCaseDictArray(snakeCaseDictArray)).toEqual( + expectedCamelCaseArray, + ); }); }); @@ -24,7 +38,9 @@ describe('utils', () => { it('formats dictionaries into snake case', () => { const startingSnakeCaseDict = { snakeCaseKey: 'foobar' }; const expectedCamelCaseDict = { snake_case_key: 'foobar' }; - expect(snakeCaseDict(startingSnakeCaseDict)).toEqual(expectedCamelCaseDict); + expect(snakeCaseDict(startingSnakeCaseDict)).toEqual( + expectedCamelCaseDict, + ); }); it('does not format dictionary value', () => { const startingDict = { foo_bar: 'example_value' }; @@ -33,7 +49,36 @@ describe('utils', () => { it('format form data to snake case', () => { const camelCaseFormData = new FormData(); camelCaseFormData.append('userName', 'ayyLmao'); - expect(snakeCaseFormData(camelCaseFormData).get('user_name')).toEqual('ayyLmao'); + expect(snakeCaseFormData(camelCaseFormData).get('user_name')).toEqual( + 'ayyLmao', + ); + }); + }); + + describe('async polling', () => { + it('polls until truthy return value', async () => { + const mockPoll = jest.fn(); + mockPoll + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValue(true); + const pollReturn = await pollAsync(mockPoll, 1000, 300); + expect(pollReturn).toEqual(true); + expect(mockPoll).toBeCalledTimes(3); + }); + it('polls until condition', async () => { + const mockPoll = jest.fn(); + mockPoll.mockReturnValueOnce(0).mockReturnValueOnce(1).mockReturnValue(2); + const pollReturn = await pollAsync(mockPoll, 1000, 300, (val) => val > 1); + expect(pollReturn).toEqual(2); + expect(mockPoll).toBeCalledTimes(3); + }); + it('times out', async () => { + const mockPoll = jest.fn(); + mockPoll.mockReturnValue(false); + const pollReturn = await pollAsync(mockPoll, 1000, 300); + expect(pollReturn).toEqual(false); + expect(mockPoll).toBeCalledTimes(4); }); }); }); From 16b1c75dd30adbc2e06ab51ed09ad78667f7ef3c Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Tue, 7 Mar 2023 01:18:21 +0000 Subject: [PATCH 61/73] feat: New LMS Config Workflow Tests/Fixes fix: CanvasConfig validations fix: Typescript import build errors --- .../RequestCodesPage/RequestCodesForm.jsx | 4 +- src/components/forms/FormContext.tsx | 2 +- src/components/forms/FormWorkflow.tsx | 4 + src/components/forms/ValidatedFormControl.tsx | 11 +- src/components/forms/data/reducer.test.ts | 26 +- src/components/forms/data/reducer.ts | 62 +-- .../settings/ConfigErrorModal.test.tsx | 34 ++ .../settings/SettingsLMSTab/LMSConfigPage.jsx | 1 + .../LMSConfigs/Canvas/CanvasConfig.tsx | 96 +++-- .../Canvas/CanvasConfigAuthorizePage.tsx | 48 ++- .../tests/CanvasConfig.test.jsx | 375 ----------------- .../tests/CanvasConfig.test.tsx | 383 ++++++++++++++++++ .../tests/LmsConfigPage.test.jsx | 2 +- src/components/test/testUtils.jsx | 6 + src/utils.js | 13 +- src/utils.test.js | 12 + 16 files changed, 615 insertions(+), 464 deletions(-) create mode 100644 src/components/settings/ConfigErrorModal.test.tsx delete mode 100644 src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx create mode 100644 src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx diff --git a/src/components/RequestCodesPage/RequestCodesForm.jsx b/src/components/RequestCodesPage/RequestCodesForm.jsx index 1748caf8db..2baab95c3e 100644 --- a/src/components/RequestCodesPage/RequestCodesForm.jsx +++ b/src/components/RequestCodesPage/RequestCodesForm.jsx @@ -8,7 +8,7 @@ import RenderField from '../RenderField'; import StatusAlert from '../StatusAlert'; import { - isRequired, isValidEmail, isValidNumber, maxLength512, + isRequired, isValidEmail, isNotValidNumberString, maxLength512, } from '../../utils'; class RequestCodesForm extends React.Component { @@ -103,7 +103,7 @@ class RequestCodesForm extends React.Component { type="number" component={RenderField} label="Number of Codes" - validate={[isValidNumber]} + validate={[isNotValidNumberString]} data-hj-suppress /> FormValidatorResult; export type FormFieldValidation = { formFieldId: string; diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index 8780dc6df9..78a1852893 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -31,6 +31,7 @@ export type FormWorkflowErrorHandler = (errMsg: string) => void; export type FormWorkflowHandlerArgs = { formFields?: FormData; + formFieldsChanged: boolean; errHandler?: FormWorkflowErrorHandler; dispatch?: Dispatch; }; @@ -117,6 +118,7 @@ function FormWorkflow({ formFields, errHandler: setFormError, dispatch, + formFieldsChanged: !!isEdited, }); if (nextButtonConfig?.awaitSuccess) { advance = await pollAsync( @@ -125,6 +127,7 @@ function FormWorkflow({ formFields: newFormFields, errHandler: setFormError, dispatch, + formFieldsChanged: !!isEdited, }), nextButtonConfig.awaitSuccess.awaitTimeout, nextButtonConfig.awaitSuccess.awaitInterval @@ -134,6 +137,7 @@ function FormWorkflow({ formFields: newFormFields, errHandler: setFormError, dispatch, + formFieldsChanged: !!isEdited, }); } } diff --git a/src/components/forms/ValidatedFormControl.tsx b/src/components/forms/ValidatedFormControl.tsx index c7f65b427d..e7f878cb16 100644 --- a/src/components/forms/ValidatedFormControl.tsx +++ b/src/components/forms/ValidatedFormControl.tsx @@ -1,5 +1,6 @@ import React from "react"; import omit from "lodash/omit"; +import isString from "lodash/isString"; import { Form } from "@edx/paragon"; @@ -31,11 +32,13 @@ const ValidatedFormControl = (props: ValidatedFormControlProps) => { setFormFieldAction({ fieldId: props.formId, value: e.target.value }) ); }; - const error = errorMap && errorMap[props.formId]; + const errors = errorMap?.[props.formId]; + // Show error message if an error message was part of any detected errors + const showError = errors?.find?.(error => isString(error)); const formControlProps = { ...omit(props, ["formId"]), onChange, - isInvalid: !!error, + isInvalid: showError, id: props.formId, value: formFields && formFields[props.formId], }; @@ -45,8 +48,8 @@ const ValidatedFormControl = (props: ValidatedFormControlProps) => { {props.fieldInstructions && ( {props.fieldInstructions} )} - {error && ( - {error} + {showError && ( + {showError} )} ); diff --git a/src/components/forms/data/reducer.test.ts b/src/components/forms/data/reducer.test.ts index e2fd81d60c..4e12169486 100644 --- a/src/components/forms/data/reducer.test.ts +++ b/src/components/forms/data/reducer.test.ts @@ -67,11 +67,14 @@ const steps: FormWorkflowStep[] = [ const testFormFields = { address: "123 45th st", zip: 12345 }; -const getTestInitializeFormArguments = () => ({ - formFields: testFormFields, - validations: dummyFormFieldsValidations, - currentStep: steps[0], -}); +const getTestInitializeFormArguments = () => { + const testArgs = { + formFields: { ...testFormFields }, + validations: dummyFormFieldsValidations, + currentStep: steps[0], + }; + return testArgs; +}; const getTestExpectedState = () => ({ formFields: testFormFields, @@ -93,6 +96,8 @@ describe("Form reducer tests", () => { formFields, currentStep: steps[0], isEdited: false, + hasErrors: false, + errorMap: {}, }); }); @@ -104,7 +109,7 @@ describe("Form reducer tests", () => { isEdited: true, hasErrors: true, errorMap: { - zip: [["zip", "Zip code should be positive nonzero number"]], + zip: ["Zip code should be positive nonzero number"], }, }; @@ -113,15 +118,16 @@ describe("Form reducer tests", () => { ).toStrictEqual(expected); }); - test("Update form fields", () => { + test("Update form fields", async () => { const action = updateFormFieldsAction({ formFields: { zip: 54321, address: "543 21st st" }, }); const expected = { ...getTestExpectedState(), - formFields: { zip: 54321, address: "543 21st st"}, + formFields: { zip: 54321, address: "543 21st st" }, hasErrors: false, + errorMap: {}, }; expect( @@ -135,6 +141,8 @@ describe("Form reducer tests", () => { const expected = { ...getTestExpectedState(), stateMap: { TEST_STATE: "Test State" }, + hasErrors: false, + errorMap: {}, }; expect( @@ -148,6 +156,8 @@ describe("Form reducer tests", () => { const expected = { ...getTestExpectedState(), currentStep: steps[1], + hasErrors: false, + errorMap: {}, }; expect( diff --git a/src/components/forms/data/reducer.ts b/src/components/forms/data/reducer.ts index a46f893d2a..b7c7c7bf1c 100644 --- a/src/components/forms/data/reducer.ts +++ b/src/components/forms/data/reducer.ts @@ -1,5 +1,6 @@ import groupBy from "lodash/groupBy"; import isEmpty from "lodash/isEmpty"; +import keys from "lodash/keys" import { SET_FORM_FIELD, SET_STEP, @@ -17,31 +18,6 @@ import type { import type { FormContext, FormFieldValidation } from "../FormContext"; import type { FormWorkflowStep } from "../FormWorkflow"; -export type InitializeFormArguments = { - formFields: FormFields; - validations: FormFieldValidation[]; - currentStep: FormWorkflowStep; -}; -export function initializeForm( - state: FormContext, - action: InitializeFormArguments -) { - const additions: Pick< - FormContext, - "isEdited" | "formFields" | "currentStep" - > = { isEdited: false }; - if (action?.formFields) { - additions.formFields = action.formFields; - } - if (action?.currentStep) { - additions.currentStep = action.currentStep; - } - return { - ...state, - ...additions, - }; -} - const processFormErrors = (state: FormContext): FormContext => { // Get all form errors let errorState: Pick = { @@ -50,6 +26,7 @@ const processFormErrors = (state: FormContext): FormContext => { }; if (state.formFields) { // Generate list of errors with their formFieldIds + // const formFieldsCopy = {...state.formFields}; const errors = state.currentStep?.validations ?.map((validation: FormFieldValidation) => [ validation.formFieldId, @@ -58,10 +35,17 @@ const processFormErrors = (state: FormContext): FormContext => { .filter((err) => !!err[1]); if (!isEmpty(errors)) { // Convert to map of errors indexed by formFieldId + let errorMap = groupBy(errors, (error) => error[0]); + keys(errorMap).forEach((key) => { + // Remove unneeded key from values now that we're grouping by it. + errorMap[key] = errorMap[key].map(kvp => kvp[1]); + }) errorState = { hasErrors: true, - errorMap: groupBy(errors, (error) => error[0]), + errorMap, }; + } else { + errorState = {hasErrors: false, errorMap: {}}; } } @@ -71,6 +55,31 @@ const processFormErrors = (state: FormContext): FormContext => { }; }; +export type InitializeFormArguments = { + formFields: FormFields; + validations: FormFieldValidation[]; + currentStep: FormWorkflowStep; +}; +export function initializeForm( + state: FormContext, + action: InitializeFormArguments +) { + const additions: Pick< + FormContext, + "isEdited" | "formFields" | "currentStep" + > = { isEdited: false }; + if (action?.formFields) { + additions.formFields = action.formFields; + } + if (action?.currentStep) { + additions.currentStep = action.currentStep; + } + return { + ...(processFormErrors(state)), + ...additions, + }; +} + export function FormReducer( state: FormContext = { formFields: {} }, action: FormActionArguments @@ -95,6 +104,7 @@ export function FormReducer( formFields: updateFormFieldsArgs.formFields, isEdited: false, hasErrors: false, + errorMap: {} }; case SET_STEP: const setStepArgs = action as SetStepArguments; diff --git a/src/components/settings/ConfigErrorModal.test.tsx b/src/components/settings/ConfigErrorModal.test.tsx new file mode 100644 index 0000000000..fad080dbee --- /dev/null +++ b/src/components/settings/ConfigErrorModal.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +// @ts-ignore +import ConfigErrorModal from './ConfigErrorModal.tsx'; + +const mockClose = jest.fn(); + +const overrideText = 'ayylmao'; + +describe('', () => { + test('renders Error Modal', () => { + render( + , + ); + expect(screen.queryByText('We were unable to process your request to submit a new LMS configuration. Please try submitting again or contact support for help.')); + expect(screen.queryByText('Contact Support')); + }); + test('renders Error Modal with text override', () => { + render( + , + ); + expect(screen.queryByText('ayylmao')); + expect(screen.queryByText('Contact Support')); + }); +}); diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 83646d178b..9e287b00ac 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -65,6 +65,7 @@ const LMSConfigPage = ({ onSubmit: setExistingConfigFormData, onClickCancel: handleCloseWorkflow, existingData: existingConfigFormData, + existingConfigNames: existingConfigs, })} onClickOut={handleCloseWorkflow} onSubmit={setExistingConfigFormData} diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx index 1930f73472..d394793b1d 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx @@ -1,5 +1,3 @@ -import type { Dispatch } from "react"; - import handleErrors from "../../../utils"; import LmsApiService from "../../../../../data/services/LmsApiService"; import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; @@ -13,6 +11,7 @@ import { import CanvasConfigActivatePage from "./CanvasConfigActivatePage.tsx"; import CanvasConfigAuthorizePage, { validations, + formFieldNames // @ts-ignore } from "./CanvasConfigAuthorizePage.tsx"; import type { @@ -29,6 +28,9 @@ import { updateFormFieldsAction, // @ts-ignore } from "../../../../forms/data/actions.ts"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; export type CanvasConfigCamelCase = { canvasAccountId: string; @@ -61,10 +63,8 @@ export type CanvasConfigSnakeCase = { export type CanvasFormConfigProps = { enterpriseCustomerUuid: string; existingData: CanvasConfigCamelCase; - onSubmit: ( - canvasConfig: CanvasConfigCamelCase, - errHandler?: FormWorkflowErrorHandler - ) => void; + existingConfigNames: string[]; + onSubmit: (canvasConfig: CanvasConfigCamelCase) => void; onClickCancel: (submitted: boolean, status: string) => Promise; }; @@ -75,7 +75,18 @@ export const CanvasFormConfig = ({ onSubmit, onClickCancel, existingData, + existingConfigNames, }: CanvasFormConfigProps): FormWorkflowConfig => { + const configNames: string[] = existingConfigNames?.filter( (name) => name !== existingData.displayName); + const checkForDuplicateNames: FormFieldValidation = { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (formFields: CanvasConfigCamelCase) => { + return configNames?.includes(formFields.displayName) + ? "Display name already taken" + : false; + }, + }; + const saveChanges = async ( formFields: CanvasConfigCamelCase, errHandler: (errMsg: string) => void @@ -93,12 +104,18 @@ export const CanvasFormConfig = ({ transformedConfig, existingData.id ); - onSubmit(formFields, errHandler); + onSubmit(formFields); } catch (error) { err = handleErrors(error); } } else { - // TODO: Don't expose option if object not created yet + try { + transformedConfig.active = false; + await LmsApiService.postNewCanvasConfig(transformedConfig); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } } if (err) { @@ -109,6 +126,7 @@ export const CanvasFormConfig = ({ const handleSubmit = async ({ formFields, + formFieldsChanged, errHandler, dispatch, }: FormWorkflowHandlerArgs) => { @@ -118,35 +136,36 @@ export const CanvasFormConfig = ({ ) as CanvasConfigSnakeCase; transformedConfig.enterprise_customer = enterpriseCustomerUuid; let err = ""; - - if (currentFormFields?.id) { - try { - transformedConfig.active = existingData.active; - const response = await LmsApiService.updateCanvasConfig( - transformedConfig, - existingData.id - ); - currentFormFields = camelCaseDict( - response.data - ) as CanvasConfigCamelCase; - onSubmit(currentFormFields, errHandler); - dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - const response = await LmsApiService.postNewCanvasConfig( - transformedConfig - ); - currentFormFields = camelCaseDict( - response.data - ) as CanvasConfigCamelCase; - onSubmit(currentFormFields, errHandler); - dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); - } catch (error) { - err = handleErrors(error); + if (formFieldsChanged) { + if (currentFormFields?.id) { + try { + transformedConfig.active = existingData.active; + const response = await LmsApiService.updateCanvasConfig( + transformedConfig, + existingData.id + ); + currentFormFields = camelCaseDict( + response.data + ) as CanvasConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + const response = await LmsApiService.postNewCanvasConfig( + transformedConfig + ); + currentFormFields = camelCaseDict( + response.data + ) as CanvasConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } } } if (err) { @@ -169,7 +188,6 @@ export const CanvasFormConfig = ({ errHandler, dispatch, }: FormWorkflowHandlerArgs) => { - // Return immediately if already authorized if (formFields?.id) { let err = ""; try { @@ -205,7 +223,7 @@ export const CanvasFormConfig = ({ { index: 0, formComponent: CanvasConfigAuthorizePage, - validations, + validations: validations.concat([checkForDuplicateNames]), stepName: "Authorize", saveChanges, nextButtonConfig: (formFields: CanvasConfigCamelCase) => { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx index c7cf9f5f24..dffdca3854 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx @@ -5,11 +5,14 @@ import { Info } from "@edx/paragon/icons"; // @ts-ignore import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; -import { urlValidation } from "../../../../../utils"; -import { +import { isValidNumber, urlValidation } from "../../../../../utils"; +import type { FormFieldValidation, - useFormContext, } from "../../../../forms/FormContext"; +import { + useFormContext, + // @ts-ignore +} from "../../../../forms/FormContext.tsx"; // @ts-ignore import FormWaitModal from "../../../../forms/FormWaitModal.tsx"; // @ts-ignore @@ -19,7 +22,7 @@ import { setWorkflowStateAction } from "../../../../forms/data/actions.ts"; // @ts-ignore import { LMS_AUTHORIZATION_FAILED } from "./CanvasConfig.tsx"; -const formFieldNames = { +export const formFieldNames = { DISPLAY_NAME: "displayName", CLIENT_ID: "clientId", CLIENT_SECRET: "clientSecret", @@ -31,19 +34,50 @@ export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.CANVAS_BASE_URL, validator: (fields) => { - const error = !urlValidation(fields[formFieldNames.CANVAS_BASE_URL]); - return error && "Please enter a valid URL"; + const canvasUrl = fields[formFieldNames.CANVAS_BASE_URL]; + if (canvasUrl) { + const error = !urlValidation(canvasUrl); + return error ? "Please enter a valid URL" : false; + } else { + return true; + } + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const displayName = fields[formFieldNames.DISPLAY_NAME]; + return !displayName; }, }, { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { - // TODO: Check for duplicate display names const displayName = fields[formFieldNames.DISPLAY_NAME]; const error = displayName?.length > 20; return error && "Display name should be 20 characters or less"; }, }, + { + formFieldId: formFieldNames.ACCOUNT_ID, + validator: (fields) => { + return !isValidNumber(fields[formFieldNames.ACCOUNT_ID]); + }, + }, + { + formFieldId: formFieldNames.CLIENT_ID, + validator: (fields) => { + const clientId = fields[formFieldNames.CLIENT_ID]; + return !clientId; + }, + }, + { + formFieldId: formFieldNames.CLIENT_SECRET, + validator: (fields) => { + const clientSecret = fields[formFieldNames.CLIENT_SECRET]; + return !clientSecret; + }, + }, ]; // Settings page of Canvas LMS config workflow diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx deleted file mode 100644 index 2d92177709..0000000000 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.jsx +++ /dev/null @@ -1,375 +0,0 @@ -import React from 'react'; -import { - act, render, fireEvent, screen, waitFor, -} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom/extend-expect'; - -// TODO: Rewrite for new Canvas workflow -import CanvasConfig from '../LMSConfigs/Canvas/CanvasConfig.tsx'; -import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; -import LmsApiService from '../../../../data/services/LmsApiService'; - -jest.mock('../../data/constants', () => ({ - ...jest.requireActual('../../data/constants'), - LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, -})); -window.open = jest.fn(); -const mockUpdateConfigApi = jest.spyOn(LmsApiService, 'updateCanvasConfig'); -const mockConfigResponseData = { - uuid: 'foobar', - id: 1, - display_name: 'display name', - canvas_base_url: 'https://foobar.com', - canvas_account_id: 1, - client_id: '123abc', - client_secret: 'asdhfahsdf', - active: false, -}; -mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockPostConfigApi = jest.spyOn(LmsApiService, 'postNewCanvasConfig'); -mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleCanvasConfig'); -mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: 'foobar' } }); - -const enterpriseId = 'test-enterprise-id'; - -const mockOnClick = jest.fn(); -// Freshly creating a config will have an empty existing data object -const noExistingData = {}; -// Existing config data that has been authorized -const existingConfigData = { - refreshToken: 'foobar', - id: 1, - displayName: 'foobarss', -}; -// Existing invalid data that will be validated on load -const invalidExistingData = { - displayName: 'fooooooooobaaaaaaaaar', - canvasBaseUrl: 'bad_url :^(', -}; -// Existing config data that has not been authorized -const existingConfigDataNoAuth = { - id: 1, - displayName: 'foobar', - canvasBaseUrl: 'https://foobarish.com', - clientId: 'ayylmao', - clientSecret: 'testingsecret', - canvasAccountId: 10, -}; - -const noConfigs = []; -const existingConfigDisplayNames = ['name']; -const existingConfigDisplayNames2 = ['foobar']; - -afterEach(() => { - jest.clearAllMocks(); -}); - -const mockSetExistingConfigFormData = jest.fn(); - -describe('', () => { - test('renders Canvas Config Form', () => { - render( - , - ); - screen.getByLabelText('Display Name'); - screen.getByLabelText('API Client ID'); - screen.getByLabelText('API Client Secret'); - screen.getByLabelText('Canvas Account Number'); - screen.getByLabelText('Canvas Base URL'); - }); - test('test button disable', async () => { - render( - , - ); - expect(screen.getByText('Authorize')).toBeDisabled(); - - userEvent.type(screen.getByLabelText('Display Name'), 'name'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'test4'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test3'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '23'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test6'); - - expect(screen.getByText('Authorize')).toBeDisabled(); - expect(screen.queryByText(INVALID_LINK)); - expect(screen.queryByText(INVALID_NAME)); - await act(async () => { - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Canvas Base URL'), { - target: { value: '' }, - }); - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - }); - test('it edits existing configs on submit', async () => { - render( - , - ); - await act(async () => { - fireEvent.change(screen.getByLabelText('API Client ID'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('API Client Secret'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Canvas Account Number'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Canvas Base URL'), { - target: { value: '' }, - }); - }); - - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - - const expectedConfig = { - canvas_base_url: 'https://www.test4.com', - canvas_account_id: '3', - client_id: 'test1', - client_secret: 'test2', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.updateCanvasConfig).toHaveBeenCalledWith(expectedConfig, 1); - }); - test('it creates new configs on submit', async () => { - render( - , - ); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - - const expectedConfig = { - active: false, - canvas_base_url: 'https://www.test4.com', - canvas_account_id: '3', - client_id: 'test1', - client_secret: 'test2', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); - }); - test('saves draft correctly', async () => { - render( - , - ); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - - userEvent.click(screen.getByText('Cancel')); - userEvent.click(screen.getByText('Save')); - - const expectedConfig = { - active: false, - display_name: 'displayName', - enterprise_customer: enterpriseId, - canvas_account_id: '3', - canvas_base_url: 'https://www.test4.com', - client_id: 'test1', - client_secret: 'test2', - }; - expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); - }); - test('Authorizing a config will initiate backend polling', async () => { - render( - , - ); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - - expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); - }); - test('Authorizing an existing, edited config will call update config endpoint', async () => { - render( - , - ); - act(() => { - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Canvas Base URL'), { - target: { value: '' }, - }); - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockUpdateConfigApi).toHaveBeenCalled(); - expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); - await waitFor(() => expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE)); - }); - test('Authorizing an existing config will not call update or create config endpoint', async () => { - render( - , - ); - expect(screen.getByText('Authorize')).not.toBeDisabled(); - - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); - expect(mockUpdateConfigApi).not.toHaveBeenCalled(); - expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); - }); - test('validates poorly formatted existing data on load', () => { - render( - , - ); - expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - expect(screen.getByText(INVALID_NAME)).toBeInTheDocument(); - }); - test('validates properly formatted existing data on load', () => { - render( - , - ); - expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - }); - test('it calls setExistingConfigFormData after authorization', async () => { - render( - , - ); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - expect(screen.getByText('Authorize')).not.toBeDisabled(); - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ - uuid: 'foobar', - id: 1, - displayName: 'display name', - canvasBaseUrl: 'https://foobar.com', - canvasAccountId: 1, - clientId: '123abc', - clientSecret: 'asdhfahsdf', - active: false, - }); - }); -}); diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx new file mode 100644 index 0000000000..47d83885b4 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx @@ -0,0 +1,383 @@ +import React from "react"; +import { + act, + render, + fireEvent, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/extend-expect"; + +// @ts-ignore +import CanvasConfig from "../LMSConfigs/Canvas/CanvasConfig.tsx"; +import { + INVALID_LINK, + INVALID_NAME, +} from "../../data/constants"; +import LmsApiService from "../../../../data/services/LmsApiService"; +// @ts-ignore +import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; +import { findElementWithText } from "../../../test/testUtils"; + +jest.mock("../../data/constants", () => ({ + ...jest.requireActual("../../data/constants"), + LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, +})); +window.open = jest.fn(); +const mockUpdateConfigApi = jest.spyOn(LmsApiService, "updateCanvasConfig"); +const mockConfigResponseData = { + uuid: "foobar", + id: 1, + display_name: "display name", + canvas_base_url: "https://foobar.com", + canvas_account_id: 1, + client_id: "123abc", + client_secret: "asdhfahsdf", + active: false, +}; +mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); + +const mockPostConfigApi = jest.spyOn(LmsApiService, "postNewCanvasConfig"); +mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); + +const mockFetchSingleConfig = jest.spyOn( + LmsApiService, + "fetchSingleCanvasConfig" +); +mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: "foobar" } }); + +const enterpriseId = "test-enterprise-id"; + +const mockOnClick = jest.fn(); +// Freshly creating a config will have an empty existing data object +const noExistingData = {}; +// Existing config data that has been authorized +const existingConfigData = { + active: true, + refreshToken: "foobar", + id: 1, + displayName: "foobarss", +}; +// Existing invalid data that will be validated on load +const invalidExistingData = { + displayName: "fooooooooobaaaaaaaaar", + canvasBaseUrl: "bad_url :^(", +}; +// Existing config data that has not been authorized +const existingConfigDataNoAuth = { + id: 1, + displayName: "foobar", + canvasBaseUrl: "https://foobarish.com", + clientId: "ayylmao", + clientSecret: "testingsecret", + canvasAccountId: 10, +}; + +const noConfigs = []; + +afterEach(() => { + jest.clearAllMocks(); +}); + +const mockSetExistingConfigFormData = jest.fn(); + +function testCanvasConfigSetup(formData) { + return ( + + ); +} + +async function clearForm() { + await act(async () => { + fireEvent.change(screen.getByLabelText('API Client ID'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('API Client Secret'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Canvas Account Number'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Canvas Base URL'), { + target: { value: '' }, + }); + }); +} + + +describe("", () => { + test("renders Canvas Authorize Form", () => { + render(testCanvasConfigSetup(noConfigs)); + + screen.getByLabelText("Display Name"); + screen.getByLabelText("API Client ID"); + screen.getByLabelText("API Client Secret"); + screen.getByLabelText("Canvas Account Number"); + screen.getByLabelText("Canvas Base URL"); + }); + test("test button disable", async () => { + const { container } = render(testCanvasConfigSetup(noExistingData)); + + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + await clearForm(); + expect(authorizeButton).toBeDisabled(); + userEvent.type(screen.getByLabelText("Display Name"), "name"); + userEvent.type(screen.getByLabelText("Canvas Base URL"), "test4"); + userEvent.type(screen.getByLabelText("API Client ID"), "test3"); + userEvent.type(screen.getByLabelText("Canvas Account Number"), "23"); + userEvent.type(screen.getByLabelText("API Client Secret"), "test6"); + + expect(authorizeButton).toBeDisabled(); + expect(screen.queryByText(INVALID_LINK)); + expect(screen.queryByText(INVALID_NAME)); + await act(async () => { + fireEvent.change(screen.getByLabelText("Display Name"), { + target: { value: "" }, + }); + fireEvent.change(screen.getByLabelText("Canvas Base URL"), { + target: { value: "" }, + }); + }); + userEvent.type(screen.getByLabelText("Display Name"), "displayName"); + userEvent.type( + screen.getByLabelText("Canvas Base URL"), + "https://www.test4.com" + ); + + expect(authorizeButton).not.toBeDisabled(); + }); + test('it edits existing configs on submit', async () => { + const { container } = render(testCanvasConfigSetup(existingConfigData)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + + await clearForm(); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); + + expect(authorizeButton).not.toBeDisabled(); + + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + + const expectedConfig = { + active: true, + id: 1, + refresh_token: "foobar", + canvas_base_url: 'https://www.test4.com', + canvas_account_id: '3', + client_id: 'test1', + client_secret: 'test2', + display_name: 'displayName', + enterprise_customer: enterpriseId, + }; + expect(LmsApiService.updateCanvasConfig).toHaveBeenCalledWith(expectedConfig, 1); + }); + test('it creates new configs on submit', async () => { + const { container } = render(testCanvasConfigSetup(noExistingData)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); + await waitFor(() => expect(authorizeButton).not.toBeDisabled()); + + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + + const expectedConfig = { + active: false, + canvas_base_url: 'https://www.test4.com', + canvas_account_id: '3', + client_id: 'test1', + client_secret: 'test2', + display_name: 'displayName', + enterprise_customer: enterpriseId, + }; + expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); + }); + test('saves draft correctly', async () => { + const { container } = render(testCanvasConfigSetup(noExistingData)); + const cancelButton = findElementWithText( + container, + "button", + "Cancel" + ); + await clearForm(); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); + + expect(cancelButton).not.toBeDisabled(); + userEvent.click(cancelButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Exit configuration')).toBeInTheDocument()); + + userEvent.click(screen.getByText('Exit')); + + const expectedConfig = { + active: false, + display_name: 'displayName', + enterprise_customer: enterpriseId, + canvas_account_id: '3', + canvas_base_url: 'https://www.test4.com', + client_id: 'test1', + client_secret: 'test2', + }; + expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); + }); + test('Authorizing a config will initiate backend polling', async () => { + const { container } = render(testCanvasConfigSetup(noExistingData)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + await clearForm(); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); + + expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + + expect(window.open).toHaveBeenCalled(); + expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + }); + test('Authorizing an existing, edited config will call update config endpoint', async () => { + const { container } = render(testCanvasConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + act(() => { + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Canvas Base URL'), { + target: { value: '' }, + }); + }); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + + expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Authorization in progress')).toBeInTheDocument()); + expect(mockUpdateConfigApi).toHaveBeenCalled(); + expect(window.open).toHaveBeenCalled(); + expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); + }); + test('Authorizing an existing config will not call update or create config endpoint', async () => { + const { container } = render(testCanvasConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + + expect(authorizeButton).not.toBeDisabled(); + + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); + expect(mockUpdateConfigApi).not.toHaveBeenCalled(); + expect(window.open).toHaveBeenCalled(); + expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + }); + test('validates poorly formatted existing data on load', async () => { + render(testCanvasConfigSetup(invalidExistingData)); + expect(screen.getByText("Please enter a valid URL")).toBeInTheDocument(); + await waitFor(() => expect(expect(screen.getByText("Display name should be 20 characters or less")).toBeInTheDocument())); + }); + test('validates properly formatted existing data on load', () => { + render(testCanvasConfigSetup(existingConfigDataNoAuth)); + expect(screen.queryByText("Please enter a valid URL")).not.toBeInTheDocument(); + expect(screen.queryByText("Display name should be 20 characters or less")).not.toBeInTheDocument(); + }); + test('it calls setExistingConfigFormData after authorization', async () => { + const { container } = render(testCanvasConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + act(() => { + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + }); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); + + const activateButton = findElementWithText( + container, + "button", + "Activate" + ); + userEvent.click(activateButton); + expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ + uuid: 'foobar', + id: 1, + displayName: 'display name', + canvasBaseUrl: 'https://foobar.com', + canvasAccountId: 1, + clientId: '123abc', + clientSecret: 'asdhfahsdf', + active: false, + }); + }); +}); diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index fe20086565..b19c064fbe 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -142,7 +142,7 @@ describe('', () => { }); const cancelButton = screen.getByText('Cancel'); userEvent.click(cancelButton); - expect(await screen.findByText('Do you want to save your work?')).toBeTruthy(); + expect(await screen.findByText('Exit configuration')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); expect(screen.queryByText('Connect Canvas')).toBeFalsy(); diff --git a/src/components/test/testUtils.jsx b/src/components/test/testUtils.jsx index a81ef6fe22..539cd0a3ad 100644 --- a/src/components/test/testUtils.jsx +++ b/src/components/test/testUtils.jsx @@ -24,3 +24,9 @@ export function renderWithRouter( history, }; } + +// Search for element by type and inner text +export function findElementWithText(container, type, text) { + const elements = container.querySelectorAll(type); + return [...elements].find((elem) => elem.innerHTML.includes(text)); +} diff --git a/src/utils.js b/src/utils.js index ae4ba3e011..13a0a2dade 100644 --- a/src/utils.js +++ b/src/utils.js @@ -3,6 +3,8 @@ import camelCase from 'lodash/camelCase'; import snakeCase from 'lodash/snakeCase'; import isArray from 'lodash/isArray'; import mergeWith from 'lodash/mergeWith'; +import isNumber from 'lodash/isNumber'; +import isString from 'lodash/isString'; import isEmail from 'validator/lib/isEmail'; import isEmpty from 'validator/lib/isEmpty'; import isNumeric from 'validator/lib/isNumeric'; @@ -105,9 +107,17 @@ const isTriggerKey = ({ triggerKeys, action, key }) => ( // Validation functions const isRequired = (value = '') => (isEmpty(value) ? 'This field is required.' : undefined); const isValidEmail = (value = '') => (!isEmail(value) ? 'Must be a valid email address.' : undefined); -const isValidNumber = (value = '') => (!isEmpty(value) && !isNumeric(value, { no_symbols: true }) ? 'Must be a valid number.' : undefined); +const isNotValidNumberString = (value = '') => (!isEmpty(value) && !isNumeric(value, { no_symbols: true }) ? 'Must be a valid number.' : undefined); const maxLength = max => value => (value && value.length > max ? 'Must be 512 characters or less' : undefined); const maxLength512 = maxLength(512); +const isValidNumber = (value) => { + // Verify is a valid number, whether it's a javascript number or string representation of a number + let isValidNum = isNumber(value); + if (!isValidNum && isString(value)) { + isValidNum = !isNotValidNumberString(value); + } + return isValidNum; +}; /** camelCase <--> snake_case functions * Because responses from django come as snake_cased JSON, its best @@ -358,4 +368,5 @@ export { normalizeFileUpload, capitalizeFirstLetter, pollAsync, + isNotValidNumberString, }; diff --git a/src/utils.test.js b/src/utils.test.js index bc03907cb8..c2c713bdc1 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -4,6 +4,7 @@ import { snakeCaseDict, snakeCaseFormData, pollAsync, + isValidNumber, } from './utils'; describe('utils', () => { @@ -81,4 +82,15 @@ describe('utils', () => { expect(mockPoll).toBeCalledTimes(4); }); }); + + describe('validations', () => { + it('detects valid number', () => { + expect(isValidNumber(1)).toEqual(true); + expect(isValidNumber('1')).toEqual(true); + expect(isValidNumber(Infinity)).toEqual(true); + expect(isValidNumber('One')).toEqual(false); + expect(isValidNumber({})).toEqual(false); + expect(isValidNumber(undefined)).toEqual(false); + }); + }); }); From 96dc2c6f159b1b1379922b3ad7c14f8a804bd3f5 Mon Sep 17 00:00:00 2001 From: Brian Citro Date: Fri, 10 Mar 2023 09:51:41 -0500 Subject: [PATCH 62/73] fix: hide integration sync status message behind feature flag --- .../settings/SettingsLMSTab/ExistingCard.jsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/settings/SettingsLMSTab/ExistingCard.jsx b/src/components/settings/SettingsLMSTab/ExistingCard.jsx index 8b5c48c614..7405c22b13 100644 --- a/src/components/settings/SettingsLMSTab/ExistingCard.jsx +++ b/src/components/settings/SettingsLMSTab/ExistingCard.jsx @@ -36,6 +36,7 @@ const ExistingCard = ({ const redirectPath = `${useRouteMatch().url}`; const [showDeleteModal, setShowDeleteModal] = useState(false); const isEdxStaff = getAuthenticatedUser().administrator; + const showErrorReporting = isEdxStaff && features.FEATURE_INTEGRATION_REPORTING; const toggleConfig = async (id, channelType, toggle) => { const configOptions = { @@ -101,7 +102,7 @@ const ExistingCard = ({ const getCardButton = () => { switch (getStatus(config)) { case ACTIVE: - if (isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) { + if (showErrorReporting) { return ; } return null; @@ -180,7 +181,7 @@ const ExistingCard = ({ alt="Actions dropdown" /> - {(isInactive && isEdxStaff && features.FEATURE_INTEGRATION_REPORTING) && ( + {(isInactive && showErrorReporting) && (
- - {getLastSync()} + {showErrorReporting && ( + <> + + {getLastSync()} + + )}
{getCardButton()}
From 4773e32baa5706ce945c310f49d594e012e5a762 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Mon, 13 Mar 2023 16:15:20 -0600 Subject: [PATCH 63/73] feat: changing blackboard to tsx (#976) --- src/components/forms/FormWaitModal.tsx | 4 +- src/components/forms/FormWorkflow.tsx | 2 +- .../settings/SettingsLMSTab/LMSConfigPage.jsx | 20 +- .../Blackboard/BlackboardConfig.tsx | 286 +++++++++++++++ .../BlackboardConfigActivatePage.tsx | 24 ++ .../BlackboardConfigAuthorizePage.tsx | 95 +++++ .../LMSConfigs/BlackboardConfig.jsx | 317 ---------------- .../tests/BlackboardConfig.test.jsx | 342 ------------------ .../tests/BlackboardConfig.test.tsx | 335 +++++++++++++++++ .../tests/LmsConfigPage.test.jsx | 5 +- 10 files changed, 759 insertions(+), 671 deletions(-) create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx delete mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx delete mode 100644 src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx create mode 100644 src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx diff --git a/src/components/forms/FormWaitModal.tsx b/src/components/forms/FormWaitModal.tsx index 8f1b25b603..9c59e122ac 100644 --- a/src/components/forms/FormWaitModal.tsx +++ b/src/components/forms/FormWaitModal.tsx @@ -25,7 +25,7 @@ const FormWaitModal = ({ return ( -
+
-

{text}

+

{text}

); }; diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index 78a1852893..2b49996f44 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -184,7 +184,7 @@ function FormWorkflow({ disabled={hasErrors || awaitingAsyncAction} > {nextButtonConfig.buttonText} - {nextButtonConfig.opensNewWindow && } + {nextButtonConfig.opensNewWindow && } )} diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 9e287b00ac..9b1e4857a8 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -13,7 +13,7 @@ import { MOODLE_TYPE, SAP_TYPE, } from '../data/constants'; -import BlackboardConfig from './LMSConfigs/BlackboardConfig'; +import { BlackboardFormConfig } from './LMSConfigs/Blackboard/BlackboardConfig.tsx'; import { CanvasFormConfig } from './LMSConfigs/Canvas/CanvasConfig.tsx'; import CornerstoneConfig from './LMSConfigs/CornerstoneConfig'; import DegreedConfig from './LMSConfigs/DegreedConfig'; @@ -24,6 +24,7 @@ import FormContextWrapper from '../../forms/FormContextWrapper.tsx'; // TODO: Add remaining configs const flowConfigs = { + [BLACKBOARD_TYPE]: BlackboardFormConfig, [CANVAS_TYPE]: CanvasFormConfig, }; @@ -50,12 +51,17 @@ const LMSConfigPage = ({ {/* TODO: Replace giant switch */} {LMSType === BLACKBOARD_TYPE && ( - )} {LMSType === CANVAS_TYPE && ( diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx new file mode 100644 index 0000000000..24052ad152 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx @@ -0,0 +1,286 @@ +import handleErrors from "../../../utils"; +import LmsApiService from "../../../../../data/services/LmsApiService"; +import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; +import { + BLACKBOARD_OAUTH_REDIRECT_URL, + LMS_CONFIG_OAUTH_POLLING_INTERVAL, + LMS_CONFIG_OAUTH_POLLING_TIMEOUT, + SUBMIT_TOAST_MESSAGE, +} from "../../../data/constants"; +// @ts-ignore +import BlackboardConfigActivatePage from "./BlackboardConfigActivatePage.tsx"; +import BlackboardConfigAuthorizePage, { + validations, + formFieldNames + // @ts-ignore +} from "./BlackboardConfigAuthorizePage.tsx"; +import type { + FormWorkflowButtonConfig, + FormWorkflowConfig, + FormWorkflowStep, + FormWorkflowHandlerArgs, + FormWorkflowErrorHandler, +} from "../../../../forms/FormWorkflow"; +// @ts-ignore +import { WAITING_FOR_ASYNC_OPERATION } from "../../../../forms/FormWorkflow.tsx"; +import { + setWorkflowStateAction, + updateFormFieldsAction, + // @ts-ignore +} from "../../../../forms/data/actions.ts"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; + +export type BlackboardConfigCamelCase = { + blackboardAccountId: string; + blackboardBaseUrl: string; + displayName: string; + clientId: string; + clientSecret: string; + id: string; + active: boolean; + uuid: string; + refreshToken: string; +}; + +// TODO: Can we generate this dynamically? +// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html +export type BlackboardConfigSnakeCase = { + blackboard_base_url: string; + display_name: string; + id: string; + active: boolean; + uuid: string; + enterprise_customer: string; + refresh_token: string; +}; + +// TODO: Make this a generic type usable by all lms configs +export type BlackboardFormConfigProps = { + enterpriseCustomerUuid: string; + existingData: BlackboardConfigCamelCase; + existingConfigNames: string[]; + onSubmit: (blackboardConfig: BlackboardConfigCamelCase) => void; + onClickCancel: (submitted: boolean, status: string) => Promise; +}; + +export const LMS_AUTHORIZATION_FAILED = "LMS AUTHORIZATION FAILED"; + +export const BlackboardFormConfig = ({ + enterpriseCustomerUuid, + onSubmit, + onClickCancel, + existingData, + existingConfigNames, +}: BlackboardFormConfigProps): FormWorkflowConfig => { + const configNames: string[] = existingConfigNames?.filter( (name) => name !== existingData.displayName); + const checkForDuplicateNames: FormFieldValidation = { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (formFields: BlackboardConfigCamelCase) => { + return configNames?.includes(formFields.displayName) + ? "Display name already taken" + : false; + }, + }; + + const saveChanges = async ( + formFields: BlackboardConfigCamelCase, + errHandler: (errMsg: string) => void + ) => { + const transformedConfig: BlackboardConfigSnakeCase = snakeCaseDict( + formFields + ) as BlackboardConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err = ""; + + if (formFields.id) { + try { + transformedConfig.active = existingData.active; + await LmsApiService.updateBlackboardConfig( + transformedConfig, + existingData.id + ); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + await LmsApiService.postNewBlackboardConfig(transformedConfig); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } + + if (err) { + errHandler(err); + } + return !err; + }; + + const handleSubmit = async ({ + formFields, + formFieldsChanged, + errHandler, + dispatch, + }: FormWorkflowHandlerArgs) => { + let currentFormFields = formFields; + const transformedConfig: BlackboardConfigSnakeCase = snakeCaseDict( + formFields + ) as BlackboardConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err = ""; + if (formFieldsChanged) { + if (currentFormFields?.id) { + try { + transformedConfig.active = existingData.active; + const response = await LmsApiService.updateBlackboardConfig( + transformedConfig, + existingData.id + ); + currentFormFields = camelCaseDict( + response.data + ) as BlackboardConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + const response = await LmsApiService.postNewBlackboardConfig( + transformedConfig + ); + currentFormFields = camelCaseDict( + response.data + ) as BlackboardConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } + } + if (err) { + errHandler?.(err); + } else if (currentFormFields && !currentFormFields?.refreshToken) { + let appKey = existingData.clientId; + let configUuid = existingData.uuid; + if (!appKey || !configUuid) { + try { + const response = await LmsApiService.fetchBlackboardGlobalConfig(); + appKey = response.data.results[response.data.results.length - 1].app_key; + configUuid = response.data.uuid; + } catch (error) { + err = handleErrors(error); + } + } + const oauthUrl = `${currentFormFields.blackboardBaseUrl}/learn/api/public/v1/oauth2/authorizationcode?` + + `redirect_uri=${BLACKBOARD_OAUTH_REDIRECT_URL}&scope=read%20write%20delete%20offline&` + + `response_type=code&client_id=${appKey}&state=${configUuid}`; + window.open(oauthUrl); + + // Open the oauth window for the user + window.open(oauthUrl); + dispatch?.(setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, true)); + } + return currentFormFields; + }; + + const awaitAfterSubmit = async ({ + formFields, + errHandler, + dispatch, + }: FormWorkflowHandlerArgs) => { + if (formFields?.id) { + let err = ""; + try { + const response = await LmsApiService.fetchSingleBlackboardConfig( + formFields.id + ); + if (response.data.refresh_token) { + dispatch?.( + setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false) + ); + return true; + } + } catch (error) { + err = handleErrors(error); + } + if (err) { + errHandler?.(err); + return false; + } + } + + return false; + }; + + const onAwaitTimeout = async ({ + dispatch, + }: FormWorkflowHandlerArgs) => { + dispatch?.(setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false)); + dispatch?.(setWorkflowStateAction(LMS_AUTHORIZATION_FAILED, true)); + }; + + const steps: FormWorkflowStep[] = [ + { + index: 0, + formComponent: BlackboardConfigAuthorizePage, + validations: validations.concat([checkForDuplicateNames]), + stepName: "Authorize", + saveChanges, + nextButtonConfig: (formFields: BlackboardConfigCamelCase) => { + let config = { + buttonText: "Authorize", + opensNewWindow: false, + onClick: handleSubmit, + }; + if (!formFields.refreshToken) { + config = { + ...config, + ...{ + opensNewWindow: true, + awaitSuccess: { + awaitCondition: awaitAfterSubmit, + awaitInterval: LMS_CONFIG_OAUTH_POLLING_INTERVAL, + awaitTimeout: LMS_CONFIG_OAUTH_POLLING_TIMEOUT, + onAwaitTimeout: onAwaitTimeout, + }, + }, + }; + } + return config as FormWorkflowButtonConfig; + }, + }, + { + index: 1, + formComponent: BlackboardConfigActivatePage, + validations: [], + stepName: "Activate", + saveChanges, + nextButtonConfig: () => ({ + buttonText: "Activate", + opensNewWindow: false, + onClick: () => { + onClickCancel(true, SUBMIT_TOAST_MESSAGE); + return Promise.resolve(existingData); + }, + }), + }, + ]; + + // Go to authorize step for now + const getCurrentStep = () => steps[0]; + + return { + getCurrentStep, + steps, + }; +}; + +export default BlackboardFormConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx new file mode 100644 index 0000000000..e410f27d60 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Form } from '@edx/paragon'; + +// Page 3 of Blackboard LMS config workflow +const BlackboardConfigActivatePage = () => ( + +
+

Activate your Blackboard integration

+ +

+ Your Blackboard integration has been successfully authorized and is ready to + activate! +

+ +

+ Once activated, edX For Business will begin syncing content metadata and + learner activity with Blackboard. +

+
+
+); + +export default BlackboardConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx new file mode 100644 index 0000000000..a696a8337e --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx @@ -0,0 +1,95 @@ +import React from "react"; + +import { Form, Alert } from "@edx/paragon"; +import { Info } from "@edx/paragon/icons"; + +// @ts-ignore +import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; +import { urlValidation } from "../../../../../utils"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; +import { + useFormContext, + // @ts-ignore +} from "../../../../forms/FormContext.tsx"; +// @ts-ignore +import FormWaitModal from "../../../../forms/FormWaitModal.tsx"; +// @ts-ignore +import { WAITING_FOR_ASYNC_OPERATION } from "../../../../forms/FormWorkflow.tsx"; +// @ts-ignore +import { setWorkflowStateAction } from "../../../../forms/data/actions.ts"; +// @ts-ignore +import { LMS_AUTHORIZATION_FAILED } from "./BlackboardConfig.tsx"; + +export const formFieldNames = { + DISPLAY_NAME: "displayName", + BLACKBOARD_BASE_URL: "blackboardBaseUrl", +}; + +export const validations: FormFieldValidation[] = [ + { + formFieldId: formFieldNames.BLACKBOARD_BASE_URL, + validator: (fields) => { + const error = !urlValidation(fields[formFieldNames.BLACKBOARD_BASE_URL]); + return error && "Please enter a valid URL"; + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + // TODO: Check for duplicate display names + const displayName = fields[formFieldNames.DISPLAY_NAME]; + const error = displayName?.length > 20; + return error && "Display name should be 20 characters or less"; + }, + }, +]; + +// Settings page of Blackboard LMS config workflow +const BlackboardConfigAuthorizePage = () => { + const { dispatch, stateMap } = useFormContext(); + return ( + +

Authorize connection to Blackboard

+ +
+ {stateMap?.[LMS_AUTHORIZATION_FAILED] && ( + +

Enablement failed

+ We were unable to enable your Blackboard integration. Please try again + or contact enterprise customer support. +
+ )} + + + + + + + + dispatch?.( + setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false) + ) + } + header="Authorization in progress" + text="Please confirm authorization through Blackboard and return to this window once complete." + /> + +
+ ); +}; + +export default BlackboardConfigAuthorizePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx deleted file mode 100644 index b9652d5f02..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/BlackboardConfig.jsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Button, Form, useToggle } from '@edx/paragon'; -import { Error } from '@edx/paragon/icons'; -import isEmpty from 'lodash/isEmpty'; -import buttonBool, { isExistingConfig } from '../utils'; -import handleErrors from '../../utils'; -import LmsApiService from '../../../../data/services/LmsApiService'; -import { snakeCaseDict, urlValidation } from '../../../../utils'; -import ConfigError from '../../ConfigError'; -import { useTimeout, useInterval } from '../../../../data/hooks'; -import ConfigModal from '../ConfigModal'; -import { - BLACKBOARD_OAUTH_REDIRECT_URL, - INVALID_LINK, - INVALID_NAME, - SUBMIT_TOAST_MESSAGE, - LMS_CONFIG_OAUTH_POLLING_INTERVAL, - LMS_CONFIG_OAUTH_POLLING_TIMEOUT, -} from '../../data/constants'; - -const BlackboardConfig = ({ - enterpriseCustomerUuid, onClick, existingData, existingConfigs, setExistingConfigFormData, -}) => { - const [displayName, setDisplayName] = React.useState(''); - const [nameValid, setNameValid] = React.useState(true); - const [blackboardBaseUrl, setBlackboardBaseUrl] = React.useState(''); - const [urlValid, setUrlValid] = React.useState(true); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [modalIsOpen, openModal, closeModal] = useToggle(false); - const [errCode, setErrCode] = React.useState(); - const [edited, setEdited] = React.useState(false); - const [authorized, setAuthorized] = React.useState(false); - const [oauthPollingInterval, setOauthPollingInterval] = React.useState(null); - const [oauthPollingTimeout, setOauthPollingTimeout] = React.useState(null); - const [oauthTimeout, setOauthTimeout] = React.useState(false); - const [configId, setConfigId] = React.useState(); - const config = { - displayName, - blackboardBaseUrl, - }; - - // Polling method to determine if the user has authorized their config - useInterval(async () => { - if (configId) { - let err; - try { - const response = await LmsApiService.fetchSingleBlackboardConfig(configId); - if (response.data.refresh_token) { - // Config has been authorized - setAuthorized(true); - // Stop both the backend polling and the timeout timer - setOauthPollingInterval(null); - setOauthPollingTimeout(null); - setOauthTimeout(false); - // trigger a success call which will redirect the user back to the landing page - onClick(SUBMIT_TOAST_MESSAGE); - } - } catch (error) { - err = handleErrors(error); - } - if (err) { - setErrCode(errCode); - openError(); - } - } - }, oauthPollingInterval); - - // Polling timeout which stops the requests to LMS and toggles the timeout alert - useTimeout(async () => { - setOauthTimeout(true); - setOauthPollingInterval(null); - }, oauthPollingTimeout); - - useEffect(() => { - // Set fields to any existing data - setBlackboardBaseUrl(existingData.blackboardBaseUrl); - setDisplayName(existingData.displayName); - // Check if the config has been authorized - if (existingData.refreshToken) { - setAuthorized(true); - } - }, [existingData]); - - // Cancel button onclick - const handleCancel = () => { - if (edited) { - openModal(); - } else { - onClick(''); - } - }; - - const formatConfigResponseData = (responseData) => { - const formattedConfig = {}; - formattedConfig.blackboardBaseUrl = responseData.blackboard_base_url; - formattedConfig.displayName = responseData.display_name; - formattedConfig.id = responseData.id; - formattedConfig.active = responseData.active; - formattedConfig.clientId = responseData.client_id; - formattedConfig.uuid = responseData.uuid; - return formattedConfig; - }; - - const handleAuthorization = async (event) => { - event.preventDefault(); - const transformedConfig = snakeCaseDict(config); - - transformedConfig.active = false; - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - let configUuid; - let fetchedConfigId; - // First either submit the new config or update the existing one before attempting to authorize - // If the config exists but has been edited, update it - if (!isEmpty(existingData) && edited) { - try { - const response = await LmsApiService.updateBlackboardConfig(transformedConfig, existingData.id); - configUuid = response.data.uuid; - fetchedConfigId = response.data.id; - setExistingConfigFormData(formatConfigResponseData(response.data)); - } catch (error) { - err = handleErrors(error); - } - // If the config didn't previously exist, create it - } else if (isEmpty(existingData)) { - try { - const response = await LmsApiService.postNewBlackboardConfig(transformedConfig); - configUuid = response.data.uuid; - fetchedConfigId = response.data.id; - setExistingConfigFormData(formatConfigResponseData(response.data)); - } catch (error) { - err = handleErrors(error); - } - // else we can retrieve the unedited, existing form's UUID and ID - } else { - configUuid = existingData.uuid; - fetchedConfigId = existingData.id; - } - if (err) { - setErrCode(errCode); - openError(); - } else { - // Either collect app key from the existing config data if it exists, otherwise - // fetch it from the global config - let appKey = existingData.clientId; - if (!appKey) { - try { - const response = await LmsApiService.fetchBlackboardGlobalConfig(); - appKey = response.data.results[response.data.results.length - 1].app_key; - } catch (error) { - err = handleErrors(error); - } - } - if (err) { - setErrCode(errCode); - openError(); - } else { - // Save the config ID so we know one was created in the authorization flow - setConfigId(fetchedConfigId); - // Reset config polling timeout flag - setOauthTimeout(false); - // Start the config polling - setOauthPollingInterval(LMS_CONFIG_OAUTH_POLLING_INTERVAL); - // Start the polling timeout timer - setOauthPollingTimeout(LMS_CONFIG_OAUTH_POLLING_TIMEOUT); - // Open the oauth window for the user - const oauthUrl = `${blackboardBaseUrl}/learn/api/public/v1/oauth2/authorizationcode?` - + `redirect_uri=${BLACKBOARD_OAUTH_REDIRECT_URL}&scope=read%20write%20delete%20offline&` - + `response_type=code&client_id=${appKey}&state=${configUuid}`; - window.open(oauthUrl); - } - } - }; - - const handleSubmit = async (event) => { - event.preventDefault(); - // format config data for the backend - const transformedConfig = snakeCaseDict(config); - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - // If we have a config that already exists, or a config that was created when authorized, post - // an update - if (!isEmpty(existingData) || configId) { - try { - const configIdToUpdate = configId || existingData.id; - transformedConfig.active = existingData.active; - await LmsApiService.updateBlackboardConfig(transformedConfig, configIdToUpdate); - } catch (error) { - err = handleErrors(error); - } - // Otherwise post a new config - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewBlackboardConfig(transformedConfig); - } catch (error) { - err = handleErrors(error); - } - } - if (err) { - setErrCode(errCode); - openError(); - } else { - onClick(SUBMIT_TOAST_MESSAGE); - } - }; - - const validateField = useCallback((field, input) => { - switch (field) { - case 'Blackboard Base URL': - setBlackboardBaseUrl(input); - setUrlValid(urlValidation(input) || input?.length === 0); - break; - case 'Display Name': - setDisplayName(input); - // on edit, we don't want to count the existing displayname as a duplicate - if (isExistingConfig(existingConfigs, input, existingData.displayName)) { - setNameValid(input?.length <= 20); - } else { - setNameValid(input?.length <= 20 && !Object.values(existingConfigs).includes(input)); - } - break; - default: - break; - } - }, [existingConfigs, existingData.displayName]); - - useEffect(() => { - if (!isEmpty(existingData)) { - validateField('Blackboard Base URL', existingData.blackboardBaseUrl); - validateField('Display Name', existingData.displayName); - } - }, [existingConfigs, existingData, validateField]); - - return ( - - - -
- - { - setEdited(true); - validateField('Display Name', e.target.value); - }} - floatingLabel="Display Name" - defaultValue={existingData.displayName} - /> - Create a custom name for this LMS. - {!nameValid && ( - - {INVALID_NAME} - - )} - - - { - setAuthorized(false); - setEdited(true); - validateField('Blackboard Base URL', e.target.value); - }} - floatingLabel="Blackboard Base URL" - defaultValue={existingData.blackboardBaseUrl} - /> - {!urlValid && ( - - {INVALID_LINK} - - )} - - {oauthTimeout && ( -
- - We were unable to confirm your authorization. Please return to your LMS to authorize edX as an integration. -
- )} - - - {!authorized && ( - - )} - -
-
- ); -}; - -BlackboardConfig.propTypes = { - enterpriseCustomerUuid: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - existingData: PropTypes.shape({ - active: PropTypes.bool, - id: PropTypes.number, - displayName: PropTypes.string, - clientId: PropTypes.string, - clientSecret: PropTypes.string, - blackboardBaseUrl: PropTypes.string, - refreshToken: PropTypes.string, - uuid: PropTypes.string, - }).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string).isRequired, - setExistingConfigFormData: PropTypes.func.isRequired, -}; -export default BlackboardConfig; diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx deleted file mode 100644 index b21476e859..0000000000 --- a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.jsx +++ /dev/null @@ -1,342 +0,0 @@ -import React from 'react'; -import { - act, render, fireEvent, screen, waitFor, -} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom/extend-expect'; - -import BlackboardConfig from '../LMSConfigs/BlackboardConfig'; -import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; -import LmsApiService from '../../../../data/services/LmsApiService'; - -jest.mock('../../data/constants', () => ({ - ...jest.requireActual('../../data/constants'), - LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, -})); -window.open = jest.fn(); -const mockUpdateConfigApi = jest.spyOn(LmsApiService, 'updateBlackboardConfig'); -const mockConfigResponseData = { - uuid: 'foobar', - id: 1, - display_name: 'display name', - blackboard_base_url: 'https://foobar.com', - client_id: '123abc', - active: false, -}; -mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockPostConfigApi = jest.spyOn(LmsApiService, 'postNewBlackboardConfig'); -mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockFetchGlobalConfig = jest.spyOn(LmsApiService, 'fetchBlackboardGlobalConfig'); -mockFetchGlobalConfig.mockResolvedValue({ data: { results: [{ app_key: 'ayylmao' }] } }); - -const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); -mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: 'foobar' } }); - -const enterpriseId = 'test-enterprise-id'; -const mockOnClick = jest.fn(); -const noConfigs = []; -const existingConfigDisplayNames = ['name']; -const existingConfigDisplayNames2 = ['foobar']; - -// Freshly creating a config will have an empty existing data object -const noExistingData = {}; -// Existing config data that has been authorized -const existingConfigData = { - refreshToken: 'ayylmao', - id: 1, - displayName: 'foobar', -}; -// Existing config data that has not been authorized -const existingConfigDataNoAuth = { - id: 1, - displayName: 'foobar', - blackboardBaseUrl: 'https://foobarish.com', -}; -// Existing invalid data that will be validated on load -const invalidExistingData = { - displayName: 'fooooooooobaaaaaaaaar', - blackboardBaseUrl: 'bad_url :^(', -}; - -afterEach(() => { - jest.clearAllMocks(); -}); - -const mockSetExistingConfigFormData = jest.fn(); - -describe('', () => { - test('renders Blackboard Config Form', () => { - render( - , - ); - screen.getByLabelText('Display Name'); - screen.getByLabelText('Blackboard Base URL'); - }); - test('test validation and button disable', async () => { - render( - , - ); - expect(screen.getByText('Authorize')).toBeDisabled(); - - userEvent.type(screen.getByLabelText('Display Name'), 'reallyreallyreallyreallyreallylongname'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'test3'); - - expect(screen.getByText('Authorize')).toBeDisabled(); - - expect(screen.queryByText(INVALID_LINK)); - expect(screen.queryByText(INVALID_NAME)); - - // test duplicate display name - userEvent.type(screen.getByLabelText('Display Name'), 'name'); - expect(screen.queryByText(INVALID_NAME)); - }); - test('test validation and button enable', async () => { - render( - , - ); - expect(screen.getByText('Authorize')).toBeDisabled(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - }); - test('it edits existing configs on submit', async () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Blackboard Base URL'), { - target: { value: '' }, - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - - await waitFor(() => userEvent.click(screen.getByText('Authorize'))); - const expectedConfig = { - active: false, - blackboard_base_url: 'https://www.test.com', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(mockUpdateConfigApi).toHaveBeenCalledWith(expectedConfig, 1); - }); - test('it creates new configs on submit', async () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Blackboard Base URL'), { - target: { value: '' }, - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - - const expectedConfig = { - active: false, - blackboard_base_url: 'https://www.test.com', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(mockPostConfigApi).toHaveBeenCalledWith(expectedConfig); - }); - test('saves draft correctly', async () => { - render( - , - ); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - userEvent.click(screen.getByText('Authorize')); - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - - userEvent.click(screen.getByText('Cancel')); - userEvent.click(screen.getByText('Save')); - - const expectedConfig = { - active: false, - blackboard_base_url: 'https://www.test.com', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(mockPostConfigApi).toHaveBeenCalledWith(expectedConfig); - }); - test('Authorizing a config will initiate backend polling', async () => { - render( - , - ); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - await waitFor(() => userEvent.click(screen.getByText('Authorize'))); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); - expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); - }); - test('Authorizing an existing, edited config will call update config endpoint', async () => { - render( - , - ); - act(() => { - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Blackboard Base URL'), { - target: { value: '' }, - }); - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test.com'); - - expect(screen.getByText('Authorize')).not.toBeDisabled(); - await waitFor(() => userEvent.click(screen.getByText('Authorize'))); - - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); - expect(window.open).toHaveBeenCalled(); - expect(mockUpdateConfigApi).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); - }); - test('Authorizing an existing config will not call update or create config endpoint', async () => { - render( - , - ); - expect(screen.getByText('Authorize')).not.toBeDisabled(); - - await waitFor(() => userEvent.click(screen.getByText('Authorize'))); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - expect(mockOnClick).toHaveBeenCalledWith(SUBMIT_TOAST_MESSAGE); - expect(mockUpdateConfigApi).not.toHaveBeenCalled(); - expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); - }); - test('validates poorly formatted existing data on load', () => { - render( - , - ); - expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - expect(screen.getByText(INVALID_NAME)).toBeInTheDocument(); - }); - test('validates properly formatted existing data on load', () => { - render( - , - ); - expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - }); - test('it calls setExistingConfigFormData after authorization', async () => { - render( - , - ); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - expect(screen.getByText('Authorize')).not.toBeDisabled(); - userEvent.click(screen.getByText('Authorize')); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.queryByText('Authorize')).not.toBeInTheDocument()); - - expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ - blackboardBaseUrl: 'https://foobar.com', - displayName: 'display name', - id: 1, - active: false, - clientId: '123abc', - uuid: 'foobar', - }); - }); -}); diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx new file mode 100644 index 0000000000..49aae2e4bf --- /dev/null +++ b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx @@ -0,0 +1,335 @@ +import React from "react"; +import { + act, + render, + fireEvent, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/extend-expect"; + +// @ts-ignore +import BlackboardConfig from "../LMSConfigs/Blackboard/BlackboardConfig.tsx"; +import { + INVALID_LINK, + INVALID_NAME, +} from "../../data/constants"; +import LmsApiService from "../../../../data/services/LmsApiService"; +// @ts-ignore +import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; +import { findElementWithText } from "../../../test/testUtils"; + +jest.mock("../../data/constants", () => ({ + ...jest.requireActual("../../data/constants"), + LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, +})); +window.open = jest.fn(); +const mockUpdateConfigApi = jest.spyOn(LmsApiService, "updateBlackboardConfig"); +const mockConfigResponseData = { + uuid: 'foobar', + id: 1, + display_name: 'display name', + blackboard_base_url: 'https://foobar.com', + active: false, +}; +mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); + +const mockPostConfigApi = jest.spyOn(LmsApiService, 'postNewBlackboardConfig'); +mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); + +const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); +mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: 'foobar' } }); + +const enterpriseId = 'test-enterprise-id'; +const mockOnClick = jest.fn(); +// Freshly creating a config will have an empty existing data object +const noExistingData = {}; +// Existing config data that has been authorized +const existingConfigData = { + active: true, + refreshToken: "foobar", + id: 1, + displayName: "foobarss", +}; +// Existing invalid data that will be validated on load +const invalidExistingData = { + displayName: "fooooooooobaaaaaaaaar", + blackboardBaseUrl: "bad_url :^(", +}; +// Existing config data that has not been authorized +const existingConfigDataNoAuth = { + id: 1, + displayName: "foobar", + blackboardBaseUrl: "https://foobarish.com", +}; + +const noConfigs = []; + +afterEach(() => { + jest.clearAllMocks(); +}); + +const mockSetExistingConfigFormData = jest.fn(); + +function testBlackboardConfigSetup(formData) { + return ( + + ); +} + +async function clearForm() { + await act(async () => { + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Blackboard Base URL'), { + target: { value: '' }, + }); + }); +} + + +describe("", () => { + test("renders Blackboard Authorize Form", () => { + render(testBlackboardConfigSetup(noConfigs)); + + screen.getByLabelText("Display Name"); + screen.getByLabelText("Blackboard Base URL"); + }); + test("test button disable", async () => { + const { container } = render(testBlackboardConfigSetup(noExistingData)); + + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + await clearForm(); + expect(authorizeButton).toBeDisabled(); + userEvent.type(screen.getByLabelText("Display Name"), "name"); + userEvent.type(screen.getByLabelText("Blackboard Base URL"), "test4"); + + expect(authorizeButton).toBeDisabled(); + expect(screen.queryByText(INVALID_LINK)); + expect(screen.queryByText(INVALID_NAME)); + await act(async () => { + fireEvent.change(screen.getByLabelText("Display Name"), { + target: { value: "" }, + }); + fireEvent.change(screen.getByLabelText("Blackboard Base URL"), { + target: { value: "" }, + }); + }); + userEvent.type(screen.getByLabelText("Display Name"), "displayName"); + userEvent.type( + screen.getByLabelText("Blackboard Base URL"), + "https://www.test4.com" + ); + + expect(authorizeButton).not.toBeDisabled(); + }); + test('it edits existing configs on submit', async () => { + const { container } = render(testBlackboardConfigSetup(existingConfigData)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + + await clearForm(); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + + expect(authorizeButton).not.toBeDisabled(); + + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + + const expectedConfig = { + active: true, + id: 1, + refresh_token: "foobar", + blackboard_base_url: 'https://www.test4.com', + display_name: 'displayName', + enterprise_customer: enterpriseId, + }; + expect(LmsApiService.updateBlackboardConfig).toHaveBeenCalledWith(expectedConfig, 1); + }); + test('it creates new configs on submit', async () => { + const { container } = render(testBlackboardConfigSetup(noExistingData)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + await waitFor(() => expect(authorizeButton).not.toBeDisabled()); + + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + + const expectedConfig = { + active: false, + blackboard_base_url: 'https://www.test4.com', + display_name: 'displayName', + enterprise_customer: enterpriseId, + }; + expect(LmsApiService.postNewBlackboardConfig).toHaveBeenCalledWith(expectedConfig); + }); + test('saves draft correctly', async () => { + const { container } = render(testBlackboardConfigSetup(noExistingData)); + const cancelButton = findElementWithText( + container, + "button", + "Cancel" + ); + await clearForm(); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + + expect(cancelButton).not.toBeDisabled(); + userEvent.click(cancelButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Exit configuration')).toBeInTheDocument()); + const closeButton = screen.getByRole('button', { name: 'Exit' }); + + userEvent.click(closeButton); + + const expectedConfig = { + active: false, + display_name: 'displayName', + enterprise_customer: enterpriseId, + blackboard_base_url: 'https://www.test4.com', + }; + expect(LmsApiService.postNewBlackboardConfig).toHaveBeenCalledWith(expectedConfig); + }); + test('Authorizing a config will initiate backend polling', async () => { + const { container } = render(testBlackboardConfigSetup(noExistingData)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + await clearForm(); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + + expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + + expect(window.open).toHaveBeenCalled(); + expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + }); + test('Authorizing an existing, edited config will call update config endpoint', async () => { + const { container } = render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + act(() => { + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Blackboard Base URL'), { + target: { value: '' }, + }); + }); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + + expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Authorization in progress')).toBeInTheDocument()); + expect(mockUpdateConfigApi).toHaveBeenCalled(); + expect(window.open).toHaveBeenCalled(); + expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); + }); + test('Authorizing an existing config will not call update or create config endpoint', async () => { + const { container } = render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + + expect(authorizeButton).not.toBeDisabled(); + + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); + expect(mockUpdateConfigApi).not.toHaveBeenCalled(); + expect(window.open).toHaveBeenCalled(); + expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + }); + test('validates poorly formatted existing data on load', async () => { + render(testBlackboardConfigSetup(invalidExistingData)); + expect(screen.getByText("Please enter a valid URL")).toBeInTheDocument(); + await waitFor(() => expect(expect(screen.getByText("Display name should be 20 characters or less")).toBeInTheDocument())); + }); + test('validates properly formatted existing data on load', () => { + render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + expect(screen.queryByText("Please enter a valid URL")).not.toBeInTheDocument(); + expect(screen.queryByText("Display name should be 20 characters or less")).not.toBeInTheDocument(); + }); + test('it calls setExistingConfigFormData after authorization', async () => { + const { container } = render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = findElementWithText( + container, + "button", + "Authorize" + ); + act(() => { + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + }); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton); + + // Await a find by text in order to account for state changes in the button callback + await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); + + const activateButton = findElementWithText( + container, + "button", + "Activate" + ); + userEvent.click(activateButton); + expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ + uuid: 'foobar', + id: 1, + displayName: 'display name', + blackboardBaseUrl: 'https://foobar.com', + active: false, + }); + }); +}); diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index b19c064fbe..b65cc798c6 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -114,14 +114,15 @@ describe('', () => { userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[BLACKBOARD_TYPE].displayName)); }); - userEvent.click(screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName)); + const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); + userEvent.click(blackboardCard); expect(screen.queryByText('Connect Blackboard')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); const cancelButton = screen.getByText('Cancel'); userEvent.click(cancelButton); - expect(await screen.findByText('Do you want to save your work?')).toBeTruthy(); + expect(await screen.findByText('Exit configuration')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); expect(screen.queryByText('Connect Blackboard')).toBeFalsy(); From 193081b4d60fe7b84b31a3f78f7b6879404de01a Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:33:24 -0600 Subject: [PATCH 64/73] fix: changing degreed to tsx + other changes (#980) * fix: changing degreed to tsx + other changes * fix!: removing degreed config * fix: fix tests --- src/components/forms/FormContextWrapper.tsx | 3 +- src/components/forms/FormWorkflow.tsx | 64 ++--- .../ErrorReporting/SyncHistory.jsx | 4 +- .../settings/SettingsLMSTab/LMSConfigPage.jsx | 159 +++++++----- .../ConfigBasePages/ConfigActivatePage.tsx | 23 ++ .../LMSConfigs/Degreed/DegreedConfig.tsx | 211 +++++++++++++++ .../Degreed/DegreedConfigActivatePage.tsx | 23 ++ .../Degreed/DegreedConfigEnablePage.tsx | 139 ++++++++++ .../LMSConfigs/Degreed2Config.jsx | 223 ---------------- .../LMSConfigs/DegreedConfig.jsx | 245 ------------------ .../settings/SettingsLMSTab/index.jsx | 10 +- .../tests/BlackboardConfig.test.tsx | 90 ++----- .../tests/CanvasConfig.test.tsx | 86 ++---- .../tests/Degreed2Config.test.jsx | 215 --------------- .../tests/DegreedConfig.test.jsx | 238 ----------------- .../tests/DegreedConfig.test.tsx | 221 ++++++++++++++++ .../tests/LmsConfigPage.test.jsx | 50 ++-- src/components/settings/data/constants.js | 1 - src/utils.js | 12 +- 19 files changed, 821 insertions(+), 1196 deletions(-) create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigActivatePage.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx delete mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx delete mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx delete mode 100644 src/components/settings/SettingsLMSTab/tests/Degreed2Config.test.jsx delete mode 100644 src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.jsx create mode 100644 src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx diff --git a/src/components/forms/FormContextWrapper.tsx b/src/components/forms/FormContextWrapper.tsx index 2126e9839c..25a9da6b04 100644 --- a/src/components/forms/FormContextWrapper.tsx +++ b/src/components/forms/FormContextWrapper.tsx @@ -15,6 +15,7 @@ function FormContextWrapper({ onClickOut, onSubmit, formData, + isStepperOpen, }: FormWorkflowProps) { const [formFieldsState, dispatch] = useReducer< FormReducer, @@ -36,7 +37,7 @@ function FormContextWrapper({ formContext={formFieldsState || {}} > ); diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index 2b49996f44..16ca291d02 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -1,6 +1,6 @@ import React from "react"; import type { Dispatch } from "react"; -import { Button, Stepper, useToggle } from "@edx/paragon"; +import { ActionRow, Button, FullscreenModal, Stepper, useToggle } from "@edx/paragon"; import { Launch } from "@edx/paragon/icons"; // @ts-ignore @@ -75,12 +75,14 @@ export type FormWorkflowProps = { formData: FormData; dispatch: Dispatch; onSubmit: (FormData) => void; + isStepperOpen: boolean; }; // Modal container for multi-step forms function FormWorkflow({ formWorkflowConfig, onClickOut, + isStepperOpen, dispatch, }: FormWorkflowProps) { const { @@ -161,37 +163,6 @@ function FormWorkflow({ ); }; - const stepActionRow = (step: FormWorkflowStep) => { - return ( - - - {/* TODO: Help Link */} - {/* TODO: Fix typescript issue with Paragon Button */} - { - // @ts-ignore - - } - {nextButtonConfig && ( - // @ts-ignore - - )} - - - ); - }; - return ( <> ({ /> {formWorkflowConfig.steps && ( - - - {formWorkflowConfig.steps.map((stepConfig) => stepBody(stepConfig))} - {formWorkflowConfig.steps.map((stepConfig) => - stepActionRow(stepConfig) + + + + + {nextButtonConfig && ( + + )} + )} - + > + + + {formWorkflowConfig.steps.map((stepConfig) => stepBody(stepConfig))} + + )} ); diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx index fffddb2104..3a3d14a9ba 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx @@ -12,7 +12,7 @@ import ConfigError from '../../ConfigError'; import LmsApiService from '../../../../data/services/LmsApiService'; import { - ACTIVATE_TOAST_MESSAGE, BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED_TYPE, + ACTIVATE_TOAST_MESSAGE, BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, errorToggleModalText, INACTIVATE_TOAST_MESSAGE, MOODLE_TYPE, SAP_TYPE, } from '../../data/constants'; @@ -45,8 +45,6 @@ const SyncHistory = () => { response = await LmsApiService.fetchSingleCanvasConfig(configId); break; case CORNERSTONE_TYPE: response = await LmsApiService.fetchSingleCornerstoneConfig(configId); break; - case DEGREED_TYPE: - response = await LmsApiService.fetchSingleDegreedConfig(configId); break; case DEGREED2_TYPE: response = await LmsApiService.fetchSingleDegreed2Config(configId); break; case MOODLE_TYPE: diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 9b1e4857a8..75788c6540 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -8,27 +8,24 @@ import { BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, - DEGREED_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, } from '../data/constants'; import { BlackboardFormConfig } from './LMSConfigs/Blackboard/BlackboardConfig.tsx'; import { CanvasFormConfig } from './LMSConfigs/Canvas/CanvasConfig.tsx'; +import { DegreedFormConfig } from './LMSConfigs/Degreed/DegreedConfig.tsx'; import CornerstoneConfig from './LMSConfigs/CornerstoneConfig'; -import DegreedConfig from './LMSConfigs/DegreedConfig'; -import Degreed2Config from './LMSConfigs/Degreed2Config'; import MoodleConfig from './LMSConfigs/MoodleConfig'; import SAPConfig from './LMSConfigs/SAPConfig'; import FormContextWrapper from '../../forms/FormContextWrapper.tsx'; -// TODO: Add remaining configs const flowConfigs = { [BLACKBOARD_TYPE]: BlackboardFormConfig, [CANVAS_TYPE]: CanvasFormConfig, + [DEGREED2_TYPE]: DegreedFormConfig, }; -// TODO: Convert to TypeScript const LMSConfigPage = ({ LMSType, onClick, @@ -36,87 +33,111 @@ const LMSConfigPage = ({ existingConfigFormData, existingConfigs, setExistingConfigFormData, + isLmsStepperOpen, + closeLmsStepper, }) => { + const [edited, setEdited] = React.useState(false); const handleCloseWorkflow = (submitted, msg) => { onClick(submitted ? msg : ''); + closeLmsStepper(); return true; }; return ( -

- - - Connect {channelMapping[LMSType].displayName} - -

- {/* TODO: Replace giant switch */} {LMSType === BLACKBOARD_TYPE && ( - + )} {LMSType === CANVAS_TYPE && ( - + )} {LMSType === CORNERSTONE_TYPE && ( - + <> +

+ + + Connect {channelMapping[LMSType]?.displayName} + +

+ + )} {LMSType === DEGREED2_TYPE && ( - - )} - {LMSType === DEGREED_TYPE && ( - + )} {LMSType === MOODLE_TYPE && ( - + <> +

+ + + Connect {channelMapping[LMSType]?.displayName} + +

+ + )} {LMSType === SAP_TYPE && ( - + <> +

+ + + Connect {channelMapping[LMSType]?.displayName} + +

+ + )}
); @@ -136,6 +157,8 @@ LMSConfigPage.propTypes = { existingConfigFormData: PropTypes.shape({}).isRequired, existingConfigs: PropTypes.arrayOf(PropTypes.string), setExistingConfigFormData: PropTypes.func.isRequired, + isLmsStepperOpen: PropTypes.bool.isRequired, + closeLmsStepper: PropTypes.func.isRequired, }; export default connect(mapStateToProps)(LMSConfigPage); diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx new file mode 100644 index 0000000000..4993ce4870 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Form } from '@edx/paragon'; + +const ConfigActivatePage = ({ lmsType }) => ( + +
+

Activate your {lmsType} integration

+ +

+ Your {lmsType} integration has been successfully authorized and is ready to + activate! +

+ +

+ Once activated, edX For Business will begin syncing content metadata and + learner activity with {lmsType}. +

+
+
+); + +export default ConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx new file mode 100644 index 0000000000..c315574d0e --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx @@ -0,0 +1,211 @@ +import handleErrors from "../../../utils"; +import LmsApiService from "../../../../../data/services/LmsApiService"; +import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; +import { SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +// @ts-ignore +import DegreedConfigActivatePage from "./DegreedConfigActivatePage.tsx"; +import DegreedConfigAuthorizePage, { + validations, + formFieldNames + // @ts-ignore +} from "./DegreedConfigEnablePage.tsx"; +import type { + FormWorkflowButtonConfig, + FormWorkflowConfig, + FormWorkflowStep, + FormWorkflowHandlerArgs, + FormWorkflowErrorHandler, +} from "../../../../forms/FormWorkflow"; +// @ts-ignore +import { WAITING_FOR_ASYNC_OPERATION } from "../../../../forms/FormWorkflow.tsx"; +import { + setWorkflowStateAction, + updateFormFieldsAction, + // @ts-ignore +} from "../../../../forms/data/actions.ts"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; + +export type DegreedConfigCamelCase = { + displayName: string; + clientId: string; + clientSecret: string; + degreedBaseUrl: string; + degreedFetchUrl: string; + id: string; + active: boolean; + uuid: string; +}; + +// TODO: Can we generate this dynamically? +// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html +export type DegreedConfigSnakeCase = { + display_name: string; + client_id: string; + client_secret: string; + degreed_base_url: string; + degreed_fetch_url: string; + id: string; + active: boolean; + uuid: string; + enterprise_customer: string; + refresh_token: string; +}; + +// TODO: Make this a generic type usable by all lms configs +export type DegreedFormConfigProps = { + enterpriseCustomerUuid: string; + existingData: DegreedConfigCamelCase; + existingConfigNames: string[]; + onSubmit: (degreedConfig: DegreedConfigCamelCase) => void; + onClickCancel: (submitted: boolean, status: string) => Promise; +}; + +export const DegreedFormConfig = ({ + enterpriseCustomerUuid, + onSubmit, + onClickCancel, + existingData, + existingConfigNames, +}: DegreedFormConfigProps): FormWorkflowConfig => { + const configNames: string[] = existingConfigNames?.filter( (name) => name !== existingData.displayName); + const checkForDuplicateNames: FormFieldValidation = { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (formFields: DegreedConfigCamelCase) => { + return configNames?.includes(formFields.displayName) + ? "Display name already taken" + : false; + }, + }; + + const saveChanges = async ( + formFields: DegreedConfigCamelCase, + errHandler: (errMsg: string) => void + ) => { + const transformedConfig: DegreedConfigSnakeCase = snakeCaseDict( + formFields + ) as DegreedConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err = ""; + + if (formFields.id) { + try { + transformedConfig.active = existingData.active; + await LmsApiService.updateDegreedConfig( + transformedConfig, + existingData.id + ); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + await LmsApiService.postNewDegreedConfig(transformedConfig); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } + + if (err) { + errHandler(err); + } + return !err; + }; + + const handleSubmit = async ({ + formFields, + formFieldsChanged, + errHandler, + dispatch, + }: FormWorkflowHandlerArgs) => { + let currentFormFields = formFields; + const transformedConfig: DegreedConfigSnakeCase = snakeCaseDict( + formFields + ) as DegreedConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err = ""; + if (formFieldsChanged) { + if (currentFormFields?.id) { + try { + transformedConfig.active = existingData.active; + const response = await LmsApiService.updateDegreedConfig( + transformedConfig, + existingData.id + ); + currentFormFields = camelCaseDict( + response.data + ) as DegreedConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + const response = await LmsApiService.postNewDegreedConfig( + transformedConfig + ); + currentFormFields = camelCaseDict( + response.data + ) as DegreedConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } + } + if (err) { + errHandler?.(err); + } + return currentFormFields; + }; + + const steps: FormWorkflowStep[] = [ + { + index: 0, + formComponent: DegreedConfigAuthorizePage, + validations: validations.concat([checkForDuplicateNames]), + stepName: "Enable", + saveChanges, + nextButtonConfig: () => { + let config = { + buttonText: "Enable", + opensNewWindow: false, + onClick: handleSubmit, + }; + return config as FormWorkflowButtonConfig; + }, + }, + { + index: 1, + formComponent: DegreedConfigActivatePage, + validations: [], + stepName: "Activate", + saveChanges, + nextButtonConfig: () => ({ + buttonText: "Activate", + opensNewWindow: false, + onClick: () => { + onClickCancel(true, SUBMIT_TOAST_MESSAGE); + return Promise.resolve(existingData); + }, + }), + }, + ]; + + // Go to authorize step for now + const getCurrentStep = () => steps[0]; + + return { + getCurrentStep, + steps, + }; +}; + +export default DegreedFormConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigActivatePage.tsx new file mode 100644 index 0000000000..0a4280478f --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigActivatePage.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Form } from '@edx/paragon'; + +const DegreedConfigActivatePage = () => ( + +
+

Activate your Degreed integration

+ +

+ Your Degreed integration has been successfully created and is ready to + activate! +

+ +

+ Once activated, edX For Business will begin syncing content metadata and + learner activity with Degreed. +

+
+
+); + +export default DegreedConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx new file mode 100644 index 0000000000..eb3938be73 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx @@ -0,0 +1,139 @@ +import React from "react"; + +import { Form, Alert } from "@edx/paragon"; +import { Info } from "@edx/paragon/icons"; + +// @ts-ignore +import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; +import { urlValidation } from "../../../../../utils"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; +import { + useFormContext, + // @ts-ignore +} from "../../../../forms/FormContext.tsx"; + +export const formFieldNames = { + DISPLAY_NAME: "displayName", + CLIENT_ID: "clientId", + CLIENT_SECRET: "clientSecret", + DEGREED_BASE_URL: "degreedBaseUrl", + DEGREED_FETCH_URL: "degreedFetchUrl", +}; + +export const validations: FormFieldValidation[] = [ + { + formFieldId: formFieldNames.DEGREED_BASE_URL, + validator: (fields) => { + const degreedUrl = fields[formFieldNames.DEGREED_BASE_URL]; + if (degreedUrl) { + const error = !urlValidation(degreedUrl); + return error ? "Please enter a valid URL" : false; + } else { + return true; + } + }, + }, + { + formFieldId: formFieldNames.DEGREED_FETCH_URL, + validator: (fields) => { + const degreedUrl = fields[formFieldNames.DEGREED_FETCH_URL]; + if (degreedUrl) { + const error = !urlValidation(degreedUrl); + return error ? "Please enter a valid URL" : false; + } else { + // fetch url is optional + return false; + } + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const displayName = fields[formFieldNames.DISPLAY_NAME]; + return !displayName; + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const displayName = fields[formFieldNames.DISPLAY_NAME]; + const error = displayName?.length > 20; + return error && "Display name should be 20 characters or less"; + }, + }, + { + formFieldId: formFieldNames.CLIENT_ID, + validator: (fields) => { + const clientId = fields[formFieldNames.CLIENT_ID]; + return !clientId; + }, + }, + { + formFieldId: formFieldNames.CLIENT_SECRET, + validator: (fields) => { + const clientSecret = fields[formFieldNames.CLIENT_SECRET]; + return !clientSecret; + }, + }, +]; + +// Settings page of Degreed LMS config workflow +const DegreedConfigEnablePage = () => { + const { dispatch, stateMap } = useFormContext(); + return ( + +

Enable connection to Degreed

+
+ + + + + + + + + + + + + + + +
+
+ ); +}; + +export default DegreedConfigEnablePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx deleted file mode 100644 index e65c78bad1..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed2Config.jsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Button, Form, useToggle } from '@edx/paragon'; -import isEmpty from 'lodash/isEmpty'; -import buttonBool, { isExistingConfig } from '../utils'; -import handleErrors from '../../utils'; -import LmsApiService from '../../../../data/services/LmsApiService'; -import { snakeCaseDict, urlValidation } from '../../../../utils'; -import ConfigError from '../../ConfigError'; -import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; - -const Degreed2Config = ({ - enterpriseCustomerUuid, onClick, existingData, existingConfigs, -}) => { - const [displayName, setDisplayName] = React.useState(''); - const [clientId, setClientId] = React.useState(''); - const [clientSecret, setClientSecret] = React.useState(''); - const [degreedBaseUrl, setDegreedBaseUrl] = React.useState(''); - const [degreedFetchUrl, setDegreedFetchUrl] = React.useState(''); - const [nameValid, setNameValid] = React.useState(true); - const [urlValid, setUrlValid] = React.useState(true); - const [fetchUrlValid, setFetchUrlValid] = React.useState(true); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [modalIsOpen, openModal, closeModal] = useToggle(false); - const [edited, setEdited] = React.useState(false); - - const config = { - displayName, - clientId, - clientSecret, - degreedBaseUrl, - degreedFetchUrl, - }; - - useEffect(() => { - setClientId(existingData.clientId); - setClientSecret(existingData.clientSecret); - setDegreedBaseUrl(existingData.degreedBaseUrl); - setDegreedFetchUrl(existingData.degreedFetchUrl); - setDisplayName(existingData.displayName); - }, [existingData]); - - const handleCancel = () => { - if (edited) { - openModal(); - } else { - onClick(''); - } - }; - - const handleSubmit = async () => { - const transformedConfig = snakeCaseDict(config); - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - - if (!isEmpty(existingData)) { - try { - transformedConfig.active = existingData.active; - await LmsApiService.updateDegreed2Config(transformedConfig, existingData.id); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewDegreed2Config(transformedConfig); - } catch (error) { - err = handleErrors(error); - } - } - - if (err) { - openError(); - } else { - onClick(SUBMIT_TOAST_MESSAGE); - } - }; - - const validateField = useCallback((field, input) => { - switch (field) { - case 'Degreed Token Fetch Base Url': - setDegreedFetchUrl(input); - setFetchUrlValid(urlValidation(input) || input?.length === 0); - break; - case 'Degreed Base URL': - setDegreedBaseUrl(input); - setUrlValid(urlValidation(input) || input?.length === 0); - break; - case 'Display Name': - setDisplayName(input); - if (isExistingConfig(existingConfigs, input, existingData.displayName)) { - setNameValid(input?.length <= 20); - } else { - setNameValid(input?.length <= 20 && !Object.values(existingConfigs).includes(input)); - } - break; - default: - break; - } - }, [existingConfigs, existingData.displayName]); - - useEffect(() => { - if (!isEmpty(existingData)) { - validateField('Degreed Base URL', existingData.degreedBaseUrl); - validateField('Display Name', existingData.displayName); - validateField('Degreed Token Fetch Base Url', existingData.degreedFetchUrl); - } - }, [existingConfigs, existingData, validateField]); - - return ( - - - -
- - { - setEdited(true); - validateField('Display Name', e.target.value); - }} - floatingLabel="Display Name" - defaultValue={existingData.displayName} - /> - Create a custom name for this LMS. - {!nameValid && ( - - {INVALID_NAME} - - )} - - - { - setEdited(true); - setClientId(e.target.value); - }} - floatingLabel="API Client ID" - defaultValue={existingData.clientId} - /> - - - { - setEdited(true); - setClientSecret(e.target.value); - }} - floatingLabel="API Client Secret" - defaultValue={existingData.clientSecret} - /> - - - { - setEdited(true); - validateField('Degreed Base URL', e.target.value); - }} - floatingLabel="Degreed Base URL" - defaultValue={existingData.degreedBaseUrl} - /> - {!urlValid && ( - - {INVALID_LINK} - - )} - - - { - setEdited(true); - validateField('Degreed Token Fetch Base Url', e.target.value); - }} - floatingLabel="Degreed Token Fetch Base Url" - defaultValue={existingData.degreedFetchUrl} - /> - - Optional: If provided, will be used as the url to fetch tokens. - - {!fetchUrlValid && ( - - {INVALID_LINK} - - )} - - - - - -
-
- ); -}; - -Degreed2Config.propTypes = { - enterpriseCustomerUuid: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - existingData: PropTypes.shape({ - active: PropTypes.bool, - displayName: PropTypes.string, - clientId: PropTypes.string, - id: PropTypes.number, - clientSecret: PropTypes.string, - degreedBaseUrl: PropTypes.string, - degreedFetchUrl: PropTypes.string, - }).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string).isRequired, -}; -export default Degreed2Config; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx deleted file mode 100644 index fa700f424a..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/DegreedConfig.jsx +++ /dev/null @@ -1,245 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Button, Form, useToggle } from '@edx/paragon'; -import isEmpty from 'lodash/isEmpty'; -import buttonBool, { isExistingConfig } from '../utils'; -import handleErrors from '../../utils'; - -import LmsApiService from '../../../../data/services/LmsApiService'; -import { snakeCaseDict, urlValidation } from '../../../../utils'; -import ConfigError from '../../ConfigError'; -import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; - -const DegreedConfig = ({ - enterpriseCustomerUuid, onClick, existingData, existingConfigs, -}) => { - const [displayName, setDisplayName] = React.useState(''); - const [nameValid, setNameValid] = React.useState(true); - const [key, setKey] = React.useState(''); - const [secret, setSecret] = React.useState(''); - const [degreedCompanyId, setDegreedCompanyId] = React.useState(''); - const [degreedBaseUrl, setDegreedBaseUrl] = React.useState(''); - const [urlValid, setUrlValid] = React.useState(true); - const [degreedUserId, setDegreedUserId] = React.useState(''); - const [degreedUserPassword, setDegreedUserPassword] = React.useState(''); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [modalIsOpen, openModal, closeModal] = useToggle(false); - const [edited, setEdited] = React.useState(false); - - const config = { - displayName, - key, - secret, - degreedCompanyId, - degreedBaseUrl, - degreedUserId, - degreedUserPassword, - }; - - useEffect(() => { - setKey(existingData.key); - setSecret(existingData.secret); - setDegreedCompanyId(existingData.degreedCompanyId); - setDegreedBaseUrl(existingData.degreedBaseUrl); - setDegreedUserId(existingData.degreedUserId); - setDegreedUserPassword(existingData.degreedUserPassword); - setDisplayName(existingData.displayName); - }, [existingData]); - - const handleCancel = () => { - if (edited) { - openModal(); - } else { - onClick(''); - } - }; - - const handleSubmit = async () => { - const transformedConfig = snakeCaseDict(config); - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - - if (!isEmpty(existingData)) { - try { - transformedConfig.active = existingData.active; - await LmsApiService.updateDegreedConfig(transformedConfig, existingData.id); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewDegreedConfig(transformedConfig); - } catch (error) { - err = handleErrors(error); - } - } - - if (err) { - openError(); - } else { - onClick(SUBMIT_TOAST_MESSAGE); - } - }; - - const validateField = useCallback((field, input) => { - switch (field) { - case 'Degreed Base URL': - setDegreedBaseUrl(input); - setUrlValid(urlValidation(input) || input?.length === 0); - break; - case 'Display Name': - setDisplayName(input); - if (isExistingConfig(existingConfigs, input, existingData.displayName)) { - setNameValid(input?.length <= 20); - } else { - setNameValid(input?.length <= 20 && !Object.values(existingConfigs).includes(input)); - } - break; - default: - break; - } - }, [existingConfigs, existingData.displayName]); - - useEffect(() => { - if (!isEmpty(existingData)) { - validateField('Degreed Base URL', existingData.degreedBaseUrl); - validateField('Display Name', existingData.displayName); - } - }, [existingConfigs, existingData, validateField]); - - return ( - - - -
- - { - setEdited(true); - validateField('Display Name', e.target.value); - }} - floatingLabel="Display Name" - defaultValue={existingData.displayName} - /> - Create a custom name for this LMS. - {!nameValid && ( - - {INVALID_NAME} - - )} - - - { - setEdited(true); - setKey(e.target.value); - }} - floatingLabel="API Client ID" - defaultValue={existingData.key} - /> - - - { - setEdited(true); - setSecret(e.target.value); - }} - floatingLabel="API Client Secret" - defaultValue={existingData.secret} - /> - - - { - setEdited(true); - setDegreedCompanyId(e.target.value); - }} - floatingLabel="Degreed Organization Code" - defaultValue={existingData.degreedCompanyId} - /> - - - { - setEdited(true); - validateField('Degreed Base URL', e.target.value); - }} - floatingLabel="Degreed Base URL" - defaultValue={existingData.degreedBaseUrl} - /> - {!urlValid && ( - - {INVALID_LINK} - - )} - - - { - setEdited(true); - setDegreedUserId(e.target.value); - }} - floatingLabel="Degreed User ID" - defaultValue={existingData.degreedUserId} - /> - Required for OAuth access token - - - { - setEdited(true); - setDegreedUserPassword(e.target.value); - }} - floatingLabel="Degreed User Password" - defaultValue={existingData.degreedUserPassword} - /> - Required for OAuth access token - - - - - -
-
- ); -}; - -DegreedConfig.propTypes = { - enterpriseCustomerUuid: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - existingData: PropTypes.shape({ - active: PropTypes.bool, - displayName: PropTypes.string, - key: PropTypes.string, - id: PropTypes.number, - secret: PropTypes.string, - degreedCompanyId: PropTypes.string, - degreedBaseUrl: PropTypes.string, - degreedUserId: PropTypes.string, - degreedUserPassword: PropTypes.string, - }).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string).isRequired, -}; -export default DegreedConfig; diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index dffe70db04..74ee932796 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { - Alert, Button, Hyperlink, CardGrid, Toast, Skeleton, + Alert, Button, Hyperlink, CardGrid, Toast, Skeleton, useToggle, } from '@edx/paragon'; import { Add, Info } from '@edx/paragon/icons'; import { logError } from '@edx/frontend-platform/logging'; @@ -48,11 +48,13 @@ const SettingsLMSTab = ({ const [existingConfigFormData, setExistingConfigFormData] = useState({}); const [toastMessage, setToastMessage] = useState(); const [displayNeedsSSOAlert, setDisplayNeedsSSOAlert] = useState(false); + const [isLmsStepperOpen, openLmsStepper, closeLmsStepper] = useToggle(false); const toastMessages = [ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE, SUBMIT_TOAST_MESSAGE]; // onClick function for existing config cards' edit action const editExistingConfig = (configData, configType) => { setConfigsLoading(false); + openLmsStepper(); // Set the form data to the card's associated config data setExistingConfigFormData(configData); // Set the config type to the card's type @@ -105,12 +107,14 @@ const SettingsLMSTab = ({ setShowToast(true); setConfig(''); setToastMessage(input); + closeLmsStepper(true); } else { // Otherwise the user has clicked a create card and we need to set existing config bool to // false and set the config type to the card that was clicked type setShowNewConfigButtons(false); setConfigsExist(false); setConfig(input); + openLmsStepper(); } }; @@ -219,7 +223,7 @@ const SettingsLMSTab = ({ )} - {config && ( + {isLmsStepperOpen && ( )} diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx index 49aae2e4bf..d13be0878b 100644 --- a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx @@ -84,6 +84,7 @@ function testBlackboardConfigSetup(formData) { onClickOut={mockOnClick} onSubmit={mockSetExistingConfigFormData} formData={formData} + isStepperOpen={true} /> ); } @@ -103,18 +104,13 @@ async function clearForm() { describe("", () => { test("renders Blackboard Authorize Form", () => { render(testBlackboardConfigSetup(noConfigs)); - screen.getByLabelText("Display Name"); screen.getByLabelText("Blackboard Base URL"); }); test("test button disable", async () => { - const { container } = render(testBlackboardConfigSetup(noExistingData)); + render(testBlackboardConfigSetup(noExistingData)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); expect(authorizeButton).toBeDisabled(); userEvent.type(screen.getByLabelText("Display Name"), "name"); @@ -140,12 +136,8 @@ describe("", () => { expect(authorizeButton).not.toBeDisabled(); }); test('it edits existing configs on submit', async () => { - const { container } = render(testBlackboardConfigSetup(existingConfigData)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testBlackboardConfigSetup(existingConfigData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); @@ -155,8 +147,8 @@ describe("", () => { userEvent.click(authorizeButton); - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + // await a change in button text from authorize to activate + await waitFor(() => expect(screen.findByRole('button', {name: 'Activate'}))) const expectedConfig = { active: true, @@ -169,12 +161,8 @@ describe("", () => { expect(LmsApiService.updateBlackboardConfig).toHaveBeenCalledWith(expectedConfig, 1); }); test('it creates new configs on submit', async () => { - const { container } = render(testBlackboardConfigSetup(noExistingData)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testBlackboardConfigSetup(noExistingData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); @@ -184,8 +172,8 @@ describe("", () => { userEvent.click(authorizeButton); - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + // await a change in button text from authorize to activate + await waitFor(() => expect(screen.findByRole('button', {name: 'Activate'}))) const expectedConfig = { active: false, @@ -196,12 +184,9 @@ describe("", () => { expect(LmsApiService.postNewBlackboardConfig).toHaveBeenCalledWith(expectedConfig); }); test('saves draft correctly', async () => { - const { container } = render(testBlackboardConfigSetup(noExistingData)); - const cancelButton = findElementWithText( - container, - "button", - "Cancel" - ); + render(testBlackboardConfigSetup(noExistingData)); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await clearForm(); userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); @@ -209,7 +194,6 @@ describe("", () => { expect(cancelButton).not.toBeDisabled(); userEvent.click(cancelButton); - // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.getByText('Exit configuration')).toBeInTheDocument()); const closeButton = screen.getByRole('button', { name: 'Exit' }); @@ -224,12 +208,8 @@ describe("", () => { expect(LmsApiService.postNewBlackboardConfig).toHaveBeenCalledWith(expectedConfig); }); test('Authorizing a config will initiate backend polling', async () => { - const { container } = render(testBlackboardConfigSetup(noExistingData)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testBlackboardConfigSetup(noExistingData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); @@ -237,19 +217,15 @@ describe("", () => { expect(authorizeButton).not.toBeDisabled(); userEvent.click(authorizeButton); - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); - + // await a change in button text from authorize to activate + await waitFor(() => expect(authorizeButton).toBeDisabled()) expect(window.open).toHaveBeenCalled(); expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); }); test('Authorizing an existing, edited config will call update config endpoint', async () => { - const { container } = render(testBlackboardConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + act(() => { fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: '' }, @@ -272,12 +248,8 @@ describe("", () => { await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); }); test('Authorizing an existing config will not call update or create config endpoint', async () => { - const { container } = render(testBlackboardConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); expect(authorizeButton).not.toBeDisabled(); @@ -300,12 +272,9 @@ describe("", () => { expect(screen.queryByText("Display name should be 20 characters or less")).not.toBeInTheDocument(); }); test('it calls setExistingConfigFormData after authorization', async () => { - const { container } = render(testBlackboardConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + act(() => { fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: '' }, @@ -318,11 +287,8 @@ describe("", () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - const activateButton = findElementWithText( - container, - "button", - "Activate" - ); + const activateButton = screen.getByRole('button', { name: 'Activate' }); + userEvent.click(activateButton); expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ uuid: 'foobar', diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx index 47d83885b4..3c19d2ec35 100644 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx @@ -94,6 +94,7 @@ function testCanvasConfigSetup(formData) { onClickOut={mockOnClick} onSubmit={mockSetExistingConfigFormData} formData={formData} + isStepperOpen={true} /> ); } @@ -130,13 +131,10 @@ describe("", () => { screen.getByLabelText("Canvas Base URL"); }); test("test button disable", async () => { - const { container } = render(testCanvasConfigSetup(noExistingData)); + render(testCanvasConfigSetup(noExistingData)); + + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); await clearForm(); expect(authorizeButton).toBeDisabled(); userEvent.type(screen.getByLabelText("Display Name"), "name"); @@ -165,12 +163,8 @@ describe("", () => { expect(authorizeButton).not.toBeDisabled(); }); test('it edits existing configs on submit', async () => { - const { container } = render(testCanvasConfigSetup(existingConfigData)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testCanvasConfigSetup(existingConfigData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); @@ -184,7 +178,7 @@ describe("", () => { userEvent.click(authorizeButton); // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument()); const expectedConfig = { active: true, @@ -200,12 +194,8 @@ describe("", () => { expect(LmsApiService.updateCanvasConfig).toHaveBeenCalledWith(expectedConfig, 1); }); test('it creates new configs on submit', async () => { - const { container } = render(testCanvasConfigSetup(noExistingData)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testCanvasConfigSetup(noExistingData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); @@ -219,7 +209,7 @@ describe("", () => { userEvent.click(authorizeButton); // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument()); const expectedConfig = { active: false, @@ -233,12 +223,9 @@ describe("", () => { expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); }); test('saves draft correctly', async () => { - const { container } = render(testCanvasConfigSetup(noExistingData)); - const cancelButton = findElementWithText( - container, - "button", - "Cancel" - ); + render(testCanvasConfigSetup(noExistingData)); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await clearForm(); userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); @@ -266,12 +253,9 @@ describe("", () => { expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); }); test('Authorizing a config will initiate backend polling', async () => { - const { container } = render(testCanvasConfigSetup(noExistingData)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testCanvasConfigSetup(noExistingData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + await clearForm(); userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); @@ -282,19 +266,16 @@ describe("", () => { expect(authorizeButton).not.toBeDisabled(); userEvent.click(authorizeButton); - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(authorizeButton).not.toBeInTheDocument()); + // await a text change from 'Authorize' to 'Activate' + await waitFor(() => expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument()); expect(window.open).toHaveBeenCalled(); expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); }); test('Authorizing an existing, edited config will call update config endpoint', async () => { - const { container } = render(testCanvasConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testCanvasConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + act(() => { fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: '' }, @@ -317,12 +298,8 @@ describe("", () => { await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); }); test('Authorizing an existing config will not call update or create config endpoint', async () => { - const { container } = render(testCanvasConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testCanvasConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); expect(authorizeButton).not.toBeDisabled(); @@ -345,12 +322,9 @@ describe("", () => { expect(screen.queryByText("Display name should be 20 characters or less")).not.toBeInTheDocument(); }); test('it calls setExistingConfigFormData after authorization', async () => { - const { container } = render(testCanvasConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = findElementWithText( - container, - "button", - "Authorize" - ); + render(testCanvasConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + act(() => { fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: '' }, @@ -362,12 +336,8 @@ describe("", () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - - const activateButton = findElementWithText( - container, - "button", - "Activate" - ); + const activateButton = screen.getByRole('button', { name: 'Activate' }); + userEvent.click(activateButton); expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ uuid: 'foobar', diff --git a/src/components/settings/SettingsLMSTab/tests/Degreed2Config.test.jsx b/src/components/settings/SettingsLMSTab/tests/Degreed2Config.test.jsx deleted file mode 100644 index ddafa3c864..0000000000 --- a/src/components/settings/SettingsLMSTab/tests/Degreed2Config.test.jsx +++ /dev/null @@ -1,215 +0,0 @@ -import React from 'react'; -import { - render, fireEvent, screen, -} from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; -import userEvent from '@testing-library/user-event'; -import Degreed2Config from '../LMSConfigs/Degreed2Config'; -import { INVALID_LINK, INVALID_NAME } from '../../data/constants'; -import LmsApiService from '../../../../data/services/LmsApiService'; - -jest.mock('../../../../data/services/LmsApiService'); - -const enterpriseId = 'test-enterprise-id'; -const mockOnClick = jest.fn(); -const noConfigs = []; -const existingConfigDisplayNames = ['name']; -const existingConfigDisplayNamesInvalid = ['foobar']; -const noExistingData = {}; -const existingConfigData = { - id: 1, - displayName: 'test ayylmao', - degreedBaseUrl: 'https://foobar.com', - degreedFetchUrl: 'https://foobar.com', -}; -// Existing invalid data that will be validated on load -const invalidExistingData = { - displayName: 'fooooooooobaaaaaaaaar', - degreedBaseUrl: 'bad_url :^(', - degreedFetchUrl: '', -}; - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('', () => { - test('renders Degreed2 Config Form', () => { - render( - , - ); - screen.getByLabelText('Display Name'); - screen.getByLabelText('API Client ID'); - screen.getByLabelText('API Client Secret'); - screen.getByLabelText('Degreed Base URL'); - }); - test('test button disable', () => { - render( - , - ); - expect(screen.getByText('Submit')).toBeDisabled(); - - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'reallyreallyreallyreallyreallylongname' }, - }); - fireEvent.change(screen.getByLabelText('API Client ID'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('API Client Secret'), { - target: { value: 'test2' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'test4' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Token Fetch Base Url'), { - target: { value: 'test5' }, - }); - expect(screen.getByText('Submit')).toBeDisabled(); - expect(screen.queryByText(INVALID_NAME)); - const linkText = screen.queryAllByText(INVALID_LINK); - expect(linkText.length).toBe(2); - - // duplicate display name not able to be submitted - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'name' }, - }); - expect(screen.queryByText(INVALID_NAME)); - - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'https://test1.com' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'https://test2.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - }); - test('it edits existing configs on submit', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('API Client ID'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('API Client Secret'), { - target: { value: 'test2' }, - }); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'https://test1.com' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Token Fetch Base Url'), { - target: { value: 'https://test2.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); - - const expectedConfig = { - degreed_base_url: 'https://test1.com', - degreed_fetch_url: 'https://test2.com', - display_name: 'displayName', - client_id: 'test1', - client_secret: 'test2', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.updateDegreed2Config).toHaveBeenCalledWith(expectedConfig, 1); - }); - test('it creates new configs on submit', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('API Client ID'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('API Client Secret'), { - target: { value: 'test2' }, - }); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'https://test1.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); - - const expectedConfig = { - active: false, - degreed_base_url: 'https://test1.com', - display_name: 'displayName', - client_id: 'test1', - client_secret: 'test2', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.postNewDegreed2Config).toHaveBeenCalledWith(expectedConfig); - }); - test('saves draft correctly', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - userEvent.click(screen.getByText('Cancel')); - userEvent.click(screen.getByText('Save')); - const expectedConfig = { - active: false, - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.postNewDegreed2Config).toHaveBeenCalledWith(expectedConfig); - }); - test('validates poorly formatted existing data on load', () => { - render( - , - ); - expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - expect(screen.getByText(INVALID_NAME)).toBeInTheDocument(); - }); - test('validates properly formatted existing data on load', () => { - render( - , - ); - expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.jsx deleted file mode 100644 index b45b619b29..0000000000 --- a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.jsx +++ /dev/null @@ -1,238 +0,0 @@ -import React from 'react'; -import { - render, fireEvent, screen, -} from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; -import userEvent from '@testing-library/user-event'; -import DegreedConfig from '../LMSConfigs/DegreedConfig'; -import { INVALID_LINK, INVALID_NAME } from '../../data/constants'; -import LmsApiService from '../../../../data/services/LmsApiService'; - -jest.mock('../../../../data/services/LmsApiService'); - -const enterpriseId = 'test-enterprise-id'; -const mockOnClick = jest.fn(); -const noConfigs = []; -const existingConfigDisplayNames = ['name']; -const existingConfigDisplayNamesInvalid = ['foobar']; -const noExistingData = {}; -const existingConfigData = { - id: 1, - displayName: 'test ayylmao', - degreedBaseUrl: 'https://foobar.com', -}; -// Existing invalid data that will be validated on load -const invalidExistingData = { - displayName: 'fooooooooobaaaaaaaaar', - degreedBaseUrl: 'bad_url :^(', -}; - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('', () => { - test('renders Degreed Config Form', () => { - render( - , - ); - screen.getByLabelText('Display Name'); - screen.getByLabelText('API Client ID'); - screen.getByLabelText('API Client Secret'); - screen.getByLabelText('Degreed Organization Code'); - screen.getByLabelText('Degreed Base URL'); - screen.getByLabelText('Degreed User ID'); - screen.getByLabelText('Degreed User Password'); - }); - test('test button disable', () => { - render( - , - ); - expect(screen.getByText('Submit')).toBeDisabled(); - - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'reallyreallyreallyreallyreallylongname' }, - }); - fireEvent.change(screen.getByLabelText('API Client ID'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('API Client Secret'), { - target: { value: 'test2' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Organization Code'), { - target: { value: 'test3' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'test4' }, - }); - fireEvent.change(screen.getByLabelText('Degreed User ID'), { - target: { value: 'test5' }, - }); - fireEvent.change(screen.getByLabelText('Degreed User Password'), { - target: { value: 'test5' }, - }); - expect(screen.getByText('Submit')).toBeDisabled(); - expect(screen.queryByText(INVALID_LINK)); - expect(screen.queryByText(INVALID_NAME)); - - // duplicate display name not able to be submitted - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'name' }, - }); - expect(screen.queryByText(INVALID_NAME)); - - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'https://test1.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - }); - test('it edits existing configs on submit', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('API Client ID'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('API Client Secret'), { - target: { value: 'test2' }, - }); - fireEvent.change(screen.getByLabelText('Degreed User ID'), { - target: { value: 'test5' }, - }); - fireEvent.change(screen.getByLabelText('Degreed User Password'), { - target: { value: 'test5' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Organization Code'), { - target: { value: 'test3' }, - }); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'https://test1.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); - - const expectedConfig = { - degreed_base_url: 'https://test1.com', - degreed_company_id: 'test3', - degreed_user_id: 'test5', - degreed_user_password: 'test5', - display_name: 'displayName', - key: 'test1', - secret: 'test2', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.updateDegreedConfig).toHaveBeenCalledWith(expectedConfig, 1); - }); - test('it creates new configs on submit', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('API Client ID'), { - target: { value: 'test1' }, - }); - fireEvent.change(screen.getByLabelText('API Client Secret'), { - target: { value: 'test2' }, - }); - fireEvent.change(screen.getByLabelText('Degreed User ID'), { - target: { value: 'test5' }, - }); - fireEvent.change(screen.getByLabelText('Degreed User Password'), { - target: { value: 'test5' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Organization Code'), { - target: { value: 'test3' }, - }); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - fireEvent.change(screen.getByLabelText('Degreed Base URL'), { - target: { value: 'https://test1.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); - - const expectedConfig = { - active: false, - degreed_base_url: 'https://test1.com', - degreed_company_id: 'test3', - degreed_user_id: 'test5', - degreed_user_password: 'test5', - display_name: 'displayName', - key: 'test1', - secret: 'test2', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.postNewDegreedConfig).toHaveBeenCalledWith(expectedConfig); - }); - test('saves draft correctly', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - userEvent.click(screen.getByText('Cancel')); - userEvent.click(screen.getByText('Save')); - const expectedConfig = { - active: false, - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.postNewDegreedConfig).toHaveBeenCalledWith(expectedConfig); - }); - test('validates poorly formatted existing data on load', () => { - render( - , - ); - expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - expect(screen.getByText(INVALID_NAME)).toBeInTheDocument(); - }); - test('validates properly formatted existing data on load', () => { - render( - , - ); - expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx new file mode 100644 index 0000000000..1cde34967c --- /dev/null +++ b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx @@ -0,0 +1,221 @@ +import React from "react"; +import { + act, + render, + fireEvent, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/extend-expect"; + +// @ts-ignore +import DegreedConfig from "../LMSConfigs/Degreed/DegreedConfig.tsx"; +import { + INVALID_LINK, + INVALID_NAME, +} from "../../data/constants"; +import LmsApiService from "../../../../data/services/LmsApiService"; +// @ts-ignore +import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; +import { findElementWithText } from "../../../test/testUtils"; + +jest.mock("../../data/constants", () => ({ + ...jest.requireActual("../../data/constants"), + LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, +})); +window.open = jest.fn(); +const mockUpdateConfigApi = jest.spyOn(LmsApiService, "updateDegreedConfig"); +const mockConfigResponseData = { + uuid: 'foobar', + id: 1, + display_name: 'display name', + degreed_base_url: 'https://foobar.com', + active: false, +}; +mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); + +const mockPostConfigApi = jest.spyOn(LmsApiService, 'postNewDegreedConfig'); +mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); + +const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleDegreedConfig'); +mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: 'foobar' } }); + +const enterpriseId = 'test-enterprise-id'; +const mockOnClick = jest.fn(); +// Freshly creating a config will have an empty existing data object +const noExistingData = {}; + +const existingConfigData = { + id: 1, + displayName: "foobar", + clientId: '1', + clientSecret: 'shhhitsasecret123', + degreedBaseUrl: "https://foobarish.com", + degreedFetchUrl: "https://foobarish.com/fetch" +}; + +// Existing invalid data that will be validated on load +const invalidExistingData = { + displayName: "your display name doesn't need to be this long stop it", + clientId: '1', + clientSecret: 'shhhitsasecret123', + degreedBaseUrl: "bad icky url", + degreedFetchUrl: "https://foobarish.com/fetch" +}; + + +const noConfigs = []; + +afterEach(() => { + jest.clearAllMocks(); +}); + +const mockSetExistingConfigFormData = jest.fn(); + +function testDegreedConfigSetup(formData) { + return ( + + ); +} + +async function clearForm() { + await act(async () => { + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('API Client ID'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('API Client Secret'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Degreed Base URL'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Degreed Token Fetch Base URL'), { + target: { value: '' }, + }); + }); +} + + +describe("", () => { + test("renders Degreed Enable Form", () => { + render(testDegreedConfigSetup(noConfigs)); + screen.getByLabelText("Display Name"); + screen.getByLabelText("API Client ID"); + screen.getByLabelText("API Client Secret"); + screen.getByLabelText("Degreed Base URL"); + screen.getByLabelText("Degreed Token Fetch Base URL"); + }); + test("test button disable", async () => { + render(testDegreedConfigSetup(noExistingData)); + + const enableButton = screen.getByRole('button', { name: 'Enable' }); + await clearForm(); + expect(enableButton).toBeDisabled(); + + userEvent.type(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); + userEvent.type(screen.getByLabelText('API Client ID'), '1'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); + userEvent.type(screen.getByLabelText('Degreed Base URL'), 'badlink'); + + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Degreed Base URL'), { + target: { value: '' }, + }); + expect(enableButton).toBeDisabled(); + expect(screen.queryByText(INVALID_LINK)); + expect(screen.queryByText(INVALID_NAME)); + userEvent.type(screen.getByLabelText("Display Name"), "displayName"); + userEvent.type( + screen.getByLabelText("Degreed Base URL"), + "https://www.test4.com" + ); + expect(enableButton).not.toBeDisabled(); + }); + test('it creates new configs on submit', async () => { + render(testDegreedConfigSetup(noExistingData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); + + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('API Client ID'), '1'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); + userEvent.type(screen.getByLabelText('Degreed Base URL'), 'https://www.test.com'); + userEvent.type(screen.getByLabelText('Degreed Token Fetch Base URL'), 'https://www.test.com'); + + await waitFor(() => expect(enableButton).not.toBeDisabled()); + + userEvent.click(enableButton); + + const expectedConfig = { + active: false, + display_name: 'displayName', + client_id: '1', + client_secret: 'shhhitsasecret123', + degreed_base_url: 'https://www.test.com', + degreed_fetch_url: 'https://www.test.com', + enterprise_customer: enterpriseId, + }; + await waitFor(() => expect(LmsApiService.postNewDegreedConfig).toHaveBeenCalledWith(expectedConfig)); + }); + test('saves draft correctly', async () => { + render(testDegreedConfigSetup(noExistingData)); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('API Client ID'), '1'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); + userEvent.type(screen.getByLabelText('Degreed Base URL'), 'https://www.test.com'); + userEvent.type(screen.getByLabelText('Degreed Token Fetch Base URL'), 'https://www.test.com'); + + expect(cancelButton).not.toBeDisabled(); + userEvent.click(cancelButton); + + await waitFor(() => expect(screen.getByText('Exit configuration')).toBeInTheDocument()); + const closeButton = screen.getByRole('button', { name: 'Exit' }); + + userEvent.click(closeButton); + + const expectedConfig = { + active: false, + display_name: 'displayName', + client_id: '1', + client_secret: 'shhhitsasecret123', + degreed_base_url: 'https://www.test.com', + degreed_fetch_url: 'https://www.test.com', + enterprise_customer: enterpriseId, + }; + expect(LmsApiService.postNewDegreedConfig).toHaveBeenCalledWith(expectedConfig); + }); + test('validates poorly formatted existing data on load', async () => { + render(testDegreedConfigSetup(invalidExistingData)); + screen.debug(); + expect(screen.queryByText("Please enter a valid URL")).toBeInTheDocument(); + expect(screen.queryByText("Display name should be 20 characters or less")).toBeInTheDocument(); + }); + test('validates properly formatted existing data on load', () => { + render(testDegreedConfigSetup(existingConfigData)); + expect(screen.queryByText("Please enter a valid URL")).not.toBeInTheDocument(); + expect(screen.queryByText("Display name should be 20 characters or less")).not.toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index b65cc798c6..bc9892423e 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -116,7 +116,7 @@ describe('', () => { }); const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); userEvent.click(blackboardCard); - expect(screen.queryByText('Connect Blackboard')).toBeTruthy(); + expect(screen.queryByText('Authorize connection to Blackboard')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -125,7 +125,7 @@ describe('', () => { expect(await screen.findByText('Exit configuration')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); - expect(screen.queryByText('Connect Blackboard')).toBeFalsy(); + expect(screen.queryByText('Authorize connection to Blackboard')).toBeFalsy(); }); test('Canvas card cancel flow', async () => { renderWithRouter(); @@ -137,7 +137,7 @@ describe('', () => { }); const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); userEvent.click(canvasCard); - expect(screen.queryByText('Connect Canvas')).toBeTruthy(); + expect(screen.queryByText('Authorize connection to Canvas')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -146,7 +146,7 @@ describe('', () => { expect(await screen.findByText('Exit configuration')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); - expect(screen.queryByText('Connect Canvas')).toBeFalsy(); + expect(screen.queryByText('Authorize connection to Canvas')).toBeFalsy(); }); test('Cornerstone card cancel flow', async () => { renderWithRouter(); @@ -179,16 +179,16 @@ describe('', () => { }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); fireEvent.click(degreedCard); - expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); + expect(screen.queryByText('Enable connection to Degreed')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); const cancelButton = screen.getByText('Cancel'); userEvent.click(cancelButton); - expect(await screen.findByText('Do you want to save your work?')).toBeTruthy(); + expect(await screen.findByText('Exit configuration')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); - expect(screen.queryByText('Connect Degreed')).toBeFalsy(); + expect(screen.queryByText('Enable connection to Degreed')).toBeFalsy(); }); test('Moodle card cancel flow', async () => { renderWithRouter(); @@ -221,7 +221,7 @@ describe('', () => { }); const sapCard = screen.getByText(channelMapping[SAP_TYPE].displayName); userEvent.click(sapCard); - expect(screen.queryByText('Connect SAP')).toBeTruthy(); + expect(screen.queryByText('Connect SAP Success Factors')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -230,7 +230,7 @@ describe('', () => { expect(await screen.findByText('Do you want to save your work?')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); - expect(screen.queryByText('Connect SAP')).toBeFalsy(); + expect(screen.queryByText('Connect SAP Success Factors')).toBeFalsy(); }); test('No action Moodle card cancel flow', async () => { renderWithRouter(); @@ -249,22 +249,6 @@ describe('', () => { await waitFor(() => expect(screen.queryByText('Exit without saving')).toBeFalsy()); await waitFor(() => expect(screen.queryByText('Connect Moodle')).toBeFalsy()); }); - test('No action Degreed2 card cancel flow', async () => { - renderWithRouter(); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => { - userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); - }); - const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - await waitFor(() => fireEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); - const cancelButton = screen.getByText('Cancel'); - await waitFor(() => userEvent.click(cancelButton)); - expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Degreed')).toBeFalsy(); - }); test('No action Degreed card cancel flow', async () => { renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); @@ -275,11 +259,11 @@ describe('', () => { }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); await waitFor(() => fireEvent.click(degreedCard)); - expect(screen.queryByText('Connect Degreed2')).toBeTruthy(); + expect(screen.queryByText('Enable connection to Degreed')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Degreed2')).toBeFalsy(); + expect(screen.queryByText('Enable connection to Degreed')).toBeFalsy(); }); test('No action Cornerstone card cancel flow', async () => { renderWithRouter(); @@ -307,11 +291,11 @@ describe('', () => { }); const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); await waitFor(() => userEvent.click(canvasCard)); - expect(screen.queryByText('Connect Canvas')).toBeTruthy(); + expect(screen.queryByText('Authorize connection to Canvas')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Canvas')).toBeFalsy(); + expect(screen.queryByText('Authorize connection to Canvas')).toBeFalsy(); }); test('No action Blackboard card cancel flow', async () => { renderWithRouter(); @@ -323,11 +307,11 @@ describe('', () => { }); const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); await waitFor(() => userEvent.click(blackboardCard)); - expect(screen.queryByText('Connect Blackboard')).toBeTruthy(); + expect(screen.queryByText('Authorize connection to Blackboard')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Blackbard')).toBeFalsy(); + expect(screen.queryByText('Authorize connection to Blackboard')).toBeFalsy(); }); test('No action SAP card cancel flow', async () => { renderWithRouter(); @@ -339,11 +323,11 @@ describe('', () => { }); const sapCard = screen.getByText(channelMapping[SAP_TYPE].displayName); userEvent.click(sapCard); - expect(screen.queryByText('Connect SAP')).toBeTruthy(); + expect(screen.queryByText('Connect SAP Success Factors')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); userEvent.click(cancelButton); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect SAP')).toBeFalsy(); + expect(screen.queryByText('Connect SAP Success Factors')).toBeFalsy(); }); test('Expected behavior when customer has no IDP configured', async () => { const history = createMemoryHistory(); diff --git a/src/components/settings/data/constants.js b/src/components/settings/data/constants.js index c2d99b0e71..a000f69305 100644 --- a/src/components/settings/data/constants.js +++ b/src/components/settings/data/constants.js @@ -27,7 +27,6 @@ export const errorDeleteDataModalText = 'We were unable to delete your provider export const BLACKBOARD_TYPE = 'BLACKBOARD'; export const CANVAS_TYPE = 'CANVAS'; export const CORNERSTONE_TYPE = 'CSOD'; -export const DEGREED_TYPE = 'DEGREED'; export const DEGREED2_TYPE = 'DEGREED2'; export const MOODLE_TYPE = 'MOODLE'; export const SAP_TYPE = 'SAP'; diff --git a/src/utils.js b/src/utils.js index 13a0a2dade..2c259a39a5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -13,7 +13,7 @@ import { history } from '@edx/frontend-platform/initialize'; import { features } from './config'; import { - BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, + BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, } from './components/settings/data/constants'; import BlackboardIcon from './icons/Blackboard.svg'; import CanvasIcon from './icons/Canvas.svg'; @@ -270,14 +270,8 @@ const channelMapping = { update: LmsApiService.updateCornerstoneConfig, delete: LmsApiService.deleteCornerstoneConfig, }, - [DEGREED_TYPE]: { - displayName: 'Degreed', - icon: DegreedIcon, - update: LmsApiService.updateDegreedConfig, - delete: LmsApiService.deleteDegreedConfig, - }, [DEGREED2_TYPE]: { - displayName: 'Degreed2', + displayName: 'Degreed', icon: DegreedIcon, update: LmsApiService.updateDegreed2Config, delete: LmsApiService.deleteDegreed2Config, @@ -289,7 +283,7 @@ const channelMapping = { delete: LmsApiService.deleteMoodleConfig, }, [SAP_TYPE]: { - displayName: 'SAP', + displayName: 'SAP Success Factors', icon: SAPIcon, update: LmsApiService.updateSuccessFactorsConfig, delete: LmsApiService.deleteSuccessFactorsConfig, From 28a8f1362e55b6224626ce544f8fa2d3391825f6 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 30 Mar 2023 12:59:42 -0600 Subject: [PATCH 65/73] fix: convert moodle to tsx (#981) * fix: changing degreed to tsx + other changes * fix!: removing degreed config * fix: fix tests * fix: converting moodle to tsx * fix: frontend quick fixes * fix: PR requests --- src/components/forms/FormWorkflow.tsx | 2 +- .../settings/SettingsLMSTab/LMSConfigPage.jsx | 30 +- .../Blackboard/BlackboardConfig.tsx | 10 +- .../BlackboardConfigActivatePage.tsx | 24 -- .../BlackboardConfigAuthorizePage.tsx | 25 +- .../LMSConfigs/Canvas/CanvasConfig.tsx | 11 +- .../Canvas/CanvasConfigActivatePage.tsx | 24 -- .../Canvas/CanvasConfigAuthorizePage.tsx | 25 +- .../ConfigBasePages/ConfigActivatePage.tsx | 50 ++- .../LMSConfigs/Degreed/DegreedConfig.tsx | 15 +- .../Degreed/DegreedConfigEnablePage.tsx | 26 +- .../LMSConfigs/Moodle/MoodleConfig.tsx | 208 +++++++++++ .../Moodle/MoodleConfigEnablePage.tsx | 154 +++++++++ .../LMSConfigs/MoodleConfig.jsx | 244 ------------- .../SettingsLMSTab/UnsavedChangesModal.tsx | 1 - .../tests/BlackboardConfig.test.tsx | 8 +- .../tests/CanvasConfig.test.tsx | 8 +- .../tests/DegreedConfig.test.tsx | 9 +- .../tests/LmsConfigPage.test.jsx | 19 +- .../tests/MoodleConfig.test.jsx | 323 ++++++++++-------- src/components/settings/data/constants.js | 1 + 21 files changed, 678 insertions(+), 539 deletions(-) delete mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx delete mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx delete mode 100644 src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index 16ca291d02..624af2b4ed 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -50,7 +50,7 @@ export type FormWorkflowButtonConfig = { awaitSuccess?: FormWorkflowAwaitHandler; }; -type DynamicComponent = React.FunctionComponent | React.ComponentClass; +type DynamicComponent = React.FunctionComponent | React.ComponentClass | React.ElementType; export type FormWorkflowStep = { index: number; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 75788c6540..2495cbd168 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -15,8 +15,8 @@ import { import { BlackboardFormConfig } from './LMSConfigs/Blackboard/BlackboardConfig.tsx'; import { CanvasFormConfig } from './LMSConfigs/Canvas/CanvasConfig.tsx'; import { DegreedFormConfig } from './LMSConfigs/Degreed/DegreedConfig.tsx'; +import { MoodleFormConfig } from './LMSConfigs/Moodle/MoodleConfig.tsx'; import CornerstoneConfig from './LMSConfigs/CornerstoneConfig'; -import MoodleConfig from './LMSConfigs/MoodleConfig'; import SAPConfig from './LMSConfigs/SAPConfig'; import FormContextWrapper from '../../forms/FormContextWrapper.tsx'; @@ -24,6 +24,7 @@ const flowConfigs = { [BLACKBOARD_TYPE]: BlackboardFormConfig, [CANVAS_TYPE]: CanvasFormConfig, [DEGREED2_TYPE]: DegreedFormConfig, + [MOODLE_TYPE]: MoodleFormConfig, }; const LMSConfigPage = ({ @@ -108,20 +109,19 @@ const LMSConfigPage = ({ /> )} {LMSType === MOODLE_TYPE && ( - <> -

- - - Connect {channelMapping[LMSType]?.displayName} - -

- - + )} {LMSType === SAP_TYPE && ( <> diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx index 24052ad152..2bc5fb6d97 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx @@ -3,12 +3,13 @@ import LmsApiService from "../../../../../data/services/LmsApiService"; import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; import { BLACKBOARD_OAUTH_REDIRECT_URL, + BLACKBOARD_TYPE, LMS_CONFIG_OAUTH_POLLING_INTERVAL, LMS_CONFIG_OAUTH_POLLING_TIMEOUT, SUBMIT_TOAST_MESSAGE, } from "../../../data/constants"; // @ts-ignore -import BlackboardConfigActivatePage from "./BlackboardConfigActivatePage.tsx"; +import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; import BlackboardConfigAuthorizePage, { validations, formFieldNames @@ -44,8 +45,6 @@ export type BlackboardConfigCamelCase = { refreshToken: string; }; -// TODO: Can we generate this dynamically? -// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html export type BlackboardConfigSnakeCase = { blackboard_base_url: string; display_name: string; @@ -56,7 +55,6 @@ export type BlackboardConfigSnakeCase = { refresh_token: string; }; -// TODO: Make this a generic type usable by all lms configs export type BlackboardFormConfigProps = { enterpriseCustomerUuid: string; existingData: BlackboardConfigCamelCase; @@ -227,6 +225,8 @@ export const BlackboardFormConfig = ({ dispatch?.(setWorkflowStateAction(LMS_AUTHORIZATION_FAILED, true)); }; + const activatePage = () => ConfigActivatePage(BLACKBOARD_TYPE); + const steps: FormWorkflowStep[] = [ { index: 0, @@ -259,7 +259,7 @@ export const BlackboardFormConfig = ({ }, { index: 1, - formComponent: BlackboardConfigActivatePage, + formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx deleted file mode 100644 index e410f27d60..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigActivatePage.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import { Form } from '@edx/paragon'; - -// Page 3 of Blackboard LMS config workflow -const BlackboardConfigActivatePage = () => ( - -
-

Activate your Blackboard integration

- -

- Your Blackboard integration has been successfully authorized and is ready to - activate! -

- -

- Once activated, edX For Business will begin syncing content metadata and - learner activity with Blackboard. -

-
-
-); - -export default BlackboardConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx index a696a8337e..b973163248 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { Form, Alert } from "@edx/paragon"; +import { Alert, Container, Form, Image } from "@edx/paragon"; import { Info } from "@edx/paragon/icons"; // @ts-ignore import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; -import { urlValidation } from "../../../../../utils"; +import { BLACKBOARD_TYPE, INVALID_LINK, INVALID_NAME } from "../../../data/constants"; +import { channelMapping, urlValidation } from "../../../../../utils"; import type { FormFieldValidation, } from "../../../../forms/FormContext"; @@ -32,16 +33,15 @@ export const validations: FormFieldValidation[] = [ formFieldId: formFieldNames.BLACKBOARD_BASE_URL, validator: (fields) => { const error = !urlValidation(fields[formFieldNames.BLACKBOARD_BASE_URL]); - return error && "Please enter a valid URL"; + return error && INVALID_LINK; }, }, { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { - // TODO: Check for duplicate display names const displayName = fields[formFieldNames.DISPLAY_NAME]; const error = displayName?.length > 20; - return error && "Display name should be 20 characters or less"; + return error && INVALID_NAME; }, }, ]; @@ -50,9 +50,16 @@ export const validations: FormFieldValidation[] = [ const BlackboardConfigAuthorizePage = () => { const { dispatch, stateMap } = useFormContext(); return ( - -

Authorize connection to Blackboard

- + + + +

+ Authorize connection to Blackboard +

+
{stateMap?.[LMS_AUTHORIZATION_FAILED] && ( @@ -88,7 +95,7 @@ const BlackboardConfigAuthorizePage = () => { text="Please confirm authorization through Blackboard and return to this window once complete." /> -
+ ); }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx index d394793b1d..ca1e9bf360 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx @@ -3,12 +3,12 @@ import LmsApiService from "../../../../../data/services/LmsApiService"; import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; import { CANVAS_OAUTH_REDIRECT_URL, + CANVAS_TYPE, LMS_CONFIG_OAUTH_POLLING_INTERVAL, LMS_CONFIG_OAUTH_POLLING_TIMEOUT, SUBMIT_TOAST_MESSAGE, } from "../../../data/constants"; // @ts-ignore -import CanvasConfigActivatePage from "./CanvasConfigActivatePage.tsx"; import CanvasConfigAuthorizePage, { validations, formFieldNames @@ -31,6 +31,8 @@ import { import type { FormFieldValidation, } from "../../../../forms/FormContext"; +// @ts-ignore +import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; export type CanvasConfigCamelCase = { canvasAccountId: string; @@ -44,8 +46,6 @@ export type CanvasConfigCamelCase = { refreshToken: string; }; -// TODO: Can we generate this dynamically? -// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html export type CanvasConfigSnakeCase = { canvas_account_id: string; canvas_base_url: string; @@ -59,7 +59,6 @@ export type CanvasConfigSnakeCase = { refresh_token: string; }; -// TODO: Make this a generic type usable by all lms configs export type CanvasFormConfigProps = { enterpriseCustomerUuid: string; existingData: CanvasConfigCamelCase; @@ -219,6 +218,8 @@ export const CanvasFormConfig = ({ dispatch?.(setWorkflowStateAction(LMS_AUTHORIZATION_FAILED, true)); }; + const activatePage = () => ConfigActivatePage(CANVAS_TYPE); + const steps: FormWorkflowStep[] = [ { index: 0, @@ -251,7 +252,7 @@ export const CanvasFormConfig = ({ }, { index: 1, - formComponent: CanvasConfigActivatePage, + formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx deleted file mode 100644 index 6412f00f77..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigActivatePage.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import { Form } from '@edx/paragon'; - -// Page 3 of Canvas LMS config workflow -const CanvasConfigActivatePage = () => ( - -
-

Activate your Canvas integration

- -

- Your Canvas integration has been successfully authorized and is ready to - activate! -

- -

- Once activated, edX For Business will begin syncing content metadata and - learner activity with Canvas. -

-
-
-); - -export default CanvasConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx index dffdca3854..ea0b96dd74 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { Form, Alert } from "@edx/paragon"; +import { Alert, Container, Form, Image } from "@edx/paragon"; import { Info } from "@edx/paragon/icons"; +import { CANVAS_TYPE, INVALID_LINK, INVALID_NAME } from "../../../data/constants"; // @ts-ignore import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; -import { isValidNumber, urlValidation } from "../../../../../utils"; +import { channelMapping, isValidNumber, urlValidation } from "../../../../../utils"; import type { FormFieldValidation, } from "../../../../forms/FormContext"; @@ -37,7 +38,7 @@ export const validations: FormFieldValidation[] = [ const canvasUrl = fields[formFieldNames.CANVAS_BASE_URL]; if (canvasUrl) { const error = !urlValidation(canvasUrl); - return error ? "Please enter a valid URL" : false; + return error ? INVALID_LINK : false; } else { return true; } @@ -55,7 +56,7 @@ export const validations: FormFieldValidation[] = [ validator: (fields) => { const displayName = fields[formFieldNames.DISPLAY_NAME]; const error = displayName?.length > 20; - return error && "Display name should be 20 characters or less"; + return error && INVALID_NAME; }, }, { @@ -84,11 +85,17 @@ export const validations: FormFieldValidation[] = [ const CanvasConfigAuthorizePage = () => { const { dispatch, stateMap } = useFormContext(); return ( - -

Authorize connection to Canvas

- + + + +

+ Authorize connection to Canvas +

+
- {/* TODO: Add vertical spacing between fields */} {stateMap?.[LMS_AUTHORIZATION_FAILED] && (

Enablement failed

@@ -152,7 +159,7 @@ const CanvasConfigAuthorizePage = () => { text="Please confirm authorization through Canvas and return to this window once complete." /> -
+ ); }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx index 4993ce4870..ae44d06b99 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/ConfigBasePages/ConfigActivatePage.tsx @@ -1,23 +1,39 @@ import React from 'react'; -import { Form } from '@edx/paragon'; +import { Container, Form, Image } from '@edx/paragon'; +import { BLACKBOARD_TYPE, CANVAS_TYPE } from '../../../data/constants'; +import { channelMapping } from '../../../../../utils'; -const ConfigActivatePage = ({ lmsType }) => ( - -
-

Activate your {lmsType} integration

+const ConfigActivatePage = (lmsType: string) => { + let verb = 'enabled'; + if (lmsType == CANVAS_TYPE || lmsType == BLACKBOARD_TYPE) { + verb = 'authorized' + } + const lmsName = channelMapping[lmsType].displayName; + return ( + + + + +

+ Activate your {lmsName} integration +

+
+

+ Your {lmsName} integration has been successfully {verb} and is ready to + activate! +

-

- Your {lmsType} integration has been successfully authorized and is ready to - activate! -

- -

- Once activated, edX For Business will begin syncing content metadata and - learner activity with {lmsType}. -

- -
-); +

+ Once activated, edX For Business will begin syncing content metadata and + learner activity with {lmsName}. +

+ + + ) +}; export default ConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx index c315574d0e..1ba8cbe542 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx @@ -1,9 +1,9 @@ import handleErrors from "../../../utils"; import LmsApiService from "../../../../../data/services/LmsApiService"; import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; -import { SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +import { DEGREED2_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; // @ts-ignore -import DegreedConfigActivatePage from "./DegreedConfigActivatePage.tsx"; +import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; import DegreedConfigAuthorizePage, { validations, formFieldNames @@ -14,12 +14,8 @@ import type { FormWorkflowConfig, FormWorkflowStep, FormWorkflowHandlerArgs, - FormWorkflowErrorHandler, } from "../../../../forms/FormWorkflow"; -// @ts-ignore -import { WAITING_FOR_ASYNC_OPERATION } from "../../../../forms/FormWorkflow.tsx"; import { - setWorkflowStateAction, updateFormFieldsAction, // @ts-ignore } from "../../../../forms/data/actions.ts"; @@ -38,8 +34,6 @@ export type DegreedConfigCamelCase = { uuid: string; }; -// TODO: Can we generate this dynamically? -// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html export type DegreedConfigSnakeCase = { display_name: string; client_id: string; @@ -53,7 +47,6 @@ export type DegreedConfigSnakeCase = { refresh_token: string; }; -// TODO: Make this a generic type usable by all lms configs export type DegreedFormConfigProps = { enterpriseCustomerUuid: string; existingData: DegreedConfigCamelCase; @@ -166,6 +159,8 @@ export const DegreedFormConfig = ({ return currentFormFields; }; + const activatePage = () => ConfigActivatePage(DEGREED2_TYPE); + const steps: FormWorkflowStep[] = [ { index: 0, @@ -184,7 +179,7 @@ export const DegreedFormConfig = ({ }, { index: 1, - formComponent: DegreedConfigActivatePage, + formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx index eb3938be73..ace6994533 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { Form, Alert } from "@edx/paragon"; -import { Info } from "@edx/paragon/icons"; +import { Container, Form, Image } from "@edx/paragon"; +import { DEGREED2_TYPE, INVALID_LINK, INVALID_NAME } from "../../../data/constants"; // @ts-ignore import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; -import { urlValidation } from "../../../../../utils"; +import { channelMapping, urlValidation } from "../../../../../utils"; import type { FormFieldValidation, } from "../../../../forms/FormContext"; @@ -29,7 +29,7 @@ export const validations: FormFieldValidation[] = [ const degreedUrl = fields[formFieldNames.DEGREED_BASE_URL]; if (degreedUrl) { const error = !urlValidation(degreedUrl); - return error ? "Please enter a valid URL" : false; + return error ? INVALID_LINK : false; } else { return true; } @@ -41,7 +41,7 @@ export const validations: FormFieldValidation[] = [ const degreedUrl = fields[formFieldNames.DEGREED_FETCH_URL]; if (degreedUrl) { const error = !urlValidation(degreedUrl); - return error ? "Please enter a valid URL" : false; + return error ? INVALID_LINK : false; } else { // fetch url is optional return false; @@ -60,7 +60,7 @@ export const validations: FormFieldValidation[] = [ validator: (fields) => { const displayName = fields[formFieldNames.DISPLAY_NAME]; const error = displayName?.length > 20; - return error && "Display name should be 20 characters or less"; + return error && INVALID_NAME; }, }, { @@ -83,8 +83,16 @@ export const validations: FormFieldValidation[] = [ const DegreedConfigEnablePage = () => { const { dispatch, stateMap } = useFormContext(); return ( - -

Enable connection to Degreed

+ + + +

+ Enable connection to Degreed +

+
{ />
-
+ ); }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx new file mode 100644 index 0000000000..7fb1af0bd9 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx @@ -0,0 +1,208 @@ +import handleErrors from "../../../utils"; +import LmsApiService from "../../../../../data/services/LmsApiService"; +import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; +import { INVALID_NAME, MOODLE_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +// @ts-ignore +import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; + +import MoodleConfigAuthorizePage, { + validations, + formFieldNames + // @ts-ignore +} from "./MoodleConfigEnablePage.tsx"; +import type { + FormWorkflowButtonConfig, + FormWorkflowConfig, + FormWorkflowStep, + FormWorkflowHandlerArgs, +} from "../../../../forms/FormWorkflow"; +import { + updateFormFieldsAction, + // @ts-ignore +} from "../../../../forms/data/actions.ts"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; + +export type MoodleConfigCamelCase = { + displayName: string; + moodleBaseUrl: string; + webserviceShortName: string; + token: string; + username: string; + password: string; + id: string; + active: boolean; + uuid: string; +}; + +export type MoodleConfigSnakeCase = { + display_name: string; + moodle_base_url: string; + webservice_short_name: string; + token: string; + username: string; + password: string; + id: string; + active: boolean; + uuid: string; + enterprise_customer: string; +}; + +export type MoodleFormConfigProps = { + enterpriseCustomerUuid: string; + existingData: MoodleConfigCamelCase; + existingConfigNames: string[]; + onSubmit: (moodleConfig: MoodleConfigCamelCase) => void; + onClickCancel: (submitted: boolean, status: string) => Promise; +}; + +export const MoodleFormConfig = ({ + enterpriseCustomerUuid, + onSubmit, + onClickCancel, + existingData, + existingConfigNames, +}: MoodleFormConfigProps): FormWorkflowConfig => { + const configNames: string[] = existingConfigNames?.filter( (name) => name !== existingData.displayName); + const checkForDuplicateNames: FormFieldValidation = { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (formFields: MoodleConfigCamelCase) => { + return configNames?.includes(formFields.displayName) + ? INVALID_NAME + : false; + }, + }; + + const saveChanges = async ( + formFields: MoodleConfigCamelCase, + errHandler: (errMsg: string) => void + ) => { + const transformedConfig: MoodleConfigSnakeCase = snakeCaseDict( + formFields + ) as MoodleConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err = ""; + + if (formFields.id) { + try { + transformedConfig.active = existingData.active; + await LmsApiService.updateMoodleConfig( + transformedConfig, + existingData.id + ); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + await LmsApiService.postNewMoodleConfig(transformedConfig); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } + + if (err) { + errHandler(err); + } + return !err; + }; + + const handleSubmit = async ({ + formFields, + formFieldsChanged, + errHandler, + dispatch, + }: FormWorkflowHandlerArgs) => { + let currentFormFields = formFields; + const transformedConfig: MoodleConfigSnakeCase = snakeCaseDict( + formFields + ) as MoodleConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err = ""; + if (formFieldsChanged) { + if (currentFormFields?.id) { + try { + transformedConfig.active = existingData.active; + const response = await LmsApiService.updateMoodleConfig( + transformedConfig, + existingData.id + ); + currentFormFields = camelCaseDict( + response.data + ) as MoodleConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + const response = await LmsApiService.postNewMoodleConfig( + transformedConfig + ); + currentFormFields = camelCaseDict( + response.data + ) as MoodleConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } + } + if (err) { + errHandler?.(err); + } + return currentFormFields; + }; + + const activatePage = () => ConfigActivatePage(MOODLE_TYPE); + + const steps: FormWorkflowStep[] = [ + { + index: 0, + formComponent: MoodleConfigAuthorizePage, + validations: validations.concat([checkForDuplicateNames]), + stepName: "Enable", + saveChanges, + nextButtonConfig: () => { + let config = { + buttonText: "Enable", + opensNewWindow: false, + onClick: handleSubmit, + }; + return config as FormWorkflowButtonConfig; + }, + }, + { + index: 1, + formComponent: activatePage, + validations: [], + stepName: "Activate", + saveChanges, + nextButtonConfig: () => ({ + buttonText: "Activate", + opensNewWindow: false, + onClick: () => { + onClickCancel(true, SUBMIT_TOAST_MESSAGE); + return Promise.resolve(existingData); + }, + }), + }, + ]; + + // Go to authorize step for now + const getCurrentStep = () => steps[0]; + + return { + getCurrentStep, + steps, + }; +}; + +export default MoodleFormConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx new file mode 100644 index 0000000000..b42d83f4da --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx @@ -0,0 +1,154 @@ +import React from "react"; + +import { Container, Form, Image } from "@edx/paragon"; + +// @ts-ignore +import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; +import { channelMapping, urlValidation } from "../../../../../utils"; +import { INVALID_LINK, INVALID_MOODLE_VERIFICATION, INVALID_NAME, MOODLE_TYPE } from "../../../data/constants"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; + +export const formFieldNames = { + DISPLAY_NAME: "displayName", + MOODLE_BASE_URL: "moodleBaseUrl", + WEBSERVICE_SHORT_NAME: "webserviceShortName", + TOKEN: "token", + USERNAME: "username", + PASSWORD: "password", + +}; + +export const validations: FormFieldValidation[] = [ + { + formFieldId: formFieldNames.MOODLE_BASE_URL, + validator: (fields) => { + const moodleUrl = fields[formFieldNames.MOODLE_BASE_URL]; + if (moodleUrl) { + const error = !urlValidation(moodleUrl); + return error ? INVALID_LINK : false; + } else { + return true; + } + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const displayName = fields[formFieldNames.DISPLAY_NAME]; + return !displayName; + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const displayName = fields[formFieldNames.DISPLAY_NAME]; + const error = displayName?.length > 20; + return error && INVALID_NAME; + }, + }, + { + formFieldId: formFieldNames.WEBSERVICE_SHORT_NAME, + validator: (fields) => { + const webserviceShortName = fields[formFieldNames.WEBSERVICE_SHORT_NAME]; + return !webserviceShortName; + }, + }, + { + formFieldId: formFieldNames.PASSWORD, + validator: (fields) => { + const token = fields[formFieldNames.TOKEN]; + const username = fields[formFieldNames.USERNAME]; + const password = fields[formFieldNames.PASSWORD]; + + if (!token) { + if (username && password) { + return false; + } + } else { + if (!username && !password) { + return false; + } + } + if (!token && !username && !password) { + return true; + } + return INVALID_MOODLE_VERIFICATION; + }, + }, +]; + +// Settings page of Moodle LMS config workflow +const MoodleConfigEnablePage = () => { + return ( + + + +

+ Enable connection to Moodle +

+
+
+ + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default MoodleConfigEnablePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx deleted file mode 100644 index cc52692158..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/MoodleConfig.jsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Button, Form, useToggle } from '@edx/paragon'; -import isEmpty from 'lodash/isEmpty'; -import buttonBool, { isExistingConfig } from '../utils'; -import handleErrors from '../../utils'; -import LmsApiService from '../../../../data/services/LmsApiService'; -import { snakeCaseDict, urlValidation } from '../../../../utils'; -import ConfigError from '../../ConfigError'; -import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; - -const MoodleConfig = ({ - enterpriseCustomerUuid, onClick, existingData, existingConfigs, -}) => { - const [moodleBaseUrl, setMoodleBaseUrl] = React.useState(''); - const [urlValid, setUrlValid] = React.useState(true); - const [serviceShortName, setServiceShortName] = React.useState(''); - const [displayName, setDisplayName] = React.useState(''); - const [token, setToken] = React.useState(''); - const [username, setUsername] = React.useState(''); - const [password, setPassword] = React.useState(''); - const [nameValid, setNameValid] = React.useState(true); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [modalIsOpen, openModal, closeModal] = useToggle(false); - const [edited, setEdited] = React.useState(false); - - const config = { - moodleBaseUrl, - serviceShortName, - displayName, - token, - username, - password, - }; - - const configToValidate = () => { - if (!token && (!username || !password)) { - return config; - } - return { - moodleBaseUrl, - serviceShortName, - displayName, - }; - }; - - useEffect(() => { - setMoodleBaseUrl(existingData.moodleBaseUrl); - setServiceShortName(existingData.serviceShortName); - setDisplayName(existingData.displayName); - setToken(existingData.token); - setUsername(existingData.username); - setPassword(existingData.password); - }, [existingData]); - - const handleCancel = () => { - if (edited) { - openModal(); - } else { - onClick(''); - } - }; - - const handleSubmit = async (event) => { - event.preventDefault(); - const transformedConfig = snakeCaseDict(config); - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - - if (!isEmpty(existingData)) { - try { - transformedConfig.active = existingData.active; - await LmsApiService.updateMoodleConfig(transformedConfig, existingData.id); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewMoodleConfig(transformedConfig); - } catch (error) { - err = handleErrors(error); - } - } - - if (err) { - openError(); - } else { - onClick(SUBMIT_TOAST_MESSAGE); - } - }; - - const validateField = useCallback((field, input) => { - switch (field) { - case 'Moodle Base URL': - setMoodleBaseUrl(input); - setUrlValid(urlValidation(input) || input?.length === 0); - break; - case 'Display Name': - setDisplayName(input); - if (isExistingConfig(existingConfigs, input, existingData.displayName)) { - setNameValid(input?.length <= 20); - } else { - setNameValid(input?.length <= 20 && !Object.values(existingConfigs).includes(input)); - } - break; - default: - break; - } - }, [existingConfigs, existingData.displayName]); - - useEffect(() => { - if (!isEmpty(existingData)) { - validateField('Moodle Base URL', existingData.moodleBaseUrl); - validateField('Display Name', existingData.displayName); - } - }, [existingConfigs, existingData, validateField]); - - return ( - - - -
- - { - setEdited(true); - validateField('Display Name', e.target.value); - }} - floatingLabel="Display Name" - defaultValue={existingData.displayName} - /> - Create a custom name for this LMS. - {!nameValid && ( - - {INVALID_NAME} - - )} - - - { - setEdited(true); - validateField('Moodle Base URL', e.target.value); - }} - floatingLabel="Moodle Base URL" - defaultValue={existingData.moodleBaseUrl} - /> - {!urlValid && ( - - {INVALID_LINK} - - )} - - - { - setEdited(true); - setServiceShortName(e.target.value); - }} - floatingLabel="Webservice Short Name" - defaultValue={existingData.serviceShortName} - /> - - - { - setEdited(true); - setToken(e.target.value); - }} - floatingLabel="Token" - defaultValue={existingData.token} - disabled={password || username} - /> - {(username || password) && ( - Please provide either a token or a username and password. - )} - - - { - setEdited(true); - setUsername(e.target.value); - }} - floatingLabel="Username" - defaultValue={existingData.username} - disabled={token} - /> - {token && ( - Please provide either a token or a username and password. - )} - - - { - setEdited(true); - setPassword(e.target.value); - }} - floatingLabel="Password" - defaultValue={existingData.password} - disabled={token} - /> - - - - - -
-
- ); -}; - -MoodleConfig.propTypes = { - enterpriseCustomerUuid: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - existingData: PropTypes.shape({ - active: PropTypes.bool, - token: PropTypes.string, - username: PropTypes.string, - password: PropTypes.string, - displayName: PropTypes.string, - id: PropTypes.number, - moodleBaseUrl: PropTypes.string, - serviceShortName: PropTypes.string, - }).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string).isRequired, -}; -export default MoodleConfig; diff --git a/src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx b/src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx index 054a1ac889..94aae982c9 100644 --- a/src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx +++ b/src/components/settings/SettingsLMSTab/UnsavedChangesModal.tsx @@ -32,7 +32,6 @@ const UnsavedChangesModal = ({ {MODAL_TEXT} - {/* TODO: Fix typescript issue with Paragon Button */} {/* @ts-ignore */} - - - - - ); -}; - -CornerstoneConfig.propTypes = { - enterpriseCustomerUuid: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - existingData: PropTypes.shape({ - active: PropTypes.bool, - id: PropTypes.number, - cornerstoneBaseUrl: PropTypes.string, - displayName: PropTypes.string, - }).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string).isRequired, -}; -export default CornerstoneConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx index 1ba8cbe542..d09f28ff69 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx @@ -1,27 +1,18 @@ -import handleErrors from "../../../utils"; -import LmsApiService from "../../../../../data/services/LmsApiService"; -import { camelCaseDict, snakeCaseDict } from "../../../../../utils"; +import { snakeCaseDict } from "../../../../../utils"; import { DEGREED2_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; // @ts-ignore import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; -import DegreedConfigAuthorizePage, { - validations, - formFieldNames - // @ts-ignore -} from "./DegreedConfigEnablePage.tsx"; +// @ts-ignore +import DegreedConfigAuthorizePage, { validations } from "./DegreedConfigEnablePage.tsx"; import type { FormWorkflowButtonConfig, FormWorkflowConfig, FormWorkflowStep, FormWorkflowHandlerArgs, -} from "../../../../forms/FormWorkflow"; -import { - updateFormFieldsAction, // @ts-ignore -} from "../../../../forms/data/actions.ts"; -import type { - FormFieldValidation, -} from "../../../../forms/FormContext"; +} from "../../../../forms/FormWorkflow.tsx"; +// @ts-ignore +import { checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; export type DegreedConfigCamelCase = { displayName: string; @@ -53,6 +44,7 @@ export type DegreedFormConfigProps = { existingConfigNames: string[]; onSubmit: (degreedConfig: DegreedConfigCamelCase) => void; onClickCancel: (submitted: boolean, status: string) => Promise; + channelMap: Record>, }; export const DegreedFormConfig = ({ @@ -61,17 +53,8 @@ export const DegreedFormConfig = ({ onClickCancel, existingData, existingConfigNames, + channelMap, }: DegreedFormConfigProps): FormWorkflowConfig => { - const configNames: string[] = existingConfigNames?.filter( (name) => name !== existingData.displayName); - const checkForDuplicateNames: FormFieldValidation = { - formFieldId: formFieldNames.DISPLAY_NAME, - validator: (formFields: DegreedConfigCamelCase) => { - return configNames?.includes(formFields.displayName) - ? "Display name already taken" - : false; - }, - }; - const saveChanges = async ( formFields: DegreedConfigCamelCase, errHandler: (errMsg: string) => void @@ -80,33 +63,7 @@ export const DegreedFormConfig = ({ formFields ) as DegreedConfigSnakeCase; transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err = ""; - - if (formFields.id) { - try { - transformedConfig.active = existingData.active; - await LmsApiService.updateDegreedConfig( - transformedConfig, - existingData.id - ); - onSubmit(formFields); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewDegreedConfig(transformedConfig); - onSubmit(formFields); - } catch (error) { - err = handleErrors(error); - } - } - - if (err) { - errHandler(err); - } - return !err; + return handleSaveHelper(transformedConfig, existingData, formFields, onSubmit, DEGREED2_TYPE, channelMap, errHandler); }; const handleSubmit = async ({ @@ -120,43 +77,9 @@ export const DegreedFormConfig = ({ formFields ) as DegreedConfigSnakeCase; transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err = ""; - if (formFieldsChanged) { - if (currentFormFields?.id) { - try { - transformedConfig.active = existingData.active; - const response = await LmsApiService.updateDegreedConfig( - transformedConfig, - existingData.id - ); - currentFormFields = camelCaseDict( - response.data - ) as DegreedConfigCamelCase; - onSubmit(currentFormFields); - dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - const response = await LmsApiService.postNewDegreedConfig( - transformedConfig - ); - currentFormFields = camelCaseDict( - response.data - ) as DegreedConfigCamelCase; - onSubmit(currentFormFields); - dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); - } catch (error) { - err = handleErrors(error); - } - } - } - if (err) { - errHandler?.(err); - } - return currentFormFields; + return handleSubmitHelper( + enterpriseCustomerUuid, transformedConfig, existingData, onSubmit, formFieldsChanged, + currentFormFields, DEGREED2_TYPE, channelMap, errHandler, dispatch); }; const activatePage = () => ConfigActivatePage(DEGREED2_TYPE); @@ -165,7 +88,7 @@ export const DegreedFormConfig = ({ { index: 0, formComponent: DegreedConfigAuthorizePage, - validations: validations.concat([checkForDuplicateNames]), + validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Enable", saveChanges, nextButtonConfig: () => { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigActivatePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigActivatePage.tsx deleted file mode 100644 index 0a4280478f..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigActivatePage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -import { Form } from '@edx/paragon'; - -const DegreedConfigActivatePage = () => ( - -
-

Activate your Degreed integration

- -

- Your Degreed integration has been successfully created and is ready to - activate! -

- -

- Once activated, edX For Business will begin syncing content metadata and - learner activity with Degreed. -

-
-
-); - -export default DegreedConfigActivatePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx index ace6994533..dbc25fe4d7 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx @@ -81,7 +81,6 @@ export const validations: FormFieldValidation[] = [ // Settings page of Degreed LMS config workflow const DegreedConfigEnablePage = () => { - const { dispatch, stateMap } = useFormContext(); return ( @@ -94,7 +93,7 @@ const DegreedConfigEnablePage = () => {
- + { void; onClickCancel: (submitted: boolean, status: string) => Promise; + channelMap: Record>; }; export const MoodleFormConfig = ({ @@ -63,16 +54,8 @@ export const MoodleFormConfig = ({ onClickCancel, existingData, existingConfigNames, + channelMap, }: MoodleFormConfigProps): FormWorkflowConfig => { - const configNames: string[] = existingConfigNames?.filter( (name) => name !== existingData.displayName); - const checkForDuplicateNames: FormFieldValidation = { - formFieldId: formFieldNames.DISPLAY_NAME, - validator: (formFields: MoodleConfigCamelCase) => { - return configNames?.includes(formFields.displayName) - ? INVALID_NAME - : false; - }, - }; const saveChanges = async ( formFields: MoodleConfigCamelCase, @@ -82,33 +65,7 @@ export const MoodleFormConfig = ({ formFields ) as MoodleConfigSnakeCase; transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err = ""; - - if (formFields.id) { - try { - transformedConfig.active = existingData.active; - await LmsApiService.updateMoodleConfig( - transformedConfig, - existingData.id - ); - onSubmit(formFields); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewMoodleConfig(transformedConfig); - onSubmit(formFields); - } catch (error) { - err = handleErrors(error); - } - } - - if (err) { - errHandler(err); - } - return !err; + return handleSaveHelper(transformedConfig, existingData, formFields, onSubmit, MOODLE_TYPE, channelMap, errHandler); }; const handleSubmit = async ({ @@ -122,43 +79,9 @@ export const MoodleFormConfig = ({ formFields ) as MoodleConfigSnakeCase; transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err = ""; - if (formFieldsChanged) { - if (currentFormFields?.id) { - try { - transformedConfig.active = existingData.active; - const response = await LmsApiService.updateMoodleConfig( - transformedConfig, - existingData.id - ); - currentFormFields = camelCaseDict( - response.data - ) as MoodleConfigCamelCase; - onSubmit(currentFormFields); - dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - const response = await LmsApiService.postNewMoodleConfig( - transformedConfig - ); - currentFormFields = camelCaseDict( - response.data - ) as MoodleConfigCamelCase; - onSubmit(currentFormFields); - dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); - } catch (error) { - err = handleErrors(error); - } - } - } - if (err) { - errHandler?.(err); - } - return currentFormFields; + return handleSubmitHelper( + enterpriseCustomerUuid, transformedConfig, existingData, onSubmit, formFieldsChanged, + currentFormFields, MOODLE_TYPE, channelMap, errHandler, dispatch) }; const activatePage = () => ConfigActivatePage(MOODLE_TYPE); @@ -166,8 +89,8 @@ export const MoodleFormConfig = ({ const steps: FormWorkflowStep[] = [ { index: 0, - formComponent: MoodleConfigAuthorizePage, - validations: validations.concat([checkForDuplicateNames]), + formComponent: MoodleConfigEnablePage, + validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Enable", saveChanges, nextButtonConfig: () => { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx new file mode 100644 index 0000000000..13333625e1 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx @@ -0,0 +1,133 @@ +import { snakeCaseDict } from "../../../../../utils"; +import { SAP_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +// @ts-ignore +import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; +// @ts-ignore +import SAPConfigEnablePage, { validations } from "./SAPConfigEnablePage.tsx"; +import type { + FormWorkflowButtonConfig, + FormWorkflowConfig, + FormWorkflowStep, + FormWorkflowHandlerArgs, + // @ts-ignore +} from "../../../../forms/FormWorkflow.tsx"; +// @ts-ignore +import { checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; + +export type SAPConfigCamelCase = { + displayName: string; + sapBaseUrl: string; + sapCompanyId: string; + sapUserId: string; + oauthClientId: string; + oauthClientSecret: string; + sapUserType: string; + id: string; + active: boolean; + uuid: string; +}; + +export type SAPConfigSnakeCase = { + display_name: string; + sap_base_url: string; + sap_company_id: string; + sap_user_id: string; + oauth_client_id: string; + oauth_client_secret: string; + sap_user_type: string; + id: string; + active: boolean; + uuid: string; + enterprise_customer: string; +}; + +export type SAPFormConfigProps = { + enterpriseCustomerUuid: string; + existingData: SAPConfigCamelCase; + existingConfigNames: string[]; + onSubmit: (sapConfig: SAPConfigCamelCase) => void; + onClickCancel: (submitted: boolean, status: string) => Promise; + channelMap: Record>; +}; + +export const SAPFormConfig = ({ + enterpriseCustomerUuid, + onSubmit, + onClickCancel, + existingData, + existingConfigNames, + channelMap, +}: SAPFormConfigProps): FormWorkflowConfig => { + + const saveChanges = async ( + formFields: SAPConfigCamelCase, + errHandler: (errMsg: string) => void + ) => { + const transformedConfig: SAPConfigSnakeCase = snakeCaseDict( + formFields + ) as SAPConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + return handleSaveHelper(transformedConfig, existingData, formFields, onSubmit, SAP_TYPE, channelMap, errHandler); + }; + + const handleSubmit = async ({ + formFields, + formFieldsChanged, + errHandler, + dispatch, + }: FormWorkflowHandlerArgs) => { + let currentFormFields = formFields; + const transformedConfig: SAPConfigSnakeCase = snakeCaseDict( + formFields + ) as SAPConfigSnakeCase; + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + return handleSubmitHelper( + enterpriseCustomerUuid, transformedConfig, existingData, onSubmit, formFieldsChanged, + currentFormFields, SAP_TYPE, channelMap, errHandler, dispatch); + }; + + const activatePage = () => ConfigActivatePage(SAP_TYPE); + + const steps: FormWorkflowStep[] = [ + { + index: 0, + formComponent: SAPConfigEnablePage, + validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), + stepName: "Enable", + saveChanges, + nextButtonConfig: () => { + let config = { + buttonText: "Enable", + opensNewWindow: false, + onClick: handleSubmit, + }; + return config as FormWorkflowButtonConfig; + }, + }, + { + index: 1, + formComponent: activatePage, + validations: [], + stepName: "Activate", + saveChanges, + nextButtonConfig: () => ({ + buttonText: "Activate", + opensNewWindow: false, + onClick: () => { + onClickCancel(true, SUBMIT_TOAST_MESSAGE); + return Promise.resolve(existingData); + }, + }), + }, + ]; + + // Go to authorize step for now + const getCurrentStep = () => steps[0]; + + return { + getCurrentStep, + steps, + }; +}; + +export default SAPFormConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx new file mode 100644 index 0000000000..50f9f9eca4 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx @@ -0,0 +1,169 @@ +import React from "react"; + +import { Container, Form, Image } from "@edx/paragon"; + +// @ts-ignore +import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; +// @ts-ignore +import ValidatedFormRadio from "../../../../forms/ValidatedFormRadio.tsx"; +import { channelMapping, urlValidation } from "../../../../../utils"; +import { INVALID_LINK, INVALID_NAME, SAP_TYPE } from "../../../data/constants"; +import type { + FormFieldValidation, +} from "../../../../forms/FormContext"; + +export const formFieldNames = { + DISPLAY_NAME: "displayName", + SAP_BASE_URL: "sapBaseUrl", + SAP_COMPANY_ID: "sapCompanyId", + SAP_USER_ID: "sapUserId", + OAUTH_CLIENT_ID: "oauthClientId", + OAUTH_CLIENT_SECRET: "oauthClientSecret", + SAP_USER_TYPE: "sapUserType", +}; + +export const validations: FormFieldValidation[] = [ + { + formFieldId: formFieldNames.SAP_BASE_URL, + validator: (fields) => { + const sapUrl = fields[formFieldNames.SAP_BASE_URL]; + if (sapUrl) { + const error = !urlValidation(sapUrl); + return error ? INVALID_LINK : false; + } else { + return true; + } + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const displayName = fields[formFieldNames.DISPLAY_NAME]; + return !displayName; + }, + }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const displayName = fields[formFieldNames.DISPLAY_NAME]; + const error = displayName?.length > 20; + return error && INVALID_NAME; + }, + }, + { + formFieldId: formFieldNames.SAP_COMPANY_ID, + validator: (fields) => { + const sapCompanyId = fields[formFieldNames.SAP_COMPANY_ID]; + return !sapCompanyId; + }, + }, + { + formFieldId: formFieldNames.SAP_USER_ID, + validator: (fields) => { + const sapUserId = fields[formFieldNames.SAP_USER_ID]; + return !sapUserId; + }, + }, + { + formFieldId: formFieldNames.OAUTH_CLIENT_ID, + validator: (fields) => { + const oauthClientId = fields[formFieldNames.OAUTH_CLIENT_ID]; + return !oauthClientId; + }, + }, + { + formFieldId: formFieldNames.OAUTH_CLIENT_SECRET, + validator: (fields) => { + const secret = fields[formFieldNames.OAUTH_CLIENT_SECRET]; + return !secret; + }, + }, + { + formFieldId: formFieldNames.SAP_USER_TYPE, + validator: (fields) => { + const sapUserType = fields[formFieldNames.SAP_USER_TYPE]; + return !sapUserType; + }, + }, +]; + +const SAPConfigEnablePage = () => { + return ( + + + +

+ Enable connection to SAP Success Factors +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default SAPConfigEnablePage; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAPConfig.jsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAPConfig.jsx deleted file mode 100644 index 7ea3ba18ba..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/SAPConfig.jsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import isEmpty from 'lodash/isEmpty'; -import PropTypes from 'prop-types'; -import { Button, Form, useToggle } from '@edx/paragon'; -import buttonBool, { isExistingConfig } from '../utils'; -import handleErrors from '../../utils'; -import LmsApiService from '../../../../data/services/LmsApiService'; -import { snakeCaseDict, urlValidation } from '../../../../utils'; -import ConfigError from '../../ConfigError'; -import ConfigModal from '../ConfigModal'; -import { INVALID_LINK, INVALID_NAME, SUBMIT_TOAST_MESSAGE } from '../../data/constants'; - -const SAPConfig = ({ - enterpriseCustomerUuid, onClick, existingData, existingConfigs, -}) => { - const [displayName, setDisplayName] = React.useState(''); - const [nameValid, setNameValid] = React.useState(true); - const [sapsfBaseUrl, setSapsfBaseUrl] = React.useState(''); - const [urlValid, setUrlValid] = React.useState(true); - const [sapsfCompanyId, setSapsfCompanyId] = React.useState(''); - const [sapsfUserId, setSapsfUserId] = React.useState(''); - const [key, setKey] = React.useState(''); - const [secret, setSecret] = React.useState(''); - const [userType, setUserType] = React.useState('user'); - const [errorIsOpen, openError, closeError] = useToggle(false); - const [modalIsOpen, openModal, closeModal] = useToggle(false); - const [edited, setEdited] = React.useState(false); - - const config = { - displayName, - sapsfBaseUrl, - sapsfCompanyId, - sapsfUserId, - key, - secret, - userType, - }; - - useEffect(() => { - setDisplayName(existingData.displayName); - setSapsfBaseUrl(existingData.sapsfBaseUrl); - setSapsfCompanyId(existingData.sapsfCompanyId); - setSapsfUserId(existingData.sapsfUserId); - setKey(existingData.key); - setSecret(existingData.secret); - setUserType(existingData.userType === 'user' ? 'user' : 'admin'); - }, [existingData]); - - const handleCancel = () => { - if (edited) { - openModal(); - } else { - onClick(''); - } - }; - - const handleSubmit = async (event) => { - event.preventDefault(); - const transformedConfig = snakeCaseDict(config); - transformedConfig.enterprise_customer = enterpriseCustomerUuid; - let err; - - if (!isEmpty(existingData)) { - try { - transformedConfig.active = existingData.active; - await LmsApiService.updateSuccessFactorsConfig(transformedConfig, existingData.id); - } catch (error) { - err = handleErrors(error); - } - } else { - try { - transformedConfig.active = false; - await LmsApiService.postNewSuccessFactorsConfig(transformedConfig); - } catch (error) { - err = handleErrors(error); - } - } - if (err) { - openError(); - } else { - onClick(SUBMIT_TOAST_MESSAGE); - } - }; - - const validateField = useCallback((field, input) => { - switch (field) { - case 'SAP Base URL': - setSapsfBaseUrl(input); - setUrlValid(urlValidation(input) || input?.length === 0); - break; - case 'Display Name': - setDisplayName(input); - if (isExistingConfig(existingConfigs, input, existingData.displayName)) { - setNameValid(input?.length <= 20); - } else { - setNameValid(input?.length <= 20 && !Object.values(existingConfigs).includes(input)); - } - break; - default: - break; - } - }, [existingConfigs, existingData.displayName]); - - useEffect(() => { - if (!isEmpty(existingData)) { - validateField('SAP Base URL', existingData.sapsfBaseUrl); - validateField('Display Name', existingData.displayName); - } - }, [existingConfigs, existingData, validateField]); - - return ( - - - -
- - { - setEdited(true); - validateField('Display Name', e.target.value); - }} - floatingLabel="Display Name" - defaultValue={existingData.displayName} - /> - Create a custom name for this LMS. - {!nameValid && ( - - {INVALID_NAME} - - )} - - - { - setEdited(true); - validateField('SAP Base URL', e.target.value); - }} - floatingLabel="SAP Base URL" - defaultValue={existingData.sapsfBaseUrl} - /> - {!urlValid && ( - - {INVALID_LINK} - - )} - - - { - setEdited(true); - setSapsfCompanyId(e.target.value); - }} - floatingLabel="SAP Company ID" - defaultValue={existingData.sapsfCompanyId} - /> - - - { - setEdited(true); - setSapsfUserId(e.target.value); - }} - floatingLabel="SAP User ID" - defaultValue={existingData.sapsfUserId} - /> - - - { - setEdited(true); - setKey(e.target.value); - }} - floatingLabel="OAuth Client ID" - defaultValue={existingData.key} - /> - - - { - setEdited(true); - setSecret(e.target.value); - }} - floatingLabel="OAuth Client Secret" - defaultValue={existingData.secret} - /> - - - SAP User Type - { - setEdited(true); - setUserType(e.target.value); - }} - defaultValue={existingData.userType === 'user' ? 'user' : 'admin'} - isInline - > - User - Admin - - - - - - -
-
- ); -}; - -SAPConfig.propTypes = { - enterpriseCustomerUuid: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - existingData: PropTypes.shape({ - active: PropTypes.bool, - displayName: PropTypes.string, - id: PropTypes.number, - sapsfBaseUrl: PropTypes.string, - sapsfCompanyId: PropTypes.string, - sapsfUserId: PropTypes.string, - key: PropTypes.string, - secret: PropTypes.string, - userType: PropTypes.string, - }).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string).isRequired, -}; -export default SAPConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx new file mode 100644 index 0000000000..7409426950 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx @@ -0,0 +1,185 @@ +import type { FormFieldValidation } from "../../../forms/FormContext"; +import { BLACKBOARD_OAUTH_REDIRECT_URL, BLACKBOARD_TYPE, CANVAS_OAUTH_REDIRECT_URL, CANVAS_TYPE, INVALID_NAME } from "../../data/constants"; +import handleErrors from "../../utils"; +import { camelCaseDict } from "../../../../utils"; +// @ts-ignore +import { setWorkflowStateAction, updateFormFieldsAction } from "../../../forms/data/actions.ts"; +import { CanvasConfigCamelCase, CanvasConfigSnakeCase } from "./Canvas/CanvasConfig"; +import { BlackboardConfigCamelCase, BlackboardConfigSnakeCase } from "./Blackboard/BlackboardConfig"; +import { CornerstoneConfigCamelCase, CornerstoneConfigSnakeCase } from "./Cornerstone/CornerstoneConfig"; +import { DegreedConfigCamelCase, DegreedConfigSnakeCase } from "./Degreed/DegreedConfig"; +import { MoodleConfigCamelCase, MoodleConfigSnakeCase } from "./Moodle/MoodleConfig"; +import { SAPConfigCamelCase, SAPConfigSnakeCase } from "./SAP/SAPConfig"; +// @ts-ignore +import { FormWorkflowErrorHandler, WAITING_FOR_ASYNC_OPERATION } from "../../../forms/FormWorkflow.tsx"; + +type ConfigCamelCase = {id?: string, active?: boolean} | + BlackboardConfigCamelCase | CanvasConfigCamelCase | CornerstoneConfigCamelCase | DegreedConfigCamelCase | MoodleConfigCamelCase | SAPConfigCamelCase; +type ConfigSnakeCase = {enterprise_customer?: string, active?: boolean} | + BlackboardConfigSnakeCase | CanvasConfigSnakeCase | CornerstoneConfigSnakeCase | DegreedConfigSnakeCase | MoodleConfigSnakeCase | SAPConfigSnakeCase; + +export const LMS_AUTHORIZATION_FAILED = "LMS AUTHORIZATION FAILED"; + +export async function handleSubmitHelper( + enterpriseCustomerUuid: string, + transformedConfig: ConfigSnakeCase, + existingData: ConfigCamelCase, + onSubmit: (param: ConfigCamelCase) => void, + formFieldsChanged: Boolean, + currentFormFields: any, + lmsType: string, + channelMap: Record>, + errHandler: FormWorkflowErrorHandler | undefined, + dispatch: any, +) { + transformedConfig.enterprise_customer = enterpriseCustomerUuid; + let err = ""; + if (formFieldsChanged) { + if (currentFormFields?.id) { + try { + transformedConfig.active = existingData.active; + const response = await channelMap[lmsType].update(transformedConfig, existingData.id) + currentFormFields = camelCaseDict( + response.data + ) as ConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + const response = await channelMap[lmsType].post(transformedConfig) + currentFormFields = camelCaseDict( + response.data + ) as ConfigCamelCase; + onSubmit(currentFormFields); + dispatch?.(updateFormFieldsAction({ formFields: currentFormFields })); + } catch (error) { + err = handleErrors(error); + } + } + } + const authorizeError = await handleSubmitAuthorize(lmsType, existingData, currentFormFields, channelMap, dispatch); + if (err) { errHandler?.(err); } + if (authorizeError) { errHandler?.(authorizeError); } + + return currentFormFields; +} + +async function handleSubmitAuthorize( + lmsType: string, + existingData: any, + currentFormFields: any, + channelMap: Record>, + dispatch: any, +) { + if ((lmsType === BLACKBOARD_TYPE || lmsType === CANVAS_TYPE) && currentFormFields && !currentFormFields?.refreshToken) { + let oauthUrl: string; + if (lmsType == BLACKBOARD_TYPE) { + let appKey = existingData.clientId; + let configUuid = existingData.uuid; + if (!appKey || !configUuid) { + try { + if (lmsType === BLACKBOARD_TYPE) { + const response = await channelMap[lmsType].fetchGlobal(); + appKey = response.data.results.at(-1).app_key; + configUuid = response.data.uuid; + } + } catch (error) { + return handleErrors(error); + } + } + oauthUrl = `${currentFormFields.blackboardBaseUrl}/learn/api/public/v1/oauth2/authorizationcode?` + + `redirect_uri=${BLACKBOARD_OAUTH_REDIRECT_URL}&scope=read%20write%20delete%20offline&` + + `response_type=code&client_id=${appKey}&state=${configUuid}`; + } + else { + oauthUrl = + `${currentFormFields.canvasBaseUrl}/login/oauth2/auth?client_id=${currentFormFields.clientId}&` + + `state=${currentFormFields.uuid}&response_type=code&` + + `redirect_uri=${CANVAS_OAUTH_REDIRECT_URL}`; + } + // Open the oauth window for the user + window.open(oauthUrl); + dispatch?.(setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, true)); + } + return null; +} + +export async function afterSubmitHelper( + lmsType: string, + formFields: any, + channelMap: Record>, + errHandler: FormWorkflowErrorHandler | undefined, + dispatch: any) { + if (formFields?.id) { + let err = ""; + try { + const response = await channelMap[lmsType].fetch(formFields.id); + if (response.data.refresh_token) { + dispatch?.( + setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false) + ); + return true; + } + } catch (error) { + err = handleErrors(error); + } + if (err) { + errHandler?.(err); + return false; + } + } + return false; +} + +export async function onTimeoutHelper(dispatch: any) { + dispatch?.(setWorkflowStateAction(WAITING_FOR_ASYNC_OPERATION, false)); + dispatch?.(setWorkflowStateAction(LMS_AUTHORIZATION_FAILED, true)); +} + +export async function handleSaveHelper( + transformedConfig: ConfigSnakeCase, + existingData: ConfigCamelCase, + formFields: ConfigCamelCase, + onSubmit: (param: ConfigCamelCase) => void, + lmsType: string, + channelMap: Record>, + errHandler: (errMsg: string) => void) { + let err = ""; + if (formFields.id) { + try { + transformedConfig.active = existingData.active; + await channelMap[lmsType].update(transformedConfig, existingData.id); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } else { + try { + transformedConfig.active = false; + await channelMap[lmsType].post(transformedConfig); + onSubmit(formFields); + } catch (error) { + err = handleErrors(error); + } + } + if (err) { + errHandler(err); + } + return !err; +} + +export function checkForDuplicateNames( + existingConfigNames: string[], existingData: {displayName: string}): FormFieldValidation { + return { + formFieldId: 'displayName', + validator: () => { + return existingConfigNames?.includes(existingData.displayName) + ? INVALID_NAME + : false; + }, + }; +} diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index 74ee932796..e41629ab2e 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -91,7 +91,6 @@ const SettingsLMSTab = ({ }); }, [enterpriseId]); - // TODO: Rewrite with more descriptive parameters once all lms configs are refactored const onClick = (input) => { // Either we're creating a new config (a create config card was clicked), or we're navigating // back to the landing state from a form (submit or cancel was hit on the forms). In both cases, diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx index 0dd94c6780..c022cbd330 100644 --- a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx @@ -15,7 +15,6 @@ import { INVALID_LINK, INVALID_NAME, } from "../../data/constants"; -import LmsApiService from "../../../../data/services/LmsApiService"; // @ts-ignore import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; import { findElementWithText } from "../../../test/testUtils"; @@ -25,22 +24,6 @@ jest.mock("../../data/constants", () => ({ LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, })); window.open = jest.fn(); -const mockUpdateConfigApi = jest.spyOn(LmsApiService, "updateBlackboardConfig"); -const mockConfigResponseData = { - uuid: 'foobar', - id: 1, - display_name: 'display name', - blackboard_base_url: 'https://foobar.com', - active: false, -}; -mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockPostConfigApi = jest.spyOn(LmsApiService, 'postNewBlackboardConfig'); -mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); -mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: 'foobar' } }); - const enterpriseId = 'test-enterprise-id'; const mockOnClick = jest.fn(); // Freshly creating a config will have an empty existing data object @@ -66,11 +49,24 @@ const existingConfigDataNoAuth = { const noConfigs = []; -afterEach(() => { - jest.clearAllMocks(); -}); +const mockConfigResponseData = { + uuid: 'foobar', + id: 1, + display_name: 'display name', + blackboard_base_url: 'https://foobar.com', + active: false, +}; const mockSetExistingConfigFormData = jest.fn(); +const mockPost = jest.fn(); +const mockUpdate = jest.fn(); +const mockFetch = jest.fn(); +const mockFetchGlobal = jest.fn(); +mockPost.mockResolvedValue({ data: mockConfigResponseData }); +mockUpdate.mockResolvedValue({ data: mockConfigResponseData }); +mockFetch.mockResolvedValue({ data: { refresh_token: 'foobar' } }); +mockFetchGlobal.mockReturnValue({ data: { results: [{ app_key: 1 }] } }) + function testBlackboardConfigSetup(formData) { return ( @@ -80,11 +76,21 @@ function testBlackboardConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, onClickCancel: mockOnClick, existingData: formData, + existingConfigNames: [], + channelMap: { + BLACKBOARD: { + post: mockPost, + update: mockUpdate, + fetch: mockFetch, + fetchGlobal: mockFetchGlobal, + }, + } })} onClickOut={mockOnClick} onSubmit={mockSetExistingConfigFormData} formData={formData} isStepperOpen={true} + dispatch={jest.fn()} /> ); } @@ -102,6 +108,9 @@ async function clearForm() { describe("", () => { + afterEach(() => { + jest.clearAllMocks(); + }); test("renders Blackboard Authorize Form", () => { render(testBlackboardConfigSetup(noConfigs)); screen.getByLabelText("Display Name"); @@ -158,7 +167,7 @@ describe("", () => { display_name: 'displayName', enterprise_customer: enterpriseId, }; - expect(LmsApiService.updateBlackboardConfig).toHaveBeenCalledWith(expectedConfig, 1); + expect(mockUpdate).toHaveBeenCalledWith(expectedConfig, 1); }); test('it creates new configs on submit', async () => { render(testBlackboardConfigSetup(noExistingData)); @@ -181,7 +190,7 @@ describe("", () => { display_name: 'displayName', enterprise_customer: enterpriseId, }; - expect(LmsApiService.postNewBlackboardConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); test('saves draft correctly', async () => { render(testBlackboardConfigSetup(noExistingData)); @@ -205,7 +214,7 @@ describe("", () => { enterprise_customer: enterpriseId, blackboard_base_url: 'https://www.test4.com', }; - expect(LmsApiService.postNewBlackboardConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); test('Authorizing a config will initiate backend polling', async () => { render(testBlackboardConfigSetup(noExistingData)); @@ -220,7 +229,7 @@ describe("", () => { // await a change in button text from authorize to activate await waitFor(() => expect(authorizeButton).toBeDisabled()) expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + expect(mockFetch).toHaveBeenCalledWith(1); }); test('Authorizing an existing, edited config will call update config endpoint', async () => { render(testBlackboardConfigSetup(existingConfigDataNoAuth)); @@ -242,9 +251,9 @@ describe("", () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.getByText('Authorization in progress')).toBeInTheDocument()); - expect(mockUpdateConfigApi).toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalled(); expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + expect(mockFetch).toHaveBeenCalledWith(1); await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); }); test('Authorizing an existing config will not call update or create config endpoint', async () => { @@ -257,9 +266,9 @@ describe("", () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - expect(mockUpdateConfigApi).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + expect(mockFetch).toHaveBeenCalledWith(1); }); test('validates poorly formatted existing data on load', async () => { render(testBlackboardConfigSetup(invalidExistingData)); diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx index c0ae490bc3..5e8e11f57c 100644 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx @@ -15,38 +15,14 @@ import { INVALID_LINK, INVALID_NAME, } from "../../data/constants"; -import LmsApiService from "../../../../data/services/LmsApiService"; // @ts-ignore import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; -import { findElementWithText } from "../../../test/testUtils"; jest.mock("../../data/constants", () => ({ ...jest.requireActual("../../data/constants"), LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, })); window.open = jest.fn(); -const mockUpdateConfigApi = jest.spyOn(LmsApiService, "updateCanvasConfig"); -const mockConfigResponseData = { - uuid: "foobar", - id: 1, - display_name: "display name", - canvas_base_url: "https://foobar.com", - canvas_account_id: 1, - client_id: "123abc", - client_secret: "asdhfahsdf", - active: false, -}; -mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockPostConfigApi = jest.spyOn(LmsApiService, "postNewCanvasConfig"); -mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); - -const mockFetchSingleConfig = jest.spyOn( - LmsApiService, - "fetchSingleCanvasConfig" -); -mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: "foobar" } }); - const enterpriseId = "test-enterprise-id"; const mockOnClick = jest.fn(); @@ -74,13 +50,28 @@ const existingConfigDataNoAuth = { canvasAccountId: 10, }; -const noConfigs = []; -afterEach(() => { - jest.clearAllMocks(); -}); +const mockConfigResponseData = { + uuid: 'foobar', + id: 1, + canvas_account_id: 1, + display_name: 'display name', + canvas_base_url: 'https://foobar.com', + client_id: "wassap", + client_secret: "chewlikeyouhaveasecret", + active: false, +}; + +const noConfigs = []; const mockSetExistingConfigFormData = jest.fn(); +const mockPost = jest.fn(); +const mockUpdate = jest.fn(); +const mockFetch = jest.fn(); +mockPost.mockResolvedValue({ data: mockConfigResponseData }); +mockUpdate.mockResolvedValue({ data: mockConfigResponseData }); +mockFetch.mockResolvedValue({ data: { refresh_token: 'foobar' } }); + function testCanvasConfigSetup(formData) { return ( @@ -90,11 +81,20 @@ function testCanvasConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, onClickCancel: mockOnClick, existingData: formData, + existingConfigNames: [], + channelMap: { + CANVAS: { + post: mockPost, + update: mockUpdate, + fetch: mockFetch, + }, + } })} onClickOut={mockOnClick} onSubmit={mockSetExistingConfigFormData} formData={formData} isStepperOpen={true} + dispatch={jest.fn()} /> ); } @@ -121,9 +121,11 @@ async function clearForm() { describe("", () => { + afterEach(() => { + jest.clearAllMocks(); + }); test("renders Canvas Authorize Form", () => { render(testCanvasConfigSetup(noConfigs)); - screen.getByLabelText("Display Name"); screen.getByLabelText("API Client ID"); screen.getByLabelText("API Client Secret"); @@ -191,7 +193,7 @@ describe("", () => { display_name: 'displayName', enterprise_customer: enterpriseId, }; - expect(LmsApiService.updateCanvasConfig).toHaveBeenCalledWith(expectedConfig, 1); + expect(mockUpdate).toHaveBeenCalledWith(expectedConfig, 1); }); test('it creates new configs on submit', async () => { render(testCanvasConfigSetup(noExistingData)); @@ -220,7 +222,7 @@ describe("", () => { display_name: 'displayName', enterprise_customer: enterpriseId, }; - expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); test('saves draft correctly', async () => { render(testCanvasConfigSetup(noExistingData)); @@ -250,7 +252,7 @@ describe("", () => { client_id: 'test1', client_secret: 'test2', }; - expect(LmsApiService.postNewCanvasConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); test('Authorizing a config will initiate backend polling', async () => { render(testCanvasConfigSetup(noExistingData)); @@ -270,7 +272,7 @@ describe("", () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument()); expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + expect(mockFetch).toHaveBeenCalledWith(1); }); test('Authorizing an existing, edited config will call update config endpoint', async () => { render(testCanvasConfigSetup(existingConfigDataNoAuth)); @@ -292,9 +294,9 @@ describe("", () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.getByText('Authorization in progress')).toBeInTheDocument()); - expect(mockUpdateConfigApi).toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalled(); expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + expect(mockFetch).toHaveBeenCalledWith(1); await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); }); test('Authorizing an existing config will not call update or create config endpoint', async () => { @@ -307,9 +309,9 @@ describe("", () => { // Await a find by text in order to account for state changes in the button callback await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - expect(mockUpdateConfigApi).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); expect(window.open).toHaveBeenCalled(); - expect(mockFetchSingleConfig).toHaveBeenCalledWith(1); + expect(mockFetch).toHaveBeenCalledWith(1); }); test('validates poorly formatted existing data on load', async () => { render(testCanvasConfigSetup(invalidExistingData)); @@ -345,8 +347,8 @@ describe("", () => { displayName: 'display name', canvasBaseUrl: 'https://foobar.com', canvasAccountId: 1, - clientId: '123abc', - clientSecret: 'asdhfahsdf', + clientId: 'wassap', + clientSecret: 'chewlikeyouhaveasecret', active: false, }); }); diff --git a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx index 0e89348e90..90cafdd3af 100644 --- a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx @@ -1,178 +1,158 @@ import React from 'react'; import { - render, fireEvent, screen, + act, render, fireEvent, screen, waitFor, } from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; import userEvent from '@testing-library/user-event'; -import CornerstoneConfig from '../LMSConfigs/CornerstoneConfig'; +import '@testing-library/jest-dom/extend-expect'; import { INVALID_LINK, INVALID_NAME } from '../../data/constants'; -import LmsApiService from '../../../../data/services/LmsApiService'; - -jest.mock('../../../../data/services/LmsApiService'); +// @ts-ignore +import CornerstoneConfig from '../LMSConfigs/Cornerstone/CornerstoneConfig.tsx'; +// @ts-ignore +import FormContextWrapper from '../../../forms/FormContextWrapper.tsx'; const enterpriseId = 'test-enterprise-id'; - const mockOnClick = jest.fn(); -const noConfigs = []; -const existingConfigDisplayNames = ['name']; -const existingConfigDisplayNamesInvalid = ['fooooooooobaaaaaaaaar']; - +// Freshly creating a config will have an empty existing data object const noExistingData = {}; -// Existing invalid data that will be validated on load -const invalidExistingData = { - displayName: 'fooooooooobaaaaaaaaar', - cornerstoneBaseUrl: 'bad_url :^(', -}; + const existingConfigData = { id: 1, - cornerstoneBaseUrl: 'https://foobar.com', - displayName: 'test display name', + displayName: 'foobar', + cornerstoneBaseUrl: 'https://example.com', +}; + +// Existing invalid data that will be validated on load +const invalidExistingData = { + displayName: 'john jacob jinglehiemer schmidt', + cornerstoneBaseUrl: 'its 2023 you know what a link looks like', }; +const noConfigs = []; + afterEach(() => { jest.clearAllMocks(); }); -describe('', () => { - test('renders Cornerstone Config Form', () => { - render( - , - ); - screen.getByLabelText('Display Name'); - screen.getByLabelText('Cornerstone Base URL'); - }); - test('test button disable', () => { - render( - , - ); - expect(screen.getByText('Submit')).toBeDisabled(); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'reallyreallyreallyreallyreallylongname' }, - }); - // bad url not able to be submitted - fireEvent.change(screen.getByLabelText('Cornerstone Base URL'), { - target: { value: 'test1' }, - }); - expect(screen.getByText('Submit')).toBeDisabled(); - expect(screen.queryByText(INVALID_LINK)); - expect(screen.queryByText(INVALID_NAME)); - // duplicate display name not able to be submitted - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'name' }, - }); - expect(screen.queryByText(INVALID_NAME)); +const mockSetExistingConfigFormData = jest.fn(); +const mockPost = jest.fn(); +const mockUpdate = jest.fn(); +const mockDelete = jest.fn(); +function testCornerstoneConfigSetup(formData) { + return ( + + ); +} + +async function clearForm() { + await act(async () => { fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'test1' }, + target: { value: '' }, }); fireEvent.change(screen.getByLabelText('Cornerstone Base URL'), { - target: { value: 'https://www.test1.com' }, + target: { value: '' }, }); - expect(screen.getByText('Submit')).not.toBeDisabled(); }); - test('it edits existing configs on submit', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - fireEvent.change(screen.getByLabelText('Cornerstone Base URL'), { - target: { value: 'https://www.test1.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); +} - const expectedConfig = { - cornerstone_base_url: 'https://www.test1.com', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.updateCornerstoneConfig).toHaveBeenCalledWith(expectedConfig, 1); +describe('', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + test('renders Cornerstone Enable Form', () => { + render(testCornerstoneConfigSetup(noConfigs)); + screen.getByLabelText('Display Name'); + screen.getByLabelText('Cornerstone Base URL'); }); - test('it creates new configs on submit', () => { - render( - , + test('test button disable', async () => { + render(testCornerstoneConfigSetup(noExistingData)); + + const enableButton = screen.getByRole('button', { name: 'Enable' }); + await clearForm(); + expect(enableButton).toBeDisabled(); + + userEvent.type(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); + userEvent.type(screen.getByLabelText('Cornerstone Base URL'), 'badlink'); + expect(enableButton).toBeDisabled(); + expect(screen.queryByText(INVALID_LINK)); + expect(screen.queryByText(INVALID_NAME)); + + await clearForm(); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type( + screen.getByLabelText('Cornerstone Base URL'), + 'https://www.test4.com', ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - fireEvent.change(screen.getByLabelText('Cornerstone Base URL'), { - target: { value: 'https://www.test1.com' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); + expect(enableButton).not.toBeDisabled(); + }); + test('it creates new configs on submit', async () => { + render(testCornerstoneConfigSetup(noExistingData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); + + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Cornerstone Base URL'), 'https://www.test.com'); + await waitFor(() => expect(enableButton).not.toBeDisabled()); + userEvent.click(enableButton); const expectedConfig = { active: false, - cornerstone_base_url: 'https://www.test1.com', display_name: 'displayName', + cornerstone_base_url: 'https://www.test.com', enterprise_customer: enterpriseId, }; - expect(LmsApiService.postNewCornerstoneConfig).toHaveBeenCalledWith(expectedConfig); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith(expectedConfig)); }); - test('saves draft correctly', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - userEvent.click(screen.getByText('Cancel')); - userEvent.click(screen.getByText('Save')); + test('saves draft correctly', async () => { + render(testCornerstoneConfigSetup(noExistingData)); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Cornerstone Base URL'), 'https://www.test.com'); + expect(cancelButton).not.toBeDisabled(); + userEvent.click(cancelButton); + + await waitFor(() => expect(screen.getByText('Exit configuration')).toBeInTheDocument()); + const closeButton = screen.getByRole('button', { name: 'Exit' }); + + userEvent.click(closeButton); const expectedConfig = { active: false, display_name: 'displayName', + cornerstone_base_url: 'https://www.test.com', enterprise_customer: enterpriseId, }; - expect(LmsApiService.postNewCornerstoneConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); - test('validates poorly formatted existing data on load', () => { - render( - , - ); - expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - expect(screen.getByText(INVALID_NAME)).toBeInTheDocument(); + test('validates poorly formatted existing data on load', async () => { + render(testCornerstoneConfigSetup(invalidExistingData)); + expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); + expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); }); test('validates properly formatted existing data on load', () => { - render( - , - ); + render(testCornerstoneConfigSetup(existingConfigData)); expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx index 22458fc39a..61f7fe3d06 100644 --- a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx @@ -1,29 +1,16 @@ import React from "react"; import { - act, - render, - fireEvent, - screen, - waitFor, + act, render, fireEvent, screen, waitFor, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/extend-expect"; - // @ts-ignore import DegreedConfig from "../LMSConfigs/Degreed/DegreedConfig.tsx"; -import { - INVALID_LINK, - INVALID_NAME, -} from "../../data/constants"; +import { INVALID_LINK, INVALID_NAME } from "../../data/constants"; import LmsApiService from "../../../../data/services/LmsApiService"; // @ts-ignore import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; -jest.mock("../../data/constants", () => ({ - ...jest.requireActual("../../data/constants"), - LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, -})); -window.open = jest.fn(); const mockUpdateConfigApi = jest.spyOn(LmsApiService, "updateDegreedConfig"); const mockConfigResponseData = { uuid: 'foobar', @@ -71,6 +58,9 @@ afterEach(() => { }); const mockSetExistingConfigFormData = jest.fn(); +const mockPost = jest.fn(); +const mockUpdate = jest.fn(); +const mockDelete = jest.fn(); function testDegreedConfigSetup(formData) { return ( @@ -80,15 +70,23 @@ function testDegreedConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, onClickCancel: mockOnClick, existingData: formData, + existingConfigNames: [], + channelMap: { + DEGREED2: { + post: mockPost, + update: mockUpdate, + delete: mockDelete, + }, + }, })} onClickOut={mockOnClick} onSubmit={mockSetExistingConfigFormData} formData={formData} - isStepperOpen={true} + isStepperOpen + dispatch={jest.fn()} /> ); } - async function clearForm() { await act(async () => { fireEvent.change(screen.getByLabelText('Display Name'), { @@ -109,8 +107,10 @@ async function clearForm() { }); } - describe("", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); test("renders Degreed Enable Form", () => { render(testDegreedConfigSetup(noConfigs)); screen.getByLabelText("Display Name"); @@ -150,7 +150,6 @@ describe("", () => { test('it creates new configs on submit', async () => { render(testDegreedConfigSetup(noExistingData)); const enableButton = screen.getByRole('button', { name: 'Enable' }); - await clearForm(); userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); @@ -172,7 +171,7 @@ describe("", () => { degreed_fetch_url: 'https://www.test.com', enterprise_customer: enterpriseId, }; - await waitFor(() => expect(LmsApiService.postNewDegreedConfig).toHaveBeenCalledWith(expectedConfig)); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith(expectedConfig)); }); test('saves draft correctly', async () => { render(testDegreedConfigSetup(noExistingData)); @@ -193,7 +192,6 @@ describe("", () => { const closeButton = screen.getByRole('button', { name: 'Exit' }); userEvent.click(closeButton); - const expectedConfig = { active: false, display_name: 'displayName', @@ -203,11 +201,10 @@ describe("", () => { degreed_fetch_url: 'https://www.test.com', enterprise_customer: enterpriseId, }; - expect(LmsApiService.postNewDegreedConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); test('validates poorly formatted existing data on load', async () => { render(testDegreedConfigSetup(invalidExistingData)); - screen.debug(); expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index a061195460..64b7bf392c 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -157,17 +157,17 @@ describe('', () => { expect(screen.findByText(channelMapping[CORNERSTONE_TYPE].displayName)); }); const cornerstoneCard = screen.getByText(channelMapping[CORNERSTONE_TYPE].displayName); - userEvent.click(cornerstoneCard); - expect(screen.queryByText('Connect Cornerstone')).toBeTruthy(); + fireEvent.click(cornerstoneCard); + expect(screen.queryByText('Enable connection to Cornerstone')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); const cancelButton = screen.getByText('Cancel'); userEvent.click(cancelButton); - expect(await screen.findByText('Do you want to save your work?')).toBeTruthy(); + expect(await screen.findByText('Exit configuration')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); - expect(screen.queryByText('Connect Cornerstone')).toBeFalsy(); + expect(screen.queryByText('Enable connection to Cornerstone')).toBeFalsy(); }); test('Degreed card cancel flow', async () => { renderWithRouter(); @@ -190,7 +190,7 @@ describe('', () => { userEvent.click(exitButton); expect(screen.queryByText('Enable connection to Degreed')).toBeFalsy(); }); - test('Degreed card cancel flow', async () => { + test('Moodle card cancel flow', async () => { renderWithRouter(); const skeleton = screen.getAllByTestId('skeleton'); await waitForElementToBeRemoved(skeleton); @@ -219,18 +219,18 @@ describe('', () => { userEvent.click(screen.getByText('New learning platform integration')); expect(screen.findByText(channelMapping[SAP_TYPE].displayName)); }); - const sapCard = screen.getByText(channelMapping[SAP_TYPE].displayName); - userEvent.click(sapCard); - expect(screen.queryByText('Connect SAP Success Factors')).toBeTruthy(); + const moodleCard = screen.getByText(channelMapping[SAP_TYPE].displayName); + fireEvent.click(moodleCard); + expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeTruthy(); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); const cancelButton = screen.getByText('Cancel'); userEvent.click(cancelButton); - expect(await screen.findByText('Do you want to save your work?')).toBeTruthy(); + expect(await screen.findByText('Exit configuration')).toBeTruthy(); const exitButton = screen.getByText('Exit without saving'); userEvent.click(exitButton); - expect(screen.queryByText('Connect SAP Success Factors')).toBeFalsy(); + expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeFalsy(); }); test('No action Moodle card cancel flow', async () => { renderWithRouter(); @@ -273,12 +273,12 @@ describe('', () => { expect(screen.findByText(channelMapping[CORNERSTONE_TYPE].displayName)); }); const cornerstoneCard = screen.getByText(channelMapping[CORNERSTONE_TYPE].displayName); - await waitFor(() => userEvent.click(cornerstoneCard)); - expect(screen.queryByText('Connect Cornerstone')).toBeTruthy(); + await waitFor(() => fireEvent.click(cornerstoneCard)); + expect(screen.queryByText('Enable connection to Cornerstone')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect Cornerstone')).toBeFalsy(); + expect(screen.queryByText('Enable connection to Cornerstone')).toBeFalsy(); }); test('No action Canvas card cancel flow', async () => { renderWithRouter(); @@ -321,12 +321,12 @@ describe('', () => { expect(screen.findByText(channelMapping[SAP_TYPE].displayName)); }); const sapCard = screen.getByText(channelMapping[SAP_TYPE].displayName); - userEvent.click(sapCard); - expect(screen.queryByText('Connect SAP Success Factors')).toBeTruthy(); + await waitFor(() => fireEvent.click(sapCard)); + expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeTruthy(); const cancelButton = screen.getByText('Cancel'); - userEvent.click(cancelButton); + await waitFor(() => userEvent.click(cancelButton)); expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Connect SAP Success Factors')).toBeFalsy(); + expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeFalsy(); }); test('Expected behavior when customer has no IDP configured', async () => { const history = createMemoryHistory(); diff --git a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx index 0832250385..168fa51480 100644 --- a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx @@ -20,11 +20,6 @@ import LmsApiService from '../../../../data/services/LmsApiService'; // @ts-ignore import FormContextWrapper from '../../../forms/FormContextWrapper.tsx'; -jest.mock('../../data/constants', () => ({ - ...jest.requireActual('../../data/constants'), - LMS_CONFIG_OAUTH_POLLING_INTERVAL: 0, -})); -window.open = jest.fn(); const mockUpdateConfigApi = jest.spyOn(LmsApiService, 'updateMoodleConfig'); const mockConfigResponseData = { uuid: 'foobar', @@ -71,6 +66,9 @@ afterEach(() => { }); const mockSetExistingConfigFormData = jest.fn(); +const mockPost = jest.fn(); +const mockUpdate = jest.fn(); +const mockDelete = jest.fn(); function testMoodleConfigSetup(formData) { return ( @@ -80,11 +78,20 @@ function testMoodleConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, onClickCancel: mockOnClick, existingData: formData, + existingConfigNames: [], + channelMap: { + MOODLE: { + post: mockPost, + update: mockUpdate, + delete: mockDelete, + }, + }, })} onClickOut={mockOnClick} onSubmit={mockSetExistingConfigFormData} formData={formData} isStepperOpen + dispatch={jest.fn()} /> ); } @@ -186,7 +193,7 @@ describe('', () => { password: 'password123', enterprise_customer: enterpriseId, }; - await waitFor(() => expect(LmsApiService.postNewMoodleConfig).toHaveBeenCalledWith(expectedConfig)); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith(expectedConfig)); }); test('saves draft correctly', async () => { render(testMoodleConfigSetup(noExistingData)); @@ -219,11 +226,10 @@ describe('', () => { enterprise_customer: enterpriseId, }; - expect(LmsApiService.postNewMoodleConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); test('validates poorly formatted existing data on load', async () => { render(testMoodleConfigSetup(invalidExistingData)); - screen.debug(); expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); expect(screen.queryByText(INVALID_MOODLE_VERIFICATION)).toBeInTheDocument(); diff --git a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx index 50ba6cead2..9dacfa9334 100644 --- a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx @@ -1,224 +1,243 @@ import React from 'react'; import { - render, fireEvent, screen, + act, + fireEvent, + render, + screen, + waitFor, } from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; import userEvent from '@testing-library/user-event'; -import SAPConfig from '../LMSConfigs/SAPConfig'; +import '@testing-library/jest-dom/extend-expect'; + +// @ts-ignore +import SAPConfig from '../LMSConfigs/SAP/SAPConfig.tsx'; import { INVALID_LINK, INVALID_NAME } from '../../data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; +// @ts-ignore +import FormContextWrapper from '../../../forms/FormContextWrapper.tsx'; + +const mockUpdateConfigApi = jest.spyOn(LmsApiService, 'updateSuccessFactorsConfig'); +const mockConfigResponseData = { + uuid: 'foobar', + id: 1, + display_name: 'display name', + sap_base_url: 'https://foobar.com', + active: false, +}; +mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); -jest.mock('../../../../data/services/LmsApiService'); +const mockPostConfigApi = jest.spyOn(LmsApiService, 'postNewSuccessFactorsConfig'); +mockPostConfigApi.mockResolvedValue({ data: mockConfigResponseData }); + +const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleSuccessFactorsConfig'); +mockFetchSingleConfig.mockResolvedValue({ data: { refresh_token: 'foobar' } }); const enterpriseId = 'test-enterprise-id'; const mockOnClick = jest.fn(); -const noConfigs = []; -const existingConfigDisplayNames = ['name']; -const existingConfigDisplayNamesInvalid = ['foobar']; +// Freshly creating a config will have an empty existing data object const noExistingData = {}; + const existingConfigData = { id: 1, - displayName: 'foobar', - sapsfBaseUrl: 'https://foobarish.com', + displayName: 'whatsinaname', + sapBaseUrl: 'http://www.example.com', + sapCompanyId: '12', + sapUserId: 'userId', + oauthClientId: 'clientId', + oauthClientSecret: 'secretshh', + sapUserType: 'admin', }; + // Existing invalid data that will be validated on load const invalidExistingData = { - displayName: 'fooooooooobaaaaaaaaar', - sapsfBaseUrl: 'bad_url :^(', + displayName: 'just a whole muddle of saps', + sapBaseUrl: 'you dumb dumb this isnt a url', + sapCompanyId: '12', + sapUserId: 'userId', + oauthClientId: 'clientId', + oauthClientSecret: 'secretshh', + sapUserType: 'admin', }; +const noConfigs = []; + afterEach(() => { jest.clearAllMocks(); }); -describe('', () => { - test('renders SAP Config Form', () => { - render( - , - ); - screen.getByLabelText('Display Name'); - screen.getByLabelText('SAP Base URL'); - screen.getByLabelText('SAP Company ID'); - screen.getByLabelText('SAP User ID'); - screen.getByLabelText('OAuth Client ID'); - screen.getByLabelText('OAuth Client Secret'); - screen.getByLabelText('SAP User Type'); - }); - test('test button disable', () => { - render( - , - ); - expect(screen.getByText('Submit')).toBeDisabled(); +const mockSetExistingConfigFormData = jest.fn(); +const mockPost = jest.fn(); +const mockUpdate = jest.fn(); +const mockDelete = jest.fn(); +function testSAPConfigSetup(formData) { + return ( + + ); +} + +async function clearForm() { + await act(async () => { fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'reallyreallyreallyreallyreallylongname' }, + target: { value: '' }, }); - // bad url, cannot be submitted fireEvent.change(screen.getByLabelText('SAP Base URL'), { - target: { value: 'test2' }, + target: { value: '' }, }); fireEvent.change(screen.getByLabelText('SAP Company ID'), { - target: { value: 'test3' }, + target: { value: '' }, }); fireEvent.change(screen.getByLabelText('SAP User ID'), { - target: { value: 'test4' }, + target: { value: '' }, }); fireEvent.change(screen.getByLabelText('OAuth Client ID'), { - target: { value: 'test5' }, + target: { value: '' }, }); fireEvent.change(screen.getByLabelText('OAuth Client Secret'), { - target: { value: 'test6' }, + target: { value: '' }, }); - // don't have to change userType, will default to user - expect(screen.getByText('Submit')).toBeDisabled(); - expect(screen.queryByText(INVALID_NAME)); + }); +} + +describe('', () => { + test('renders SAP Enable Form', () => { + render(testSAPConfigSetup(noConfigs)); + screen.getByLabelText('Display Name'); + screen.getByLabelText('SAP Base URL'); + screen.getByLabelText('SAP Company ID'); + screen.getByLabelText('SAP User ID'); + screen.getByLabelText('OAuth Client ID'); + screen.getByLabelText('OAuth Client Secret'); + screen.getByLabelText('SAP User Type'); + }); + test('test button disable', async () => { + render(testSAPConfigSetup(noExistingData)); + + const enableButton = screen.getByRole('button', { name: 'Enable' }); + await clearForm(); + expect(enableButton).toBeDisabled(); + + userEvent.type(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); + userEvent.type(screen.getByLabelText('SAP Base URL'), 'badlink'); + userEvent.type(screen.getByLabelText('SAP Company ID'), '1'); + userEvent.type(screen.getByLabelText('SAP User ID'), '1'); + userEvent.type(screen.getByLabelText('OAuth Client ID'), 'id'); + userEvent.type(screen.getByLabelText('OAuth Client Secret'), 'secret'); + userEvent.click(screen.getByLabelText('Admin')); + + expect(enableButton).toBeDisabled(); expect(screen.queryByText(INVALID_LINK)); - fireEvent.change(screen.getByLabelText('SAP Base URL'), { - target: { value: 'https://test2.com' }, - }); + expect(screen.queryByText(INVALID_NAME)); + fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'test2' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - }); - test('it edits existing configs on submit', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('SAP Company ID'), { - target: { value: 'test3' }, - }); - fireEvent.change(screen.getByLabelText('SAP User ID'), { - target: { value: 'test4' }, - }); - fireEvent.change(screen.getByLabelText('OAuth Client ID'), { - target: { value: 'test5' }, - }); - fireEvent.change(screen.getByLabelText('OAuth Client Secret'), { - target: { value: 'test6' }, + target: { value: '' }, }); fireEvent.change(screen.getByLabelText('SAP Base URL'), { - target: { value: 'https://www.test.com' }, + target: { value: '' }, }); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type( + screen.getByLabelText('SAP Base URL'), + 'https://www.test.com', + ); - const expectedConfig = { - sapsf_base_url: 'https://www.test.com', - sapsf_company_id: 'test3', - sapsf_user_id: 'test4', - secret: 'test6', - key: 'test5', - user_type: 'admin', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(LmsApiService.updateSuccessFactorsConfig).toHaveBeenCalledWith(expectedConfig, 1); + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); + expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); + expect(enableButton).not.toBeDisabled(); }); - test('it creates new configs on submit', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('SAP Company ID'), { - target: { value: 'test3' }, - }); - fireEvent.change(screen.getByLabelText('SAP User ID'), { - target: { value: 'test4' }, - }); - fireEvent.change(screen.getByLabelText('OAuth Client ID'), { - target: { value: 'test5' }, - }); - fireEvent.change(screen.getByLabelText('OAuth Client Secret'), { - target: { value: 'test6' }, - }); - fireEvent.change(screen.getByLabelText('SAP Base URL'), { - target: { value: 'https://www.test.com' }, - }); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - expect(screen.getByText('Submit')).not.toBeDisabled(); - userEvent.click(screen.getByText('Submit')); + test('it creates new configs on submit', async () => { + render(testSAPConfigSetup(noExistingData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); + + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'lmsconfig'); + userEvent.type(screen.getByLabelText('SAP Base URL'), 'http://www.example.com'); + userEvent.type(screen.getByLabelText('SAP Company ID'), '1'); + userEvent.type(screen.getByLabelText('SAP User ID'), '1'); + userEvent.type(screen.getByLabelText('OAuth Client ID'), 'id'); + userEvent.type(screen.getByLabelText('OAuth Client Secret'), 'secret'); + userEvent.click(screen.getByLabelText('Admin')); + + await waitFor(() => expect(enableButton).not.toBeDisabled()); + + userEvent.click(enableButton); const expectedConfig = { active: false, - sapsf_base_url: 'https://www.test.com', - sapsf_company_id: 'test3', - sapsf_user_id: 'test4', - secret: 'test6', - key: 'test5', - user_type: 'admin', - display_name: 'displayName', + display_name: 'lmsconfig', + sap_base_url: 'http://www.example.com', + sap_company_id: '1', + sap_user_id: '1', + oauth_client_id: 'id', + oauth_client_secret: 'secret', + sap_user_type: 'admin', enterprise_customer: enterpriseId, }; - expect(LmsApiService.postNewSuccessFactorsConfig).toHaveBeenCalledWith(expectedConfig); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith(expectedConfig)); }); - test('saves draft correctly', () => { - render( - , - ); - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: 'displayName' }, - }); - userEvent.click(screen.getByText('Cancel')); - userEvent.click(screen.getByText('Save')); + test('saves draft correctly', async () => { + render(testSAPConfigSetup(noExistingData)); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + + await clearForm(); + + userEvent.type(screen.getByLabelText('Display Name'), 'lmsconfig'); + userEvent.type(screen.getByLabelText('SAP Base URL'), 'http://www.example.com'); + userEvent.type(screen.getByLabelText('SAP Company ID'), '1'); + userEvent.type(screen.getByLabelText('SAP User ID'), '1'); + userEvent.type(screen.getByLabelText('OAuth Client ID'), 'id'); + userEvent.type(screen.getByLabelText('OAuth Client Secret'), 'secret'); + userEvent.click(screen.getByLabelText('User')); + + expect(cancelButton).not.toBeDisabled(); + userEvent.click(cancelButton); + + await waitFor(() => expect(screen.getByText('Exit configuration')).toBeInTheDocument()); + const closeButton = screen.getByRole('button', { name: 'Exit' }); + + userEvent.click(closeButton); + const expectedConfig = { active: false, - display_name: 'displayName', + display_name: 'lmsconfig', + sap_base_url: 'http://www.example.com', + sap_company_id: '1', + sap_user_id: '1', + oauth_client_id: 'id', + oauth_client_secret: 'secret', + sap_user_type: 'user', enterprise_customer: enterpriseId, - user_type: 'admin', }; - expect(LmsApiService.postNewSuccessFactorsConfig).toHaveBeenCalledWith(expectedConfig); + expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); - test('validates poorly formatted existing data on load', () => { - render( - , - ); - expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - expect(screen.getByText(INVALID_NAME)).toBeInTheDocument(); + test('validates poorly formatted existing data on load', async () => { + render(testSAPConfigSetup(invalidExistingData)); + expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); + expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); }); test('validates properly formatted existing data on load', () => { - render( - , - ); + render(testSAPConfigSetup(existingConfigData)); expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/utils.js b/src/utils.js index 2c259a39a5..11e955607b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -251,40 +251,97 @@ function urlValidation(urlString) { const normalizeFileUpload = (value) => value && value.split(/\r\n|\n/); +export const getChannelMap = () => ({ + [BLACKBOARD_TYPE]: { + displayName: 'Blackboard', + icon: BlackboardIcon, + post: LmsApiService.postNewBlackboardConfig, + update: LmsApiService.updateBlackboardConfig, + delete: LmsApiService.deleteBlackboardConfig, + fetch: LmsApiService.fetchSingleBlackboardConfig, + fetchGlobal: LmsApiService.fetchBlackboardGlobalConfig, + }, + [CANVAS_TYPE]: { + displayName: 'Canvas', + icon: CanvasIcon, + post: LmsApiService.postNewCanvasConfig, + update: LmsApiService.updateCanvasConfig, + delete: LmsApiService.deleteCanvasConfig, + fetch: LmsApiService.fetchSingleCanvasConfig, + }, + [CORNERSTONE_TYPE]: { + displayName: 'Cornerstone', + icon: CornerstoneIcon, + post: LmsApiService.postNewCornerstoneConfig, + update: LmsApiService.updateCornerstoneConfig, + delete: LmsApiService.deleteCornerstoneConfig, + }, + [DEGREED2_TYPE]: { + displayName: 'Degreed', + icon: DegreedIcon, + post: LmsApiService.postNewDegreed2Config, + update: LmsApiService.updateDegreed2Config, + delete: LmsApiService.deleteDegreed2Config, + }, + [MOODLE_TYPE]: { + displayName: 'Moodle', + icon: MoodleIcon, + post: LmsApiService.postNewMoodleConfig, + update: LmsApiService.updateMoodleConfig, + delete: LmsApiService.deleteMoodleConfig, + }, + [SAP_TYPE]: { + displayName: 'SAP Success Factors', + icon: SAPIcon, + post: LmsApiService.postNewSuccessFactorsConfig, + update: LmsApiService.updateSuccessFactorsConfig, + delete: LmsApiService.deleteSuccessFactorsConfig, + }, +}); + const channelMapping = { [BLACKBOARD_TYPE]: { displayName: 'Blackboard', icon: BlackboardIcon, + post: LmsApiService.postNewBlackboardConfig, update: LmsApiService.updateBlackboardConfig, delete: LmsApiService.deleteBlackboardConfig, + fetch: LmsApiService.fetchSingleBlackboardConfig, + fetchGlobal: LmsApiService.fetchBlackboardGlobalConfig, }, [CANVAS_TYPE]: { displayName: 'Canvas', icon: CanvasIcon, + post: LmsApiService.postNewCanvasConfig, update: LmsApiService.updateCanvasConfig, delete: LmsApiService.deleteCanvasConfig, + fetch: LmsApiService.fetchSingleCanvasConfig, }, [CORNERSTONE_TYPE]: { displayName: 'Cornerstone', icon: CornerstoneIcon, + post: LmsApiService.postNewCornerstoneConfig, update: LmsApiService.updateCornerstoneConfig, delete: LmsApiService.deleteCornerstoneConfig, }, [DEGREED2_TYPE]: { displayName: 'Degreed', icon: DegreedIcon, + post: LmsApiService.postNewDegreed2Config, update: LmsApiService.updateDegreed2Config, delete: LmsApiService.deleteDegreed2Config, }, [MOODLE_TYPE]: { displayName: 'Moodle', icon: MoodleIcon, + post: LmsApiService.postNewMoodleConfig, update: LmsApiService.updateMoodleConfig, delete: LmsApiService.deleteMoodleConfig, }, [SAP_TYPE]: { displayName: 'SAP Success Factors', icon: SAPIcon, + post: LmsApiService.postNewSuccessFactorsConfig, update: LmsApiService.updateSuccessFactorsConfig, delete: LmsApiService.deleteSuccessFactorsConfig, }, From 2cc0060c43c8127db0e0d7d1ded41d80c5b21c92 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Wed, 3 May 2023 10:41:51 -0600 Subject: [PATCH 67/73] feat: adding in the full lms stepper (#985) * doc: Lay out workflow changes for adding LMS Selector page * feat!: adding in the full stepper * fix: test fixes pt 1 * fix: test changes * fix: ugh --------- Co-authored-by: Marlon Keating --- src/components/forms/FormContext.tsx | 10 +- src/components/forms/FormWorkflow.tsx | 15 +- src/components/forms/data/reducer.ts | 11 +- .../settings/SettingsLMSTab/LMSCard.jsx | 28 -- .../settings/SettingsLMSTab/LMSConfigPage.jsx | 57 ++-- .../Blackboard/BlackboardConfig.tsx | 40 ++- .../BlackboardConfigAuthorizePage.tsx | 11 +- .../LMSConfigs/Canvas/CanvasConfig.tsx | 48 +-- .../Cornerstone/CornerstoneConfig.tsx | 38 ++- .../LMSConfigs/Degreed/DegreedConfig.tsx | 44 +-- .../LMSConfigs/Moodle/MoodleConfig.tsx | 43 +-- .../LMSConfigs/SAP/SAPConfig.tsx | 44 +-- .../SettingsLMSTab/LMSConfigs/utils.tsx | 58 +++- .../SettingsLMSTab/LMSFormWorkflowConfig.tsx | 113 +++++++ .../SettingsLMSTab/LMSSelectorPage.tsx | 59 ++++ .../settings/SettingsLMSTab/NoConfigCard.jsx | 6 +- .../settings/SettingsLMSTab/index.jsx | 68 +--- .../tests/AuthorizationsConfigs.test.tsx | 304 ++++++++++++++++++ .../tests/BlackboardConfig.test.tsx | 82 +---- .../tests/CanvasConfig.test.tsx | 135 +------- .../tests/CornerstoneConfig.test.jsx | 2 +- .../tests/DegreedConfig.test.tsx | 2 +- .../tests/LmsConfigPage.test.jsx | 129 ++------ .../tests/MoodleConfig.test.jsx | 10 +- .../SettingsLMSTab/tests/SAPConfig.test.jsx | 2 +- src/components/settings/data/constants.js | 2 + src/components/settings/settings.scss | 12 +- 27 files changed, 778 insertions(+), 595 deletions(-) delete mode 100644 src/components/settings/SettingsLMSTab/LMSCard.jsx create mode 100644 src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx create mode 100644 src/components/settings/SettingsLMSTab/LMSSelectorPage.tsx create mode 100644 src/components/settings/SettingsLMSTab/tests/AuthorizationsConfigs.test.tsx diff --git a/src/components/forms/FormContext.tsx b/src/components/forms/FormContext.tsx index bafe30ae2a..bbabb0c786 100644 --- a/src/components/forms/FormContext.tsx +++ b/src/components/forms/FormContext.tsx @@ -1,10 +1,4 @@ -import React, { - createContext, - useContext, - Context, - ReactNode, - Dispatch, -} from "react"; +import React, { createContext, useContext, Context, ReactNode, Dispatch } from "react"; import type { FormActionArguments } from "./data/actions"; import type { FormWorkflowStep } from "./FormWorkflow"; @@ -12,7 +6,7 @@ export type FormFields = { [name: string]: any }; export type FormValidatorResult = boolean | string; export type FormValidator = (formFields: FormFields) => FormValidatorResult; export type FormFieldValidation = { - formFieldId: string; + formFieldId?: string; validator: FormValidator; }; diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index f08ac293e1..5eaa83b75e 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -5,18 +5,9 @@ import { Launch } from "@edx/paragon/icons"; // @ts-ignore import { useFormContext } from "./FormContext.tsx"; -import type { - FormFields, - FormFieldValidation, - FormContext, -} from "./FormContext"; - -import { - setStepAction, - setWorkflowStateAction, - FORM_ERROR_MESSAGE, - // @ts-ignore -} from "./data/actions.ts"; +import type { FormFieldValidation, FormContext } from "./FormContext"; +// @ts-ignore +import { setStepAction, setWorkflowStateAction, FORM_ERROR_MESSAGE } from "./data/actions.ts"; import { SUBMIT_TOAST_MESSAGE } from "../settings/data/constants"; import { FormActionArguments } from "./data/actions"; // @ts-ignore diff --git a/src/components/forms/data/reducer.ts b/src/components/forms/data/reducer.ts index b7c7c7bf1c..2085890aa9 100644 --- a/src/components/forms/data/reducer.ts +++ b/src/components/forms/data/reducer.ts @@ -1,13 +1,8 @@ import groupBy from "lodash/groupBy"; import isEmpty from "lodash/isEmpty"; import keys from "lodash/keys" -import { - SET_FORM_FIELD, - SET_STEP, - SET_WORKFLOW_STATE, - UPDATE_FORM_FIELDS, // @ts-ignore -} from "./actions.ts"; +import { SET_FORM_FIELD, SET_STEP, SET_WORKFLOW_STATE, UPDATE_FORM_FIELDS } from "./actions.ts"; import type { FormActionArguments, SetFormFieldArguments, @@ -24,7 +19,9 @@ const processFormErrors = (state: FormContext): FormContext => { hasErrors: false, errorMap: {}, }; - if (state.formFields) { + if (typeof state.currentStep?.validations == "boolean") { + errorState = {hasErrors: state.currentStep?.validations, errorMap: {}}; + } else if (state.formFields) { // Generate list of errors with their formFieldIds // const formFieldsCopy = {...state.formFields}; const errors = state.currentStep?.validations diff --git a/src/components/settings/SettingsLMSTab/LMSCard.jsx b/src/components/settings/SettingsLMSTab/LMSCard.jsx deleted file mode 100644 index be9a2774b2..0000000000 --- a/src/components/settings/SettingsLMSTab/LMSCard.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Card, Image, Stack } from '@edx/paragon'; -import { channelMapping } from '../../../utils'; - -const LMSCard = ({ LMSType, onClick, disabled }) => ( - !disabled && onClick(LMSType)} - > - - - {channelMapping[LMSType].displayName} - - )} - /> - -); - -LMSCard.propTypes = { - LMSType: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - disabled: PropTypes.bool.isRequired, -}; -export default LMSCard; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 514aa8f521..30f68898be 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -2,29 +2,11 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { - BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, -} from '../data/constants'; -import { BlackboardFormConfig } from './LMSConfigs/Blackboard/BlackboardConfig.tsx'; -import { CanvasFormConfig } from './LMSConfigs/Canvas/CanvasConfig.tsx'; -import { CornerstoneFormConfig } from './LMSConfigs/Cornerstone/CornerstoneConfig.tsx'; -import { DegreedFormConfig } from './LMSConfigs/Degreed/DegreedConfig.tsx'; -import { MoodleFormConfig } from './LMSConfigs/Moodle/MoodleConfig.tsx'; -import { SAPFormConfig } from './LMSConfigs/SAP/SAPConfig.tsx'; import FormContextWrapper from '../../forms/FormContextWrapper.tsx'; import { getChannelMap } from '../../../utils'; - -const flowConfigs = { - [BLACKBOARD_TYPE]: BlackboardFormConfig, - [CANVAS_TYPE]: CanvasFormConfig, - [CORNERSTONE_TYPE]: CornerstoneFormConfig, - [DEGREED2_TYPE]: DegreedFormConfig, - [MOODLE_TYPE]: MoodleFormConfig, - [SAP_TYPE]: SAPFormConfig, -}; +import LMSFormWorkflowConfig from './LMSFormWorkflowConfig.tsx'; const LMSConfigPage = ({ - LMSType, onClick, enterpriseCustomerUuid, existingConfigFormData, @@ -32,6 +14,7 @@ const LMSConfigPage = ({ setExistingConfigFormData, isLmsStepperOpen, closeLmsStepper, + lmsType, }) => { const channelMap = useMemo(() => getChannelMap(), []); const handleCloseWorkflow = (submitted, msg) => { @@ -40,24 +23,25 @@ const LMSConfigPage = ({ return true; }; + const formWorkflowConfig = LMSFormWorkflowConfig({ + enterpriseCustomerUuid, + onSubmit: setExistingConfigFormData, + handleCloseClick: handleCloseWorkflow, + existingData: existingConfigFormData, + existingConfigNames: existingConfigs, + channelMap, + lmsType, + }); + return (
- {LMSType && ( - - )} +
); }; @@ -67,17 +51,18 @@ const mapStateToProps = (state) => ({ LMSConfigPage.defaultProps = { existingConfigs: [], + lmsType: '', }; LMSConfigPage.propTypes = { enterpriseCustomerUuid: PropTypes.string.isRequired, - LMSType: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, existingConfigFormData: PropTypes.shape({}).isRequired, existingConfigs: PropTypes.arrayOf(PropTypes.string), setExistingConfigFormData: PropTypes.func.isRequired, isLmsStepperOpen: PropTypes.bool.isRequired, closeLmsStepper: PropTypes.func.isRequired, + lmsType: PropTypes.string, }; export default connect(mapStateToProps)(LMSConfigPage); diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx index dc21ecec93..f32e079181 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx @@ -3,7 +3,6 @@ import { BLACKBOARD_TYPE, LMS_CONFIG_OAUTH_POLLING_INTERVAL, LMS_CONFIG_OAUTH_POLLING_TIMEOUT, - SUBMIT_TOAST_MESSAGE, } from "../../../data/constants"; // @ts-ignore import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; @@ -17,9 +16,10 @@ import type { // @ts-ignore } from "../../../../forms/FormWorkflow.tsx"; // @ts-ignore -import { afterSubmitHelper, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper, onTimeoutHelper } from "../utils.tsx"; +import { activateConfig, afterSubmitHelper, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper, onTimeoutHelper } from "../utils.tsx"; export type BlackboardConfigCamelCase = { + lms: string; blackboardAccountId: string; blackboardBaseUrl: string; displayName: string; @@ -32,6 +32,7 @@ export type BlackboardConfigCamelCase = { }; export type BlackboardConfigSnakeCase = { + lms: string; blackboard_base_url: string; display_name: string; id: string; @@ -46,14 +47,14 @@ export type BlackboardFormConfigProps = { existingData: BlackboardConfigCamelCase; existingConfigNames: string[]; onSubmit: (blackboardConfig: BlackboardConfigCamelCase) => void; - onClickCancel: (submitted: boolean, status: string) => Promise; + handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, }; export const BlackboardFormConfig = ({ enterpriseCustomerUuid, onSubmit, - onClickCancel, + handleCloseClick, existingData, existingConfigNames, channelMap, @@ -91,7 +92,8 @@ export const BlackboardFormConfig = ({ errHandler, dispatch, }: FormWorkflowHandlerArgs) => { - return afterSubmitHelper(BLACKBOARD_TYPE, formFields, channelMap, errHandler, dispatch); + const response = await afterSubmitHelper(BLACKBOARD_TYPE, formFields, channelMap, errHandler, dispatch); + return response; }; const onAwaitTimeout = async ({ @@ -100,11 +102,19 @@ export const BlackboardFormConfig = ({ onTimeoutHelper(dispatch); }; + const activate = async ({ + formFields, + errHandler, + }: FormWorkflowHandlerArgs) => { + activateConfig(enterpriseCustomerUuid, channelMap, BLACKBOARD_TYPE, formFields?.id, handleCloseClick, errHandler); + return formFields; + }; + const activatePage = () => ConfigActivatePage(BLACKBOARD_TYPE); const steps: FormWorkflowStep[] = [ { - index: 0, + index: 1, formComponent: BlackboardConfigAuthorizePage, validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Authorize", @@ -133,19 +143,19 @@ export const BlackboardFormConfig = ({ }, }, { - index: 1, + index: 2, formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, - nextButtonConfig: () => ({ - buttonText: "Activate", - opensNewWindow: false, - onClick: () => { - onClickCancel(true, SUBMIT_TOAST_MESSAGE); - return Promise.resolve(existingData); - }, - }), + nextButtonConfig: () => { + let config = { + buttonText: "Activate", + opensNewWindow: false, + onClick: activate, + }; + return config as FormWorkflowButtonConfig; + } }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx index 92fcc9103c..4ffc5ecc3c 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx @@ -1,5 +1,4 @@ import React from "react"; - import { Alert, Container, Form, Image } from "@edx/paragon"; import { Info } from "@edx/paragon/icons"; @@ -7,13 +6,9 @@ import { Info } from "@edx/paragon/icons"; import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; import { BLACKBOARD_TYPE, INVALID_LINK, INVALID_NAME } from "../../../data/constants"; import { channelMapping, urlValidation } from "../../../../../utils"; -import type { - FormFieldValidation, -} from "../../../../forms/FormContext"; -import { - useFormContext, - // @ts-ignore -} from "../../../../forms/FormContext.tsx"; +import type { FormFieldValidation } from "../../../../forms/FormContext"; +// @ts-ignore +import { useFormContext } from "../../../../forms/FormContext.tsx"; // @ts-ignore import FormWaitModal from "../../../../forms/FormWaitModal.tsx"; // @ts-ignore diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx index f25961c36a..6170b5efbb 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx @@ -1,25 +1,20 @@ import { snakeCaseDict } from "../../../../../utils"; -import { - CANVAS_TYPE, - LMS_CONFIG_OAUTH_POLLING_INTERVAL, - LMS_CONFIG_OAUTH_POLLING_TIMEOUT, - SUBMIT_TOAST_MESSAGE, +import { + CANVAS_TYPE, LMS_CONFIG_OAUTH_POLLING_INTERVAL, LMS_CONFIG_OAUTH_POLLING_TIMEOUT, } from "../../../data/constants"; // @ts-ignore import CanvasConfigAuthorizePage, { validations } from "./CanvasConfigAuthorizePage.tsx"; import type { - FormWorkflowButtonConfig, - FormWorkflowConfig, - FormWorkflowStep, - FormWorkflowHandlerArgs, + FormWorkflowButtonConfig, FormWorkflowConfig, FormWorkflowStep, FormWorkflowHandlerArgs, // @ts-ignore } from "../../../../forms/FormWorkflow.tsx"; // @ts-ignore import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; // @ts-ignore -import { afterSubmitHelper, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper, onTimeoutHelper } from "../utils.tsx"; +import { activateConfig, afterSubmitHelper, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper, onTimeoutHelper } from "../utils.tsx"; export type CanvasConfigCamelCase = { + lms: string; canvasAccountId: string; canvasBaseUrl: string; displayName: string; @@ -32,6 +27,7 @@ export type CanvasConfigCamelCase = { }; export type CanvasConfigSnakeCase = { + lms: string; canvas_account_id: string; canvas_base_url: string; display_name: string; @@ -49,14 +45,14 @@ export type CanvasFormConfigProps = { existingData: CanvasConfigCamelCase; existingConfigNames: string[]; onSubmit: (canvasConfig: CanvasConfigCamelCase) => void; - onClickCancel: (submitted: boolean, status: string) => Promise; + handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, }; export const CanvasFormConfig = ({ enterpriseCustomerUuid, onSubmit, - onClickCancel, + handleCloseClick, existingData, existingConfigNames, channelMap, @@ -103,11 +99,19 @@ export const CanvasFormConfig = ({ onTimeoutHelper(dispatch); }; + const activate = async ({ + formFields, + errHandler, + }: FormWorkflowHandlerArgs) => { + activateConfig(enterpriseCustomerUuid, channelMap, CANVAS_TYPE, formFields?.id, handleCloseClick, errHandler); + return formFields; + }; + const activatePage = () => ConfigActivatePage(CANVAS_TYPE); const steps: FormWorkflowStep[] = [ { - index: 0, + index: 1, formComponent: CanvasConfigAuthorizePage, validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Authorize", @@ -136,19 +140,19 @@ export const CanvasFormConfig = ({ }, }, { - index: 1, + index: 2, formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, - nextButtonConfig: () => ({ - buttonText: "Activate", - opensNewWindow: false, - onClick: () => { - onClickCancel(true, SUBMIT_TOAST_MESSAGE); - return Promise.resolve(existingData); - }, - }), + nextButtonConfig: () => { + let config = { + buttonText: "Activate", + opensNewWindow: false, + onClick: activate, + }; + return config as FormWorkflowButtonConfig; + } }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx index 453b47aa91..832ee5901d 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx @@ -1,5 +1,5 @@ import { snakeCaseDict } from "../../../../../utils"; -import { CORNERSTONE_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +import { CORNERSTONE_TYPE } from "../../../data/constants"; // @ts-ignore import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; // @ts-ignore @@ -9,9 +9,10 @@ import type { // @ts-ignore } from "../../../../forms/FormWorkflow.tsx"; // @ts-ignore -import { checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; +import { activateConfig, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; export type CornerstoneConfigCamelCase = { + lms: string; displayName: string; cornerstoneBaseUrl: string; id: string; @@ -20,6 +21,7 @@ export type CornerstoneConfigCamelCase = { }; export type CornerstoneConfigSnakeCase = { + lms: string; display_name: string; cornerstone_base_url: string; id: string; @@ -33,14 +35,14 @@ export type CornerstoneFormConfigProps = { existingData: CornerstoneConfigCamelCase; existingConfigNames: string[]; onSubmit: (cornerstoneConfig: CornerstoneConfigCamelCase) => void; - onClickCancel: (submitted: boolean, status: string) => Promise; + handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, }; export const CornerstoneFormConfig = ({ enterpriseCustomerUuid, onSubmit, - onClickCancel, + handleCloseClick, existingData, existingConfigNames, channelMap, @@ -74,11 +76,19 @@ export const CornerstoneFormConfig = ({ currentFormFields, CORNERSTONE_TYPE, channelMap, errHandler, dispatch); }; + const activate = async ({ + formFields, + errHandler, + }: FormWorkflowHandlerArgs) => { + activateConfig(enterpriseCustomerUuid, channelMap, CORNERSTONE_TYPE, formFields?.id, handleCloseClick, errHandler); + return formFields; + }; + const activatePage = () => ConfigActivatePage(CORNERSTONE_TYPE); const steps: FormWorkflowStep[] = [ { - index: 0, + index: 1, formComponent: CornerstoneConfigEnablePage, validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Enable", @@ -93,19 +103,19 @@ export const CornerstoneFormConfig = ({ }, }, { - index: 1, + index: 2, formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, - nextButtonConfig: () => ({ - buttonText: "Activate", - opensNewWindow: false, - onClick: () => { - onClickCancel(true, SUBMIT_TOAST_MESSAGE); - return Promise.resolve(existingData); - }, - }), + nextButtonConfig: () => { + let config = { + buttonText: "Activate", + opensNewWindow: false, + onClick: activate, + }; + return config as FormWorkflowButtonConfig; + } }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx index d09f28ff69..d44e5491bd 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx @@ -1,20 +1,18 @@ import { snakeCaseDict } from "../../../../../utils"; -import { DEGREED2_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +import { DEGREED2_TYPE } from "../../../data/constants"; // @ts-ignore import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; // @ts-ignore import DegreedConfigAuthorizePage, { validations } from "./DegreedConfigEnablePage.tsx"; import type { - FormWorkflowButtonConfig, - FormWorkflowConfig, - FormWorkflowStep, - FormWorkflowHandlerArgs, + FormWorkflowButtonConfig, FormWorkflowConfig, FormWorkflowStep, FormWorkflowHandlerArgs, // @ts-ignore } from "../../../../forms/FormWorkflow.tsx"; // @ts-ignore -import { checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; +import { activateConfig, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; export type DegreedConfigCamelCase = { + lms: string; displayName: string; clientId: string; clientSecret: string; @@ -26,6 +24,7 @@ export type DegreedConfigCamelCase = { }; export type DegreedConfigSnakeCase = { + lms: string; display_name: string; client_id: string; client_secret: string; @@ -43,14 +42,14 @@ export type DegreedFormConfigProps = { existingData: DegreedConfigCamelCase; existingConfigNames: string[]; onSubmit: (degreedConfig: DegreedConfigCamelCase) => void; - onClickCancel: (submitted: boolean, status: string) => Promise; + handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, }; export const DegreedFormConfig = ({ enterpriseCustomerUuid, onSubmit, - onClickCancel, + handleCloseClick, existingData, existingConfigNames, channelMap, @@ -82,11 +81,18 @@ export const DegreedFormConfig = ({ currentFormFields, DEGREED2_TYPE, channelMap, errHandler, dispatch); }; - const activatePage = () => ConfigActivatePage(DEGREED2_TYPE); + const activate = async ({ + formFields, + errHandler, + }: FormWorkflowHandlerArgs) => { + activateConfig(enterpriseCustomerUuid, channelMap, DEGREED2_TYPE, formFields?.id, handleCloseClick, errHandler); + return formFields; + }; + const activatePage = () => ConfigActivatePage(DEGREED2_TYPE); const steps: FormWorkflowStep[] = [ { - index: 0, + index: 1, formComponent: DegreedConfigAuthorizePage, validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Enable", @@ -101,19 +107,19 @@ export const DegreedFormConfig = ({ }, }, { - index: 1, + index: 2, formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, - nextButtonConfig: () => ({ - buttonText: "Activate", - opensNewWindow: false, - onClick: () => { - onClickCancel(true, SUBMIT_TOAST_MESSAGE); - return Promise.resolve(existingData); - }, - }), + nextButtonConfig: () => { + let config = { + buttonText: "Activate", + opensNewWindow: false, + onClick: activate, + }; + return config as FormWorkflowButtonConfig; + } }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx index 55ca1c90f8..622dc0bc9d 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx @@ -1,20 +1,18 @@ import { snakeCaseDict } from "../../../../../utils"; -import { MOODLE_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +import { MOODLE_TYPE } from "../../../data/constants"; // @ts-ignore import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; // @ts-ignore import MoodleConfigEnablePage, { validations } from "./MoodleConfigEnablePage.tsx"; import type { - FormWorkflowButtonConfig, - FormWorkflowConfig, - FormWorkflowStep, - FormWorkflowHandlerArgs, + FormWorkflowButtonConfig, FormWorkflowConfig, FormWorkflowStep, FormWorkflowHandlerArgs, // @ts-ignore } from "../../../../forms/FormWorkflow.tsx"; // @ts-ignore -import { checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; +import { activateConfig, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; export type MoodleConfigCamelCase = { + lms: string; displayName: string; moodleBaseUrl: string; webserviceShortName: string; @@ -27,6 +25,7 @@ export type MoodleConfigCamelCase = { }; export type MoodleConfigSnakeCase = { + lms: string; display_name: string; moodle_base_url: string; webservice_short_name: string; @@ -44,14 +43,14 @@ export type MoodleFormConfigProps = { existingData: MoodleConfigCamelCase; existingConfigNames: string[]; onSubmit: (moodleConfig: MoodleConfigCamelCase) => void; - onClickCancel: (submitted: boolean, status: string) => Promise; + handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>; }; export const MoodleFormConfig = ({ enterpriseCustomerUuid, onSubmit, - onClickCancel, + handleCloseClick, existingData, existingConfigNames, channelMap, @@ -84,11 +83,19 @@ export const MoodleFormConfig = ({ currentFormFields, MOODLE_TYPE, channelMap, errHandler, dispatch) }; + const activate = async ({ + formFields, + errHandler, + }: FormWorkflowHandlerArgs) => { + activateConfig(enterpriseCustomerUuid, channelMap, MOODLE_TYPE, formFields?.id, handleCloseClick, errHandler); + return formFields; + }; + const activatePage = () => ConfigActivatePage(MOODLE_TYPE); const steps: FormWorkflowStep[] = [ { - index: 0, + index: 1, formComponent: MoodleConfigEnablePage, validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Enable", @@ -103,19 +110,19 @@ export const MoodleFormConfig = ({ }, }, { - index: 1, + index: 2, formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, - nextButtonConfig: () => ({ - buttonText: "Activate", - opensNewWindow: false, - onClick: () => { - onClickCancel(true, SUBMIT_TOAST_MESSAGE); - return Promise.resolve(existingData); - }, - }), + nextButtonConfig: () => { + let config = { + buttonText: "Activate", + opensNewWindow: false, + onClick: activate, + }; + return config as FormWorkflowButtonConfig; + } }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx index 13333625e1..8a48afdbce 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx @@ -1,20 +1,18 @@ import { snakeCaseDict } from "../../../../../utils"; -import { SAP_TYPE, SUBMIT_TOAST_MESSAGE } from "../../../data/constants"; +import { SAP_TYPE } from "../../../data/constants"; // @ts-ignore import ConfigActivatePage from "../ConfigBasePages/ConfigActivatePage.tsx"; // @ts-ignore import SAPConfigEnablePage, { validations } from "./SAPConfigEnablePage.tsx"; import type { - FormWorkflowButtonConfig, - FormWorkflowConfig, - FormWorkflowStep, - FormWorkflowHandlerArgs, + FormWorkflowButtonConfig, FormWorkflowConfig, FormWorkflowStep, FormWorkflowHandlerArgs, // @ts-ignore } from "../../../../forms/FormWorkflow.tsx"; // @ts-ignore -import { checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; +import { activateConfig, checkForDuplicateNames, handleSaveHelper, handleSubmitHelper } from "../utils.tsx"; export type SAPConfigCamelCase = { + lms: string; displayName: string; sapBaseUrl: string; sapCompanyId: string; @@ -28,6 +26,7 @@ export type SAPConfigCamelCase = { }; export type SAPConfigSnakeCase = { + lms: string; display_name: string; sap_base_url: string; sap_company_id: string; @@ -46,14 +45,14 @@ export type SAPFormConfigProps = { existingData: SAPConfigCamelCase; existingConfigNames: string[]; onSubmit: (sapConfig: SAPConfigCamelCase) => void; - onClickCancel: (submitted: boolean, status: string) => Promise; + handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>; }; export const SAPFormConfig = ({ enterpriseCustomerUuid, onSubmit, - onClickCancel, + handleCloseClick, existingData, existingConfigNames, channelMap, @@ -86,11 +85,20 @@ export const SAPFormConfig = ({ currentFormFields, SAP_TYPE, channelMap, errHandler, dispatch); }; + const activate = async ({ + formFields, + errHandler, + }: FormWorkflowHandlerArgs) => { + activateConfig(enterpriseCustomerUuid, channelMap, SAP_TYPE, formFields?.id, handleCloseClick, errHandler); + return formFields; + }; + + const activatePage = () => ConfigActivatePage(SAP_TYPE); const steps: FormWorkflowStep[] = [ { - index: 0, + index: 1, formComponent: SAPConfigEnablePage, validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), stepName: "Enable", @@ -105,19 +113,19 @@ export const SAPFormConfig = ({ }, }, { - index: 1, + index: 2, formComponent: activatePage, validations: [], stepName: "Activate", saveChanges, - nextButtonConfig: () => ({ - buttonText: "Activate", - opensNewWindow: false, - onClick: () => { - onClickCancel(true, SUBMIT_TOAST_MESSAGE); - return Promise.resolve(existingData); - }, - }), + nextButtonConfig: () => { + let config = { + buttonText: "Activate", + opensNewWindow: false, + onClick: activate, + }; + return config as FormWorkflowButtonConfig; + } }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx index 7409426950..3baa0b38cf 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx @@ -1,5 +1,7 @@ import type { FormFieldValidation } from "../../../forms/FormContext"; -import { BLACKBOARD_OAUTH_REDIRECT_URL, BLACKBOARD_TYPE, CANVAS_OAUTH_REDIRECT_URL, CANVAS_TYPE, INVALID_NAME } from "../../data/constants"; +import { + BLACKBOARD_OAUTH_REDIRECT_URL, BLACKBOARD_TYPE, CANVAS_OAUTH_REDIRECT_URL, CANVAS_TYPE, INVALID_NAME, SUBMIT_TOAST_MESSAGE +} from "../../data/constants"; import handleErrors from "../../utils"; import { camelCaseDict } from "../../../../utils"; // @ts-ignore @@ -13,9 +15,9 @@ import { SAPConfigCamelCase, SAPConfigSnakeCase } from "./SAP/SAPConfig"; // @ts-ignore import { FormWorkflowErrorHandler, WAITING_FOR_ASYNC_OPERATION } from "../../../forms/FormWorkflow.tsx"; -type ConfigCamelCase = {id?: string, active?: boolean} | +type ConfigCamelCase = { id?: string, active?: boolean, lms?: string, } | BlackboardConfigCamelCase | CanvasConfigCamelCase | CornerstoneConfigCamelCase | DegreedConfigCamelCase | MoodleConfigCamelCase | SAPConfigCamelCase; -type ConfigSnakeCase = {enterprise_customer?: string, active?: boolean} | +type ConfigSnakeCase = { enterprise_customer?: string, active?: boolean } | BlackboardConfigSnakeCase | CanvasConfigSnakeCase | CornerstoneConfigSnakeCase | DegreedConfigSnakeCase | MoodleConfigSnakeCase | SAPConfigSnakeCase; export const LMS_AUTHORIZATION_FAILED = "LMS AUTHORIZATION FAILED"; @@ -69,7 +71,7 @@ export async function handleSubmitHelper( } async function handleSubmitAuthorize( - lmsType: string, + lmsType: string, existingData: any, currentFormFields: any, channelMap: Record>, @@ -77,7 +79,7 @@ async function handleSubmitAuthorize( ) { if ((lmsType === BLACKBOARD_TYPE || lmsType === CANVAS_TYPE) && currentFormFields && !currentFormFields?.refreshToken) { let oauthUrl: string; - if (lmsType == BLACKBOARD_TYPE) { + if (lmsType === BLACKBOARD_TYPE) { let appKey = existingData.clientId; let configUuid = existingData.uuid; if (!appKey || !configUuid) { @@ -173,13 +175,41 @@ export async function handleSaveHelper( } export function checkForDuplicateNames( - existingConfigNames: string[], existingData: {displayName: string}): FormFieldValidation { - return { - formFieldId: 'displayName', - validator: () => { - return existingConfigNames?.includes(existingData.displayName) - ? INVALID_NAME - : false; - }, - }; + existingConfigNames: string[], existingData: { displayName: string }): FormFieldValidation { + return { + formFieldId: 'displayName', + validator: () => { + return existingConfigNames?.includes(existingData.displayName) + ? INVALID_NAME + : false; + }, + }; +} + +export async function activateConfig( + enterpriseCustomerUuid: string, + channelMap: Record>, + lmsType: string, + id: string | undefined, + handleCloseClick: (submitted: boolean, status: string) => Promise, + errHandler: FormWorkflowErrorHandler | undefined, +) { + const configOptions = { + active: true, + enterprise_customer: enterpriseCustomerUuid, + }; + let err; + try { + await channelMap[lmsType].update(configOptions, id); + } catch (error) { + err = handleErrors(error); + } + if (err) { + errHandler?.(err); + } else { + handleCloseClick(true, SUBMIT_TOAST_MESSAGE); + } + if (err) { + errHandler?.(err); + } } diff --git a/src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx b/src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx new file mode 100644 index 0000000000..e72586b3c1 --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import isEmpty from "lodash/isEmpty"; + +import { CANVAS_TYPE } from "../data/constants"; +// @ts-ignore +import type { FormWorkflowConfig, FormWorkflowStep } from "../../../../forms/FormWorkflow.tsx"; +// @ts-ignore +import BlackboardFormConfig, { BlackboardConfigCamelCase, BlackboardConfigSnakeCase } from "./LMSConfigs/Blackboard/BlackboardConfig.tsx"; +// @ts-ignore +import CanvasFormConfig, { CanvasConfigCamelCase, CanvasConfigSnakeCase } from "./LMSConfigs/Canvas/CanvasConfig.tsx"; +// @ts-ignore +import CornerstoneFormConfig, { CornerstoneConfigCamelCase, CornerstoneConfigSnakeCase } from "./LMSConfigs/Cornerstone/CornerstoneConfig.tsx"; +// @ts-ignore +import DegreedFormConfig, { DegreedConfigCamelCase, DegreedConfigSnakeCase } from "./LMSConfigs/Degreed/DegreedConfig.tsx"; +// @ts-ignore +import MoodleFormConfig, { MoodleConfigCamelCase, MoodleConfigSnakeCase } from "./LMSConfigs/Moodle/MoodleConfig.tsx"; +// @ts-ignore +import SAPFormConfig, { SAPConfigCamelCase, SAPConfigSnakeCase } from "./LMSConfigs/SAP/SAPConfig.tsx"; +import { BLACKBOARD_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE } from "../data/constants"; +// @ts-ignore +import { LMSSelectorPage, validations} from "./LMSSelectorPage.tsx"; + +const flowConfigs = { + [BLACKBOARD_TYPE]: BlackboardFormConfig, + [CANVAS_TYPE]: CanvasFormConfig, + [CORNERSTONE_TYPE]: CornerstoneFormConfig, + [DEGREED2_TYPE]: DegreedFormConfig, + [MOODLE_TYPE]: MoodleFormConfig, + [SAP_TYPE]: SAPFormConfig, +}; + +export type LMSConfigCamelCase = BlackboardConfigCamelCase | CanvasConfigCamelCase | CornerstoneConfigCamelCase | DegreedConfigCamelCase + | MoodleConfigCamelCase | SAPConfigCamelCase; +export type LMSConfigSnakeCase = BlackboardConfigSnakeCase | CanvasConfigSnakeCase | CornerstoneConfigSnakeCase | DegreedConfigSnakeCase + | MoodleConfigSnakeCase | SAPConfigSnakeCase; + +export type LMSFormConfigProps = { + enterpriseCustomerUuid: string; + existingData: LMSConfigCamelCase; + existingConfigNames: string[]; + onSubmit: (LMSConfigCamelCase) => void; + handleCloseClick: (submitted: boolean, status: string) => Promise; + channelMap: Record>; + lmsType?: string; +}; + +export const LMSFormWorkflowConfig = ({ + enterpriseCustomerUuid, + onSubmit, + handleCloseClick, + existingData, + existingConfigNames, + channelMap, + lmsType, +}: LMSFormConfigProps): FormWorkflowConfig => { + const [lms, setLms] = useState(lmsType ? lmsType : ''); + // once an lms is selected by the user (or they are editing and existing one) + // we dynamically render the correct FormConfig + const lmsConfig = + (lms && !isEmpty(lms)) && + flowConfigs[lms]({ + enterpriseCustomerUuid, + onSubmit, + handleCloseClick, + existingData, + existingConfigNames, + channelMap + }); + + let steps: FormWorkflowStep[] = [ + { + index: 0, + formComponent: LMSSelectorPage(setLms), + validations: validations, + stepName: "Select LMS", + nextButtonConfig: () => ({ + buttonText: "Next", + opensNewWindow: false, + onClick: () => {}, + }), + }, + ]; + + // If we've selected an LMS, add its steps. Otherwise add a placeholder steps + if (lmsConfig) { + steps = steps.concat(lmsConfig.steps); + } else { + steps = steps.concat( + { + index: 1, + stepName: "Activate", + }, + { + index: 2, + stepName: "Enable", + } + ); + } + + // Go to selector step if the config has not yet been created + const getCurrentStep = () => { + const startStep: number = existingData.id ? 1 : 0; + return steps[startStep]; + }; + + return { + ...lmsConfig, + getCurrentStep, + steps, + }; +}; + +export default LMSFormWorkflowConfig; diff --git a/src/components/settings/SettingsLMSTab/LMSSelectorPage.tsx b/src/components/settings/SettingsLMSTab/LMSSelectorPage.tsx new file mode 100644 index 0000000000..97c284ffdc --- /dev/null +++ b/src/components/settings/SettingsLMSTab/LMSSelectorPage.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { Container, Image, SelectableBox, } from "@edx/paragon"; + +import { channelMapping } from "../../../utils.js"; +import { LMS_KEYS } from "../data/constants.js"; +// @ts-ignore +import { FormFieldValidation, useFormContext } from "../../forms/FormContext.tsx"; +// @ts-ignore +import { setFormFieldAction } from "../../forms/data/actions.ts"; + +export const validations: FormFieldValidation[] = [ + { + formFieldId: 'lms', + validator: (fields) => { + return !LMS_KEYS.includes(fields['lms']) + }, + }, +]; + +export function LMSSelectorPage(setLms: (string) => void) { + const LMSSelectorPageImpl = () => { + const { dispatch, formFields } = useFormContext(); + const handleChange = (e: React.ChangeEvent) => { + // setting this value allows the LMSFormWorkflowConfig page to rerender with the state + // change, which sets the LMS's associated steps + setLms(e.target.value); + dispatch && dispatch( + setFormFieldAction({ fieldId: 'lms', value: e.target.value }) + ); + }; + return ( + + +

+ Let's get started +

+

Select the LMS or LXP you want to integrate with edX For Business.

+ + {LMS_KEYS.map(lms => ( + +
+ +

{channelMapping[lms].displayName}

+
+
+ ))} +
+
+
+ ); + }; + return LMSSelectorPageImpl; +} \ No newline at end of file diff --git a/src/components/settings/SettingsLMSTab/NoConfigCard.jsx b/src/components/settings/SettingsLMSTab/NoConfigCard.jsx index 0918087457..4f1fbd9dd5 100644 --- a/src/components/settings/SettingsLMSTab/NoConfigCard.jsx +++ b/src/components/settings/SettingsLMSTab/NoConfigCard.jsx @@ -8,11 +8,11 @@ import { Add, Error } from '@edx/paragon/icons'; import cardImage from '../../../data/images/NoConfig.svg'; const NoConfigCard = ({ - enterpriseSlug, setShowNoConfigCard, createNewConfig, hasSSOConfig, + enterpriseSlug, setShowNoConfigCard, openLmsStepper, hasSSOConfig, }) => { const onClick = () => { setShowNoConfigCard(false); - createNewConfig(true); + openLmsStepper(); }; return ( @@ -52,7 +52,7 @@ const NoConfigCard = ({ NoConfigCard.propTypes = { enterpriseSlug: PropTypes.string.isRequired, setShowNoConfigCard: PropTypes.func.isRequired, - createNewConfig: PropTypes.func.isRequired, + openLmsStepper: PropTypes.func.isRequired, hasSSOConfig: PropTypes.bool.isRequired, }; diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index e41629ab2e..3ace60b582 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -3,30 +3,25 @@ import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { - Alert, Button, Hyperlink, CardGrid, Toast, Skeleton, useToggle, + Alert, Button, Hyperlink, Toast, Skeleton, useToggle, } from '@edx/paragon'; import { Add, Info } from '@edx/paragon/icons'; import { logError } from '@edx/frontend-platform/logging'; import { camelCaseDictArray } from '../../../utils'; -import LMSCard from './LMSCard'; import LMSConfigPage from './LMSConfigPage'; import ExistingLMSCardDeck from './ExistingLMSCardDeck'; import NoConfigCard from './NoConfigCard'; import { - BLACKBOARD_TYPE, - CANVAS_TYPE, - CORNERSTONE_TYPE, - DEGREED2_TYPE, HELP_CENTER_LINK, - MOODLE_TYPE, - SAP_TYPE, ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE, SUBMIT_TOAST_MESSAGE, } from '../data/constants'; import LmsApiService from '../../../data/services/LmsApiService'; +// @ts-ignore +import { useFormContext } from '../../forms/FormContext.tsx'; const SettingsLMSTab = ({ enterpriseId, @@ -40,7 +35,6 @@ const SettingsLMSTab = ({ const [existingConfigsData, setExistingConfigsData] = useState({}); const [configsExist, setConfigsExist] = useState(false); - const [showNewConfigButtons, setShowNewConfigButtons] = useState(false); const [showNoConfigCard, setShowNoConfigCard] = useState(true); const [configsLoading, setConfigsLoading] = useState(true); const [displayNames, setDisplayNames] = useState([]); @@ -48,19 +42,23 @@ const SettingsLMSTab = ({ const [existingConfigFormData, setExistingConfigFormData] = useState({}); const [toastMessage, setToastMessage] = useState(); const [displayNeedsSSOAlert, setDisplayNeedsSSOAlert] = useState(false); + const [lmsType, setLmsType] = useState(''); const [isLmsStepperOpen, openLmsStepper, closeLmsStepper] = useToggle(false); const toastMessages = [ACTIVATE_TOAST_MESSAGE, DELETE_TOAST_MESSAGE, INACTIVATE_TOAST_MESSAGE, SUBMIT_TOAST_MESSAGE]; + const { dispatch } = useFormContext(); // onClick function for existing config cards' edit action const editExistingConfig = (configData, configType) => { setConfigsLoading(false); + // Setting this allows us to skip the selection step in the stepper + dispatch?.setFormFieldAction({ fieldId: 'lms', value: configData.channelCode }); + setLmsType(configData.channelCode); openLmsStepper(); // Set the form data to the card's associated config data setExistingConfigFormData(configData); // Set the config type to the card's type setConfig(configType); // Hide the create new configs button - setShowNewConfigButtons(false); setShowNoConfigCard(false); // Since the user is editing, hide the existing config cards setConfigsExist(false); @@ -78,8 +76,6 @@ const SettingsLMSTab = ({ setShowNoConfigCard(false); // toggle the existing configs bool setConfigsExist(true); - // Hide the create cards and show the create button - setShowNewConfigButtons(false); } else { setShowNoConfigCard(true); } @@ -92,9 +88,9 @@ const SettingsLMSTab = ({ }, [enterpriseId]); const onClick = (input) => { - // Either we're creating a new config (a create config card was clicked), or we're navigating - // back to the landing state from a form (submit or cancel was hit on the forms). In both cases, - // we want to clear existing config form data. + // Either we're creating a new config, or we're navigating back to + // the landing state from a form (submit or cancel was hit on the forms). + // In both cases, we want to clear existing config form data. setExistingConfigFormData({}); // If either the user has submit or canceled if (input === '' || toastMessages.includes(input)) { @@ -108,22 +104,15 @@ const SettingsLMSTab = ({ setToastMessage(input); closeLmsStepper(true); } else { - // Otherwise the user has clicked a create card and we need to set existing config bool to - // false and set the config type to the card that was clicked type - setShowNewConfigButtons(false); + // Otherwise the user has clicked to create an lms and we need to open the stepper setConfigsExist(false); setConfig(input); openLmsStepper(); } }; - // onClick function for the show create cards button - const showCreateConfigCards = () => { - setShowNewConfigButtons(true); - }; - useEffect(() => { - // On load fetch potential existing configs + // On load, fetch potential existing configs fetchExistingConfigs(); }, [fetchExistingConfigs]); @@ -150,13 +139,13 @@ const SettingsLMSTab = ({ Help Center: Integrations
- {!showNewConfigButtons && !configsLoading && !config && ( + {!configsLoading && !config && ( @@ -195,43 +184,20 @@ const SettingsLMSTab = ({ )} - {showNewConfigButtons && !configsLoading && ( - -

New configurations

-

Click on a card to start a new configuration

- - - - - - - - - -
- )} {isLmsStepperOpen && ( )} diff --git a/src/components/settings/SettingsLMSTab/tests/AuthorizationsConfigs.test.tsx b/src/components/settings/SettingsLMSTab/tests/AuthorizationsConfigs.test.tsx new file mode 100644 index 0000000000..227005b19c --- /dev/null +++ b/src/components/settings/SettingsLMSTab/tests/AuthorizationsConfigs.test.tsx @@ -0,0 +1,304 @@ +import React from 'react'; +import { + act, fireEvent, screen, waitFor, waitForElementToBeRemoved +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { renderWithRouter } from '../../../test/testUtils'; +import { BLACKBOARD_TYPE, CANVAS_TYPE } from '../../data/constants'; +import { channelMapping } from '../../../../utils'; + +import SettingsLMSTab from '../index'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +const enterpriseId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +const enterpriseSlug = 'test-slug'; +const enableSamlConfigurationScreen = false; +const identityProvider = ''; + +const initialState = { + portalConfiguration: { + enterpriseId, enterpriseSlug, enableSamlConfigurationScreen, identityProvider, + }, +}; +const mockBlackboardResponseData = { + uuid: 'foobar', + id: 1, + display_name: 'display name', + blackboard_base_url: 'https://foobar.com', + active: false, +}; + +const mockBlackboardResponseDataActive = { + uuid: 'foobar', + id: 1, + display_name: 'display name', + blackboard_base_url: 'https://foobar.com', + active: true, +}; + +const mockCanvasResponseData = { + uuid: 'foobar', + id: 1, + canvas_account_id: 1, + display_name: 'display name', + canvas_base_url: 'https://foobar.com', + client_id: "wassap", + client_secret: "chewlikeyouhaveasecret", + active: false, +}; + +const mockCanvasResponseDataActive = { + uuid: 'foobar', + id: 1, + canvas_account_id: 1, + display_name: 'display name', + canvas_base_url: 'https://foobar.com', + client_id: "wassap", + client_secret: "chewlikeyouhaveasecret", + active: true, +}; + + +const mockStore = configureMockStore([thunk]); +window.open = jest.fn(); + +const mockBlackboardPost = jest.spyOn(LmsApiService, 'postNewBlackboardConfig'); +const mockBlackboardUpdate = jest.spyOn(LmsApiService, 'updateBlackboardConfig'); +const mockBlackboardFetch = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); +const mockBlackboardFetchGlobal = jest.spyOn(LmsApiService, 'fetchBlackboardGlobalConfig'); +mockBlackboardPost.mockResolvedValue({ data: mockBlackboardResponseData }); +mockBlackboardUpdate.mockResolvedValue({ data: mockBlackboardResponseData }); +mockBlackboardFetch.mockResolvedValue({ data: { refresh_token: 'foobar' } }); +mockBlackboardFetchGlobal.mockReturnValue({ data: { results: [{ app_key: 1 }] } }) + +const SettingsBlackboardWrapper = () => ( + + + + + +); + +const mockCanvasPost = jest.spyOn(LmsApiService, 'postNewCanvasConfig'); +const mockCanvasUpdate = jest.spyOn(LmsApiService, 'updateCanvasConfig'); +const mockCanvasFetch = jest.spyOn(LmsApiService, 'fetchSingleCanvasConfig'); +mockCanvasPost.mockResolvedValue({ data: mockCanvasResponseData }); +mockCanvasUpdate.mockResolvedValue({ data: mockCanvasResponseData }); +mockCanvasFetch.mockResolvedValue({ data: { refresh_token: 'foobar' } }); + + +const SettingsCanvasWrapper = () => ( + + + + + +); + + +describe('Test authorization flows for Blackboard and Canvas', () => { + test('Blackboard properly authorizes', async () => { + renderWithRouter(); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); + expect(screen.findByText(channelMapping[BLACKBOARD_TYPE].displayName)); + }); + const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); + userEvent.click(blackboardCard); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Authorize connection to Blackboard')).toBeTruthy(); + }); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + userEvent.click(authorizeButton); + await waitFor(() => { + expect(screen.queryByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeTruthy(); + }) + const expectedConfig = { + active: false, + blackboard_base_url: 'https://www.test4.com', + display_name: 'displayName', + enterprise_customer: enterpriseId, + lms: BLACKBOARD_TYPE, + }; + expect(mockBlackboardPost).toHaveBeenCalledWith(expectedConfig); + expect(window.open).toHaveBeenCalled(); + expect(mockBlackboardFetch).toHaveBeenCalledWith(1); + }); + // TODO: Figure out how to mock existing data deeper to test + // - Authorizing an existing, edited config will call update config endpoint + // - Authorizing an existing config will not call update or create config endpoint + test('Blackboard config is activated after last step', async () => { + renderWithRouter(); + mockBlackboardUpdate.mockResolvedValue({ data: mockBlackboardResponseDataActive }); + + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); + }); + const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); + userEvent.click(blackboardCard); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Authorize connection to Blackboard')).toBeTruthy(); + }); + await act(async () => { + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Blackboard Base URL'), { + target: { value: '' }, + }); + }); + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + userEvent.click(authorizeButton); + await waitFor(() => { + expect(screen.queryByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeTruthy(); + }) + + const activateButton = screen.getByRole('button', { name: 'Activate' }); + userEvent.click(activateButton); + await waitFor(() => { + expect(screen.queryByText('Learning platform integration successfully submitted.')).toBeTruthy(); + }) + expect(mockBlackboardUpdate).toHaveBeenCalledWith({"active": true, "enterprise_customer": enterpriseId}, 1); + }); + test('Canvas properly authorizes', async () => { + renderWithRouter(); + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); + expect(screen.findByText(channelMapping[CANVAS_TYPE].displayName)); + }); + const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); + userEvent.click(canvasCard); + userEvent.click(screen.getByText('Next')); + + await waitFor(() => { + expect(screen.queryByText('Authorize connection to Canvas')).toBeTruthy(); + }); + await act(async () => { + fireEvent.change(screen.getByLabelText('API Client ID'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('API Client Secret'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Canvas Account Number'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Canvas Base URL'), { + target: { value: '' }, + }); + }); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); + + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + userEvent.click(authorizeButton); + await waitFor(() => { + expect(screen.queryByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeTruthy(); + }) + const expectedConfig = { + active: false, + canvas_base_url: 'https://www.test4.com', + canvas_account_id: '3', + client_id: 'test1', + client_secret: 'test2', + display_name: 'displayName', + enterprise_customer: enterpriseId, + lms: CANVAS_TYPE, + }; + expect(mockCanvasPost).toHaveBeenCalledWith(expectedConfig); + expect(window.open).toHaveBeenCalled(); + expect(mockCanvasFetch).toHaveBeenCalledWith(1); + }); + test('Canvas config is activated after last step', async () => { + renderWithRouter(); + mockCanvasUpdate.mockResolvedValue({ data: mockCanvasResponseDataActive }); + + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + await waitFor(() => { + userEvent.click(screen.getByText('New learning platform integration')); + }); + const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); + userEvent.click(canvasCard); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Authorize connection to Canvas')).toBeTruthy(); + }); + + await act(async () => { + fireEvent.change(screen.getByLabelText('API Client ID'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('API Client Secret'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Canvas Account Number'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Display Name'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('Canvas Base URL'), { + target: { value: '' }, + }); + }); + + userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); + userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + userEvent.click(authorizeButton); + await waitFor(() => { + expect(screen.queryByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeTruthy(); + }) + + const activateButton = screen.getByRole('button', { name: 'Activate' }); + userEvent.click(activateButton); + await waitFor(() => { + expect(screen.queryByText('Learning platform integration successfully submitted.')).toBeTruthy(); + }) + expect(mockCanvasUpdate).toHaveBeenCalledWith({"active": true, "enterprise_customer": enterpriseId}, 1); + }); +}); diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx index c022cbd330..71bf306552 100644 --- a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx @@ -74,7 +74,7 @@ function testBlackboardConfigSetup(formData) { formWorkflowConfig={BlackboardConfig({ enterpriseCustomerUuid: enterpriseId, onSubmit: mockSetExistingConfigFormData, - onClickCancel: mockOnClick, + handleCloseClick: mockOnClick, existingData: formData, existingConfigNames: [], channelMap: { @@ -141,7 +141,6 @@ describe("", () => { screen.getByLabelText("Blackboard Base URL"), "https://www.test4.com" ); - expect(authorizeButton).not.toBeDisabled(); }); test('it edits existing configs on submit', async () => { @@ -156,8 +155,8 @@ describe("", () => { userEvent.click(authorizeButton); - // await a change in button text from authorize to activate - await waitFor(() => expect(screen.findByRole('button', {name: 'Activate'}))) + // await authorization loading modal + await waitFor(() => expect(screen.queryByText('Please confirm authorization through Blackboard and return to this window once complete.'))); const expectedConfig = { active: true, @@ -180,9 +179,8 @@ describe("", () => { await waitFor(() => expect(authorizeButton).not.toBeDisabled()); userEvent.click(authorizeButton); - - // await a change in button text from authorize to activate - await waitFor(() => expect(screen.findByRole('button', {name: 'Activate'}))) + // await authorization loading modal + await waitFor(() => expect(screen.queryByText('Please confirm authorization through Blackboard and return to this window once complete.'))); const expectedConfig = { active: false, @@ -226,47 +224,8 @@ describe("", () => { expect(authorizeButton).not.toBeDisabled(); userEvent.click(authorizeButton); - // await a change in button text from authorize to activate - await waitFor(() => expect(authorizeButton).toBeDisabled()) - expect(window.open).toHaveBeenCalled(); - expect(mockFetch).toHaveBeenCalledWith(1); - }); - test('Authorizing an existing, edited config will call update config endpoint', async () => { - render(testBlackboardConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - act(() => { - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Blackboard Base URL'), { - target: { value: '' }, - }); - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); - - expect(authorizeButton).not.toBeDisabled(); - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByText('Authorization in progress')).toBeInTheDocument()); - expect(mockUpdate).toHaveBeenCalled(); - expect(window.open).toHaveBeenCalled(); - expect(mockFetch).toHaveBeenCalledWith(1); - await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - }); - test('Authorizing an existing config will not call update or create config endpoint', async () => { - render(testBlackboardConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - expect(authorizeButton).not.toBeDisabled(); - - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - expect(mockUpdate).not.toHaveBeenCalled(); + // await authorization loading modal + await waitFor(() => expect(screen.queryByText('Please confirm authorization through Blackboard and return to this window once complete.'))); expect(window.open).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalledWith(1); }); @@ -280,31 +239,4 @@ describe("", () => { expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); - test('it calls setExistingConfigFormData after authorization', async () => { - render(testBlackboardConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - act(() => { - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - expect(authorizeButton).not.toBeDisabled(); - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByText('Your Blackboard integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - - const activateButton = screen.getByRole('button', { name: 'Activate' }); - - userEvent.click(activateButton); - expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ - uuid: 'foobar', - id: 1, - displayName: 'display name', - blackboardBaseUrl: 'https://foobar.com', - active: false, - }); - }); }); diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx index 5e8e11f57c..4eb896075e 100644 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx @@ -79,7 +79,7 @@ function testCanvasConfigSetup(formData) { formWorkflowConfig={CanvasConfig({ enterpriseCustomerUuid: enterpriseId, onSubmit: mockSetExistingConfigFormData, - onClickCancel: mockOnClick, + handleCloseClick: mockOnClick, existingData: formData, existingConfigNames: [], channelMap: { @@ -164,66 +164,6 @@ describe("", () => { expect(authorizeButton).not.toBeDisabled(); }); - test('it edits existing configs on submit', async () => { - render(testCanvasConfigSetup(existingConfigData)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); - - expect(authorizeButton).not.toBeDisabled(); - - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument()); - - const expectedConfig = { - active: true, - id: 1, - refresh_token: "foobar", - canvas_base_url: 'https://www.test4.com', - canvas_account_id: '3', - client_id: 'test1', - client_secret: 'test2', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(mockUpdate).toHaveBeenCalledWith(expectedConfig, 1); - }); - test('it creates new configs on submit', async () => { - render(testCanvasConfigSetup(noExistingData)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - await clearForm(); - - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); - await waitFor(() => expect(authorizeButton).not.toBeDisabled()); - - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument()); - - const expectedConfig = { - active: false, - canvas_base_url: 'https://www.test4.com', - canvas_account_id: '3', - client_id: 'test1', - client_secret: 'test2', - display_name: 'displayName', - enterprise_customer: enterpriseId, - }; - expect(mockPost).toHaveBeenCalledWith(expectedConfig); - }); test('saves draft correctly', async () => { render(testCanvasConfigSetup(noExistingData)); const cancelButton = screen.getByRole('button', { name: 'Cancel' }); @@ -268,48 +208,8 @@ describe("", () => { expect(authorizeButton).not.toBeDisabled(); userEvent.click(authorizeButton); - // await a text change from 'Authorize' to 'Activate' - await waitFor(() => expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument()); - - expect(window.open).toHaveBeenCalled(); - expect(mockFetch).toHaveBeenCalledWith(1); - }); - test('Authorizing an existing, edited config will call update config endpoint', async () => { - render(testCanvasConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - act(() => { - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Canvas Base URL'), { - target: { value: '' }, - }); - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - - expect(authorizeButton).not.toBeDisabled(); - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByText('Authorization in progress')).toBeInTheDocument()); - expect(mockUpdate).toHaveBeenCalled(); - expect(window.open).toHaveBeenCalled(); - expect(mockFetch).toHaveBeenCalledWith(1); - await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - }); - test('Authorizing an existing config will not call update or create config endpoint', async () => { - render(testCanvasConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - expect(authorizeButton).not.toBeDisabled(); - - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - expect(mockUpdate).not.toHaveBeenCalled(); + // await authorization loading modal + await waitFor(() => expect(screen.queryByText('Please confirm authorization through Canvas and return to this window once complete.'))); expect(window.open).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalledWith(1); }); @@ -323,33 +223,4 @@ describe("", () => { expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); - test('it calls setExistingConfigFormData after authorization', async () => { - render(testCanvasConfigSetup(existingConfigDataNoAuth)); - const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); - - act(() => { - fireEvent.change(screen.getByLabelText('Display Name'), { - target: { value: '' }, - }); - }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - expect(authorizeButton).not.toBeDisabled(); - userEvent.click(authorizeButton); - - // Await a find by text in order to account for state changes in the button callback - await waitFor(() => expect(screen.getByText('Your Canvas integration has been successfully authorized and is ready to activate!')).toBeInTheDocument()); - const activateButton = screen.getByRole('button', { name: 'Activate' }); - - userEvent.click(activateButton); - expect(mockSetExistingConfigFormData).toHaveBeenCalledWith({ - uuid: 'foobar', - id: 1, - displayName: 'display name', - canvasBaseUrl: 'https://foobar.com', - canvasAccountId: 1, - clientId: 'wassap', - clientSecret: 'chewlikeyouhaveasecret', - active: false, - }); - }); }); diff --git a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx index 90cafdd3af..ee7bc1b3f7 100644 --- a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx @@ -44,7 +44,7 @@ function testCornerstoneConfigSetup(formData) { formWorkflowConfig={CornerstoneConfig({ enterpriseCustomerUuid: enterpriseId, onSubmit: mockSetExistingConfigFormData, - onClickCancel: mockOnClick, + handleCloseClick: mockOnClick, existingData: formData, existingConfigNames: [], channelMap: { diff --git a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx index 61f7fe3d06..b0887291f9 100644 --- a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx @@ -68,7 +68,7 @@ function testDegreedConfigSetup(formData) { formWorkflowConfig={DegreedConfig({ enterpriseCustomerUuid: enterpriseId, onSubmit: mockSetExistingConfigFormData, - onClickCancel: mockOnClick, + handleCloseClick: mockOnClick, existingData: formData, existingConfigNames: [], channelMap: { diff --git a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx index 64b7bf392c..094a3b5aaa 100644 --- a/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/LmsConfigPage.test.jsx @@ -90,7 +90,7 @@ describe('', () => { expect(screen.queryByText('At least one active Single Sign-On (SSO) integration is required to configure a new learning platform integration.')).toBeFalsy(); expect(screen.queryByText('New learning platform integration')).toBeTruthy(); userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.queryByText('New configurations')).toBeTruthy(); + expect(screen.queryByText('Select the LMS or LXP you want to integrate with edX For Business.')).toBeTruthy(); }); }); @@ -98,6 +98,7 @@ describe('', () => { renderWithRouter(); await waitFor(() => { userEvent.click(screen.getByText('New learning platform integration')); + expect(screen.queryByText('Select the LMS or LXP you want to integrate with edX For Business.')).toBeTruthy(); expect(screen.queryByText(channelMapping[BLACKBOARD_TYPE].displayName)).toBeTruthy(); expect(screen.queryByText(channelMapping[CANVAS_TYPE].displayName)).toBeTruthy(); expect(screen.queryByText(channelMapping[CORNERSTONE_TYPE].displayName)).toBeTruthy(); @@ -116,7 +117,10 @@ describe('', () => { }); const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); userEvent.click(blackboardCard); - expect(screen.queryByText('Authorize connection to Blackboard')).toBeTruthy(); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Authorize connection to Blackboard')).toBeTruthy(); + }); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -137,7 +141,10 @@ describe('', () => { }); const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); userEvent.click(canvasCard); - expect(screen.queryByText('Authorize connection to Canvas')).toBeTruthy(); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Authorize connection to Canvas')).toBeTruthy(); + }); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -158,7 +165,10 @@ describe('', () => { }); const cornerstoneCard = screen.getByText(channelMapping[CORNERSTONE_TYPE].displayName); fireEvent.click(cornerstoneCard); - expect(screen.queryByText('Enable connection to Cornerstone')).toBeTruthy(); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Enable connection to Cornerstone')).toBeTruthy(); + }); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -179,7 +189,10 @@ describe('', () => { }); const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); fireEvent.click(degreedCard); - expect(screen.queryByText('Enable connection to Degreed')).toBeTruthy(); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Enable connection to Degreed')).toBeTruthy(); + }); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -200,7 +213,10 @@ describe('', () => { }); const moodleCard = screen.getByText(channelMapping[MOODLE_TYPE].displayName); fireEvent.click(moodleCard); - expect(screen.queryByText('Enable connection to Moodle')).toBeTruthy(); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Enable connection to Moodle')).toBeTruthy(); + }); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -221,7 +237,10 @@ describe('', () => { }); const moodleCard = screen.getByText(channelMapping[SAP_TYPE].displayName); fireEvent.click(moodleCard); - expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeTruthy(); + userEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeTruthy(); + }); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: 'displayName' }, }); @@ -232,102 +251,6 @@ describe('', () => { userEvent.click(exitButton); expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeFalsy(); }); - test('No action Moodle card cancel flow', async () => { - renderWithRouter(); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => { - userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.findByText(channelMapping[MOODLE_TYPE].displayName)); - }); - const moodleCard = screen.getByText(channelMapping[MOODLE_TYPE].displayName); - await waitFor(() => fireEvent.click(moodleCard)); - expect(screen.queryByText('Enable connection to Moodle')).toBeTruthy(); - const cancelButton = screen.getByText('Cancel'); - await waitFor(() => userEvent.click(cancelButton)); - expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Enable connection to Moodle')).toBeFalsy(); - }); - test('No action Degreed card cancel flow', async () => { - renderWithRouter(); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => { - userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.findByText(channelMapping[DEGREED2_TYPE].displayName)); - }); - const degreedCard = screen.getByText(channelMapping[DEGREED2_TYPE].displayName); - await waitFor(() => fireEvent.click(degreedCard)); - expect(screen.queryByText('Enable connection to Degreed')).toBeTruthy(); - const cancelButton = screen.getByText('Cancel'); - await waitFor(() => userEvent.click(cancelButton)); - expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Enable connection to Degreed')).toBeFalsy(); - }); - test('No action Cornerstone card cancel flow', async () => { - renderWithRouter(); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => { - userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.findByText(channelMapping[CORNERSTONE_TYPE].displayName)); - }); - const cornerstoneCard = screen.getByText(channelMapping[CORNERSTONE_TYPE].displayName); - await waitFor(() => fireEvent.click(cornerstoneCard)); - expect(screen.queryByText('Enable connection to Cornerstone')).toBeTruthy(); - const cancelButton = screen.getByText('Cancel'); - await waitFor(() => userEvent.click(cancelButton)); - expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Enable connection to Cornerstone')).toBeFalsy(); - }); - test('No action Canvas card cancel flow', async () => { - renderWithRouter(); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => { - userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.findByText(channelMapping[CANVAS_TYPE].displayName)); - }); - const canvasCard = screen.getByText(channelMapping[CANVAS_TYPE].displayName); - await waitFor(() => userEvent.click(canvasCard)); - expect(screen.queryByText('Authorize connection to Canvas')).toBeTruthy(); - const cancelButton = screen.getByText('Cancel'); - await waitFor(() => userEvent.click(cancelButton)); - expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Authorize connection to Canvas')).toBeFalsy(); - }); - test('No action Blackboard card cancel flow', async () => { - renderWithRouter(); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => { - userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.findByText(channelMapping[BLACKBOARD_TYPE].displayName)); - }); - const blackboardCard = screen.getByText(channelMapping[BLACKBOARD_TYPE].displayName); - await waitFor(() => userEvent.click(blackboardCard)); - expect(screen.queryByText('Authorize connection to Blackboard')).toBeTruthy(); - const cancelButton = screen.getByText('Cancel'); - await waitFor(() => userEvent.click(cancelButton)); - expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Authorize connection to Blackboard')).toBeFalsy(); - }); - test('No action SAP card cancel flow', async () => { - renderWithRouter(); - const skeleton = screen.getAllByTestId('skeleton'); - await waitForElementToBeRemoved(skeleton); - await waitFor(() => { - userEvent.click(screen.getByText('New learning platform integration')); - expect(screen.findByText(channelMapping[SAP_TYPE].displayName)); - }); - const sapCard = screen.getByText(channelMapping[SAP_TYPE].displayName); - await waitFor(() => fireEvent.click(sapCard)); - expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeTruthy(); - const cancelButton = screen.getByText('Cancel'); - await waitFor(() => userEvent.click(cancelButton)); - expect(screen.queryByText('Exit without saving')).toBeFalsy(); - expect(screen.queryByText('Enable connection to SAP Success Factors')).toBeFalsy(); - }); test('Expected behavior when customer has no IDP configured', async () => { const history = createMemoryHistory(); const samlConfigurationScreenEnabled = true; diff --git a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx index 168fa51480..66f6d39b66 100644 --- a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx @@ -11,11 +11,7 @@ import '@testing-library/jest-dom/extend-expect'; // @ts-ignore import MoodleConfig from '../LMSConfigs/Moodle/MoodleConfig.tsx'; -import { - INVALID_LINK, - INVALID_MOODLE_VERIFICATION, - INVALID_NAME, -} from '../../data/constants'; +import { INVALID_LINK, INVALID_MOODLE_VERIFICATION, INVALID_NAME } from '../../data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; // @ts-ignore import FormContextWrapper from '../../../forms/FormContextWrapper.tsx'; @@ -76,7 +72,7 @@ function testMoodleConfigSetup(formData) { formWorkflowConfig={MoodleConfig({ enterpriseCustomerUuid: enterpriseId, onSubmit: mockSetExistingConfigFormData, - onClickCancel: mockOnClick, + handleCloseClick: mockOnClick, existingData: formData, existingConfigNames: [], channelMap: { @@ -180,6 +176,7 @@ describe('', () => { userEvent.type(screen.getByLabelText('Password'), 'password123'); await waitFor(() => expect(enableButton).not.toBeDisabled()); + await act(async () => { userEvent.click(enableButton); }); userEvent.click(enableButton); @@ -225,7 +222,6 @@ describe('', () => { password: 'password123', enterprise_customer: enterpriseId, }; - expect(mockPost).toHaveBeenCalledWith(expectedConfig); }); test('validates poorly formatted existing data on load', async () => { diff --git a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx index 9dacfa9334..5d7a8b81af 100644 --- a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx @@ -76,7 +76,7 @@ function testSAPConfigSetup(formData) { formWorkflowConfig={SAPConfig({ enterpriseCustomerUuid: enterpriseId, onSubmit: mockSetExistingConfigFormData, - onClickCancel: mockOnClick, + handleCloseClick: mockOnClick, existingData: formData, existingConfigNames: [], channelMap: { diff --git a/src/components/settings/data/constants.js b/src/components/settings/data/constants.js index 54c2318429..3e080b3b27 100644 --- a/src/components/settings/data/constants.js +++ b/src/components/settings/data/constants.js @@ -31,6 +31,8 @@ export const DEGREED2_TYPE = 'DEGREED2'; export const MOODLE_TYPE = 'MOODLE'; export const SAP_TYPE = 'SAP'; +export const LMS_KEYS = [BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE]; + export const INVALID_LINK = 'Link must be properly formatted and start with http or https'; export const INVALID_NAME = 'Display name must be unique and cannot be over 20 characters'; export const INVALID_LENGTH = 'Max length must be a number, but cannot be over 2 weeks (1210000 seconds)'; diff --git a/src/components/settings/settings.scss b/src/components/settings/settings.scss index 42c26adfec..2b32b81874 100644 --- a/src/components/settings/settings.scss +++ b/src/components/settings/settings.scss @@ -6,7 +6,7 @@ .lms-icon { max-width: 35px; - height: 100%; + max-height: 35px; } .lms-card-hover { @@ -139,6 +139,14 @@ padding: 1rem 9rem !important; .error-icon { color: $danger-600; - } } + +.select-lms-card { + height: 6rem; + display: flex; + align-items: center; + justify-content: center; + width: 22rem; + padding-top: 1rem; +} From 25373f2239120a5895f2bfe959fcd202b91298c1 Mon Sep 17 00:00:00 2001 From: irfanuddinahmad <34648393+irfanuddinahmad@users.noreply.github.com> Date: Mon, 8 May 2023 17:44:08 +0500 Subject: [PATCH 68/73] feat: removed A/B experiment for subscription management with LPR (#986) Co-authored-by: IrfanUddinAhmad --- .../Admin/__snapshots__/Admin.test.jsx.snap | 234 ++++++++++++++++++ src/components/Admin/index.jsx | 24 +- .../licenses/LicenseManagementTable/index.jsx | 10 - src/index.jsx | 2 - 4 files changed, 241 insertions(+), 29 deletions(-) diff --git a/src/components/Admin/__snapshots__/Admin.test.jsx.snap b/src/components/Admin/__snapshots__/Admin.test.jsx.snap index a1402acce9..082fe5233c 100644 --- a/src/components/Admin/__snapshots__/Admin.test.jsx.snap +++ b/src/components/Admin/__snapshots__/Admin.test.jsx.snap @@ -99,6 +99,24 @@ exports[` renders correctly calls fetchDashboardAnalytics prop 1`] = `
+
+
+
+ Loading... + + Loading + +
+
+
@@ -619,6 +637,24 @@ exports[` renders correctly with dashboard analytics data renders # cou
+
+
+
+ Loading... + + Loading + +
+
+
@@ -1180,6 +1216,24 @@ exports[` renders correctly with dashboard analytics data renders # of
+
+
+
+ Loading... + + Loading + +
+
+
@@ -1741,6 +1795,24 @@ exports[` renders correctly with dashboard analytics data renders # of
+
+
+
+ Loading... + + Loading + +
+
+
@@ -2306,6 +2378,24 @@ exports[` renders correctly with dashboard analytics data renders colla
+
+
+
+ Loading... + + Loading + +
+
+
@@ -3026,6 +3116,24 @@ exports[` renders correctly with dashboard analytics data renders full
+
+
+
+ Loading... + + Loading + +
+
+
@@ -3746,6 +3854,24 @@ exports[` renders correctly with dashboard analytics data renders inact
+
+
+
+ Loading... + + Loading + +
+
+
@@ -4311,6 +4437,24 @@ exports[` renders correctly with dashboard analytics data renders inact
+
+
+
+ Loading... + + Loading + +
+
+
@@ -4876,6 +5020,24 @@ exports[` renders correctly with dashboard analytics data renders learn
+
+
+
+ Loading... + + Loading + +
+
+
@@ -5441,6 +5603,24 @@ exports[` renders correctly with dashboard analytics data renders regis
+
+
+
+ Loading... + + Loading + +
+
+
@@ -6002,6 +6182,24 @@ exports[` renders correctly with dashboard analytics data renders top a
+
+
+
+ Loading... + + Loading + +
+
+
@@ -6168,6 +6366,24 @@ exports[` renders correctly with error state 1`] = `
+
+
+
+ Loading... + + Loading + +
+
+
@@ -6313,6 +6529,24 @@ exports[` renders correctly with loading state 1`] = `
+
+
+
+ Loading... + + Loading + +
+
+
diff --git a/src/components/Admin/index.jsx b/src/components/Admin/index.jsx index 7157b9b425..a1d9ef7b85 100644 --- a/src/components/Admin/index.jsx +++ b/src/components/Admin/index.jsx @@ -4,7 +4,6 @@ import Helmet from 'react-helmet'; import { Icon } from '@edx/paragon'; import { Link } from 'react-router-dom'; -import { getConfig } from '@edx/frontend-platform/config'; import Hero from '../Hero'; import StatusAlert from '../StatusAlert'; import EnrollmentsTable from '../EnrollmentsTable'; @@ -25,7 +24,6 @@ import { formatTimestamp } from '../../utils'; import AdminCardsSkeleton from './AdminCardsSkeleton'; import { SubscriptionData } from '../subscriptions'; import EmbeddedSubscription from './EmbeddedSubscription'; -import { isExperimentVariant } from '../../optimizely'; class Admin extends React.Component { componentDidMount() { @@ -297,11 +295,6 @@ class Admin extends React.Component { searchDateQuery: queryParams.get('search_start_date') || '', }; - const config = getConfig(); - - // Only users buckted in `Variation 1` can see the Subscription Management UI on LPR. - const isExperimentVariation1 = isExperimentVariant(config.EXPERIMENT_1_ID, config.EXPERIMENT_1_VARIANT_1_ID); - return (
{!loading && !error && !this.hasAnalyticsData() ? : ( @@ -325,16 +318,13 @@ class Admin extends React.Component { )}
- {isExperimentVariation1 - && ( -
-
- - - -
-
- )} +
+
+ + + +
+
diff --git a/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx b/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx index de5794e6a2..71e2dd74bc 100644 --- a/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx +++ b/src/components/subscriptions/licenses/LicenseManagementTable/index.jsx @@ -12,7 +12,6 @@ import { import debounce from 'lodash.debounce'; import moment from 'moment'; import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; -import { getConfig } from '@edx/frontend-platform/config'; import { SubscriptionContext } from '../../SubscriptionData'; import { SubscriptionDetailContext, defaultStatusFilter } from '../../SubscriptionDetailContextProvider'; @@ -29,7 +28,6 @@ import RevokeBulkAction from './bulk-actions/RevokeBulkAction'; import LicenseManagementTableActionColumn from './LicenseManagementTableActionColumn'; import LicenseManagementUserBadge from './LicenseManagementUserBadge'; import { SUBSCRIPTION_TABLE_EVENTS } from '../../../../eventTracking'; -import { pushEvent, EVENTS, isExperimentActive } from '../../../../optimizely'; const userRecentAction = (user) => { switch (user.status) { @@ -61,8 +59,6 @@ const LicenseManagementTable = () => { const { width } = useWindowSize(); const showFiltersInSidebar = useMemo(() => width > breakpoints.medium.maxWidth, [width]); - const config = getConfig(); - const { forceRefresh: forceRefreshSubscription, } = useContext(SubscriptionContext); @@ -176,18 +172,12 @@ const LicenseManagementTable = () => { // Successful action modal callback const onRemindSuccess = () => { - if (isExperimentActive(config.EXPERIMENT_1_ID)) { - pushEvent(EVENTS.SUBSCRIPTION_LICENSE_REMIND, { enterpriseUUID: subscription.enterpriseCustomerUuid }); - } // Refresh users to get updated lastRemindDate forceRefreshUsers(); setToastMessage('Users successfully reminded'); setShowToast(true); }; const onRevokeSuccess = () => { - if (isExperimentActive(config.EXPERIMENT_1_ID)) { - pushEvent(EVENTS.SUBSCRIPTION_LICENSE_REVOKE, { enterpriseUUID: subscription.enterpriseCustomerUuid }); - } // Refresh subscription and user data to get updated revoke count and revoked list of users forceRefreshSubscription(); forceRefreshDetailView(); diff --git a/src/index.jsx b/src/index.jsx index 37e837333a..54443b23a6 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -40,8 +40,6 @@ initialize({ FEATURE_LEARNER_CREDIT_MANAGEMENT: process.env.FEATURE_LEARNER_CREDIT_MANAGEMENT || hasFeatureFlagEnabled('LEARNER_CREDIT_MANAGEMENT') || null, FEATURE_CONTENT_HIGHLIGHTS: process.env.FEATURE_CONTENT_HIGHLIGHTS || hasFeatureFlagEnabled('CONTENT_HIGHLIGHTS') || null, ENTERPRISE_SUPPORT_PROGRAM_OPTIMIZATION_URL: process.env.ENTERPRISE_SUPPORT_PROGRAM_OPTIMIZATION_URL || null, - EXPERIMENT_1_ID: process.env.EXPERIMENT_1_ID || null, - EXPERIMENT_1_VARIANT_1_ID: process.env.EXPERIMENT_1_VARIANT_1_ID || null, }); }, }, From 2add15550756bba197ef0e375563c787df7004fe Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Thu, 11 May 2023 18:12:43 -0600 Subject: [PATCH 69/73] fix: finishing touches to sync history (#987) * fix: finishing touches to sync history * fix: remove comments * fix: PR requests * fix: adding badge --- .../ErrorReporting/SyncHistory.jsx | 60 +++++++++++++++---- .../tests/ErrorReporting.test.jsx | 43 ++++++++++++- .../settings/SettingsLMSTab/index.jsx | 41 ++++++++----- src/components/settings/settings.scss | 7 +++ src/utils.js | 20 ++++--- 5 files changed, 134 insertions(+), 37 deletions(-) diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx index 3a3d14a9ba..11d2707ea6 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/SyncHistory.jsx @@ -1,13 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { - ActionRow, AlertModal, Breadcrumb, Button, Card, Icon, Image, Skeleton, Toast, useToggle, + ActionRow, AlertModal, Badge, Breadcrumb, Button, Card, Hyperlink, + Icon, Image, Skeleton, Toast, useToggle, } from '@edx/paragon'; import { CheckCircle, Error, Sync } from '@edx/paragon/icons'; import { getStatus } from '../utils'; import { getTimeAgo } from './utils'; import handleErrors from '../../utils'; +import { channelMapping, formatTimestamp } from '../../../../utils'; import ConfigError from '../../ConfigError'; import LmsApiService from '../../../../data/services/LmsApiService'; @@ -15,17 +17,24 @@ import { ACTIVATE_TOAST_MESSAGE, BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, errorToggleModalText, INACTIVATE_TOAST_MESSAGE, MOODLE_TYPE, SAP_TYPE, } from '../../data/constants'; - -import { channelMapping } from '../../../../utils'; import ErrorReportingTable from './ErrorReportingTable'; const SyncHistory = () => { + // the simple redirect is used for going back to the lms page const vars = (window.location.pathname).split('lms/'); const redirectPath = `${vars[0]}lms/`; const configInfo = vars[1].split('/'); const configChannel = configInfo[0]; const configId = configInfo[1]; + // the redirect with params is used when editing an existing config + let editConfigUrl = `${((window.location.href).split('lms/'))[0]}lms/?`; + const queryParams = new URLSearchParams({ + lms: configChannel, + id: configId, + }); + editConfigUrl += queryParams.toString(); + const [config, setConfig] = useState(); const [errorModalText, setErrorModalText] = useState(); const [errorIsOpen, openError, closeError] = useToggle(false); @@ -33,7 +42,23 @@ const SyncHistory = () => { const [toastMessage, setToastMessage] = useState(null); const [reloadPage, setReloadPage] = useState(false); - const getActiveStatus = status => (status === 'Active' ? `${status} •` : ''); + const lmsStatus = useMemo(() => { + if (config) { return getStatus(config); } + return null; + }, [config]); + + const getSubheaders = () => { + const status = (lmsStatus === 'Active' ? `${lmsStatus}` : null); + const lmsChannel = channelMapping[config.channelCode].displayName; + const modified = `Last modified on ${formatTimestamp({ timestamp: config.lastModifiedAt })}`; + return ( + + {status && ({status}•)} + {lmsChannel}• + {modified} + + ); + }; useEffect(() => { const fetchData = async () => { @@ -94,8 +119,8 @@ const SyncHistory = () => { if (input !== null) { setReloadPage(true); setToastMessage(input); - // if configuration is being deleted } else { + // if configuration is being deleted window.location.href = redirectPath; } }; @@ -120,19 +145,23 @@ const SyncHistory = () => { }; const createActionRow = () => { - if (getStatus(config) === 'Active') { + if (lmsStatus === 'Active') { return ( - {/* */} + + + ); } - if (getStatus(config) === 'Inactive') { + if (lmsStatus === 'Inactive') { return ( - {/* */} + + + ); @@ -140,7 +169,9 @@ const SyncHistory = () => { return ( // if incomplete - {/* */} + + + ); }; @@ -204,9 +235,12 @@ const SyncHistory = () => { src={channelMapping[configChannel].icon} /> {config.displayName} + {lmsStatus !== 'Active' && ( + {lmsStatus} + )} -

- {getActiveStatus(getStatus(config))} {config.channelCode} +

+ {getSubheaders()}

{getLastSync()} diff --git a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx index 4db1165e77..eda33da9b6 100644 --- a/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx +++ b/src/components/settings/SettingsLMSTab/ErrorReporting/tests/ErrorReporting.test.jsx @@ -33,6 +33,25 @@ const configData = { lastSyncErroredAt: null, lastContentSyncErroredAt: null, lastLearnerSyncErroredAt: null, + lastModifiedAt: '2023-05-05T14:51:53.473144Z', + }, +}; + +const configDataDisabled = { + data: { + channelCode: 'BLACKBOARD', + id: 1, + isValid: [{ missing: [] }, { incorrect: [] }], + active: false, + displayName: 'foobar', + enterpriseCustomer: enterpriseCustomerUuid, + lastSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastContentSyncAttemptedAt: '2022-11-22T20:59:56Z', + lastLearnerSyncAttemptedAt: null, + lastSyncErroredAt: null, + lastContentSyncErroredAt: null, + lastLearnerSyncErroredAt: null, + lastModifiedAt: '2023-05-05T14:51:53.473144Z', }, }; @@ -214,10 +233,12 @@ describe('', () => { administrator: true, }); features.FEATURE_INTEGRATION_REPORTING = true; - const url = 'http://dummy.com/test-enterprise/admin/settings/lms'; + const baseUrl = 'http://dummy.com'; + const pathName = '/test-enterprise/admin/settings/lms'; Object.defineProperty(window, 'location', { value: { - pathname: `${url}/${configData.data.channelCode}/${configData.data.id}`, + pathname: `${pathName}/${configData.data.channelCode}/${configData.data.id}`, + href: `${baseUrl}${pathName}/${configData.data.channelCode}/${configData.data.id}`, }, writable: true, }); @@ -239,6 +260,8 @@ describe('', () => { await waitForElementToBeRemoved(skeleton); expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); expect(screen.getByText('Disable')).toBeInTheDocument(); + expect(screen.getByText('Configure')).toBeInTheDocument(); + expect(screen.getByText('Last modified on May 5, 2023')).toBeInTheDocument(); expect(screen.getByText('Last sync:')).toBeInTheDocument(); await waitFor(() => expect(screen.getByText('Course key')).toBeInTheDocument()); @@ -247,6 +270,22 @@ describe('', () => { expect(screen.getAllByText('No results found')).toHaveLength(2); }); + it('check disabled config', async () => { + const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); + mockFetchSingleConfig.mockResolvedValue(configDataDisabled); + render( + + + , + ); + + const skeleton = screen.getAllByTestId('skeleton'); + await waitForElementToBeRemoved(skeleton); + expect(mockFetchSingleConfig).toHaveBeenCalledWith('1'); + expect(screen.getByText('Enable')).toBeInTheDocument(); + expect(screen.getByText('Configure')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); it('populates with learner sync data', async () => { const mockFetchSingleConfig = jest.spyOn(LmsApiService, 'fetchSingleBlackboardConfig'); mockFetchSingleConfig.mockResolvedValue(configData); diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index 3ace60b582..4673d415d7 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -1,14 +1,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; - +import { camelCaseObject } from '@edx/frontend-platform/utils'; import { Alert, Button, Hyperlink, Toast, Skeleton, useToggle, } from '@edx/paragon'; import { Add, Info } from '@edx/paragon/icons'; import { logError } from '@edx/frontend-platform/logging'; -import { camelCaseDictArray } from '../../../utils'; +import { camelCaseDictArray, channelMapping } from '../../../utils'; import LMSConfigPage from './LMSConfigPage'; import ExistingLMSCardDeck from './ExistingLMSCardDeck'; import NoConfigCard from './NoConfigCard'; @@ -48,12 +48,11 @@ const SettingsLMSTab = ({ const { dispatch } = useFormContext(); // onClick function for existing config cards' edit action - const editExistingConfig = (configData, configType) => { + const editExistingConfig = useCallback((configData, configType) => { setConfigsLoading(false); // Setting this allows us to skip the selection step in the stepper dispatch?.setFormFieldAction({ fieldId: 'lms', value: configData.channelCode }); setLmsType(configData.channelCode); - openLmsStepper(); // Set the form data to the card's associated config data setExistingConfigFormData(configData); // Set the config type to the card's type @@ -62,7 +61,21 @@ const SettingsLMSTab = ({ setShowNoConfigCard(false); // Since the user is editing, hide the existing config cards setConfigsExist(false); - }; + openLmsStepper(); + }, [dispatch, openLmsStepper]); + + // we pass in params (configId and lmsType) from SyncHistory when user wants to edit that config + useEffect(() => { + const query = new URLSearchParams(window.location.search); + const fetchData = async () => channelMapping[query.get('lms')].fetch(query.get('id')); + fetchData() + .then((response) => { + editExistingConfig(camelCaseObject(response.data), query.get('id')); + }) + .catch((err) => { + logError(err); + }); + }, [editExistingConfig]); const fetchExistingConfigs = useCallback(() => { const options = { enterprise_customer: enterpriseId }; @@ -140,15 +153,15 @@ const SettingsLMSTab = ({
{!configsLoading && !config && ( - + )}
diff --git a/src/components/settings/settings.scss b/src/components/settings/settings.scss index 2b32b81874..55d8dfa37b 100644 --- a/src/components/settings/settings.scss +++ b/src/components/settings/settings.scss @@ -150,3 +150,10 @@ width: 22rem; padding-top: 1rem; } + +.card-status-badge { + margin-left: 0.5rem; + vertical-align: middle; + font-size: 1rem; + font-weight: 500; +} diff --git a/src/utils.js b/src/utils.js index 11e955607b..8cd7e6b59e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -303,47 +303,51 @@ const channelMapping = { [BLACKBOARD_TYPE]: { displayName: 'Blackboard', icon: BlackboardIcon, - post: LmsApiService.postNewBlackboardConfig, - update: LmsApiService.updateBlackboardConfig, delete: LmsApiService.deleteBlackboardConfig, fetch: LmsApiService.fetchSingleBlackboardConfig, fetchGlobal: LmsApiService.fetchBlackboardGlobalConfig, + post: LmsApiService.postNewBlackboardConfig, + update: LmsApiService.updateBlackboardConfig, }, [CANVAS_TYPE]: { displayName: 'Canvas', icon: CanvasIcon, - post: LmsApiService.postNewCanvasConfig, - update: LmsApiService.updateCanvasConfig, delete: LmsApiService.deleteCanvasConfig, fetch: LmsApiService.fetchSingleCanvasConfig, + post: LmsApiService.postNewCanvasConfig, + update: LmsApiService.updateCanvasConfig, }, [CORNERSTONE_TYPE]: { displayName: 'Cornerstone', icon: CornerstoneIcon, + delete: LmsApiService.deleteCornerstoneConfig, + fetch: LmsApiService.fetchSingleCornerstoneConfig, post: LmsApiService.postNewCornerstoneConfig, update: LmsApiService.updateCornerstoneConfig, - delete: LmsApiService.deleteCornerstoneConfig, }, [DEGREED2_TYPE]: { displayName: 'Degreed', icon: DegreedIcon, + delete: LmsApiService.deleteDegreed2Config, + fetch: LmsApiService.fetchSingleDegreed2Config, post: LmsApiService.postNewDegreed2Config, update: LmsApiService.updateDegreed2Config, - delete: LmsApiService.deleteDegreed2Config, }, [MOODLE_TYPE]: { displayName: 'Moodle', icon: MoodleIcon, + delete: LmsApiService.deleteMoodleConfig, + fetch: LmsApiService.fetchSingleMoodleConfig, post: LmsApiService.postNewMoodleConfig, update: LmsApiService.updateMoodleConfig, - delete: LmsApiService.deleteMoodleConfig, }, [SAP_TYPE]: { displayName: 'SAP Success Factors', icon: SAPIcon, + delete: LmsApiService.deleteSuccessFactorsConfig, + fetch: LmsApiService.fetchSingleSuccessFactorsConfig, post: LmsApiService.postNewSuccessFactorsConfig, update: LmsApiService.updateSuccessFactorsConfig, - delete: LmsApiService.deleteSuccessFactorsConfig, }, }; From 3400b018acce5838f6230cbc26beec635fa1b16c Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Wed, 24 May 2023 15:46:38 -0600 Subject: [PATCH 70/73] fix: bug bash fixes (#989) * fix: bug bash fixes * fix: fixing displayname bug * fix: fixing displayname bug * fix: timeout bug --- .../settings/SettingsLMSTab/LMSConfigPage.jsx | 3 +- .../Blackboard/BlackboardConfig.tsx | 8 ++- .../BlackboardConfigAuthorizePage.tsx | 26 +++++++- .../LMSConfigs/Canvas/CanvasConfig.tsx | 7 ++- .../Canvas/CanvasConfigAuthorizePage.tsx | 28 +++++++-- .../Cornerstone/CornerstoneConfig.tsx | 2 +- .../LMSConfigs/Degreed/DegreedConfig.tsx | 6 +- .../Degreed/DegreedConfigEnablePage.tsx | 12 ++-- .../LMSConfigs/Moodle/MoodleConfig.tsx | 6 +- .../Moodle/MoodleConfigEnablePage.tsx | 10 ++-- .../LMSConfigs/SAP/SAPConfig.tsx | 26 ++++---- .../LMSConfigs/SAP/SAPConfigEnablePage.tsx | 56 ++++++++--------- .../SettingsLMSTab/LMSConfigs/utils.tsx | 12 ++-- .../settings/SettingsLMSTab/index.jsx | 3 +- .../tests/BlackboardConfig.test.tsx | 7 +++ .../tests/CanvasConfig.test.tsx | 16 ++++- .../tests/CornerstoneConfig.test.jsx | 4 ++ .../tests/DegreedConfig.test.tsx | 20 +++++-- .../tests/MoodleConfig.test.jsx | 14 +++-- .../SettingsLMSTab/tests/SAPConfig.test.jsx | 60 +++++++++++-------- .../settings/SettingsLMSTab/utils.js | 2 +- 21 files changed, 211 insertions(+), 117 deletions(-) diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 30f68898be..907f2e3477 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -1,3 +1,4 @@ +import _ from 'lodash'; import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; @@ -27,7 +28,7 @@ const LMSConfigPage = ({ enterpriseCustomerUuid, onSubmit: setExistingConfigFormData, handleCloseClick: handleCloseWorkflow, - existingData: existingConfigFormData, + existingData: _.cloneDeep(existingConfigFormData), existingConfigNames: existingConfigs, channelMap, lmsType, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx index f32e079181..2b7104f91e 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx @@ -1,3 +1,5 @@ +import { useEffect } from "react"; +import _ from 'lodash'; import { snakeCaseDict } from "../../../../../utils"; import { BLACKBOARD_TYPE, @@ -112,11 +114,12 @@ export const BlackboardFormConfig = ({ const activatePage = () => ConfigActivatePage(BLACKBOARD_TYPE); + const steps: FormWorkflowStep[] = [ { index: 1, formComponent: BlackboardConfigAuthorizePage, - validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), + validations: validations.concat([checkForDuplicateNames(existingConfigNames)]), stepName: "Authorize", saveChanges, nextButtonConfig: (formFields: BlackboardConfigCamelCase) => { @@ -125,7 +128,8 @@ export const BlackboardFormConfig = ({ opensNewWindow: false, onClick: handleSubmit, }; - if (!formFields.refreshToken) { + // if they've never authorized it or if they've changed the form + if (!formFields.refreshToken || !_.isEqual(existingData, formFields)) { config = { ...config, ...{ diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx index 4ffc5ecc3c..ca8dd890a4 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Alert, Container, Form, Image } from "@edx/paragon"; import { Info } from "@edx/paragon/icons"; @@ -43,7 +43,13 @@ export const validations: FormFieldValidation[] = [ // Settings page of Blackboard LMS config workflow const BlackboardConfigAuthorizePage = () => { - const { dispatch, stateMap } = useFormContext(); + const { formFields, dispatch, stateMap } = useFormContext(); + const [isExisting, setIsExisting] = useState(false); + useEffect(() => { + if (formFields?.id) { + setIsExisting(true); + } + }, []); return ( @@ -57,12 +63,20 @@ const BlackboardConfigAuthorizePage = () => {
{stateMap?.[LMS_AUTHORIZATION_FAILED] && ( - +

Enablement failed

We were unable to enable your Blackboard integration. Please try again or contact enterprise customer support.
)} + {isExisting && ( + +

Form updates require reauthorization

+ Your authorization is currently complete. By updating the form below, + reauthorization will be required and advancing to the next step will + open a new window to complete the process in Blackboard. Return to this window + following reauthorization to finish reconfiguring your integration. +
)} { floatingLabel="Blackboard Base URL" /> + +

Authorization in Blackboard required to complete configuration

+ Advancing to the next step will open a new window to complete the authorization + process in Blackboard. Return to this window following authorization to finish configuring + your new integration. +
diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx index 6170b5efbb..6aa09b5917 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx @@ -1,3 +1,5 @@ +import { useState, useEffect } from 'react'; +import _ from 'lodash'; import { snakeCaseDict } from "../../../../../utils"; import { CANVAS_TYPE, LMS_CONFIG_OAUTH_POLLING_INTERVAL, LMS_CONFIG_OAUTH_POLLING_TIMEOUT, @@ -113,7 +115,7 @@ export const CanvasFormConfig = ({ { index: 1, formComponent: CanvasConfigAuthorizePage, - validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), + validations: validations.concat([checkForDuplicateNames(existingConfigNames)]), stepName: "Authorize", saveChanges, nextButtonConfig: (formFields: CanvasConfigCamelCase) => { @@ -122,7 +124,8 @@ export const CanvasFormConfig = ({ opensNewWindow: false, onClick: handleSubmit, }; - if (!formFields.refreshToken) { + // if they've never authorized it or if they've changed the form + if (!formFields.refreshToken || !_.isEqual(existingData, formFields)) { config = { ...config, ...{ diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx index fc6376b404..5d120b9ce7 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Alert, Container, Form, Image } from "@edx/paragon"; import { Info } from "@edx/paragon/icons"; @@ -83,7 +83,14 @@ export const validations: FormFieldValidation[] = [ // Settings page of Canvas LMS config workflow const CanvasConfigAuthorizePage = () => { - const { dispatch, stateMap } = useFormContext(); + const { formFields, dispatch, stateMap } = useFormContext(); + const [isExisting, setIsExisting] = useState(false); + useEffect(() => { + if (formFields?.id) { + setIsExisting(true); + } + }, []); + return ( @@ -97,13 +104,20 @@ const CanvasConfigAuthorizePage = () => { {stateMap?.[LMS_AUTHORIZATION_FAILED] && ( - +

Enablement failed

We were unable to enable your Canvas integration. Please try again or contact enterprise customer support.
)} - + {isExisting && ( + +

Form updates require reauthorization

+ Your authorization is currently complete. By updating the form below, + reauthorization will be required and advancing to the next step will + open a new window to complete the process in Canvas. Return to this window + following reauthorization to finish reconfiguring your integration. +
)} { floatingLabel="Canvas Base URL" /> + +

Authorization in Canvas required to complete configuration

+ Advancing to the next step will open a new window to complete the authorization + process in Canvas. Return to this window following authorization to finish configuring + your new integration. +
diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx index 832ee5901d..5a39e38cb1 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx @@ -90,7 +90,7 @@ export const CornerstoneFormConfig = ({ { index: 1, formComponent: CornerstoneConfigEnablePage, - validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), + validations: validations.concat([checkForDuplicateNames(existingConfigNames)]), stepName: "Enable", saveChanges, nextButtonConfig: () => { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx index d44e5491bd..5da11b3b67 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx @@ -17,7 +17,7 @@ export type DegreedConfigCamelCase = { clientId: string; clientSecret: string; degreedBaseUrl: string; - degreedFetchUrl: string; + degreedTokenFetchBaseUrl: string; id: string; active: boolean; uuid: string; @@ -29,7 +29,7 @@ export type DegreedConfigSnakeCase = { client_id: string; client_secret: string; degreed_base_url: string; - degreed_fetch_url: string; + degreed_token_fetch_base_url: string; id: string; active: boolean; uuid: string; @@ -94,7 +94,7 @@ export const DegreedFormConfig = ({ { index: 1, formComponent: DegreedConfigAuthorizePage, - validations: validations.concat([checkForDuplicateNames(existingConfigNames, existingData)]), + validations: validations.concat([checkForDuplicateNames(existingConfigNames)]), stepName: "Enable", saveChanges, nextButtonConfig: () => { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx index dbc25fe4d7..39a9aeb7c5 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx @@ -9,17 +9,13 @@ import { channelMapping, urlValidation } from "../../../../../utils"; import type { FormFieldValidation, } from "../../../../forms/FormContext"; -import { - useFormContext, - // @ts-ignore -} from "../../../../forms/FormContext.tsx"; export const formFieldNames = { DISPLAY_NAME: "displayName", CLIENT_ID: "clientId", CLIENT_SECRET: "clientSecret", DEGREED_BASE_URL: "degreedBaseUrl", - DEGREED_FETCH_URL: "degreedFetchUrl", + DEGREED_TOKEN_FETCH_BASE_URL: "degreedTokenFetchBaseUrl", }; export const validations: FormFieldValidation[] = [ @@ -36,9 +32,9 @@ export const validations: FormFieldValidation[] = [ }, }, { - formFieldId: formFieldNames.DEGREED_FETCH_URL, + formFieldId: formFieldNames.DEGREED_TOKEN_FETCH_BASE_URL, validator: (fields) => { - const degreedUrl = fields[formFieldNames.DEGREED_FETCH_URL]; + const degreedUrl = fields[formFieldNames.DEGREED_TOKEN_FETCH_BASE_URL]; if (degreedUrl) { const error = !urlValidation(degreedUrl); return error ? INVALID_LINK : false; @@ -130,7 +126,7 @@ const DegreedConfigEnablePage = () => { { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx index b42d83f4da..3e2d9d736d 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx @@ -13,7 +13,7 @@ import type { export const formFieldNames = { DISPLAY_NAME: "displayName", MOODLE_BASE_URL: "moodleBaseUrl", - WEBSERVICE_SHORT_NAME: "webserviceShortName", + SERVICE_SHORT_NAME: "serviceShortName", TOKEN: "token", USERNAME: "username", PASSWORD: "password", @@ -49,10 +49,10 @@ export const validations: FormFieldValidation[] = [ }, }, { - formFieldId: formFieldNames.WEBSERVICE_SHORT_NAME, + formFieldId: formFieldNames.SERVICE_SHORT_NAME, validator: (fields) => { - const webserviceShortName = fields[formFieldNames.WEBSERVICE_SHORT_NAME]; - return !webserviceShortName; + const serviceShortName = fields[formFieldNames.SERVICE_SHORT_NAME]; + return !serviceShortName; }, }, { @@ -112,7 +112,7 @@ const MoodleConfigEnablePage = () => { { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx index 50f9f9eca4..9ff0690914 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx @@ -14,19 +14,19 @@ import type { export const formFieldNames = { DISPLAY_NAME: "displayName", - SAP_BASE_URL: "sapBaseUrl", - SAP_COMPANY_ID: "sapCompanyId", - SAP_USER_ID: "sapUserId", - OAUTH_CLIENT_ID: "oauthClientId", - OAUTH_CLIENT_SECRET: "oauthClientSecret", - SAP_USER_TYPE: "sapUserType", + SAPSF_BASE_URL: "sapsfBaseUrl", + SAPSF_COMPANY_ID: "sapsfCompanyId", + SAPSF_USER_ID: "sapsfUserId", + KEY: "key", + SECRET: "secret", + USER_TYPE: "userType", }; export const validations: FormFieldValidation[] = [ { - formFieldId: formFieldNames.SAP_BASE_URL, + formFieldId: formFieldNames.SAPSF_BASE_URL, validator: (fields) => { - const sapUrl = fields[formFieldNames.SAP_BASE_URL]; + const sapUrl = fields[formFieldNames.SAPSF_BASE_URL]; if (sapUrl) { const error = !urlValidation(sapUrl); return error ? INVALID_LINK : false; @@ -51,38 +51,38 @@ export const validations: FormFieldValidation[] = [ }, }, { - formFieldId: formFieldNames.SAP_COMPANY_ID, + formFieldId: formFieldNames.SAPSF_COMPANY_ID, validator: (fields) => { - const sapCompanyId = fields[formFieldNames.SAP_COMPANY_ID]; - return !sapCompanyId; + const sapsfCompanyId = fields[formFieldNames.SAPSF_COMPANY_ID]; + return !sapsfCompanyId; }, }, { - formFieldId: formFieldNames.SAP_USER_ID, + formFieldId: formFieldNames.SAPSF_USER_ID, validator: (fields) => { - const sapUserId = fields[formFieldNames.SAP_USER_ID]; - return !sapUserId; + const sapsfUserId = fields[formFieldNames.SAPSF_USER_ID]; + return !sapsfUserId; }, }, { - formFieldId: formFieldNames.OAUTH_CLIENT_ID, + formFieldId: formFieldNames.KEY, validator: (fields) => { - const oauthClientId = fields[formFieldNames.OAUTH_CLIENT_ID]; - return !oauthClientId; + const key = fields[formFieldNames.KEY]; + return !key; }, }, { - formFieldId: formFieldNames.OAUTH_CLIENT_SECRET, + formFieldId: formFieldNames.SECRET, validator: (fields) => { - const secret = fields[formFieldNames.OAUTH_CLIENT_SECRET]; + const secret = fields[formFieldNames.SECRET]; return !secret; }, }, { - formFieldId: formFieldNames.SAP_USER_TYPE, + formFieldId: formFieldNames.USER_TYPE, validator: (fields) => { - const sapUserType = fields[formFieldNames.SAP_USER_TYPE]; - return !sapUserType; + const userType = fields[formFieldNames.USER_TYPE]; + return !userType; }, }, ]; @@ -110,7 +110,7 @@ const SAPConfigEnablePage = () => { { { { { { { - return existingConfigNames?.includes(existingData.displayName) + validator: (fields) => { + return existingConfigNames?.includes(fields['displayName']) ? INVALID_NAME : false; }, diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index 4673d415d7..9615767ccd 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -1,3 +1,4 @@ +import _ from 'lodash'; import React, { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; @@ -54,7 +55,7 @@ const SettingsLMSTab = ({ dispatch?.setFormFieldAction({ fieldId: 'lms', value: configData.channelCode }); setLmsType(configData.channelCode); // Set the form data to the card's associated config data - setExistingConfigFormData(configData); + setExistingConfigFormData(_.cloneDeep(configData)); // Set the config type to the card's type setConfig(configType); // Hide the create new configs button diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx index 71bf306552..562944832b 100644 --- a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx @@ -236,6 +236,13 @@ describe("", () => { }); test('validates properly formatted existing data on load', () => { render(testBlackboardConfigSetup(existingConfigDataNoAuth)); + expect(screen.getByText('Form updates require reauthorization')); + // ensuring the existing data is prefilled + expect((screen.getByLabelText('Display Name') as HTMLInputElement).value).toEqual( + existingConfigDataNoAuth.displayName); + expect((screen.getByLabelText('Blackboard Base URL') as HTMLInputElement).value).toEqual( + existingConfigDataNoAuth.blackboardBaseUrl); + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx index 4eb896075e..01a0783aff 100644 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx @@ -47,14 +47,14 @@ const existingConfigDataNoAuth = { canvasBaseUrl: "https://foobarish.com", clientId: "ayylmao", clientSecret: "testingsecret", - canvasAccountId: 10, + canvasAccountId: '10', }; const mockConfigResponseData = { uuid: 'foobar', id: 1, - canvas_account_id: 1, + canvas_account_id: '1', display_name: 'display name', canvas_base_url: 'https://foobar.com', client_id: "wassap", @@ -220,6 +220,18 @@ describe("", () => { }); test('validates properly formatted existing data on load', () => { render(testCanvasConfigSetup(existingConfigDataNoAuth)); + // ensuring the existing data is prefilled + expect((screen.getByLabelText("Display Name") as HTMLInputElement).value).toEqual( + existingConfigDataNoAuth.displayName); + expect((screen.getByLabelText("Canvas Base URL") as HTMLInputElement).value).toEqual( + existingConfigDataNoAuth.canvasBaseUrl); + expect((screen.getByLabelText("API Client ID") as HTMLInputElement).value).toEqual( + existingConfigDataNoAuth.clientId); + expect((screen.getByLabelText("Canvas Account Number") as HTMLInputElement).value).toEqual( + existingConfigDataNoAuth.canvasAccountId); + expect((screen.getByLabelText("API Client Secret") as HTMLInputElement).value).toEqual( + existingConfigDataNoAuth.clientSecret); + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx index ee7bc1b3f7..d57dda3357 100644 --- a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx @@ -153,6 +153,10 @@ describe('', () => { }); test('validates properly formatted existing data on load', () => { render(testCornerstoneConfigSetup(existingConfigData)); + // ensuring the existing data is prefilled + expect(screen.getByLabelText('Display Name').value).toEqual(existingConfigData.displayName); + expect(screen.getByLabelText('Cornerstone Base URL').value).toEqual(existingConfigData.cornerstoneBaseUrl); + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx index b0887291f9..84a77cd81c 100644 --- a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx @@ -38,7 +38,7 @@ const existingConfigData = { clientId: '1', clientSecret: 'shhhitsasecret123', degreedBaseUrl: "https://foobarish.com", - degreedFetchUrl: "https://foobarish.com/fetch" + degreedTokenFetchBaseUrl: "https://foobarish.com/fetch" }; // Existing invalid data that will be validated on load @@ -47,7 +47,7 @@ const invalidExistingData = { clientId: '1', clientSecret: 'shhhitsasecret123', degreedBaseUrl: "bad icky url", - degreedFetchUrl: "https://foobarish.com/fetch" + degreedTokenFetchBaseUrl: "https://foobarish.com/fetch" }; @@ -168,7 +168,7 @@ describe("", () => { client_id: '1', client_secret: 'shhhitsasecret123', degreed_base_url: 'https://www.test.com', - degreed_fetch_url: 'https://www.test.com', + degreed_token_fetch_base_url: 'https://www.test.com', enterprise_customer: enterpriseId, }; await waitFor(() => expect(mockPost).toHaveBeenCalledWith(expectedConfig)); @@ -198,7 +198,7 @@ describe("", () => { client_id: '1', client_secret: 'shhhitsasecret123', degreed_base_url: 'https://www.test.com', - degreed_fetch_url: 'https://www.test.com', + degreed_token_fetch_base_url: 'https://www.test.com', enterprise_customer: enterpriseId, }; expect(mockPost).toHaveBeenCalledWith(expectedConfig); @@ -210,6 +210,18 @@ describe("", () => { }); test('validates properly formatted existing data on load', () => { render(testDegreedConfigSetup(existingConfigData)); + // ensuring the existing data is prefilled + expect((screen.getByLabelText('Display Name') as HTMLInputElement).value).toEqual( + existingConfigData.displayName); + expect((screen.getByLabelText('API Client ID') as HTMLInputElement).value).toEqual( + existingConfigData.clientId); + expect((screen.getByLabelText('API Client Secret') as HTMLInputElement).value).toEqual( + existingConfigData.clientSecret); + expect((screen.getByLabelText('Degreed Base URL') as HTMLInputElement).value).toEqual( + existingConfigData.degreedBaseUrl); + expect((screen.getByLabelText('Degreed Token Fetch Base URL') as HTMLInputElement).value).toEqual( + existingConfigData.degreedTokenFetchBaseUrl) + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx index 66f6d39b66..220459b2df 100644 --- a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx @@ -41,7 +41,7 @@ const existingConfigData = { id: 1, displayName: 'hola', moodleBaseUrl: 'https://example.com', - webserviceShortName: 'shortname', + serviceShortName: 'shortname', token: 'token', }; @@ -49,7 +49,7 @@ const existingConfigData = { const invalidExistingData = { displayName: 'just a whole muddle of moodles', moodleBaseUrl: "you dumb dumb this isn't a url", - webserviceShortName: 'shortname', + serviceShortName: 'shortname', token: 'token', username: 'blah1', password: 'blahblah', @@ -184,7 +184,7 @@ describe('', () => { active: false, display_name: 'displayName', moodle_base_url: 'https://www.test.com', - webservice_short_name: 'name', + service_short_name: 'name', token: '', username: 'user', password: 'password123', @@ -216,7 +216,7 @@ describe('', () => { active: false, display_name: 'displayName', moodle_base_url: 'https://www.test.com', - webservice_short_name: 'name', + service_short_name: 'name', token: '', username: 'user', password: 'password123', @@ -232,6 +232,12 @@ describe('', () => { }); test('validates properly formatted existing data on load', () => { render(testMoodleConfigSetup(existingConfigData)); + // ensuring the existing data is prefilled + expect(screen.getByLabelText('Display Name').value).toEqual(existingConfigData.displayName); + expect(screen.getByLabelText('Moodle Base URL').value).toEqual(existingConfigData.moodleBaseUrl); + expect(screen.getByLabelText('Webservice Short Name').value).toEqual(existingConfigData.serviceShortName); + expect(screen.getByLabelText('Token').value).toEqual(existingConfigData.token); + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_MOODLE_VERIFICATION)).not.toBeInTheDocument(); diff --git a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx index 5d7a8b81af..57b82a60bb 100644 --- a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx @@ -21,7 +21,7 @@ const mockConfigResponseData = { uuid: 'foobar', id: 1, display_name: 'display name', - sap_base_url: 'https://foobar.com', + sapf_base_url: 'https://foobar.com', active: false, }; mockUpdateConfigApi.mockResolvedValue({ data: mockConfigResponseData }); @@ -40,23 +40,23 @@ const noExistingData = {}; const existingConfigData = { id: 1, displayName: 'whatsinaname', - sapBaseUrl: 'http://www.example.com', - sapCompanyId: '12', - sapUserId: 'userId', - oauthClientId: 'clientId', - oauthClientSecret: 'secretshh', - sapUserType: 'admin', + sapsfBaseUrl: 'http://www.example.com', + sapsfCompanyId: '12', + sapsfUserId: 'userId', + key: 'clientId', + secret: 'secretshh', + userType: 'admin', }; // Existing invalid data that will be validated on load const invalidExistingData = { displayName: 'just a whole muddle of saps', - sapBaseUrl: 'you dumb dumb this isnt a url', - sapCompanyId: '12', - sapUserId: 'userId', - oauthClientId: 'clientId', - oauthClientSecret: 'secretshh', - sapUserType: 'admin', + sapsfBaseUrl: 'you dumb dumb this isnt a url', + sapsfCompanyId: '12', + sapsfUserId: 'userId', + key: 'clientId', + secret: 'secretshh', + userType: 'admin', }; const noConfigs = []; @@ -186,16 +186,16 @@ describe('', () => { const expectedConfig = { active: false, display_name: 'lmsconfig', - sap_base_url: 'http://www.example.com', - sap_company_id: '1', - sap_user_id: '1', - oauth_client_id: 'id', - oauth_client_secret: 'secret', - sap_user_type: 'admin', + sapsf_base_url: 'http://www.example.com', + sapsf_company_id: '1', + sapsf_user_id: '1', + key: 'id', + secret: 'secret', + user_type: 'admin', enterprise_customer: enterpriseId, }; await waitFor(() => expect(mockPost).toHaveBeenCalledWith(expectedConfig)); - }); + }, 30000); test('saves draft correctly', async () => { render(testSAPConfigSetup(noExistingData)); const cancelButton = screen.getByRole('button', { name: 'Cancel' }); @@ -221,12 +221,12 @@ describe('', () => { const expectedConfig = { active: false, display_name: 'lmsconfig', - sap_base_url: 'http://www.example.com', - sap_company_id: '1', - sap_user_id: '1', - oauth_client_id: 'id', - oauth_client_secret: 'secret', - sap_user_type: 'user', + sapsf_base_url: 'http://www.example.com', + sapsf_company_id: '1', + sapsf_user_id: '1', + key: 'id', + secret: 'secret', + user_type: 'user', enterprise_customer: enterpriseId, }; expect(mockPost).toHaveBeenCalledWith(expectedConfig); @@ -238,6 +238,14 @@ describe('', () => { }); test('validates properly formatted existing data on load', () => { render(testSAPConfigSetup(existingConfigData)); + // ensuring the existing data is prefilled + expect(screen.getByLabelText('Display Name').value).toEqual(existingConfigData.displayName); + expect(screen.getByLabelText('SAP Base URL').value).toEqual(existingConfigData.sapsfBaseUrl); + expect(screen.getByLabelText('SAP Company ID').value).toEqual(existingConfigData.sapsfCompanyId); + expect(screen.getByLabelText('SAP User ID').value).toEqual(existingConfigData.sapsfUserId); + expect(screen.getByLabelText('OAuth Client ID').value).toEqual(existingConfigData.key); + expect(screen.getByLabelText('OAuth Client Secret').value).toEqual(existingConfigData.secret); + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/utils.js b/src/components/settings/SettingsLMSTab/utils.js index 6781d80d44..9415c82cab 100644 --- a/src/components/settings/SettingsLMSTab/utils.js +++ b/src/components/settings/SettingsLMSTab/utils.js @@ -5,7 +5,7 @@ export default function buttonBool(config) { Object.entries(config).forEach(entry => { const [key, value] = entry; // check whether or not the field is an optional value - if ((key !== 'displayName' && key !== 'degreedFetchUrl') && !value) { + if ((key !== 'displayName' && key !== 'degreedTokenFetchBaseUrl') && !value) { returnVal = false; } }); From 19a454116ff6ad7562ea4dc9e8086abddf830137 Mon Sep 17 00:00:00 2001 From: Kira Miller <31229189+kiram15@users.noreply.github.com> Date: Tue, 30 May 2023 12:41:55 -0600 Subject: [PATCH 71/73] fix: bug bash fixes (#990) * fix: bug bash fixes * fix: bug bash fixes * fix: pr requests * fix: pr changes --- package-lock.json | 31 +++-- package.json | 2 +- src/components/SidebarToggle/index.jsx | 4 +- src/components/forms/FormContext.tsx | 1 + src/components/forms/FormWorkflow.tsx | 111 +++++++++++------- src/components/forms/ValidatedFormControl.tsx | 7 +- src/components/forms/ValidatedFormRadio.tsx | 6 +- src/components/forms/data/actions.ts | 12 +- src/components/forms/data/reducer.ts | 5 +- .../forms/tests/ValidatedFormControl.test.tsx | 11 +- .../forms/tests/ValidatedFormRadio.test.tsx | 1 + .../settings/SettingsLMSTab/LMSConfigPage.jsx | 4 +- .../Blackboard/BlackboardConfig.tsx | 2 +- .../BlackboardConfigAuthorizePage.tsx | 19 +++ .../LMSConfigs/Canvas/CanvasConfig.tsx | 2 +- .../Canvas/CanvasConfigAuthorizePage.tsx | 25 ++-- .../Cornerstone/CornerstoneConfig.tsx | 2 +- .../CornerstoneConfigEnablePage.tsx | 13 +- .../LMSConfigs/Degreed/DegreedConfig.tsx | 2 +- .../Degreed/DegreedConfigEnablePage.tsx | 23 ++-- .../LMSConfigs/Moodle/MoodleConfig.tsx | 2 +- .../Moodle/MoodleConfigEnablePage.tsx | 30 +++-- .../LMSConfigs/SAP/SAPConfig.tsx | 2 +- .../LMSConfigs/SAP/SAPConfigEnablePage.tsx | 41 +++++-- .../SettingsLMSTab/LMSConfigs/utils.tsx | 13 +- .../SettingsLMSTab/LMSFormWorkflowConfig.tsx | 2 +- .../settings/SettingsLMSTab/index.jsx | 6 +- .../tests/BlackboardConfig.test.tsx | 62 +++++----- .../tests/CanvasConfig.test.tsx | 74 ++++++------ .../tests/CornerstoneConfig.test.jsx | 29 +++-- .../tests/DegreedConfig.test.tsx | 58 +++++---- .../tests/MoodleConfig.test.jsx | 71 +++++------ .../SettingsLMSTab/tests/SAPConfig.test.jsx | 71 ++++++----- src/components/settings/data/constants.js | 9 +- .../SidebarToggle/SidebarToggle.test.jsx | 4 +- src/utils.js | 11 +- 36 files changed, 475 insertions(+), 293 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef10b94c34..1e12984d81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.26.3", + "@edx/paragon": "20.39.2", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -3578,9 +3578,9 @@ } }, "node_modules/@edx/paragon": { - "version": "20.26.3", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.26.3.tgz", - "integrity": "sha512-+N05050zBBGYohb0/CAOEzD7oRCQhTjmEW5ZOT4+JTa8JXdIfJ0+YGLc2ZZ3Nvz80r4o+og2hGA0oU1SA6UY2A==", + "version": "20.39.2", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.39.2.tgz", + "integrity": "sha512-SvJskMG+hjRAoteR+dhjXIFAAgMk4IgnDA4gvBomxOk/D+zV+E3mebEoslX2Qx+krRLSwmfQLWhWYn4qlps/5w==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -3595,6 +3595,7 @@ "mailto-link": "^2.0.0", "prop-types": "^15.8.1", "react-bootstrap": "^1.6.5", + "react-colorful": "^5.6.1", "react-dropzone": "^14.2.1", "react-focus-on": "^3.5.4", "react-loading-skeleton": "^3.1.0", @@ -18622,6 +18623,15 @@ "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", @@ -26636,9 +26646,9 @@ } }, "@edx/paragon": { - "version": "20.26.3", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.26.3.tgz", - "integrity": "sha512-+N05050zBBGYohb0/CAOEzD7oRCQhTjmEW5ZOT4+JTa8JXdIfJ0+YGLc2ZZ3Nvz80r4o+og2hGA0oU1SA6UY2A==", + "version": "20.39.2", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.39.2.tgz", + "integrity": "sha512-SvJskMG+hjRAoteR+dhjXIFAAgMk4IgnDA4gvBomxOk/D+zV+E3mebEoslX2Qx+krRLSwmfQLWhWYn4qlps/5w==", "requires": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -26653,6 +26663,7 @@ "mailto-link": "^2.0.0", "prop-types": "^15.8.1", "react-bootstrap": "^1.6.5", + "react-colorful": "^5.6.1", "react-dropzone": "^14.2.1", "react-focus-on": "^3.5.4", "react-loading-skeleton": "^3.1.0", @@ -38030,6 +38041,12 @@ "@babel/runtime": "^7.12.13" } }, + "react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "requires": {} + }, "react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", diff --git a/package.json b/package.json index f2a953e269..261c288642 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@edx/frontend-enterprise-logistration": "2.1.0", "@edx/frontend-enterprise-utils": "2.1.0", "@edx/frontend-platform": "2.4.0", - "@edx/paragon": "20.26.3", + "@edx/paragon": "20.39.2", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", diff --git a/src/components/SidebarToggle/index.jsx b/src/components/SidebarToggle/index.jsx index 57720300ce..c42d8f278c 100644 --- a/src/components/SidebarToggle/index.jsx +++ b/src/components/SidebarToggle/index.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button } from '@edx/paragon'; -import { Close, Menu } from '@edx/paragon/icons'; +import { Close, MenuIcon } from '@edx/paragon/icons'; import './SidebarToggle.scss'; @@ -12,7 +12,7 @@ const SidebarToggle = (props) => { collapseSidebar, } = props; - const Icon = isExpandedByToggle ? Close : Menu; + const Icon = isExpandedByToggle ? Close : MenuIcon; return ( + + Help Center: Integrations + {nextButtonConfig && ( - + )} )} - > + > {formWorkflowConfig.steps.map((stepConfig) => stepBody(stepConfig))} diff --git a/src/components/forms/ValidatedFormControl.tsx b/src/components/forms/ValidatedFormControl.tsx index e7f878cb16..f642ddbb01 100644 --- a/src/components/forms/ValidatedFormControl.tsx +++ b/src/components/forms/ValidatedFormControl.tsx @@ -9,7 +9,6 @@ import { setFormFieldAction } from "./data/actions.ts"; // @ts-ignore import { useFormContext } from "./FormContext.tsx"; -// TODO: Add Form.Control props. Does Paragon export? type InheritedParagonControlProps = { className?: string; type: string; @@ -26,7 +25,7 @@ export type ValidatedFormControlProps = { // Control that reads from/writes to form context store const ValidatedFormControl = (props: ValidatedFormControlProps) => { - const { formFields, errorMap, dispatch } = useFormContext(); + const { showErrors, formFields, errorMap, dispatch } = useFormContext(); const onChange = (e: React.ChangeEvent) => { dispatch && dispatch( setFormFieldAction({ fieldId: props.formId, value: e.target.value }) @@ -38,7 +37,7 @@ const ValidatedFormControl = (props: ValidatedFormControlProps) => { const formControlProps = { ...omit(props, ["formId"]), onChange, - isInvalid: showError, + isInvalid: showErrors && showError, id: props.formId, value: formFields && formFields[props.formId], }; @@ -48,7 +47,7 @@ const ValidatedFormControl = (props: ValidatedFormControlProps) => { {props.fieldInstructions && ( {props.fieldInstructions} )} - {showError && ( + {showErrors && showError && ( {showError} )} diff --git a/src/components/forms/ValidatedFormRadio.tsx b/src/components/forms/ValidatedFormRadio.tsx index beb0b8e048..c7d7d20602 100644 --- a/src/components/forms/ValidatedFormRadio.tsx +++ b/src/components/forms/ValidatedFormRadio.tsx @@ -23,7 +23,7 @@ export type ValidatedFormRadioProps = { } & InheritedParagonRadioProps; const ValidatedFormRadio = (props: ValidatedFormRadioProps) => { - const { formFields, errorMap, dispatch } = useFormContext(); + const { showErrors, formFields, errorMap, dispatch } = useFormContext(); const onChange = (e: React.ChangeEvent) => { dispatch && dispatch( setFormFieldAction({ fieldId: props.formId, value: e.target.value }) @@ -36,7 +36,7 @@ const ValidatedFormRadio = (props: ValidatedFormRadioProps) => { const formRadioProps = { ...omit(props, ["formId"]), onChange, - isInvalid: showError, + isInvalid: showErrors && showError, id: props.formId, value: formFields && formFields[props.formId], }; @@ -68,7 +68,7 @@ const ValidatedFormRadio = (props: ValidatedFormRadioProps) => { {formRadioProps.fieldInstructions && ( {formRadioProps.fieldInstructions} )} - {showError && ( + {showErrors && showError && ( {showError} )} diff --git a/src/components/forms/data/actions.ts b/src/components/forms/data/actions.ts index 2307c58932..cb8175b24c 100644 --- a/src/components/forms/data/actions.ts +++ b/src/components/forms/data/actions.ts @@ -35,11 +35,21 @@ export function updateFormFieldsAction({ }; } +export const SET_SHOW_ERRORS = "SET SHOW ERRORS"; +export type SetShowErrorsArguments = { + showErrors: boolean; +} & FormActionArguments; +// Construct action for setting a step +export const setShowErrorsAction = ({ showErrors }: SetShowErrorsArguments) => ({ + type: SET_SHOW_ERRORS, + showErrors, +}); + export const SET_STEP = "SET STEP"; export type SetStepArguments = { step: FormWorkflowStep; } & FormActionArguments; -// Construct action for setting a form field value +// Construct action for setting a step export const setStepAction = ({ step }: SetStepArguments) => ({ type: SET_STEP, step, diff --git a/src/components/forms/data/reducer.ts b/src/components/forms/data/reducer.ts index 2085890aa9..baf0fddb2d 100644 --- a/src/components/forms/data/reducer.ts +++ b/src/components/forms/data/reducer.ts @@ -2,7 +2,7 @@ import groupBy from "lodash/groupBy"; import isEmpty from "lodash/isEmpty"; import keys from "lodash/keys" // @ts-ignore -import { SET_FORM_FIELD, SET_STEP, SET_WORKFLOW_STATE, UPDATE_FORM_FIELDS } from "./actions.ts"; +import { SET_FORM_FIELD, SET_SHOW_ERRORS, SET_STEP, SET_WORKFLOW_STATE, SetShowErrorsArguments, UPDATE_FORM_FIELDS } from "./actions.ts"; import type { FormActionArguments, SetFormFieldArguments, @@ -106,6 +106,9 @@ export function FormReducer( case SET_STEP: const setStepArgs = action as SetStepArguments; return { ...state, currentStep: setStepArgs.step }; + case SET_SHOW_ERRORS: + const SetShowErrorsArgs = action as SetShowErrorsArguments; + return { ...state, showErrors: SetShowErrorsArgs.showErrors }; case SET_WORKFLOW_STATE: const setStateArgs = action as SetWorkflowStateArguments; const oldStateMap = state.stateMap || {}; diff --git a/src/components/forms/tests/ValidatedFormControl.test.tsx b/src/components/forms/tests/ValidatedFormControl.test.tsx index 941920a4aa..734a20f016 100644 --- a/src/components/forms/tests/ValidatedFormControl.test.tsx +++ b/src/components/forms/tests/ValidatedFormControl.test.tsx @@ -1,15 +1,16 @@ /* eslint-disable react/prop-types */ -import React from 'react'; +import React, { Component } from 'react'; import '@testing-library/jest-dom/extend-expect'; import { screen, render } from '@testing-library/react'; import userEvent, { TargetElement } from '@testing-library/user-event'; // @ts-ignore -import FormContextProvider from '../FormContext.tsx'; +import FormContextProvider, { FormFieldValidation } from '../FormContext.tsx'; import type { FormContext } from '../FormContext'; // @ts-ignore import ValidatedFormControl from '../ValidatedFormControl.tsx'; import type {ValidatedFormControlProps} from '../ValidatedFormControl'; +import { FormWorkflowStep } from '../FormWorkflow.js'; type ValidatedFormControlWrapperProps = { mockDispatch: () => void; @@ -30,7 +31,11 @@ const ValidatedFormControlWrapper = ({ formFields: { [formId]: formValue }, }; if (formError) { - contextValue = { ...contextValue, errorMap: { [formId]: [formError] } }; + contextValue = + { ...contextValue, + errorMap: { [formId]: [formError] }, + showErrors: true, + }; } return ( { let contextValue: FormContext = { formFields: { [formId]: formValue }, + showErrors: true, }; if (formError) { contextValue = { ...contextValue, errorMap: { [formId]: [formError] } }; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx index 907f2e3477..f9fe6e32ea 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigPage.jsx @@ -51,7 +51,7 @@ const mapStateToProps = (state) => ({ }); LMSConfigPage.defaultProps = { - existingConfigs: [], + existingConfigs: {}, lmsType: '', }; @@ -59,7 +59,7 @@ LMSConfigPage.propTypes = { enterpriseCustomerUuid: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, existingConfigFormData: PropTypes.shape({}).isRequired, - existingConfigs: PropTypes.arrayOf(PropTypes.string), + existingConfigs: PropTypes.shape({}), setExistingConfigFormData: PropTypes.func.isRequired, isLmsStepperOpen: PropTypes.bool.isRequired, closeLmsStepper: PropTypes.func.isRequired, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx index 2b7104f91e..36011111fd 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfig.tsx @@ -47,7 +47,7 @@ export type BlackboardConfigSnakeCase = { export type BlackboardFormConfigProps = { enterpriseCustomerUuid: string; existingData: BlackboardConfigCamelCase; - existingConfigNames: string[]; + existingConfigNames: Map; onSubmit: (blackboardConfig: BlackboardConfigCamelCase) => void; handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx index ca8dd890a4..188d1976f6 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx @@ -23,7 +23,19 @@ export const formFieldNames = { BLACKBOARD_BASE_URL: "blackboardBaseUrl", }; +export const validationMessages = { + displayNameRequired: 'Please enter Display Name', + baseUrlRequired: 'Please enter Blackboard Base URL', +}; + export const validations: FormFieldValidation[] = [ + { + formFieldId: formFieldNames.BLACKBOARD_BASE_URL, + validator: (fields) => { + const error = !fields[formFieldNames.BLACKBOARD_BASE_URL]; + return error && validationMessages.baseUrlRequired; + }, + }, { formFieldId: formFieldNames.BLACKBOARD_BASE_URL, validator: (fields) => { @@ -31,6 +43,13 @@ export const validations: FormFieldValidation[] = [ return error && INVALID_LINK; }, }, + { + formFieldId: formFieldNames.DISPLAY_NAME, + validator: (fields) => { + const error = !fields[formFieldNames.DISPLAY_NAME]; + return error && validationMessages.displayNameRequired; + }, + }, { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx index 6aa09b5917..5a685761a8 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfig.tsx @@ -45,7 +45,7 @@ export type CanvasConfigSnakeCase = { export type CanvasFormConfigProps = { enterpriseCustomerUuid: string; existingData: CanvasConfigCamelCase; - existingConfigNames: string[]; + existingConfigNames: Map; onSubmit: (canvasConfig: CanvasConfigCamelCase) => void; handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx index 5d120b9ce7..986d18248d 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx @@ -31,6 +31,14 @@ export const formFieldNames = { CANVAS_BASE_URL: "canvasBaseUrl", }; +export const validationMessages = { + displayNameRequired: 'Please enter Display Name', + clientIdRequired: 'Please enter API Client ID', + clientSecretRequired: 'Please enter API Client Secret', + accountIdRequired: 'Please enter Account ID', + canvasUrlRequired: 'Please enter Canvas Base Url', +}; + export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.CANVAS_BASE_URL, @@ -40,15 +48,15 @@ export const validations: FormFieldValidation[] = [ const error = !urlValidation(canvasUrl); return error ? INVALID_LINK : false; } else { - return true; + return validationMessages.canvasUrlRequired; } }, }, { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { - const displayName = fields[formFieldNames.DISPLAY_NAME]; - return !displayName; + const error = !(fields[formFieldNames.DISPLAY_NAME]); + return error && validationMessages.displayNameRequired; }, }, { @@ -62,21 +70,22 @@ export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.ACCOUNT_ID, validator: (fields) => { - return !isValidNumber(fields[formFieldNames.ACCOUNT_ID]); + const error = !isValidNumber(fields[formFieldNames.ACCOUNT_ID]); + return error && validationMessages.accountIdRequired; }, }, { formFieldId: formFieldNames.CLIENT_ID, validator: (fields) => { - const clientId = fields[formFieldNames.CLIENT_ID]; - return !clientId; + const error = !(fields[formFieldNames.CLIENT_ID]); + return error && validationMessages.clientIdRequired; }, }, { formFieldId: formFieldNames.CLIENT_SECRET, validator: (fields) => { - const clientSecret = fields[formFieldNames.CLIENT_SECRET]; - return !clientSecret; + const error = !(fields[formFieldNames.CLIENT_SECRET]); + return error && validationMessages.clientSecretRequired; }, }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx index 5a39e38cb1..886fe7890c 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfig.tsx @@ -33,7 +33,7 @@ export type CornerstoneConfigSnakeCase = { export type CornerstoneFormConfigProps = { enterpriseCustomerUuid: string; existingData: CornerstoneConfigCamelCase; - existingConfigNames: string[]; + existingConfigNames: Map; onSubmit: (cornerstoneConfig: CornerstoneConfigCamelCase) => void; handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfigEnablePage.tsx index dba623de97..5ce485e00d 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Cornerstone/CornerstoneConfigEnablePage.tsx @@ -16,6 +16,11 @@ export const formFieldNames = { CORNERSTONE_FETCH_URL: "cornerstoneFetchUrl", }; +export const validationMessages = { + displayNameRequired: 'Please enter Display Name', + cornerstoneUrlRequired: 'Please enter Cornerstone Base Url', +}; + export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.CORNERSTONE_BASE_URL, @@ -25,16 +30,16 @@ export const validations: FormFieldValidation[] = [ const error = !urlValidation(cornerstoneUrl); return error ? INVALID_LINK : false; } else { - return true; + return validationMessages.cornerstoneUrlRequired; } }, }, { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { - const displayName = fields[formFieldNames.DISPLAY_NAME]; - return !displayName; - }, + const error = !(fields[formFieldNames.DISPLAY_NAME]); + return error && validationMessages.displayNameRequired; + } }, { formFieldId: formFieldNames.DISPLAY_NAME, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx index 5da11b3b67..b2a5f77ff6 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfig.tsx @@ -40,7 +40,7 @@ export type DegreedConfigSnakeCase = { export type DegreedFormConfigProps = { enterpriseCustomerUuid: string; existingData: DegreedConfigCamelCase; - existingConfigNames: string[]; + existingConfigNames: Map; onSubmit: (degreedConfig: DegreedConfigCamelCase) => void; handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>, diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx index 39a9aeb7c5..78bb398d01 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Degreed/DegreedConfigEnablePage.tsx @@ -18,6 +18,13 @@ export const formFieldNames = { DEGREED_TOKEN_FETCH_BASE_URL: "degreedTokenFetchBaseUrl", }; +export const validationMessages = { + displayNameRequired: 'Please enter Display Name', + clientIdRequired: 'Please enter Client Id', + clientSecretRequired: 'Please enter Client Secret', + degreedUrlRequired: 'Please enter Degreed Base Url', +}; + export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.DEGREED_BASE_URL, @@ -27,7 +34,7 @@ export const validations: FormFieldValidation[] = [ const error = !urlValidation(degreedUrl); return error ? INVALID_LINK : false; } else { - return true; + return validationMessages.degreedUrlRequired; } }, }, @@ -47,9 +54,9 @@ export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { - const displayName = fields[formFieldNames.DISPLAY_NAME]; - return !displayName; - }, + const error = !(fields[formFieldNames.DISPLAY_NAME]); + return error && validationMessages.displayNameRequired; + } }, { formFieldId: formFieldNames.DISPLAY_NAME, @@ -62,15 +69,15 @@ export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.CLIENT_ID, validator: (fields) => { - const clientId = fields[formFieldNames.CLIENT_ID]; - return !clientId; + const error = !fields[formFieldNames.CLIENT_ID]; + return error && validationMessages.clientIdRequired; }, }, { formFieldId: formFieldNames.CLIENT_SECRET, validator: (fields) => { - const clientSecret = fields[formFieldNames.CLIENT_SECRET]; - return !clientSecret; + const error = !fields[formFieldNames.CLIENT_SECRET]; + return error && validationMessages.clientSecretRequired; }, }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx index 0049e3b8ac..e63f833664 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfig.tsx @@ -41,7 +41,7 @@ export type MoodleConfigSnakeCase = { export type MoodleFormConfigProps = { enterpriseCustomerUuid: string; existingData: MoodleConfigCamelCase; - existingConfigNames: string[]; + existingConfigNames: Map; onSubmit: (moodleConfig: MoodleConfigCamelCase) => void; handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx index 3e2d9d736d..13c494c492 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/Moodle/MoodleConfigEnablePage.tsx @@ -5,7 +5,7 @@ import { Container, Form, Image } from "@edx/paragon"; // @ts-ignore import ValidatedFormControl from "../../../../forms/ValidatedFormControl.tsx"; import { channelMapping, urlValidation } from "../../../../../utils"; -import { INVALID_LINK, INVALID_MOODLE_VERIFICATION, INVALID_NAME, MOODLE_TYPE } from "../../../data/constants"; +import { INVALID_LINK, INVALID_NAME, MOODLE_TYPE } from "../../../data/constants"; import type { FormFieldValidation, } from "../../../../forms/FormContext"; @@ -17,7 +17,13 @@ export const formFieldNames = { TOKEN: "token", USERNAME: "username", PASSWORD: "password", +}; +export const validationMessages = { + displayNameRequired: 'Please enter Display Name', + baseUrlRequired: 'Please enter Moodle Base Url', + serviceNameRequired: 'Please enter Webservice Short Name', + verificationRequired: 'Please provide either a token OR a username and password', }; export const validations: FormFieldValidation[] = [ @@ -37,22 +43,29 @@ export const validations: FormFieldValidation[] = [ formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { const displayName = fields[formFieldNames.DISPLAY_NAME]; - return !displayName; + const error = displayName?.length > 20; + return error && INVALID_NAME; + }, + }, + { + formFieldId: formFieldNames.MOODLE_BASE_URL, + validator: (fields) => { + const error = !fields[formFieldNames.MOODLE_BASE_URL]; + return error && validationMessages.baseUrlRequired; }, }, { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { - const displayName = fields[formFieldNames.DISPLAY_NAME]; - const error = displayName?.length > 20; - return error && INVALID_NAME; + const error = !fields[formFieldNames.DISPLAY_NAME]; + return error && validationMessages.displayNameRequired; }, }, { formFieldId: formFieldNames.SERVICE_SHORT_NAME, validator: (fields) => { - const serviceShortName = fields[formFieldNames.SERVICE_SHORT_NAME]; - return !serviceShortName; + const error = !fields[formFieldNames.SERVICE_SHORT_NAME]; + return error && validationMessages.serviceNameRequired; }, }, { @@ -61,7 +74,6 @@ export const validations: FormFieldValidation[] = [ const token = fields[formFieldNames.TOKEN]; const username = fields[formFieldNames.USERNAME]; const password = fields[formFieldNames.PASSWORD]; - if (!token) { if (username && password) { return false; @@ -74,7 +86,7 @@ export const validations: FormFieldValidation[] = [ if (!token && !username && !password) { return true; } - return INVALID_MOODLE_VERIFICATION; + return validationMessages.verificationRequired; }, }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx index 8de90ca6d4..e533446282 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfig.tsx @@ -43,7 +43,7 @@ export type SAPConfigSnakeCase = { export type SAPFormConfigProps = { enterpriseCustomerUuid: string; existingData: SAPConfigCamelCase; - existingConfigNames: string[]; + existingConfigNames: Map; onSubmit: (sapConfig: SAPConfigCamelCase) => void; handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx index 9ff0690914..923c775d86 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/SAP/SAPConfigEnablePage.tsx @@ -22,6 +22,16 @@ export const formFieldNames = { USER_TYPE: "userType", }; +export const validationMessages = { + displayNameRequired: 'Please enter Display Name', + baseUrlRequired: 'Please enter SAP Base URL', + companyIdRequired: 'Please enter SAP Company ID', + userIdRequired: 'Please enter SAP User ID', + keyRequired: 'Please enter OAuth Client ID', + secretRequired: 'Please enter OAuth Client Secret', + userTypeRequired: 'Please select SAP User Type', +}; + export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.SAPSF_BASE_URL, @@ -38,8 +48,15 @@ export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.DISPLAY_NAME, validator: (fields) => { - const displayName = fields[formFieldNames.DISPLAY_NAME]; - return !displayName; + const error = !fields[formFieldNames.DISPLAY_NAME]; + return error && validationMessages.displayNameRequired; + }, + }, + { + formFieldId: formFieldNames.SAPSF_BASE_URL, + validator: (fields) => { + const error = !fields[formFieldNames.SAPSF_BASE_URL]; + return error && validationMessages.baseUrlRequired; }, }, { @@ -53,36 +70,36 @@ export const validations: FormFieldValidation[] = [ { formFieldId: formFieldNames.SAPSF_COMPANY_ID, validator: (fields) => { - const sapsfCompanyId = fields[formFieldNames.SAPSF_COMPANY_ID]; - return !sapsfCompanyId; + const error = !fields[formFieldNames.SAPSF_COMPANY_ID]; + return error && validationMessages.companyIdRequired; }, }, { formFieldId: formFieldNames.SAPSF_USER_ID, validator: (fields) => { - const sapsfUserId = fields[formFieldNames.SAPSF_USER_ID]; - return !sapsfUserId; + const error = !fields[formFieldNames.SAPSF_USER_ID]; + return error && validationMessages.userIdRequired; }, }, { formFieldId: formFieldNames.KEY, validator: (fields) => { - const key = fields[formFieldNames.KEY]; - return !key; + const error = !fields[formFieldNames.KEY]; + return error && validationMessages.keyRequired; }, }, { formFieldId: formFieldNames.SECRET, validator: (fields) => { - const secret = fields[formFieldNames.SECRET]; - return !secret; + const error = !fields[formFieldNames.SECRET]; + return error && validationMessages.secretRequired; }, }, { formFieldId: formFieldNames.USER_TYPE, validator: (fields) => { - const userType = fields[formFieldNames.USER_TYPE]; - return !userType; + const error = !fields[formFieldNames.USER_TYPE]; + return error && validationMessages.userTypeRequired; }, }, ]; diff --git a/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx b/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx index 7739a95d62..5a9db49c39 100644 --- a/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx +++ b/src/components/settings/SettingsLMSTab/LMSConfigs/utils.tsx @@ -175,13 +175,18 @@ export async function handleSaveHelper( return !err; } -export function checkForDuplicateNames(existingConfigNames: string[]): FormFieldValidation { +export function checkForDuplicateNames(existingConfigNames: Map): FormFieldValidation { return { formFieldId: 'displayName', validator: (fields) => { - return existingConfigNames?.includes(fields['displayName']) - ? INVALID_NAME - : false; + let validName = true; + validName = !(existingConfigNames?.has(fields['displayName'])); + if (fields.id && !validName) { // if we're editing an existing config + if (existingConfigNames.get(fields['displayName']) == fields.id) { + validName = true; + } + } + return validName ? false : INVALID_NAME; }, }; } diff --git a/src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx b/src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx index e72586b3c1..55757e683e 100644 --- a/src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx +++ b/src/components/settings/SettingsLMSTab/LMSFormWorkflowConfig.tsx @@ -37,7 +37,7 @@ export type LMSConfigSnakeCase = BlackboardConfigSnakeCase | CanvasConfigSnakeCa export type LMSFormConfigProps = { enterpriseCustomerUuid: string; existingData: LMSConfigCamelCase; - existingConfigNames: string[]; + existingConfigNames: Map; onSubmit: (LMSConfigCamelCase) => void; handleCloseClick: (submitted: boolean, status: string) => Promise; channelMap: Record>; diff --git a/src/components/settings/SettingsLMSTab/index.jsx b/src/components/settings/SettingsLMSTab/index.jsx index 9615767ccd..5f215dbb4c 100644 --- a/src/components/settings/SettingsLMSTab/index.jsx +++ b/src/components/settings/SettingsLMSTab/index.jsx @@ -38,7 +38,7 @@ const SettingsLMSTab = ({ const [configsExist, setConfigsExist] = useState(false); const [showNoConfigCard, setShowNoConfigCard] = useState(true); const [configsLoading, setConfigsLoading] = useState(true); - const [displayNames, setDisplayNames] = useState([]); + const [displayNames, setDisplayNames] = useState(new Map()); const [existingConfigFormData, setExistingConfigFormData] = useState({}); const [toastMessage, setToastMessage] = useState(); @@ -137,9 +137,11 @@ const SettingsLMSTab = ({ useEffect(() => { // update list of used display names to prevent duplicates + const updatedMap = new Map(); if (existingConfigsData[0]) { - setDisplayNames(existingConfigsData?.map((existingConfig) => existingConfig.displayName)); + existingConfigsData?.forEach((existingConfig) => updatedMap.set(existingConfig.displayName, existingConfig.id)); } + setDisplayNames(updatedMap); }, [existingConfigsData]); return ( diff --git a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx index 562944832b..74f68b3446 100644 --- a/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/BlackboardConfig.test.tsx @@ -17,7 +17,8 @@ import { } from "../../data/constants"; // @ts-ignore import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; -import { findElementWithText } from "../../../test/testUtils"; +// @ts-ignore +import { validationMessages } from "../LMSConfigs/Blackboard/BlackboardConfigAuthorizePage.tsx"; jest.mock("../../data/constants", () => ({ ...jest.requireActual("../../data/constants"), @@ -76,7 +77,7 @@ function testBlackboardConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, handleCloseClick: mockOnClick, existingData: formData, - existingConfigNames: [], + existingConfigNames: new Map(), channelMap: { BLACKBOARD: { post: mockPost, @@ -116,40 +117,38 @@ describe("", () => { screen.getByLabelText("Display Name"); screen.getByLabelText("Blackboard Base URL"); }); - test("test button disable", async () => { + test("test error messages", async () => { render(testBlackboardConfigSetup(noExistingData)); const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); - expect(authorizeButton).toBeDisabled(); - userEvent.type(screen.getByLabelText("Display Name"), "name"); - userEvent.type(screen.getByLabelText("Blackboard Base URL"), "test4"); + userEvent.click(authorizeButton); + expect(screen.queryByText(validationMessages.displayNameRequired)); + expect(screen.queryByText(validationMessages.baseUrlRequired)); - expect(authorizeButton).toBeDisabled(); + userEvent.paste(screen.getByLabelText("Display Name"), "name"); + userEvent.paste(screen.getByLabelText("Blackboard Base URL"), "test4"); + + userEvent.click(authorizeButton) expect(screen.queryByText(INVALID_LINK)); expect(screen.queryByText(INVALID_NAME)); - await act(async () => { - fireEvent.change(screen.getByLabelText("Display Name"), { - target: { value: "" }, - }); - fireEvent.change(screen.getByLabelText("Blackboard Base URL"), { - target: { value: "" }, - }); - }); - userEvent.type(screen.getByLabelText("Display Name"), "displayName"); - userEvent.type( + await clearForm(); + userEvent.paste(screen.getByLabelText("Display Name"), "displayName"); + userEvent.paste( screen.getByLabelText("Blackboard Base URL"), "https://www.test4.com" ); - expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton) + expect(!screen.queryByText(INVALID_LINK)); + expect(!screen.queryByText(INVALID_NAME)); }); test('it edits existing configs on submit', async () => { render(testBlackboardConfigSetup(existingConfigData)); const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); expect(authorizeButton).not.toBeDisabled(); @@ -174,9 +173,8 @@ describe("", () => { await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); - await waitFor(() => expect(authorizeButton).not.toBeDisabled()); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); userEvent.click(authorizeButton); // await authorization loading modal @@ -195,8 +193,8 @@ describe("", () => { const cancelButton = screen.getByRole('button', { name: 'Cancel' }); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); expect(cancelButton).not.toBeDisabled(); userEvent.click(cancelButton); @@ -218,10 +216,8 @@ describe("", () => { render(testBlackboardConfigSetup(noExistingData)); const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); - - expect(authorizeButton).not.toBeDisabled(); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Blackboard Base URL'), 'https://www.test4.com'); userEvent.click(authorizeButton); // await authorization loading modal @@ -231,8 +227,12 @@ describe("", () => { }); test('validates poorly formatted existing data on load', async () => { render(testBlackboardConfigSetup(invalidExistingData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + userEvent.click(authorizeButton); + + await waitFor(() => expect(screen.getByText(INVALID_NAME)).toBeInTheDocument()); expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - await waitFor(() => expect(expect(screen.getByText(INVALID_NAME)).toBeInTheDocument())); + }); test('validates properly formatted existing data on load', () => { render(testBlackboardConfigSetup(existingConfigDataNoAuth)); @@ -243,6 +243,8 @@ describe("", () => { expect((screen.getByLabelText('Blackboard Base URL') as HTMLInputElement).value).toEqual( existingConfigDataNoAuth.blackboardBaseUrl); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + userEvent.click(authorizeButton); expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx index 01a0783aff..fff2bf9d09 100644 --- a/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/CanvasConfig.test.tsx @@ -17,6 +17,8 @@ import { } from "../../data/constants"; // @ts-ignore import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; +// @ts-ignore +import { validationMessages } from "../LMSConfigs/Canvas/CanvasConfigAuthorizePage.tsx"; jest.mock("../../data/constants", () => ({ ...jest.requireActual("../../data/constants"), @@ -81,7 +83,7 @@ function testCanvasConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, handleCloseClick: mockOnClick, existingData: formData, - existingConfigNames: [], + existingConfigNames: new Map(), channelMap: { CANVAS: { post: mockPost, @@ -132,48 +134,48 @@ describe("", () => { screen.getByLabelText("Canvas Account Number"); screen.getByLabelText("Canvas Base URL"); }); - test("test button disable", async () => { + test("test error messages", async () => { render(testCanvasConfigSetup(noExistingData)); const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); - expect(authorizeButton).toBeDisabled(); - userEvent.type(screen.getByLabelText("Display Name"), "name"); - userEvent.type(screen.getByLabelText("Canvas Base URL"), "test4"); - userEvent.type(screen.getByLabelText("API Client ID"), "test3"); - userEvent.type(screen.getByLabelText("Canvas Account Number"), "23"); - userEvent.type(screen.getByLabelText("API Client Secret"), "test6"); - - expect(authorizeButton).toBeDisabled(); + userEvent.click(authorizeButton); + expect(screen.queryByText(validationMessages.displayNameRequired)); + expect(screen.queryByText(validationMessages.canvasUrlRequired)); + expect(screen.queryByText(validationMessages.clientIdRequired)); + expect(screen.queryByText(validationMessages.accountIdRequired)); + expect(screen.queryByText(validationMessages.clientSecretRequired)); + + userEvent.paste(screen.getByLabelText("Display Name"), "name"); + userEvent.paste(screen.getByLabelText("Canvas Base URL"), "test4"); + userEvent.paste(screen.getByLabelText("API Client ID"), "test3"); + userEvent.paste(screen.getByLabelText("Canvas Account Number"), "23"); + userEvent.paste(screen.getByLabelText("API Client Secret"), "test6"); + userEvent.click(authorizeButton); + expect(screen.queryByText(INVALID_LINK)); expect(screen.queryByText(INVALID_NAME)); - await act(async () => { - fireEvent.change(screen.getByLabelText("Display Name"), { - target: { value: "" }, - }); - fireEvent.change(screen.getByLabelText("Canvas Base URL"), { - target: { value: "" }, - }); - }); - userEvent.type(screen.getByLabelText("Display Name"), "displayName"); - userEvent.type( + await clearForm(); + userEvent.paste(screen.getByLabelText("Display Name"), "displayName"); + userEvent.paste( screen.getByLabelText("Canvas Base URL"), "https://www.test4.com" ); - - expect(authorizeButton).not.toBeDisabled(); + userEvent.click(authorizeButton); + expect(!screen.queryByText(INVALID_LINK)); + expect(!screen.queryByText(INVALID_NAME)); }); test('saves draft correctly', async () => { render(testCanvasConfigSetup(noExistingData)); const cancelButton = screen.getByRole('button', { name: 'Cancel' }); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + userEvent.paste(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.paste(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.paste(screen.getByLabelText('API Client Secret'), 'test2'); expect(cancelButton).not.toBeDisabled(); userEvent.click(cancelButton); @@ -199,13 +201,11 @@ describe("", () => { const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); - userEvent.type(screen.getByLabelText('API Client ID'), 'test1'); - userEvent.type(screen.getByLabelText('Canvas Account Number'), '3'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'test2'); - - expect(authorizeButton).not.toBeDisabled(); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Canvas Base URL'), 'https://www.test4.com'); + userEvent.paste(screen.getByLabelText('API Client ID'), 'test1'); + userEvent.paste(screen.getByLabelText('Canvas Account Number'), '3'); + userEvent.paste(screen.getByLabelText('API Client Secret'), 'test2'); userEvent.click(authorizeButton); // await authorization loading modal @@ -215,11 +215,14 @@ describe("", () => { }); test('validates poorly formatted existing data on load', async () => { render(testCanvasConfigSetup(invalidExistingData)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); + userEvent.click(authorizeButton); + await waitFor(() => expect(screen.getByText(INVALID_NAME)).toBeInTheDocument()); expect(screen.getByText(INVALID_LINK)).toBeInTheDocument(); - await waitFor(() => expect(expect(screen.getByText(INVALID_NAME)).toBeInTheDocument())); }); test('validates properly formatted existing data on load', () => { render(testCanvasConfigSetup(existingConfigDataNoAuth)); + const authorizeButton = screen.getByRole('button', { name: 'Authorize' }); // ensuring the existing data is prefilled expect((screen.getByLabelText("Display Name") as HTMLInputElement).value).toEqual( existingConfigDataNoAuth.displayName); @@ -232,6 +235,7 @@ describe("", () => { expect((screen.getByLabelText("API Client Secret") as HTMLInputElement).value).toEqual( existingConfigDataNoAuth.clientSecret); + userEvent.click(authorizeButton); expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx index d57dda3357..ae3326b16a 100644 --- a/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/CornerstoneConfig.test.jsx @@ -46,7 +46,7 @@ function testCornerstoneConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, handleCloseClick: mockOnClick, existingData: formData, - existingConfigNames: [], + existingConfigNames: new Map(), channelMap: { CSOD: { post: mockPost, @@ -89,32 +89,29 @@ describe('', () => { const enableButton = screen.getByRole('button', { name: 'Enable' }); await clearForm(); - expect(enableButton).toBeDisabled(); - userEvent.type(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); - userEvent.type(screen.getByLabelText('Cornerstone Base URL'), 'badlink'); - expect(enableButton).toBeDisabled(); + userEvent.paste(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); + userEvent.paste(screen.getByLabelText('Cornerstone Base URL'), 'badlink'); + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)); expect(screen.queryByText(INVALID_NAME)); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type( + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste( screen.getByLabelText('Cornerstone Base URL'), 'https://www.test4.com', ); - expect(enableButton).not.toBeDisabled(); + expect(!screen.queryByText(INVALID_LINK)); + expect(!screen.queryByText(INVALID_NAME)); }); test('it creates new configs on submit', async () => { render(testCornerstoneConfigSetup(noExistingData)); const enableButton = screen.getByRole('button', { name: 'Enable' }); await clearForm(); - - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Cornerstone Base URL'), 'https://www.test.com'); - await waitFor(() => expect(enableButton).not.toBeDisabled()); - + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Cornerstone Base URL'), 'https://www.test.com'); userEvent.click(enableButton); const expectedConfig = { active: false, @@ -129,8 +126,8 @@ describe('', () => { const cancelButton = screen.getByRole('button', { name: 'Cancel' }); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Cornerstone Base URL'), 'https://www.test.com'); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Cornerstone Base URL'), 'https://www.test.com'); expect(cancelButton).not.toBeDisabled(); userEvent.click(cancelButton); @@ -148,6 +145,8 @@ describe('', () => { }); test('validates poorly formatted existing data on load', async () => { render(testCornerstoneConfigSetup(invalidExistingData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx index 84a77cd81c..87593d00fb 100644 --- a/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx +++ b/src/components/settings/SettingsLMSTab/tests/DegreedConfig.test.tsx @@ -10,6 +10,8 @@ import { INVALID_LINK, INVALID_NAME } from "../../data/constants"; import LmsApiService from "../../../../data/services/LmsApiService"; // @ts-ignore import FormContextWrapper from "../../../forms/FormContextWrapper.tsx"; +// @ts-ignore +import { validationMessages } from "../LMSConfigs/Degreed/DegreedConfigEnablePage.tsx"; const mockUpdateConfigApi = jest.spyOn(LmsApiService, "updateDegreedConfig"); const mockConfigResponseData = { @@ -70,7 +72,7 @@ function testDegreedConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, handleCloseClick: mockOnClick, existingData: formData, - existingConfigNames: [], + existingConfigNames: new Map(), channelMap: { DEGREED2: { post: mockPost, @@ -119,17 +121,21 @@ describe("", () => { screen.getByLabelText("Degreed Base URL"); screen.getByLabelText("Degreed Token Fetch Base URL"); }); - test("test button disable", async () => { + test("test error messages", async () => { render(testDegreedConfigSetup(noExistingData)); const enableButton = screen.getByRole('button', { name: 'Enable' }); await clearForm(); - expect(enableButton).toBeDisabled(); + userEvent.click(enableButton); + expect(screen.queryByText(validationMessages.displayNameRequired)); + expect(screen.queryByText(validationMessages.clientIdRequired)); + expect(screen.queryByText(validationMessages.clientSecretRequired)); + expect(screen.queryByText(validationMessages.degreedUrlRequired)); - userEvent.type(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); - userEvent.type(screen.getByLabelText('API Client ID'), '1'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); - userEvent.type(screen.getByLabelText('Degreed Base URL'), 'badlink'); + userEvent.paste(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); + userEvent.paste(screen.getByLabelText('API Client ID'), '1'); + userEvent.paste(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); + userEvent.paste(screen.getByLabelText('Degreed Base URL'), 'badlink'); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: '' }, @@ -137,29 +143,28 @@ describe("", () => { fireEvent.change(screen.getByLabelText('Degreed Base URL'), { target: { value: '' }, }); - expect(enableButton).toBeDisabled(); + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)); expect(screen.queryByText(INVALID_NAME)); - userEvent.type(screen.getByLabelText("Display Name"), "displayName"); - userEvent.type( + userEvent.paste(screen.getByLabelText("Display Name"), "displayName"); + userEvent.paste( screen.getByLabelText("Degreed Base URL"), "https://www.test4.com" ); - expect(enableButton).not.toBeDisabled(); + userEvent.click(enableButton); + expect(!screen.queryByText(INVALID_LINK)); + expect(!screen.queryByText(INVALID_NAME)); }); test('it creates new configs on submit', async () => { render(testDegreedConfigSetup(noExistingData)); const enableButton = screen.getByRole('button', { name: 'Enable' }); await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('API Client ID'), '1'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); - userEvent.type(screen.getByLabelText('Degreed Base URL'), 'https://www.test.com'); - userEvent.type(screen.getByLabelText('Degreed Token Fetch Base URL'), 'https://www.test.com'); - - await waitFor(() => expect(enableButton).not.toBeDisabled()); - + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('API Client ID'), '1'); + userEvent.paste(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); + userEvent.paste(screen.getByLabelText('Degreed Base URL'), 'https://www.test.com'); + userEvent.paste(screen.getByLabelText('Degreed Token Fetch Base URL'), 'https://www.test.com'); userEvent.click(enableButton); const expectedConfig = { @@ -178,12 +183,11 @@ describe("", () => { const cancelButton = screen.getByRole('button', { name: 'Cancel' }); await clearForm(); - - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('API Client ID'), '1'); - userEvent.type(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); - userEvent.type(screen.getByLabelText('Degreed Base URL'), 'https://www.test.com'); - userEvent.type(screen.getByLabelText('Degreed Token Fetch Base URL'), 'https://www.test.com'); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('API Client ID'), '1'); + userEvent.paste(screen.getByLabelText('API Client Secret'), 'shhhitsasecret123'); + userEvent.paste(screen.getByLabelText('Degreed Base URL'), 'https://www.test.com'); + userEvent.paste(screen.getByLabelText('Degreed Token Fetch Base URL'), 'https://www.test.com'); expect(cancelButton).not.toBeDisabled(); userEvent.click(cancelButton); @@ -205,11 +209,14 @@ describe("", () => { }); test('validates poorly formatted existing data on load', async () => { render(testDegreedConfigSetup(invalidExistingData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); + userEvent.click(enableButton) expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); }); test('validates properly formatted existing data on load', () => { render(testDegreedConfigSetup(existingConfigData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); // ensuring the existing data is prefilled expect((screen.getByLabelText('Display Name') as HTMLInputElement).value).toEqual( existingConfigData.displayName); @@ -222,6 +229,7 @@ describe("", () => { expect((screen.getByLabelText('Degreed Token Fetch Base URL') as HTMLInputElement).value).toEqual( existingConfigData.degreedTokenFetchBaseUrl) + userEvent.click(enableButton) expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx index 220459b2df..3470e5e55d 100644 --- a/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/MoodleConfig.test.jsx @@ -11,10 +11,12 @@ import '@testing-library/jest-dom/extend-expect'; // @ts-ignore import MoodleConfig from '../LMSConfigs/Moodle/MoodleConfig.tsx'; -import { INVALID_LINK, INVALID_MOODLE_VERIFICATION, INVALID_NAME } from '../../data/constants'; +import { INVALID_LINK, INVALID_NAME } from '../../data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; // @ts-ignore import FormContextWrapper from '../../../forms/FormContextWrapper.tsx'; +// @ts-ignore +import { validationMessages } from '../LMSConfigs/Moodle/MoodleConfigEnablePage.tsx'; const mockUpdateConfigApi = jest.spyOn(LmsApiService, 'updateMoodleConfig'); const mockConfigResponseData = { @@ -74,7 +76,7 @@ function testMoodleConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, handleCloseClick: mockOnClick, existingData: formData, - existingConfigNames: [], + existingConfigNames: new Map(), channelMap: { MOODLE: { post: mockPost, @@ -125,23 +127,26 @@ describe('', () => { screen.getByLabelText('Username'); screen.getByLabelText('Password'); }); - test('test button disable', async () => { + test('test error messages', async () => { render(testMoodleConfigSetup(noExistingData)); const enableButton = screen.getByRole('button', { name: 'Enable' }); await clearForm(); - expect(enableButton).toBeDisabled(); + userEvent.click(enableButton); + expect(screen.queryByText(validationMessages.displayNameRequired)); + expect(screen.queryByText(validationMessages.baseUrlRequired)); + expect(screen.queryByText(validationMessages.serviceNameRequired)); + expect(screen.queryByText(validationMessages.verificationRequired)); - userEvent.type(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); - userEvent.type(screen.getByLabelText('Moodle Base URL'), 'badlink'); - userEvent.type(screen.getByLabelText('Webservice Short Name'), 'name'); - userEvent.type(screen.getByLabelText('Token'), 'ofmyaffection'); - userEvent.type(screen.getByLabelText('Username'), 'user'); + userEvent.paste(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); + userEvent.paste(screen.getByLabelText('Moodle Base URL'), 'badlink'); + userEvent.paste(screen.getByLabelText('Webservice Short Name'), 'name'); + userEvent.paste(screen.getByLabelText('Token'), 'ofmyaffection'); + userEvent.paste(screen.getByLabelText('Username'), 'user'); - expect(enableButton).toBeDisabled(); expect(screen.queryByText(INVALID_LINK)); expect(screen.queryByText(INVALID_NAME)); - expect(screen.queryByText(INVALID_MOODLE_VERIFICATION)); + expect(screen.queryByText(validationMessages.serviceNameRequired)); fireEvent.change(screen.getByLabelText('Display Name'), { target: { value: '' }, @@ -152,16 +157,15 @@ describe('', () => { fireEvent.change(screen.getByLabelText('Username'), { target: { value: '' }, }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type( + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste( screen.getByLabelText('Moodle Base URL'), 'https://www.test.com', ); - expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_MOODLE_VERIFICATION)).not.toBeInTheDocument(); - expect(enableButton).not.toBeDisabled(); + expect(!screen.queryByText(INVALID_LINK)); + expect(screen.queryByText(INVALID_NAME)); + expect(!screen.queryByText(validationMessages.serviceNameRequired)); }); test('it creates new configs on submit', async () => { render(testMoodleConfigSetup(noExistingData)); @@ -169,17 +173,13 @@ describe('', () => { await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Moodle Base URL'), 'https://www.test.com'); - userEvent.type(screen.getByLabelText('Webservice Short Name'), 'name'); - userEvent.type(screen.getByLabelText('Username'), 'user'); - userEvent.type(screen.getByLabelText('Password'), 'password123'); - - await waitFor(() => expect(enableButton).not.toBeDisabled()); - await act(async () => { userEvent.click(enableButton); }); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Moodle Base URL'), 'https://www.test.com'); + userEvent.paste(screen.getByLabelText('Webservice Short Name'), 'name'); + userEvent.paste(screen.getByLabelText('Username'), 'user'); + userEvent.paste(screen.getByLabelText('Password'), 'password123'); userEvent.click(enableButton); - const expectedConfig = { active: false, display_name: 'displayName', @@ -198,11 +198,11 @@ describe('', () => { await clearForm(); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type(screen.getByLabelText('Moodle Base URL'), 'https://www.test.com'); - userEvent.type(screen.getByLabelText('Webservice Short Name'), 'name'); - userEvent.type(screen.getByLabelText('Username'), 'user'); - userEvent.type(screen.getByLabelText('Password'), 'password123'); + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste(screen.getByLabelText('Moodle Base URL'), 'https://www.test.com'); + userEvent.paste(screen.getByLabelText('Webservice Short Name'), 'name'); + userEvent.paste(screen.getByLabelText('Username'), 'user'); + userEvent.paste(screen.getByLabelText('Password'), 'password123'); expect(cancelButton).not.toBeDisabled(); userEvent.click(cancelButton); @@ -226,20 +226,25 @@ describe('', () => { }); test('validates poorly formatted existing data on load', async () => { render(testMoodleConfigSetup(invalidExistingData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); - expect(screen.queryByText(INVALID_MOODLE_VERIFICATION)).toBeInTheDocument(); + expect(screen.queryByText(validationMessages.verificationRequired)).toBeInTheDocument(); }); test('validates properly formatted existing data on load', () => { render(testMoodleConfigSetup(existingConfigData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); // ensuring the existing data is prefilled expect(screen.getByLabelText('Display Name').value).toEqual(existingConfigData.displayName); expect(screen.getByLabelText('Moodle Base URL').value).toEqual(existingConfigData.moodleBaseUrl); expect(screen.getByLabelText('Webservice Short Name').value).toEqual(existingConfigData.serviceShortName); expect(screen.getByLabelText('Token').value).toEqual(existingConfigData.token); + userEvent.click(enableButton); + expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - expect(screen.queryByText(INVALID_MOODLE_VERIFICATION)).not.toBeInTheDocument(); + expect(screen.queryByText(validationMessages.serviceNameRequired)).not.toBeInTheDocument(); }); }); diff --git a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx index 57b82a60bb..1cd2132c86 100644 --- a/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx +++ b/src/components/settings/SettingsLMSTab/tests/SAPConfig.test.jsx @@ -15,6 +15,8 @@ import { INVALID_LINK, INVALID_NAME } from '../../data/constants'; import LmsApiService from '../../../../data/services/LmsApiService'; // @ts-ignore import FormContextWrapper from '../../../forms/FormContextWrapper.tsx'; +// @ts-ignore +import { validationMessages } from '../LMSConfigs/SAP/SAPConfigEnablePage.tsx'; const mockUpdateConfigApi = jest.spyOn(LmsApiService, 'updateSuccessFactorsConfig'); const mockConfigResponseData = { @@ -78,7 +80,7 @@ function testSAPConfigSetup(formData) { onSubmit: mockSetExistingConfigFormData, handleCloseClick: mockOnClick, existingData: formData, - existingConfigNames: [], + existingConfigNames: new Map(), channelMap: { SAP: { post: mockPost, @@ -130,22 +132,29 @@ describe('', () => { screen.getByLabelText('OAuth Client Secret'); screen.getByLabelText('SAP User Type'); }); - test('test button disable', async () => { + test('test error messages', async () => { render(testSAPConfigSetup(noExistingData)); const enableButton = screen.getByRole('button', { name: 'Enable' }); await clearForm(); - expect(enableButton).toBeDisabled(); - - userEvent.type(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); - userEvent.type(screen.getByLabelText('SAP Base URL'), 'badlink'); - userEvent.type(screen.getByLabelText('SAP Company ID'), '1'); - userEvent.type(screen.getByLabelText('SAP User ID'), '1'); - userEvent.type(screen.getByLabelText('OAuth Client ID'), 'id'); - userEvent.type(screen.getByLabelText('OAuth Client Secret'), 'secret'); + userEvent.click(enableButton); + expect(screen.queryByText(validationMessages.displayNameRequired)); + expect(screen.queryByText(validationMessages.baseUrlRequired)); + expect(screen.queryByText(validationMessages.companyIdRequired)); + expect(screen.queryByText(validationMessages.userIdRequired)); + expect(screen.queryByText(validationMessages.keyRequired)); + expect(screen.queryByText(validationMessages.secretRequired)); + expect(screen.queryByText(validationMessages.userTypeRequired)); + + userEvent.paste(screen.getByLabelText('Display Name'), 'terriblenogoodverybaddisplayname'); + userEvent.paste(screen.getByLabelText('SAP Base URL'), 'badlink'); + userEvent.paste(screen.getByLabelText('SAP Company ID'), '1'); + userEvent.paste(screen.getByLabelText('SAP User ID'), '1'); + userEvent.paste(screen.getByLabelText('OAuth Client ID'), 'id'); + userEvent.paste(screen.getByLabelText('OAuth Client Secret'), 'secret'); userEvent.click(screen.getByLabelText('Admin')); - expect(enableButton).toBeDisabled(); + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)); expect(screen.queryByText(INVALID_NAME)); @@ -155,34 +164,29 @@ describe('', () => { fireEvent.change(screen.getByLabelText('SAP Base URL'), { target: { value: '' }, }); - userEvent.type(screen.getByLabelText('Display Name'), 'displayName'); - userEvent.type( + userEvent.paste(screen.getByLabelText('Display Name'), 'displayName'); + userEvent.paste( screen.getByLabelText('SAP Base URL'), 'https://www.test.com', ); - + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); - expect(enableButton).not.toBeDisabled(); }); test('it creates new configs on submit', async () => { render(testSAPConfigSetup(noExistingData)); const enableButton = screen.getByRole('button', { name: 'Enable' }); await clearForm(); - - userEvent.type(screen.getByLabelText('Display Name'), 'lmsconfig'); - userEvent.type(screen.getByLabelText('SAP Base URL'), 'http://www.example.com'); - userEvent.type(screen.getByLabelText('SAP Company ID'), '1'); - userEvent.type(screen.getByLabelText('SAP User ID'), '1'); - userEvent.type(screen.getByLabelText('OAuth Client ID'), 'id'); - userEvent.type(screen.getByLabelText('OAuth Client Secret'), 'secret'); + userEvent.paste(screen.getByLabelText('Display Name'), 'lmsconfig'); + userEvent.paste(screen.getByLabelText('SAP Base URL'), 'http://www.example.com'); + userEvent.paste(screen.getByLabelText('SAP Company ID'), '1'); + userEvent.paste(screen.getByLabelText('SAP User ID'), '1'); + userEvent.paste(screen.getByLabelText('OAuth Client ID'), 'id'); + userEvent.paste(screen.getByLabelText('OAuth Client Secret'), 'secret'); userEvent.click(screen.getByLabelText('Admin')); - await waitFor(() => expect(enableButton).not.toBeDisabled()); - userEvent.click(enableButton); - const expectedConfig = { active: false, display_name: 'lmsconfig', @@ -201,13 +205,12 @@ describe('', () => { const cancelButton = screen.getByRole('button', { name: 'Cancel' }); await clearForm(); - - userEvent.type(screen.getByLabelText('Display Name'), 'lmsconfig'); - userEvent.type(screen.getByLabelText('SAP Base URL'), 'http://www.example.com'); - userEvent.type(screen.getByLabelText('SAP Company ID'), '1'); - userEvent.type(screen.getByLabelText('SAP User ID'), '1'); - userEvent.type(screen.getByLabelText('OAuth Client ID'), 'id'); - userEvent.type(screen.getByLabelText('OAuth Client Secret'), 'secret'); + userEvent.paste(screen.getByLabelText('Display Name'), 'lmsconfig'); + userEvent.paste(screen.getByLabelText('SAP Base URL'), 'http://www.example.com'); + userEvent.paste(screen.getByLabelText('SAP Company ID'), '1'); + userEvent.paste(screen.getByLabelText('SAP User ID'), '1'); + userEvent.paste(screen.getByLabelText('OAuth Client ID'), 'id'); + userEvent.paste(screen.getByLabelText('OAuth Client Secret'), 'secret'); userEvent.click(screen.getByLabelText('User')); expect(cancelButton).not.toBeDisabled(); @@ -233,11 +236,14 @@ describe('', () => { }); test('validates poorly formatted existing data on load', async () => { render(testSAPConfigSetup(invalidExistingData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)).toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).toBeInTheDocument(); }); test('validates properly formatted existing data on load', () => { render(testSAPConfigSetup(existingConfigData)); + const enableButton = screen.getByRole('button', { name: 'Enable' }); // ensuring the existing data is prefilled expect(screen.getByLabelText('Display Name').value).toEqual(existingConfigData.displayName); expect(screen.getByLabelText('SAP Base URL').value).toEqual(existingConfigData.sapsfBaseUrl); @@ -246,6 +252,7 @@ describe('', () => { expect(screen.getByLabelText('OAuth Client ID').value).toEqual(existingConfigData.key); expect(screen.getByLabelText('OAuth Client Secret').value).toEqual(existingConfigData.secret); + userEvent.click(enableButton); expect(screen.queryByText(INVALID_LINK)).not.toBeInTheDocument(); expect(screen.queryByText(INVALID_NAME)).not.toBeInTheDocument(); }); diff --git a/src/components/settings/data/constants.js b/src/components/settings/data/constants.js index 3e080b3b27..0c82e89381 100644 --- a/src/components/settings/data/constants.js +++ b/src/components/settings/data/constants.js @@ -12,9 +12,17 @@ const SSO_TAB_LABEL = 'Single Sign On (SSO)'; const APPEARANCE_TAB_LABEL = 'Portal Appearance'; export const HELP_CENTER_LINK = 'https://business-support.edx.org/hc/en-us/categories/360000368453-Integrations'; +export const HELP_CENTER_BLACKBOARD = 'https://business-support.edx.org/hc/en-us/sections/4405096719895-Blackboard'; +export const HELP_CENTER_CANVAS = 'https://business-support.edx.org/hc/en-us/sections/1500002584121-Canvas'; +export const HELP_CENTER_CORNERSTONE = 'https://business-support.edx.org/hc/en-us/sections/1500002151021-Cornerstone'; +export const HELP_CENTER_DEGREED = 'https://business-support.edx.org/hc/en-us/sections/360000868494-Degreed'; +export const HELP_CENTER_MOODLE = 'https://business-support.edx.org/hc/en-us/sections/1500002758722-Moodle'; +export const HELP_CENTER_SAP = 'https://business-support.edx.org/hc/en-us/sections/360000868534-SuccessFactors'; + export const HELP_CENTER_SAML_LINK = 'https://business-support.edx.org/hc/en-us/articles/360005421073-5-Implementing-Single-Sign-on-SSO-with-edX'; export const HELP_CENTER_SAP_IDP_LINK = 'https://business-support.edx.org/hc/en-us/articles/360005205314'; export const HELP_CENTER_BRANDING_LINK = 'https://business-support.edx.org/hc/en-us/sections/8739219372183'; + export const ACTIVATE_TOAST_MESSAGE = 'Learning platform integration successfully activated.'; export const DELETE_TOAST_MESSAGE = 'Learning platform integration successfully removed.'; export const INACTIVATE_TOAST_MESSAGE = 'Learning platform integration successfully disabled.'; @@ -39,7 +47,6 @@ export const INVALID_LENGTH = 'Max length must be a number, but cannot be over 2 export const INVALID_API_ROOT_URL = 'OAuth API Root URL attribute must be a valid URL'; export const INVALID_SAPSF_OAUTH_ROOT_URL = 'SAPSF OAuth URL attribute must be a valid URL'; export const INVALID_ODATA_API_TIMEOUT_INTERVAL = 'OData API timeout interval must be a number less than 30'; -export const INVALID_MOODLE_VERIFICATION = 'Please provide either a token OR a username and password'; /** * Used as tab values and in router params diff --git a/src/containers/SidebarToggle/SidebarToggle.test.jsx b/src/containers/SidebarToggle/SidebarToggle.test.jsx index 108183f46d..301624074a 100644 --- a/src/containers/SidebarToggle/SidebarToggle.test.jsx +++ b/src/containers/SidebarToggle/SidebarToggle.test.jsx @@ -5,7 +5,7 @@ import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; import { mount } from 'enzyme'; -import { Close, Menu } from '@edx/paragon/icons'; +import { Close, MenuIcon } from '@edx/paragon/icons'; import SidebarToggle from './index'; import { @@ -42,7 +42,7 @@ SidebarToggleWrapper.propTypes = { describe('', () => { it('renders correctly with menu icon', () => { const wrapper = mount(); - expect(wrapper.find(Menu)).toHaveLength(1); + expect(wrapper.find(MenuIcon)).toHaveLength(1); }); it('renders correctly with close icon', () => { diff --git a/src/utils.js b/src/utils.js index 8cd7e6b59e..6fe486e35c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -13,7 +13,9 @@ import { history } from '@edx/frontend-platform/initialize'; import { features } from './config'; import { - BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, MOODLE_TYPE, SAP_TYPE, + BLACKBOARD_TYPE, CANVAS_TYPE, CORNERSTONE_TYPE, DEGREED2_TYPE, + HELP_CENTER_BLACKBOARD, HELP_CENTER_CANVAS, HELP_CENTER_CORNERSTONE, + HELP_CENTER_DEGREED, HELP_CENTER_MOODLE, HELP_CENTER_SAP, MOODLE_TYPE, SAP_TYPE, } from './components/settings/data/constants'; import BlackboardIcon from './icons/Blackboard.svg'; import CanvasIcon from './icons/Canvas.svg'; @@ -251,6 +253,7 @@ function urlValidation(urlString) { const normalizeFileUpload = (value) => value && value.split(/\r\n|\n/); +// this is needed for annoying testing mock reasons export const getChannelMap = () => ({ [BLACKBOARD_TYPE]: { displayName: 'Blackboard', @@ -303,6 +306,7 @@ const channelMapping = { [BLACKBOARD_TYPE]: { displayName: 'Blackboard', icon: BlackboardIcon, + helpCenter: HELP_CENTER_BLACKBOARD, delete: LmsApiService.deleteBlackboardConfig, fetch: LmsApiService.fetchSingleBlackboardConfig, fetchGlobal: LmsApiService.fetchBlackboardGlobalConfig, @@ -312,6 +316,7 @@ const channelMapping = { [CANVAS_TYPE]: { displayName: 'Canvas', icon: CanvasIcon, + helpCenter: HELP_CENTER_CANVAS, delete: LmsApiService.deleteCanvasConfig, fetch: LmsApiService.fetchSingleCanvasConfig, post: LmsApiService.postNewCanvasConfig, @@ -320,6 +325,7 @@ const channelMapping = { [CORNERSTONE_TYPE]: { displayName: 'Cornerstone', icon: CornerstoneIcon, + helpCenter: HELP_CENTER_CORNERSTONE, delete: LmsApiService.deleteCornerstoneConfig, fetch: LmsApiService.fetchSingleCornerstoneConfig, post: LmsApiService.postNewCornerstoneConfig, @@ -328,6 +334,7 @@ const channelMapping = { [DEGREED2_TYPE]: { displayName: 'Degreed', icon: DegreedIcon, + helpCenter: HELP_CENTER_DEGREED, delete: LmsApiService.deleteDegreed2Config, fetch: LmsApiService.fetchSingleDegreed2Config, post: LmsApiService.postNewDegreed2Config, @@ -336,6 +343,7 @@ const channelMapping = { [MOODLE_TYPE]: { displayName: 'Moodle', icon: MoodleIcon, + helpCenter: HELP_CENTER_MOODLE, delete: LmsApiService.deleteMoodleConfig, fetch: LmsApiService.fetchSingleMoodleConfig, post: LmsApiService.postNewMoodleConfig, @@ -344,6 +352,7 @@ const channelMapping = { [SAP_TYPE]: { displayName: 'SAP Success Factors', icon: SAPIcon, + helpCenter: HELP_CENTER_SAP, delete: LmsApiService.deleteSuccessFactorsConfig, fetch: LmsApiService.fetchSingleSuccessFactorsConfig, post: LmsApiService.postNewSuccessFactorsConfig, From 4de58460b27b7f1be03386dfb7cbcc24755b6e34 Mon Sep 17 00:00:00 2001 From: Emily Aquin Date: Thu, 25 May 2023 22:19:50 +0000 Subject: [PATCH 72/73] feat: enable enterprise apis in learner credit management --- .env.development | 1 + .env.test | 1 + .../EnterpriseSubsidiesContext/data/hooks.js | 45 ++++++-- .../data/tests/hooks.test.js | 105 +++++++++++++++--- .../EnterpriseSubsidiesContext/index.jsx | 2 +- .../LearnerCreditManagement.jsx | 6 +- .../tests/LearnerCreditManagement.test.jsx | 12 +- src/config/index.js | 1 + .../services/EnterpriseSubsidyApiService.js | 22 ++++ .../tests/EnterpriseSubsidyApiService.test.js | 25 +++++ 10 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 src/data/services/EnterpriseSubsidyApiService.js create mode 100644 src/data/services/tests/EnterpriseSubsidyApiService.test.js diff --git a/.env.development b/.env.development index 453ee05271..d74a56083c 100644 --- a/.env.development +++ b/.env.development @@ -12,6 +12,7 @@ LICENSE_MANAGER_BASE_URL='http://localhost:18170' DISCOVERY_BASE_URL='http://localhost:18381' ENTERPRISE_CATALOG_BASE_URL='http://localhost:18160' ENTERPRISE_ACCESS_BASE_URL='http://localhost:18270' +ENTERPRISE_SUBSIDY_BASE_URL='http://localhost:18280' ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734' ENTERPRISE_SUPPORT_URL='https://edx.org' ENTERPRISE_SUPPORT_REVOKE_LICENSE_URL='https://edx.org' diff --git a/.env.test b/.env.test index bd586ba243..bb5c5aef28 100644 --- a/.env.test +++ b/.env.test @@ -6,6 +6,7 @@ ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734' DISCOVERY_BASE_URL='http://localhost:18381' ENTERPRISE_CATALOG_BASE_URL='http://localhost:18160' ENTERPRISE_ACCESS_BASE_URL='http://localhost:18270' +ENTERPRISE_SUBSIDY_BASE_URL='http://localhost:18280' LOGO_URL='https://edx-cdn.org/v3/prod/logo.svg' LOGO_TRADEMARK_URL='https://edx-cdn.org/v3/default/logo-trademark.svg' LOGO_WHITE_URL='https://edx-cdn.org/v3/default/logo-white.svg' diff --git a/src/components/EnterpriseSubsidiesContext/data/hooks.js b/src/components/EnterpriseSubsidiesContext/data/hooks.js index f4f0380935..1bb9fc505a 100644 --- a/src/components/EnterpriseSubsidiesContext/data/hooks.js +++ b/src/components/EnterpriseSubsidiesContext/data/hooks.js @@ -2,24 +2,52 @@ import { useEffect, useState } from 'react'; import { logError } from '@edx/frontend-platform/logging'; import { getConfig } from '@edx/frontend-platform/config'; import { camelCaseObject } from '@edx/frontend-platform/utils'; +import moment from 'moment'; import EcommerceApiService from '../../../data/services/EcommerceApiService'; import LicenseManagerApiService from '../../../data/services/LicenseManagerAPIService'; +import SubsidyApiService from '../../../data/services/EnterpriseSubsidyApiService'; -export const useEnterpriseOffers = ({ enablePortalLearnerCreditManagementScreen }) => { +export const useEnterpriseOffers = ({ enablePortalLearnerCreditManagementScreen, enterpriseId }) => { const [offers, setOffers] = useState([]); const [isLoading, setIsLoading] = useState(true); const [canManageLearnerCredit, setCanManageLearnerCredit] = useState(false); useEffect(() => { + setIsLoading(true); const fetchOffers = async () => { try { - const response = await EcommerceApiService.fetchEnterpriseOffers({ - isCurrent: true, - }); - const { results } = camelCaseObject(response.data); - setOffers(results); + const [enterpriseSubsidyResponse, ecommerceApiResponse] = await Promise.all([ + SubsidyApiService.getSubsidyByCustomerUUID(enterpriseId), + EcommerceApiService.fetchEnterpriseOffers({ + isCurrent: true, + }), + ]); + + // If there are no subsidies in enterprise, fall back to the e-commerce API. + let { results } = camelCaseObject(enterpriseSubsidyResponse.data); + let source = 'subsidyApi'; + if (results.length === 0) { + results = camelCaseObject(ecommerceApiResponse.data.results); + source = 'ecommerceApi'; + } + + if (results.length !== 0) { + const subsidy = results[0]; + const isCurrent = source === 'ecommerceApi' + ? subsidy.isCurrent + : moment().isSameOrBefore(subsidy.expirationDatetime) + && moment().isSameOrAfter(subsidy.activeDatetime); + const offerData = { + id: subsidy.uuid || subsidy.id, + name: subsidy.title || subsidy.displayName, + start: subsidy.activeDatetime || subsidy.startDatetime, + end: subsidy.expirationDatetime || subsidy.endDatetime, + isCurrent, + }; + setOffers([offerData]); + } // We only released learner credit management to customers with 1 offer for the MVP. if (results.length === 1) { setCanManageLearnerCredit(true); @@ -31,12 +59,13 @@ export const useEnterpriseOffers = ({ enablePortalLearnerCreditManagementScreen } }; - if (getConfig().FEATURE_LEARNER_CREDIT_MANAGEMENT && enablePortalLearnerCreditManagementScreen) { + if (getConfig().FEATURE_LEARNER_CREDIT_MANAGEMENT + && enablePortalLearnerCreditManagementScreen) { fetchOffers(); } else { setIsLoading(false); } - }, [enablePortalLearnerCreditManagementScreen]); + }, [enablePortalLearnerCreditManagementScreen, enterpriseId]); return { isLoading, diff --git a/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js b/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js index 5a1f8e9807..87101a7b52 100644 --- a/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js +++ b/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react-hooks/dom'; import { useCoupons, useCustomerAgreement, useEnterpriseOffers } from '../hooks'; import EcommerceApiService from '../../../../data/services/EcommerceApiService'; import LicenseManagerApiService from '../../../../data/services/LicenseManagerAPIService'; +import SubsidyApiService from '../../../../data/services/EnterpriseSubsidyApiService'; jest.mock('@edx/frontend-platform/config', () => ({ getConfig: jest.fn(() => ({ @@ -11,6 +12,8 @@ jest.mock('@edx/frontend-platform/config', () => ({ })); jest.mock('../../../../data/services/EcommerceApiService'); jest.mock('../../../../data/services/LicenseManagerAPIService'); +jest.mock('../../../../data/services/EnterpriseAccessApiService'); +jest.mock('../../../../data/services/EnterpriseSubsidyApiService'); const TEST_ENTERPRISE_UUID = 'test-enterprise-uuid'; @@ -23,6 +26,8 @@ describe('useEnterpriseOffers', () => { const { result } = renderHook(() => useEnterpriseOffers({ enablePortalLearnerCreditManagementScreen: false })); expect(EcommerceApiService.fetchEnterpriseOffers).not.toHaveBeenCalled(); + expect(SubsidyApiService.getSubsidyByCustomerUUID).not.toHaveBeenCalled(); + expect(result.current).toEqual({ offers: [], isLoading: false, @@ -30,15 +35,32 @@ describe('useEnterpriseOffers', () => { }); }); - it('should fetch enterprise offers for the enterprise', async () => { - const mockOffers = [ + it('should fetch enterprise offers for the enterprise when data is available only in e-commerce', async () => { + const mockEcommerceResponse = [ { - uuid: 'uuid', + id: 'uuid', + display_name: 'offer-name', + start_datetime: '2021-05-15T19:56:09Z', + end_datetime: '2100-05-15T19:56:09Z', + is_current: true, }, ]; + const mockOffers = [{ + id: 'uuid', + name: 'offer-name', + start: '2021-05-15T19:56:09Z', + end: '2100-05-15T19:56:09Z', + isCurrent: true, + }]; + + SubsidyApiService.getSubsidyByCustomerUUID.mockResolvedValueOnce({ + data: { + results: [], + }, + }); EcommerceApiService.fetchEnterpriseOffers.mockResolvedValueOnce({ data: { - results: mockOffers, + results: mockEcommerceResponse, }, }); const { result, waitForNextUpdate } = renderHook(() => useEnterpriseOffers({ @@ -57,30 +79,87 @@ describe('useEnterpriseOffers', () => { }); }); - it.each([0, 2])('should set canManageLearnerCredit to false if enterprise does not have exactly 1 offer', async ( - offersCount, - ) => { - const mockOffers = [...Array(offersCount)].map((_, index) => ({ - uuid: `offer-${index}`, + it('should fetch enterprise offers for the enterprise when data available in enterprise-subsidy', async () => { + const mockOffers = [ + { + id: 'offer-id', + name: 'offer-name', + start: '2021-05-15T19:56:09Z', + end: '2100-05-15T19:56:09Z', + isCurrent: true, + }, + ]; + const mockSubsidyServiceResponse = [{ + uuid: 'offer-id', + title: 'offer-name', + active_datetime: '2021-05-15T19:56:09Z', + expiration_datetime: '2100-05-15T19:56:09Z', + }]; + SubsidyApiService.getSubsidyByCustomerUUID.mockResolvedValueOnce({ + data: { + results: mockSubsidyServiceResponse, + }, + }); + + const { result, waitForNextUpdate } = renderHook(() => useEnterpriseOffers({ + enablePortalLearnerCreditManagementScreen: true, + enterpriseId: TEST_ENTERPRISE_UUID, })); + await waitForNextUpdate(); + + expect(SubsidyApiService.getSubsidyByCustomerUUID).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); + expect(result.current).toEqual({ + offers: mockOffers, + isLoading: false, + canManageLearnerCredit: true, + }); + }); + + it('should set canManageLearnerCredit to false if enterprise offer or subsidy does not have exactly 1 offer', async () => { + const mockOffers = [{ subsidyUuid: 'offer-1' }, { subsidyUuid: 'offer-2' }]; + const mockSubsidyServiceResponse = [ + { + uuid: 'offer-1', + title: 'offer-name', + active_datetime: '2021-05-15T19:56:09Z', + expiration_datetime: '2100-05-15T19:56:09Z', + }, + { + uuid: 'offer-2', + }, + ]; + const mockOfferData = [ + { + id: 'offer-1', + name: 'offer-name', + start: '2021-05-15T19:56:09Z', + end: '2100-05-15T19:56:09Z', + isCurrent: true, + }, + ]; + EcommerceApiService.fetchEnterpriseOffers.mockResolvedValueOnce({ data: { results: mockOffers, }, }); + SubsidyApiService.getSubsidyByCustomerUUID.mockResolvedValueOnce({ + data: { + results: mockSubsidyServiceResponse, + }, + }); const { result, waitForNextUpdate } = renderHook(() => useEnterpriseOffers({ enablePortalLearnerCreditManagementScreen: true, + enterpriseId: TEST_ENTERPRISE_UUID, })); await waitForNextUpdate(); - expect(EcommerceApiService.fetchEnterpriseOffers).toHaveBeenCalledWith( - { isCurrent: true }, - ); + expect(SubsidyApiService.getSubsidyByCustomerUUID).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); expect(result.current).toEqual({ - offers: mockOffers, + offers: mockOfferData, isLoading: false, canManageLearnerCredit: false, }); diff --git a/src/components/EnterpriseSubsidiesContext/index.jsx b/src/components/EnterpriseSubsidiesContext/index.jsx index 414704148c..59d4a540bd 100644 --- a/src/components/EnterpriseSubsidiesContext/index.jsx +++ b/src/components/EnterpriseSubsidiesContext/index.jsx @@ -9,7 +9,7 @@ export const useEnterpriseSubsidiesContext = ({ enablePortalLearnerCreditManagem offers, canManageLearnerCredit, isLoading: isLoadingOffers, - } = useEnterpriseOffers({ enablePortalLearnerCreditManagementScreen }); + } = useEnterpriseOffers({ enablePortalLearnerCreditManagementScreen, enterpriseId }); const { customerAgreement, diff --git a/src/components/learner-credit-management/LearnerCreditManagement.jsx b/src/components/learner-credit-management/LearnerCreditManagement.jsx index 0f46725298..788ce48e23 100644 --- a/src/components/learner-credit-management/LearnerCreditManagement.jsx +++ b/src/components/learner-credit-management/LearnerCreditManagement.jsx @@ -69,7 +69,7 @@ const LearnerCreditManagement = ({ enterpriseUUID }) => { remainingFunds={offerSummary?.remainingFunds} enterpriseUUID={enterpriseUUID} /> - +
{enterpriseOffer.isCurrent ? ( @@ -78,8 +78,8 @@ const LearnerCreditManagement = ({ enterpriseUUID }) => { Ended )}
diff --git a/src/components/learner-credit-management/tests/LearnerCreditManagement.test.jsx b/src/components/learner-credit-management/tests/LearnerCreditManagement.test.jsx index a713eff91d..be0430a93e 100644 --- a/src/components/learner-credit-management/tests/LearnerCreditManagement.test.jsx +++ b/src/components/learner-credit-management/tests/LearnerCreditManagement.test.jsx @@ -128,9 +128,9 @@ describe('', () => { it('displays correctly', () => { const mockOffer = { id: mockEnterpriseOfferId, - displayName: mockOfferDisplayName, - startDatetime: '2022-01-01', - endDatetime: '2023-01-01', + name: mockOfferDisplayName, + start: '2022-01-01', + end: '2023-01-01', }; const mockOfferRedemption = { created: '2022-02-01', @@ -160,10 +160,10 @@ describe('', () => { render(); expect(screen.queryByTestId('404-page-not-found')).toBeFalsy(); expect(screen.getByText('Learner Credit Management')); - expect(screen.getByText(mockOffer.displayName)); + expect(screen.getByText(mockOffer.name)); - expect(screen.getByText(mockOffer.startDatetime)); - expect(screen.getByText(mockOffer.endDatetime)); + expect(screen.getByText(mockOffer.start)); + expect(screen.getByText(mockOffer.end)); expect(screen.getByText(`Data last updated on ${moment(mockOfferRedemption.created).format(DATE_FORMAT)}`, { exact: false })); diff --git a/src/config/index.js b/src/config/index.js index ab7a561e12..ca5e4b3f7a 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -16,6 +16,7 @@ const configuration = { DISCOVERY_BASE_URL: process.env.DISCOVERY_BASE_URL, ENTERPRISE_CATALOG_BASE_URL: process.env.ENTERPRISE_CATALOG_BASE_URL, ENTERPRISE_ACCESS_BASE_URL: process.env.ENTERPRISE_ACCESS_BASE_URL, + ENTERPRISE_SUBSIDY_BASE_URL: process.env.ENTERPRISE_SUBSIDY_BASE_URL, SECURE_COOKIES: process.env.NODE_ENV !== 'development', SEGMENT_KEY: process.env.SEGMENT_KEY, ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME, diff --git a/src/data/services/EnterpriseSubsidyApiService.js b/src/data/services/EnterpriseSubsidyApiService.js new file mode 100644 index 0000000000..85ffe4f62d --- /dev/null +++ b/src/data/services/EnterpriseSubsidyApiService.js @@ -0,0 +1,22 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { configuration } from '../../config'; + +class SubsidyApiService { + static baseUrl = `${configuration.ENTERPRISE_SUBSIDY_BASE_URL}/api/v1`; + + static apiClient = getAuthenticatedHttpClient; + + static getSubsidyByCustomerUUID(uuid, options = {}) { + const queryParams = new URLSearchParams({ + enterprise_customer_uuid: uuid, + ...options, + }); + const url = `${SubsidyApiService.baseUrl}/subsidies/?${queryParams.toString()}`; + return SubsidyApiService.apiClient({ + useCache: configuration.USE_API_CACHE, + }).get(url, { clearCacheEntry: true }); + } +} + +export default SubsidyApiService; diff --git a/src/data/services/tests/EnterpriseSubsidyApiService.test.js b/src/data/services/tests/EnterpriseSubsidyApiService.test.js new file mode 100644 index 0000000000..18797031f6 --- /dev/null +++ b/src/data/services/tests/EnterpriseSubsidyApiService.test.js @@ -0,0 +1,25 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import SubsidyApiService from '../EnterpriseSubsidyApiService'; + +const axiosMock = new MockAdapter(axios); +getAuthenticatedHttpClient.mockReturnValue(axios); + +axiosMock.onAny().reply(200); +axios.get = jest.fn(); + +describe('EnterpriseSubsidyApiService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('getSubsidyByCustomerUUID calls the API to fetch subsides by enterprise customer UUID', () => { + const mockCustomerUUID = 'test-customer-uuid'; + const expectedUrl = `${SubsidyApiService.baseUrl}/subsidies/?enterprise_customer_uuid=${mockCustomerUUID}`; + SubsidyApiService.getSubsidyByCustomerUUID(mockCustomerUUID); + expect(axios.get).toBeCalledWith(expectedUrl, { clearCacheEntry: true }); + }); +}); From d363452ad086b6f7c0212b18b42aedea5097fb13 Mon Sep 17 00:00:00 2001 From: Emily Aquin Date: Thu, 1 Jun 2023 16:06:07 +0000 Subject: [PATCH 73/73] chore: specify learner_credit subsidy type in useEnterpriseOffers --- .../EnterpriseSubsidiesContext/data/hooks.js | 2 +- .../data/tests/hooks.test.js | 10 ++++++++-- src/data/services/EnterpriseSubsidyApiService.js | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/EnterpriseSubsidiesContext/data/hooks.js b/src/components/EnterpriseSubsidiesContext/data/hooks.js index 1bb9fc505a..ff4ad6ff3d 100644 --- a/src/components/EnterpriseSubsidiesContext/data/hooks.js +++ b/src/components/EnterpriseSubsidiesContext/data/hooks.js @@ -18,7 +18,7 @@ export const useEnterpriseOffers = ({ enablePortalLearnerCreditManagementScreen, const fetchOffers = async () => { try { const [enterpriseSubsidyResponse, ecommerceApiResponse] = await Promise.all([ - SubsidyApiService.getSubsidyByCustomerUUID(enterpriseId), + SubsidyApiService.getSubsidyByCustomerUUID(enterpriseId, { subsidyType: 'learner_credit' }), EcommerceApiService.fetchEnterpriseOffers({ isCurrent: true, }), diff --git a/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js b/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js index 87101a7b52..668fa9d386 100644 --- a/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js +++ b/src/components/EnterpriseSubsidiesContext/data/tests/hooks.test.js @@ -108,7 +108,10 @@ describe('useEnterpriseOffers', () => { await waitForNextUpdate(); - expect(SubsidyApiService.getSubsidyByCustomerUUID).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); + expect(SubsidyApiService.getSubsidyByCustomerUUID).toHaveBeenCalledWith( + TEST_ENTERPRISE_UUID, + { subsidyType: 'learner_credit' }, + ); expect(result.current).toEqual({ offers: mockOffers, isLoading: false, @@ -157,7 +160,10 @@ describe('useEnterpriseOffers', () => { await waitForNextUpdate(); - expect(SubsidyApiService.getSubsidyByCustomerUUID).toHaveBeenCalledWith(TEST_ENTERPRISE_UUID); + expect(SubsidyApiService.getSubsidyByCustomerUUID).toHaveBeenCalledWith( + TEST_ENTERPRISE_UUID, + { subsidyType: 'learner_credit' }, + ); expect(result.current).toEqual({ offers: mockOfferData, isLoading: false, diff --git a/src/data/services/EnterpriseSubsidyApiService.js b/src/data/services/EnterpriseSubsidyApiService.js index 85ffe4f62d..f1769424bf 100644 --- a/src/data/services/EnterpriseSubsidyApiService.js +++ b/src/data/services/EnterpriseSubsidyApiService.js @@ -1,4 +1,5 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { snakeCaseObject } from '@edx/frontend-platform'; import { configuration } from '../../config'; @@ -10,7 +11,7 @@ class SubsidyApiService { static getSubsidyByCustomerUUID(uuid, options = {}) { const queryParams = new URLSearchParams({ enterprise_customer_uuid: uuid, - ...options, + ...snakeCaseObject(options), }); const url = `${SubsidyApiService.baseUrl}/subsidies/?${queryParams.toString()}`; return SubsidyApiService.apiClient({