Skip to content

Commit b71f214

Browse files
authored
feat: update ora settings to only be flexible peer grading (#1332)
1 parent e9c10c7 commit b71f214

File tree

8 files changed

+369
-122
lines changed

8 files changed

+369
-122
lines changed
Lines changed: 151 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,176 @@
1-
import React from 'react';
1+
import { useEffect, useState, useRef } from 'react';
22
import PropTypes from 'prop-types';
3-
import * as Yup from 'yup';
43

5-
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
5+
import { useDispatch, useSelector } from 'react-redux';
66

7-
import { Hyperlink } from '@openedx/paragon';
8-
import { useModel } from 'CourseAuthoring/generic/model-store';
7+
import {
8+
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton,
9+
} from '@openedx/paragon';
10+
import { Info } from '@openedx/paragon/icons';
11+
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store';
912

13+
import { RequestStatus } from 'CourseAuthoring/data/constants';
1014
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
11-
import { useAppSetting } from 'CourseAuthoring/utils';
12-
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
15+
import Loading from 'CourseAuthoring/generic/Loading';
16+
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
17+
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert';
18+
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils';
19+
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors';
20+
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice';
21+
1322
import messages from './messages';
1423

15-
const ORASettings = ({ intl, onClose }) => {
24+
const ORASettings = ({ onClose }) => {
25+
const dispatch = useDispatch();
26+
const { formatMessage } = useIntl();
27+
const alertRef = useRef(null);
28+
const updateSettingsRequestStatus = useSelector(getSavingStatus);
29+
const loadingStatus = useSelector(getLoadingStatus);
30+
const isMobile = useIsMobile();
31+
const modalVariant = isMobile ? 'dark' : 'default';
1632
const appId = 'ora_settings';
1733
const appInfo = useModel('courseApps', appId);
34+
1835
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting(
1936
'forceOnFlexiblePeerOpenassessments',
2037
);
38+
const initialFormValues = { enableFlexiblePeerGrade };
39+
40+
const [formValues, setFormValues] = useState(initialFormValues);
41+
const [saveError, setSaveError] = useState(false);
42+
43+
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default';
2144
const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade);
2245

23-
const title = (
24-
<div>
25-
<p>{intl.formatMessage(messages.heading)}</p>
26-
<div className="pt-3">
27-
<Hyperlink
28-
className="text-primary-500 small"
29-
destination={appInfo.documentationLinks?.learnMoreConfiguration}
30-
target="_blank"
31-
rel="noreferrer noopener"
32-
>
33-
{intl.formatMessage(messages.ORASettingsHelpLink)}
34-
</Hyperlink>
35-
</div>
36-
</div>
37-
);
46+
const handleSubmit = async (event) => {
47+
let success = true;
48+
event.preventDefault();
49+
50+
success = success && await handleSettingsSave(formValues);
51+
await setSaveError(!success);
52+
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) {
53+
success = await dispatch(updateModel({
54+
modelType: 'courseApps',
55+
model: {
56+
id: appId, enabled: formValues.enableFlexiblePeerGrade,
57+
},
58+
}));
59+
}
60+
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions
61+
};
62+
63+
const handleChange = (e) => {
64+
setFormValues({ enableFlexiblePeerGrade: e.target.checked });
65+
};
66+
67+
useEffect(() => {
68+
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) {
69+
dispatch(updateSavingStatus({ status: '' }));
70+
onClose();
71+
}
72+
}, [updateSettingsRequestStatus]);
73+
74+
const renderBody = () => {
75+
switch (loadingStatus) {
76+
case RequestStatus.SUCCESSFUL:
77+
return (
78+
<>
79+
{saveError && (
80+
<Alert variant="danger" icon={Info} ref={alertRef}>
81+
<Alert.Heading>
82+
{formatMessage(messages.errorSavingTitle)}
83+
</Alert.Heading>
84+
{formatMessage(messages.errorSavingMessage)}
85+
</Alert>
86+
)}
87+
<FormSwitchGroup
88+
id="enable-flexible-peer-grade"
89+
name="enableFlexiblePeerGrade"
90+
label={(
91+
<div className="d-flex align-items-center">
92+
{formatMessage(messages.enableFlexPeerGradeLabel)}
93+
{formValues.enableFlexiblePeerGrade && (
94+
<Badge className="ml-2" variant="success" data-testid="enable-badge">
95+
{formatMessage(messages.enabledBadgeLabel)}
96+
</Badge>
97+
)}
98+
</div>
99+
)}
100+
helpText={(
101+
<div>
102+
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p>
103+
<span className="py-3">
104+
<Hyperlink
105+
className="text-primary-500 small"
106+
destination={appInfo.documentationLinks?.learnMoreConfiguration}
107+
target="_blank"
108+
rel="noreferrer noopener"
109+
>
110+
{formatMessage(messages.ORASettingsHelpLink)}
111+
</Hyperlink>
112+
</span>
113+
</div>
114+
)}
115+
onChange={handleChange}
116+
checked={formValues.enableFlexiblePeerGrade}
117+
/>
118+
</>
119+
);
120+
case RequestStatus.DENIED:
121+
return <PermissionDeniedAlert />;
122+
case RequestStatus.FAILED:
123+
return <ConnectionErrorAlert />;
124+
default:
125+
return <Loading />;
126+
}
127+
};
38128

