diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx
index c9ec8acf89..d0d5b60d47 100644
--- a/src/course-outline/CourseOutline.jsx
+++ b/src/course-outline/CourseOutline.jsx
@@ -29,6 +29,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';
@@ -51,11 +52,14 @@ const CourseOutline = ({ courseId }) => {
isDisabledReindexButton,
isHighlightsModalOpen,
isPublishModalOpen,
+ isConfigureModalOpen,
isDeleteModalOpen,
closeHighlightsModal,
closePublishModal,
+ closeConfigureModal,
closeDeleteModal,
openPublishModal,
+ openConfigureModal,
openDeleteModal,
headerNavigationsActions,
openEnableHighlightsModal,
@@ -65,6 +69,7 @@ const CourseOutline = ({ courseId }) => {
handleOpenHighlightsModal,
handleHighlightsFormSubmit,
handlePublishSectionSubmit,
+ handleConfigureSectionSubmit,
handleEditSectionSubmit,
handleDeleteSectionSubmit,
handleDuplicateSectionSubmit,
@@ -143,6 +148,7 @@ const CourseOutline = ({ courseId }) => {
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
+ onOpenConfigureModal={openConfigureModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSectionSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
@@ -188,6 +194,11 @@ const CourseOutline = ({ courseId }) => {
onClose={closePublishModal}
onPublishSubmit={handlePublishSectionSubmit}
/>
+
', () => {
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();
+ 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,
+ start: newReleaseDate,
+ });
+
+ 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' } });
+ 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();
diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx
index a6d87171a9..eeedf676b0 100644
--- a/src/course-outline/card-header/CardHeader.jsx
+++ b/src/course-outline/card-header/CardHeader.jsx
@@ -30,6 +30,7 @@ const CardHeader = ({
hasChanges,
isExpanded,
onClickPublish,
+ onClickConfigure,
onClickMenuButton,
onClickEdit,
onExpand,
@@ -136,7 +137,7 @@ const CardHeader = ({
>
{intl.formatMessage(messages.menuPublish)}
- {intl.formatMessage(messages.menuConfigure)}
+ {intl.formatMessage(messages.menuConfigure)}
{intl.formatMessage(messages.menuDuplicate)}
{intl.formatMessage(messages.menuDelete)}
@@ -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,
diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/course-outline/configure-modal/BasicTab.jsx
new file mode 100644
index 0000000000..ffdfc81c5a
--- /dev/null
+++ b/src/course-outline/configure-modal/BasicTab.jsx
@@ -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 (
+ <>
+
+
+
+ onChange(date)}
+ />
+ onChange(date)}
+ />
+
+ >
+ );
+};
+
+BasicTab.propTypes = {
+ releaseDate: PropTypes.string.isRequired,
+ setReleaseDate: PropTypes.func.isRequired,
+};
+
+export default injectIntl(BasicTab);
diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/course-outline/configure-modal/ConfigureModal.jsx
new file mode 100644
index 0000000000..cedcf01989
--- /dev/null
+++ b/src/course-outline/configure-modal/ConfigureModal.jsx
@@ -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 (
+
+
+
+ {intl.formatMessage(messages.title, { title: displayName })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.cancelButton)}
+
+
+
+
+
+ );
+};
+
+ConfigureModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onConfigureSubmit: PropTypes.func.isRequired,
+};
+
+export default ConfigureModal;
diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/course-outline/configure-modal/ConfigureModal.scss
new file mode 100644
index 0000000000..1fad13926a
--- /dev/null
+++ b/src/course-outline/configure-modal/ConfigureModal.scss
@@ -0,0 +1,12 @@
+.configure-modal {
+ max-width: 33.6875rem;
+ overflow: visible;
+
+ .configure-modal__header {
+ padding-top: 1.5rem;
+ }
+
+ .configure-modal__body {
+ overflow: visible;
+ }
+}
diff --git a/src/course-outline/configure-modal/ConfigureModal.test.jsx b/src/course-outline/configure-modal/ConfigureModal.test.jsx
new file mode 100644
index 0000000000..d27056e443
--- /dev/null
+++ b/src/course-outline/configure-modal/ConfigureModal.test.jsx
@@ -0,0 +1,159 @@
+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(
+
+
+
+ ,
+ ,
+);
+
+describe('', () => {
+ 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();
+ });
+
+ it('calls OnConfigureSubmit upon save', () => {
+ const { getByRole, getByPlaceholderText } = renderComponent();
+
+ const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
+ const timeInput = getByPlaceholderText('HH:MM');
+
+ fireEvent.change(timeInput, { target: { value: '10:10' } });
+ fireEvent.click(saveButton);
+
+ expect(onConfigureSubmitMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls the onClose function when the cancel button is clicked', () => {
+ const { getByRole } = renderComponent();
+
+ const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
+ fireEvent.click(cancelButton);
+ expect(onCloseMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/course-outline/configure-modal/VisibilityTab.jsx
new file mode 100644
index 0000000000..033f58018e
--- /dev/null
+++ b/src/course-outline/configure-modal/VisibilityTab.jsx
@@ -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 (
+ <>
+
+
+
+
+
+ {showWarning && (
+ <>
+
+
+
+
+ >
+
+ )}
+ >
+ );
+};
+
+VisibilityTab.propTypes = {
+ isVisibleToStaffOnly: PropTypes.bool.isRequired,
+ showWarning: PropTypes.bool.isRequired,
+ setIsVisibleToStaffOnly: PropTypes.func.isRequired,
+};
+
+export default injectIntl(VisibilityTab);
diff --git a/src/course-outline/configure-modal/messages.js b/src/course-outline/configure-modal/messages.js
new file mode 100644
index 0000000000..3fd9f50bc8
--- /dev/null
+++ b/src/course-outline/configure-modal/messages.js
@@ -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;
diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js
index 11bce0b17a..52ea3b86c7 100644
--- a/src/course-outline/data/api.js
+++ b/src/course-outline/data/api.js
@@ -152,6 +152,25 @@ export async function publishCourseSection(sectionId) {
return data;
}
+/**
+ * Configure course section
+ * @param {string} sectionId
+ * @returns {Promise