feat: add xpert summaries configuration by default for units (#567)

* feat: add xpert summaries configuration by default for units
This commit is contained in:
German
2023-08-21 16:14:39 -03:00
committed by GitHub
parent b65f4f2b74
commit fda1208660
11 changed files with 359 additions and 21 deletions

View File

@@ -13,3 +13,4 @@
@import "grading-settings/scss/GradingSettings";
@import "generic/styles";
@import "schedule-and-details/ScheduleAndDetails";
@import "pages-and-resources/PagesAndResources";

View File

@@ -0,0 +1 @@
@import "./xpert-unit-summary/settings-modal/SettingsModal";

View File

@@ -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;

View File

@@ -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,

View File

@@ -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}
/>
);

View File

@@ -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 });
});
});
});

View File

@@ -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;
}

View File

@@ -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;
}
};
}

View File

@@ -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 (
<OverlayTrigger
placement="right"
overlay={(
<Tooltip id={`tooltip-reset-${checked}`}>
{intl.formatMessage(messages[messageKey])}
</Tooltip>
)}
>
<StatefulButton
className="reset-units-button"
labels={{
default: intl.formatMessage(messages.resetAllUnits),
pending: '',
finish: intl.formatMessage(messages.reset),
}}
icons={{
default: <Icon src={RotateLeft} />,
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
finish: <Icon src={CheckCircleOutline} />,
}}
state={getResetButtonState()}
onClick={handleResetUnits}
disabledStates={['pending', 'finish']}
variant="outline"
data-testid="reset-units"
/>
</OverlayTrigger>
);
};
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 (
<Formik
initialValues={{
enabled: !!xpertSettings?.enabled,
enabled: xpertSettings?.enabled !== undefined,
checked: xpertSettings?.enabled?.toString() || 'true',
...initialValues,
}}
validationSchema={
Yup.object()
.shape({
enabled: Yup.boolean(),
checked: Yup.string().oneOf(['true', 'false']),
...validationSchema,
})
}
@@ -206,6 +299,7 @@ const SettingsModal = ({
}}
state={submitButtonState}
onClick={handleFormikSubmit(formikProps)}
disabled={!formikProps.dirty}
/>
)}
>
@@ -220,7 +314,7 @@ const SettingsModal = ({
<FormSwitchGroup
id={`enable-${appId}-toggle`}
name="enabled"
onChange={(event) => formikProps.handleChange(event)}
onChange={formikProps.handleChange}
onBlur={formikProps.handleBlur}
checked={formikProps.values.enabled}
label={(
@@ -240,6 +334,41 @@ const SettingsModal = ({
</div>
)}
/>
{(formikProps.values.enabled || configureBeforeEnable) && (
<Form.RadioSet
name="checked"
onChange={formikProps.handleChange}
onBlur={formikProps.handleBlur}
value={formikProps.values.checked}
>
<Form.Radio
className="summary-radio m-2 px-3"
data-testid="enable-radio"
value="true"
>
{allUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'true'}
/>
</Form.Radio>
<Form.Radio
className="summary-radio m-2 px-3"
data-testid="disable-radio"
value="false"
>
{noUnitsEnabledText}
<ResetUnitsButton
intl={intl}
courseId={courseId}
checked={formikProps.values.checked}
visible={formikProps.values.checked === 'false'}
/>
</Form.Radio>
</Form.RadioSet>
)}
{(formikProps.values.enabled || configureBeforeEnable) && children
&& <AppConfigFormDivider marginAdj={{ default: 0, sm: 0 }} />}
<AppSettingsForm formikProps={formikProps} showForm={formikProps.values.enabled || configureBeforeEnable}>
@@ -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,
};

View File

@@ -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;
}

View File

@@ -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.',