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,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "calculator" Course App.
* @param {{onClose: () => void}} props
*/
const CalculatorSettings = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="calculator"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableCalculatorHelp)}
enableAppLabel={intl.formatMessage(messages.enableCalculatorLabel)}
learnMoreText={intl.formatMessage(messages.enableCalculatorLink)}
onClose={onClose}
/>
);
};
CalculatorSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default CalculatorSettings;

View File

@@ -0,0 +1,24 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.calculator.heading',
defaultMessage: 'Configure calculator',
},
enableCalculatorLabel: {
id: 'course-authoring.pages-resources.calculator.enable-calculator.label',
defaultMessage: 'Calculator',
},
enableCalculatorHelp: {
id: 'course-authoring.pages-resources.calculator.enable-calculator.help',
defaultMessage: `The calculator supports numbers, operators, constants,
functions, and other mathematical concepts. When enabled, an icon to
access the calculator appears on all pages in the body of your course.`,
},
enableCalculatorLink: {
id: 'course-authoring.pages-resources.calculator.enable-calculator.link',
defaultMessage: 'Learn more about the calculator',
},
});
export default messages;

View File

@@ -0,0 +1,17 @@
{
"name": "@openedx-plugins/course-app-calculator",
"version": "0.1.0",
"description": "Calculator configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
/**
* Settings widget for the "edxnotes" Course App.
* @param {{onClose: () => void}} props
*/
const NotesSettings = ({ onClose }) => {
const intl = useIntl();
return (
<AppSettingsModal
appId="edxnotes"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableNotesHelp)}
enableAppLabel={intl.formatMessage(messages.enableNotesLabel)}
learnMoreText={intl.formatMessage(messages.enableNotesLink)}
onClose={onClose}
/>
);
};
NotesSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default NotesSettings;

View File

@@ -0,0 +1,25 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.notes.heading',
defaultMessage: 'Configure notes',
},
enableNotesLabel: {
id: 'course-authoring.pages-resources.notes.enable-notes.label',
defaultMessage: 'Notes',
},
enableNotesHelp: {
id: 'course-authoring.pages-resources.notes.enable-notes.help',
defaultMessage: `Learners can access their notes either in the body of the
course of on a notes page. On the notes page, a learner can see all the
notes made during the course. The page also contains links to the location
of the notes in the course body.`,
},
enableNotesLink: {
id: 'course-authoring.pages-resources.notes.enable-notes.link',
defaultMessage: 'Learn more about notes',
},
});
export default messages;

View File

@@ -0,0 +1,17 @@
{
"name": "@openedx-plugins/course-app-edxnotes",
"version": "0.1.0",
"description": "edxnotes configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import messages from './messages';
const LearningAssistantSettings = ({ onClose }) => {
const appId = 'learning_assistant';
const appInfo = useModel('courseApps', appId);
const intl = useIntl();
// We need to render more than one link, so we use the bodyChildren prop.
const bodyChildren = (
appInfo?.documentationLinks?.learnMoreOpenaiDataPrivacy && appInfo?.documentationLinks?.learnMoreOpenai
? (
<div className="d-flex flex-column">
{appInfo.documentationLinks?.learnMoreOpenaiDataPrivacy && (
<Hyperlink
className="text-primary-500"
destination={appInfo.documentationLinks.learnMoreOpenaiDataPrivacy}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages.learningAssistantOpenAIDataPrivacyLink)}
</Hyperlink>
)}
{appInfo.documentationLinks?.learnMoreOpenai && (
<Hyperlink
className="text-primary-500"
destination={appInfo.documentationLinks.learnMoreOpenai}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages.learningAssistantOpenAILink)}
</Hyperlink>
)}
</div>
)
: null
);
return (
<AppSettingsModal
appId={appId}
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableLearningAssistantHelp)}
enableAppLabel={intl.formatMessage(messages.enableLearningAssistantLabel)}
bodyChildren={bodyChildren}
onClose={onClose}
/>
);
};
LearningAssistantSettings.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default LearningAssistantSettings;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { render } from 'CourseAuthoring/pages-and-resources/utils.test';
import LearningAssistantSettings from './Settings';
const onClose = () => { };
describe('Learning Assistant Settings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', async () => {
const initialState = {
models: {
courseApps: {
learning_assistant:
{
id: 'learning_assistant',
enabled: true,
name: 'Learning Assistant',
description: 'Learning Assistant description',
allowedOperations: {
configure: false,
enable: true,
},
documentationLinks: {
learnMoreOpenaiDataPrivacy: 'www.example.com/learn-more-data-privacy',
learnMoreOpenai: 'www.example.com/learn-more',
},
},
},
},
pagesAndResources: {
loadingStatus: RequestStatus.SUCCESSFUL,
},
};
render(
<LearningAssistantSettings
onClose={onClose}
/>,
{
preloadedState: initialState,
},
);
const toggleDescription = 'Reinforce learning concepts by sharing text-based course content '
+ 'with OpenAI (via API) to power an in-course Learning Assistant. Learners can leave feedback about the quality '
+ 'of the AI-powered experience for use by edX to improve the performance of the tool.';
await waitFor(() => expect(screen.getByRole('heading', { name: 'Configure Learning Assistant' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByText(toggleDescription)).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learn more about how OpenAI handles data')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Learn more about OpenAI API data privacy')).toBeInTheDocument());
});
});

View File

@@ -0,0 +1,28 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.learning-assistant.heading',
defaultMessage: 'Configure Learning Assistant',
},
enableLearningAssistantLabel: {
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.label',
defaultMessage: 'Learning Assistant',
},
enableLearningAssistantHelp: {
id: 'course-authoring.pages-resources.learning_assistant.enable-learning-assistant.help',
defaultMessage: `Reinforce learning concepts by sharing text-based course content with OpenAI (via API) to power
an in-course Learning Assistant. Learners can leave feedback about the quality of the AI-powered experience for
use by edX to improve the performance of the tool.`,
},
learningAssistantOpenAILink: {
id: 'course-authoring.pages-resources.learning_assistant.open-ai.link',
defaultMessage: 'Learn more about how OpenAI handles data',
},
learningAssistantOpenAIDataPrivacyLink: {
id: 'course-authoring.pages-resources.learning_assistant.open-ai.data-privacy.link',
defaultMessage: 'Learn more about OpenAI API data privacy',
},
});
export default messages;

View File

@@ -0,0 +1,19 @@
{
"name": "@openedx-plugins/course-app-learning_assistant",
"version": "0.1.0",
"description": "Learning Assistant configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,130 @@
import React, { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, Hyperlink } from '@openedx/paragon';
import PropTypes from 'prop-types';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { useModel } from 'CourseAuthoring/generic/model-store';
import { providerNames, bbbPlanTypes } from './constants';
import LiveCommonFields from './LiveCommonFields';
import messages from './messages';
const BbbSettings = ({
intl,
values,
setFieldValue,
}) => {
const [bbbPlan, setBbbPlan] = useState(values.tierType);
useEffect(() => {
setBbbPlan(values.tierType);
}, [values.tierType]);
const app = useModel('liveApps', 'big_blue_button');
const isPiiDisabled = !values.piiSharingEnable;
function getBbbPlanOptions() {
const options = ['Select', bbbPlanTypes.commercial];
if (app.hasFreeTier) { options.push(bbbPlanTypes.free); }
return options.map(option => (
<option
key={option}
value={option}
data-testid={option}
>
{option}
</option>
));
}
const handlePlanChange = (e) => {
setBbbPlan(e.target.value);
setFieldValue('tierType', e.target.value);
};
return (
<>
{isPiiDisabled ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnableForBbb, { provider: providerNames[values.provider] })}
</p>
) : (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<Form.Group controlId="bbs-settings" data-testid="plansDropDown">
<Form.Label as="planSelector" className="h6">
<FormattedMessage
id="authoring.live.bbb.selectPlan.label"
defaultMessage="Select a plan"
description="Label for bbb plan selection"
/>
</Form.Label>
<Form.Control
as="select"
name="tierType"
value={bbbPlan}
onChange={handlePlanChange}
disabled={isPiiDisabled}
>
{getBbbPlanOptions()}
</Form.Control>
</Form.Group>
<Hyperlink
destination={getConfig().BBB_LEARN_MORE_URL}
target="_blank"
rel="noopener noreferrer"
showLaunchIcon
className="text-primary-500 pt-2"
>
{ intl.formatMessage(messages.learnMore, { providerName: 'plans' }) }
</Hyperlink>
<>
<AppConfigFormDivider thick marginAdj={{ default: 0, sm: 2 }} />
{isPiiDisabled ? (
<p data-testid="help-request-pii-sharing">
{intl.formatMessage(messages.piiSharingEnableHelpTextBbb)}
</p>
) : (
<>
{bbbPlan === bbbPlanTypes.commercial && <LiveCommonFields values={values} />}
{bbbPlan === bbbPlanTypes.free && (
<span data-testid="free-plan-message">
{intl.formatMessage(messages.freePlanMessage)}
<Hyperlink
destination="https://bigbluebutton.org/privacy-policy/"
target="_blank"
rel="noopener noreferrer"
showLaunchIcon
className="text-gray-700 ml-1"
>
{intl.formatMessage(messages.privacyPolicy)}
</Hyperlink>
</span>
)}
</>
)}
</>
</>
);
};
BbbSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
launchUrl: PropTypes.string,
launchEmail: PropTypes.string,
provider: PropTypes.string,
piiSharingEmail: PropTypes.bool,
piiSharingUsername: PropTypes.bool,
piiSharingEnable: PropTypes.bool,
tierType: PropTypes.string,
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
};
export default injectIntl(BbbSettings);

View File

@@ -0,0 +1,139 @@
import {
render,
queryByTestId,
getByRole,
getAllByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
courseId,
initialState,
configurationProviders,
} from './factories/mockApiResponses';
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
let axiosMock;
let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
const mockStore = async ({
usernameSharing = false,
emailSharing = false,
enabled = true,
piiSharingAllowed = true,
isFreeTier = false,
}) => {
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
axiosMock.onGet(fetchProviderConfigUrl).reply(200, configurationProviders(emailSharing, usernameSharing, 'big_blue_button', isFreeTier));
axiosMock.onGet(fetchLiveConfigUrl).reply(200, generateLiveConfigurationApiResponse(enabled, piiSharingAllowed, 'bigBlueButton', isFreeTier));
await executeThunk(fetchLiveProviders(courseId), store.dispatch);
await executeThunk(fetchLiveConfiguration(courseId), store.dispatch);
};
describe('BBB Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('Plan dropdown to be visible and enabled in UI', async () => {
await mockStore({ emailSharing: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
expect(queryByTestId(container, 'plansDropDown')).toBeInTheDocument();
expect(container.querySelector('select[name="tierType"]')).not.toBeDisabled();
});
test.each([[true, 3], [false, 2]])('Plan dropdown should display correct number of options', async (isFreeTier, noOfOptions) => {
await mockStore({ emailSharing: true, isFreeTier });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const dropDown = queryByTestId(container, 'plansDropDown');
expect(getAllByRole(dropDown, 'option').length).toBe(noOfOptions);
});
test(
'Connect to support and PII sharing message is visible and plans selection is disabled, When pii sharing is disabled, ',
async () => {
await mockStore({ piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const requestPiiText = queryByTestId(container, 'request-pii-sharing');
const helpRequestPiiText = queryByTestId(container, 'help-request-pii-sharing');
expect(requestPiiText).toHaveTextContent(
messages.requestPiiSharingEnableForBbb.defaultMessage.replaceAll('{provider}', 'BigBlueButton'),
);
expect(helpRequestPiiText).toHaveTextContent(messages.piiSharingEnableHelpTextBbb.defaultMessage);
expect(container.querySelector('select[name="tierType"]')).toBeDisabled();
},
);
test('free plans message is visible when free plan is selected', async () => {
await mockStore({ emailSharing: true, isFreeTier: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const dropDown = container.querySelector('select[name="tierType"]');
userEvent.selectOptions(
dropDown,
getByRole(dropDown, 'option', { name: 'Free' }),
);
expect(queryByTestId(container, 'free-plan-message')).toBeInTheDocument();
expect(queryByTestId(container, 'free-plan-message')).toHaveTextContent(messages.freePlanMessage.defaultMessage);
});
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
const LiveCommonFields = ({
intl,
values,
}) => (
<>
<p className="pb-2">{intl.formatMessage(messages.formInstructions)}</p>
<FormikControl
name="consumerKey"
value={values.consumerKey}
floatingLabel={intl.formatMessage(messages.consumerKey)}
className="pb-1"
type="input"
/>
<FormikControl
name="consumerSecret"
value={values.consumerSecret}
floatingLabel={intl.formatMessage(messages.consumerSecret)}
className="pb-1"
type="password"
/>
<FormikControl
name="launchUrl"
value={values.launchUrl}
floatingLabel={intl.formatMessage(messages.launchUrl)}
className="pb-1"
type="input"
/>
</>
);
LiveCommonFields.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
launchUrl: PropTypes.string,
launchEmail: PropTypes.string,
}).isRequired,
};
export default injectIntl(LiveCommonFields);

View File

@@ -0,0 +1,136 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { camelCase } from 'lodash';
import { SelectableBox, Icon } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { useNavigate } from 'react-router-dom';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import { useModel } from 'CourseAuthoring/generic/model-store';
import Loading from 'CourseAuthoring/generic/Loading';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { fetchLiveData, saveLiveConfiguration, saveLiveConfigurationAsDraft } from './data/thunks';
import { selectApp } from './data/slice';
import { iconsSrc, bbbPlanTypes } from './constants';
import messages from './messages';
import ZoomSettings from './ZoomSettings';
import BBBSettings from './BBBSettings';
const LiveSettings = ({
intl,
onClose,
}) => {
const navigate = useNavigate();
const dispatch = useDispatch();
const courseId = useSelector(state => state.courseDetail.courseId);
const availableProviders = useSelector((state) => state.live.appIds);
const {
piiSharingAllowed, selectedAppId, enabled, status,
} = useSelector(state => state.live);
const appConfig = useModel('liveAppConfigs', selectedAppId);
const app = useModel('liveApps', selectedAppId);
const liveConfiguration = {
enabled: enabled || false,
consumerKey: appConfig?.consumerKey || '',
consumerSecret: appConfig?.consumerSecret || '',
launchUrl: appConfig?.launchUrl || '',
launchEmail: appConfig?.launchEmail || '',
provider: selectedAppId || 'zoom',
piiSharingEnable: piiSharingAllowed || false,
piiSharingUsername: app?.piiSharing?.username || false,
piiSharingEmail: app?.piiSharing?.email || false,
tierType: appConfig?.tierType || '',
};
const validationSchema = {
enabled: Yup.boolean(),
consumerKey: Yup.string().when(['provider', 'tierType'], {
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
then: Yup.string().required(intl.formatMessage(messages.consumerKeyRequired)),
}),
consumerSecret: Yup.string().when(['provider', 'tierType'], {
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
then: Yup.string().notRequired(intl.formatMessage(messages.consumerSecretRequired)),
}),
launchUrl: Yup.string().when(['provider', 'tierType'], {
is: (provider, tier) => provider === 'zoom' || (provider === 'big_blue_button' && tier === bbbPlanTypes.commercial),
then: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)),
}),
launchEmail: Yup.string(),
};
const handleProviderChange = (providerId, setFieldValue, values) => {
dispatch(saveLiveConfigurationAsDraft(values));
dispatch(selectApp({ appId: providerId }));
setFieldValue('provider', providerId);
};
const handleSettingsSave = async (values) => {
await dispatch(saveLiveConfiguration(courseId, values, navigate));
};
useEffect(() => {
dispatch(fetchLiveData(courseId));
}, [courseId]);
return (
<AppSettingsModal
appId="live"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableLiveHelp)}
enableAppLabel={intl.formatMessage(messages.enableLiveLabel)}
learnMoreText={intl.formatMessage(messages.enableLiveLink)}
onClose={onClose}
initialValues={liveConfiguration}
validationSchema={validationSchema}
onSettingsSave={handleSettingsSave}
configureBeforeEnable
enableReinitialize
>
{({ values, setFieldValue }) => (
(status === RequestStatus.IN_PROGRESS) ? (
<Loading />
) : (
<>
<h4 className="my-3">{intl.formatMessage(messages.selectProvider)}</h4>
<SelectableBox.Set
type="checkbox"
value={values.provider}
onChange={(event) => handleProviderChange(event.target.value, setFieldValue, values)}
name="provider"
columns={3}
className="mb-3"
>
{availableProviders.map((provider) => (
<SelectableBox value={provider} type="checkbox" key={provider}>
<div className="d-flex flex-column align-items-center">
<Icon src={iconsSrc[`${camelCase(provider)}`]} alt={provider} />
<span>{intl.formatMessage(messages[`appName-${camelCase(provider)}`])}</span>
</div>
</SelectableBox>
))}
</SelectableBox.Set>
{values.provider === 'zoom' ? <ZoomSettings values={values} />
: (
<BBBSettings
values={values}
setFieldValue={setFieldValue}
/>
)}
</>
)
)}
</AppSettingsModal>
);
};
LiveSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(LiveSettings);

