Convert "Pages & Resources" page to a plugin system (#638)

* feat: Make "Pages & Resources" course apps into plugins

* feat: move ora_settings

* feat: move proctoring

* feat: move progress

* feat: move teams

* feat: move wiki

* feat: move Xpert settings

* fix: add webpack.prod.config.js

* fix: clean up unused parts of package.json files

* feat: Add an error message when displaying a Course App Plugin fails

* chore: fix various eslint warnings

* chore: fix jest tests

* fix: error preventing "npm ci" from working

* feat: better tests for <SettingsComponent>

* chore: move xpert_unit_summary into same dir as other plugins

* fix: eslint-import-resolver-webpack is a dev dependency

* chore: move learning_assistant to be a plugin too

* feat: for compatibility, install 2U plugins by default

* fix: bug with learning_assistant package.json
This commit is contained in:
Braden MacDonald
2024-02-28 08:50:54 -08:00
committed by GitHub
parent 49fce4622c
commit 3c661e15cb
75 changed files with 910 additions and 254 deletions

View File

@@ -0,0 +1,124 @@
/* eslint-disable import/prefer-default-export */
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { bbbPlanTypes } from '../constants';
ensureConfig([
'STUDIO_BASE_URL',
], 'Course Apps API service');
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
export const providersApiUrl = `${apiBaseUrl}/api/course_live/providers`;
export const providerConfigurationApiUrl = `${apiBaseUrl}/api/course_live/course`;
function normalizeProviders(data) {
const apps = Object.entries(data.providers.available).map(([key, app]) => ({
id: key,
featureIds: app.features,
name: app.name,
piiSharing: app.pii_sharing,
hasFreeTier: app.has_free_tier,
}));
return {
activeAppId: data.providers.active,
selectedAppId: data.providers.active,
apps,
};
}
function normalizeLtiConfig(data) {
if (!data || Object.keys(data).length < 1) {
return {};
}
return {
consumerKey: data.lti_1p1_client_key,
consumerSecret: data.lti_1p1_client_secret,
launchUrl: data.lti_1p1_launch_url,
launchEmail: data.lti_config?.additional_parameters?.custom_instructor_email,
tierType: data.tierType,
};
}
export function normalizeSettings(data) {
let tier;
if (data.provider_type === 'big_blue_button') {
tier = data.free_tier === true ? bbbPlanTypes.free : bbbPlanTypes.commercial;
}
return {
enabled: data.enabled,
piiSharingAllowed: data.pii_sharing_allowed,
appConfig: {
id: data.provider_type,
...normalizeLtiConfig({ ...data.lti_configuration, tierType: tier }),
},
};
}
export function deNormalizeSettings(data) {
const ltiConfiguration = {};
if (data.consumerKey) {
ltiConfiguration.lti_1p1_client_key = data.consumerKey;
}
if (data.consumerSecret) {
ltiConfiguration.lti_1p1_client_secret = data.consumerSecret;
}
if (data.launchUrl) {
ltiConfiguration.lti_1p1_launch_url = data.launchUrl;
}
if (data?.provider === 'zoom' || data.tierType !== 'Free') {
ltiConfiguration.lti_config = {};
if (data.launchEmail) {
ltiConfiguration.lti_config.additional_parameters = {
custom_instructor_email: data.launchEmail,
};
}
}
if (Object.keys(ltiConfiguration).length > 0) {
// Only add this in if we're sending LTI fields.
// TODO: Eventually support LTI v1.3 here.
ltiConfiguration.version = 'lti_1p1';
}
const apiData = {
enabled: data?.enabled || false,
lti_configuration: Object.keys(ltiConfiguration).length ? ltiConfiguration : undefined,
provider_type: data?.provider || 'zoom',
pii_sharing_allowed: data?.piiSharingEnable || false,
free_tier: data?.provider === 'zoom' ? false : Boolean(data.tierType === 'Free'),
};
return apiData;
}
/**
* Fetches providers for provided course
* @param {string} courseId
* @returns {Promise<[{}]>}
*/
export async function getLiveProviders(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${providersApiUrl}/${courseId}/`);
return normalizeProviders(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 normalizeSettings(data);
}
export async function postLiveConfiguration(courseId, config) {
const { data } = await getAuthenticatedHttpClient().post(
`${providerConfigurationApiUrl}/${courseId}/`,
deNormalizeSettings(config),
);
return normalizeSettings(data);
}

View File

@@ -0,0 +1,48 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from 'CourseAuthoring/data/constants';
const slice = createSlice({
name: 'live',
initialState: {
appIds: [],
// activeAppId is the ID of the app that has been configured for the course.
activeAppId: null,
// selectedAppId is the ID of the app that has been selected in the UI. This happens when an
// activeAppId has been configured but the user is about to configure a different provider
// instead.
selectedAppId: null,
status: RequestStatus.IN_PROGRESS,
saveStatus: RequestStatus.SUCCESSFUL,
},
reducers: {
loadApps: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL;
state.saveStatus = RequestStatus.SUCCESSFUL;
Object.assign(state, payload);
},
selectApp: (state, { payload }) => {
const { appId } = payload;
state.selectedAppId = appId;
},
updateStatus: (state, { payload }) => {
const { status } = payload;
state.status = status;
},
updateSaveStatus: (state, { payload }) => {
const { status } = payload;
state.saveStatus = status;
},
},
});
export const {
loadApps,
selectApp,
updateStatus,
updateSaveStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,86 @@
import { addModel, addModels, updateModel } from 'CourseAuthoring/generic/model-store';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import {
getLiveConfiguration,
getLiveProviders,
postLiveConfiguration,
normalizeSettings,
deNormalizeSettings,
} from './api';
import { loadApps, updateStatus, updateSaveStatus } from './slice';
function updateLiveSettingsState({
appConfig,
...liveSettings
}) {
return async (dispatch) => {
dispatch(addModel({ modelType: 'liveAppConfigs', model: appConfig }));
dispatch(loadApps(liveSettings));
};
}
export function fetchLiveProviders(courseId) {
return async (dispatch) => {
const { activeAppId, selectedAppId, apps } = await getLiveProviders(courseId);
dispatch(addModels({ modelType: 'liveApps', models: apps }));
dispatch(loadApps({
activeAppId,
selectedAppId,
appIds: apps.map((app) => app.id),
}));
};
}
export function fetchLiveConfiguration(courseId) {
return async (dispatch) => {
const settings = await getLiveConfiguration(courseId);
dispatch(updateLiveSettingsState(settings));
};
}
export function fetchLiveData(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await dispatch(fetchLiveProviders(courseId));
await dispatch(fetchLiveConfiguration(courseId));
} 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, navigate) {
return async (dispatch) => {
dispatch(updateSaveStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const apps = await postLiveConfiguration(courseId, config);
dispatch(updateLiveSettingsState(apps));
dispatch(updateSaveStatus({ status: RequestStatus.SUCCESSFUL }));
navigate(`/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 }));
}
}
};
}
export function saveLiveConfigurationAsDraft(config) {
return async (dispatch) => {
const { appConfig, ...liveSettings } = normalizeSettings(deNormalizeSettings(config));
dispatch(updateModel({ modelType: 'liveAppConfigs', model: appConfig }));
dispatch(loadApps(liveSettings));
};
}