feat: add subsection configuration modal
test: add render and API tests fix: fix non saving options and add review style changes fix: remove additional tab in the section configuration fix: remove isSubsection state, fix css issues and fix tests fix: add review changes, fix advanced tab hour selection and update tests test: fix failing test in courseOutline.test.jsx fix: remove unused state, add TODO comment, fix stack rendering and NaN values feat: show previous state in autosuggest if an invalid option is provided and fix warnings test: fix failing test
This commit is contained in:
committed by
Kristin Aoki
parent
53118a4e0b
commit
ffec32cba8
@@ -81,7 +81,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureSectionSubmit,
|
||||
handleConfigureSubmit,
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
@@ -339,6 +339,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
onOrderChange={updateSubsectionOrderByIndex(
|
||||
sectionIndex,
|
||||
@@ -428,7 +429,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={closeConfigureModal}
|
||||
onConfigureSubmit={handleConfigureSectionSubmit}
|
||||
onConfigureSubmit={handleConfigureSubmit}
|
||||
/>
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
configureCourseSubsectionQuery,
|
||||
} from './data/thunk';
|
||||
import initializeStore from '../store';
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ import headerMessages from './header-navigations/messages';
|
||||
import cardHeaderMessages from './card-header/messages';
|
||||
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
|
||||
import statusBarMessages from './status-bar/messages';
|
||||
import configureModalMessages from './configure-modal/messages';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -651,6 +653,94 @@ describe('<CourseOutline />', () => {
|
||||
expect(datePicker).toHaveValue('08/10/2025');
|
||||
});
|
||||
|
||||
it('check configure subsection when configure subsection query is successful', async () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByText,
|
||||
findAllByRole,
|
||||
findByRole,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const subsection = section.childInfo.children[0];
|
||||
const newReleaseDate = '2025-08-10T10:00:00Z';
|
||||
const newGraderType = 'Homework';
|
||||
const newDue = '2025-09-10T10:00:00Z';
|
||||
const isTimeLimited = true;
|
||||
const defaultTimeLimitMinutes = 210;
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(subsection.id), {
|
||||
publish: 'republish',
|
||||
graderType: newGraderType,
|
||||
metadata: {
|
||||
visible_to_staff_only: true,
|
||||
due: newDue,
|
||||
hide_after_due: false,
|
||||
show_correctness: false,
|
||||
is_practice_exam: false,
|
||||
is_time_limited: isTimeLimited,
|
||||
exam_review_rules: '',
|
||||
is_proctored_enabled: false,
|
||||
default_time_limit_minutes: defaultTimeLimitMinutes,
|
||||
is_onboarding_exam: false,
|
||||
start: newReleaseDate,
|
||||
},
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
const [currentSection] = await findAllByTestId('section-card');
|
||||
const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card');
|
||||
const subsectionDropdownButton = firstSubsection.querySelector('#subsection-card-header__menu');
|
||||
expect(subsectionDropdownButton).toBeInTheDocument();
|
||||
|
||||
subsection.start = newReleaseDate;
|
||||
subsection.due = newDue;
|
||||
subsection.format = newGraderType;
|
||||
subsection.isTimeLimited = isTimeLimited;
|
||||
subsection.defaultTimeLimitMinutes = defaultTimeLimitMinutes;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
await executeThunk(configureCourseSubsectionQuery(
|
||||
subsection.id,
|
||||
section.id,
|
||||
true,
|
||||
newReleaseDate,
|
||||
newGraderType,
|
||||
newDue,
|
||||
true,
|
||||
defaultTimeLimitMinutes,
|
||||
false,
|
||||
false,
|
||||
), store.dispatch);
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
expect(await findByText(newGraderType)).toBeInTheDocument();
|
||||
const releaseDateStack = await findByTestId('release-date-stack');
|
||||
const releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(releaseDatePicker).toHaveValue('08/10/2025');
|
||||
const dueDateStack = await findByTestId('due-date-stack');
|
||||
const dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(dueDatePicker).toHaveValue('09/10/2025');
|
||||
|
||||
const advancedTab = await findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
const radioButtons = await findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', true);
|
||||
const hours = await findByTestId('hour-autosuggest');
|
||||
expect(hours).toHaveValue('03:30');
|
||||
});
|
||||
|
||||
it('check update highlights when update highlights query is successfully', async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
|
||||
|
||||
112
src/course-outline/configure-modal/AdvancedTab.jsx
Normal file
112
src/course-outline/configure-modal/AdvancedTab.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Form } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const timeLimits = Array.from({ length: 48 }, (_, i) => 30 + 30 * i);
|
||||
|
||||
const AdvancedTab = ({
|
||||
isTimeLimited,
|
||||
setIsTimeLimited,
|
||||
defaultTimeLimit,
|
||||
setDefaultTimeLimit,
|
||||
}) => {
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
const handleChange = (e) => {
|
||||
if (e.target.value === 'timed') {
|
||||
setIsTimeLimited(true);
|
||||
} else {
|
||||
setDefaultTimeLimit(null);
|
||||
setIsTimeLimited(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setCurrentTimeLimit = (valueString) => {
|
||||
const value = valueString.split(':');
|
||||
setDefaultTimeLimit(Number.parseInt(value[0], 10) * 60 + Number.parseInt(value[1], 10));
|
||||
};
|
||||
|
||||
const formatHour = (hour) => {
|
||||
const hh = Math.floor(hour / 60);
|
||||
const mm = hour % 60;
|
||||
let hhs = `${hh}`;
|
||||
let mms = `${mm}`;
|
||||
if (hh < 10) {
|
||||
hhs = `0${hh}`;
|
||||
}
|
||||
if (mm < 10) {
|
||||
mms = `0${mm}`;
|
||||
}
|
||||
if (Number.isNaN(hh)) {
|
||||
hhs = '00';
|
||||
}
|
||||
if (Number.isNaN(mm)) {
|
||||
mms = '00';
|
||||
}
|
||||
return `${hhs}:${mms}`;
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
const isValid = /^\d\d:\d\d$/.test(e.target.value);
|
||||
if (isValid) {
|
||||
setCurrentTimeLimit(e.target.value);
|
||||
} else {
|
||||
// Ensure the component re-renders and reset the value to the previous state
|
||||
setKey(key + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const generateTimeLimits = () => timeLimits.map(
|
||||
(hour) => (
|
||||
<Form.AutosuggestOption key={hour}>
|
||||
{formatHour(hour)}
|
||||
</Form.AutosuggestOption>
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.setSpecialExam} /></h5>
|
||||
<hr />
|
||||
<Form.RadioSet
|
||||
name="specialExam"
|
||||
onChange={handleChange}
|
||||
value={isTimeLimited ? 'timed' : 'none'}
|
||||
>
|
||||
<Form.Radio value="none">
|
||||
<FormattedMessage {...messages.none} />
|
||||
</Form.Radio>
|
||||
<Form.Radio value="timed">
|
||||
<FormattedMessage {...messages.timed} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.timedDescription} /></Form.Text>
|
||||
</Form.RadioSet>
|
||||
{ isTimeLimited && (
|
||||
<>
|
||||
<h6 className="mt-4 text-gray-700"><FormattedMessage {...messages.timeAllotted} /></h6>
|
||||
<Form.Autosuggest
|
||||
key={key}
|
||||
value={defaultTimeLimit === null ? formatHour(timeLimits[0]) : formatHour(defaultTimeLimit)}
|
||||
onBlur={handleBlur}
|
||||
onSelected={setCurrentTimeLimit}
|
||||
data-testid="hour-autosuggest"
|
||||
>
|
||||
{generateTimeLimits()}
|
||||
</Form.Autosuggest>
|
||||
<Form.Text><FormattedMessage {...messages.timeLimitDescription} /></Form.Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedTab.propTypes = {
|
||||
isTimeLimited: PropTypes.bool.isRequired,
|
||||
setIsTimeLimited: PropTypes.func.isRequired,
|
||||
defaultTimeLimit: PropTypes.number.isRequired,
|
||||
setDefaultTimeLimit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AdvancedTab);
|
||||
@@ -1,36 +1,87 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Stack } from '@edx/paragon';
|
||||
import { Stack, Form } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control';
|
||||
|
||||
const BasicTab = ({ releaseDate, setReleaseDate }) => {
|
||||
const BasicTab = ({
|
||||
releaseDate,
|
||||
setReleaseDate,
|
||||
isSubsection,
|
||||
graderType,
|
||||
courseGraders,
|
||||
setGraderType,
|
||||
dueDate,
|
||||
setDueDate,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const onChange = (value) => {
|
||||
setReleaseDate(value);
|
||||
};
|
||||
|
||||
const onChangeGraderType = (e) => setGraderType(e.target.value);
|
||||
|
||||
const createOptions = () => courseGraders.map((option) => (
|
||||
<option key={option} value={option}> {option} </option>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mt-3"><FormattedMessage {...messages.releaseDateAndTime} /></h3>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.releaseDateAndTime} /></h5>
|
||||
<hr />
|
||||
<Stack direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseDate)}
|
||||
controlName="state-date"
|
||||
onChange={(date) => onChange(date)}
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={(date) => onChange(date)}
|
||||
/>
|
||||
</Stack>
|
||||
<div data-testid="release-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseDate)}
|
||||
controlName="state-date"
|
||||
onChange={setReleaseDate}
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={setReleaseDate}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
{
|
||||
isSubsection && (
|
||||
<div>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.grading} /></h5>
|
||||
<hr />
|
||||
<Form.Label><FormattedMessage {...messages.gradeAs} /></Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
defaultValue={graderType}
|
||||
onChange={(value) => onChangeGraderType(value)}
|
||||
data-testid="grader-type-select"
|
||||
>
|
||||
<option key="notGraded" value="Not Graded"> Not Graded </option>
|
||||
{createOptions()}
|
||||
</Form.Control>
|
||||
<div data-testid="due-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueDate)}
|
||||
controlName="state-date"
|
||||
onChange={setDueDate}
|
||||
data-testid="due-date-picker"
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={setDueDate}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -38,6 +89,12 @@ const BasicTab = ({ releaseDate, setReleaseDate }) => {
|
||||
BasicTab.propTypes = {
|
||||
releaseDate: PropTypes.string.isRequired,
|
||||
setReleaseDate: PropTypes.func.isRequired,
|
||||
isSubsection: PropTypes.bool.isRequired,
|
||||
graderType: PropTypes.string.isRequired,
|
||||
setGraderType: PropTypes.func.isRequired,
|
||||
dueDate: PropTypes.string.isRequired,
|
||||
setDueDate: PropTypes.func.isRequired,
|
||||
courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BasicTab);
|
||||
|
||||
@@ -16,6 +16,8 @@ import { getCurrentItem } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
import BasicTab from './BasicTab';
|
||||
import VisibilityTab from './VisibilityTab';
|
||||
import AdvancedTab from './AdvancedTab';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
|
||||
const ConfigureModal = ({
|
||||
isOpen,
|
||||
@@ -23,33 +25,195 @@ const ConfigureModal = ({
|
||||
onConfigureSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { displayName, start: sectionStartDate, visibilityState } = useSelector(getCurrentItem);
|
||||
const {
|
||||
displayName,
|
||||
start: sectionStartDate,
|
||||
visibilityState,
|
||||
due,
|
||||
isTimeLimited,
|
||||
defaultTimeLimitMinutes,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
courseGraders,
|
||||
category,
|
||||
format,
|
||||
} = useSelector(getCurrentItem);
|
||||
const [releaseDate, setReleaseDate] = useState(sectionStartDate);
|
||||
const [isVisibleToStaffOnly, setIsVisibleToStaffOnly] = useState(visibilityState === VisibilityTypes.STAFF_ONLY);
|
||||
const [saveButtonDisabled, setSaveButtonDisabled] = useState(true);
|
||||
const [graderType, setGraderType] = useState(format == null ? 'Not Graded' : format);
|
||||
const [dueDateState, setDueDateState] = useState(due == null ? '' : due);
|
||||
const [isTimeLimitedState, setIsTimeLimitedState] = useState(false);
|
||||
const [defaultTimeLimitMin, setDefaultTimeLimitMin] = useState(defaultTimeLimitMinutes);
|
||||
const [hideAfterDueState, setHideAfterDueState] = useState(hideAfterDue === undefined ? false : hideAfterDue);
|
||||
const [showCorrectnessState, setShowCorrectnessState] = useState(false);
|
||||
const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id;
|
||||
|
||||
/* TODO: The use of these useEffects needs to be updated to use Formik, please see,
|
||||
* https://github.com/open-craft/frontend-app-course-authoring/pull/22#discussion_r1435957797 as reference. */
|
||||
useEffect(() => {
|
||||
setReleaseDate(sectionStartDate);
|
||||
}, [sectionStartDate]);
|
||||
|
||||
useEffect(() => {
|
||||
setGraderType(format == null ? 'Not Graded' : format);
|
||||
}, [format]);
|
||||
|
||||
useEffect(() => {
|
||||
setDueDateState(due == null ? '' : due);
|
||||
}, [due]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTimeLimitedState(isTimeLimited);
|
||||
}, [isTimeLimited]);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultTimeLimitMin(defaultTimeLimitMinutes);
|
||||
}, [defaultTimeLimitMinutes]);
|
||||
|
||||
useEffect(() => {
|
||||
setHideAfterDueState(hideAfterDue === undefined ? false : hideAfterDue);
|
||||
}, [hideAfterDue]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowCorrectnessState(showCorrectness);
|
||||
}, [showCorrectness]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY);
|
||||
}, [visibilityState]);
|
||||
|
||||
useEffect(() => {
|
||||
const visibilityUnchanged = isVisibleToStaffOnly === (visibilityState === VisibilityTypes.STAFF_ONLY);
|
||||
setSaveButtonDisabled(visibilityUnchanged && releaseDate === sectionStartDate);
|
||||
}, [releaseDate, isVisibleToStaffOnly]);
|
||||
const graderTypeUnchanged = graderType === (format == null ? 'Not Graded' : format);
|
||||
const dueDateUnchanged = dueDateState === (due == null ? '' : due);
|
||||
const hideAfterDueUnchanged = hideAfterDueState === (hideAfterDue === undefined ? false : hideAfterDue);
|
||||
setSaveButtonDisabled(
|
||||
visibilityUnchanged
|
||||
&& releaseDate === sectionStartDate
|
||||
&& dueDateUnchanged
|
||||
&& isTimeLimitedState === isTimeLimited
|
||||
&& defaultTimeLimitMin === defaultTimeLimitMinutes
|
||||
&& hideAfterDueUnchanged
|
||||
&& showCorrectnessState === showCorrectness
|
||||
&& graderTypeUnchanged,
|
||||
);
|
||||
}, [
|
||||
releaseDate,
|
||||
isVisibleToStaffOnly,
|
||||
dueDateState,
|
||||
isTimeLimitedState,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDueState,
|
||||
showCorrectnessState,
|
||||
graderType,
|
||||
]);
|
||||
|
||||
const handleSave = () => {
|
||||
onConfigureSubmit(isVisibleToStaffOnly, releaseDate);
|
||||
if (isSubsection) {
|
||||
onConfigureSubmit(
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType === 'Not Graded' ? 'notgraded' : graderType,
|
||||
dueDateState,
|
||||
isTimeLimitedState,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDueState,
|
||||
showCorrectnessState,
|
||||
);
|
||||
} else {
|
||||
onConfigureSubmit(isVisibleToStaffOnly, releaseDate);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setReleaseDate(sectionStartDate);
|
||||
setDueDateState(due);
|
||||
setIsTimeLimitedState(isTimeLimited);
|
||||
setDefaultTimeLimitMin(defaultTimeLimitMinutes);
|
||||
setHideAfterDueState(hideAfterDue);
|
||||
setShowCorrectnessState(showCorrectness);
|
||||
setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY);
|
||||
setGraderType(format);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const createTabs = () => {
|
||||
if (isSubsection) {
|
||||
return (
|
||||
<Tabs>
|
||||
<Tab eventKey="basic" title={intl.formatMessage(messages.basicTabTitle)}>
|
||||
<BasicTab
|
||||
releaseDate={releaseDate}
|
||||
setReleaseDate={setReleaseDate}
|
||||
isSubsection={isSubsection}
|
||||
graderType={graderType}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
setGraderType={setGraderType}
|
||||
dueDate={dueDateState}
|
||||
setDueDate={setDueDateState}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
<VisibilityTab
|
||||
category={category}
|
||||
isSubsection={isSubsection}
|
||||
isVisibleToStaffOnly={isVisibleToStaffOnly}
|
||||
setIsVisibleToStaffOnly={setIsVisibleToStaffOnly}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||
hideAfterDue={hideAfterDueState}
|
||||
setHideAfterDue={setHideAfterDueState}
|
||||
showCorrectness={showCorrectnessState}
|
||||
setShowCorrectness={setShowCorrectnessState}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="advanced" title={intl.formatMessage(messages.advancedTabTitle)}>
|
||||
<AdvancedTab
|
||||
isTimeLimited={isTimeLimitedState}
|
||||
setIsTimeLimited={setIsTimeLimitedState}
|
||||
defaultTimeLimit={defaultTimeLimitMin}
|
||||
setDefaultTimeLimit={setDefaultTimeLimitMin}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tabs>
|
||||
<Tab eventKey="basic" title={intl.formatMessage(messages.basicTabTitle)}>
|
||||
<BasicTab
|
||||
releaseDate={releaseDate}
|
||||
setReleaseDate={setReleaseDate}
|
||||
isSubsection={isSubsection}
|
||||
graderType={graderType}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
setGraderType={setGraderType}
|
||||
dueDate={dueDateState}
|
||||
setDueDate={setDueDateState}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
<VisibilityTab
|
||||
category={category}
|
||||
isSubsection={isSubsection}
|
||||
isVisibleToStaffOnly={isVisibleToStaffOnly}
|
||||
setIsVisibleToStaffOnly={setIsVisibleToStaffOnly}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||
hideAfterDue={hideAfterDueState}
|
||||
setHideAfterDue={setHideAfterDueState}
|
||||
showCorrectness={showCorrectnessState}
|
||||
setShowCorrectness={setShowCorrectnessState}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
className="configure-modal"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
@@ -58,19 +222,8 @@ const ConfigureModal = ({
|
||||
{intl.formatMessage(messages.title, { title: displayName })}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body className="configure-modal__body">
|
||||
<Tabs>
|
||||
<Tab eventKey="basic" title={intl.formatMessage(messages.basicTabTitle)}>
|
||||
<BasicTab releaseDate={releaseDate} setReleaseDate={setReleaseDate} />
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
<VisibilityTab
|
||||
isVisibleToStaffOnly={isVisibleToStaffOnly}
|
||||
setIsVisibleToStaffOnly={setIsVisibleToStaffOnly}
|
||||
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<ModalDialog.Body className={!isSubsection ? 'configure-modal__body' : ''}>
|
||||
{createTabs()}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="pt-1">
|
||||
<ActionRow>
|
||||
|
||||
@@ -30,12 +30,25 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
const currentSectionMock = {
|
||||
displayName: 'Section1',
|
||||
category: 'chapter',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
format: 'Not Graded',
|
||||
childInfo: {
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
displayName: 'Subsection 1',
|
||||
id: 1,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
@@ -49,6 +62,15 @@ const currentSectionMock = {
|
||||
{
|
||||
displayName: 'Subsection 2',
|
||||
id: 2,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
@@ -62,6 +84,15 @@ const currentSectionMock = {
|
||||
{
|
||||
displayName: 'Subsection 3',
|
||||
id: 3,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
children: [],
|
||||
},
|
||||
@@ -117,7 +148,7 @@ describe('<ConfigureModal />', () => {
|
||||
|
||||
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
expect(getByText(messages.sectionVisibility.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText('Section Visibility')).toBeInTheDocument();
|
||||
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -137,3 +168,118 @@ describe('<ConfigureModal />', () => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
const currentSubsectionMock = {
|
||||
displayName: 'Subsection 1',
|
||||
id: 1,
|
||||
category: 'sequential',
|
||||
due: '',
|
||||
start: '2025-08-10T10:00:00Z',
|
||||
visibilityState: true,
|
||||
defaultTimeLimitMinutes: null,
|
||||
hideAfterDue: false,
|
||||
showCorrectness: false,
|
||||
format: 'Homework',
|
||||
courseGraders: ['Homework', 'Exam'],
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
displayName: 'Subsection_1 Unit 1',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
displayName: 'Subsection_1 Unit 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const renderSubsectionComponent = () => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<ConfigureModal />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentSubsectionMock);
|
||||
});
|
||||
|
||||
it('renders subsection ConfigureModal component correctly', () => {
|
||||
const { getByText, getByRole } = renderSubsectionComponent();
|
||||
expect(getByText(`${currentSubsectionMock.displayName} Settings`)).toBeInTheDocument();
|
||||
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.advancedTabTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.grading.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.gradeAs.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.dueDate.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.dueTimeUTC.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the subsection Visibility tab and renders correctly', () => {
|
||||
const { getByRole, getByText } = renderSubsectionComponent();
|
||||
|
||||
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
expect(getByText('Subsection Visibility')).toBeInTheDocument();
|
||||
expect(getByText(messages.showEntireSubsection.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.showEntireSubsectionDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideContentAfterDue.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideContentAfterDueDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideEntireSubsection.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideEntireSubsectionDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.assessmentResultsVisibility.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alwaysShowAssessmentResults.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.alwaysShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.neverShowAssessmentResults.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.neverShowAssessmentResultsDescription.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.showAssessmentResultsPastDue.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.showAssessmentResultsPastDueDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the subsection Advanced tab and renders correctly', () => {
|
||||
const { getByRole, getByText } = renderSubsectionComponent();
|
||||
|
||||
const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
expect(getByText(messages.setSpecialExam.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.none.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.timed.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.timedDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the Save button and enables it if there is a change', () => {
|
||||
const { getByRole, getByTestId } = renderSubsectionComponent();
|
||||
|
||||
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const input = getByTestId('grader-type-select');
|
||||
fireEvent.change(input, { target: { value: 'Exam' } });
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,108 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Form } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
|
||||
const VisibilityTab = ({ isVisibleToStaffOnly, setIsVisibleToStaffOnly, showWarning }) => {
|
||||
const VisibilityTab = ({
|
||||
category,
|
||||
isVisibleToStaffOnly,
|
||||
setIsVisibleToStaffOnly,
|
||||
showWarning,
|
||||
isSubsection,
|
||||
hideAfterDue,
|
||||
setHideAfterDue,
|
||||
showCorrectness,
|
||||
setShowCorrectness,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name;
|
||||
const handleChange = (e) => {
|
||||
setIsVisibleToStaffOnly(e.target.checked);
|
||||
};
|
||||
|
||||
const getVisibilityValue = () => {
|
||||
if (isVisibleToStaffOnly) {
|
||||
return 'hide';
|
||||
}
|
||||
if (hideAfterDue) {
|
||||
return 'hideDue';
|
||||
}
|
||||
return 'show';
|
||||
};
|
||||
|
||||
const visibilityChanged = (e) => {
|
||||
const selected = e.target.value;
|
||||
if (selected === 'hide') {
|
||||
setIsVisibleToStaffOnly(true);
|
||||
setHideAfterDue(false);
|
||||
} else if (selected === 'hideDue') {
|
||||
setIsVisibleToStaffOnly(false);
|
||||
setHideAfterDue(true);
|
||||
} else {
|
||||
setIsVisibleToStaffOnly(false);
|
||||
setHideAfterDue(false);
|
||||
}
|
||||
};
|
||||
|
||||
const correctnessChanged = (e) => {
|
||||
setShowCorrectness(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mt-3"><FormattedMessage {...messages.sectionVisibility} /></h3>
|
||||
<h5 className="mt-4 text-gray-700">
|
||||
{intl.formatMessage(messages.visibilitySectionTitle, { visibilityTitle })}
|
||||
</h5>
|
||||
<hr />
|
||||
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="visibility-checkbox">
|
||||
<FormattedMessage {...messages.hideFromLearners} />
|
||||
</Form.Checkbox>
|
||||
{
|
||||
isSubsection ? (
|
||||
<>
|
||||
<Form.RadioSet
|
||||
name="subsectionVisibility"
|
||||
onChange={visibilityChanged}
|
||||
value={getVisibilityValue()}
|
||||
>
|
||||
<Form.Radio value="show">
|
||||
<FormattedMessage {...messages.showEntireSubsection} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.showEntireSubsectionDescription} /></Form.Text>
|
||||
<Form.Radio value="hideDue">
|
||||
<FormattedMessage {...messages.hideContentAfterDue} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.hideContentAfterDueDescription} /></Form.Text>
|
||||
<Form.Radio value="hide">
|
||||
<FormattedMessage {...messages.hideEntireSubsection} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.hideEntireSubsectionDescription} /></Form.Text>
|
||||
</Form.RadioSet>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.assessmentResultsVisibility} /></h5>
|
||||
<Form.RadioSet
|
||||
name="assessmentResultsVisibility"
|
||||
onChange={correctnessChanged}
|
||||
value={showCorrectness}
|
||||
>
|
||||
<Form.Radio value="always">
|
||||
<FormattedMessage {...messages.alwaysShowAssessmentResults} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.alwaysShowAssessmentResultsDescription} /></Form.Text>
|
||||
<Form.Radio value="never">
|
||||
<FormattedMessage {...messages.neverShowAssessmentResults} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.neverShowAssessmentResultsDescription} /></Form.Text>
|
||||
<Form.Radio value="past_due">
|
||||
<FormattedMessage {...messages.showAssessmentResultsPastDue} />
|
||||
</Form.Radio>
|
||||
<Form.Text><FormattedMessage {...messages.showAssessmentResultsPastDueDescription} /></Form.Text>
|
||||
</Form.RadioSet>
|
||||
</>
|
||||
) : (
|
||||
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="visibility-checkbox">
|
||||
<FormattedMessage {...messages.hideFromLearners} />
|
||||
</Form.Checkbox>
|
||||
)
|
||||
}
|
||||
{showWarning && (
|
||||
<>
|
||||
<hr />
|
||||
@@ -30,9 +117,15 @@ const VisibilityTab = ({ isVisibleToStaffOnly, setIsVisibleToStaffOnly, showWarn
|
||||
};
|
||||
|
||||
VisibilityTab.propTypes = {
|
||||
category: PropTypes.string.isRequired,
|
||||
isVisibleToStaffOnly: PropTypes.bool.isRequired,
|
||||
showWarning: PropTypes.bool.isRequired,
|
||||
setIsVisibleToStaffOnly: PropTypes.func.isRequired,
|
||||
isSubsection: PropTypes.bool.isRequired,
|
||||
hideAfterDue: PropTypes.bool.isRequired,
|
||||
setHideAfterDue: PropTypes.func.isRequired,
|
||||
showCorrectness: PropTypes.string.isRequired,
|
||||
setShowCorrectness: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(VisibilityTab);
|
||||
|
||||
@@ -25,9 +25,9 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.title',
|
||||
defaultMessage: 'Visibility',
|
||||
},
|
||||
sectionVisibility: {
|
||||
visibilitySectionTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility',
|
||||
defaultMessage: 'Section Visibility',
|
||||
defaultMessage: '{visibilityTitle} Visibility',
|
||||
},
|
||||
hideFromLearners: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-from-learners',
|
||||
@@ -45,6 +45,106 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.configure-modal.button.label',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
grading: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.grading',
|
||||
defaultMessage: 'Grading',
|
||||
},
|
||||
gradeAs: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.grade-as',
|
||||
defaultMessage: 'Grade as:',
|
||||
},
|
||||
dueDate: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-date',
|
||||
defaultMessage: 'Due Date:',
|
||||
},
|
||||
dueTimeUTC: {
|
||||
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-time-UTC',
|
||||
defaultMessage: 'Due Time in UTC:',
|
||||
},
|
||||
subsectionVisibility: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.subsection-visibility',
|
||||
defaultMessage: 'Subsection Visibility',
|
||||
},
|
||||
showEntireSubsection: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection',
|
||||
defaultMessage: 'Show entire subsection',
|
||||
},
|
||||
showEntireSubsectionDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection-description',
|
||||
defaultMessage: 'Learners see the published subsection and can access its content',
|
||||
},
|
||||
hideContentAfterDue: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due',
|
||||
defaultMessage: 'Hide content after due date',
|
||||
},
|
||||
hideContentAfterDueDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-content-after-due-description',
|
||||
defaultMessage: 'After the subsection\'s due date has passed, learners can no longer access its content. The subsection is not included in grade calculations.',
|
||||
},
|
||||
hideEntireSubsection: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection',
|
||||
defaultMessage: 'Hide entire subsection',
|
||||
},
|
||||
hideEntireSubsectionDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-entire-subsection-description',
|
||||
defaultMessage: 'Learners do not see the subsection in the course outline. The subsection is not included in grade calculations.',
|
||||
},
|
||||
assessmentResultsVisibility: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.assessment-results-visibility',
|
||||
defaultMessage: 'Assessment Results Visibility',
|
||||
},
|
||||
alwaysShowAssessmentResults: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results',
|
||||
defaultMessage: 'Always show assessment results',
|
||||
},
|
||||
alwaysShowAssessmentResultsDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.always-show-assessment-results-description',
|
||||
defaultMessage: 'When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.',
|
||||
},
|
||||
neverShowAssessmentResults: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results',
|
||||
defaultMessage: 'Never show assessment results',
|
||||
},
|
||||
neverShowAssessmentResultsDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.never-show-assessment-results-description',
|
||||
defaultMessage: 'Learners never see whether their answers to assessments are correct or incorrect, nor the score received.',
|
||||
},
|
||||
showAssessmentResultsPastDue: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due',
|
||||
defaultMessage: 'Show assessment results when subsection is past due',
|
||||
},
|
||||
showAssessmentResultsPastDueDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-assessment-results-past-due-description',
|
||||
defaultMessage: 'Learners do not see whether their answer to assessments were correct or incorrect, nor the score received, until after the due date for the subsection has passed. If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.',
|
||||
},
|
||||
setSpecialExam: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.set-special-exam',
|
||||
defaultMessage: 'Set as a Special Exam',
|
||||
},
|
||||
none: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.none',
|
||||
defaultMessage: 'None',
|
||||
},
|
||||
timed: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed',
|
||||
defaultMessage: 'Timed',
|
||||
},
|
||||
timedDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description',
|
||||
defaultMessage: 'Use a timed exam to limit the time learners can spend on problems in this subsection. Learners must submit answers before the time expires. You can allow additional time for individual learners through the instructor Dashboard.',
|
||||
},
|
||||
advancedTabTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.title',
|
||||
defaultMessage: 'Advanced',
|
||||
},
|
||||
timeAllotted: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted',
|
||||
defaultMessage: 'Time Allotted (HH:MM):',
|
||||
},
|
||||
timeLimitDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description',
|
||||
defaultMessage: 'Select a time allotment for the exam. If it is over 24 hours, type in the amount of time. You can grant individual learners extra time to complete the exam through the Instructor Dashboard.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -234,6 +234,52 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure course section
|
||||
* @param {string} itemId
|
||||
* @param {string} isVisibleToStaffOnly
|
||||
* @param {string} releaseDate
|
||||
* @param {string} graderType
|
||||
* @param {string} dueDateState
|
||||
* @param {string} isTimeLimitedState
|
||||
* @param {string} defaultTimeLimitMin
|
||||
* @param {string} hideAfterDueState
|
||||
* @param {string} showCorrectnessState
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDateState,
|
||||
isTimeLimitedState,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDueState,
|
||||
showCorrectnessState,
|
||||
) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
publish: 'republish',
|
||||
graderType,
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
due: dueDateState,
|
||||
hide_after_due: hideAfterDueState,
|
||||
show_correctness: showCorrectnessState,
|
||||
is_practice_exam: false,
|
||||
is_time_limited: isTimeLimitedState,
|
||||
exam_review_rules: '',
|
||||
is_proctored_enabled: false,
|
||||
default_time_limit_minutes: defaultTimeLimitMin,
|
||||
is_onboarding_exam: false,
|
||||
start: releaseDate,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit course section
|
||||
* @param {string} itemId
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getCourseItem,
|
||||
publishCourseSection,
|
||||
configureCourseSection,
|
||||
configureCourseSubsection,
|
||||
restartIndexingOnCourse,
|
||||
updateCourseSectionHighlights,
|
||||
setSectionOrderList,
|
||||
@@ -241,6 +242,47 @@ export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, sta
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSubsectionQuery(
|
||||
itemId,
|
||||
sectionId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDateState,
|
||||
isTimeLimitedState,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDueState,
|
||||
showCorrectnessState,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDateState,
|
||||
isTimeLimitedState,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDueState,
|
||||
showCorrectnessState,
|
||||
).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
publishCourseItemQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
configureCourseSectionQuery,
|
||||
configureCourseSubsectionQuery,
|
||||
setSectionOrderListQuery,
|
||||
setVideoSharingOptionQuery,
|
||||
setSubsectionOrderListQuery,
|
||||
@@ -146,7 +147,43 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const handleConfigureSectionSubmit = (isVisibleToStaffOnly, startDatetime) => {
|
||||
dispatch(configureCourseSectionQuery(currentSection.id, isVisibleToStaffOnly, startDatetime));
|
||||
};
|
||||
|
||||
const handleConfigureSubsectionSubmit = (
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDateState,
|
||||
isTimeLimitedState,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDueState,
|
||||
showCorrectnessState,
|
||||
) => {
|
||||
dispatch(configureCourseSubsectionQuery(
|
||||
currentItem.id,
|
||||
currentSection.id,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDateState,
|
||||
isTimeLimitedState,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDueState,
|
||||
showCorrectnessState,
|
||||
));
|
||||
};
|
||||
|
||||
const handleConfigureSubmit = (...args) => {
|
||||
switch (currentItem.category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
handleConfigureSectionSubmit(...args);
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
handleConfigureSubsectionSubmit(...args);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
closeConfigureModal();
|
||||
};
|
||||
|
||||
@@ -238,7 +275,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
headerNavigationsActions,
|
||||
handleEnableHighlightsSubmit,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureSectionSubmit,
|
||||
handleConfigureSubmit,
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
statusBarData,
|
||||
|
||||
@@ -28,6 +28,7 @@ const SubsectionCard = ({
|
||||
onDuplicateSubmit,
|
||||
onNewUnitSubmit,
|
||||
onOrderChange,
|
||||
onOpenConfigureModal,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
@@ -145,6 +146,7 @@ const SubsectionCard = ({
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleSubsectionMoveUp}
|
||||
onClickMoveDown={handleSubsectionMoveDown}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
@@ -218,6 +220,7 @@ SubsectionCard.propTypes = {
|
||||
index: PropTypes.number.isRequired,
|
||||
canMoveItem: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
onOpenConfigureModal: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SubsectionCard;
|
||||
|
||||
Reference in New Issue
Block a user