View File

@@ -0,0 +1,166 @@
import {
render,
act,
fireEvent,
waitFor,
queryByRole,
queryByTestId,
queryByText,
getByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
courseId,
initialState,
configurationProviders,
} from './factories/mockApiResponses';
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
let axiosMock;
let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
const mockStore = async ({
usernameSharing = false,
emailSharing = false,
enabled = true,
piiSharingAllowed = true,
}) => {
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
axiosMock.onGet(fetchProviderConfigUrl).reply(200, configurationProviders(emailSharing, usernameSharing));
axiosMock.onGet(fetchLiveConfigUrl).reply(200, generateLiveConfigurationApiResponse(enabled, piiSharingAllowed));
await executeThunk(fetchLiveProviders(courseId), store.dispatch);
await executeThunk(fetchLiveConfiguration(courseId), store.dispatch);
};
describe('LiveSettings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('Live Configuration modal is visible', async () => {
renderComponent();
expect(queryByRole(container, 'dialog')).toBeVisible();
});
test('Displays "Configure Live" heading', async () => {
renderComponent();
const headingElement = queryByTestId(container, 'modal-title');
expect(headingElement).toHaveTextContent(messages.heading.defaultMessage);
});
test('Displays title, helper text and badge when live configuration button is enabled', async () => {
await mockStore({ enabled: true });
renderComponent();
const label = container.querySelector('label[for="enable-live-toggle"]');
const helperText = container.querySelector('#enable-live-toggleHelpText');
const enableBadge = queryByTestId(container, 'enable-badge');
expect(label).toHaveTextContent(messages.enableLiveLabel.defaultMessage);
expect(enableBadge).toHaveTextContent('Enabled');
expect(helperText).toHaveTextContent(messages.enableLiveHelp.defaultMessage);
});
test('Displays title, helper text and hides badge when live configuration button is disabled', async () => {
await mockStore({ enabled: false, piiSharingAllowed: false });
renderComponent();
const label = container.querySelector('label[for="enable-live-toggle"]');
const helperText = container.querySelector('#enable-live-toggleHelpText');
expect(label).toHaveTextContent('Live');
expect(label.firstChild).not.toHaveTextContent('Enabled');
expect(helperText).toHaveTextContent(messages.enableLiveHelp.defaultMessage);
});
test('Displays provider heading, helper text and all providers', async () => {
await mockStore({
enabled: true,
piiSharingAllowed: true,
usernameSharing: false,
emailSharing: true,
});
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const providers = queryByRole(container, 'group');
const helperText = queryByTestId(container, 'helper-text');
expect(providers.childElementCount).toBe(2);
expect(providers).toHaveTextContent('Zoom');
expect(providers).toHaveTextContent('BigBlueButton');
expect(helperText).toHaveTextContent(
messages.providerHelperText.defaultMessage.replace('{providerName}', 'Zoom'),
);
});
test('Unable to save error should be shown on submission if a field is empty', async () => {
const apiDefaultResponse = generateLiveConfigurationApiResponse(true, true);
apiDefaultResponse.lti_configuration.lti_1p1_client_key = '';
await mockStore({ emailSharing: false, piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const saveButton = queryByText(container, 'Save');
await waitFor(async () => {
await act(async () => fireEvent.click(saveButton));
expect(queryByRole(container, 'alert')).toBeVisible();
});
});
});

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
import { providerNames } from './constants';
import LiveCommonFields from './LiveCommonFields';
const ZoomSettings = ({
intl,
values,
}) => (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{!values.piiSharingEnable ? (
<p data-testid="request-pii-sharing">
{intl.formatMessage(messages.requestPiiSharingEnable, { provider: providerNames[values.provider] })}
</p>
) : (
<>
{(values.piiSharingEmail || values.piiSharingUsername)
&& (
<p data-testid="helper-text">
{intl.formatMessage(messages.providerHelperText, { providerName: providerNames[values.provider] })}
</p>
)}
<LiveCommonFields values={values} />
<FormikControl
name="launchEmail"
value={values.launchEmail}
floatingLabel={intl.formatMessage(messages.launchEmail)}
type="input"
/>
</>
)}
</>
);
ZoomSettings.propTypes = {
intl: intlShape.isRequired,
values: PropTypes.shape({
consumerKey: PropTypes.string,
consumerSecret: PropTypes.string,
launchUrl: PropTypes.string,
launchEmail: PropTypes.string,
provider: PropTypes.string,
piiSharingEmail: PropTypes.bool,
piiSharingUsername: PropTypes.bool,
piiSharingEnable: PropTypes.bool,
}).isRequired,
};
export default injectIntl(ZoomSettings);

View File

@@ -0,0 +1,153 @@
import {
render,
queryByTestId,
getByRole,
waitForElementToBeRemoved,
} from '@testing-library/react';
import ReactDOM from 'react-dom';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import LiveSettings from './Settings';
import {
generateLiveConfigurationApiResponse,
courseId,
initialState,
configurationProviders,
} from './factories/mockApiResponses';
import { fetchLiveConfiguration, fetchLiveProviders } from './data/thunks';
import { providerConfigurationApiUrl, providersApiUrl } from './data/api';
import messages from './messages';
let axiosMock;
let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={[liveSettingsUrl]}>
<Routes>
<Route path={liveSettingsUrl} element={<PageWrap><LiveSettings onClose={() => {}} /></PageWrap>} />
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
};
const mockStore = async ({
usernameSharing = false,
emailSharing = false,
enabled = true,
piiSharingAllowed = true,
}) => {
const fetchProviderConfigUrl = `${providersApiUrl}/${courseId}/`;
const fetchLiveConfigUrl = `${providerConfigurationApiUrl}/${courseId}/`;
axiosMock.onGet(fetchProviderConfigUrl).reply(200, configurationProviders(emailSharing, usernameSharing));
axiosMock.onGet(fetchLiveConfigUrl).reply(200, generateLiveConfigurationApiResponse(enabled, piiSharingAllowed));
await executeThunk(fetchLiveProviders(courseId), store.dispatch);
await executeThunk(fetchLiveConfiguration(courseId), store.dispatch);
};
describe('Zoom Settings', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
test('LTI fields are visible when pii sharing is enabled', async () => {
await mockStore({ piiSharingAllowed: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const consumerKey = container.querySelector('input[name="consumerKey"]').parentElement;
const consumerSecret = container.querySelector('input[name="consumerSecret"]').parentElement;
const launchUrl = container.querySelector('input[name="launchUrl"]').parentElement;
const launchEmail = container.querySelector('input[name="launchEmail"]').parentElement;
expect(consumerKey.firstChild).toBeVisible();
expect(consumerKey.lastChild).toHaveTextContent(messages.consumerKey.defaultMessage);
expect(consumerSecret.firstChild).toBeVisible();
expect(consumerSecret.lastChild).toHaveTextContent(messages.consumerSecret.defaultMessage);
expect(launchUrl.firstChild).toBeVisible();
expect(launchUrl.lastChild).toHaveTextContent(messages.launchUrl.defaultMessage);
expect(launchEmail.firstChild).toBeVisible();
expect(launchEmail.lastChild).toHaveTextContent(messages.launchEmail.defaultMessage);
});
test(
'Only connect to support message is visible when pii sharing is disabled',
async () => {
await mockStore({ piiSharingAllowed: false });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const requestPiiText = queryByTestId(container, 'request-pii-sharing');
const consumerKey = container.querySelector('input[name="consumerKey"]');
const consumerSecret = container.querySelector('input[name="consumerSecret"]');
const launchUrl = container.querySelector('input[name="launchUrl"]');
const launchEmail = container.querySelector('input[name="launchEmail"]');
expect(requestPiiText).toHaveTextContent(
messages.requestPiiSharingEnable.defaultMessage.replaceAll('{provider}', 'Zoom'),
);
expect(consumerKey).not.toBeInTheDocument();
expect(consumerSecret).not.toBeInTheDocument();
expect(launchUrl).not.toBeInTheDocument();
expect(launchEmail).not.toBeInTheDocument();
},
);
test('Provider Configuration should be displayed correctly', async () => {
const apiDefaultResponse = generateLiveConfigurationApiResponse(true, true);
await mockStore({ piiSharingAllowed: true });
renderComponent();
const spinner = getByRole(container, 'status');
await waitForElementToBeRemoved(spinner);
const consumerKey = container.querySelector('input[name="consumerKey"]');
const consumerSecret = container.querySelector('input[name="consumerSecret"]');
const launchUrl = container.querySelector('input[name="launchUrl"]');
const launchEmail = container.querySelector('input[name="launchEmail"]');
expect(consumerKey.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_client_key);
expect(consumerSecret.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_client_secret);
expect(launchUrl.value).toBe(apiDefaultResponse.lti_configuration.lti_1p1_launch_url);
expect(launchEmail.value).toBe(
apiDefaultResponse.lti_configuration.lti_config.additional_parameters.custom_instructor_email,
);
});
});

View File

@@ -0,0 +1,20 @@
import {
GoogleMeet, MicrosoftTeams, Zoom, Bbb,
} from '@openedx/paragon/icons';
export const iconsSrc = {
googleMeet: GoogleMeet,
microsoftTeams: MicrosoftTeams,
zoom: Zoom,
bigBlueButton: Bbb,
};
export const providerNames = {
zoom: 'Zoom',
big_blue_button: 'BigBlueButton',
};
export const bbbPlanTypes = {
free: 'Free',
commercial: 'Commercial/self-hosted',
};

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

View File

@@ -0,0 +1,86 @@
export const courseId = 'course-v1:edX+DemoX+Demo_Course';
export const initialState = {
courseDetail: {
courseId,
status: 'LOADED',
},
pagesAndResources: {
courseAppIds: [
'live',
],
loadingStatus: 'successful',
savingStatus: '',
courseAppsApiStatus: {},
courseAppSettings: {},
},
models: {
courseApps: {
live: {
id: 'live',
enabled: true,
name: 'Live',
description: 'Enable in-platform video conferencing by configuring live',
allowedOperations: {
enable: true,
configure: true,
},
documentationLinks: {
learnMoreConfiguration: '',
},
},
},
},
};
export const configurationProviders = (
emailSharing,
usernameSharing,
activeProvider,
hasFreeTier,
) => ({
providers: {
active: activeProvider || 'zoom',
available: {
zoom: {
features: [],
name: 'Zoom LTI PRO',
pii_sharing: {
email: emailSharing,
username: usernameSharing,
},
},
big_blue_button: {
additional_parameters: [],
features: [],
has_free_tier: hasFreeTier,
name: 'Big Blue Button',
},
},
},
});
export const generateLiveConfigurationApiResponse = (
enabled,
piiSharingAllowed,
providerType = 'zoom',
isFreeTier = undefined,
) => ({
course_key: courseId,
enabled,
lti_configuration: {
lti_1p1_client_key: 'consumer_key',
lti_1p1_client_secret: 'secret_key',
lti_1p1_launch_url: 'https://launch-url.com',
version: 'lti_1p1',
lti_config: {
additional_parameters: {
custom_instructor_email: 'launch@email.com',
},
},
},
pii_sharing_allowed: piiSharingAllowed,
provider_type: providerType,
free_tier: providerType === 'bigBlueButton' ? isFreeTier : undefined,
});

View File

@@ -0,0 +1,173 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'authoring.pagesAndResources.live.enableLive.heading',
defaultMessage: 'Configure Live',
description: 'Heading for live configuration',
},
enableLiveLabel: {
id: 'authoring.pagesAndResources.live.enableLive.label',
defaultMessage: 'Live',
description: 'Title for configuration',
},
enableLiveHelp: {
id: 'authoring.pagesAndResources.live.enableLive.help',
defaultMessage: 'Schedule meetings and conduct live course sessions with learners.',
description: 'Tells the purpose of live configuration',
},
enableLiveLink: {
id: 'authoring.pagesAndResources.live.enableLive.link',
defaultMessage: 'Learn more about live',
description: 'Link text that tells the user to learn about the live',
},
saveButton: {
id: 'authoring.discussions.saveButton',
defaultMessage: 'Save',
description: 'Button allowing the user to submit their discussion configuration.',
},
savingButton: {
id: 'authoring.discussions.savingButton',
defaultMessage: 'Saving',
description: 'Button label when the discussion configuration is being submitted.',
},
savedButton: {
id: 'authoring.discussions.savedButton',
defaultMessage: 'Saved',
description: 'Button label when the discussion configuration has been successfully submitted.',
},
selectProvider: {
id: 'authoring.live.selectProvider',
defaultMessage: 'Select a video conferencing tool',
description: '',
},
formInstructions: {
id: 'authoring.live.formInstructions',
defaultMessage: 'Complete the fields below to set up your video conferencing tool.',
description: 'Instruction for configure the video conferencing tool.',
},
consumerKey: {
id: 'authoring.live.consumerKey',
defaultMessage: 'Consumer Key',
description: 'Label for the Consumer Key field.',
},
consumerKeyRequired: {
id: 'authoring.live.consumerKey.required',
defaultMessage: 'Consumer key is a required field',
description: 'Tells the user that the Consumer Key field is required and must have a value.',
},
consumerSecret: {
id: 'authoring.live.consumerSecret',
defaultMessage: 'Consumer Secret',
description: 'Label for the Consumer Secret field.',
},
consumerSecretRequired: {
id: 'authoring.live.consumerSecret.required',
defaultMessage: 'Consumer secret is a required field',
description: 'Tells the user that the Consumer Secret field is required and must have a value.',
},
launchUrl: {
id: 'authoring.live.launchUrl',
defaultMessage: 'Launch URL',
description: 'Label for the Launch URL field.',
},
launchUrlRequired: {
id: 'authoring.live.launchUrl.required',
defaultMessage: 'Launch URL is a required field',
description: 'Tells the user that the Launch URL field is required and must have a value.',
},
launchEmail: {
id: 'authoring.live.launchEmail',
defaultMessage: 'Launch Email',
description: 'Label for the Launch Email field.',
},
launchEmailRequired: {
id: 'authoring.live.launchEmail.required',
defaultMessage: 'Launch Email is a required field',
description: 'Tells the user that the Launch Email field is required and must have a value.',
},
providerHelperText: {
id: 'authoring.live.provider.helpText',
defaultMessage: 'This configuration will require sharing username and emails of learners and the course team with {providerName}.',
description: 'Tells the user that sharing username and email is required for configuration',
},
requestPiiSharingEnable: {
id: 'authoring.live.requestPiiSharingEnable',
defaultMessage: 'This configuration will require sharing usernames and emails of learners and the course team with {provider}. To access the LTI configuration for {provider}, please request your edX project coordinator to get PII sharing enabled for this course.',
description: 'Tells the user that request edx project coordinator to enable the PII sharing to access the LTI configuration for a provider.',
},
general: {
id: 'authoring.live.appDocInstructions.documentationLink',
defaultMessage: 'General documentation',
description: 'Application Document Instructions message for documentation link',
},
accessibility: {
id: 'authoring.live.appDocInstructions.accessibilityDocumentationLink',
defaultMessage: 'Accessibility documentation',
description: 'Application Document Instructions message for accessibility link',
},
configuration: {
id: 'authoring.live.appDocInstructions.configurationLink',
defaultMessage: 'Configuration documentation',
description: 'Application Document Instructions message for configurations link',
},
learnMore: {
id: 'authoring.live.appDocInstructions.learnMoreLink',
defaultMessage: 'Learn more about {providerName}',
description: 'Application Document Instructions message for learn more links',
},
linkTextHeading: {
id: 'authoring.live.appDocInstructions.linkTextHeading',
defaultMessage: 'External help and documentation',
description: 'External help and documentation heading',
},
linkText: {
id: 'authoring.live.appDocInstructions.linkText',
defaultMessage: '{link}',
description: 'link',
},
'appName-zoom': {
id: 'authoring.live.appName-yellowdig',
defaultMessage: 'Zoom',
description: 'The name of the Zoom app.',
},
'appName-googleMeet': {
id: 'authoring.live.appName-googleMeet',
defaultMessage: 'Google Meet',
description: 'The name of the Google Meet app.',
},
'appName-microsoftTeams': {
id: 'authoring.live.appName-microsoftTeams',
defaultMessage: 'Microsoft Teams',
description: 'The name of the Microsoft Teams app.',
},
'appName-bigBlueButton': {
id: 'authoring.live.appName-bigBlueButton',
defaultMessage: 'BigBlueButton',
description: 'The name of the Big Blue Button Teams app.',
},
requestPiiSharingEnableForBbb: {
id: 'authoring.live.requestPiiSharingEnableForBbb',
defaultMessage: 'This configuration will require sharing usernames of learners and the course team with {provider}.',
description: 'Tells the user that they require sharing usernames with the provider to use this feature',
},
piiSharingEnableHelpTextBbb: {
id: 'authoring.live.piiSharingEnableHelpText',
defaultMessage: 'To enable this feature, contact your edX support team to enable PII sharing for this course.',
description: 'Tells the user that request edx project coordinator to enable the PII sharing to access the LTI configuration for a provider.',
},
freePlanMessage: {
id: 'authoring.live.freePlanMessage',
defaultMessage: 'The free plan is pre-configured, and no additional configurations are required. By selecting the free plan, you are agreeing to Blindside Networks',
description: 'Tells user that free plans requires no additional configurations',
},
privacyPolicy: {
id: 'authoring.live.privacyPolicy',
defaultMessage: 'Privacy Policy.',
description: 'The text of privacy policy hyperlink for free plan',
},
});
export default messages;