39129
return (
40-
<AppSettingsModal
41-
appId={appId}
42-
title={title}
130+
<ModalDialog
131+
title={formatMessage(messages.heading)}
132+
isOpen
43133
onClose={onClose}
44-
initialValues={{ enableFlexiblePeerGrade }}
45-
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }}
46-
onSettingsSave={handleSettingsSave}
47-
hideAppToggle
134+
size="lg"
135+
variant={modalVariant}
136+
hasCloseButton={isMobile}
137+
isFullscreenScroll
138+
isFullscreenOnMobile
48139
>
49-
{({ values, handleChange, handleBlur }) => (
50-
<FormSwitchGroup
51-
id="enable-flexible-peer-grade"
52-
name="enableFlexiblePeerGrade"
53-
label={intl.formatMessage(messages.enableFlexPeerGradeLabel)}
54-
helpText={intl.formatMessage(messages.enableFlexPeerGradeHelp)}
55-
onChange={handleChange}
56-
onBlur={handleBlur}
57-
checked={values.enableFlexiblePeerGrade}
58-
/>
59-
)}
60-
</AppSettingsModal>
140+
<Form onSubmit={handleSubmit} data-testid="proctoringForm">
141+
<ModalDialog.Header>
142+
<ModalDialog.Title>
143+
{formatMessage(messages.heading)}
144+
</ModalDialog.Title>
145+
</ModalDialog.Header>
146+
<ModalDialog.Body>
147+
{renderBody()}
148+
</ModalDialog.Body>
149+
<ModalDialog.Footer className="p-4">
150+
<ActionRow>
151+
<ModalDialog.CloseButton variant="tertiary">
152+
{formatMessage(messages.cancelLabel)}
153+
</ModalDialog.CloseButton>
154+
<StatefulButton
155+
labels={{
156+
default: formatMessage(messages.saveLabel),
157+
pending: formatMessage(messages.pendingSaveLabel),
158+
}}
159+
description="Form save button"
160+
data-testid="submissionButton"
161+
disabled={submitButtonState === RequestStatus.IN_PROGRESS}
162+
state={submitButtonState}
163+
type="submit"
164+
/>
165+
</ActionRow>
166+
</ModalDialog.Footer>
167+
</Form>
168+
</ModalDialog>
61169
);
62170
};
63171

64172
ORASettings.propTypes = {
65-
intl: intlShape.isRequired,
66173
onClose: PropTypes.func.isRequired,
67174
};
68175

69-
export default injectIntl(ORASettings);
176+
export default ORASettings;
Lines changed: 145 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,152 @@
1-
import { shallow } from '@edx/react-unit-test-utils';
1+
import {
2+
render,
3+
screen,
4+
waitFor,
5+
within,
6+
} from '@testing-library/react';
7+
import ReactDOM from 'react-dom';
8+
import { Routes, Route, MemoryRouter } from 'react-router-dom';
9+
import { initializeMockApp } from '@edx/frontend-platform';
10+
import MockAdapter from 'axios-mock-adapter';
11+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
12+
import { AppProvider, PageWrap } from '@edx/frontend-platform/react';
13+
import { IntlProvider } from '@edx/frontend-platform/i18n';
14+
15+
import initializeStore from 'CourseAuthoring/store';
16+
import { executeThunk } from 'CourseAuthoring/utils';
17+
import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
18+
import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api';
19+
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks';
220
import ORASettings from './Settings';
21+
import messages from './messages';
22+
import {
23+
courseId,
24+
inititalState,
25+
} from './factories/mockData';
26+
27+
let axiosMock;
28+
let store;
29+
const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
30+
31+
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
32+
ReactDOM.createPortal = jest.fn(node => node);
33+
34+
const renderComponent = () => (
35+
render(
36+
<IntlProvider locale="en">
37+
<AppProvider store={store} wrapWithRouter={false}>
38+
<PagesAndResourcesProvider courseId={courseId}>
39+
<MemoryRouter initialEntries={[oraSettingsUrl]}>
40+
<Routes>
41+
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} />
42+
</Routes>
43+
</MemoryRouter>
44+
</PagesAndResourcesProvider>
45+
</AppProvider>
46+
</IntlProvider>,
47+
)
48+
);
349

