feat: add Section Configure

This commit is contained in:
Moncef Abboud
2023-11-30 19:00:06 +01:00
committed by Kristin Aoki
parent 134b75568a
commit 7286b21f5a
15 changed files with 502 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ import SectionCard from './section-card/SectionCard';
import HighlightsModal from './highlights-modal/HighlightsModal';
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
import PublishModal from './publish-modal/PublishModal';
import ConfigureModal from './configure-modal/ConfigureModal';
import DeleteModal from './delete-modal/DeleteModal';
import { useCourseOutline } from './hooks';
import messages from './messages';
@@ -52,11 +53,14 @@ const CourseOutline = ({ courseId }) => {
isDisabledReindexButton,
isHighlightsModalOpen,
isPublishModalOpen,
isConfigureModalOpen,
isDeleteModalOpen,
closeHighlightsModal,
closePublishModal,
closeConfigureModal,
closeDeleteModal,
openPublishModal,
openConfigureModal,
openDeleteModal,
headerNavigationsActions,
openEnableHighlightsModal,
@@ -66,6 +70,7 @@ const CourseOutline = ({ courseId }) => {
handleOpenHighlightsModal,
handleHighlightsFormSubmit,
handlePublishSectionSubmit,
handleConfigureSectionSubmit,
handleEditSectionSubmit,
handleDeleteSectionSubmit,
handleDuplicateSectionSubmit,
@@ -145,6 +150,7 @@ const CourseOutline = ({ courseId }) => {
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSectionSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
@@ -190,6 +196,11 @@ const CourseOutline = ({ courseId }) => {
onClose={closePublishModal}
onPublishSubmit={handlePublishSectionSubmit}
/>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={closeConfigureModal}
onConfigureSubmit={handleConfigureSectionSubmit}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
close={closeDeleteModal}

View File

@@ -5,3 +5,4 @@
@import "./empty-placeholder/EmptyPlaceholder";
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./configure-modal/ConfigureModal";

View File

@@ -44,6 +44,7 @@ import { executeThunk } from '../utils';
import CourseOutline from './CourseOutline';
import messages from './messages';
import headerMessages from './header-navigations/messages';
import cardHeaderMessages from './card-header/messages';
let axiosMock;
let store;
@@ -324,6 +325,55 @@ describe('<CourseOutline />', () => {
expect(firstSection.querySelector('.section-card-header__badge-status')).toHaveTextContent('Published not live');
});
it('check configure section when configure query is successful', async () => {
cleanup();
const { getAllByTestId, getByText, getByPlaceholderText } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const newReleaseDate = '2025-08-10T10:00:00Z';
axiosMock
.onPost(getUpdateCourseSectionApiUrl(section.id), {
id: section.id,
data: null,
metadata: {
display_name: section.displayName,
start: newReleaseDate,
visible_to_staff_only: true,
},
})
.reply(200);
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, section);
await executeThunk(fetchCourseSectionQuery(section.id), store.dispatch);
const firstSection = getAllByTestId('section-card')[0];
const sectionDropdownButton = firstSection.querySelector('#section-card-header__menu');
expect(sectionDropdownButton).toBeInTheDocument();
fireEvent.click(sectionDropdownButton);
const configureBtn = getByText(cardHeaderMessages.menuConfigure.defaultMessage);
fireEvent.click(configureBtn);
const datePicker = getByPlaceholderText('MM/DD/YYYY');
fireEvent.change(datePicker, { target: { value: '08/10/2025' } });
axiosMock
.onGet(getXBlockApiUrl(section.id))
.reply(200, {
...section,
start: newReleaseDate,
});
fireEvent.click(getByText('Save'));
fireEvent.click(sectionDropdownButton);
fireEvent.click(configureBtn);
expect(datePicker).toHaveValue('08/10/2025');
});
it('check update highlights when update highlights query is successfully', async () => {
const { getByRole } = render(<RootWrapper />);

View File

@@ -30,6 +30,7 @@ const CardHeader = ({
hasChanges,
isExpanded,
onClickPublish,
onClickConfigure,
onClickMenuButton,
onClickEdit,
onExpand,
@@ -136,7 +137,7 @@ const CardHeader = ({
>
{intl.formatMessage(messages.menuPublish)}
</Dropdown.Item>
<Dropdown.Item>{intl.formatMessage(messages.menuConfigure)}</Dropdown.Item>
<Dropdown.Item onClick={onClickConfigure}>{intl.formatMessage(messages.menuConfigure)}</Dropdown.Item>
<Dropdown.Item onClick={onClickDuplicate}>{intl.formatMessage(messages.menuDuplicate)}</Dropdown.Item>
<Dropdown.Item onClick={onClickDelete}>{intl.formatMessage(messages.menuDelete)}</Dropdown.Item>
</Dropdown.Menu>
@@ -153,6 +154,7 @@ CardHeader.propTypes = {
isExpanded: PropTypes.bool.isRequired,
onExpand: PropTypes.func.isRequired,
onClickPublish: PropTypes.func.isRequired,
onClickConfigure: PropTypes.func.isRequired,
onClickMenuButton: PropTypes.func.isRequired,
onClickEdit: PropTypes.func.isRequired,
isFormOpen: PropTypes.bool.isRequired,

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Stack } 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 intl = useIntl();
const onChange = (value) => {
setReleaseDate(value);
};
return (
<>
<h3 className="mt-3"><FormattedMessage {...messages.releaseDateAndTime} /></h3>
<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>
</>
);
};
BasicTab.propTypes = {
releaseDate: PropTypes.string.isRequired,
setReleaseDate: PropTypes.func.isRequired,
};
export default injectIntl(BasicTab);

View File

@@ -0,0 +1,95 @@
/* eslint-disable import/named */
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ModalDialog,
Button,
ActionRow,
Tab,
Tabs,
} from '@edx/paragon';
import { useSelector } from 'react-redux';
import { VisibilityTypes } from '../../data/constants';
import { getCurrentSection } from '../data/selectors';
import messages from './messages';
import BasicTab from './BasicTab';
import VisibilityTab from './VisibilityTab';
const ConfigureModal = ({
isOpen,
onClose,
onConfigureSubmit,
}) => {
const intl = useIntl();
const { displayName, start: sectionStartDate, visibilityState } = useSelector(getCurrentSection);
const [releaseDate, setReleaseDate] = useState(sectionStartDate);
const [isVisibleToStaffOnly, setIsVisibleToStaffOnly] = useState(visibilityState === VisibilityTypes.STAFF_ONLY);
const [saveButtonDisabled, setSaveButtonDisabled] = useState(true);
useEffect(() => {
setReleaseDate(sectionStartDate);
}, [sectionStartDate]);
useEffect(() => {
setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY);
}, [visibilityState]);
useEffect(() => {
const visibilityUnchanged = isVisibleToStaffOnly === (visibilityState === VisibilityTypes.STAFF_ONLY);
setSaveButtonDisabled(visibilityUnchanged && releaseDate === sectionStartDate);
}, [releaseDate, isVisibleToStaffOnly]);
const handleSave = () => {
onConfigureSubmit(isVisibleToStaffOnly, releaseDate);
};
return (
<ModalDialog
className="configure-modal"
isOpen={isOpen}
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header className="configure-modal__header">
<ModalDialog.Title>
{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>
<ModalDialog.Footer className="pt-1">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancelButton)}
</ModalDialog.CloseButton>
<Button onClick={handleSave} disabled={saveButtonDisabled}>
{intl.formatMessage(messages.saveButton)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
ConfigureModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfigureSubmit: PropTypes.func.isRequired,
};
export default ConfigureModal;

View File

@@ -0,0 +1,12 @@
.configure-modal {
max-width: 33.6875rem;
overflow: visible;
.configure-modal__header {
padding-top: 1.5rem;
}
.configure-modal__body {
overflow: visible;
}
}

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import ConfigureModal from './ConfigureModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const currentSectionMock = {
displayName: 'Section1',
childInfo: {
displayName: 'Subsection',
children: [
{
displayName: 'Subsection 1',
id: 1,
childInfo: {
displayName: 'Unit',
children: [
{
id: 11,
displayName: 'Subsection_1 Unit 1',
},
],
},
},
{
displayName: 'Subsection 2',
id: 2,
childInfo: {
displayName: 'Unit',
children: [
{
id: 21,
displayName: 'Subsection_2 Unit 1',
},
],
},
},
{
displayName: 'Subsection 3',
id: 3,
childInfo: {
children: [],
},
},
],
},
};
const onCloseMock = jest.fn();
const onConfigureSubmitMock = jest.fn();
const renderComponent = () => 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(currentSectionMock);
});
it('renders ConfigureModal component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument();
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
it('switches to the Visibility tab and renders correctly', () => {
const { getByRole, getByText } = renderComponent();
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
expect(getByText(messages.sectionVisibility.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
});
it('disables the Save button and enables it if there is a change', () => {
const { getByRole, getByPlaceholderText, getByTestId } = renderComponent();
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
expect(saveButton).toBeDisabled();
const input = getByPlaceholderText('MM/DD/YYYY');
fireEvent.change(input, { target: { value: '12/15/2023' } });
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
fireEvent.click(visibilityTab);
const checkbox = getByTestId('visibility-checkbox');
fireEvent.click(checkbox);
expect(saveButton).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Form } from '@edx/paragon';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const VisibilityTab = ({ isVisibleToStaffOnly, setIsVisibleToStaffOnly, showWarning }) => {
const handleChange = (e) => {
setIsVisibleToStaffOnly(e.target.checked);
};
return (
<>
<h3 className="mt-3"><FormattedMessage {...messages.sectionVisibility} /></h3>
<hr />
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="visibility-checkbox">
<FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox>
{showWarning && (
<>
<hr />
<Alert variant="warning">
<FormattedMessage {...messages.visibilityWarning} />
</Alert>
</>
)}
</>
);
};
VisibilityTab.propTypes = {
isVisibleToStaffOnly: PropTypes.bool.isRequired,
showWarning: PropTypes.bool.isRequired,
setIsVisibleToStaffOnly: PropTypes.func.isRequired,
};
export default injectIntl(VisibilityTab);

View File

@@ -0,0 +1,50 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.configure-modal.title',
defaultMessage: '{title} Settings',
},
basicTabTitle: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.title',
defaultMessage: 'Basic',
},
releaseDateAndTime: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time',
defaultMessage: 'Release Date and Time',
},
releaseDate: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date',
defaultMessage: 'Release Date:',
},
releaseTimeUTC: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC',
defaultMessage: 'Release Time in UTC:',
},
visibilityTabTitle: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.title',
defaultMessage: 'Visibility',
},
sectionVisibility: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility',
defaultMessage: 'Section Visibility',
},
hideFromLearners: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-from-learners',
defaultMessage: 'Hide from learners',
},
visibilityWarning: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.visibility-warning',
defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the unit. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.',
},
cancelButton: {
id: 'course-authoring.course-outline.configure-modal.button.cancel',
defaultMessage: 'Cancel',
},
saveButton: {
id: 'course-authoring.course-outline.configure-modal.button.label',
defaultMessage: 'Save',
},
});
export default messages;