View File

@@ -0,0 +1,22 @@
{
"name": "@openedx-plugins/course-app-live",
"version": "0.1.0",
"description": "Live course configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"@reduxjs/toolkit": "*",
"lodash": "*",
"prop-types": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { useModel } from 'CourseAuthoring/generic/model-store';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ORASettings = ({ intl, onClose }) => {
const appId = 'ora_settings';
const appInfo = useModel('courseApps', appId);
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
'forceOnFlexiblePeerOpenassessments',
);
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
const title = (
<div>
<p>{intl.formatMessage(messages.heading)}</p>
<div className="pt-3">
<Hyperlink
className="text-primary-500 small"
destination={appInfo.documentationLinks?.learnMoreConfiguration}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages.ORASettingsHelpLink)}
</Hyperlink>
</div>
</div>
);
return (
<AppSettingsModal
appId={appId}
title={title}
onClose={onClose}
initialValues={{ enableFlexiblePeerGrade }}
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
onSettingsSave={handleSettingsSave}
hideAppToggle
>
{({ values, handleChange, handleBlur }) => (
<FormSwitchGroup
id="enable-flexible-peer-grade"
name="enableFlexiblePeerGrade"
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
onChange={handleChange}
onBlur={handleBlur}
checked={values.enableFlexiblePeerGrade}
/>
)}
</AppSettingsModal>
);
};
ORASettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(ORASettings);

View File

