From cf616ccdf8701ee935f234f4e326588f79c85955 Mon Sep 17 00:00:00 2001 From: KristinAoki Date: Thu, 26 Sep 2024 09:29:36 -0400 Subject: [PATCH] feat: update ora settings to only be flexible peer grading --- plugins/course-apps/ora_settings/Settings.jsx | 195 ++++++++++++++---- .../ora_settings/Settings.test.jsx | 171 ++++++++++++--- .../__snapshots__/Settings.test.jsx.snap | 47 ----- .../ora_settings/factories/mockData.js | 32 +++ plugins/course-apps/ora_settings/messages.js | 34 ++- plugins/course-apps/ora_settings/package.json | 1 + src/pages-and-resources/data/api.js | 5 +- src/pages-and-resources/data/thunks.js | 6 +- 8 files changed, 369 insertions(+), 122 deletions(-) delete mode 100644 plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap create mode 100644 plugins/course-apps/ora_settings/factories/mockData.js diff --git a/plugins/course-apps/ora_settings/Settings.jsx b/plugins/course-apps/ora_settings/Settings.jsx index b3e3c0d287..f16d10f48b 100644 --- a/plugins/course-apps/ora_settings/Settings.jsx +++ b/plugins/course-apps/ora_settings/Settings.jsx @@ -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 = ( -
-

{intl.formatMessage(messages.heading)}

-
- - {intl.formatMessage(messages.ORASettingsHelpLink)} - -
-
- ); + 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 && ( + + + {formatMessage(messages.errorSavingTitle)} + + {formatMessage(messages.errorSavingMessage)} + + )} + + {formatMessage(messages.enableFlexPeerGradeLabel)} + {formValues.enableFlexiblePeerGrade && ( + + {formatMessage(messages.enabledBadgeLabel)} + + )} + + )} + helpText={( +
+

{formatMessage(messages.enableFlexPeerGradeHelp)}

+ + + {formatMessage(messages.ORASettingsHelpLink)} + + +
+ )} + onChange={handleChange} + checked={formValues.enableFlexiblePeerGrade} + /> + + ); + case RequestStatus.DENIED: + return ; + case RequestStatus.FAILED: + return ; + default: + return ; + } + }; return ( - - {({ values, handleChange, handleBlur }) => ( - - )} - +
+ + + {formatMessage(messages.heading)} + + + + {renderBody()} + + + + + {formatMessage(messages.cancelLabel)} + + + + +
+ ); }; ORASettings.propTypes = { - intl: intlShape.isRequired, onClose: PropTypes.func.isRequired, }; -export default injectIntl(ORASettings); +export default ORASettings; diff --git a/plugins/course-apps/ora_settings/Settings.test.jsx b/plugins/course-apps/ora_settings/Settings.test.jsx index d74cab9e69..a037e0c438 100644 --- a/plugins/course-apps/ora_settings/Settings.test.jsx +++ b/plugins/course-apps/ora_settings/Settings.test.jsx @@ -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( + + + + + + } /> + + + + + , + ) +); -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(); - 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(); }); }); diff --git a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap b/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap deleted file mode 100644 index 676fae11a9..0000000000 --- a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ORASettings should render 1`] = ` - -

- Configure open response assessment -

