Translation Support (#25)

This commit is contained in:
Zachary Hancock
2020-08-05 12:30:13 -04:00
committed by GitHub
parent 490b66db58
commit 0094f7c30f
3 changed files with 220 additions and 53 deletions

View File

@@ -6,7 +6,13 @@ import {
Alert, Button, Form, Spinner,
} from '@edx/paragon';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
injectIntl,
intlShape,
FormattedMessage,
} from '@edx/frontend-platform/i18n';
import messages from './ProctoredExamSettings.messages';
import StudioApiService from '../data/services/StudioApiService';
function ExamSettings(props) {
@@ -102,7 +108,7 @@ function ExamSettings(props) {
function onButtonClick() {
if (proctoringProvider === 'proctortrack' && !EmailValidator.validate(proctortrackEscalationEmail)) {
if (proctortrackEscalationEmail === '') {
const errorMessage = 'The Proctortrack Escalation Email field cannot be empty if proctortrack is the selected provider.';
const errorMessage = props.intl.formatMessage(messages['authoring.examsettings.escalationemail.error.blank']);
setFormStatus({
isValid: false,
@@ -114,7 +120,7 @@ function ExamSettings(props) {
},
});
} else {
const errorMessage = 'The Proctortrack Escalation Email field is in the wrong format and is not valid.';
const errorMessage = props.intl.formatMessage(messages['authoring.examsettings.escalationemail.error.invalid']);
setFormStatus({
isValid: false,
@@ -169,10 +175,11 @@ function ExamSettings(props) {
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.examsettings.error.multiple' : 'authoring.examsettings.error.single';
return (
<>
<div>{numOfErrors > 1 ? `There are ${numOfErrors} errors in this form.` : 'There is 1 error in this form.'}</div>
<div>{props.intl.formatMessage(messages[messageId], { numOfErrors })}</div>
<ul>
{errors}
</ul>
@@ -201,14 +208,18 @@ function ExamSettings(props) {
<Form.Check
type="checkbox"
id="enableProctoredExams"
label="Enable Proctored Exams"
label={props.intl.formatMessage(messages['authoring.examsettings.enableproctoredexams.label'])}
aria-describedby="enableProctoredExamsHelpText"
onChange={onEnableProctoredExamsChange}
checked={enableProctoredExams}
inline
/>
<Form.Text id="enableProctoredExamsHelpText">
If checked, proctored exams are enabled in your course.
<FormattedMessage
id="authoring.examsettings.enableproctoredexams.help"
defaultMessage="If checked, proctored exams are enabled in your course."
description="Help text for checkbox to enable proctored exams."
/>
</Form.Text>
</Form.Group>
@@ -216,12 +227,18 @@ function ExamSettings(props) {
{ isEdxStaff && enableProctoredExams && (
<fieldset aria-describedby="allowOptingOutHelpText">
<Form.Group controlId="formAllowingOptingOut">
<Form.Label as="legend">Allow Opting Out of Proctored Exams</Form.Label>
<Form.Label as="legend">
<FormattedMessage
id="authoring.examsettings.allowoptout.label"
defaultMessage="Allow Opting Out of Proctored Exams"
description="Label for radio selection allowing proctored exam opt out"
/>
</Form.Label>
<Form.Check
type="radio"
id="allowOptingOutYes"
name="allowOptingOut"
label="Yes"
label={props.intl.formatMessage(messages['authoring.examsettings.allowoptout.yes'])}
inline
checked={allowOptingOut}
onChange={() => onAllowOptingOutChange(true)}
@@ -231,16 +248,22 @@ function ExamSettings(props) {
type="radio"
id="allowOptingOutNo"
name="allowOptingOut"
label="No"
label={props.intl.formatMessage(messages['authoring.examsettings.allowoptout.no'])}
inline
checked={!allowOptingOut}
onChange={() => onAllowOptingOutChange(false)}
data-test-id="allowOptingOutNo"
/>
<Form.Text id="allowOptingOutHelpText">
If this value is &quot;Yes&quot;, learners can choose to take proctored exams without proctoring.
If this value is &quot;No&quot;, all learners must take the exam with proctoring.
This setting only applies if proctored exams are enabled for the course.
<FormattedMessage
id="authoring.examsettings.allowoptout.help"
defaultMessage={`
If this value is "Yes", learners can choose to take proctored exams without proctoring.
If this value is "No", all learners must take the exam with proctoring.
This setting only applies if proctored exams are enabled for the course.
`}
description="Help text for proctored exam opt out radio selection"
/>
</Form.Text>
</Form.Group>
</fieldset>
@@ -249,7 +272,13 @@ function ExamSettings(props) {
{/* PROCTORING PROVIDER */}
{ enableProctoredExams && (
<Form.Group controlId="formProctoringProvider">
<Form.Label>Proctoring Provider</Form.Label>
<Form.Label>
<FormattedMessage
id="authoring.examsettings.provider.label"
defaultMessage="Proctoring Provider"
description="Label for proctoring provider dropdown selection"
/>
</Form.Label>
<Form.Control
as="select"
value={proctoringProvider}
@@ -258,14 +287,22 @@ function ExamSettings(props) {
>
{getProctoringProviderOptions(availableProctoringProviders)}
</Form.Control>
<Form.Text id="proctoringProviderHelpText">{cannotEditProctoringProvider() ? ('Proctoring provider cannot be modified after course start date.') : ('Select the proctoring provider you want to use for this course run.')}</Form.Text>
<Form.Text id="proctoringProviderHelpText">
{cannotEditProctoringProvider() ? props.intl.formatMessage(messages['authoring.examsettings.provider.help.aftercoursestart']) : props.intl.formatMessage(messages['authoring.examsettings.provider.help'])}
</Form.Text>
</Form.Group>
)}
{/* PROCTORTRACK ESCALATION EMAIL */}
{showProctortrackEscalationEmail && enableProctoredExams && (
<Form.Group controlId="formProctortrackEscalationEmail">
<Form.Label>Proctortrack Escalation Email</Form.Label>
<Form.Label>
<FormattedMessage
id="authoring.examsettings.escalationemail.label"
defaultMessage="Proctortrack Escalation Email"
description="Label for escalation email text field"
/>
</Form.Label>
<Form.Control
ref={proctoringEscalationEmailInputRef}
type="email"
@@ -277,9 +314,14 @@ function ExamSettings(props) {
/>
<Form.Control.Feedback type="invalid">{formStatus.errors.formProctortrackEscalationEmail && formStatus.errors.formProctortrackEscalationEmail.inputErrorMessage} </Form.Control.Feedback>
<Form.Text id="proctortrackEscalationEmailHelpText">
Required if &quot;proctortrack&quot; is selected as your proctoring provider.
Enter an email address to be contacted by the support team whenever there are escalations
(e.g. appeals, delayed reviews, etc.).
<FormattedMessage
id="authoring.examsettings.escalationemail.help"
defaultMessage={`
Required if "proctortrack" is selected as your proctoring provider. Enter an email address to be
contacted by the support team whenever there are escalations (e.g. appeals, delayed reviews, etc.).
`}
description="Help text explaining escalation email field."
/>
</Form.Text>
</Form.Group>
)}
@@ -287,11 +329,17 @@ function ExamSettings(props) {
{ isEdxStaff && enableProctoredExams && (
<fieldset aria-describedby="createZendeskTicketsText">
<Form.Group controlId="formCreateZendeskTickets">
<Form.Label as="legend">Create Zendesk Tickets for Suspicious Proctored Exam Attempts</Form.Label>
<Form.Label as="legend">
<FormattedMessage
id="authoring.examsettings.createzendesk.label"
defaultMessage="Create Zendesk Tickets for Suspicious Proctored Exam Attempts"
description="Label for Zendesk ticket creation radio select."
/>
</Form.Label>
<Form.Check
type="radio"
id="createZendeskTicketsYes"
label="Yes"
label={props.intl.formatMessage(messages['authoring.examsettings.createzendesk.yes'])}
inline
name="createZendeskTickets"
checked={createZendeskTickets}
@@ -301,7 +349,7 @@ function ExamSettings(props) {
<Form.Check
type="radio"
id="createZendeskTicketsNo"
label="No"
label={props.intl.formatMessage(messages['authoring.examsettings.createzendesk.no'])}
inline
name="createZendeskTickets"
checked={!createZendeskTickets}
@@ -309,8 +357,11 @@ function ExamSettings(props) {
data-test-id="createZendeskTicketsNo"
/>
<Form.Text id="createZendeskTicketsText">
If this value is &quot;Yes&quot;,
a Zendesk ticket will be created for suspicious proctored exam attempts.
<FormattedMessage
id="authoring.examsettings.createzendesk.help"
defaultMessage="If this value is &quot;Yes&quot;, a Zendesk ticket will be created for suspicious proctored exam attempts."
description="Help text for Zendesk ticket creation radio select"
/>
</Form.Text>
</Form.Group>
</fieldset>
@@ -321,7 +372,11 @@ function ExamSettings(props) {
onClick={onButtonClick}
disabled={submissionInProgress}
>
Submit
<FormattedMessage
id="authoring.examsettings.submit"
defaultMessage="Submit"
description="Form submit button"
/>
</Button> {' '}
{submissionInProgress && <Spinner animation="border" variant="primary" data-test-id="saveInProgress" aria-label="Save in progress" />}
</Form>
@@ -338,7 +393,13 @@ function ExamSettings(props) {
data-test-id="spinnerContainer"
>
<Spinner className animation="border" role="status" variant="primary">
<span className="sr-only">Loading...</span>
<span className="sr-only">
<FormattedMessage
id="authoring.examsettings.loading"
defaultMessage="Loading..."
description=""
/>
</span>
</Spinner>
</div>
);
@@ -347,10 +408,15 @@ function ExamSettings(props) {
function renderConnectionError() {
return (
<Alert variant="danger" data-test-id="connectionError">
We encountered a technical error when loading this page.
This might be a temporary issue, so please try again in a few minutes.
If the problem persists,
please go to <Alert.Link href="https://support.edx.org/hc/en-us">edX Support Page</Alert.Link> for help.
<FormattedMessage
id="authoring.examsettings.alert.error.connection"
defaultMessage={`
We encountered a technical error when loading this page. This might be a temporary issue,
so please try again in a few minutes. If the problem persists, please go to {support_link} for help.
`}
values={{ support_link: <Alert.Link href="https://support.edx.org/hc/en-us">{props.intl.formatMessage(messages['authoring.examsettings.support.text'])}</Alert.Link> }}
description=""
/>
</Alert>
);
}
@@ -358,8 +424,13 @@ function ExamSettings(props) {
function renderPermissionError() {
return (
<Alert variant="danger" data-test-id="permissionError">
You are not authorized to view this page. If you feel you should have access,
please reach out to your course team admin to be given access.
<FormattedMessage
id="authoring.examsettings.alert.error.permission"
defaultMessage={`
You are not authorized to view this page. If you feel you should have access,
please reach out to your course team admin to be given access.
`}
/>
</Alert>
);
}
@@ -373,8 +444,14 @@ function ExamSettings(props) {
data-test-id="saveSuccess"
onClose={() => setSaveSuccess(false)}
>
Proctored exam settings saved successfully.
You can go back to your course in Studio <Alert.Link href={studioCourseRunURL}>here</Alert.Link>.
<FormattedMessage
id="authoring.examsettings.alert.success"
defaultMessage={`
Proctored exam settings saved successfully.
You can go back to your course in Studio {studioCourseRunURL}.
`}
values={{ studioCourseRunURL: <Alert.Link href={studioCourseRunURL}>here</Alert.Link> }}
/>
</Alert>
);
}
@@ -387,10 +464,16 @@ function ExamSettings(props) {
data-test-id="saveError"
onClose={() => setSaveError(false)}
>
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 <Alert.Link href="https://support.edx.org/hc/en-us">edX Support Page</Alert.Link> for help.
<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 {support_link} for help.
`}
values={{ support_link: <Alert.Link href="https://support.edx.org/hc/en-us">{props.intl.formatMessage(messages['authoring.examsettings.support.text'])}</Alert.Link> }}
/>
</Alert>
);
}
@@ -459,9 +542,10 @@ function ExamSettings(props) {
}
ExamSettings.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};
ExamSettings.defaultProps = {};
export default ExamSettings;
export default injectIntl(ExamSettings);

View File

@@ -0,0 +1,71 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authoring.examsettings.allowoptout.no': {
id: 'authoring.examsettings.allowoptout.no',
defaultMessage: 'No',
description: '"No" option for yes/no radio button set',
},
'authoring.examsettings.allowoptout.yes': {
id: 'authoring.examsettings.allowoptout.yes',
defaultMessage: 'Yes',
description: '"Yes" option for yes/no radio button set',
},
'authoring.examsettings.createzendesk.no': {
id: 'authoring.examsettings.createzendesk.no',
defaultMessage: 'No',
description: '"No" option for yes/no radio button set.',
},
'authoring.examsettings.createzendesk.yes': {
id: 'authoring.examsettings.createzendesk.yes',
defaultMessage: 'Yes',
description: '"Yes" option for yes/no radio button set.',
},
'authoring.examsettings.support.text': {
id: 'authoring.examsettings.support.text',
defaultMessage: 'edX Support Page',
description: 'Text linking to the support page.',
},
'authoring.examsettings.enableproctoredexams.label': {
id: 'authoring.examsettings.escalationemail.enableproctoredexams.label',
defaultMessage: 'Enable Proctored Exams',
description: 'Label for checkbox to enable proctored exams.',
},
'authoring.examsettings.escalationemail.error.blank': {
id: 'authoring.examsettings.escalationemail.error.blank',
defaultMessage: 'The Proctortrack Escalation Email field cannot be empty if proctortrack is the selected provider.',
description: 'Error message for missing required email field.',
},
'authoring.examsettings.escalationemail.error.invalid': {
id: 'authoring.examsettings.escalationemail.error.invalid',
defaultMessage: 'The Proctortrack Escalation Email field is in the wrong format and is not valid.',
description: 'Error message for a invalid email format.',
},
'authoring.examsettings.error.single': {
id: 'authoring.examsettings.error.single',
defaultMessage: 'There is 1 error in this form.',
description: 'Error alert for one and only one error in the form.',
},
'authoring.examsettings.error.multiple': {
id: 'authoring.examsettings.escalationemail.error.multiple',
defaultMessage: 'There are {numOfErrors} errors in this form.',
description: 'Error alert for multiple errors in the form.',
},
'authoring.examsettings.provider.label': {
id: 'authoring.examsettings.provider.label',
defaultMessage: 'Proctoring Provider',
description: 'Label for provider dropdown selection.',
},
'authoring.examsettings.provider.help': {
id: 'authoring.examsettings.provider.help',
defaultMessage: 'Select the proctoring provider you want to use for this course run.',
description: 'Help text for selecting a proctoring provider.',
},
'authoring.examsettings.provider.help.aftercoursestart': {
id: 'authoring.examsettings.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.',
},
});
export default messages;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import {
render, screen, cleanup, waitFor, waitForElementToBeRemoved, fireEvent, act,
} from '@testing-library/react';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import * as auth from '@edx/frontend-platform/auth';
import ProctoredExamSettings from './ProctoredExamSettings';
import StudioApiService from '../data/services/StudioApiService';
@@ -10,6 +11,14 @@ const defaultProps = {
courseId: 'course-v1%3AedX%2BDemoX%2BDemo_Course',
};
const IntlProctoredExamSettings = injectIntl(ProctoredExamSettings);
const intlWrapper = children => (
<IntlProvider locale="en">
{children}
</IntlProvider>
);
describe('ProctoredExamSettings field dependency tests', () => {
beforeEach(async () => {
auth.getAuthenticatedHttpClient = jest.fn(() => ({
@@ -29,7 +38,7 @@ describe('ProctoredExamSettings field dependency tests', () => {
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, administrator: true }));
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
afterEach(() => {
@@ -90,7 +99,7 @@ describe('ProctoredExamSettings field dependency tests', () => {
}),
}));
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
await waitFor(() => {
screen.getByLabelText('Enable Proctored Exams');
});
@@ -148,7 +157,7 @@ describe('ProctoredExamSettings validation with invalid escalation email', () =>
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, administrator: false }));
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
});
afterEach(() => {
@@ -305,28 +314,28 @@ describe('Disables proctoring provider options', () => {
it('disables irrelevant Proctoring Provider fields when user is not an administrator and it is after start date', async () => {
mockAPI(mockGetPastCourseData, false);
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
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 () => {
mockAPI(mockGetFutureCourseData, false);
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
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 () => {
mockAPI(mockGetPastCourseData, true);
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
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 () => {
mockAPI(mockGetFutureCourseData, true);
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const providerOption = screen.getByTestId('proctortrack');
expect(providerOption.hasAttribute('disabled')).toEqual(false);
});
@@ -362,14 +371,14 @@ describe('Hides fields based on user permissions', () => {
it('hides opting out and zendesk tickets for non edX staff', async () => {
mockAuthentication(false);
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
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 () => {
mockAuthentication(true);
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull();
expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull();
});
@@ -377,10 +386,13 @@ describe('Hides fields based on user permissions', () => {
describe('ProctoredExamSettings connection states tests', () => {
it('shows the spinner before the connection is complete', async () => {
render(<ProctoredExamSettings {...defaultProps} />);
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, administrator: false }));
auth.getAuthenticatedHttpClient = jest.fn(() => ({
get: jest.fn(() => new Promise(() => {})),
}));
render(<IntlProvider local="en"><IntlProctoredExamSettings {...defaultProps} /></IntlProvider>);
const spinner = screen.getByTestId('spinnerContainer');
expect(spinner.textContent).toEqual('Loading...');
await waitForElementToBeRemoved(spinner);
});
it('show connection error message when we suffer server side error', async () => {
@@ -395,7 +407,7 @@ describe('ProctoredExamSettings connection states tests', () => {
},
}));
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('connectionError');
expect(connectionError.textContent).toEqual(
expect.stringContaining('We encountered a technical error'),
@@ -414,7 +426,7 @@ describe('ProctoredExamSettings connection states tests', () => {
},
}));
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const connectionError = screen.getByTestId('permissionError');
expect(connectionError.textContent).toEqual(
expect.stringContaining('You are not authorized to view this page'),
@@ -453,7 +465,7 @@ describe('ProctoredExamSettings save settings tests', () => {
it('Show spinner while saving', async () => {
const mockedFunctions = mockAPI(mockGetData, { data: 'success' });
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
const submitButton = screen.getByTestId('submissionButton');
expect(screen.queryByTestId('saveInProgress')).toBeFalsy();
fireEvent.click(submitButton);
@@ -466,7 +478,7 @@ describe('ProctoredExamSettings save settings tests', () => {
it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => {
const mockedFunctions = mockAPI(mockGetData, { data: 'success' });
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
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 () => {
@@ -532,7 +544,7 @@ describe('ProctoredExamSettings save settings tests', () => {
it('Makes API call generated error', async () => {
const mockedFunctions = mockAPI(mockGetData, false);
await act(async () => render(<ProctoredExamSettings {...defaultProps} />));
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
// Make a change to the provider to proctortrack and set the email
const submitButton = screen.getByTestId('submissionButton');
await act(async () => {