@@ -0,0 +1,33 @@
import { shallow } from '@edx/react-unit-test-utils';
import ORASettings from './Settings';
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
injectIntl: (component) => component,
intlShape: {},
}));
jest.mock('yup', () => ({
boolean: jest.fn().mockReturnValue('Yub.boolean'),
}));
jest.mock('CourseAuthoring/generic/model-store', () => ({
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
}));
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
jest.mock('CourseAuthoring/utils', () => ({
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
}));
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
const props = {
onClose: jest.fn().mockName('onClose'),
intl: {
formatMessage: (message) => message.defaultMessage,
},
};
describe('ORASettings', () => {
it('should render', () => {
const wrapper = shallow(<ORASettings {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ORASettings should render 1`] = `
<AppSettingsModal
appId="ora_settings"
hideAppToggle={true}
initialValues={
Object {
"enableFlexiblePeerGrade": "abitrary value",
}
}
onClose={[MockFunction onClose]}
onSettingsSave={[Function]}
title={
<div>
<p>
Configure open response assessment
</p>
<div
className="pt-3"
>
<withDeprecatedProps(Hyperlink)
className="text-primary-500 small"
destination="https://learnmore.test"
rel="noreferrer noopener"
target="_blank"
>
Learn more about open response assessment settings
</withDeprecatedProps(Hyperlink)>
</div>
</div>
}
validationSchema={
Object {
"enableFlexiblePeerGrade": "Yub.boolean",
}
}
>
[Function]
</AppSettingsModal>
`;

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.ora.heading',
defaultMessage: 'Configure open response assessment',
},
ORASettingsHelpLink: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.link',
defaultMessage: 'Learn more about open response assessment settings',
},
enableFlexPeerGradeLabel: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.label',
defaultMessage: 'Flex Peer Grading',
},
enableFlexPeerGradeHelp: {
id: 'course-authoring.pages-resources.ora.flex-peer-grading.help',
defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.',
},
});
export default messages;

View File

@@ -0,0 +1,19 @@
{
"name": "@openedx-plugins/course-app-ora_settings",
"version": "0.1.0",
"description": "Open Response Assessment configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,635 @@
import React, {
useContext, useEffect, useRef, useState,
} from 'react';
import classNames from 'classnames';
import EmailValidator from 'email-validator';
import moment from 'moment';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
} from '@openedx/paragon';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import Loading from 'CourseAuthoring/generic/Loading';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import messages from './messages';
const ProctoringSettings = ({ intl, onClose }) => {
const initialFormValues = {
enableProctoredExams: false,
proctoringProvider: false,
escalationEmail: '',
allowOptingOut: false,
createZendeskTickets: false,
};
const [formValues, setFormValues] = useState(initialFormValues);
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
const [loadingConnectionError, setLoadingConnectionError] = useState(false);
const [loadingPermissionError, setLoadingPermissionError] = useState(false);
const [allowLtiProviders, setAllowLtiProviders] = useState(false);
const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]);
const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]);
const [courseStartDate, setCourseStartDate] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState(false);
const [submissionInProgress, setSubmissionInProgress] = useState(false);
const [showEscalationEmail, setShowEscalationEmail] = useState(false);
const isEdxStaff = getAuthenticatedUser().administrator;
const [formStatus, setFormStatus] = useState({
isValid: true,
errors: {},
});
const isMobile = useIsMobile();
const modalVariant = isMobile ? 'dark' : 'default';
const isLtiProvider = (provider) => (
ltiProctoringProviders.some(p => p.name === provider)
);
function getProviderDisplayLabel(provider) {
// if a display label exists for this provider return it
return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider;
}
const { courseId } = useContext(PagesAndResourcesContext);
const appInfo = useModel('courseApps', 'proctoring');
const alertRef = React.createRef();
const saveStatusAlertRef = React.createRef();
const proctoringEscalationEmailInputRef = useRef(null);
const submitButtonState = submissionInProgress ? 'pending' : 'default';
const handleChange = (event) => {
const { target } = event;
const value = target.type === 'checkbox' ? target.checked : target.value;
const { name } = target;
if (['allowOptingOut', 'createZendeskTickets'].includes(name)) {
// Form.Radio expects string values, so convert back to a boolean here
setFormValues({ ...formValues, [name]: value === 'true' });
} else if (name === 'proctoringProvider') {
const newFormValues = { ...formValues, proctoringProvider: value };
if (value === 'proctortrack') {
setFormValues({ ...newFormValues, createZendeskTickets: false });
setShowEscalationEmail(true);
} else if (value === 'software_secure') {
setFormValues({ ...newFormValues, createZendeskTickets: true });
setShowEscalationEmail(false);
} else if (isLtiProvider(value)) {
setFormValues(newFormValues);
setShowEscalationEmail(true);
} else {
setFormValues(newFormValues);
setShowEscalationEmail(false);
}
} else {
setFormValues({ ...formValues, [name]: value });
}
};
const setFocusToEscalationEmailInput = () => {
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
proctoringEscalationEmailInputRef.current.focus();
}
};
function postSettingsBackToServer() {
const selectedProvider = formValues.proctoringProvider;
const isLtiProviderSelected = isLtiProvider(selectedProvider);
const studioDataToPostBack = {
proctored_exam_settings: {
enable_proctored_exams: formValues.enableProctoredExams,
// lti providers are managed outside edx-platform, lti_external indicates this
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
create_zendesk_tickets: formValues.createZendeskTickets,
},
};
if (isEdxStaff) {
studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = formValues.allowOptingOut;
}
if (formValues.proctoringProvider === 'proctortrack') {
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
}
// only save back to exam service if necessary
setSubmissionInProgress(true);
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
if (allowLtiProviders && ExamsApiService.isAvailable()) {
const selectedEscalationEmail = formValues.escalationEmail;
saveOperations.push(
ExamsApiService.saveCourseExamConfiguration(
courseId,
{
provider: isLtiProviderSelected ? formValues.proctoringProvider : null,
escalationEmail: (isLtiProviderSelected && selectedEscalationEmail !== '') ? selectedEscalationEmail : null,
},
),
);
}
Promise.all(saveOperations)
.then(() => {
setSaveSuccess(true);
setSaveError(false);
setSubmissionInProgress(false);
}).catch(() => {
setSaveSuccess(false);
setSaveError(true);
setSubmissionInProgress(false);
});
}
const handleSubmit = (event) => {
event.preventDefault();
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
if (
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
&& !EmailValidator.validate(formValues.escalationEmail)
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
) {
if (formValues.escalationEmail === '') {
const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank'], { proctoringProviderName: getProviderDisplayLabel(formValues.proctoringProvider) });
setFormStatus({
isValid: false,
errors: {
formEscalationEmail: {
dialogErrorMessage: (
<Alert.Link onClick={setFocusToEscalationEmailInput} href="#formEscalationEmail" data-testid="escalationEmailErrorLink">
{errorMessage}
</Alert.Link>
),
inputErrorMessage: errorMessage,
},
},
});
} else {
const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.invalid']);
setFormStatus({
isValid: false,
errors: {
formEscalationEmail: {
dialogErrorMessage: (<Alert.Link onClick={setFocusToEscalationEmailInput} href="#formEscalationEmail" data-testid="escalationEmailErrorLink">{errorMessage}</Alert.Link>),
inputErrorMessage: errorMessage,
},
},
});
}
} else {
postSettingsBackToServer();
const errors = { ...formStatus.errors };
delete errors.formEscalationEmail;
setFormStatus({
isValid: true,
errors,
});
}
};
function cannotEditProctoringProvider() {
const currentDate = moment(moment()).format('YYYY-MM-DD[T]hh:mm:ss[Z]');
const isAfterCourseStart = currentDate > courseStartDate;
// if the user is not edX staff and it is after the course start date, user cannot edit proctoring provider
return !isEdxStaff && isAfterCourseStart;
}
function isDisabledOption(provider) {
let markDisabled = false;
if (cannotEditProctoringProvider()) {
markDisabled = provider !== formValues.proctoringProvider;
}
return markDisabled;
}
function getProctoringProviderOptions(providers) {
return providers.map(provider => (
<option
key={provider}
value={provider}
disabled={isDisabledOption(provider)}
data-testid={provider}
>
{getProviderDisplayLabel(provider)}
</option>
));
}
function getFormErrorMessage() {
const numOfErrors = Object.keys(formStatus.errors).length;
const errors = Object.entries(formStatus.errors).map(([id, error]) => <li key={id}>{error.dialogErrorMessage}</li>);
const messageId = numOfErrors > 1 ? 'authoring.proctoring.error.multiple' : 'authoring.proctoring.error.single';
return (
<>
<div>{intl.formatMessage(messages[messageId], { numOfErrors })}</div>
<ul>
{errors}
</ul>
</>
);
}
const learnMoreLink = appInfo?.documentationLinks?.learnMoreConfiguration && (
<Hyperlink
className="text-primary-500"
destination={appInfo.documentationLinks.learnMoreConfiguration}
target="_blank"
rel="noreferrer noopener"
>
{intl.formatMessage(messages['authoring.proctoring.learn.more'])}
</Hyperlink>
);
function renderContent() {
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
return (
<>
{!formStatus.isValid && formStatus.errors.formEscalationEmail
&& (
// tabIndex="-1" to make non-focusable element focusable
<Alert
id="escalationEmailError"
variant="danger"
tabIndex="-1"
data-testid="escalationEmailError"
ref={alertRef}
>
{getFormErrorMessage()}
</Alert>
)}
{/* ENABLE PROCTORED EXAMS */}
<FormSwitchGroup
id="enable-proctoring-toggle"
name="enableProctoredExams"
onChange={handleChange}
checked={formValues.enableProctoredExams}
label={(
<div className="d-flex align-items-center">
{intl.formatMessage(messages['authoring.proctoring.enableproctoredexams.label'])}
{
formValues.enableProctoredExams && (
<Badge className="ml-2" variant="success">
{intl.formatMessage(messages['authoring.proctoring.enabled'])}
</Badge>
)
}
</div>
)}
helpText={(
<div>
<p>
{intl.formatMessage(messages['authoring.proctoring.enableproctoredexams.help'])}
</p>
<span className="py-3">{learnMoreLink}</span>
</div>
)}
/>
{/* PROCTORING PROVIDER */}
{ formValues.enableProctoredExams && (
<>
<hr />
<Form.Group controlId="formProctoringProvider">
<Form.Label as="legend" className="font-weight-bold">
{intl.formatMessage(messages['authoring.proctoring.provider.label'])}
</Form.Label>
<Form.Control
as="select"
name="proctoringProvider"
value={formValues.proctoringProvider}
onChange={handleChange}
aria-describedby="proctoringProviderHelpText"
>
{getProctoringProviderOptions(availableProctoringProviders)}
</Form.Control>
<Form.Text id="proctoringProviderHelpText">
{
cannotEditProctoringProvider()
? intl.formatMessage(messages['authoring.proctoring.provider.help.aftercoursestart'])
: intl.formatMessage(messages['authoring.proctoring.provider.help'])
}
</Form.Text>
</Form.Group>
</>
)}
{/* ESCALATION EMAIL */}
{showEscalationEmail && formValues.enableProctoredExams && (
<Form.Group controlId="formEscalationEmail">
<Form.Label className="font-weight-bold">
{intl.formatMessage(messages['authoring.proctoring.escalationemail.label'])}
</Form.Label>
<Form.Control
ref={proctoringEscalationEmailInputRef}
type="email"
name="escalationEmail"
data-testid="escalationEmail"
onChange={handleChange}
value={formValues.escalationEmail}
isInvalid={Object.prototype.hasOwnProperty.call(formStatus.errors, 'formEscalationEmail')}
aria-describedby="escalationEmailHelpText"
/>
<Form.Text id="escalationEmailHelpText">
{intl.formatMessage(messages['authoring.proctoring.escalationemail.help'])}
</Form.Text>
{Object.prototype.hasOwnProperty.call(formStatus.errors, 'formEscalationEmail') && (
<Form.Control.Feedback type="invalid">
{
formStatus.errors.formEscalationEmail
&& formStatus.errors.formEscalationEmail.inputErrorMessage
}
</Form.Control.Feedback>
)}
</Form.Group>
)}
{/* ALLOW OPTING OUT OF PROCTORED EXAMS */}
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
<fieldset aria-describedby="allowOptingOutHelpText">
<Form.Group controlId="formAllowingOptingOut">
<Form.Label as="legend" className="font-weight-bold">
{intl.formatMessage(messages['authoring.proctoring.allowoptout.label'])}
</Form.Label>
<Form.RadioSet
name="allowOptingOut"
data-testid="allowOptingOutRadio"
value={formValues.allowOptingOut.toString()}
onChange={handleChange}
>
<Form.Radio value="true" data-testid="allowOptingOutYes">
{intl.formatMessage(messages['authoring.proctoring.yes'])}
</Form.Radio>
<Form.Radio value="false" data-testid="allowOptingOutNo">
{intl.formatMessage(messages['authoring.proctoring.no'])}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</fieldset>
)}
{/* CREATE ZENDESK TICKETS */}
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend" className="font-weight-bold">
{intl.formatMessage(messages['authoring.proctoring.createzendesk.label'])}
</Form.Label>
<Form.RadioSet
name="createZendeskTickets"
value={formValues.createZendeskTickets.toString()}
onChange={handleChange}
>
<Form.Radio value="true" data-testid="createZendeskTicketsYes">
{intl.formatMessage(messages['authoring.proctoring.yes'])}
</Form.Radio>
<Form.Radio value="false" data-testid="createZendeskTicketsNo">
{intl.formatMessage(messages['authoring.proctoring.no'])}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</fieldset>
)}
</>
);
}
function renderLoading() {
return (
<Loading />
);
}
function renderConnectionError() {
return (
<ConnectionErrorAlert />
);
}
function renderPermissionError() {
return (
<PermissionDeniedAlert />
);
}
function renderSaveSuccess() {
const studioCourseRunURL = StudioApiService.getStudioCourseRunUrl(courseId);
return (
<Alert
variant="success"
data-testid="saveSuccess"
tabIndex="-1"
ref={saveStatusAlertRef}
onClose={() => setSaveSuccess(false)}
dismissible
>
<FormattedMessage
id="authoring.proctoring.alert.success"
defaultMessage={`
Proctored exam settings saved successfully. {studioCourseRunURL}.
`}
values={{
studioCourseRunURL: (
<Alert.Link href={studioCourseRunURL}>
{intl.formatMessage(messages['authoring.proctoring.studio.link.text'])}
</Alert.Link>
),
}}
/>
</Alert>
);
}
function renderSaveError() {
return (
<Alert
variant="danger"
data-testid="saveError"
tabIndex="-1"
ref={saveStatusAlertRef}
onClose={() => setSaveError(false)}
dismissible
>
<FormattedMessage
id="authoring.examsettings.alert.error"
defaultMessage={`
We encountered a technical error while trying to save proctored exam settings.
This might be a temporary issue, so please try again in a few minutes.
If the problem persists, please go to the {support_link} for help.
`}
values={{
support_link: (
<Alert.Link href={getConfig().SUPPORT_URL}>
{intl.formatMessage(messages['authoring.proctoring.support.text'])}
</Alert.Link>
),
}}
/>
</Alert>
);
}
useEffect(() => {
Promise.all([
StudioApiService.getProctoredExamSettingsData(courseId),
ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(),
ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(),
])
.then(
([settingsResponse, examConfigResponse, ltiProvidersResponse]) => {
const proctoredExamSettings = settingsResponse.data.proctored_exam_settings;
setLoaded(true);
setLoading(false);
setSubmissionInProgress(false);
setCourseStartDate(settingsResponse.data.course_start_date);
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
// The list of providers returned by studio settings are the default behavior. If lti_external
// is available as an option display the list of LTI providers returned by the exam service.
// Setting 'lti_external' in studio indicates an LTI provider configured outside of edx-platform.
// This option is not directly selectable.
const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers;
const proctoringProvidersLti = ltiProvidersResponse?.data || [];
const enableLtiProviders = proctoringProvidersStudio.includes('lti_external');
setAllowLtiProviders(enableLtiProviders);
setLtiProctoringProviders(proctoringProvidersLti);
// flatten provider objects and coalesce values to just the provider key
let availableProviders = proctoringProvidersStudio.filter(value => value !== 'lti_external');
if (enableLtiProviders) {
availableProviders = proctoringProvidersLti.reduce(
(result, provider) => [...result, provider.name],
availableProviders,
);
}
setAvailableProctoringProviders(availableProviders);
let selectedProvider;
if (proctoredExamSettings.proctoring_provider === 'lti_external') {
selectedProvider = examConfigResponse.data.provider;
} else {
selectedProvider = proctoredExamSettings.proctoring_provider;
}
const isProctortrack = selectedProvider === 'proctortrack';
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
if (isProctortrack || ltiProviderSelected) {
setShowEscalationEmail(true);
}
const proctoringEscalationEmail = ltiProviderSelected
? examConfigResponse.data.escalation_email
: proctoredExamSettings.proctoring_escalation_email;
setFormValues({
...formValues,
proctoringProvider: selectedProvider,
enableProctoredExams: proctoredExamSettings.enable_proctored_exams,
allowOptingOut: proctoredExamSettings.allow_proctoring_opt_out,
createZendeskTickets: proctoredExamSettings.create_zendesk_tickets,
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
// In order to keep our email input component controlled, we use the empty string as the default
// and perform this conversion during GETs and POSTs.
escalationEmail: proctoringEscalationEmail === null ? '' : proctoringEscalationEmail,
});
},
).catch(
error => {
if (error.response?.status === 403) {
setLoadingPermissionError(true);
} else {
setLoadingConnectionError(true);
}
setLoading(false);
setLoaded(false);
setSubmissionInProgress(false);
},
);
}, []);
useEffect(() => {
if ((saveSuccess || saveError) && !!saveStatusAlertRef.current) {
saveStatusAlertRef.current.focus();
}
if (!formStatus.isValid && !!alertRef.current) {
alertRef.current.focus();
}
}, [formStatus, saveSuccess, saveError]);
return (
<ModalDialog
title="Proctoring Settings"
isOpen
onClose={onClose}
size="lg"
variant={modalVariant}
hasCloseButton={isMobile}
isFullscreenScroll
isFullscreenOnMobile
>
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
<ModalDialog.Header>
<ModalDialog.Title>
Proctored Exam Settings
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{loading ? renderLoading() : null}
{saveSuccess ? renderSaveSuccess() : null}
{saveError ? renderSaveError() : null}
{loaded ? renderContent() : null}
{loadingConnectionError ? renderConnectionError() : null}
{loadingPermissionError ? renderPermissionError() : null}
</ModalDialog.Body>
<ModalDialog.Footer
className={classNames(
'p-4',
)}
>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages['authoring.proctoring.cancel'])}
</ModalDialog.CloseButton>
<StatefulButton
labels={{
default: intl.formatMessage(messages['authoring.proctoring.save']),
pending: intl.formatMessage(messages['authoring.proctoring.saving']),
}}
description="Form save button"
data-testid="submissionButton"
disabled={submissionInProgress}
state={submitButtonState}
type="submit"
/>
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
);
};
ProctoringSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
ProctoringSettings.defaultProps = {};
export default injectIntl(ProctoringSettings);

View File

@@ -0,0 +1,912 @@
import React from 'react';
import {
render, screen, cleanup, waitFor, fireEvent, act,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp, mergeConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import StudioApiService from 'CourseAuthoring/data/services/StudioApiService';
import ExamsApiService from 'CourseAuthoring/data/services/ExamsApiService';
import initializeStore from 'CourseAuthoring/store';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import ProctoredExamSettings from './Settings';
const defaultProps = {
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
onClose: () => {},
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
let store;
const intlWrapper = children => (
<AppProvider store={store}>
<PagesAndResourcesProvider courseId={defaultProps.courseId}>
<IntlProvider locale="en">
{children}
</IntlProvider>
</PagesAndResourcesProvider>
</AppProvider>
);
let axiosMock;
describe('ProctoredExamSettings', () => {
function setupApp(isAdmin = true) {
mergeConfig({
EXAMS_BASE_URL: 'http://exams.testing.co',
}, 'CourseAuthoringConfig');
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: isAdmin,
roles: [],
},
});
store = initializeStore({
models: {
courseApps: {
proctoring: {},
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(200, [
{
name: 'test_lti',
verbose_name: 'LTI Provider',
},
]);
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: null,
});
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
course_start_date: '2070-01-01T00:00:00Z',
});
}
afterEach(() => {
cleanup();
axiosMock.reset();
});
beforeEach(async () => {
setupApp();
});
describe('Field dependencies', () => {
beforeEach(async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
it('Updates Zendesk ticket field if proctortrack is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsNo');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Updates Zendesk ticket field if software_secure is provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'software_secure' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Does not update zendesk ticket field for any other provider', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'mockproc' } });
});
const zendeskTicketInput = screen.getByTestId('createZendeskTicketsYes');
expect(zendeskTicketInput.checked).toEqual(true);
});
it('Hides all other fields when enabledProctorExam is false when first loaded', async () => {
cleanup();
// Overrides the handler defined in beforeEach.
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: false,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctored exams');
});
const enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
expect(enabledProctoredExamCheck.checked).toEqual(false);
expect(screen.queryByText('Allow Opting Out of Proctored Exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides all other fields when enableProctoredExams toggled to false', async () => {
await waitFor(() => {
screen.getByText('Proctored exams');
});
expect(screen.queryByText('Allow opting out of proctored exams')).toBeDefined();
expect(screen.queryByDisplayValue('mockproc')).toBeDefined();
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeDefined();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeDefined();
let enabledProctoredExamCheck = screen.getAllByLabelText('Proctored exams', { exact: false })[0];
expect(enabledProctoredExamCheck.checked).toEqual(true);
await act(async () => {
fireEvent.click(enabledProctoredExamCheck, { target: { value: false } });
});
enabledProctoredExamCheck = screen.getByLabelText('Proctored exams');
expect(enabledProctoredExamCheck.checked).toEqual(false);
expect(screen.queryByText('Allow opting out of proctored exams')).toBeNull();
expect(screen.queryByDisplayValue('mockproc')).toBeNull();
expect(screen.queryByTestId('escalationEmail')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
it('Hides unsupported fields when lti provider is selected', async () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
});
});
describe('Validation with invalid escalation email', () => {
const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
beforeEach(async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
course_start_date: '2070-01-01T00:00:00Z',
});
axiosMock.onPatch(
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
).reply(204, {});
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
expect(escalationEmailError.textContent).not.toBeNull();
expect(document.activeElement).toEqual(escalationEmailError);
// verify alert link links to offending input
const errorLink = screen.getByTestId('escalationEmailErrorLink');
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectElement = screen.getByDisplayValue('proctortrack');
await act(async () => {
fireEvent.change(selectElement, { target: { value: provider } });
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
expect(document.activeElement).toEqual(escalationEmailError);
expect(escalationEmailError.textContent).not.toBeNull();
expect(document.activeElement).toEqual(escalationEmailError);
// verify alert link links to offending input
const errorLink = screen.getByTestId('escalationEmailErrorLink');
await act(async () => {
fireEvent.click(errorLink);
});
const escalationEmailInput = screen.getByTestId('escalationEmail');
expect(document.activeElement).toEqual(escalationEmailInput);
});
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
});
const enableProctoringElement = screen.getByText('Proctored exams');
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify alert content and focus management
const escalationEmailError = screen.getByTestId('escalationEmailError');
expect(document.activeElement).toEqual(escalationEmailError);
expect(escalationEmailError.textContent).not.toBeNull();
expect(document.activeElement).toEqual(escalationEmailError);
});
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
const enableProctoringElement = screen.getByText('Proctored exams');
await act(async () => fireEvent.click(enableProctoringElement));
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
});
const selectButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(selectButton);
});
// verify there is no escalation email alert, and focus has been set on save success alert
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
});
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeNull();
await act(async () => {
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
});
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
});
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
await waitFor(() => {
screen.getByDisplayValue('proctortrack');
});
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
await act(async () => {
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
});
await act(async () => {
fireEvent.submit(selectEscalationEmailElement);
});
// if the error appears, the form has been submitted
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
});
});
});
describe('Proctoring provider options', () => {
const mockGetFutureCourseData = {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2099-01-01T00:00:00Z',
};
const mockGetPastCourseData = {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2013-01-01T00:00:00Z',
};
function mockCourseData(data) {
axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, data);
}
it('Disables irrelevant proctoring provider fields when user is not an administrator and it is after start date', async () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(true);
});
it('Enables all proctoring provider options if user is not an administrator and it is before start date', async () => {
const isAdmin = false;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is after start date', async () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetPastCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Enables all proctoring provider options if user administrator and it is before start date', async () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Does not include lti_external as a selectable option', async () => {
const courseData = {
...mockGetFutureCourseData,
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
expect(screen.queryByTestId('lti_external')).toBeNull();
});
it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => {
const courseData = {
...mockGetFutureCourseData,
available_proctoring_providers: ['lti_external', 'proctortrack', 'mockproc'],
};
mockCourseData(courseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const providerOption = screen.getByTestId('test_lti');
// as as admin the provider should not be disabled
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
it('Does not include lti provider options when lti_external is not available in studio', async () => {
const isAdmin = true;
setupApp(isAdmin);
mockCourseData(mockGetFutureCourseData);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
const providerOption = screen.queryByTestId('test_lti');
expect(providerOption).not.toBeInTheDocument();
});
it('Does not request lti provider options if there is no exam service url configuration', async () => {
mergeConfig({
EXAMS_BASE_URL: null,
}, 'CourseAuthoringConfig');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// only outgoing request should be for studio settings
expect(axiosMock.history.get.length).toBe(1);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});
it('Selected LTI proctoring provider is shown on page load', async () => {
const courseData = { ...mockGetFutureCourseData };
courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc'];
courseData.proctored_exam_settings.proctoring_provider = 'lti_external';
mockCourseData(courseData);
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, {
provider: 'test_lti',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByText('Proctoring provider');
});
// make sure test_lti is the selected provider
expect(screen.getByDisplayValue('LTI Provider')).toBeInTheDocument();
});
});
describe('Toggles field visibility based on user permissions', () => {
it('Hides opting out and zendesk tickets for non edX staff', async () => {
setupApp(false);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
});
it('Shows opting out and zendesk tickets for edX staff', async () => {
setupApp(true);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
});
describe('Connection states', () => {
it('Shows the spinner before the connection is complete', async () => {
await act(async () => {
render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />));
// This expectation is _inside_ the `act` intentionally, so that it executes immediately.
const spinner = screen.getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
});
it('Show connection error message when we suffer studio server side error', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
);
});
it('Show connection error message when we suffer edx-exams server side error', async () => {
axiosMock.onGet(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`,
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionErrorAlert');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error when loading this page.'),
);
});
it('Show permission error message when user do not have enough permission', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(403);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const permissionError = screen.getByTestId('permissionDeniedAlert');
expect(permissionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
);
});
});
describe('Save settings', () => {
beforeEach(async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(200, 'success');
});
it('Disable button while submitting', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
let submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
act(() => {
fireEvent.click(submitButton);
});
submitButton = screen.getByTestId('submissionButton');
expect(submitButton).toHaveAttribute('disabled');
});
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
await act(async () => {
fireEvent.change(escalationEmail, { target: { value: 'proctortrack@example.com' } });
});
expect(escalationEmail.value).toEqual('proctortrack@example.com');
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'proctortrack@example.com',
create_zendesk_tickets: false,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// make sure we have not selected proctortrack as the proctoring provider
expect(screen.getByDisplayValue('mockproc')).toBeDefined();
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to test_lti and set the email
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
});
const escalationEmail = screen.getByTestId('escalationEmail');
expect(escalationEmail.value).toEqual('test@example.com');
await act(async () => {
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
});
expect(escalationEmail.value).toEqual('test_lti@example.com');
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: 'test_lti',
escalation_email: 'test_lti@example.com',
});
// update studio settings
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'lti_external',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Sets exam service provider to null if a non-lti provider is selected', async () => {
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// update exam service config
expect(axiosMock.history.patch.length).toBe(1);
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
provider: null,
escalation_email: null,
});
expect(axiosMock.history.patch.length).toBe(1);
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Does not update exam service if lti is not enabled in studio', async () => {
axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: true,
},
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
course_start_date: '2070-01-01T00:00:00Z',
});
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
// does not update exam service config
expect(axiosMock.history.patch.length).toBe(0);
// does update studio
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
allow_proctoring_opt_out: false,
proctoring_provider: 'mockproc',
create_zendesk_tickets: true,
},
});
const errorAlert = screen.getByTestId('saveSuccess');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes studio API call generated error', async () => {
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Makes exams API call generated error', async () => {
axiosMock.onPatch(
`${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`,
).reply(500, 'error');
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
});
it('Manages focus correctly after different save statuses', async () => {
// first make a call that will cause a save error
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(500);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
const errorAlert = screen.getByTestId('saveError');
expect(errorAlert.textContent).toEqual(
expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'),
);
expect(document.activeElement).toEqual(errorAlert);
// now make a call that will allow for a successful save
axiosMock.onPost(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, 'success');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(2);
const successAlert = screen.getByTestId('saveSuccess');
expect(successAlert.textContent).toEqual(
expect.stringContaining('Proctored exam settings saved successfully.'),
);
expect(document.activeElement).toEqual(successAlert);
});
it('Include Zendesk ticket in post request if user is not an admin', async () => {
// use non-admin user for test
const isAdmin = false;
setupApp(isAdmin);
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the proctoring provider
const selectElement = screen.getByDisplayValue('mockproc');
await act(async () => {
fireEvent.change(selectElement, { target: { value: 'proctortrack' } });
});
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {
fireEvent.click(submitButton);
});
expect(axiosMock.history.post.length).toBe(1);
expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({
proctored_exam_settings: {
enable_proctored_exams: true,
proctoring_provider: 'proctortrack',
proctoring_escalation_email: 'test@example.com',
create_zendesk_tickets: false,
},
});
});
});
});

