diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss
index 01afbe0fb..886cc45dc 100644
--- a/src/course-outline/CourseOutline.scss
+++ b/src/course-outline/CourseOutline.scss
@@ -1,6 +1,7 @@
@import "./header-navigations/HeaderNavigations";
@import "./status-bar/StatusBar";
@import "./section-card/SectionCard";
+@import "./subsection-card/SubsectionCard";
@import "./card-header/CardHeader";
@import "./empty-placeholder/EmptyPlaceholder";
@import "./highlights-modal/HighlightsModal";
diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx
index b6fa767c0..fce1de138 100644
--- a/src/course-outline/CourseOutline.test.jsx
+++ b/src/course-outline/CourseOutline.test.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- render, waitFor, cleanup, fireEvent,
+ render, waitFor, cleanup, fireEvent, within,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -15,21 +15,24 @@ import {
getCourseReindexApiUrl,
getXBlockApiUrl,
getEnableHighlightsEmailsApiUrl,
- getUpdateCourseSectionApiUrl,
+ getCourseItemApiUrl,
getXBlockBaseApiUrl,
} from './data/api';
import {
- addNewCourseSectionQuery,
+ addNewSectionQuery,
+ addNewSubsectionQuery,
deleteCourseSectionQuery,
- duplicateCourseSectionQuery,
- editCourseSectionQuery,
+ deleteCourseSubsectionQuery,
+ duplicateSectionQuery,
+ duplicateSubsectionQuery,
+ editCourseItemQuery,
enableCourseHighlightsEmailsQuery,
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery,
fetchCourseReindexQuery,
fetchCourseSectionQuery,
- publishCourseSectionQuery,
+ publishCourseItemQuery,
updateCourseSectionHighlightsQuery,
} from './data/thunk';
import initializeStore from '../store';
@@ -39,6 +42,7 @@ import {
courseBestPracticesMock,
courseLaunchMock,
courseSectionMock,
+ courseSubsectionMock,
} from './__mocks__';
import { executeThunk } from '../utils';
import CourseOutline from './CourseOutline';
@@ -129,13 +133,34 @@ describe('
', () => {
axiosMock
.onGet(getXBlockApiUrl(courseSectionMock.id))
.reply(200, courseSectionMock);
- await executeThunk(addNewCourseSectionQuery(courseId), store.dispatch);
+ await executeThunk(addNewSectionQuery(courseId), store.dispatch);
element = await findAllByTestId('section-card');
expect(element.length).toBe(5);
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
});
+ it('adds new subsection correctly', async () => {
+ const { findAllByTestId } = render(
);
+ const sectionId = courseOutlineIndexMock.courseStructure.childInfo.children[0].id;
+ const [section] = await findAllByTestId('section-card');
+ let subsections = await within(section).findAllByTestId('subsection-card');
+ expect(subsections.length).toBe(1);
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl())
+ .reply(200, {
+ locator: courseSubsectionMock.id,
+ });
+ axiosMock
+ .onGet(getXBlockApiUrl(courseSubsectionMock.id))
+ .reply(200, courseSubsectionMock);
+ await executeThunk(addNewSubsectionQuery(sectionId), store.dispatch);
+
+ subsections = await within(section).findAllByTestId('subsection-card');
+ expect(subsections.length).toBe(2);
+ });
+
it('render error alert after failed reindex correctly', async () => {
const { getByText } = render(
);
@@ -196,18 +221,17 @@ describe('
', () => {
});
it('should expand and collapse subsections, after click on subheader buttons', async () => {
- const { queryAllByTestId, getByText } = render(
);
+ 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 collapseBtn = getByText(headerMessages.collapseAllButton.defaultMessage);
- expect(collapseBtn).toBeInTheDocument();
- fireEvent.click(collapseBtn);
-
- const expendBtn = getByText(headerMessages.expandAllButton.defaultMessage);
- expect(expendBtn).toBeInTheDocument();
-
- fireEvent.click(expendBtn);
-
const cardSubsections = queryAllByTestId('section-card__subsections');
cardSubsections.forEach(element => expect(element).toBeVisible());
@@ -236,14 +260,14 @@ describe('
', () => {
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
axiosMock
- .onPost(getUpdateCourseSectionApiUrl(section.id, {
+ .onPost(getCourseItemApiUrl(section.id, {
metadata: {
display_name: newDisplayName,
},
}))
.reply(200);
- await executeThunk(editCourseSectionQuery(section.id, newDisplayName), store.dispatch);
+ await executeThunk(editCourseItemQuery(section.id, section.id, newDisplayName), store.dispatch);
axiosMock
.onGet(getXBlockApiUrl(section.id))
@@ -255,11 +279,14 @@ describe('
', () => {
});
});
- it('check delete section when edit query is successfully', async () => {
+ it('check whether section is deleted when delete query is successfully', async () => {
const { queryByText } = render(
);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[1];
+ await waitFor(() => {
+ expect(queryByText(section.displayName)).toBeInTheDocument();
+ });
- axiosMock.onDelete(getUpdateCourseSectionApiUrl(section.id)).reply(200);
+ axiosMock.onDelete(getCourseItemApiUrl(section.id)).reply(200);
await executeThunk(deleteCourseSectionQuery(section.id), store.dispatch);
await waitFor(() => {
@@ -267,22 +294,72 @@ describe('
', () => {
});
});
- it('check duplicate section when duplicate query is successfully', async () => {
- const { getAllByTestId } = render(
);
+ it('check whether subsection is deleted when delete query is successfully', async () => {
+ const { queryByText } = render(
);
+ const section = courseOutlineIndexMock.courseStructure.childInfo.children[1];
+ const [subsection] = section.childInfo.children;
+ await waitFor(() => {
+ expect(queryByText(subsection.displayName)).toBeInTheDocument();
+ });
+
+ axiosMock.onDelete(getCourseItemApiUrl(subsection.id)).reply(200);
+ await executeThunk(deleteCourseSubsectionQuery(subsection.id, section.id), store.dispatch);
+
+ 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[0];
+ const sectionId = section.id;
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
+ expect(await findAllByTestId('section-card')).toHaveLength(4);
axiosMock
.onPost(getXBlockBaseApiUrl())
.reply(200, {
- duplicate_source_locator: section.id,
- parent_locator: courseBlockId,
+ locator: courseSectionMock.id,
});
- await executeThunk(duplicateCourseSectionQuery(section.id, courseBlockId), store.dispatch);
+ section.id = courseSectionMock.id;
+ axiosMock
+ .onGet(getXBlockApiUrl(section.id))
+ .reply(200, {
+ ...section,
+ });
+ await executeThunk(duplicateSectionQuery(sectionId, courseBlockId), store.dispatch);
- await waitFor(() => {
- expect(getAllByTestId('section-card')).toHaveLength(4);
- });
+ 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;
+ const subsectionId = subsection.id;
+ 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,
+ });
+
+ await executeThunk(duplicateSubsectionQuery(subsectionId, section.id), store.dispatch);
+
+ [sectionElement] = await findAllByTestId('section-card');
+ subsections = await within(sectionElement).findAllByTestId('subsection-card');
+ expect(subsections.length).toBe(2);
});
it('check publish section when publish query is successfully', async () => {
@@ -307,12 +384,12 @@ describe('
', () => {
});
axiosMock
- .onPost(getUpdateCourseSectionApiUrl(section.id), {
+ .onPost(getCourseItemApiUrl(section.id), {
publish: 'make_public',
})
.reply(200);
- await executeThunk(publishCourseSectionQuery(section.id), store.dispatch);
+ await executeThunk(publishCourseItemQuery(section.id, section.id), store.dispatch);
axiosMock
.onGet(getXBlockApiUrl(section.id))
@@ -325,7 +402,7 @@ describe('
', () => {
await executeThunk(fetchCourseSectionQuery(section.id), store.dispatch);
const firstSection = getAllByTestId('section-card')[0];
- expect(firstSection.querySelector('.section-card-header__badge-status')).toHaveTextContent('Published not live');
+ expect(firstSection.querySelector('.item-card-header__badge-status')).toHaveTextContent('Published not live');
});
it('check configure section when configure query is successful', async () => {
@@ -334,7 +411,7 @@ describe('
', () => {
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const newReleaseDate = '2025-08-10T10:00:00Z';
axiosMock
- .onPost(getUpdateCourseSectionApiUrl(section.id), {
+ .onPost(getCourseItemApiUrl(section.id), {
id: section.id,
data: null,
metadata: {
@@ -390,7 +467,7 @@ describe('
', () => {
];
axiosMock
- .onPost(getUpdateCourseSectionApiUrl(section.id), {
+ .onPost(getCourseItemApiUrl(section.id), {
publish: 'republish',
metadata: {
highlights,
diff --git a/src/course-outline/__mocks__/courseSubsection.js b/src/course-outline/__mocks__/courseSubsection.js
new file mode 100644
index 000000000..a7e72d0ef
--- /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 942145257..15c6504cb 100644
--- a/src/course-outline/__mocks__/index.js
+++ b/src/course-outline/__mocks__/index.js
@@ -3,3 +3,4 @@ export { default as courseOutlineIndexWithoutSections } from './courseOutlineInd
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
index eeedf676b..150d90c26 100644
--- a/src/course-outline/card-header/CardHeader.jsx
+++ b/src/course-outline/card-header/CardHeader.jsx
@@ -20,13 +20,13 @@ import {
import classNames from 'classnames';
import { useEscapeClick } from '../../hooks';
-import { SECTION_BADGE_STATUTES } from '../constants';
-import { getSectionStatusBadgeContent } from '../utils';
+import { ITEM_BADGE_STATUS } from '../constants';
+import { getItemStatusBadgeContent } from '../utils';
import messages from './messages';
const CardHeader = ({
title,
- sectionStatus,
+ status,
hasChanges,
isExpanded,
onClickPublish,
@@ -40,13 +40,14 @@ const CardHeader = ({
isDisabledEditField,
onClickDelete,
onClickDuplicate,
+ namePrefix,
}) => {
const intl = useIntl();
const [titleValue, setTitleValue] = useState(title);
- const { badgeTitle, badgeIcon } = getSectionStatusBadgeContent(sectionStatus, messages, intl);
- const isDisabledPublish = (sectionStatus === SECTION_BADGE_STATUTES.live
- || sectionStatus === SECTION_BADGE_STATUTES.publishedNotLive) && !hasChanges;
+ const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl);
+ const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
+ || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
useEscapeClick({
onEscape: () => {
@@ -57,7 +58,7 @@ const CardHeader = ({
});
return (
-
+
{isFormOpen ? (
{intl.formatMessage(messages.expandTooltip)}
@@ -91,18 +92,18 @@ const CardHeader = ({