diff --git a/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx index 997c18ea0..aaedc3c64 100644 --- a/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx +++ b/src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx @@ -121,6 +121,7 @@ function AppSettingsModal({ enableAppLabel, enableAppHelp, learnMoreText, + enableReinitialize, }) { const { courseId } = useContext(PagesAndResourcesContext); const loadingStatus = useSelector(getLoadingStatus); @@ -188,6 +189,7 @@ function AppSettingsModal({ }) } onSubmit={handleFormSubmit} + enableReinitialize={enableReinitialize} > {(formikProps) => (
@@ -284,6 +286,7 @@ AppSettingsModal.propTypes = { enableAppHelp: PropTypes.string.isRequired, learnMoreText: PropTypes.string.isRequired, configureBeforeEnable: PropTypes.bool, + enableReinitialize: PropTypes.bool, }; AppSettingsModal.defaultProps = { @@ -292,6 +295,7 @@ AppSettingsModal.defaultProps = { initialValues: {}, validationSchema: {}, configureBeforeEnable: false, + enableReinitialize: false, }; export default injectIntl(AppSettingsModal); diff --git a/src/pages-and-resources/live/Settings.jsx b/src/pages-and-resources/live/Settings.jsx index 986fa59d5..efc78bfd4 100644 --- a/src/pages-and-resources/live/Settings.jsx +++ b/src/pages-and-resources/live/Settings.jsx @@ -1,12 +1,12 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { camelCase } from 'lodash'; +import { SelectableBox, Icon } from '@edx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import * as Yup from 'yup'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { SelectableBox, Icon } from '@edx/paragon'; -import { camelCase } from 'lodash'; +import { fetchLiveData, saveLiveConfiguration } from './data/thunks'; import FormikControl from '../../generic/FormikControl'; -import { useAppSetting } from '../../utils'; -import AppExternalLinks from '../discussions/app-config-form/apps/shared/AppExternalLinks'; import AppSettingsModal from '../app-settings-modal/AppSettingsModal'; import iconsSrc from './constants'; import messages from './messages'; @@ -15,17 +15,15 @@ function LiveSettings({ intl, onClose, }) { - const [liveConfiguration, saveSettings] = useAppSetting('liveConfiguration'); - const liveData = { - consumerKey: liveConfiguration?.consumerKey || '', - consumerSecret: liveConfiguration?.consumerSecret || '', - launchUrl: liveConfiguration?.launchUrl || '', - launchEmail: liveConfiguration?.launchEmail || '', - provider: liveConfiguration?.provider || 'zoom', - piiSharingEnable: liveConfiguration?.piiSharing - ? liveConfiguration.piiShareUsername && liveConfiguration.piiShareEmail - : false, - }; + const dispatch = useDispatch(); + const courseId = useSelector(state => state.courseDetail.courseId); + const liveConfiguration = useSelector((state) => state.live.configuration); + const availableProviders = useSelector((state) => state.live.appIds); + + useEffect(() => { + dispatch(fetchLiveData(courseId)); + }, [courseId]); + const validationSchema = { enabled: Yup.boolean(), consumerKey: Yup.string().required(intl.formatMessage(messages.consumerKeyRequired)), @@ -34,7 +32,9 @@ function LiveSettings({ launchEmail: Yup.string().required(intl.formatMessage(messages.launchEmailRequired)), }; - const handleSettingsSave = async (values) => saveSettings(values); + const handleSettingsSave = async (values) => { + await dispatch(saveLiveConfiguration(courseId, values)); + }; const handleProviderChange = (selectedProvider, setFieldValue) => { setFieldValue('provider', selectedProvider); }; @@ -47,10 +47,11 @@ function LiveSettings({ enableAppLabel={intl.formatMessage(messages.enableLiveLabel)} learnMoreText={intl.formatMessage(messages.enableLiveLink)} onClose={onClose} - initialValues={liveData} + initialValues={liveConfiguration} validationSchema={validationSchema} onSettingsSave={handleSettingsSave} configureBeforeEnable + enableReinitialize > { ({ values, setFieldValue }) => ( @@ -64,7 +65,7 @@ function LiveSettings({ columns={3} className="mb-3" > - {[{ id: 'zoom' }, { id: 'google meet' }, { id: 'microsoft teams' }].map((app) => ( + {availableProviders.map((app) => (
@@ -74,7 +75,7 @@ function LiveSettings({ ))}

{intl.formatMessage(messages.providerHelperText, { providerName: 'Zoom' })}

- {liveData.piiSharingEnable ? ( + {values.piiSharingEnable ? ( <>

{intl.formatMessage(messages.formInstructions)}

- ) : (

{intl.formatMessage(messages.requestPiiSharingEnable)}

diff --git a/src/pages-and-resources/live/data/api.js b/src/pages-and-resources/live/data/api.js new file mode 100644 index 000000000..143ddddaa --- /dev/null +++ b/src/pages-and-resources/live/data/api.js @@ -0,0 +1,42 @@ +/* eslint-disable import/prefer-default-export */ +import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +ensureConfig([ + 'STUDIO_BASE_URL', +], 'Course Apps API service'); + +const apiBaseUrl = getConfig().STUDIO_BASE_URL; + +const providersApiUrl = `${apiBaseUrl}/api/course_live/providers`; +const providerConfigurationApiUrl = `${apiBaseUrl}/api/course_live/course`; + +/** + * Fetches providers for provided course + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function getLiveProviders(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(`${providersApiUrl}/${courseId}/`); + return camelCaseObject(data); +} + +/** + * Fetches provider settings for provided course + * @param {string} courseId + * @returns {Promise<[{}]>} + */ +export async function getLiveConfiguration(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(`${providerConfigurationApiUrl}/${courseId}/`); + return camelCaseObject(data); +} + +export async function postLiveConfiguration(courseId, config) { + const data = await getAuthenticatedHttpClient().post( + `${providerConfigurationApiUrl}/${courseId}/`, + config, + ); + return camelCaseObject(data); +} diff --git a/src/pages-and-resources/live/data/slice.js b/src/pages-and-resources/live/data/slice.js new file mode 100644 index 000000000..8bc6a7013 --- /dev/null +++ b/src/pages-and-resources/live/data/slice.js @@ -0,0 +1,49 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { RequestStatus } from '../../../data/constants'; + +const slice = createSlice({ + name: 'live', + initialState: { + providers: { + available: {}, + selectedProvider: {}, + }, + appIds: [], + status: RequestStatus.IN_PROGRESS, + configuration: {}, + saveStatus: RequestStatus.SUCCESSFUL, + }, + reducers: { + updateProviders: (state, { payload }) => { + Object.assign(state.providers, payload); + }, + updateConfiguration: (state, { payload }) => { + Object.assign(state.configuration, payload); + state.configuredProvider = payload.provider; + }, + updateStatus: (state, { payload }) => { + const { status } = payload; + state.status = status; + }, + updateAppIds: (state, { payload }) => { + state.appIds = payload; + }, + updateSaveStatus: (state, { payload }) => { + const { status } = payload; + state.saveStatus = status; + }, + }, +}); + +export const { + updateProviders, + updateConfiguration, + updateStatus, + updateSaveStatus, + updateAppIds, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/pages-and-resources/live/data/thunks.js b/src/pages-and-resources/live/data/thunks.js new file mode 100644 index 000000000..f57021d5e --- /dev/null +++ b/src/pages-and-resources/live/data/thunks.js @@ -0,0 +1,102 @@ +import { history } from '@edx/frontend-platform'; +import { getLiveConfiguration, getLiveProviders, postLiveConfiguration } from './api'; +import { + updateStatus, updateSaveStatus, updateProviders, + updateAppIds, updateConfiguration, +} from './slice'; +import { RequestStatus } from '../../../data/constants'; + +function normalizeLiveConfig(config) { + const configuration = {}; + configuration.courseKey = config?.courseKey || ''; + configuration.enabled = config?.enabled || false; + configuration.consumerKey = config?.ltiConfiguration?.lti1P1ClientKey || ''; + configuration.consumerSecret = config?.ltiConfiguration?.lti1P1ClientSecret || ''; + configuration.launchUrl = config?.ltiConfiguration?.lti1P1LaunchUrl || ''; + configuration.launchEmail = config?.ltiConfiguration?.ltiConfig?.additionalParameters?.customInstructorEmail || ''; + configuration.provider = config?.providerType || 'zoom'; + configuration.piiSharingEnable = config?.piiSharingAllowed || false; + return configuration; +} + +function deNormalizeLiveConfig(config) { + const configuration = {}; + configuration.course_key = config.courseKey; + configuration.provider_type = config?.provider || 'zoom'; + configuration.enabled = config?.enabled || false; + configuration.lti_configuration = { + lti_1p1_client_key: config?.consumerKey || '', + lti_1p1_client_secret: config?.consumerSecret || '', + lti_1p1_launch_url: config?.launchUrl || '', + version: 'lti_1p1', + lti_config: { + additional_parameters: { + custom_instructor_email: config?.launchEmail || '', + }, + }, + }; + configuration.pii_sharing_allowed = config?.piiSharingEnable || false; + return configuration; +} + +export function fetchLiveProviders(courseId) { + return async (dispatch) => { + const providers = await getLiveProviders(courseId); + dispatch(updateProviders(providers.providers)); + const availableProvidersInfo = []; + Object.keys(providers.providers.available).forEach((key) => { availableProvidersInfo.push({ id: key }); }); + dispatch(updateAppIds(availableProvidersInfo)); + }; +} + +function updateLiveConfigurationState(config) { + return async (dispatch) => { + const data = normalizeLiveConfig(config); + dispatch(updateConfiguration(data)); + }; +} + +export function fetchLiveConfiguration(courseId) { + return async (dispatch) => { + const config = await getLiveConfiguration(courseId); + dispatch(updateLiveConfigurationState(config)); + }; +} + +export function fetchLiveData(courseId) { + return async (dispatch) => { + dispatch(updateStatus({ status: RequestStatus.IN_PROGRESS })); + try { + await dispatch(fetchLiveConfiguration(courseId)); + await dispatch(fetchLiveProviders(courseId)); + dispatch(updateStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + if (error.response && error.response.status === 403) { + dispatch(updateStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateStatus({ status: RequestStatus.FAILED })); + } + } + }; +} + +export function saveLiveConfiguration(courseId, config) { + return async (dispatch) => { + dispatch(updateSaveStatus({ status: RequestStatus.IN_PROGRESS })); + try { + const configuration = normalizeLiveConfig( + (await postLiveConfiguration(courseId, deNormalizeLiveConfig(config))).data, + ); + dispatch(updateConfiguration(configuration)); + dispatch(updateSaveStatus({ status: RequestStatus.SUCCESSFUL })); + history.push(`/course/${courseId}/pages-and-resources/`); + } catch (error) { + if (error.response && error.response.status === 403) { + dispatch(updateSaveStatus({ status: RequestStatus.DENIED })); + dispatch(updateStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateSaveStatus({ status: RequestStatus.FAILED })); + } + } + }; +} diff --git a/src/store.js b/src/store.js index e023afd31..a4a53b9dd 100644 --- a/src/store.js +++ b/src/store.js @@ -4,6 +4,7 @@ import { reducer as modelsReducer } from './generic/model-store'; import { reducer as courseDetailReducer } from './data/slice'; import { reducer as discussionsReducer } from './pages-and-resources/discussions'; import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/slice'; +import { reducer as liveReducer } from './pages-and-resources/live/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ @@ -12,6 +13,7 @@ export default function initializeStore(preloadedState = undefined) { discussions: discussionsReducer, pagesAndResources: pagesAndResourcesReducer, models: modelsReducer, + live: liveReducer, }, preloadedState, });