View File

@@ -0,0 +1,116 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authoring.proctoring.no': {
id: 'authoring.proctoring.no',
defaultMessage: 'No',
description: '"No" option for yes/no radio button set',
},
'authoring.proctoring.yes': {
id: 'authoring.proctoring.yes',
defaultMessage: 'Yes',
description: '"Yes" option for proctored exam settings',
},
'authoring.proctoring.support.text': {
id: 'authoring.proctoring.support.text',
defaultMessage: 'Support Page',
description: 'Text linking to the support page.',
},
'authoring.proctoring.enableproctoredexams.label': {
id: 'authoring.proctoring.enableproctoredexams.label',
defaultMessage: 'Proctored exams',
description: 'Label for checkbox to enable proctored exams.',
},
'authoring.proctoring.enableproctoredexams.help': {
id: 'authoring.proctoring.enableproctoredexams.help',
defaultMessage: 'Enable and configure proctored exams in your course.',
description: 'Help text for checkbox to enable proctored exams.',
},
'authoring.proctoring.enabled': {
id: 'authoring.proctoring.enabled',
defaultMessage: 'Enabled',
description: 'Text describing that the feature is enabled.',
},
'authoring.proctoring.learn.more': {
id: 'authoring.proctoring.learn.more',
defaultMessage: 'Learn more about proctoring',
description: 'Link to learn more about the proctoring feature.',
},
'authoring.proctoring.provider.label': {
id: 'authoring.proctoring.provider.label',
defaultMessage: 'Proctoring provider',
description: 'Label for proctoring provider dropdown selection.',
},
'authoring.proctoring.provider.help': {
id: 'authoring.proctoring.provider.help',
defaultMessage: 'Select the proctoring provider you want to use for this course run.',
description: 'Help text for selecting a proctoring provider.',
},
'authoring.proctoring.provider.help.aftercoursestart': {
id: 'authoring.proctoring.provider.help.aftercoursestart',
defaultMessage: 'Proctoring provider cannot be modified after course start date.',
description: 'Help text notifying the user that the provider cannot be changed for a course that has already begun.',
},
'authoring.proctoring.escalationemail.label': {
id: 'authoring.proctoring.escalationemail.label',
defaultMessage: 'Escalation email',
description: 'Label for escalation email text field',
},
'authoring.proctoring.escalationemail.help': {
id: 'authoring.proctoring.escalationemail.help',
defaultMessage: 'Provide an email address to be contacted by the support team for escalations (e.g. appeals, delayed reviews).',
description: 'Help text explaining escalation email field.',
},
'authoring.proctoring.escalationemail.error.blank': {
id: 'authoring.proctoring.escalationemail.error.blank',
defaultMessage: 'The Escalation Email field cannot be empty if {proctoringProviderName} is the selected provider.',
description: 'Error message for missing required email field.',
},
'authoring.proctoring.escalationemail.error.invalid': {
id: 'authoring.proctoring.escalationemail.error.invalid',
defaultMessage: 'The Escalation Email field is in the wrong format and is not valid.',
description: 'Error message for a invalid email format.',
},
'authoring.proctoring.allowoptout.label': {
id: 'authoring.proctoring.allowoptout.label',
defaultMessage: 'Allow learners to opt out of proctoring on proctored exams',
description: 'Label for radio selection allowing proctored exam opt out',
},
'authoring.proctoring.createzendesk.label': {
id: 'authoring.proctoring.createzendesk.label',
defaultMessage: 'Create Zendesk tickets for suspicious attempts',
description: 'Label for Zendesk ticket creation radio select.',
},
'authoring.proctoring.error.single': {
id: 'authoring.proctoring.error.single',
defaultMessage: 'There is 1 error in this form.',
description: 'Error alert for one and only one error in the form.',
},
'authoring.proctoring.error.multiple': {
id: 'authoring.proctoring.escalationemail.error.multiple',
defaultMessage: 'There are {numOfErrors} errors in this form.',
description: 'Error alert for multiple errors in the form.',
},
'authoring.proctoring.save': {
id: 'authoring.proctoring.save',
defaultMessage: 'Save',
description: 'Button to save proctoring settings.',
},
'authoring.proctoring.saving': {
id: 'authoring.proctoring.saving',
defaultMessage: 'Saving...',
description: 'Proctoring settings are in the process of saving.',
},
'authoring.proctoring.cancel': {
id: 'authoring.proctoring.cancel',
defaultMessage: 'Cancel',
description: 'Button to cancel edits to proctoring settings.',
},
'authoring.proctoring.studio.link.text': {
id: 'authoring.proctoring.studio.link.text',
defaultMessage: 'Go back to your course in Studio',
description: 'Link to go back to the course Studio page.',
},
});
export default messages;

View File

@@ -0,0 +1,20 @@
{
"name": "@openedx-plugins/course-app-proctoring",
"version": "0.1.0",
"description": "Proctoring configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"classnames": "*",
"email-validator": "*",
"react": "*",
"prop-types": "*",
"moment": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,55 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
import { getConfig } from '@edx/frontend-platform';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const ProgressSettings = ({ intl, onClose }) => {
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
const handleSettingsSave = async (values) => {
if (showProgressGraphSetting) { await saveSetting(!values.enableProgressGraph); }
};
return (
<AppSettingsModal
appId="progress"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableProgressHelp)}
enableAppLabel={intl.formatMessage(messages.enableProgressLabel)}
learnMoreText={intl.formatMessage(messages.enableProgressLink)}
onClose={onClose}
initialValues={{ enableProgressGraph: !disableProgressGraph }}
validationSchema={{ enableProgressGraph: Yup.boolean() }}
onSettingsSave={handleSettingsSave}
>
{
({ handleChange, handleBlur, values }) => (
showProgressGraphSetting && (
<FormSwitchGroup
id="enable-progress-graph"
name="enableProgressGraph"
label={intl.formatMessage(messages.enableGraphLabel)}
helpText={intl.formatMessage(messages.enableGraphHelp)}
onChange={handleChange}
onBlur={handleBlur}
checked={values.enableProgressGraph}
/>
)
)
}
</AppSettingsModal>
);
};
ProgressSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(ProgressSettings);

View File

@@ -0,0 +1,33 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.progress.heading',
defaultMessage: 'Configure progress',
},
enableProgressLabel: {
id: 'course-authoring.pages-resources.progress.enable-progress.label',
defaultMessage: 'Progress',
},
enableProgressHelp: {
id: 'course-authoring.pages-resources.progress.enable-progress.help',
defaultMessage: `As students work through graded assignments, scores
will appear under the progress tab. The progress tab contains a chart of
all graded assignments in the course, with a list of all assignments and
scores below.`,
},
enableProgressLink: {
id: 'course-authoring.pages-resources.progress.enable-progress.link',
defaultMessage: 'Learn more about progress',
},
enableGraphLabel: {
id: 'course-authoring.pages-resources.progress.enable-graph.label',
defaultMessage: 'Enable progress graph',
},
enableGraphHelp: {
id: 'course-authoring.pages-resources.progress.enable-graph.help',
defaultMessage: 'If enabled, students can view the progress graph',
},
});
export default messages;

View File

