diff --git a/src/constants.js b/src/constants.js
index 6305606e70..7c293fe122 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -23,6 +23,7 @@ export const NOTIFICATION_MESSAGES = {
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
+ empty: '',
};
export const DEFAULT_TIME_STAMP = '00:00';
diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx
index 51bf39f269..678b721093 100644
--- a/src/course-outline/CourseOutline.jsx
+++ b/src/course-outline/CourseOutline.jsx
@@ -1,33 +1,56 @@
-import React from 'react';
+import {
+ React, useState, useEffect,
+} from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
+ Button,
Container,
Layout,
TransitionReplace,
} from '@edx/paragon';
+import { Helmet } from 'react-helmet';
import {
+ Add as IconAdd,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
} from '@edx/paragon/icons';
+import { useSelector } from 'react-redux';
+import {
+ DraggableList,
+ SortableItem,
+ ErrorAlert,
+} from '@edx/frontend-lib-content-components';
-import SubHeader from '../generic/sub-header/SubHeader';
+import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import { RequestStatus } from '../data/constants';
+import SubHeader from '../generic/sub-header/SubHeader';
+import ProcessingNotification from '../generic/processing-notification';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import AlertMessage from '../generic/alert-message';
+import getPageHeadTitle from '../generic/utils';
import HeaderNavigations from './header-navigations/HeaderNavigations';
import OutlineSideBar from './outline-sidebar/OutlineSidebar';
-import messages from './messages';
-import { useCourseOutline } from './hooks';
import StatusBar from './status-bar/StatusBar';
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
+import SectionCard from './section-card/SectionCard';
+import SubsectionCard from './subsection-card/SubsectionCard';
+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';
const CourseOutline = ({ courseId }) => {
const intl = useIntl();
const {
+ courseName,
savingStatus,
statusBarData,
+ sectionsList,
isLoading,
isReIndexShow,
showErrorAlert,
@@ -36,13 +59,54 @@ const CourseOutline = ({ courseId }) => {
isEnableHighlightsModalOpen,
isInternetConnectionAlertFailed,
isDisabledReindexButton,
+ isHighlightsModalOpen,
+ isPublishModalOpen,
+ isConfigureModalOpen,
+ isDeleteModalOpen,
+ closeHighlightsModal,
+ closePublishModal,
+ closeConfigureModal,
+ closeDeleteModal,
+ openPublishModal,
+ openConfigureModal,
+ openDeleteModal,
headerNavigationsActions,
openEnableHighlightsModal,
closeEnableHighlightsModal,
handleEnableHighlightsSubmit,
handleInternetConnectionFailed,
+ handleOpenHighlightsModal,
+ handleHighlightsFormSubmit,
+ handleConfigureSectionSubmit,
+ handlePublishItemSubmit,
+ handleEditSubmit,
+ handleDeleteItemSubmit,
+ handleDuplicateSectionSubmit,
+ handleDuplicateSubsectionSubmit,
+ handleNewSectionSubmit,
+ handleNewSubsectionSubmit,
+ handleDragNDrop,
} = useCourseOutline({ courseId });
+ const [sections, setSections] = useState(sectionsList);
+
+ const initialSections = [...sectionsList];
+
+ const {
+ isShow: isShowProcessingNotification,
+ title: processingNotificationTitle,
+ } = useSelector(getProcessingNotification);
+
+ const finalizeSectionOrder = () => (newSections) => {
+ handleDragNDrop(newSections.map((section) => section.id), () => {
+ setSections(() => initialSections);
+ });
+ };
+
+ useEffect(() => {
+ setSections(sectionsList);
+ }, [sectionsList]);
+
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>>;
@@ -50,8 +114,14 @@ const CourseOutline = ({ courseId }) => {
return (
<>
+
+ {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}
+
+
+ {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })}
+
{showSuccessAlert ? (
{
isSectionsExpanded={isSectionsExpanded}
headerNavigationsActions={headerNavigationsActions}
isDisabledReindexButton={isDisabledReindexButton}
+ hasSections={Boolean(sectionsList.length)}
/>
)}
/>
@@ -97,6 +168,66 @@ const CourseOutline = ({ courseId }) => {
statusBarData={statusBarData}
openEnableHighlightsModal={openEnableHighlightsModal}
/>
+
+ {sections.length ? (
+ <>
+
+ {sections.map((section) => (
+
+
+ {section.childInfo.children.map((subsection) => (
+
+ ))}
+
+
+ ))}
+
+
+ >
+ ) : (
+
+ )}
+
@@ -109,11 +240,34 @@ const CourseOutline = ({ courseId }) => {
isOpen={isEnableHighlightsModalOpen}
close={closeEnableHighlightsModal}
onEnableHighlightsSubmit={handleEnableHighlightsSubmit}
- highlightsDocUrl={statusBarData.highlightsDocUrl}
/>
+
+
+
+
+
({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
@@ -42,6 +56,15 @@ jest.mock('react-router-dom', () => ({
}),
}));
+jest.mock('../help-urls/hooks', () => ({
+ useHelpUrls: () => ({
+ contentHighlights: 'some',
+ visibility: 'some',
+ grading: 'some',
+ outline: 'some',
+ }),
+}));
+
const RootWrapper = () => (
@@ -79,25 +102,84 @@ describe('', () => {
});
it('check reindex and render success alert is correctly', async () => {
- const { getByText } = render();
+ const { findByText, findByTestId } = render();
axiosMock
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
.reply(200);
- await executeThunk(fetchCourseReindexQuery(courseId, courseOutlineIndexMock.reindexLink), store.dispatch);
+ const reindexButton = await findByTestId('course-reindex');
+ fireEvent.click(reindexButton);
- expect(getByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument();
+ expect(await findByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument();
});
it('render error alert after failed reindex correctly', async () => {
- const { getByText } = render();
+ const { findByText, findByTestId } = render();
axiosMock
- .onGet(getCourseReindexApiUrl('some link'))
+ .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
.reply(500);
- await executeThunk(fetchCourseReindexQuery(courseId, 'some link'), store.dispatch);
+ const reindexButton = await findByTestId('course-reindex');
+ await act(async () => {
+ fireEvent.click(reindexButton);
+ });
+
+ expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
+ });
- expect(getByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
+ it('adds new section correctly', async () => {
+ const { findAllByTestId, findByTestId } = render();
+ let elements = await findAllByTestId('section-card');
+ window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
+ top: 0,
+ bottom: 4000,
+ }));
+ expect(elements.length).toBe(4);
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl())
+ .reply(200, {
+ locator: courseSectionMock.id,
+ });
+ axiosMock
+ .onGet(getXBlockApiUrl(courseSectionMock.id))
+ .reply(200, courseSectionMock);
+ const newSectionButton = await findByTestId('new-section-button');
+ await act(async () => {
+ fireEvent.click(newSectionButton);
+ });
+
+ elements = await findAllByTestId('section-card');
+ expect(elements.length).toBe(5);
+ expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
+ });
+
+ it('adds new subsection correctly', async () => {
+ const { findAllByTestId } = render();
+ const [section] = await findAllByTestId('section-card');
+ let subsections = await within(section).findAllByTestId('subsection-card');
+ expect(subsections.length).toBe(1);
+ window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
+ top: 0,
+ bottom: 4000,
+ }));
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl())
+ .reply(200, {
+ locator: courseSubsectionMock.id,
+ });
+ axiosMock
+ .onGet(getXBlockApiUrl(courseSubsectionMock.id))
+ .reply(200, courseSubsectionMock);
+ const newSubsectionButton = await within(section).findByTestId('new-subsection-button');
+ await act(async () => {
+ fireEvent.click(newSubsectionButton);
+ });
+
+ subsections = await within(section).findAllByTestId('subsection-card');
+ expect(subsections.length).toBe(2);
+ expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
});
it('render checklist value correctly', async () => {
@@ -126,25 +208,356 @@ describe('', () => {
});
it('check highlights are enabled after enable highlights query is successful', async () => {
- const { findByTestId } = render();
+ const { findByTestId, findByText } = render();
+ axiosMock.reset();
+ axiosMock
+ .onPost(getEnableHighlightsEmailsApiUrl(courseId), {
+ publish: 'republish',
+ metadata: {
+ highlights_enabled_for_messaging: true,
+ },
+ })
+ .reply(200);
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, {
...courseOutlineIndexMock,
- highlightsEnabledForMessaging: false,
+ courseStructure: {
+ ...courseOutlineIndexMock.courseStructure,
+ highlightsEnabledForMessaging: true,
+ },
});
+ const enableButton = await findByTestId('highlights-enable-button');
+ fireEvent.click(enableButton);
+ const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage);
+ await act(async () => {
+ fireEvent.click(saveButton);
+ });
+ expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument();
+ });
+
+ it('should expand and collapse subsections, after click on subheader buttons', async () => {
+ const { queryAllByTestId, findByText } = render();
+
+ const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage);
+ expect(collapseBtn).toBeInTheDocument();
+ fireEvent.click(collapseBtn);
+
+ const expandBtn = await findByText(headerMessages.expandAllButton.defaultMessage);
+ expect(expandBtn).toBeInTheDocument();
+ fireEvent.click(expandBtn);
+
+ await waitFor(() => {
+ const cardSubsections = queryAllByTestId('section-card__subsections');
+ cardSubsections.forEach(element => expect(element).toBeVisible());
+
+ fireEvent.click(collapseBtn);
+ cardSubsections.forEach(element => expect(element).not.toBeVisible());
+ });
+ });
+
+ it('render CourseOutline component without sections correctly', async () => {
+ cleanup();
axiosMock
- .onPost(getEnableHighlightsEmailsApiUrl(courseId), {
+ .onGet(getCourseOutlineIndexApiUrl(courseId))
+ .reply(200, courseOutlineIndexWithoutSections);
+
+ const { getByTestId } = render();
+
+ await waitFor(() => {
+ expect(getByTestId('empty-placeholder')).toBeInTheDocument();
+ });
+ });
+
+ it('check edit section when edit query is successfully', async () => {
+ const { findAllByTestId, findByText } = render();
+ const newDisplayName = 'New section name';
+
+ const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
+
+ axiosMock
+ .onPost(getCourseItemApiUrl(section.id, {
+ metadata: {
+ display_name: newDisplayName,
+ },
+ }))
+ .reply(200, { dummy: 'value' });
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, {
+ ...section,
+ display_name: newDisplayName,
+ });
+
+ const [sectionElement] = await findAllByTestId('section-card');
+ const editButton = await within(sectionElement).findByTestId('section-edit-button');
+ fireEvent.click(editButton);
+ const editField = await within(sectionElement).findByTestId('section-edit-field');
+ fireEvent.change(editField, { target: { value: newDisplayName } });
+ await act(async () => {
+ fireEvent.blur(editField);
+ });
+
+ expect(await findByText(newDisplayName)).toBeInTheDocument();
+ });
+
+ it('check whether section is deleted when delete button is clicked', async () => {
+ const { findAllByTestId, findByTestId, queryByText } = render();
+ const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
+ await waitFor(() => {
+ expect(queryByText(section.displayName)).toBeInTheDocument();
+ });
+
+ axiosMock.onDelete(getCourseItemApiUrl(section.id)).reply(200);
+
+ const [sectionElement] = await findAllByTestId('section-card');
+ const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
+ fireEvent.click(menu);
+ const deleteButton = await within(sectionElement).findByTestId('section-card-header__menu-delete-button');
+ fireEvent.click(deleteButton);
+ const confirmButton = await findByTestId('delete-confirm-button');
+ await act(async () => {
+ fireEvent.click(confirmButton);
+ });
+
+ await waitFor(() => {
+ expect(queryByText(section.displayName)).not.toBeInTheDocument();
+ });
+ });
+
+ it('check whether subsection is deleted when delete button is clicked', async () => {
+ const { findAllByTestId, findByTestId, queryByText } = render();
+ const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
+ const [subsection] = section.childInfo.children;
+ await waitFor(() => {
+ expect(queryByText(subsection.displayName)).toBeInTheDocument();
+ });
+
+ axiosMock.onDelete(getCourseItemApiUrl(subsection.id)).reply(200);
+
+ const [sectionElement] = await findAllByTestId('section-card');
+ const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
+ const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
+ fireEvent.click(menu);
+ const deleteButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-delete-button');
+ fireEvent.click(deleteButton);
+ const confirmButton = await findByTestId('delete-confirm-button');
+ await act(async () => {
+ fireEvent.click(confirmButton);
+ });
+
+ await waitFor(() => {
+ expect(queryByText(subsection.displayName)).not.toBeInTheDocument();
+ });
+ });
+
+ it('check whether section is duplicated successfully', async () => {
+ const { findAllByTestId } = render();
+ const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
+ expect(await findAllByTestId('section-card')).toHaveLength(4);
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl())
+ .reply(200, {
+ locator: courseSectionMock.id,
+ });
+ section.id = courseSectionMock.id;
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, {
+ ...section,
+ });
+
+ const [sectionElement] = await findAllByTestId('section-card');
+ const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
+ fireEvent.click(menu);
+ const duplicateButton = await within(sectionElement).findByTestId('section-card-header__menu-duplicate-button');
+ await act(async () => {
+ fireEvent.click(duplicateButton);
+ });
+ expect(await findAllByTestId('section-card')).toHaveLength(5);
+ });
+
+ it('check whether subsection is duplicated successfully', async () => {
+ const { findAllByTestId } = render();
+ const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
+ let [sectionElement] = await findAllByTestId('section-card');
+ const [subsection] = section.childInfo.children;
+ let subsections = await within(sectionElement).findAllByTestId('subsection-card');
+ expect(subsections.length).toBe(1);
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl())
+ .reply(200, {
+ locator: courseSubsectionMock.id,
+ });
+ subsection.id = courseSubsectionMock.id;
+ section.childInfo.children = [...section.childInfo.children, subsection];
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, {
+ ...section,
+ });
+
+ const menu = await within(subsections[0]).findByTestId('subsection-card-header__menu-button');
+ fireEvent.click(menu);
+ const duplicateButton = await within(subsections[0]).findByTestId('subsection-card-header__menu-duplicate-button');
+ await act(async () => {
+ fireEvent.click(duplicateButton);
+ });
+
+ [sectionElement] = await findAllByTestId('section-card');
+ subsections = await within(sectionElement).findAllByTestId('subsection-card');
+ expect(subsections.length).toBe(2);
+ });
+
+ it('check section is published when publish button is clicked', async () => {
+ const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
+ const { findAllByTestId, findByTestId } = render();
+
+ axiosMock
+ .onPost(getCourseItemApiUrl(section.id), {
+ publish: 'make_public',
+ })
+ .reply(200, { dummy: 'value' });
+
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, {
+ ...section,
+ published: true,
+ releasedToStudents: false,
+ });
+
+ const [sectionElement] = await findAllByTestId('section-card');
+ const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
+ fireEvent.click(menu);
+ const publishButton = await within(sectionElement).findByTestId('section-card-header__menu-publish-button');
+ await act(async () => fireEvent.click(publishButton));
+ const confirmButton = await findByTestId('publish-confirm-button');
+ await act(async () => fireEvent.click(confirmButton));
+
+ expect(
+ sectionElement.querySelector('.item-card-header__badge-status'),
+ ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage);
+ });
+
+ it('check configure section when configure query is successful', async () => {
+ const { findAllByTestId, findByPlaceholderText } = render();
+ const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
+ const newReleaseDate = '2025-08-10T10:00:00Z';
+ axiosMock
+ .onPost(getCourseItemApiUrl(section.id), {
publish: 'republish',
metadata: {
- highlights_enabled_for_messaging: true,
+ visible_to_staff_only: true,
+ start: newReleaseDate,
},
})
- .reply(200);
+ .reply(200, { dummy: 'value' });
- await executeThunk(enableCourseHighlightsEmailsQuery(courseId), store.dispatch);
- expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument();
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, section);
+
+ const [firstSection] = await findAllByTestId('section-card');
+
+ const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button');
+ fireEvent.click(sectionDropdownButton);
+
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, {
+ ...section,
+ start: newReleaseDate,
+ });
+
+ await executeThunk(configureCourseSectionQuery(section.id, true, newReleaseDate), store.dispatch);
+ fireEvent.click(sectionDropdownButton);
+ const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button');
+ fireEvent.click(configureBtn);
+
+ const datePicker = await findByPlaceholderText('MM/DD/YYYY');
+ expect(datePicker).toHaveValue('08/10/2025');
+ });
+
+ it('check update highlights when update highlights query is successfully', async () => {
+ const { getByRole } = render();
+
+ const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
+ const highlights = [
+ 'New Highlight 1',
+ 'New Highlight 2',
+ 'New Highlight 3',
+ 'New Highlight 4',
+ 'New Highlight 5',
+ ];
+
+ axiosMock
+ .onPost(getCourseItemApiUrl(section.id), {
+ publish: 'republish',
+ metadata: {
+ highlights,
+ },
+ })
+ .reply(200, { dummy: 'value' });
+
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, {
+ ...section,
+ highlights,
+ });
+
+ await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
+
+ await waitFor(() => {
+ expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
+ });
+ });
+
+ it('check section list is ordered successfully', async () => {
+ const { getAllByTestId } = render();
+ const courseBlockId = courseOutlineIndexMock.courseStructure.id;
+ let { children } = courseOutlineIndexMock.courseStructure.childInfo;
+ children = children.splice(2, 0, children.splice(0, 1)[0]);
+
+ axiosMock
+ .onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children })
+ .reply(200, { dummy: 'value' });
+
+ await executeThunk(setSectionOrderListQuery(courseBlockId, children, () => {}), store.dispatch);
+
+ await waitFor(() => {
+ expect(getAllByTestId('section-card')).toHaveLength(4);
+ const newSections = getAllByTestId('section-card');
+ for (let i; i < children.length; i++) {
+ expect(children[i].id === newSections[i].id);
+ }
+ });
+ });
+
+ it('check section list is restored to original order when API call fails', async () => {
+ const { getAllByTestId } = render();
+ const courseBlockId = courseOutlineIndexMock.courseStructure.id;
+ const { children } = courseOutlineIndexMock.courseStructure.childInfo;
+ const newChildren = children.splice(2, 0, children.splice(0, 1)[0]);
+
+ axiosMock
+ .onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children })
+ .reply(500);
+
+ await executeThunk(setSectionOrderListQuery(courseBlockId, undefined, () => children), store.dispatch);
+
+ await waitFor(() => {
+ expect(getAllByTestId('section-card')).toHaveLength(4);
+ const newSections = getAllByTestId('section-card');
+ for (let i; i < children.length; i++) {
+ expect(children[i].id === newSections[i].id);
+ expect(newChildren[i].id !== newSections[i].id);
+ }
+ });
});
});
diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js
index b65784c9be..d7ed4ed35f 100644
--- a/src/course-outline/__mocks__/courseOutlineIndex.js
+++ b/src/course-outline/__mocks__/courseOutlineIndex.js
@@ -55,7 +55,7 @@ module.exports = {
},
],
showCorrectness: 'always',
- highlightsEnabledForMessaging: true,
+ highlightsEnabledForMessaging: false,
highlightsEnabled: true,
highlightsPreviewOnly: false,
highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
@@ -72,7 +72,7 @@ module.exports = {
category: 'chapter',
hasChildren: true,
editedOn: 'Aug 23, 2023 at 12:35 UTC',
- published: true,
+ published: false,
publishedOn: 'Aug 23, 2023 at 12:35 UTC',
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b',
releasedToStudents: true,
diff --git a/src/course-outline/__mocks__/courseSection.js b/src/course-outline/__mocks__/courseSection.js
new file mode 100644
index 0000000000..2e4e6f8de9
--- /dev/null
+++ b/src/course-outline/__mocks__/courseSection.js
@@ -0,0 +1,93 @@
+module.exports = {
+ id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d0e78d363a424da6be5c22704c34f7a7',
+ display_name: 'Section',
+ category: 'chapter',
+ has_children: true,
+ edited_on: 'Nov 22, 2023 at 07:45 UTC',
+ published: true,
+ published_on: 'Nov 22, 2023 at 07:45 UTC',
+ studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d0e78d363a424da6be5c22704c34f7a7',
+ released_to_students: true,
+ release_date: 'Feb 05, 2013 at 05:00 UTC',
+ visibility_state: 'live',
+ has_explicit_staff_lock: false,
+ start: '2013-02-05T05:00:00Z',
+ graded: false,
+ due_date: '',
+ due: null,
+ relative_weeks_due: null,
+ format: null,
+ course_graders: [
+ 'Homework',
+ 'Exam',
+ ],
+ has_changes: false,
+ actions: {
+ deletable: true,
+ draggable: true,
+ childAddable: true,
+ duplicable: true,
+ },
+ explanatory_message: null,
+ group_access: {},
+ user_partitions: [
+ {
+ id: 50,
+ name: 'Enrollment Track Groups',
+ scheme: 'enrollment_track',
+ groups: [
+ {
+ id: 2,
+ name: 'Verified Certificate',
+ selected: false,
+ deleted: false,
+ },
+ {
+ id: 1,
+ name: 'Audit',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ ],
+ show_correctness: 'always',
+ highlights: [],
+ highlights_enabled: true,
+ highlights_preview_only: false,
+ highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages',
+ child_info: {
+ category: 'sequential',
+ display_name: 'Subsection',
+ children: [],
+ },
+ ancestor_has_staff_lock: false,
+ staff_only_message: false,
+ enable_copy_paste_units: false,
+ has_partition_group_components: false,
+ user_partition_info: {
+ selectable_partitions: [
+ {
+ id: 50,
+ name: 'Enrollment Track Groups',
+ scheme: 'enrollment_track',
+ groups: [
+ {
+ id: 2,
+ name: 'Verified Certificate',
+ selected: false,
+ deleted: false,
+ },
+ {
+ id: 1,
+ name: 'Audit',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ ],
+ selected_partition_index: -1,
+ selected_groups_label: '',
+ },
+};
diff --git a/src/course-outline/__mocks__/courseSubsection.js b/src/course-outline/__mocks__/courseSubsection.js
new file mode 100644
index 0000000000..a7e72d0efa
--- /dev/null
+++ b/src/course-outline/__mocks__/courseSubsection.js
@@ -0,0 +1,101 @@
+module.exports = {
+ id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@b713bc2830f34f6f87554028c3068729',
+ display_name: 'Subsection',
+ category: 'sequential',
+ has_children: true,
+ edited_on: 'Dec 05, 2023 at 10:35 UTC',
+ published: true,
+ published_on: 'Dec 05, 2023 at 10:35 UTC',
+ studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40b713bc2830f34f6f87554028c3068729',
+ released_to_students: true,
+ release_date: 'Feb 05, 2013 at 05:00 UTC',
+ visibility_state: 'live',
+ has_explicit_staff_lock: false,
+ start: '2013-02-05T05:00:00Z',
+ graded: false,
+ due_date: '',
+ due: null,
+ relative_weeks_due: null,
+ format: null,
+ course_graders: [
+ 'Homework',
+ 'Exam',
+ ],
+ has_changes: false,
+ actions: {
+ deletable: true,
+ draggable: true,
+ childAddable: true,
+ duplicable: true,
+ },
+ explanatory_message: null,
+ group_access: {},
+ user_partitions: [
+ {
+ id: 50,
+ name: 'Enrollment Track Groups',
+ scheme: 'enrollment_track',
+ groups: [
+ {
+ id: 2,
+ name: 'Verified Certificate',
+ selected: false,
+ deleted: false,
+ },
+ {
+ id: 1,
+ name: 'Audit',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ ],
+ show_correctness: 'always',
+ hide_after_due: false,
+ is_proctored_exam: false,
+ was_exam_ever_linked_with_external: false,
+ online_proctoring_rules: '',
+ is_practice_exam: false,
+ is_onboarding_exam: false,
+ is_time_limited: false,
+ exam_review_rules: '',
+ default_time_limit_minutes: null,
+ proctoring_exam_configuration_link: null,
+ supports_onboarding: false,
+ show_review_rules: true,
+ child_info: {
+ category: 'vertical',
+ display_name: 'Unit',
+ children: [],
+ },
+ ancestor_has_staff_lock: false,
+ staff_only_message: false,
+ enable_copy_paste_units: false,
+ has_partition_group_components: false,
+ user_partition_info: {
+ selectable_partitions: [
+ {
+ id: 50,
+ name: 'Enrollment Track Groups',
+ scheme: 'enrollment_track',
+ groups: [
+ {
+ id: 2,
+ name: 'Verified Certificate',
+ selected: false,
+ deleted: false,
+ },
+ {
+ id: 1,
+ name: 'Audit',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ ],
+ selected_partition_index: -1,
+ selected_groups_label: '',
+ },
+};
diff --git a/src/course-outline/__mocks__/index.js b/src/course-outline/__mocks__/index.js
index e699605ef6..15c6504cb1 100644
--- a/src/course-outline/__mocks__/index.js
+++ b/src/course-outline/__mocks__/index.js
@@ -2,3 +2,5 @@ export { default as courseOutlineIndexMock } from './courseOutlineIndex';
export { default as courseOutlineIndexWithoutSections } from './courseOutlineIndexWithoutSections';
export { default as courseBestPracticesMock } from './courseBestPractices';
export { default as courseLaunchMock } from './courseLaunch';
+export { default as courseSectionMock } from './courseSection';
+export { default as courseSubsectionMock } from './courseSubsection';
diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx
new file mode 100644
index 0000000000..2c06e02220
--- /dev/null
+++ b/src/course-outline/card-header/CardHeader.jsx
@@ -0,0 +1,186 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Button,
+ Dropdown,
+ Form,
+ Icon,
+ IconButton,
+ OverlayTrigger,
+ Tooltip,
+ Truncate,
+} from '@edx/paragon';
+import {
+ ArrowDropDown as ArrowDownIcon,
+ ArrowDropUp as ArrowUpIcon,
+ MoreVert as MoveVertIcon,
+ EditOutline as EditIcon,
+} from '@edx/paragon/icons';
+import classNames from 'classnames';
+
+import { useEscapeClick } from '../../hooks';
+import { ITEM_BADGE_STATUS } from '../constants';
+import { getItemStatusBadgeContent } from '../utils';
+import messages from './messages';
+
+const CardHeader = ({
+ title,
+ status,
+ hasChanges,
+ isExpanded,
+ onClickPublish,
+ onClickConfigure,
+ onClickMenuButton,
+ onClickEdit,
+ onExpand,
+ isFormOpen,
+ onEditSubmit,
+ closeForm,
+ isDisabledEditField,
+ onClickDelete,
+ onClickDuplicate,
+ namePrefix,
+}) => {
+ const intl = useIntl();
+ const [titleValue, setTitleValue] = useState(title);
+
+ const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl);
+ const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
+ || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
+
+ useEscapeClick({
+ onEscape: () => {
+ setTitleValue(title);
+ closeForm();
+ },
+ dependency: title,
+ });
+
+ return (
+
+ {isFormOpen ? (
+
+ e && e.focus()}
+ value={titleValue}
+ name="displayName"
+ onChange={(e) => setTitleValue(e.target.value)}
+ aria-label="edit field"
+ onBlur={() => onEditSubmit(titleValue)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ onEditSubmit(titleValue);
+ }
+ }}
+ disabled={isDisabledEditField}
+ />
+
+ ) : (
+
+ {intl.formatMessage(messages.expandTooltip)}
+
+ )}
+ >
+
+
+ )}
+
+ {!isFormOpen && (
+
+ )}
+
+
+
+
+ {intl.formatMessage(messages.menuPublish)}
+
+
+ {intl.formatMessage(messages.menuConfigure)}
+
+
+ {intl.formatMessage(messages.menuDuplicate)}
+
+
+ {intl.formatMessage(messages.menuDelete)}
+
+
+
+
+
+ );
+};
+
+CardHeader.propTypes = {
+ title: PropTypes.string.isRequired,
+ status: PropTypes.string.isRequired,
+ hasChanges: PropTypes.bool.isRequired,
+ 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,
+ onEditSubmit: PropTypes.func.isRequired,
+ closeForm: PropTypes.func.isRequired,
+ isDisabledEditField: PropTypes.bool.isRequired,
+ onClickDelete: PropTypes.func.isRequired,
+ onClickDuplicate: PropTypes.func.isRequired,
+ namePrefix: PropTypes.string.isRequired,
+};
+
+export default CardHeader;
diff --git a/src/course-outline/card-header/CardHeader.scss b/src/course-outline/card-header/CardHeader.scss
new file mode 100644
index 0000000000..12744ba9f6
--- /dev/null
+++ b/src/course-outline/card-header/CardHeader.scss
@@ -0,0 +1,44 @@
+.item-card-header {
+ display: flex;
+ align-items: center;
+ margin-right: -.5rem;
+
+ .item-card-header__expanded-btn {
+ justify-content: flex-start;
+ padding: 0;
+ width: 80%;
+ height: 1.5rem;
+ margin-right: .25rem;
+ background: transparent;
+ color: $black;
+ }
+
+ .item-card-header__badge-status {
+ display: flex;
+ padding: 1px .5rem;
+ justify-content: center;
+ align-items: center;
+ gap: .25rem;
+ border-radius: .375rem;
+ border: 1px solid $light-300;
+ margin: 0 .75rem;
+
+ & span:last-child {
+ color: $primary-700;
+ }
+ }
+
+ .pgn__form-group {
+ width: 80%;
+ }
+}
+
+.item-card-header-tooltip {
+ .tooltip-inner {
+ max-width: 18.75rem;
+ }
+
+ .arrow {
+ transform: translate(5.75rem, 0) !important;
+ }
+}
diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.jsx
new file mode 100644
index 0000000000..704d69baad
--- /dev/null
+++ b/src/course-outline/card-header/CardHeader.test.jsx
@@ -0,0 +1,204 @@
+import React from 'react';
+import { render, fireEvent, waitFor } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import { ITEM_BADGE_STATUS } from '../constants';
+import CardHeader from './CardHeader';
+import messages from './messages';
+
+const onExpandMock = jest.fn();
+const onClickMenuButtonMock = jest.fn();
+const onClickPublishMock = jest.fn();
+const onClickEditMock = jest.fn();
+const onClickDeleteMock = jest.fn();
+const onClickDuplicateMock = jest.fn();
+const closeFormMock = jest.fn();
+
+const cardHeaderProps = {
+ title: 'Some title',
+ status: ITEM_BADGE_STATUS.live,
+ hasChanges: false,
+ isExpanded: true,
+ onExpand: onExpandMock,
+ onClickMenuButton: onClickMenuButtonMock,
+ onClickPublish: onClickPublishMock,
+ onClickEdit: onClickEditMock,
+ isFormOpen: false,
+ onEditSubmit: jest.fn(),
+ closeForm: closeFormMock,
+ isDisabledEditField: false,
+ onClickDelete: onClickDeleteMock,
+ onClickDuplicate: onClickDuplicateMock,
+ namePrefix: 'section',
+};
+
+const renderComponent = (props) => render(
+
+
+ ,
+);
+
+describe('', () => {
+ it('render CardHeader component correctly', async () => {
+ const { findByText, findByTestId, queryByTestId } = renderComponent();
+
+ expect(await findByText(cardHeaderProps.title)).toBeInTheDocument();
+ expect(await findByTestId('section-card-header__expanded-btn')).toBeInTheDocument();
+ expect(await findByTestId('section-card-header__badge-status')).toBeInTheDocument();
+ expect(await findByTestId('section-card-header__menu')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(queryByTestId('edit field')).not.toBeInTheDocument();
+ });
+ });
+
+ it('render status badge as live', async () => {
+ const { findByText } = renderComponent();
+ expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('render status badge as published_not_live', async () => {
+ const { findByText } = renderComponent({
+ ...cardHeaderProps,
+ status: ITEM_BADGE_STATUS.publishedNotLive,
+ });
+
+ expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('render status badge as staff_only', async () => {
+ const { findByText } = renderComponent({
+ ...cardHeaderProps,
+ status: ITEM_BADGE_STATUS.staffOnly,
+ });
+
+ expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('render status badge as draft', async () => {
+ const { findByText } = renderComponent({
+ ...cardHeaderProps,
+ status: ITEM_BADGE_STATUS.draft,
+ });
+
+ expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('check publish menu item is disabled when section status is live or published not live and it has no changes', async () => {
+ const { findByText, findByTestId } = renderComponent({
+ ...cardHeaderProps,
+ status: ITEM_BADGE_STATUS.publishedNotLive,
+ });
+
+ const menuButton = await findByTestId('section-card-header__menu-button');
+ fireEvent.click(menuButton);
+ expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('check publish menu item is enabled when section status is live or published not live and it has changes', async () => {
+ const { findByText, findByTestId } = renderComponent({
+ ...cardHeaderProps,
+ status: ITEM_BADGE_STATUS.publishedNotLive,
+ hasChanges: true,
+ });
+
+ const menuButton = await findByTestId('section-card-header__menu-button');
+ fireEvent.click(menuButton);
+ expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
+ });
+
+ it('calls handleExpanded when button is clicked', async () => {
+ const { findByTestId } = renderComponent();
+
+ const expandButton = await findByTestId('section-card-header__expanded-btn');
+ fireEvent.click(expandButton);
+ expect(onExpandMock).toHaveBeenCalled();
+ });
+
+ it('calls onClickMenuButton when menu is clicked', async () => {
+ const { findByTestId } = renderComponent();
+
+ const menuButton = await findByTestId('section-card-header__menu-button');
+ fireEvent.click(menuButton);
+ waitFor(() => {
+ expect(onClickMenuButtonMock).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClickPublish when item is clicked', async () => {
+ const { findByText, findByTestId } = renderComponent({
+ ...cardHeaderProps,
+ status: ITEM_BADGE_STATUS.draft,
+ });
+
+ const menuButton = await findByTestId('section-card-header__menu-button');
+ fireEvent.click(menuButton);
+
+ const publishMenuItem = await findByText(messages.menuPublish.defaultMessage);
+ fireEvent.click(publishMenuItem);
+ waitFor(() => {
+ expect(onClickPublishMock).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClickEdit when the button is clicked', async () => {
+ const { findByTestId } = renderComponent();
+
+ const editButton = await findByTestId('section-edit-button');
+ fireEvent.click(editButton);
+ waitFor(() => {
+ expect(onClickEditMock).toHaveBeenCalled();
+ });
+ });
+
+ it('check is field visible when isFormOpen is true', async () => {
+ const { findByTestId, queryByTestId } = renderComponent({
+ ...cardHeaderProps,
+ isFormOpen: true,
+ });
+
+ expect(await findByTestId('section-edit-field')).toBeInTheDocument();
+ waitFor(() => {
+ expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument();
+ expect(queryByTestId('edit-button')).not.toBeInTheDocument();
+ });
+ });
+
+ it('check is field disabled when isDisabledEditField is true', async () => {
+ const { findByTestId } = renderComponent({
+ ...cardHeaderProps,
+ isFormOpen: true,
+ isDisabledEditField: true,
+ });
+
+ expect(await findByTestId('section-edit-field')).toBeDisabled();
+ });
+
+ it('calls onClickDelete when item is clicked', async () => {
+ const { findByText, findByTestId } = renderComponent();
+
+ const menuButton = await findByTestId('section-card-header__menu-button');
+ fireEvent.click(menuButton);
+
+ const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage);
+ fireEvent.click(deleteMenuItem);
+ waitFor(() => {
+ expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('calls onClickDuplicate when item is clicked', async () => {
+ const { findByText, findByTestId } = renderComponent();
+
+ const menuButton = await findByTestId('section-card-header__menu-button');
+ fireEvent.click(menuButton);
+
+ const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage);
+ fireEvent.click(duplicateMenuItem);
+ waitFor(() => {
+ expect(onClickDuplicateMock).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js
new file mode 100644
index 0000000000..0197722dd9
--- /dev/null
+++ b/src/course-outline/card-header/messages.js
@@ -0,0 +1,46 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ expandTooltip: {
+ id: 'course-authoring.course-outline.card.expandTooltip',
+ defaultMessage: 'Collapse/Expand this card',
+ },
+ statusBadgeLive: {
+ id: 'course-authoring.course-outline.card.status-badge.live',
+ defaultMessage: 'Live',
+ },
+ statusBadgePublishedNotLive: {
+ id: 'course-authoring.course-outline.card.status-badge.published-not-live',
+ defaultMessage: 'Published not live',
+ },
+ statusBadgeStaffOnly: {
+ id: 'course-authoring.course-outline.card.status-badge.staff-only',
+ defaultMessage: 'Staff only',
+ },
+ statusBadgeDraft: {
+ id: 'course-authoring.course-outline.card.status-badge.draft',
+ defaultMessage: 'Draft',
+ },
+ altButtonEdit: {
+ id: 'course-authoring.course-outline.card.button.edit.alt',
+ defaultMessage: 'Edit',
+ },
+ menuPublish: {
+ id: 'course-authoring.course-outline.card.menu.publish',
+ defaultMessage: 'Publish',
+ },
+ menuConfigure: {
+ id: 'course-authoring.course-outline.card.menu.configure',
+ defaultMessage: 'Configure',
+ },
+ menuDuplicate: {
+ id: 'course-authoring.course-outline.card.menu.duplicate',
+ defaultMessage: 'Duplicate',
+ },
+ menuDelete: {
+ id: 'course-authoring.course-outline.card.menu.delete',
+ defaultMessage: 'Delete',
+ },
+});
+
+export default messages;
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..5490ef8942
--- /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 { getCurrentItem } 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(getCurrentItem);
+ 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..b9a331a4eb
--- /dev/null
+++ b/src/course-outline/configure-modal/ConfigureModal.test.jsx
@@ -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(
+
+
+
+ ,
+ ,
+);
+
+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();
+ });
+});
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/constants.js b/src/course-outline/constants.js
index cd4c58eeb4..f9a2c8821e 100644
--- a/src/course-outline/constants.js
+++ b/src/course-outline/constants.js
@@ -1,10 +1,27 @@
-export const CHECKLIST_FILTERS = {
+export const ITEM_BADGE_STATUS = /** @type {const} */ ({
+ live: 'live',
+ publishedNotLive: 'published_not_live',
+ staffOnly: 'staff_only',
+ draft: 'draft',
+});
+
+export const STAFF_ONLY = 'staff_only';
+
+export const HIGHLIGHTS_FIELD_MAX_LENGTH = 250;
+
+export const CHECKLIST_FILTERS = /** @type {const} */ ({
ALL: 'ALL',
SELF_PACED: 'SELF_PACED',
INSTRUCTOR_PACED: 'INSTRUCTOR_PACED',
-};
+});
+
+export const COURSE_BLOCK_NAMES = /** @type {const} */ ({
+ chapter: { id: 'chapter', name: 'Section' },
+ sequential: { id: 'sequential', name: 'Subsection' },
+ vertical: { id: 'vertical', name: 'Unit' },
+});
-export const LAUNCH_CHECKLIST = {
+export const LAUNCH_CHECKLIST = /** @type {const} */ ({
data: [
{
id: 'welcomeMessage',
@@ -31,9 +48,9 @@ export const LAUNCH_CHECKLIST = {
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
],
-};
+});
-export const BEST_PRACTICES_CHECKLIST = {
+export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({
data: [
{
id: 'videoDuration',
@@ -56,4 +73,4 @@ export const BEST_PRACTICES_CHECKLIST = {
pacingTypeFilter: CHECKLIST_FILTERS.ALL,
},
],
-};
+});
diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js
index 9b613bf81c..702715ce8f 100644
--- a/src/course-outline/data/api.js
+++ b/src/course-outline/data/api.js
@@ -1,3 +1,4 @@
+// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -24,11 +25,32 @@ export const getEnableHighlightsEmailsApiUrl = (courseId) => {
};
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
+export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
+export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
+export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
+
+/**
+ * @typedef {Object} courseOutline
+ * @property {string} courseReleaseDate
+ * @property {Object} courseStructure
+ * @property {Object} deprecatedBlocksInfo
+ * @property {string} discussionsIncontextFeedbackUrl
+ * @property {string} discussionsIncontextLearnmoreUrl
+ * @property {Object} initialState
+ * @property {Object} initialUserClipboard
+ * @property {string} languageCode
+ * @property {string} lmsLink
+ * @property {string} mfeProctoredExamSettingsUrl
+ * @property {string} notificationDismissUrl
+ * @property {string[]} proctoringErrors
+ * @property {string} reindexLink
+ * @property {null} rerunNotificationId
+ */
/**
* Get course outline index.
* @param {string} courseId
- * @returns {Promise,
@@ -78,4 +80,40 @@ describe('', () => {
expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument();
});
+
+ it('render collapse button correctly', () => {
+ const { getByRole } = renderComponent({
+ isSectionsExpanded: true,
+ });
+
+ expect(getByRole('button', { name: messages.collapseAllButton.defaultMessage })).toBeInTheDocument();
+ });
+
+ it('render expand button correctly', () => {
+ const { getByRole } = renderComponent({
+ isSectionsExpanded: false,
+ });
+
+ expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument();
+ });
+
+ it('render reindex button tooltip correctly', async () => {
+ const { getByText, getByRole } = renderComponent({
+ isDisabledReindexButton: false,
+ });
+ userEvent.hover(getByRole('button', { name: messages.reindexButton.defaultMessage }));
+ await waitFor(() => {
+ expect(getByText(messages.reindexButtonTooltip.defaultMessage)).toBeInTheDocument();
+ });
+ });
+
+ it('not render reindex button tooltip when button is disabled correctly', async () => {
+ const { queryByText, getByRole } = renderComponent({
+ isDisabledReindexButton: true,
+ });
+ userEvent.hover(getByRole('button', { name: messages.reindexButton.defaultMessage }));
+ await waitFor(() => {
+ expect(queryByText(messages.reindexButtonTooltip.defaultMessage)).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/src/course-outline/highlights-modal/HighlightsModal.jsx b/src/course-outline/highlights-modal/HighlightsModal.jsx
new file mode 100644
index 0000000000..8d1a73ec20
--- /dev/null
+++ b/src/course-outline/highlights-modal/HighlightsModal.jsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ ModalDialog,
+ Button,
+ ActionRow,
+ Hyperlink,
+} from '@edx/paragon';
+import { Formik } from 'formik';
+import { useSelector } from 'react-redux';
+
+import { useHelpUrls } from '../../help-urls/hooks';
+import FormikControl from '../../generic/FormikControl';
+import { getCurrentSection } from '../data/selectors';
+import { HIGHLIGHTS_FIELD_MAX_LENGTH } from '../constants';
+import { getHighlightsFormValues } from '../utils';
+import messages from './messages';
+
+const HighlightsModal = ({
+ isOpen,
+ onClose,
+ onSubmit,
+}) => {
+ const intl = useIntl();
+ const { highlights = [], displayName } = useSelector(getCurrentSection);
+ const initialFormValues = getHighlightsFormValues(highlights);
+
+ const {
+ contentHighlights: contentHighlightsUrl,
+ } = useHelpUrls(['contentHighlights']);
+
+ return (
+
+
+
+ {intl.formatMessage(messages.title, {
+ title: displayName,
+ })}
+
+
+
+ {({ values, dirty, handleSubmit }) => (
+ <>
+
+
+ {intl.formatMessage(messages.description, {
+ documentation: (
+
+ {intl.formatMessage(messages.documentationLink)}
+ ),
+ })}
+
+ {Object.entries(initialFormValues).map(([key], index) => (
+
+ ))}
+
+
+
+
+ {intl.formatMessage(messages.cancelButton)}
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+HighlightsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+};
+
+export default HighlightsModal;
diff --git a/src/course-outline/highlights-modal/HighlightsModal.scss b/src/course-outline/highlights-modal/HighlightsModal.scss
new file mode 100644
index 0000000000..ef991d8be2
--- /dev/null
+++ b/src/course-outline/highlights-modal/HighlightsModal.scss
@@ -0,0 +1,15 @@
+.highlights-modal {
+ max-width: 33.6875rem;
+
+ .highlights-modal__header {
+ padding-top: 1.5rem;
+ }
+
+ .form-control {
+ color: $black;
+ }
+
+ .pgn__form-control-decorator-group {
+ margin-inline-end: 0;
+ }
+}
diff --git a/src/course-outline/highlights-modal/HighlightsModal.test.jsx b/src/course-outline/highlights-modal/HighlightsModal.test.jsx
new file mode 100644
index 0000000000..0a767c2a60
--- /dev/null
+++ b/src/course-outline/highlights-modal/HighlightsModal.test.jsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import {
+ render, fireEvent, act, waitFor,
+} 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 HighlightsModal from './HighlightsModal';
+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,
+ }),
+}));
+
+jest.mock('../../help-urls/hooks', () => ({
+ useHelpUrls: () => ({
+ contentHighlights: 'some',
+ }),
+}));
+
+const currentItemMock = {
+ highlights: ['Highlight 1', 'Highlight 2'],
+ displayName: 'Test Section',
+};
+
+const onCloseMock = jest.fn();
+const onSubmitMock = jest.fn();
+
+const renderComponent = () => render(
+
+
+
+ ,
+ ,
+);
+
+describe('', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ useSelector.mockReturnValue(currentItemMock);
+ });
+
+ it('renders HighlightsModal component correctly', () => {
+ const { getByText, getByRole, getByLabelText } = renderComponent();
+
+ expect(getByText(`Highlights for ${currentItemMock.displayName}`)).toBeInTheDocument();
+ expect(getByText(/Enter 3-5 highlights to include in the email message that learners receive for this section/i)).toBeInTheDocument();
+ expect(getByText(/For more information and an example of the email template, read our/i)).toBeInTheDocument();
+ expect(getByText(messages.documentationLink.defaultMessage)).toBeInTheDocument();
+ expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '1'))).toBeInTheDocument();
+ expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '2'))).toBeInTheDocument();
+ expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '3'))).toBeInTheDocument();
+ expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '4'))).toBeInTheDocument();
+ expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '5'))).toBeInTheDocument();
+ expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
+ expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
+ });
+
+ 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);
+ });
+
+ it('calls the onSubmit function with correct values when the save button is clicked', async () => {
+ const { getByRole, getByLabelText } = renderComponent();
+
+ const field1 = getByLabelText(messages.highlight.defaultMessage.replace('{index}', '1'));
+ const field2 = getByLabelText(messages.highlight.defaultMessage.replace('{index}', '2'));
+ fireEvent.change(field1, { target: { value: 'New highlight 1' } });
+ fireEvent.change(field2, { target: { value: 'New highlight 2' } });
+
+ const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ });
+
+ await waitFor(() => {
+ expect(onSubmitMock).toHaveBeenCalledTimes(1);
+ expect(onSubmitMock).toHaveBeenCalledWith(
+ {
+ highlight_1: 'New highlight 1',
+ highlight_2: 'New highlight 2',
+ highlight_3: '',
+ highlight_4: '',
+ highlight_5: '',
+ },
+ expect.objectContaining({ submitForm: expect.any(Function) }),
+ );
+ });
+ });
+});
diff --git a/src/course-outline/highlights-modal/messages.js b/src/course-outline/highlights-modal/messages.js
new file mode 100644
index 0000000000..e8c083a9dc
--- /dev/null
+++ b/src/course-outline/highlights-modal/messages.js
@@ -0,0 +1,30 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ title: {
+ id: 'course-authoring.course-outline.highlights-modal.title',
+ defaultMessage: 'Highlights for {title}',
+ },
+ description: {
+ id: 'course-authoring.course-outline.highlights-modal.description',
+ defaultMessage: 'Enter 3-5 highlights to include in the email message that learners receive for this section (250 character limit). For more information and an example of the email template, read our {documentation}.',
+ },
+ documentationLink: {
+ id: 'course-authoring.course-outline.highlights-modal.documentation-link',
+ defaultMessage: 'documentation',
+ },
+ highlight: {
+ id: 'course-authoring.course-outline.highlights-modal.highlight',
+ defaultMessage: 'Highlight {index}',
+ },
+ cancelButton: {
+ id: 'course-authoring.course-outline.highlights-modal.button.cancel',
+ defaultMessage: 'Cancel',
+ },
+ saveButton: {
+ id: 'course-authoring.course-outline.highlights-modal.button.save',
+ defaultMessage: 'Save',
+ },
+});
+
+export default messages;
diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx
index f96b84641c..ec361ce6ce 100644
--- a/src/course-outline/hooks.jsx
+++ b/src/course-outline/hooks.jsx
@@ -3,39 +3,73 @@ import { useDispatch, useSelector } from 'react-redux';
import { useToggle } from '@edx/paragon';
import { RequestStatus } from '../data/constants';
-import { updateSavingStatus } from './data/slice';
+import { COURSE_BLOCK_NAMES } from './constants';
+import {
+ setCurrentItem,
+ setCurrentSection,
+ updateSavingStatus,
+} from './data/slice';
import {
getLoadingStatus,
getOutlineIndexData,
getSavingStatus,
getStatusBarData,
+ getSectionsList,
+ getCurrentItem,
+ getCurrentSection,
+ getCurrentSubsection,
} from './data/selectors';
import {
+ addNewSectionQuery,
+ addNewSubsectionQuery,
+ deleteCourseSectionQuery,
+ deleteCourseSubsectionQuery,
+ editCourseItemQuery,
+ duplicateSectionQuery,
+ duplicateSubsectionQuery,
enableCourseHighlightsEmailsQuery,
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery,
fetchCourseReindexQuery,
+ publishCourseItemQuery,
+ updateCourseSectionHighlightsQuery,
+ configureCourseSectionQuery,
+ setSectionOrderListQuery,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
- const { reindexLink, lmsLink } = useSelector(getOutlineIndexData);
+ const { reindexLink, courseStructure, lmsLink } = useSelector(getOutlineIndexData);
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
const statusBarData = useSelector(getStatusBarData);
const savingStatus = useSelector(getSavingStatus);
+ const sectionsList = useSelector(getSectionsList);
+ const currentItem = useSelector(getCurrentItem);
+ const currentSection = useSelector(getCurrentSection);
+ const currentSubsection = useSelector(getCurrentSubsection);
const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false);
- const [isSectionsExpanded, setSectionsExpanded] = useState(false);
+ const [isSectionsExpanded, setSectionsExpanded] = useState(true);
const [isDisabledReindexButton, setDisableReindexButton] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
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 = () => {
+ dispatch(addNewSectionQuery(courseStructure.id));
+ };
+
+ const handleNewSubsectionSubmit = (sectionId) => {
+ dispatch(addNewSubsectionQuery(sectionId));
+ };
const headerNavigationsActions = {
- handleNewSection: () => {
- // TODO add handler
- },
+ handleNewSection: handleNewSectionSubmit,
handleReIndex: () => {
setDisableReindexButton(true);
setShowSuccessAlert(false);
@@ -60,6 +94,64 @@ const useCourseOutline = ({ courseId }) => {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
};
+ const handleOpenHighlightsModal = (section) => {
+ dispatch(setCurrentItem(section));
+ dispatch(setCurrentSection(section));
+ openHighlightsModal();
+ };
+
+ const handleHighlightsFormSubmit = (highlights) => {
+ const dataToSend = Object.values(highlights).filter(Boolean);
+ dispatch(updateCourseSectionHighlightsQuery(currentItem.id, dataToSend));
+
+ closeHighlightsModal();
+ };
+
+ const handlePublishItemSubmit = () => {
+ dispatch(publishCourseItemQuery(currentItem.id, currentSection.id));
+
+ closePublishModal();
+ };
+
+ const handleConfigureSectionSubmit = (isVisibleToStaffOnly, startDatetime) => {
+ dispatch(configureCourseSectionQuery(currentSection.id, isVisibleToStaffOnly, startDatetime));
+
+ closeConfigureModal();
+ };
+
+ const handleEditSubmit = (itemId, sectionId, displayName) => {
+ dispatch(editCourseItemQuery(itemId, sectionId, displayName));
+ };
+
+ const handleDeleteItemSubmit = () => {
+ switch (currentItem.category) {
+ case COURSE_BLOCK_NAMES.chapter.id:
+ dispatch(deleteCourseSectionQuery(currentItem.id));
+ break;
+ case COURSE_BLOCK_NAMES.sequential.id:
+ dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id));
+ break;
+ case COURSE_BLOCK_NAMES.vertical.id:
+ // delete unit
+ break;
+ default:
+ return;
+ }
+ closeDeleteModal();
+ };
+
+ const handleDuplicateSectionSubmit = () => {
+ dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id));
+ };
+
+ const handleDuplicateSubsectionSubmit = () => {
+ dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id));
+ };
+
+ const handleDragNDrop = (newListId, restoreCallback) => {
+ dispatch(setSectionOrderListQuery(courseId, newListId, restoreCallback));
+ };
+
useEffect(() => {
dispatch(fetchCourseOutlineIndexQuery(courseId));
dispatch(fetchCourseBestPracticesQuery({ courseId }));
@@ -78,20 +170,44 @@ const useCourseOutline = ({ courseId }) => {
return {
savingStatus,
+ sectionsList,
isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS,
isReIndexShow: Boolean(reindexLink),
showSuccessAlert,
showErrorAlert,
isDisabledReindexButton,
isSectionsExpanded,
+ isPublishModalOpen,
+ openPublishModal,
+ closePublishModal,
+ isConfigureModalOpen,
+ openConfigureModal,
+ closeConfigureModal,
headerNavigationsActions,
handleEnableHighlightsSubmit,
+ handleHighlightsFormSubmit,
+ handleConfigureSectionSubmit,
+ handlePublishItemSubmit,
+ handleEditSubmit,
statusBarData,
isEnableHighlightsModalOpen,
openEnableHighlightsModal,
closeEnableHighlightsModal,
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
handleInternetConnectionFailed,
+ handleOpenHighlightsModal,
+ isHighlightsModalOpen,
+ closeHighlightsModal,
+ courseName: courseStructure?.displayName,
+ isDeleteModalOpen,
+ closeDeleteModal,
+ openDeleteModal,
+ handleDeleteItemSubmit,
+ handleDuplicateSectionSubmit,
+ handleDuplicateSubsectionSubmit,
+ handleNewSectionSubmit,
+ handleNewSubsectionSubmit,
+ handleDragNDrop,
};
};
diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js
index 387b7f8ded..b1ff8cde6e 100644
--- a/src/course-outline/messages.js
+++ b/src/course-outline/messages.js
@@ -29,6 +29,14 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.reindex.alert.error.title',
defaultMessage: 'There were errors reindexing course.',
},
+ newSectionButton: {
+ id: 'course-authoring.course-outline.section-list.button.new-section',
+ defaultMessage: 'New section',
+ },
+ alertFailedGeneric: {
+ id: 'course-authoring.course-outline.general.alert.error.description',
+ defaultMessage: 'Unable to {actionName} {type}. Please try again.',
+ },
});
export default messages;
diff --git a/src/course-outline/publish-modal/PublishModal.jsx b/src/course-outline/publish-modal/PublishModal.jsx
new file mode 100644
index 0000000000..d14057370f
--- /dev/null
+++ b/src/course-outline/publish-modal/PublishModal.jsx
@@ -0,0 +1,88 @@
+/* eslint-disable import/named */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ ModalDialog,
+ Button,
+ ActionRow,
+} from '@edx/paragon';
+import { useSelector } from 'react-redux';
+
+import { getCurrentItem } from '../data/selectors';
+import messages from './messages';
+
+const PublishModal = ({
+ isOpen,
+ onClose,
+ onPublishSubmit,
+}) => {
+ const intl = useIntl();
+ const { displayName, childInfo, category } = useSelector(getCurrentItem);
+ const children = childInfo?.children || [];
+
+ return (
+
+
+
+ {intl.formatMessage(messages.title, { title: displayName })}
+
+
+
+ {intl.formatMessage(messages.description, { category })}
+ {children.filter(child => child.hasChanges).map((child) => {
+ let grandChildren = child.childInfo?.children || [];
+ grandChildren = grandChildren.filter(grandChild => grandChild.hasChanges);
+
+ return grandChildren.length ? (
+
+ {child.displayName}
+ {grandChildren.map((grandChild) => (
+
+ {grandChild.displayName}
+
+ ))}
+
+ ) : (
+
+ {child.displayName}
+
+ );
+ })}
+
+
+
+
+ {intl.formatMessage(messages.cancelButton)}
+
+
+
+
+
+ );
+};
+
+PublishModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onPublishSubmit: PropTypes.func.isRequired,
+};
+
+export default PublishModal;
diff --git a/src/course-outline/publish-modal/PublishModal.scss b/src/course-outline/publish-modal/PublishModal.scss
new file mode 100644
index 0000000000..08a8b0a573
--- /dev/null
+++ b/src/course-outline/publish-modal/PublishModal.scss
@@ -0,0 +1,15 @@
+.publish-modal {
+ max-width: 33.6875rem;
+
+ .pgn__modal-close-container {
+ transform: translateY(.5rem);
+ }
+
+ .publish-modal__header {
+ padding-top: 1.5rem;
+ }
+
+ .publish-modal__subsection:not(:last-child) {
+ margin-bottom: .5rem;
+ }
+}
diff --git a/src/course-outline/publish-modal/PublishModal.test.jsx b/src/course-outline/publish-modal/PublishModal.test.jsx
new file mode 100644
index 0000000000..9c47a84c71
--- /dev/null
+++ b/src/course-outline/publish-modal/PublishModal.test.jsx
@@ -0,0 +1,136 @@
+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 PublishModal from './PublishModal';
+import messages from './messages';
+
+// eslint-disable-next-line no-unused-vars
+let axiosMock;
+let store;
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+jest.mock('@edx/frontend-platform/i18n', () => ({
+ ...jest.requireActual('@edx/frontend-platform/i18n'),
+ useIntl: () => ({
+ formatMessage: (message) => message.defaultMessage,
+ }),
+}));
+
+const currentItemMock = {
+ displayName: 'Publish',
+ childInfo: {
+ displayName: 'Subsection',
+ children: [
+ {
+ displayName: 'Subsection 1',
+ id: 1,
+ hasChanges: true,
+ childInfo: {
+ displayName: 'Unit',
+ children: [
+ {
+ id: 11,
+ displayName: 'Subsection_1 Unit 1',
+ hasChanges: true,
+ },
+ ],
+ },
+ },
+ {
+ displayName: 'Subsection 2',
+ id: 2,
+ hasChanges: true,
+ childInfo: {
+ displayName: 'Unit',
+ children: [
+ {
+ id: 21,
+ displayName: 'Subsection_2 Unit 1',
+ hasChanges: true,
+ },
+ ],
+ },
+ },
+ {
+ displayName: 'Subsection 3',
+ id: 3,
+ childInfo: {
+ children: [],
+ },
+ },
+ ],
+ },
+};
+
+const onCloseMock = jest.fn();
+const onPublishSubmitMock = jest.fn();
+
+const renderComponent = () => render(
+
+
+
+ ,
+ ,
+);
+
+describe('', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ useSelector.mockReturnValue(currentItemMock);
+ });
+
+ it('renders PublishModal component correctly', () => {
+ const { getByText, getByRole, queryByText } = renderComponent();
+
+ expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
+ expect(getByText(/Subsection 1/i)).toBeInTheDocument();
+ expect(getByText(/Subsection_1 Unit 1/i)).toBeInTheDocument();
+ expect(getByText(/Subsection 2/i)).toBeInTheDocument();
+ expect(getByText(/Subsection_2 Unit 1/i)).toBeInTheDocument();
+ expect(queryByText(/Subsection 3/i)).not.toBeInTheDocument();
+ expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
+ expect(getByRole('button', { name: messages.publishButton.defaultMessage })).toBeInTheDocument();
+ });
+
+ 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);
+ });
+
+ it('calls the onPublishSubmit function when save button is clicked', async () => {
+ const { getByRole } = renderComponent();
+
+ const publishButton = getByRole('button', { name: messages.publishButton.defaultMessage });
+ fireEvent.click(publishButton);
+ expect(onPublishSubmitMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/course-outline/publish-modal/messages.js b/src/course-outline/publish-modal/messages.js
new file mode 100644
index 0000000000..7ea14a73b7
--- /dev/null
+++ b/src/course-outline/publish-modal/messages.js
@@ -0,0 +1,22 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ title: {
+ id: 'course-authoring.course-outline.publish-modal.title',
+ defaultMessage: 'Publish {title}',
+ },
+ description: {
+ id: 'course-authoring.course-outline.publish-modal.description',
+ defaultMessage: 'Publish all unpublished changes for this {category}?',
+ },
+ cancelButton: {
+ id: 'course-authoring.course-outline.publish-modal.button.cancel',
+ defaultMessage: 'Cancel',
+ },
+ publishButton: {
+ id: 'course-authoring.course-outline.publish-modal.button.label',
+ defaultMessage: 'Publish',
+ },
+});
+
+export default messages;
diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx
new file mode 100644
index 0000000000..8b6730d37b
--- /dev/null
+++ b/src/course-outline/section-card/SectionCard.jsx
@@ -0,0 +1,187 @@
+import React, {
+ useEffect, useState, useRef,
+} from 'react';
+import PropTypes from 'prop-types';
+import { useDispatch } from 'react-redux';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Badge, Button, useToggle } from '@edx/paragon';
+import { Add as IconAdd } from '@edx/paragon/icons';
+
+import { setCurrentItem, setCurrentSection } from '../data/slice';
+import { RequestStatus } from '../../data/constants';
+import CardHeader from '../card-header/CardHeader';
+import { getItemStatus, scrollToElement } from '../utils';
+import messages from './messages';
+
+const SectionCard = ({
+ section,
+ children,
+ onOpenHighlightsModal,
+ onOpenPublishModal,
+ onOpenConfigureModal,
+ onEditSectionSubmit,
+ savingStatus,
+ onOpenDeleteModal,
+ onDuplicateSubmit,
+ isSectionsExpanded,
+ onNewSubsectionSubmit,
+}) => {
+ const currentRef = useRef(null);
+ const intl = useIntl();
+ const dispatch = useDispatch();
+ const [isExpanded, setIsExpanded] = useState(isSectionsExpanded);
+ const [isFormOpen, openForm, closeForm] = useToggle(false);
+
+ useEffect(() => {
+ setIsExpanded(isSectionsExpanded);
+ }, [isSectionsExpanded]);
+
+ useEffect(() => {
+ // if this items has been newly added, scroll to it.
+ if (currentRef.current && section.shouldScroll) {
+ scrollToElement(currentRef.current);
+ }
+ }, []);
+
+ const {
+ id,
+ displayName,
+ hasChanges,
+ published,
+ releasedToStudents,
+ visibleToStaffOnly = false,
+ visibilityState,
+ staffOnlyMessage,
+ highlights,
+ } = section;
+
+ const sectionStatus = getItemStatus({
+ published,
+ releasedToStudents,
+ visibleToStaffOnly,
+ visibilityState,
+ staffOnlyMessage,
+ });
+
+ const handleExpandContent = () => {
+ setIsExpanded((prevState) => !prevState);
+ };
+
+ const handleClickMenuButton = () => {
+ dispatch(setCurrentItem(section));
+ dispatch(setCurrentSection(section));
+ };
+
+ const handleEditSubmit = (titleValue) => {
+ if (displayName !== titleValue) {
+ // both itemId and sectionId are same
+ onEditSectionSubmit(id, id, titleValue);
+ return;
+ }
+
+ closeForm();
+ };
+
+ const handleOpenHighlightsModal = () => {
+ onOpenHighlightsModal(section);
+ };
+
+ const handleNewSubsectionSubmit = () => {
+ onNewSubsectionSubmit(id);
+ };
+
+ useEffect(() => {
+ if (savingStatus === RequestStatus.SUCCESSFUL) {
+ closeForm();
+ }
+ }, [savingStatus]);
+
+ return (
+
+
+
+
+
+
+
+
+ {isExpanded && (
+
+ {children}
+
+
+ )}
+
+
+ );
+};
+
+SectionCard.defaultProps = {
+ children: null,
+};
+
+SectionCard.propTypes = {
+ section: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ displayName: PropTypes.string.isRequired,
+ published: PropTypes.bool.isRequired,
+ hasChanges: PropTypes.bool.isRequired,
+ releasedToStudents: PropTypes.bool.isRequired,
+ visibleToStaffOnly: PropTypes.bool,
+ visibilityState: PropTypes.string.isRequired,
+ staffOnlyMessage: PropTypes.bool.isRequired,
+ highlights: PropTypes.arrayOf(PropTypes.string).isRequired,
+ shouldScroll: PropTypes.bool,
+ }).isRequired,
+ 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,
+ onDuplicateSubmit: PropTypes.func.isRequired,
+ isSectionsExpanded: PropTypes.bool.isRequired,
+ onNewSubsectionSubmit: PropTypes.func.isRequired,
+};
+
+export default SectionCard;
diff --git a/src/course-outline/section-card/SectionCard.scss b/src/course-outline/section-card/SectionCard.scss
new file mode 100644
index 0000000000..f386297a11
--- /dev/null
+++ b/src/course-outline/section-card/SectionCard.scss
@@ -0,0 +1,39 @@
+.section-card {
+ flex-grow: 1;
+
+ .section-card__subsections {
+ margin-top: $spacer;
+ margin-right: -2.75rem;
+ }
+
+ .section-card-title {
+ font-size: $h3-font-size;
+ font-family: $headings-font-family;
+ font-weight: $headings-font-weight;
+ line-height: $headings-line-height;
+ color: $headings-color;
+ }
+
+ .section-card__highlights {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ padding: 0;
+ background: transparent;
+
+ &::before {
+ display: none;
+ }
+
+ .highlights-badge {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 1.375rem;
+ font-size: 1.125rem;
+ font-weight: 700;
+ }
+ }
+}
diff --git a/src/course-outline/section-card/SectionCard.test.jsx b/src/course-outline/section-card/SectionCard.test.jsx
new file mode 100644
index 0000000000..d391e48ffc
--- /dev/null
+++ b/src/course-outline/section-card/SectionCard.test.jsx
@@ -0,0 +1,151 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { initializeMockApp } from '@edx/frontend-platform';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import initializeStore from '../../store';
+import SectionCard from './SectionCard';
+import cardHeaderMessages from '../card-header/messages';
+
+// eslint-disable-next-line no-unused-vars
+let axiosMock;
+let store;
+
+const section = {
+ id: '123',
+ displayName: 'Section Name',
+ published: true,
+ releasedToStudents: true,
+ visibleToStaffOnly: false,
+ visibilityState: 'visible',
+ staffOnlyMessage: false,
+ hasChanges: false,
+ highlights: ['highlight 1', 'highlight 2'],
+};
+
+const onEditSectionSubmit = jest.fn();
+
+const renderComponent = (props) => render(
+
+
+
+ children
+
+ ,
+ ,
+);
+
+describe('', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+
+ it('render SectionCard component correctly', () => {
+ const { getByTestId } = renderComponent();
+
+ expect(getByTestId('section-card-header')).toBeInTheDocument();
+ expect(getByTestId('section-card__content')).toBeInTheDocument();
+ });
+
+ it('expands/collapses the card when the expand button is clicked', () => {
+ const { queryByTestId, getByTestId } = renderComponent();
+
+ const expandButton = getByTestId('section-card-header__expanded-btn');
+ fireEvent.click(expandButton);
+ expect(queryByTestId('section-card__subsections')).not.toBeInTheDocument();
+ expect(queryByTestId('new-subsection-button')).not.toBeInTheDocument();
+
+ fireEvent.click(expandButton);
+ expect(queryByTestId('section-card__subsections')).toBeInTheDocument();
+ expect(queryByTestId('new-subsection-button')).toBeInTheDocument();
+ });
+
+ it('title only updates if changed', async () => {
+ const { findByTestId } = renderComponent();
+
+ let editButton = await findByTestId('section-edit-button');
+ fireEvent.click(editButton);
+ let editField = await findByTestId('section-edit-field');
+ fireEvent.blur(editField);
+
+ expect(onEditSectionSubmit).not.toHaveBeenCalled();
+
+ editButton = await findByTestId('section-edit-button');
+ fireEvent.click(editButton);
+ editField = await findByTestId('section-edit-field');
+ fireEvent.change(editField, { target: { value: 'some random value' } });
+ fireEvent.blur(editField);
+ expect(onEditSectionSubmit).toHaveBeenCalled();
+ });
+
+ it('renders live status', async () => {
+ const { findByText } = renderComponent();
+ expect(await findByText(cardHeaderMessages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders published but live status', async () => {
+ const { findByText } = renderComponent({
+ section: {
+ ...section,
+ published: true,
+ releasedToStudents: false,
+ visibleToStaffOnly: false,
+ visibilityState: 'visible',
+ staffOnlyMessage: false,
+ },
+ });
+ expect(await findByText(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders staff status', async () => {
+ const { findByText } = renderComponent({
+ section: {
+ ...section,
+ published: false,
+ releasedToStudents: false,
+ visibleToStaffOnly: true,
+ visibilityState: 'staff_only',
+ staffOnlyMessage: true,
+ },
+ });
+ expect(await findByText(cardHeaderMessages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders draft status', async () => {
+ const { findByText } = renderComponent({
+ section: {
+ ...section,
+ published: false,
+ releasedToStudents: false,
+ visibleToStaffOnly: false,
+ visibilityState: 'staff_only',
+ staffOnlyMessage: false,
+ },
+ });
+ expect(await findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
+ });
+});
diff --git a/src/course-outline/section-card/messages.js b/src/course-outline/section-card/messages.js
new file mode 100644
index 0000000000..4730c31a3b
--- /dev/null
+++ b/src/course-outline/section-card/messages.js
@@ -0,0 +1,14 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ newSubsectionButton: {
+ id: 'course-authoring.course-outline.section.button.new-subsection',
+ defaultMessage: 'New subsection',
+ },
+ sectionHighlightsBadge: {
+ id: 'course-authoring.course-outline.section.badge.section-highlights',
+ defaultMessage: 'Section highlights',
+ },
+});
+
+export default messages;
diff --git a/src/course-outline/status-bar/StatusBar.jsx b/src/course-outline/status-bar/StatusBar.jsx
index 62d28f0430..3e08d5b414 100644
--- a/src/course-outline/status-bar/StatusBar.jsx
+++ b/src/course-outline/status-bar/StatusBar.jsx
@@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink, Stack } from '@edx/paragon';
import { AppContext } from '@edx/frontend-platform/react';
+import { useHelpUrls } from '../../help-urls/hooks';
import messages from './messages';
const StatusBar = ({
@@ -18,7 +19,6 @@ const StatusBar = ({
const {
courseReleaseDate,
highlightsEnabledForMessaging,
- highlightsDocUrl,
checklist,
isSelfPaced,
} = statusBarData;
@@ -31,8 +31,12 @@ const StatusBar = ({
} = checklist;
const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`;
- const checklistDestination = new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href;
- const scheduleDestination = new URL(`course/${courseId}/settings/details#schedule`, config.BASE_URL).href;
+ const checklistDestination = () => new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href;
+ const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href;
+
+ const {
+ contentHighlights: contentHighlightsUrl,
+ } = useHelpUrls(['contentHighlights']);
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
@@ -45,7 +49,7 @@ const StatusBar = ({
{intl.formatMessage(messages.startDateTitle)}
{courseReleaseDate}
@@ -63,7 +67,7 @@ const StatusBar = ({
{intl.formatMessage(messages.checklistTitle)}
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
@@ -77,13 +81,13 @@ const StatusBar = ({
{intl.formatMessage(messages.highlightEmailsEnabled)}
) : (
-