diff --git a/src/index.scss b/src/index.scss index 62da213ee..83c5dac16 100755 --- a/src/index.scss +++ b/src/index.scss @@ -13,3 +13,4 @@ @import "grading-settings/scss/GradingSettings"; @import "generic/styles"; @import "schedule-and-details/ScheduleAndDetails"; +@import "pages-and-resources/PagesAndResources"; diff --git a/src/pages-and-resources/PagesAndResources.scss b/src/pages-and-resources/PagesAndResources.scss new file mode 100644 index 000000000..b577fe0f4 --- /dev/null +++ b/src/pages-and-resources/PagesAndResources.scss @@ -0,0 +1 @@ +@import "./xpert-unit-summary/settings-modal/SettingsModal"; diff --git a/src/pages-and-resources/data/selectors.js b/src/pages-and-resources/data/selectors.js index 9e97e10af..c3d93a080 100644 --- a/src/pages-and-resources/data/selectors.js +++ b/src/pages-and-resources/data/selectors.js @@ -6,3 +6,4 @@ export const getCourseAppsApiStatus = (state) => state.pagesAndResources.courseA export const getCourseAppSettingValue = (setting) => (state) => ( state.pagesAndResources.courseAppSettings[setting]?.value ); +export const getResetStatus = (state) => state.pagesAndResources.resetStatus; diff --git a/src/pages-and-resources/data/slice.js b/src/pages-and-resources/data/slice.js index 771e9cf6c..95379cd34 100644 --- a/src/pages-and-resources/data/slice.js +++ b/src/pages-and-resources/data/slice.js @@ -9,6 +9,7 @@ const slice = createSlice({ courseAppIds: [], loadingStatus: RequestStatus.IN_PROGRESS, savingStatus: '', + resetStatus: '', courseAppsApiStatus: {}, courseAppSettings: {}, }, @@ -22,6 +23,9 @@ const slice = createSlice({ updateSavingStatus: (state, { payload }) => { state.savingStatus = payload.status; }, + updateResetStatus: (state, { payload }) => { + state.resetStatus = payload.status; + }, updateCourseAppsApiStatus: (state, { payload }) => { state.courseAppsApiStatus = payload.status; }, @@ -38,6 +42,7 @@ export const { fetchCourseAppsSuccess, updateLoadingStatus, updateSavingStatus, + updateResetStatus, updateCourseAppsApiStatus, fetchCourseAppsSettingsSuccess, updateCourseAppsSettingsSuccess, diff --git a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx index b99e42257..c90d3a22b 100644 --- a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx +++ b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.jsx @@ -44,6 +44,8 @@ const XpertUnitSummarySettings = ({ intl }) => { } enableAppLabel={intl.formatMessage(messages.enableXpertUnitSummaryLabel)} learnMoreText={intl.formatMessage(messages.enableXpertUnitSummaryLink)} + allUnitsEnabledText={intl.formatMessage(messages.allUnitsEnabledByDefault)} + noUnitsEnabledText={intl.formatMessage(messages.noUnitsEnabledByDefault)} onClose={handleClose} /> ); diff --git a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx index c6f2e98b6..bd682d96a 100644 --- a/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx +++ b/src/pages-and-resources/xpert-unit-summary/XpertUnitSummarySettings.test.jsx @@ -112,12 +112,12 @@ describe('XpertUnitSummarySettings', () => { renderComponent(); }); - test('Shows enabled if enabled from backend', async () => { + 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('Does not show enabled if disabled from backend', async () => { + test('Shows switch on if disabled from backend', async () => { axiosMock.onGet(API.getXpertSettingsUrl(courseId)) .reply(200, generateCourseLevelAPIRepsonse({ success: true, @@ -126,8 +126,25 @@ describe('XpertUnitSummarySettings', () => { renderComponent(); 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(); + 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, generateCourseLevelAPIRepsonse({ + success: true, + enabled: false, + })); + + renderComponent(); + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); + expect(queryByTestId(container, 'disable-radio').checked).toBeTruthy(); }); }); @@ -136,7 +153,7 @@ describe('XpertUnitSummarySettings', () => { axiosMock.onGet(API.getXpertSettingsUrl(courseId)) .reply(400, generateCourseLevelAPIRepsonse({ success: false, - enabled: false, + enabled: undefined, })); renderComponent(); @@ -151,6 +168,12 @@ describe('XpertUnitSummarySettings', () => { describe('saving configuration changes', () => { beforeEach(() => { + axiosMock.onGet(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: false, + })); + axiosMock.onPost(API.getXpertSettingsUrl(courseId)) .reply(200, generateCourseLevelAPIRepsonse({ success: true, @@ -164,8 +187,10 @@ describe('XpertUnitSummarySettings', () => { 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')).not.toBeTruthy()); + await waitFor(() => expect(container.querySelector('#enable-xpert-unit-summary-toggle')).toBeTruthy()); expect(API.postXpertSettings).toBeCalled(); }); }); @@ -186,4 +211,78 @@ describe('XpertUnitSummarySettings', () => { expect(API.getXpertPluginConfigurable).toBeCalled(); }); }); + + describe('removing course configuration', () => { + beforeEach(() => { + axiosMock.onGet(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + success: true, + enabled: true, + })); + + axiosMock.onDelete(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + 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, generateCourseLevelAPIRepsonse({ + success: true, + enabled: true, + })); + + axiosMock.onPost(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + 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, generateCourseLevelAPIRepsonse({ + success: true, + enabled: false, + })); + + axiosMock.onPost(API.getXpertSettingsUrl(courseId)) + .reply(200, generateCourseLevelAPIRepsonse({ + 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 }); + }); + }); }); diff --git a/src/pages-and-resources/xpert-unit-summary/data/api.js b/src/pages-and-resources/xpert-unit-summary/data/api.js index b7b489861..32233ac2b 100644 --- a/src/pages-and-resources/xpert-unit-summary/data/api.js +++ b/src/pages-and-resources/xpert-unit-summary/data/api.js @@ -20,6 +20,7 @@ export async function postXpertSettings(courseId, state) { const { data } = await getAuthenticatedHttpClient() .post(getXpertSettingsUrl(courseId), { enabled: state.enabled, + reset: state.reset, }); return data; @@ -31,3 +32,10 @@ export async function getXpertPluginConfigurable(courseId) { return data; } + +export async function deleteXpertSettings(courseId) { + const { data } = await getAuthenticatedHttpClient() + .delete(getXpertSettingsUrl(courseId)); + + return data; +} diff --git a/src/pages-and-resources/xpert-unit-summary/data/thunks.js b/src/pages-and-resources/xpert-unit-summary/data/thunks.js index c10fb7529..b79a36ccb 100644 --- a/src/pages-and-resources/xpert-unit-summary/data/thunks.js +++ b/src/pages-and-resources/xpert-unit-summary/data/thunks.js @@ -1,6 +1,8 @@ -import { getXpertSettings, postXpertSettings, getXpertPluginConfigurable } from './api'; +import { + getXpertSettings, postXpertSettings, getXpertPluginConfigurable, deleteXpertSettings, +} from './api'; -import { updateSavingStatus, updateLoadingStatus } from '../../data/slice'; +import { updateSavingStatus, updateLoadingStatus, updateResetStatus } from '../../data/slice'; import { RequestStatus } from '../../../data/constants'; import { addModel, updateModel } from '../../../generic/model-store'; @@ -27,13 +29,13 @@ export function updateXpertSettings(courseId, state) { export function fetchXpertPluginConfigurable(courseId) { return async (dispatch) => { - let enabled = false; + let enabled; dispatch(updateLoadingStatus({ status: RequestStatus.PENDING })); try { const { response } = await getXpertPluginConfigurable(courseId); enabled = response?.enabled; } catch (e) { - enabled = false; + enabled = undefined; } dispatch(addModel({ @@ -48,14 +50,14 @@ export function fetchXpertPluginConfigurable(courseId) { export function fetchXpertSettings(courseId) { return async (dispatch) => { - let enabled = false; + let enabled; dispatch(updateLoadingStatus({ status: RequestStatus.PENDING })); try { const { response } = await getXpertSettings(courseId); enabled = response?.enabled; } catch (e) { - enabled = false; + enabled = undefined; } dispatch(addModel({ @@ -69,3 +71,44 @@ export function fetchXpertSettings(courseId) { 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; + } + }; +} diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx index b75b7f374..733c09308 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.jsx @@ -4,12 +4,17 @@ import { Alert, Badge, Form, + Icon, ModalDialog, + OverlayTrigger, StatefulButton, + Tooltip, TransitionReplace, Hyperlink, } from '@edx/paragon'; -import { Info } from '@edx/paragon/icons'; +import { + Info, CheckCircleOutline, RotateLeft, SpinnerSimple, +} from '@edx/paragon/icons'; import { Formik } from 'formik'; import PropTypes from 'prop-types'; @@ -26,9 +31,9 @@ import Loading from '../../../generic/Loading'; import { useModel } from '../../../generic/model-store'; import PermissionDeniedAlert from '../../../generic/PermissionDeniedAlert'; import { useIsMobile } from '../../../utils'; -import { getLoadingStatus, getSavingStatus } from '../../data/selectors'; -import { updateSavingStatus } from '../../data/slice'; -import { updateXpertSettings } from '../data/thunks'; +import { getLoadingStatus, getSavingStatus, getResetStatus } from '../../data/selectors'; +import { updateSavingStatus, updateResetStatus } from '../../data/slice'; +import { updateXpertSettings, resetXpertSettings, removeXpertSettings } from '../data/thunks'; import AppConfigFormDivider from '../../discussions/app-config-form/apps/shared/AppConfigFormDivider'; import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider'; import messages from './messages'; @@ -105,6 +110,84 @@ 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 ( + + {intl.formatMessage(messages[messageKey])} + + )} + > + , + pending: , + finish: , + }} + state={getResetButtonState()} + onClick={handleResetUnits} + disabledStates={['pending', 'finish']} + variant="outline" + data-testid="reset-units" + /> + + ); +}; + +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, @@ -119,6 +202,8 @@ const SettingsModal = ({ enableAppHelp, learnMoreText, enableReinitialize, + allUnitsEnabledText, + noUnitsEnabledText, }) => { const { courseId } = useContext(PagesAndResourcesContext); const loadingStatus = useSelector(getLoadingStatus); @@ -139,9 +224,15 @@ const SettingsModal = ({ } }, [updateSettingsRequestStatus]); - const handleFormSubmit = async (values) => { - let success = true; - success = await dispatch(updateXpertSettings(courseId, values)); + 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); @@ -174,13 +265,15 @@ const SettingsModal = ({ return ( )} > @@ -220,7 +314,7 @@ const SettingsModal = ({ formikProps.handleChange(event)} + onChange={formikProps.handleChange} onBlur={formikProps.handleBlur} checked={formikProps.values.enabled} label={( @@ -240,6 +334,41 @@ const SettingsModal = ({ )} /> + {(formikProps.values.enabled || configureBeforeEnable) && ( + + + {allUnitsEnabledText} + + + + {noUnitsEnabledText} + + + + )} {(formikProps.values.enabled || configureBeforeEnable) && children && } @@ -281,6 +410,8 @@ SettingsModal.propTypes = { enableAppLabel: PropTypes.string.isRequired, enableAppHelp: PropTypes.string.isRequired, learnMoreText: PropTypes.string.isRequired, + allUnitsEnabledText: PropTypes.string.isRequired, + noUnitsEnabledText: PropTypes.string.isRequired, configureBeforeEnable: PropTypes.bool, enableReinitialize: PropTypes.bool, }; diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss new file mode 100644 index 000000000..c37d257db --- /dev/null +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/SettingsModal.scss @@ -0,0 +1,31 @@ +.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 { + height: 80px; + } + } +} + +.reset-units-button { + color: $link-color; + border-width: $border-width; + border-color: $border-color; + border-radius: $border-radius; + border-style: solid; + margin-left: auto; +} diff --git a/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js b/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js index b5586e993..5dd2c2f4b 100644 --- a/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js +++ b/src/pages-and-resources/xpert-unit-summary/settings-modal/messages.js @@ -29,6 +29,22 @@ const messages = defineMessages({ 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.',