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:
Stephannie Jimenez
2023-12-18 20:18:11 -05:00
committed by Kristin Aoki
parent 53118a4e0b
commit ffec32cba8
12 changed files with 932 additions and 52 deletions

View File

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

View File

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

View 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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