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:
31
plugins/course-apps/calculator/Settings.jsx
Normal file
31
plugins/course-apps/calculator/Settings.jsx
Normal 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;
|
||||
24
plugins/course-apps/calculator/messages.js
Normal file
24
plugins/course-apps/calculator/messages.js
Normal 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;
|
||||
17
plugins/course-apps/calculator/package.json
Normal file
17
plugins/course-apps/calculator/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
31
plugins/course-apps/edxnotes/Settings.jsx
Normal file
31
plugins/course-apps/edxnotes/Settings.jsx
Normal 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;
|
||||
25
plugins/course-apps/edxnotes/messages.js
Normal file
25
plugins/course-apps/edxnotes/messages.js
Normal 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;
|
||||
17
plugins/course-apps/edxnotes/package.json
Normal file
17
plugins/course-apps/edxnotes/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
63
plugins/course-apps/learning_assistant/Settings.jsx
Normal file
63
plugins/course-apps/learning_assistant/Settings.jsx
Normal 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;
|
||||
59
plugins/course-apps/learning_assistant/Settings.test.jsx
Normal file
59
plugins/course-apps/learning_assistant/Settings.test.jsx
Normal 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());
|
||||
});
|
||||
});
|
||||
28
plugins/course-apps/learning_assistant/messages.js
Normal file
28
plugins/course-apps/learning_assistant/messages.js
Normal 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;
|
||||
19
plugins/course-apps/learning_assistant/package.json
Normal file
19
plugins/course-apps/learning_assistant/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
plugins/course-apps/live/BBBSettings.jsx
Normal file
130
plugins/course-apps/live/BBBSettings.jsx
Normal 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);
|
||||
139
plugins/course-apps/live/BbbSettings.test.jsx
Normal file
139
plugins/course-apps/live/BbbSettings.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
48
plugins/course-apps/live/LiveCommonFields.jsx
Normal file
48
plugins/course-apps/live/LiveCommonFields.jsx
Normal 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);
|
||||
136
plugins/course-apps/live/Settings.jsx
Normal file
136
plugins/course-apps/live/Settings.jsx
Normal 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);
|
||||
166
plugins/course-apps/live/Settings.test.jsx
Normal file
166
plugins/course-apps/live/Settings.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
54
plugins/course-apps/live/ZoomSettings.jsx
Normal file
54
plugins/course-apps/live/ZoomSettings.jsx
Normal 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);
|
||||
153
plugins/course-apps/live/ZoomSettings.test.jsx
Normal file
153
plugins/course-apps/live/ZoomSettings.test.jsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
20
plugins/course-apps/live/constants.js
Normal file
20
plugins/course-apps/live/constants.js
Normal 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',
|
||||
};
|
||||
124
plugins/course-apps/live/data/api.js
Normal file
124
plugins/course-apps/live/data/api.js
Normal 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);
|
||||
}
|
||||
48
plugins/course-apps/live/data/slice.js
Normal file
48
plugins/course-apps/live/data/slice.js
Normal 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;
|
||||
86
plugins/course-apps/live/data/thunks.js
Normal file
86
plugins/course-apps/live/data/thunks.js
Normal 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));
|
||||
};
|
||||
}
|
||||
86
plugins/course-apps/live/factories/mockApiResponses.jsx
Normal file
86
plugins/course-apps/live/factories/mockApiResponses.jsx
Normal 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,
|
||||
});
|
||||
173
plugins/course-apps/live/messages.js
Normal file
173
plugins/course-apps/live/messages.js
Normal 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;
|
||||
22
plugins/course-apps/live/package.json
Normal file
22
plugins/course-apps/live/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
69
plugins/course-apps/ora_settings/Settings.jsx
Normal file
69
plugins/course-apps/ora_settings/Settings.jsx
Normal 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);
|
||||
33
plugins/course-apps/ora_settings/Settings.test.jsx
Normal file
33
plugins/course-apps/ora_settings/Settings.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
22
plugins/course-apps/ora_settings/messages.js
Normal file
22
plugins/course-apps/ora_settings/messages.js
Normal 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;
|
||||
19
plugins/course-apps/ora_settings/package.json
Normal file
19
plugins/course-apps/ora_settings/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
635
plugins/course-apps/proctoring/Settings.jsx
Normal file
635
plugins/course-apps/proctoring/Settings.jsx
Normal 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);
|
||||
912
plugins/course-apps/proctoring/Settings.test.jsx
Normal file
912
plugins/course-apps/proctoring/Settings.test.jsx
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
116
plugins/course-apps/proctoring/messages.js
Normal file
116
plugins/course-apps/proctoring/messages.js
Normal 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;
|
||||
20
plugins/course-apps/proctoring/package.json
Normal file
20
plugins/course-apps/proctoring/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
55
plugins/course-apps/progress/Settings.jsx
Normal file
55
plugins/course-apps/progress/Settings.jsx
Normal 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);
|
||||
33
plugins/course-apps/progress/messages.js
Normal file
33
plugins/course-apps/progress/messages.js
Normal 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;
|
||||
18
plugins/course-apps/progress/package.json
Normal file
18
plugins/course-apps/progress/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
168
plugins/course-apps/teams/GroupEditor.jsx
Normal file
168
plugins/course-apps/teams/GroupEditor.jsx
Normal 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);
|
||||
171
plugins/course-apps/teams/Settings.jsx
Normal file
171
plugins/course-apps/teams/Settings.jsx
Normal 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);
|
||||
171
plugins/course-apps/teams/messages.js
Normal file
171
plugins/course-apps/teams/messages.js
Normal 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;
|
||||
20
plugins/course-apps/teams/package.json
Normal file
20
plugins/course-apps/teams/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
49
plugins/course-apps/wiki/Settings.jsx
Normal file
49
plugins/course-apps/wiki/Settings.jsx
Normal 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);
|
||||
34
plugins/course-apps/wiki/messages.js
Normal file
34
plugins/course-apps/wiki/messages.js
Normal 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;
|
||||
18
plugins/course-apps/wiki/package.json
Normal file
18
plugins/course-apps/wiki/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
4
plugins/course-apps/xpert_unit_summary/README.rst
Normal file
4
plugins/course-apps/xpert_unit_summary/README.rst
Normal file
@@ -0,0 +1,4 @@
|
||||
Xpert Unit Summaries Configuration Plugin
|
||||
=========================================
|
||||
|
||||
Install this using ``npm install plugins/course-apps/xpert_unit_summary/ --no-save``.
|
||||
45
plugins/course-apps/xpert_unit_summary/Settings.jsx
Normal file
45
plugins/course-apps/xpert_unit_summary/Settings.jsx
Normal 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);
|
||||
281
plugins/course-apps/xpert_unit_summary/Settings.test.jsx
Normal file
281
plugins/course-apps/xpert_unit_summary/Settings.test.jsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
13
plugins/course-apps/xpert_unit_summary/appInfo.js
Normal file
13
plugins/course-apps/xpert_unit_summary/appInfo.js
Normal 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/',
|
||||
},
|
||||
};
|
||||
41
plugins/course-apps/xpert_unit_summary/data/api.js
Normal file
41
plugins/course-apps/xpert_unit_summary/data/api.js
Normal 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;
|
||||
}
|
||||
113
plugins/course-apps/xpert_unit_summary/data/thunks.js
Normal file
113
plugins/course-apps/xpert_unit_summary/data/thunks.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
34
plugins/course-apps/xpert_unit_summary/messages.js
Normal file
34
plugins/course-apps/xpert_unit_summary/messages.js
Normal 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;
|
||||
21
plugins/course-apps/xpert_unit_summary/package.json
Normal file
21
plugins/course-apps/xpert_unit_summary/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user