-
- - Learn more about open response assessment settings - -
- - } - validationSchema={ - { - "enableFlexiblePeerGrade": "Yub.boolean", - } - } -> - [Function] -
-`; diff --git a/plugins/course-apps/ora_settings/factories/mockData.js b/plugins/course-apps/ora_settings/factories/mockData.js new file mode 100644 index 0000000000..a86ccc6a13 --- /dev/null +++ b/plugins/course-apps/ora_settings/factories/mockData.js @@ -0,0 +1,32 @@ +export const courseId = 'course-v1:org+num+run'; + +export const inititalState = { + courseDetail: { + courseId, + status: 'successful', + }, + pagesAndResources: { + courseAppIds: ['ora_settings'], + loadingStatus: 'in-progress', + savingStatus: '', + courseAppsApiStatus: {}, + courseAppSettings: {}, + }, + models: { + courseApps: { + ora_settings: { + id: 'ora_settings', + name: 'Flexible Peer Grading', + enabled: true, + description: 'Enable flexible peer grading', + allowedOperations: { + enable: false, + configure: true, + }, + documentationLinks: { + learnMoreConfiguration: '', + }, + }, + }, + }, +}; diff --git a/plugins/course-apps/ora_settings/messages.js b/plugins/course-apps/ora_settings/messages.js index 7b05afa5d4..3b119b5660 100644 --- a/plugins/course-apps/ora_settings/messages.js +++ b/plugins/course-apps/ora_settings/messages.js @@ -3,19 +3,51 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ heading: { id: 'course-authoring.pages-resources.ora.heading', - defaultMessage: 'Configure open response assessment', + defaultMessage: 'Configure Flexible Peer Grading', + description: 'Title for the modal dialog header', }, ORASettingsHelpLink: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.link', defaultMessage: 'Learn more about open response assessment settings', + description: 'Descriptive text for the hyperlink to the docs site', }, enableFlexPeerGradeLabel: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.label', defaultMessage: 'Flex Peer Grading', + description: 'Label for form switch', }, 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.', + description: 'Help text describing what happens when the switch is enabled', + }, + enabledBadgeLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label', + defaultMessage: 'Enabled', + description: 'Label for badge that show users that a setting is enabled', + }, + cancelLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label', + defaultMessage: 'Cancel', + description: 'Label for button that cancels user changes', + }, + saveLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label', + defaultMessage: 'Save', + description: 'Label for button that saves user changes', + }, + pendingSaveLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label', + defaultMessage: 'Saving', + description: 'Label for button that has pending api save calls', + }, + errorSavingTitle: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title', + defaultMessage: 'We couldn\'t apply your changes.', + }, + errorSavingMessage: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message', + defaultMessage: 'Please check your entries and try again.', }, }); diff --git a/plugins/course-apps/ora_settings/package.json b/plugins/course-apps/ora_settings/package.json index d6de338820..8cc4bf2243 100644 --- a/plugins/course-apps/ora_settings/package.json +++ b/plugins/course-apps/ora_settings/package.json @@ -8,6 +8,7 @@ "@openedx/paragon": "*", "prop-types": "*", "react": "*", + "react-redux": "*", "yup": "*" }, "peerDependenciesMeta": { diff --git a/src/pages-and-resources/data/api.js b/src/pages-and-resources/data/api.js index ec865f4fd2..92ddc751b2 100644 --- a/src/pages-and-resources/data/api.js +++ b/src/pages-and-resources/data/api.js @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import { snakeCase } from 'lodash/string'; import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform'; @@ -9,8 +8,8 @@ ensureConfig([ ], 'Course Apps API service'); const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getCourseAppsApiUrl = () => `${getApiBaseUrl()}/api/course_apps/v1/apps`; -const getCourseAdvancedSettingsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings`; +export const getCourseAppsApiUrl = () => `${getApiBaseUrl()}/api/course_apps/v1/apps`; +export const getCourseAdvancedSettingsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings`; /** * Fetches the course apps installed for provided course diff --git a/src/pages-and-resources/data/thunks.js b/src/pages-and-resources/data/thunks.js index 06b45459d7..f6384f6d7c 100644 --- a/src/pages-and-resources/data/thunks.js +++ b/src/pages-and-resources/data/thunks.js @@ -78,7 +78,11 @@ export function fetchCourseAppSettings(courseId, settings) { dispatch(fetchCourseAppsSettingsSuccess(settingValues)); dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); + if (error.response && error.response.status === 403) { + dispatch(updateLoadingStatus({ status: RequestStatus.DENIED })); + } else { + dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); + } } }; }