@@ -0,0 +1,18 @@
{
"name": "@openedx-plugins/course-app-progress",
"version": "0.1.0",
"description": "Progress configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,168 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form, TransitionReplace } from '@openedx/paragon';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
import CollapsableEditor from 'CourseAuthoring/generic/CollapsableEditor';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import messages from './messages';
// Maps a team type to its corresponding intl message
const TeamTypeNameMessage = {
[GroupTypes.OPEN]: {
label: messages.groupTypeOpen,
description: messages.groupTypeOpenDescription,
},
[GroupTypes.PUBLIC_MANAGED]: {
label: messages.groupTypePublicManaged,
description: messages.groupTypePublicManagedDescription,
},
[GroupTypes.PRIVATE_MANAGED]: {
label: messages.groupTypePrivateManaged,
description: messages.groupTypePrivateManagedDescription,
},
};
const GroupEditor = ({
intl, group, onDelete, onChange, onBlur, fieldNameCommonBase, errors,
}) => {
const [isDeleting, setDeleting] = useState(false);
const [isOpen, setOpen] = useState(group.id === null);
const initiateDeletion = () => setDeleting(true);
const cancelDeletion = () => setDeleting(false);
const handleToggle = (open) => setOpen(Boolean(errors.name || errors.maxTeamSize || errors.description) || open);
const formGroupClasses = 'mb-4 mx-2';
return (
<TransitionReplace>
{isDeleting
? (
<div className="d-flex flex-column card rounded mb-3 px-4 py-2 p-4" key="isDeleting">
<h4 className="mb-3">{intl.formatMessage(messages.groupDeleteHeading)}</h4>
{intl.formatMessage(messages.groupDeleteBody).split('\n').map(text => <p>{text}</p>)}
<div className="d-flex flex-row justify-content-end">
<Button variant="muted" size="sm" onClick={cancelDeletion}>
{intl.formatMessage(messages.cancel)}
</Button>
<Button variant="outline-danger" size="sm" onClick={() => onDelete(group)}>
{intl.formatMessage(messages.delete)}
</Button>
</div>
</div>
)
: (
<CollapsableEditor
// If there is no id, this is a new team, so automatically open it
key="isConfiguring"
open={isOpen}
onToggle={handleToggle}
onDelete={initiateDeletion}
deleteAlt={intl.formatMessage(messages.deleteAlt)}
expandAlt={intl.formatMessage(messages.expandAlt)}
collapseAlt={intl.formatMessage(messages.collapseAlt)}
title={
isOpen
? (
<div className="d-flex flex-column flex-shrink-1 h4 p-0 m-0">
{intl.formatMessage(messages.configureGroup)}
</div>
) : (
<div className="d-flex flex-column flex-shrink-1 small mw-100">
<div className="small text-gray-500">{intl.formatMessage(TeamTypeNameMessage[group.type ? group.type : GroupTypes.OPEN].label)} </div>
<div className="h4 text-truncate my-1 font-weight-normal">{group.name}</div>
<div className="small text-truncate text-muted text-gray-500">{group.description}</div>
</div>
)
}
>
<FormikControl
name={`${fieldNameCommonBase}.name`}
value={group.name}
floatingLabel={intl.formatMessage(messages.groupFormNameLabel)}
help={intl.formatMessage(messages.groupFormNameHelp)}
className={`${formGroupClasses} mt-2.5`}
/>
<FormikControl
name={`${fieldNameCommonBase}.description`}
value={group.description}
floatingLabel={intl.formatMessage(messages.groupFormDescriptionLabel)}
help={intl.formatMessage(messages.groupFormDescriptionHelp)}
as="textarea"
rows={4}
style={{ minHeight: '2.5rem' }}
className={formGroupClasses}
/>
<Form.Group className={formGroupClasses}>
<Form.Label className="h4 my-3">
{intl.formatMessage(messages.groupFormTypeLabel)}
</Form.Label>
<Form.RadioSet
name={`${fieldNameCommonBase}.type`}
value={group.type}
onChange={onChange}
onBlur={onBlur}
>
{Object.values(GroupTypes).map(groupType => (
<Form.Radio
key={groupType}
value={groupType}
description={intl.formatMessage(TeamTypeNameMessage[groupType].description)}
className="my-2"
// TODO: This should probably be fixed on the paragon side
style={{ minWidth: '1.25rem', minHeight: '1.25rem' }}
>
{intl.formatMessage(TeamTypeNameMessage[groupType].label)}
</Form.Radio>
))}
</Form.RadioSet>
</Form.Group>
<FormikControl
type="number"
name={`${fieldNameCommonBase}.maxTeamSize`}
floatingLabel={intl.formatMessage(messages.groupFormMaxSizeLabel)}
value={group.maxTeamSize}
help={intl.formatMessage(messages.groupFormMaxSizeHelp)}
label={<Form.Label className="h4 pb-4">{intl.formatMessage(messages.teamSize)}</Form.Label>}
className="mx-2"
placeholder={TeamSizes.DEFAULT}
/>
</CollapsableEditor>
)}
</TransitionReplace>
);
};
export const groupShape = PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
maxTeamSize: PropTypes.number.isRequired,
});
GroupEditor.propTypes = {
intl: intlShape.isRequired,
fieldNameCommonBase: PropTypes.string.isRequired,
errors: PropTypes.shape({
name: PropTypes.string,
description: PropTypes.string,
maxTeamSize: PropTypes.string,
}),
group: groupShape.isRequired,
onDelete: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired,
};
GroupEditor.defaultProps = {
errors: {
name: null,
description: null,
maxTeamSize: null,
},
};
export default injectIntl(GroupEditor);

View File

@@ -0,0 +1,171 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Form } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import { FieldArray } from 'formik';
import PropTypes from 'prop-types';
import React from 'react';
import { v4 as uuid } from 'uuid';
import * as Yup from 'yup';
import { GroupTypes, TeamSizes } from 'CourseAuthoring/data/constants';
import FormikControl from 'CourseAuthoring/generic/FormikControl';
import { setupYupExtensions, useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import GroupEditor from './GroupEditor';
import messages from './messages';
setupYupExtensions();
const TeamSettings = ({
intl,
onClose,
}) => {
const [teamsConfiguration, saveSettings] = useAppSetting('teamsConfiguration');
const blankNewGroup = {
name: '',
description: '',
type: GroupTypes.OPEN,
maxTeamSize: null,
id: null,
key: uuid(),
};
const handleSettingsSave = async (values) => {
// For newly-added teams, fill in an id.
const groups = values.groups?.map(group => ({
id: group.id || uuid(),
name: group.name,
type: group.type,
description: group.description,
max_team_size: group.maxTeamSize,
}));
return saveSettings({
team_sets: groups,
max_team_size: values.maxTeamSize,
enabled: values.enabled,
});
};
const enableAppError = {
title: intl.formatMessage(messages.noGroupsErrorTitle),
message: intl.formatMessage(messages.noGroupsErrorMessage),
};
return (
<AppSettingsModal
appId="teams"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableTeamsHelp)}
enableAppLabel={intl.formatMessage(messages.enableTeamsLabel)}
learnMoreText={intl.formatMessage(messages.enableTeamsLink)}
onClose={onClose}
bodyClassName="bg-light-200"
// Topic is supported for backwards compatibility, the new field is team_sets:
// ref: https://github.com/openedx/edx-platform/blob/15461d3b6e6c0a724a7b8ed09241d970f201e5e7/openedx/core/lib/teams_config.py#L104-L108
initialValues={{
maxTeamSize: teamsConfiguration?.maxTeamSize,
groups: teamsConfiguration?.teamSets || teamsConfiguration?.topics,
}}
validationSchema={{
enabled: Yup.boolean()
.test(
'has-groups',
enableAppError,
(value, context) => (!value || context.parent.groups.length > 0),
),
maxTeamSize: Yup.number()
.required(intl.formatMessage(messages.maxTeamSizeEmpty))
.min(TeamSizes.MIN, intl.formatMessage(messages.maxTeamSizeInvalid))
.max(
TeamSizes.MAX,
intl.formatMessage(messages.maxTeamSizeTooHigh, {
max: TeamSizes.MAX,
}),
),
groups: Yup.array().of(
Yup.object({
id: Yup.string().nullable(),
name: Yup.string()
.required(intl.formatMessage(messages.groupFormNameEmpty))
.trim(),
type: Yup.string().oneOf(Object.values(GroupTypes)),
description: Yup.string()
.required(intl.formatMessage(messages.groupFormDescriptionError))
.trim(),
maxTeamSize: Yup.number()
.nullable()
.min(TeamSizes.MIN, intl.formatMessage(messages.maxTeamSizeInvalid))
.max(
TeamSizes.MAX,
intl.formatMessage(messages.maxTeamSizeTooHigh, {
max: TeamSizes.MAX,
}),
)
.default(null),
}),
)
.when('enabled', {
is: true,
then: Yup.array().min(1),
})
.default([])
.uniqueProperty('name', intl.formatMessage(messages.groupFormNameExists)),
}}
onSettingsSave={handleSettingsSave}
configureBeforeEnable
>
{
({
handleChange, handleBlur, values, errors,
}) => (
<>
<h4 className="my-3 pb-2">{intl.formatMessage(messages.teamSize)}</h4>
<FormikControl
name="maxTeamSize"
value={values.maxTeamSize}
floatingLabel={intl.formatMessage(messages.maxTeamSize)}
help={intl.formatMessage(messages.maxTeamSizeHelp)}
className="pb-1"
type="number"
/>
<div className="bg-light-200 d-flex flex-column mx-n4 px-4 py-4 border border-top mb-n3.5">
<h4>{intl.formatMessage(messages.groups)}</h4>
<Form.Text className="mb-3">{intl.formatMessage(messages.groupsHelp)}</Form.Text>
<FieldArray name="groups">
{({ push, remove }) => (
<>
{values.groups?.map((group, index) => (
<GroupEditor
key={group.id || group.key}
group={group}
errors={errors.groups?.[index]}
fieldNameCommonBase={`groups.${index}`}
onDelete={() => remove(index)}
onChange={handleChange}
onBlur={handleBlur}
/>
))}
<Button
variant="plain"
className="p-0 align-self-start mt-3"
iconBefore={Add}
onClick={() => push(blankNewGroup)}
>
{intl.formatMessage(messages.addGroup)}
</Button>
</>
)}
</FieldArray>
</div>
</>
)
}
</AppSettingsModal>
);
};
TeamSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(TeamSettings);

View File

@@ -0,0 +1,171 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'authoring.pagesAndResources.teams.heading',
defaultMessage: 'Configure teams',
},
enableTeamsLabel: {
id: 'authoring.pagesAndResources.teams.enableTeams.label',
defaultMessage: 'Teams',
},
enableTeamsHelp: {
id: 'authoring.pagesAndResources.teams.enableTeams.help',
defaultMessage: 'Allow learners to work together on specific projects or activities.',
},
enableTeamsLink: {
id: 'authoring.pagesAndResources.teams.enableTeams.link',
defaultMessage: 'Learn more about teams',
},
teamSize: {
id: 'authoring.pagesAndResources.teams.teamSize.heading',
defaultMessage: 'Team size',
},
maxTeamSize: {
id: 'authoring.pagesAndResources.teams.teamSize.maxTeamSize',
defaultMessage: 'Max team size',
},
maxTeamSizeHelp: {
id: 'authoring.pagesAndResources.teams.teamSize.maxTeamSizeHelp',
defaultMessage: 'The maximum number of learners that can join a team',
},
maxTeamSizeEmpty: {
id: 'authoring.pagesAndResources.teams.teamSize.maxTeamSizeEmpty',
defaultMessage: 'Enter max team size',
},
maxTeamSizeInvalid: {
id: 'authoring.pagesAndResources.teams.teamSize.maxTeamSizeInvalid',
defaultMessage: 'Max team size must be a positive number larger than zero.',
},
maxTeamSizeTooHigh: {
id: 'authoring.pagesAndResources.teams.teamSize.maxTeamSizeTooHigh',
defaultMessage: 'Max team size cannot be greater than {max}',
},
groups: {
id: 'authoring.pagesAndResources.teams.groups.heading',
defaultMessage: 'Groups',
},
groupsHelp: {
id: 'authoring.pagesAndResources.teams.groups.help',
defaultMessage: 'Groups are spaces where learners can create or join teams.',
},
configureGroup: {
id: 'authoring.pagesAndResources.teams.configureGroup.heading',
defaultMessage: 'Configure group',
},
groupFormNameLabel: {
id: 'authoring.pagesAndResources.teams.group.name.label',
defaultMessage: 'Name',
},
groupFormNameHelp: {
id: 'authoring.pagesAndResources.teams.group.name.help',
defaultMessage: 'Choose a unique name for this group',
},
groupFormNameEmpty: {
id: 'authoring.pagesAndResources.teams.group.name.error.empty',
defaultMessage: 'Enter a unique name for this group',
},
groupFormNameExists: {
id: 'authoring.pagesAndResources.teams.group.name.error.exists',
defaultMessage: 'It looks like this name is already in use',
},
groupFormDescriptionLabel: {
id: 'authoring.pagesAndResources.teams.group.description.label',
defaultMessage: 'Description',
},
groupFormDescriptionHelp: {
id: 'authoring.pagesAndResources.teams.group.description.help',
defaultMessage: 'Enter details about this group',
},
groupFormDescriptionError: {
id: 'authoring.pagesAndResources.teams.group.description.error',
defaultMessage: 'Enter a description for this group',
},
groupFormTypeLabel: {
id: 'authoring.pagesAndResources.teams.group.type.label',
defaultMessage: 'Type',
},
groupFormTypeHelp: {
id: 'authoring.pagesAndResources.teams.group.type.help',
defaultMessage: 'Control who can see, create and join teams',
},
groupTypeOpen: {
id: 'authoring.pagesAndResources.teams.group.types.open',
defaultMessage: 'Open',
},
groupTypeOpenDescription: {
id: 'authoring.pagesAndResources.teams.group.types.open.description',
defaultMessage: 'Learners can create, join, leave, and see other teams',
},
groupTypePublicManaged: {
id: 'authoring.pagesAndResources.teams.group.types.public_managed',
defaultMessage: 'Public managed',
},
groupTypePublicManagedDescription: {
id: 'authoring.pagesAndResources.teams.group.types.public_managed.description',
defaultMessage: 'Only course staff can control teams and memberships. Learners can see other teams.',
},
groupTypePrivateManaged: {
id: 'authoring.pagesAndResources.teams.group.types.private_managed',
defaultMessage: 'Private managed',
},
groupTypePrivateManagedDescription: {
id: 'authoring.pagesAndResources.teams.group.types.private_managed.description',
defaultMessage: 'Only course staff can control teams, memberships, and see other teams',
},
groupFormMaxSizeLabel: {
id: 'authoring.pagesAndResources.teams.group.maxSize.label',
defaultMessage: 'Max team size (optional)',
},
groupFormMaxSizeHelp: {
id: 'authoring.pagesAndResources.teams.group.maxSize.help',
defaultMessage: 'Override the global max team size',
},
addGroup: {
id: 'authoring.pagesAndResources.teams.addGroup.button',
defaultMessage: 'Add group',
},
deleteAlt: {
id: 'authoring.pagesAndResources.teams.group.delete',
defaultMessage: 'Delete',
},
expandAlt: {
id: 'authoring.pagesAndResources.teams.group.expand',
defaultMessage: 'Expand group editor',
},
collapseAlt: {
id: 'authoring.pagesAndResources.teams.group.collapse',
defaultMessage: 'Close group editor',
},
delete: {
id: 'authoring.pagesAndResources.teams.deleteGroup.initiateDelete',
defaultMessage: 'Delete',
},
cancel: {
id: 'authoring.pagesAndResources.teams.deleteGroup.cancel-delete.button',
defaultMessage: 'Cancel',
},
groupDeleteHeading: {
id: 'authoring.pagesAndResources.teams.deleteGroup.heading',
defaultMessage: 'Delete this group?',
},
groupDeleteBody: {
id: 'authoring.pagesAndResources.teams.deleteGroup.body',
defaultMessage: `edX recommends that you do not delete groups once your course is running.
Your group will no longer be visible in the LMS and learners will not be able to leave teams associated with it.
Please delete learners from teams before deleting the associated group.`,
description: 'Message displayed to admins when deleting a group. Make sure to include line breaks so that the final text is rendered properly.',
},
noGroupsErrorTitle: {
id: 'authoring.pagesAndResources.teams.enableGroups.error.noGroupsFound.title',
defaultMessage: 'No groups found',
description: 'Title of error message displayed when a user tries to enable teams but no group is defined.',
},
noGroupsErrorMessage: {
id: 'authoring.pagesAndResources.teams.enableGroups.error.noGroupsFound.message',
defaultMessage: 'Add one or more groups to enable teams.',
description: 'Body of error message displayed when a user tries to enable teams but no group is defined.',
},
});
export default messages;

