-
Notifications
You must be signed in to change notification settings - Fork 75
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: update ora settings to only be flexible peer grading (#1332)
- Loading branch information
1 parent
e9c10c7
commit b71f214
Showing
8 changed files
with
369 additions
and
122 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,69 +1,176 @@ | ||
import React from 'react'; | ||
import { useEffect, useState, useRef } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import * as Yup from 'yup'; | ||
|
||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; | ||
import { useIntl } from '@edx/frontend-platform/i18n'; | ||
import { useDispatch, useSelector } from 'react-redux'; | ||
|
||
import { Hyperlink } from '@openedx/paragon'; | ||
import { useModel } from 'CourseAuthoring/generic/model-store'; | ||
import { | ||
ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton, | ||
} from '@openedx/paragon'; | ||
import { Info } from '@openedx/paragon/icons'; | ||
import { updateModel, useModel } from 'CourseAuthoring/generic/model-store'; | ||
|
||
import { RequestStatus } from 'CourseAuthoring/data/constants'; | ||
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; | ||
import { useAppSetting } from 'CourseAuthoring/utils'; | ||
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; | ||
import Loading from 'CourseAuthoring/generic/Loading'; | ||
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; | ||
import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert'; | ||
import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils'; | ||
import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors'; | ||
import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice'; | ||
|
||
import messages from './messages'; | ||
|
||
const ORASettings = ({ intl, onClose }) => { | ||
const ORASettings = ({ onClose }) => { | ||
const dispatch = useDispatch(); | ||
const { formatMessage } = useIntl(); | ||
const alertRef = useRef(null); | ||
const updateSettingsRequestStatus = useSelector(getSavingStatus); | ||
const loadingStatus = useSelector(getLoadingStatus); | ||
const isMobile = useIsMobile(); | ||
const modalVariant = isMobile ? 'dark' : 'default'; | ||
const appId = 'ora_settings'; | ||
const appInfo = useModel('courseApps', appId); | ||
|
||
const [enableFlexiblePeerGrade, saveSetting] = useAppSetting( | ||
'forceOnFlexiblePeerOpenassessments', | ||
); | ||
const initialFormValues = { enableFlexiblePeerGrade }; | ||
|
||
const [formValues, setFormValues] = useState(initialFormValues); | ||
const [saveError, setSaveError] = useState(false); | ||
|
||
const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default'; | ||
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> | ||
); | ||
const handleSubmit = async (event) => { | ||
let success = true; | ||
event.preventDefault(); | ||
|
||
success = success && await handleSettingsSave(formValues); | ||
await setSaveError(!success); | ||
if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) { | ||
success = await dispatch(updateModel({ | ||
modelType: 'courseApps', | ||
model: { | ||
id: appId, enabled: formValues.enableFlexiblePeerGrade, | ||
}, | ||
})); | ||
} | ||
!success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions | ||
}; | ||
|
||
const handleChange = (e) => { | ||
setFormValues({ enableFlexiblePeerGrade: e.target.checked }); | ||
}; | ||
|
||
useEffect(() => { | ||
if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) { | ||
dispatch(updateSavingStatus({ status: '' })); | ||
onClose(); | ||
} | ||
}, [updateSettingsRequestStatus]); | ||
|
||
const renderBody = () => { | ||
switch (loadingStatus) { | ||
case RequestStatus.SUCCESSFUL: | ||
return ( | ||
<> | ||
{saveError && ( | ||
<Alert variant="danger" icon={Info} ref={alertRef}> | ||
<Alert.Heading> | ||
{formatMessage(messages.errorSavingTitle)} | ||
</Alert.Heading> | ||
{formatMessage(messages.errorSavingMessage)} | ||
</Alert> | ||
)} | ||
<FormSwitchGroup | ||
id="enable-flexible-peer-grade" | ||
name="enableFlexiblePeerGrade" | ||
label={( | ||
<div className="d-flex align-items-center"> | ||
{formatMessage(messages.enableFlexPeerGradeLabel)} | ||
{formValues.enableFlexiblePeerGrade && ( | ||
<Badge className="ml-2" variant="success" data-testid="enable-badge"> | ||
{formatMessage(messages.enabledBadgeLabel)} | ||
</Badge> | ||
)} | ||
</div> | ||
)} | ||
helpText={( | ||
<div> | ||
<p>{formatMessage(messages.enableFlexPeerGradeHelp)}</p> | ||
<span className="py-3"> | ||
<Hyperlink | ||
className="text-primary-500 small" | ||
destination={appInfo.documentationLinks?.learnMoreConfiguration} | ||
target="_blank" | ||
rel="noreferrer noopener" | ||
> | ||
{formatMessage(messages.ORASettingsHelpLink)} | ||
</Hyperlink> | ||
</span> | ||
</div> | ||
)} | ||
onChange={handleChange} | ||
checked={formValues.enableFlexiblePeerGrade} | ||
/> | ||
</> | ||
); | ||
case RequestStatus.DENIED: | ||
return <PermissionDeniedAlert />; | ||
case RequestStatus.FAILED: | ||
return <ConnectionErrorAlert />; | ||
default: | ||
return <Loading />; | ||
} | ||
}; | ||
|
||
return ( | ||
<AppSettingsModal | ||
appId={appId} | ||
title={title} | ||
<ModalDialog | ||
title={formatMessage(messages.heading)} | ||
isOpen | ||
onClose={onClose} | ||
initialValues={{ enableFlexiblePeerGrade }} | ||
validationSchema={{ enableFlexiblePeerGrade: Yup.boolean() }} | ||
onSettingsSave={handleSettingsSave} | ||
hideAppToggle | ||
size="lg" | ||
variant={modalVariant} | ||
hasCloseButton={isMobile} | ||
isFullscreenScroll | ||
isFullscreenOnMobile | ||
> | ||
{({ 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> | ||
<Form onSubmit={handleSubmit} data-testid="proctoringForm"> | ||
<ModalDialog.Header> | ||
<ModalDialog.Title> | ||
{formatMessage(messages.heading)} | ||
</ModalDialog.Title> | ||
</ModalDialog.Header> | ||
<ModalDialog.Body> | ||
{renderBody()} | ||
</ModalDialog.Body> | ||
<ModalDialog.Footer className="p-4"> | ||
<ActionRow> | ||
<ModalDialog.CloseButton variant="tertiary"> | ||
{formatMessage(messages.cancelLabel)} | ||
</ModalDialog.CloseButton> | ||
<StatefulButton | ||
labels={{ | ||
default: formatMessage(messages.saveLabel), | ||
pending: formatMessage(messages.pendingSaveLabel), | ||
}} | ||
description="Form save button" | ||
data-testid="submissionButton" | ||
disabled={submitButtonState === RequestStatus.IN_PROGRESS} | ||
state={submitButtonState} | ||
type="submit" | ||
/> | ||
</ActionRow> | ||
</ModalDialog.Footer> | ||
</Form> | ||
</ModalDialog> | ||
); | ||
}; | ||
|
||
ORASettings.propTypes = { | ||
intl: intlShape.isRequired, | ||
onClose: PropTypes.func.isRequired, | ||
}; | ||
|
||
export default injectIntl(ORASettings); | ||
export default ORASettings; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,33 +1,152 @@ | ||
import { shallow } from '@edx/react-unit-test-utils'; | ||
import { | ||
render, | ||
screen, | ||
waitFor, | ||
within, | ||
} 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 { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api'; | ||
import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks'; | ||
import ORASettings from './Settings'; | ||
import messages from './messages'; | ||
import { | ||
courseId, | ||
inititalState, | ||
} from './factories/mockData'; | ||
|
||
let axiosMock; | ||
let store; | ||
const oraSettingsUrl = `/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 = () => ( | ||
render( | ||
<IntlProvider locale="en"> | ||
<AppProvider store={store} wrapWithRouter={false}> | ||
<PagesAndResourcesProvider courseId={courseId}> | ||
<MemoryRouter initialEntries={[oraSettingsUrl]}> | ||
<Routes> | ||
<Route path={oraSettingsUrl} element={<PageWrap><ORASettings onClose={jest.fn()} /></PageWrap>} /> | ||
</Routes> | ||
</MemoryRouter> | ||
</PagesAndResourcesProvider> | ||
</AppProvider> | ||
</IntlProvider>, | ||
) | ||
); | ||
|
||
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, | ||
}, | ||
const mockStore = async ({ | ||
apiStatus, | ||
enabled, | ||
}) => { | ||
const settings = ['forceOnFlexiblePeerOpenassessments']; | ||
const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`; | ||
const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`; | ||
|
||
axiosMock.onGet(fetchCourseAppsUrl).reply( | ||
200, | ||
[{ | ||
allowed_operations: { enable: false, configure: true }, | ||
description: 'setting', | ||
documentation_links: { learnMoreConfiguration: '' }, | ||
enabled, | ||
id: 'ora_settings', | ||
name: 'Flexible Peer Grading for ORAs', | ||
}], | ||
); | ||
axiosMock.onGet(fetchAdvancedSettingsUrl).reply( | ||
apiStatus, | ||
{ force_on_flexible_peer_openassessments: { value: enabled } }, | ||
); | ||
|
||
await executeThunk(fetchCourseApps(courseId), store.dispatch); | ||
await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch); | ||
}; | ||
|
||
describe('ORASettings', () => { | ||
it('should render', () => { | ||
const wrapper = shallow(<ORASettings {...props} />); | ||
expect(wrapper.snapshot).toMatchSnapshot(); | ||
beforeEach(async () => { | ||
initializeMockApp({ | ||
authenticatedUser: { | ||
userId: 3, | ||
username: 'abc123', | ||
administrator: false, | ||
roles: [], | ||
}, | ||
}); | ||
store = initializeStore(inititalState); | ||
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); | ||
}); | ||
|
||
it('Flexible peer grading configuration modal is visible', async () => { | ||
renderComponent(); | ||
expect(screen.getByRole('dialog')).toBeVisible(); | ||
}); | ||
|
||
it('Displays "Configure Flexible Peer Grading" heading', async () => { | ||
renderComponent(); | ||
const headingElement = screen.getByText(messages.heading.defaultMessage); | ||
|
||
expect(headingElement).toBeVisible(); | ||
}); | ||
|
||
it('Displays loading component', () => { | ||
renderComponent(); | ||
const loadingElement = screen.getByRole('status'); | ||
|
||
expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument(); | ||
}); | ||
|
||
it('Displays Connection Error Alert', async () => { | ||
await mockStore({ apiStatus: 404, enabled: true }); | ||
renderComponent(); | ||
const errorAlert = screen.getByRole('alert'); | ||
|
||
expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible(); | ||
}); | ||
|
||
it('Displays Permissions Error Alert', async () => { | ||
await mockStore({ apiStatus: 403, enabled: true }); | ||
renderComponent(); | ||
const errorAlert = screen.getByRole('alert'); | ||
|
||
expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible(); | ||
}); | ||
|
||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => { | ||
renderComponent(); | ||
await mockStore({ apiStatus: 200, enabled: true }); | ||
|
||
waitFor(() => { | ||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage); | ||
const enableBadge = screen.getByTestId('enable-badge'); | ||
|
||
expect(label).toBeVisible(); | ||
|
||
expect(enableBadge).toHaveTextContent('Enabled'); | ||
}); | ||
}); | ||
|
||
it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => { | ||
renderComponent(); | ||
await mockStore({ apiStatus: 200, enabled: false }); | ||
|
||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage); | ||
const enableBadge = screen.queryByTestId('enable-badge'); | ||
|
||
expect(label).toBeVisible(); | ||
|
||
expect(enableBadge).toBeNull(); | ||
}); | ||
}); |
Oops, something went wrong.