View File

@@ -215,6 +215,25 @@ export async function publishCourseSection(sectionId) {
return data;
}
/**
* Configure course section
* @param {string} sectionId
* @returns {Promise<Object>}
*/
export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) {
const { data } = await getAuthenticatedHttpClient()
.post(getUpdateCourseSectionApiUrl(sectionId), {
publish: 'republish',
metadata: {
// The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
start: startDatetime,
},
});
return data;
}
/**
* Edit course section
* @param {string} sectionId

View File

@@ -20,6 +20,7 @@ import {
getCourseOutlineIndex,
getCourseSection,
publishCourseSection,
configureCourseSection,
restartIndexingOnCourse,
updateCourseSectionHighlights,
} from './api';
@@ -177,6 +178,26 @@ export function publishCourseSectionQuery(sectionId) {
};
}
export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime).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 editCourseSectionQuery(sectionId, displayName) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));

View File

@@ -27,6 +27,7 @@ import {
fetchCourseReindexQuery,
publishCourseSectionQuery,
updateCourseSectionHighlightsQuery,
configureCourseSectionQuery,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -46,6 +47,7 @@ const useCourseOutline = ({ courseId }) => {
const [showErrorAlert, setShowErrorAlert] = useState(false);
const [isHighlightsModalOpen, openHighlightsModal, closeHighlightsModal] = useToggle(false);
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const handleNewSectionSubmit = () => {
@@ -96,6 +98,12 @@ const useCourseOutline = ({ courseId }) => {
closePublishModal();
};
const handleConfigureSectionSubmit = (isVisibleToStaffOnly, startDatetime) => {
dispatch(configureCourseSectionQuery(currentSection.id, isVisibleToStaffOnly, startDatetime));
closeConfigureModal();
};
const handleEditSectionSubmit = (sectionId, displayName) => {
dispatch(editCourseSectionQuery(sectionId, displayName));
};
@@ -137,10 +145,14 @@ const useCourseOutline = ({ courseId }) => {
isPublishModalOpen,
openPublishModal,
closePublishModal,
isConfigureModalOpen,
openConfigureModal,
closeConfigureModal,
headerNavigationsActions,
handleEnableHighlightsSubmit,
handleHighlightsFormSubmit,
handlePublishSectionSubmit,
handleConfigureSectionSubmit,
handleEditSectionSubmit,
statusBarData,
isEnableHighlightsModalOpen,

View File

@@ -16,6 +16,7 @@ const SectionCard = ({
children,
onOpenHighlightsModal,
onOpenPublishModal,
onOpenConfigureModal,
onEditSectionSubmit,
savingStatus,
onOpenDeleteModal,
@@ -89,6 +90,7 @@ const SectionCard = ({
onExpand={handleExpandContent}
onClickMenuButton={handleClickMenuButton}
onClickPublish={onOpenPublishModal}
onClickConfigure={onOpenConfigureModal}
onClickEdit={openForm}
onClickDelete={onOpenDeleteModal}
isFormOpen={isFormOpen}
@@ -149,6 +151,7 @@ SectionCard.propTypes = {
children: PropTypes.node,
onOpenHighlightsModal: PropTypes.func.isRequired,
onOpenPublishModal: PropTypes.func.isRequired,
onOpenConfigureModal: PropTypes.func.isRequired,
onEditSectionSubmit: PropTypes.func.isRequired,
savingStatus: PropTypes.string.isRequired,
onOpenDeleteModal: PropTypes.func.isRequired,

View File

@@ -41,3 +41,8 @@ export const DivisionSchemes = {
NONE: 'none',
COHORT: 'cohort',
};
export const VisibilityTypes = {
STAFF_ONLY: 'staff_only',
HIDE_AFTER_DUE: 'hide_after_due',
};