View File

@@ -0,0 +1,20 @@
{
"name": "@openedx-plugins/course-app-teams",
"version": "0.1.0",
"description": "Teams configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"react": "*",
"uuid": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,49 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import * as Yup from 'yup';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
import messages from './messages';
const WikiSettings = ({ intl, onClose }) => {
const [enablePublicWiki, saveSetting] = useAppSetting('allowPublicWikiAccess');
const handleSettingsSave = (values) => saveSetting(values.enablePublicWiki);
return (
<AppSettingsModal
appId="wiki"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableWikiHelp)}
enableAppLabel={intl.formatMessage(messages.enableWikiLabel)}
learnMoreText={intl.formatMessage(messages.enableWikiLink)}
onClose={onClose}
initialValues={{ enablePublicWiki }}
validationSchema={{ enablePublicWiki: Yup.boolean() }}
onSettingsSave={handleSettingsSave}
>
{
({ values, handleChange, handleBlur }) => (
<FormSwitchGroup
id="enable-public-wiki"
name="enablePublicWiki"
label={intl.formatMessage(messages.enablePublicWikiLabel)}
helpText={intl.formatMessage(messages.enablePublicWikiHelp)}
onChange={handleChange}
onBlue={handleBlur}
checked={values.enablePublicWiki}
/>
)
}
</AppSettingsModal>
);
};
WikiSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(WikiSettings);

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.wiki.heading',
defaultMessage: 'Configure wiki',
},
enableWikiLabel: {
id: 'course-authoring.pages-resources.wiki.enable-wiki.label',
defaultMessage: 'Wiki',
},
enableWikiHelp: {
id: 'course-authoring.pages-resources.wiki.enable-wiki.help',
defaultMessage: `The course wiki can be set up based on the needs of your
course. Common uses might include sharing answers to course FAQs, sharing
editable course information, or providing access to learner-created
resources.`,
},
enableWikiLink: {
id: 'course-authoring.pages-resources.wiki.enable-wiki.link',
defaultMessage: 'Learn more about the wiki',
},
enablePublicWikiLabel: {
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.label',
defaultMessage: 'Enable public wiki access',
},
enablePublicWikiHelp: {
id: 'course-authoring.pages-resources.wiki.enable-public-wiki.help',
defaultMessage: `If enabled, edX users can view the course wiki even when
they're not enrolled in the course.`,
},
});
export default messages;

View File

@@ -0,0 +1,18 @@
{
"name": "@openedx-plugins/course-app-wiki",
"version": "0.1.0",
"description": "Wiki configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"prop-types": "*",
"react": "*",
"yup": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,4 @@
Xpert Unit Summaries Configuration Plugin
=========================================
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.

View File

@@ -0,0 +1,45 @@
import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { useNavigate } from 'react-router-dom';
import SettingsModal from './settings-modal/SettingsModal';
import messages from './messages';
import { fetchXpertSettings } from './data/thunks';
const XpertUnitSummarySettings = ({ intl }) => {
const { path: pagesAndResourcesPath, courseId } = useContext(PagesAndResourcesContext);
const dispatch = useDispatch();
const navigate = useNavigate();
useEffect(() => {
dispatch(fetchXpertSettings(courseId));
}, [courseId]);
const handleClose = useCallback(() => {
navigate(pagesAndResourcesPath);
}, [pagesAndResourcesPath]);
return (
<SettingsModal
appId="xpert-unit-summary"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableXpertUnitSummaryHelp)}
helpPrivacyText={intl.formatMessage(messages.enableXpertUnitSummaryHelpPrivacyLink)}
enableAppLabel={intl.formatMessage(messages.enableXpertUnitSummaryLabel)}
learnMoreText={intl.formatMessage(messages.enableXpertUnitSummaryLink)}
allUnitsEnabledText={intl.formatMessage(messages.allUnitsEnabledByDefault)}
noUnitsEnabledText={intl.formatMessage(messages.noUnitsEnabledByDefault)}
onClose={handleClose}
/>
);
};
XpertUnitSummarySettings.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(XpertUnitSummarySettings);

View File

@@ -0,0 +1,281 @@
import ReactDOM from 'react-dom';
import React from 'react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import {
getConfig, initializeMockApp, setConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
import {
queryByTestId, render, waitFor, getByText, fireEvent,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import initializeStore from 'CourseAuthoring/store';
import { executeThunk } from 'CourseAuthoring/utils';
import XpertUnitSummarySettings from './Settings';
import * as API from './data/api';
import * as Thunks from './data/thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock;
let store;
let container;
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = jest.fn(node => node);
function renderComponent() {
const wrapper = render(
<AppProvider store={store} wrapWithRouter={false}>
<PagesAndResourcesProvider courseId={courseId}>
<MemoryRouter initialEntries={['/xpert-unit-summary/settings']}>
<Routes>
<Route
path="/xpert-unit-summary/settings"
element={<PageWrap><XpertUnitSummarySettings courseId={courseId} /></PageWrap>}
/>
<Route
path="/"
element={<PageWrap><div /></PageWrap>}
/>
</Routes>
</MemoryRouter>
</PagesAndResourcesProvider>
</AppProvider>,
);
container = wrapper.container;
}
function generateCourseLevelAPIResponse({
success, enabled,
}) {
return {
response: {
success, enabled,
},
};
}
describe('XpertUnitSummarySettings', () => {
beforeEach(() => {
setConfig({
...getConfig(),
BASE_URL: 'http://test.edx.org',
LMS_BASE_URL: 'http://lmstest.edx.org',
CMS_BASE_URL: 'http://cmstest.edx.org',
LOGIN_URL: 'http://support.edx.org/login',
LOGOUT_URL: 'http://support.edx.org/logout',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://support.edx.org/access_token',
ACCESS_TOKEN_COOKIE_NAME: 'cookie',
CSRF_TOKEN_API_PATH: '/',
SUPPORT_URL: 'http://support.edx.org',
});
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
models: {
courseDetails: {
[courseId]: {
start: Date(),
},
},
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('with successful network connections', () => {
beforeEach(() => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: true,
}));
renderComponent();
});
test('Shows switch on if enabled from backend', async () => {
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
});
test('Shows switch on if disabled from backend', async () => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: false,
}));
renderComponent();
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).toBeTruthy();
});
test('Shows enable radio selected if enabled from backend', async () => {
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(queryByTestId(container, 'enable-radio').checked).toBeTruthy();
});
test('Shows disable radio selected if enabled from backend', async () => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: false,
}));
renderComponent();
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy();
});
});
describe('first time course configuration', () => {
beforeEach(() => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(400, generateCourseLevelAPIResponse({
success: false,
enabled: undefined,
}));
renderComponent();
});
test('Does not show as enabled if configuration does not exist', async () => {
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(container.querySelector('#enable-xpert-unit-summary-toggle').checked).not.toBeTruthy();
expect(queryByTestId(container, 'enable-badge')).not.toBeTruthy();
});
});
describe('saving configuration changes', () => {
beforeEach(() => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: false,
}));
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: true,
}));
renderComponent();
});
test('Saving configuration changes', async () => {
jest.spyOn(API, 'postXpertSettings');
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy();
fireEvent.click(queryByTestId(container, 'enable-radio'));
fireEvent.click(getByText(container, 'Save'));
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(API.postXpertSettings).toBeCalled();
});
});
describe('testing configurable gating', () => {
beforeEach(async () => {
axiosMock.onGet(API.getXpertConfigurationStatusUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: true,
}));
jest.spyOn(API, 'getXpertPluginConfigurable');
await executeThunk(Thunks.fetchXpertPluginConfigurable(courseId), store.dispatch);
renderComponent();
});
test('getting Xpert Plugin configurable status', () => {
expect(API.getXpertPluginConfigurable).toBeCalled();
});
});
describe('removing course configuration', () => {
beforeEach(() => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: true,
}));
axiosMock.onDelete(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: undefined,
}));
renderComponent();
});
test('Deleting course configuration', async () => {
jest.spyOn(API, 'deleteXpertSettings');
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
fireEvent.click(container.querySelector('#enable-xpert-unit-summary-toggle'));
fireEvent.click(getByText(container, 'Save'));
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
expect(API.deleteXpertSettings).toBeCalled();
});
});
describe('resetting course units', () => {
test('reset all units to be enabled', async () => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: true,
}));
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: true,
}));
renderComponent();
jest.spyOn(API, 'postXpertSettings');
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
fireEvent.click(queryByTestId(container, 'reset-units'));
expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: true });
});
test('reset all units to be disabled', async () => {
axiosMock.onGet(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: false,
}));
axiosMock.onPost(API.getXpertSettingsUrl(courseId))
.reply(200, generateCourseLevelAPIResponse({
success: true,
enabled: false,
}));
renderComponent();
jest.spyOn(API, 'postXpertSettings');
await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy());
fireEvent.click(queryByTestId(container, 'reset-units'));
expect(API.postXpertSettings).toBeCalledWith(courseId, { reset: true, enabled: false });
});
});
});

View File

@@ -0,0 +1,13 @@
export default {
id: 'xpert-unit-summary',
enabled: false,
name: 'Xpert unit summaries',
description: 'Use generative AI to summarize course content and reinforce learning.',
allowedOperations: {
enable: true,
configure: true,
},
documentationLinks: {
learnMoreConfiguration: 'https://openai.com/',
},
};

View File

