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();