4-
jest.mock('@edx/frontend-platform/i18n', () => ({
5-
...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts
6-
injectIntl: (component) => component,
7-
intlShape: {},
8-
}));
9-
jest.mock('yup', () => ({
10-
boolean: jest.fn().mockReturnValue('Yub.boolean'),
11-
}));
12-
jest.mock('CourseAuthoring/generic/model-store', () => ({
13-
useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }),
14-
}));
15-
jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup');
16-
jest.mock('CourseAuthoring/utils', () => ({
17-
useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]),
18-
}));
19-
jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal');
20-
21-
const props = {
22-
onClose: jest.fn().mockName('onClose'),
23-
intl: {
24-
formatMessage: (message) => message.defaultMessage,
25-
},
50+
const mockStore = async ({
51+
apiStatus,
52+
enabled,
53+
}) => {
54+
const settings = ['forceOnFlexiblePeerOpenassessments'];
55+
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`;
56+
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`;
57+
58+
axiosMock.onGet(fetchCourseAppsUrl).reply(
59+
200,
60+
[{
61+
allowed_operations: { enable: false, configure: true },
62+
description: 'setting',
63+
documentation_links: { learnMoreConfiguration: '' },
64+
enabled,
65+
id: 'ora_settings',
66+
name: 'Flexible Peer Grading for ORAs',
67+
}],
68+
);
69+
axiosMock.onGet(fetchAdvancedSettingsUrl).reply(
70+
apiStatus,
71+
{ force_on_flexible_peer_openassessments: { value: enabled } },
72+
);
73+
74+
await executeThunk(fetchCourseApps(courseId), store.dispatch);
75+
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch);
2676
};
2777

2878
describe('ORASettings', () => {
29-
it('should render', () => {
30-
const wrapper = shallow(<ORASettings {...props} />);
31-
expect(wrapper.snapshot).toMatchSnapshot();
79+
beforeEach(async () => {
80+
initializeMockApp({
81+
authenticatedUser: {
82+
userId: 3,
83+
username: 'abc123',
84+
administrator: false,
85+
roles: [],
86+
},
87+
});
88+
store = initializeStore(inititalState);
89+
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
90+
});
91+
92+
it('Flexible peer grading configuration modal is visible', async () => {
93+
renderComponent();
94+
expect(screen.getByRole('dialog')).toBeVisible();
95+
});
96+
97+
it('Displays "Configure Flexible Peer Grading" heading', async () => {
98+
renderComponent();
99+
const headingElement = screen.getByText(messages.heading.defaultMessage);
100+
101+
expect(headingElement).toBeVisible();
102+
});
103+
104+
it('Displays loading component', () => {
105+
renderComponent();
106+
const loadingElement = screen.getByRole('status');
107+
108+
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument();
109+
});
110+
111+
it('Displays Connection Error Alert', async () => {
112+
await mockStore({ apiStatus: 404, enabled: true });
113+
renderComponent();
114+
const errorAlert = screen.getByRole('alert');
115+
116+
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible();
117+
});
118+
119+
it('Displays Permissions Error Alert', async () => {
120+
await mockStore({ apiStatus: 403, enabled: true });
121+
renderComponent();
122+
const errorAlert = screen.getByRole('alert');
123+
124+
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible();
125+
});
126+
127+
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
128+
renderComponent();
129+
await mockStore({ apiStatus: 200, enabled: true });
130+
131+
waitFor(() => {
132+
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
133+
const enableBadge = screen.getByTestId('enable-badge');
134+
135+
expect(label).toBeVisible();
136+
137+
expect(enableBadge).toHaveTextContent('Enabled');
138+
});
139+
});
140+
141+
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => {
142+
renderComponent();
143+
await mockStore({ apiStatus: 200, enabled: false });
144+
145+
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
146+
const enableBadge = screen.queryByTestId('enable-badge');
147+
148+
expect(label).toBeVisible();
149+
150+
expect(enableBadge).toBeNull();
32151
});
33152
});

0 commit comments

Comments
 (0)