@@ -0,0 +1,41 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export function getXpertSettingsUrl(courseId) {
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}`;
}
export function getXpertConfigurationStatusUrl(courseId) {
return `${getConfig().STUDIO_BASE_URL}/ai_aside/v1/${courseId}/configurable`;
}
export async function getXpertSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getXpertSettingsUrl(courseId));
return data;
}
export async function postXpertSettings(courseId, state) {
const { data } = await getAuthenticatedHttpClient()
.post(getXpertSettingsUrl(courseId), {
enabled: state.enabled,
reset: state.reset,
});
return data;
}
export async function getXpertPluginConfigurable(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getXpertConfigurationStatusUrl(courseId));
return data;
}
export async function deleteXpertSettings(courseId) {
const { data } = await getAuthenticatedHttpClient()
.delete(getXpertSettingsUrl(courseId));
return data;
}

View File

@@ -0,0 +1,113 @@
import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import { addModel, updateModel } from 'CourseAuthoring/generic/model-store';
import {
getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings,
} from './api';
export function updateXpertSettings(courseId, state) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const { response } = await postXpertSettings(courseId, state);
const { success } = response;
if (success) {
dispatch(updateModel({ modelType: 'XpertSettings', model: { id: 'xpert-unit-summary', enabled: state.enabled } }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
}
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function fetchXpertPluginConfigurable(courseId) {
return async (dispatch) => {
let enabled;
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
try {
const { response } = await getXpertPluginConfigurable(courseId);
enabled = response?.enabled;
} catch (e) {
enabled = undefined;
}
dispatch(addModel({
modelType: 'XpertSettings.enabled',
model: {
id: 'xpert-unit-summary',
enabled,
},
}));
};
}
export function fetchXpertSettings(courseId) {
return async (dispatch) => {
let enabled;
dispatch(updateLoadingStatus({ status: RequestStatus.PENDING }));
try {
const { response } = await getXpertSettings(courseId);
enabled = response?.enabled;
} catch (e) {
enabled = undefined;
}
dispatch(addModel({
modelType: 'XpertSettings',
model: {
id: 'xpert-unit-summary',
enabled,
},
}));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
};
}
export function removeXpertSettings(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
try {
const { response } = await deleteXpertSettings(courseId);
const { success } = response;
if (success) {
const model = { id: 'xpert-unit-summary', enabled: undefined };
dispatch(updateModel({ modelType: 'XpertSettings', model }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
}
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function resetXpertSettings(courseId, state) {
return async (dispatch) => {
dispatch(updateResetStatus({ status: RequestStatus.PENDING }));
try {
const { response } = await postXpertSettings(courseId, state);
const { success } = response;
if (success) {
dispatch(updateResetStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
}
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
return false;
} catch (error) {
dispatch(updateResetStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.xpert-unit-summary.heading',
defaultMessage: 'Configure Xpert unit summaries',
},
enableXpertUnitSummaryLabel: {
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.label',
defaultMessage: 'Xpert unit summaries',
},
enableXpertUnitSummaryHelp: {
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.help',
defaultMessage: 'Reinforce learning concepts by sharing text-based course content with OpenAI (via API) to display unit summaries on-demand for learners. Learners can leave feedback about the quality of the AI-generated summaries for use by edX to improve the performance of the tool.',
},
enableXpertUnitSummaryHelpPrivacyLink: {
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.help.privacylink',
defaultMessage: 'Learn more about OpenAI API data privacy.',
},
enableXpertUnitSummaryLink: {
id: 'course-authoring.pages-resources.xpert-unit-summary.enable-xpert-unit-summary.link',
defaultMessage: 'Learn more about how OpenAI handles data',
},
allUnitsEnabledByDefault: {
id: 'course-authoring.pages-resources.xpert-unit-summary.all-units-enabled-by-default',
defaultMessage: 'All units enabled by default',
},
noUnitsEnabledByDefault: {
id: 'course-authoring.pages-resources.xpert-unit-summary.no-units-enabled-by-default',
defaultMessage: 'No units enabled by default',
},
});
export default messages;

View File

@@ -0,0 +1,21 @@
{
"name": "@openedx-plugins/course-app-xpert_unit_summary",
"version": "0.1.0",
"description": "Xpert Unit Summaries configuration for courses using it",
"peerDependencies": {
"@edx/frontend-app-course-authoring": "*",
"@edx/frontend-platform": "*",
"@openedx/paragon": "*",
"formik": "*",
"prop-types": "*",
"yup": "*",
"react": "*",
"react-redux": "*",
"react-router-dom": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-course-authoring": {
"optional": true
}
}
}

View File

@@ -0,0 +1,21 @@
const ResetIcon = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
role="img"
focusable="false"
aria-hidden="true"
transform="scale(-1,1)"
{...props}
>
<path
d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
fill="currentColor"
/>
</svg>
);
export default ResetIcon;

View File

@@ -0,0 +1,453 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
Badge,
Form,
Icon,
ModalDialog,
OverlayTrigger,
StatefulButton,
Tooltip,
TransitionReplace,
Hyperlink,
} from '@openedx/paragon';
import {
Info, CheckCircleOutline, SpinnerSimple,
} from '@openedx/paragon/icons';
import { Formik } from 'formik';
import PropTypes from 'prop-types';
import React, {
useContext, useEffect, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import { RequestStatus } from 'CourseAuthoring/data/constants';
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import Loading from 'CourseAuthoring/generic/Loading';
import { useModel } from 'CourseAuthoring/generic/model-store';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import { useIsMobile } from 'CourseAuthoring/utils';
import { getLoadingStatus, getSavingStatus, getResetStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
import { updateSavingStatus, updateResetStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
import AppConfigFormDivider from 'CourseAuthoring/pages-and-resources/discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks';
import messages from './messages';
import appInfo from '../appInfo';
import ResetIcon from './ResetIcon';
import './SettingsModal.scss';
const AppSettingsForm = ({
formikProps, children, showForm,
}) => children && (
<TransitionReplace>
{showForm ? (
<React.Fragment key="app-enabled">
{children(formikProps)}
</React.Fragment>
) : (
<React.Fragment key="app-disabled" />
)}
</TransitionReplace>
);
AppSettingsForm.propTypes = {
// Ignore the warning here since we're just passing along the props as-is and the child component should validate
// eslint-disable-next-line react/forbid-prop-types
formikProps: PropTypes.object.isRequired,
showForm: PropTypes.bool.isRequired,
children: PropTypes.func,
};
AppSettingsForm.defaultProps = {
children: null,
};
const SettingsModalBase = ({
intl, title, onClose, variant, isMobile, children, footer,
}) => (
<ModalDialog
title={title}
isOpen
onClose={onClose}
size="lg"
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{children}
</ModalDialog.Body>
<ModalDialog.Footer className="p-4">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancel)}
</ModalDialog.CloseButton>
{footer}
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
SettingsModalBase.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
variant: PropTypes.oneOf(['default', 'dark']).isRequired,
isMobile: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
footer: PropTypes.node,
};
SettingsModalBase.defaultProps = {
footer: null,
};
const ResetUnitsButton = ({
intl,
courseId,
checked,
visible,
}) => {
const resetStatusRequestStatus = useSelector(getResetStatus);
const dispatch = useDispatch();
useEffect(() => {
if (resetStatusRequestStatus === RequestStatus.SUCCESSFUL) {
setTimeout(() => {
dispatch(updateResetStatus({ status: '' }));
}, 2000);
}
}, [resetStatusRequestStatus]);
const handleResetUnits = () => {
dispatch(resetXpertSettings(courseId, { enabled: checked === 'true', reset: true }));
};
const getResetButtonState = () => {
switch (resetStatusRequestStatus) {
case RequestStatus.PENDING:
return 'pending';
case RequestStatus.SUCCESSFUL:
return 'finish';
default:
return 'default';
}
};
if (!visible) { return null; }
const messageKey = checked === 'true' ? 'resetAllUnitsTooltipChecked' : 'resetAllUnitsTooltipUnchecked';
return (
<OverlayTrigger
placement="right"
overlay={(
<Tooltip
id={`tooltip-reset-${checked}`}
className="reset-tooltip"
>
{intl.formatMessage(messages[messageKey])}
</Tooltip>
)}
>
<StatefulButton
className="reset-units-button"
labels={{
default: intl.formatMessage(messages.resetAllUnits),
pending: '',
finish: intl.formatMessage(messages.reset),
}}
icons={{
default: <Icon src={ResetIcon} />,
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
finish: <Icon src={CheckCircleOutline} />,
}}
state={getResetButtonState()}
onClick={handleResetUnits}
disabledStates={['pending', 'finish']}
variant="outline"
data-testid="reset-units"
/>
</OverlayTrigger>
);
};
ResetUnitsButton.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
checked: PropTypes.oneOf(['true', 'false']).isRequired,
visible: PropTypes.bool,
};
ResetUnitsButton.defaultProps = {
visible: false,
};
const SettingsModal = ({
intl,
appId,
title,
children,
configureBeforeEnable,
initialValues,
validationSchema,
onClose,
onSettingsSave,
enableAppLabel,
enableAppHelp,
learnMoreText,
helpPrivacyText,
enableReinitialize,
allUnitsEnabledText,
noUnitsEnabledText,
}) => {
const { courseId } = useContext(PagesAndResourcesContext);
const loadingStatus = useSelector(getLoadingStatus);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
const alertRef = useRef(null);
const [saveError, setSaveError] = useState(false);
const dispatch = useDispatch();
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
const isMobile = useIsMobile();
const modalVariant = isMobile ? 'dark' : 'default';
const xpertSettings = useModel('XpertSettings', appId);
useEffect(() => {
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateSavingStatus({ status: '' }));
onClose();
}
}, [updateSettingsRequestStatus]);
const handleFormSubmit = async ({ enabled, checked, ...rest }) => {
let success;
const values = { ...rest, enabled: enabled ? checked === 'true' : undefined };
if (enabled) {
success = await dispatch(updateXpertSettings(courseId, values));
} else {
success = await dispatch(removeXpertSettings(courseId));
}
if (onSettingsSave) {
success = success && await onSettingsSave(values);
}
setSaveError(!success);
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line no-unused-expressions
};
const handleFormikSubmit = ({ handleSubmit, errors }) => async (event) => {
// If submitting the form with errors, show the alert and scroll to it.
await handleSubmit(event);
if (Object.keys(errors).length > 0) {
setSaveError(true);
alertRef?.current.scrollIntoView?.(); // eslint-disable-line no-unused-expressions
}
};
const learnMoreLink = appInfo.documentationLinks?.learnMoreConfiguration && (
<div className="py-1">
<Hyperlink
className="text-primary-500"
destination={appInfo.documentationLinks.learnMoreConfiguration}
target="_blank"
rel="noreferrer noopener"
>
{learnMoreText}
</Hyperlink>
</div>
);
const helpPrivacyLink = (
<div className="py-1">
<Hyperlink
className="text-primary-500"
destination="https://openai.com/api-data-privacy"
target="_blank"
rel="noreferrer noopener"
>
{helpPrivacyText}
</Hyperlink>
</div>
);
if (loadingStatus === RequestStatus.SUCCESSFUL) {
return (
<Formik
initialValues={{
enabled: xpertSettings?.enabled !== undefined,
checked: xpertSettings?.enabled?.toString() || 'true',
...initialValues,
}}
validationSchema={
Yup.object()
.shape({
enabled: Yup.boolean(),
checked: Yup.string().oneOf(['true', 'false']),
...validationSchema,
})
}
onSubmit={handleFormSubmit}
enableReinitialize={enableReinitialize}
>
{(formikProps) => (
<Form onSubmit={handleFormikSubmit(formikProps)}>
<SettingsModalBase
title={title}
isOpen
onClose={onClose}
variant={modalVariant}
isMobile={isMobile}
isFullscreenOnMobile
intl={intl}
footer={(
<StatefulButton
labels={{
default: intl.formatMessage(messages.save),
pending: intl.formatMessage(messages.saving),
complete: intl.formatMessage(messages.saved),
}}
state={submitButtonState}
onClick={handleFormikSubmit(formikProps)}
disabled={!formikProps.dirty}
/>
)}
>
{saveError && (
<Alert variant="danger" icon={Info} ref={alertRef}>
<Alert.Heading>
{formikProps.errors.enabled?.title || intl.formatMessage(messages.errorSavingTitle)}
</Alert.Heading>
{formikProps.errors.enabled?.message || intl.formatMessage(messages.errorSavingMessage)}
</Alert>
)}
<FormSwitchGroup
id={`enable-${appId}-toggle`}
name="enabled"
onChange={formikProps.handleChange}
onBlur={formikProps.handleBlur}
checked={formikProps.values.enabled}
label={(
<div className="d-flex align-items-center">
{enableAppLabel}
{formikProps.values.enabled && (
<Badge className="ml-2" variant="success" data-testid="enable-badge">
{intl.formatMessage(messages.enabled)}
</Badge>
)}
</div>
)}
helpText={(
<div>
<p>{enableAppHelp}</p>
{helpPrivacyLink}
{learnMoreLink}
</div>
)}
/>
{(formikProps.values.enabled || configureBeforeEnable) && (
<Form.RadioSet
name="checked"
onChange={formikProps.handleChange}
onBlur={formikProps.handleBlur}
value={formikProps.values.checked}
>
<Form.Radio
className="summary-radio m-2 px-3"
data-testid="enable-radio"
value="true"
>
{allUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'true'}
/>
</Form.Radio>
<Form.Radio
className="summary-radio m-2 px-3"
data-testid="disable-radio"
value="false"
>
{noUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'false'}
/>
</Form.Radio>
</Form.RadioSet>
)}
{(formikProps.values.enabled || configureBeforeEnable) && children
&& <AppConfigFormDivider marginAdj={{ default: 0, sm: 0 }} />}
<AppSettingsForm formikProps={formikProps} showForm={formikProps.values.enabled || configureBeforeEnable}>
{children}
</AppSettingsForm>
</SettingsModalBase>
</Form>
)}
</Formik>
);
}
return (
<SettingsModalBase
intl={intl}
title={title}
isOpen
onClose={onClose}
size="sm"
variant={modalVariant}
isMobile={isMobile}
isFullscreenOnMobile
>
{loadingStatus === RequestStatus.IN_PROGRESS && <Loading />}
{loadingStatus === RequestStatus.FAILED && <ConnectionErrorAlert />}
{loadingStatus === RequestStatus.DENIED && <PermissionDeniedAlert />}
</SettingsModalBase>
);
};
SettingsModal.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
appId: PropTypes.string.isRequired,
children: PropTypes.func,
onSettingsSave: PropTypes.func,
initialValues: PropTypes.shape({}),
validationSchema: PropTypes.shape({}),
onClose: PropTypes.func.isRequired,
enableAppLabel: PropTypes.string.isRequired,
enableAppHelp: PropTypes.string.isRequired,
learnMoreText: PropTypes.string.isRequired,
helpPrivacyText: PropTypes.string.isRequired,
allUnitsEnabledText: PropTypes.string.isRequired,
noUnitsEnabledText: PropTypes.string.isRequired,
configureBeforeEnable: PropTypes.bool,
enableReinitialize: PropTypes.bool,
};
SettingsModal.defaultProps = {
children: null,
onSettingsSave: null,
initialValues: {},
validationSchema: {},
configureBeforeEnable: false,
enableReinitialize: false,
};
export default injectIntl(SettingsModal);

View File

@@ -0,0 +1,45 @@
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/utilities-only";
.summary-radio {
display: flex;
align-items: center;
width: 100%;
border-width: $border-width;
border-color: $border-color;
border-radius: $border-radius;
border-style: solid;
&:has(input:checked) {
border-width: 3px;
border-color: theme-color("primary");
}
> div {
flex: 1;
> label {
min-height: 80px;
flex-wrap: wrap;
justify-content: space-between;
}
}
}
.reset-units-button {
color: $link-color;
border-width: $border-width;
border-color: $border-color;
border-radius: $border-radius;
border-style: solid;
}
.reset-tooltip {
.arrow::before {
border-right-color: #00262B;
}
.tooltip-inner {
background-color: #00262B;
}
}

View File

@@ -0,0 +1,58 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
cancel: {
id: 'course-authoring.pages-resources.app-settings-modal.button.cancel',
defaultMessage: 'Cancel',
},
save: {
id: 'course-authoring.pages-resources.app-settings-modal.button.save',
defaultMessage: 'Save',
},
saving: {
id: 'course-authoring.pages-resources.app-settings-modal.button.saving',
defaultMessage: 'Saving',
},
saved: {
id: 'course-authoring.pages-resources.app-settings-modal.button.saved',
defaultMessage: 'Saved',
},
retry: {
id: 'course-authoring.pages-resources.app-settings-modal.button.retry',
defaultMessage: 'Retry',
},
enabled: {
id: 'course-authoring.pages-resources.app-settings-modal.badge.enabled',
defaultMessage: 'Enabled',
},
disabled: {
id: 'course-authoring.pages-resources.app-settings-modal.badge.disabled',
defaultMessage: 'Disabled',
},
resetAllUnits: {
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units',
defaultMessage: 'Reset all units',
},
resetAllUnitsTooltipChecked: {
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.checked',
defaultMessage: 'Immediately reset any unit-level changes and checked "Enable summaries" on all units.',
},
resetAllUnitsTooltipUnchecked: {
id: 'course-authoring.pages-resources.app-settings-modal.reset-all-units-tooltip.unchecked',
defaultMessage: 'Immediately reset any unit-level changes and unchecked "Enable summaries" on all units.',
},
reset: {
id: 'course-authoring.pages-resources.app-settings-modal.reset',
defaultMessage: 'Reset',
},
errorSavingTitle: {
id: 'course-authoring.pages-resources.app-settings-modal.save-error.title',
defaultMessage: 'We couldn\'t apply your changes.',
},
errorSavingMessage: {
id: 'course-authoring.pages-resources.app-settings-modal.save-error.message',
defaultMessage: 'Please check your entries and try again.',
},
});
export default messages;