feat: add subsection component
refactor: update publish modal to handle subsections and units refactor: rename current section state and handlers refactor: generalize edit title for section, subsection and unit feat: generalize delete modal feat: generalize publish modal refactor: use currentSection and currentSubsection to improve delete item feat: generalize duplication functionality feat: generalize add new item for sections and subsections test: fix subsection tests fix: lint issues and test arguments test: fix card header, delete and publish modal tests fix: invalid use of delete subsection query for unit refactor: use current section for highlights modal feat: add auto scroll to subsection and improve scroll behaviour fix: jsdoc types
This commit is contained in:
committed by
Kristin Aoki
parent
91ba00346c
commit
f79bebceeb
@@ -27,6 +27,7 @@ import OutlineSideBar from './outline-sidebar/OutlineSidebar';
|
||||
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';
|
||||
@@ -71,12 +72,14 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
handleHighlightsFormSubmit,
|
||||
handlePublishSectionSubmit,
|
||||
handleConfigureSectionSubmit,
|
||||
handleEditSectionSubmit,
|
||||
handleDeleteSectionSubmit,
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleNewSectionSubmit,
|
||||
handleNewSubsectionSubmit,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,11 +161,26 @@ const CourseOutline = ({ courseId }) => {
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSectionSubmit={handleEditSectionSubmit}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
ref={listRef}
|
||||
/>
|
||||
>
|
||||
{section.childInfo.children.map((subsection) => (
|
||||
<SubsectionCard
|
||||
key={subsection.id}
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
ref={listRef}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
))}
|
||||
<Button
|
||||
data-testid="new-section-button"
|
||||
@@ -201,7 +219,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
<PublishModal
|
||||
isOpen={isPublishModalOpen}
|
||||
onClose={closePublishModal}
|
||||
onPublishSubmit={handlePublishSectionSubmit}
|
||||
onPublishSubmit={handlePublishItemSubmit}
|
||||
/>
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
@@ -211,7 +229,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
onDeleteSubmit={handleDeleteSectionSubmit}
|
||||
onDeleteSubmit={handleDeleteItemSubmit}
|
||||
/>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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('<CourseOutline />', () => {
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
|
||||
@@ -196,18 +221,17 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('should expand and collapse subsections, after click on subheader buttons', async () => {
|
||||
const { queryAllByTestId, getByText } = render(<RootWrapper />);
|
||||
const { queryAllByTestId, findByText } = render(<RootWrapper />);
|
||||
|
||||
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('<CourseOutline />', () => {
|
||||
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('<CourseOutline />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(<RootWrapper />);
|
||||
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('<CourseOutline />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('check duplicate section when duplicate query is successfully', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
it('check whether subsection is deleted when delete query is successfully', async () => {
|
||||
const { queryByText } = render(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
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('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
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('<CourseOutline />', () => {
|
||||
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('<CourseOutline />', () => {
|
||||
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('<CourseOutline />', () => {
|
||||
];
|
||||
|
||||
axiosMock
|
||||
.onPost(getUpdateCourseSectionApiUrl(section.id), {
|
||||
.onPost(getCourseItemApiUrl(section.id), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
highlights,
|
||||
|
||||
101
src/course-outline/__mocks__/courseSubsection.js
Normal file
101
src/course-outline/__mocks__/courseSubsection.js
Normal file
@@ -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: '',
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
<div className="section-card-header" data-testid="section-card-header">
|
||||
<div className="item-card-header" data-testid={`${namePrefix}-card-header`}>
|
||||
{isFormOpen ? (
|
||||
<Form.Group className="m-0">
|
||||
<Form.Control
|
||||
@@ -82,7 +83,7 @@ const CardHeader = ({
|
||||
overlay={(
|
||||
<Tooltip
|
||||
id={intl.formatMessage(messages.expandTooltip)}
|
||||
className="section-card-header-tooltip"
|
||||
className="item-card-header-tooltip"
|
||||
>
|
||||
{intl.formatMessage(messages.expandTooltip)}
|
||||
</Tooltip>
|
||||
@@ -91,18 +92,18 @@ const CardHeader = ({
|
||||
<Button
|
||||
iconBefore={isExpanded ? ArrowUpIcon : ArrowDownIcon}
|
||||
variant="tertiary"
|
||||
data-testid="section-card-header__expanded-btn"
|
||||
className="section-card-header__expanded-btn"
|
||||
data-testid={`${namePrefix}-card-header__expanded-btn`}
|
||||
className="item-card-header__expanded-btn"
|
||||
onClick={() => onExpand((prevState) => !prevState)}
|
||||
>
|
||||
<Truncate lines={1} className="h3 mb-0">{title}</Truncate>
|
||||
<Truncate lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate>
|
||||
{badgeTitle && (
|
||||
<div className="section-card-header__badge-status" data-testid="section-card-header__badge-status">
|
||||
<div className="item-card-header__badge-status" data-testid={`${namePrefix}-card-header__badge-status`}>
|
||||
{badgeIcon && (
|
||||
<Icon
|
||||
src={badgeIcon}
|
||||
size="sm"
|
||||
className={classNames({ 'text-success-500': sectionStatus === SECTION_BADGE_STATUTES.live })}
|
||||
className={classNames({ 'text-success-500': status === ITEM_BADGE_STATUS.live })}
|
||||
/>
|
||||
)}
|
||||
<span className="small">{badgeTitle}</span>
|
||||
@@ -120,14 +121,14 @@ const CardHeader = ({
|
||||
onClick={onClickEdit}
|
||||
/>
|
||||
)}
|
||||
<Dropdown data-testid="section-card-header__menu" onClick={onClickMenuButton}>
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
<Dropdown.Toggle
|
||||
className="section-card-header__menu"
|
||||
id="section-card-header__menu"
|
||||
data-testid="section-card-header__menu-button"
|
||||
className="item-card-header__menu"
|
||||
id={`${namePrefix}-card-header__menu`}
|
||||
data-testid={`${namePrefix}-card-header__menu-button`}
|
||||
as={IconButton}
|
||||
src={MoveVertIcon}
|
||||
alt="section-card-header__menu"
|
||||
alt={`${namePrefix}-card-header__menu`}
|
||||
iconAs={Icon}
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
@@ -149,7 +150,7 @@ const CardHeader = ({
|
||||
|
||||
CardHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
sectionStatus: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
isExpanded: PropTypes.bool.isRequired,
|
||||
onExpand: PropTypes.func.isRequired,
|
||||
@@ -163,6 +164,7 @@ CardHeader.propTypes = {
|
||||
isDisabledEditField: PropTypes.bool.isRequired,
|
||||
onClickDelete: PropTypes.func.isRequired,
|
||||
onClickDuplicate: PropTypes.func.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CardHeader;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.section-card-header {
|
||||
.item-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: -.5rem;
|
||||
|
||||
.section-card-header__expanded-btn {
|
||||
.item-card-header__expanded-btn {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
width: 80%;
|
||||
@@ -13,7 +13,7 @@
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.section-card-header__badge-status {
|
||||
.item-card-header__badge-status {
|
||||
display: flex;
|
||||
padding: 1px .5rem;
|
||||
justify-content: center;
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.section-card-header-tooltip {
|
||||
.item-card-header-tooltip {
|
||||
.tooltip-inner {
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { SECTION_BADGE_STATUTES } from '../constants';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import CardHeader from './CardHeader';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -16,7 +16,7 @@ const closeFormMock = jest.fn();
|
||||
|
||||
const cardHeaderProps = {
|
||||
title: 'Some title',
|
||||
sectionStatus: SECTION_BADGE_STATUTES.live,
|
||||
status: ITEM_BADGE_STATUS.live,
|
||||
hasChanges: false,
|
||||
isExpanded: true,
|
||||
onExpand: onExpandMock,
|
||||
@@ -29,6 +29,7 @@ const cardHeaderProps = {
|
||||
isDisabledEditField: false,
|
||||
onClickDelete: onClickDeleteMock,
|
||||
onClickDuplicate: onClickDuplicateMock,
|
||||
namePrefix: 'section',
|
||||
};
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
@@ -61,7 +62,7 @@ describe('<CardHeader />', () => {
|
||||
it('render status badge as published_not_live', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
@@ -70,7 +71,7 @@ describe('<CardHeader />', () => {
|
||||
it('render status badge as staff_only', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
sectionStatus: SECTION_BADGE_STATUTES.staffOnly,
|
||||
status: ITEM_BADGE_STATUS.staffOnly,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
@@ -79,7 +80,7 @@ describe('<CardHeader />', () => {
|
||||
it('render status badge as draft', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
sectionStatus: SECTION_BADGE_STATUTES.draft,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
@@ -88,7 +89,7 @@ describe('<CardHeader />', () => {
|
||||
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,
|
||||
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('section-card-header__menu-button');
|
||||
@@ -99,7 +100,7 @@ describe('<CardHeader />', () => {
|
||||
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,
|
||||
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
hasChanges: true,
|
||||
});
|
||||
|
||||
@@ -129,7 +130,7 @@ describe('<CardHeader />', () => {
|
||||
it('calls onClickPublish when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
sectionStatus: SECTION_BADGE_STATUTES.draft,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('section-card-header__menu-button');
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { VisibilityTypes } from '../../data/constants';
|
||||
import { getCurrentSection } from '../data/selectors';
|
||||
import { getCurrentItem } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
import BasicTab from './BasicTab';
|
||||
import VisibilityTab from './VisibilityTab';
|
||||
@@ -23,7 +23,7 @@ const ConfigureModal = ({
|
||||
onConfigureSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { displayName, start: sectionStartDate, visibilityState } = useSelector(getCurrentSection);
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SECTION_BADGE_STATUTES = /** @type {const} */ ({
|
||||
export const ITEM_BADGE_STATUS = /** @type {const} */ ({
|
||||
live: 'live',
|
||||
publishedNotLive: 'published_not_live',
|
||||
staffOnly: 'staff_only',
|
||||
@@ -15,9 +15,10 @@ export const CHECKLIST_FILTERS = /** @type {const} */ ({
|
||||
INSTRUCTOR_PACED: 'INSTRUCTOR_PACED',
|
||||
});
|
||||
|
||||
export const DEFAULT_NEW_DISPLAY_NAMES = /** @type {const} */ ({
|
||||
chapter: 'Section',
|
||||
subsection: 'Subsection',
|
||||
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 = /** @type {const} */ ({
|
||||
|
||||
@@ -26,7 +26,7 @@ export const getEnableHighlightsEmailsApiUrl = (courseId) => {
|
||||
|
||||
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
|
||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getUpdateCourseSectionApiUrl = (sectionId) => `${getXBlockBaseApiUrl()}${sectionId}`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
|
||||
/**
|
||||
@@ -174,12 +174,12 @@ export async function restartIndexingOnCourse(reindexLink) {
|
||||
|
||||
/**
|
||||
* Get course section
|
||||
* @param {string} sectionId
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<section>}
|
||||
*/
|
||||
export async function getCourseSection(sectionId) {
|
||||
export async function getCourseItem(itemId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXBlockApiUrl(sectionId));
|
||||
.get(getXBlockApiUrl(itemId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export async function getCourseSection(sectionId) {
|
||||
*/
|
||||
export async function updateCourseSectionHighlights(sectionId, highlights) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getUpdateCourseSectionApiUrl(sectionId), {
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
highlights,
|
||||
@@ -208,7 +208,7 @@ export async function updateCourseSectionHighlights(sectionId, highlights) {
|
||||
*/
|
||||
export async function publishCourseSection(sectionId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getUpdateCourseSectionApiUrl(sectionId), {
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'make_public',
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ export async function publishCourseSection(sectionId) {
|
||||
*/
|
||||
export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getUpdateCourseSectionApiUrl(sectionId), {
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
@@ -236,13 +236,13 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st
|
||||
|
||||
/**
|
||||
* Edit course section
|
||||
* @param {string} sectionId
|
||||
* @param {string} itemId
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function editCourseSection(sectionId, displayName) {
|
||||
export async function editItemDisplayName(itemId, displayName) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getUpdateCourseSectionApiUrl(sectionId), {
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
metadata: {
|
||||
display_name: displayName,
|
||||
},
|
||||
@@ -253,27 +253,27 @@ export async function editCourseSection(sectionId, displayName) {
|
||||
|
||||
/**
|
||||
* Delete course section
|
||||
* @param {string} sectionId
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteCourseSection(sectionId) {
|
||||
export async function deleteCourseItem(itemId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getUpdateCourseSectionApiUrl(sectionId));
|
||||
.delete(getCourseItemApiUrl(itemId));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate course section
|
||||
* @param {string} sectionId
|
||||
* @param {string} courseBlockId
|
||||
* @param {string} itemId
|
||||
* @param {string} parentId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function duplicateCourseSection(sectionId, courseBlockId) {
|
||||
export async function duplicateCourseItem(itemId, parentId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
duplicate_source_locator: sectionId,
|
||||
parent_locator: courseBlockId,
|
||||
duplicate_source_locator: itemId,
|
||||
parent_locator: parentId,
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -281,13 +281,15 @@ export async function duplicateCourseSection(sectionId, courseBlockId) {
|
||||
|
||||
/**
|
||||
* Add new course item like section, subsection or unit.
|
||||
* @param {string} courseBlockId
|
||||
* @param {string} parentLocator
|
||||
* @param {string} category
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function addNewCourseItem(courseBlockId, category, displayName) {
|
||||
export async function addNewCourseItem(parentLocator, category, displayName) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: courseBlockId,
|
||||
parent_locator: parentLocator,
|
||||
category,
|
||||
display_name: displayName,
|
||||
});
|
||||
|
||||
@@ -3,4 +3,6 @@ export const getLoadingStatus = (state) => state.courseOutline.loadingStatus;
|
||||
export const getStatusBarData = (state) => state.courseOutline.statusBarData;
|
||||
export const getSavingStatus = (state) => state.courseOutline.savingStatus;
|
||||
export const getSectionsList = (state) => state.courseOutline.sectionsList;
|
||||
export const getCurrentItem = (state) => state.courseOutline.currentItem;
|
||||
export const getCurrentSection = (state) => state.courseOutline.currentSection;
|
||||
export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection;
|
||||
|
||||
@@ -26,6 +26,8 @@ const slice = createSlice({
|
||||
},
|
||||
sectionsList: [],
|
||||
currentSection: {},
|
||||
currentSubsection: {},
|
||||
currentItem: {},
|
||||
},
|
||||
reducers: {
|
||||
fetchOutlineIndexSuccess: (state, { payload }) => {
|
||||
@@ -71,22 +73,69 @@ const slice = createSlice({
|
||||
updateSectionList: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => (section.id === payload.id ? payload : section));
|
||||
},
|
||||
setCurrentItem: (state, { payload }) => {
|
||||
state.currentItem = payload;
|
||||
},
|
||||
setCurrentSection: (state, { payload }) => {
|
||||
state.currentSection = payload;
|
||||
},
|
||||
setCurrentSubsection: (state, { payload }) => {
|
||||
state.currentSubsection = payload;
|
||||
},
|
||||
addSection: (state, { payload }) => {
|
||||
state.sectionsList = [
|
||||
...state.sectionsList,
|
||||
payload,
|
||||
];
|
||||
},
|
||||
addSubsection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id === payload.parentLocator) {
|
||||
section.childInfo.children = [
|
||||
...section.childInfo.children,
|
||||
payload.data,
|
||||
];
|
||||
}
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteSection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.filter(({ id }) => id !== payload);
|
||||
state.sectionsList = state.sectionsList.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
},
|
||||
deleteSubsection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
}
|
||||
section.childInfo.children = section.childInfo.children.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
}
|
||||
section.childInfo.children = section.childInfo.children.map((subsection) => {
|
||||
if (subsection.id !== payload.subsectionId) {
|
||||
return subsection;
|
||||
}
|
||||
subsection.childInfo.children = subsection.childInfo.children.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
duplicateSection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.reduce((result, currentValue) => {
|
||||
if (currentValue.id === payload.id) {
|
||||
return [...result, currentValue, payload.duplicatedSection];
|
||||
return [...result, currentValue, payload.duplicatedItem];
|
||||
}
|
||||
return [...result, currentValue];
|
||||
}, []);
|
||||
@@ -96,6 +145,7 @@ const slice = createSlice({
|
||||
|
||||
export const {
|
||||
addSection,
|
||||
addSubsection,
|
||||
fetchOutlineIndexSuccess,
|
||||
updateOutlineIndexLoadingStatus,
|
||||
updateReindexLoadingStatus,
|
||||
@@ -105,8 +155,12 @@ export const {
|
||||
updateFetchSectionLoadingStatus,
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
setCurrentItem,
|
||||
setCurrentSection,
|
||||
setCurrentSubsection,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
} = slice.actions;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { DEFAULT_NEW_DISPLAY_NAMES } from '../constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
} from '../utils/getChecklistForStatusBar';
|
||||
import {
|
||||
addNewCourseItem,
|
||||
deleteCourseSection,
|
||||
duplicateCourseSection,
|
||||
editCourseSection,
|
||||
deleteCourseItem,
|
||||
duplicateCourseItem,
|
||||
editItemDisplayName,
|
||||
enableCourseHighlightsEmails,
|
||||
getCourseBestPractices,
|
||||
getCourseLaunch,
|
||||
getCourseOutlineIndex,
|
||||
getCourseSection,
|
||||
getCourseItem,
|
||||
publishCourseSection,
|
||||
configureCourseSection,
|
||||
restartIndexingOnCourse,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from './api';
|
||||
import {
|
||||
addSection,
|
||||
addSubsection,
|
||||
fetchOutlineIndexSuccess,
|
||||
updateOutlineIndexLoadingStatus,
|
||||
updateReindexLoadingStatus,
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
updateSectionList,
|
||||
updateFetchSectionLoadingStatus,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
} from './slice';
|
||||
|
||||
@@ -129,7 +132,7 @@ export function fetchCourseSectionQuery(sectionId) {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const data = await getCourseSection(sectionId);
|
||||
const data = await getCourseItem(sectionId);
|
||||
dispatch(updateSectionList(data));
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
@@ -158,13 +161,13 @@ export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
|
||||
};
|
||||
}
|
||||
|
||||
export function publishCourseSectionQuery(sectionId) {
|
||||
export function publishCourseItemQuery(itemId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await publishCourseSection(sectionId).then(async (result) => {
|
||||
await publishCourseSection(itemId).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
dispatch(hideProcessingNotification());
|
||||
@@ -198,13 +201,13 @@ export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, sta
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseSectionQuery(sectionId, displayName) {
|
||||
export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await editCourseSection(sectionId, displayName).then(async (result) => {
|
||||
await editItemDisplayName(itemId, displayName).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery(sectionId));
|
||||
dispatch(hideProcessingNotification());
|
||||
@@ -218,14 +221,20 @@ export function editCourseSectionQuery(sectionId, displayName) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSectionQuery(sectionId) {
|
||||
/**
|
||||
* Generic function to delete course item, see below wrapper funcs for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {() => {}} deleteItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function deleteCourseItemQuery(itemId, deleteItemFn) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
||||
|
||||
try {
|
||||
await deleteCourseSection(sectionId);
|
||||
dispatch(deleteSection(sectionId));
|
||||
await deleteCourseItem(itemId);
|
||||
dispatch(deleteItemFn());
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
@@ -235,16 +244,49 @@ export function deleteCourseSectionQuery(sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateCourseSectionQuery(sectionId, courseBlockId) {
|
||||
export function deleteCourseSectionQuery(sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
sectionId,
|
||||
() => deleteSection({ itemId: sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
subsectionId,
|
||||
() => deleteSubsection({ itemId: subsectionId, sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
unitId,
|
||||
() => deleteUnit({ itemId: unitId, subsectionId, sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to duplicate any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {string} parentLocator
|
||||
* @param {(locator) => Promise<any>} duplicateFn
|
||||
* @returns {}
|
||||
*/
|
||||
function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await duplicateCourseSection(sectionId, courseBlockId).then(async (result) => {
|
||||
await duplicateCourseItem(itemId, parentLocator).then(async (result) => {
|
||||
if (result) {
|
||||
const duplicatedSection = await getCourseSection(result.locator);
|
||||
dispatch(duplicateSection({ id: sectionId, duplicatedSection }));
|
||||
await duplicateFn(result.locator);
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
@@ -256,20 +298,51 @@ export function duplicateCourseSectionQuery(sectionId, courseBlockId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewCourseSectionQuery(courseBlockId) {
|
||||
export function duplicateSectionQuery(sectionId, courseBlockId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
sectionId,
|
||||
courseBlockId,
|
||||
async (locator) => {
|
||||
const duplicatedItem = await getCourseItem(locator);
|
||||
dispatch(duplicateSection({ id: sectionId, duplicatedItem }));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSubsectionQuery(subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId)),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to add any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} parentLocator
|
||||
* @param {string} category
|
||||
* @param {string} displayName
|
||||
* @param {(data) => {}} addItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await addNewCourseItem(
|
||||
courseBlockId,
|
||||
'chapter',
|
||||
DEFAULT_NEW_DISPLAY_NAMES.chapter,
|
||||
parentLocator,
|
||||
category,
|
||||
displayName,
|
||||
).then(async (result) => {
|
||||
if (result) {
|
||||
const data = await getCourseSection(result.locator);
|
||||
dispatch(addSection(data));
|
||||
const data = await getCourseItem(result.locator);
|
||||
dispatch(addItemFn(data));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
@@ -280,3 +353,25 @@ export function addNewCourseSectionQuery(courseBlockId) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSectionQuery(parentLocator) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.chapter.id,
|
||||
COURSE_BLOCK_NAMES.chapter.name,
|
||||
(data) => addSection(data),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSubsectionQuery(parentLocator) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.sequential.id,
|
||||
COURSE_BLOCK_NAMES.sequential.name,
|
||||
(data) => addSubsection({ parentLocator, data }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,16 +5,21 @@ import {
|
||||
Button,
|
||||
AlertModal,
|
||||
} from '@edx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import { getCurrentItem } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
|
||||
const intl = useIntl();
|
||||
let { category } = useSelector(getCurrentItem);
|
||||
category = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.title)}
|
||||
title={intl.formatMessage(messages.title, { category })}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
footerNode={(
|
||||
@@ -28,12 +33,12 @@ const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
|
||||
onDeleteSubmit();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteButton)}
|
||||
{intl.formatMessage(messages.deleteButton, { category })}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>{intl.formatMessage(messages.description)}</p>
|
||||
<p>{intl.formatMessage(messages.description, { category })}</p>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +1,68 @@
|
||||
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 DeleteModal from './DeleteModal';
|
||||
import messages from './messages';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const onDeleteSubmitMock = jest.fn();
|
||||
const closeMock = jest.fn();
|
||||
|
||||
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: 'Delete',
|
||||
};
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<DeleteModal
|
||||
isOpen
|
||||
close={closeMock}
|
||||
onDeleteSubmit={onDeleteSubmitMock}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<DeleteModal
|
||||
isOpen
|
||||
close={closeMock}
|
||||
onDeleteSubmit={onDeleteSubmitMock}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<DeleteModal />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
});
|
||||
|
||||
it('render DeleteModal component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'course-authoring.course-outline.delete-modal.title',
|
||||
defaultMessage: 'Delete this section?',
|
||||
defaultMessage: 'Delete this {category}?',
|
||||
},
|
||||
description: {
|
||||
id: 'course-authoring.course-outline.delete-modal.description',
|
||||
defaultMessage: 'Deleting this section is permanent and cannot be undone.',
|
||||
defaultMessage: 'Deleting this {category} is permanent and cannot be undone.',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'course-authoring.course-outline.delete-modal.button.delete',
|
||||
|
||||
@@ -36,7 +36,7 @@ jest.mock('../../help-urls/hooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const currentSectionMock = {
|
||||
const currentItemMock = {
|
||||
highlights: ['Highlight 1', 'Highlight 2'],
|
||||
displayName: 'Test Section',
|
||||
};
|
||||
@@ -69,13 +69,13 @@ describe('<HighlightsModal />', () => {
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentSectionMock);
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
});
|
||||
|
||||
it('renders HighlightsModal component correctly', () => {
|
||||
const { getByText, getByRole, getByLabelText } = renderComponent();
|
||||
|
||||
expect(getByText(`Highlights for ${currentSectionMock.displayName}`)).toBeInTheDocument();
|
||||
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();
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useToggle } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import {
|
||||
setCurrentItem,
|
||||
setCurrentSection,
|
||||
updateSavingStatus,
|
||||
} from './data/slice';
|
||||
@@ -13,19 +15,25 @@ import {
|
||||
getSavingStatus,
|
||||
getStatusBarData,
|
||||
getSectionsList,
|
||||
getCurrentItem,
|
||||
getCurrentSection,
|
||||
getCurrentSubsection,
|
||||
} from './data/selectors';
|
||||
import {
|
||||
addNewCourseSectionQuery,
|
||||
addNewSectionQuery,
|
||||
addNewSubsectionQuery,
|
||||
deleteCourseSectionQuery,
|
||||
editCourseSectionQuery,
|
||||
duplicateCourseSectionQuery,
|
||||
deleteCourseSubsectionQuery,
|
||||
deleteCourseUnitQuery,
|
||||
editCourseItemQuery,
|
||||
duplicateSectionQuery,
|
||||
duplicateSubsectionQuery,
|
||||
enableCourseHighlightsEmailsQuery,
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery,
|
||||
fetchCourseReindexQuery,
|
||||
publishCourseSectionQuery,
|
||||
publishCourseItemQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
configureCourseSectionQuery,
|
||||
} from './data/thunk';
|
||||
@@ -38,7 +46,9 @@ const useCourseOutline = ({ courseId }) => {
|
||||
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(true);
|
||||
@@ -51,7 +61,11 @@ const useCourseOutline = ({ courseId }) => {
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
|
||||
const handleNewSectionSubmit = () => {
|
||||
dispatch(addNewCourseSectionQuery(courseStructure.id));
|
||||
dispatch(addNewSectionQuery(courseStructure.id));
|
||||
};
|
||||
|
||||
const handleNewSubsectionSubmit = (sectionId) => {
|
||||
dispatch(addNewSubsectionQuery(sectionId));
|
||||
};
|
||||
|
||||
const headerNavigationsActions = {
|
||||
@@ -81,19 +95,20 @@ const useCourseOutline = ({ courseId }) => {
|
||||
};
|
||||
|
||||
const handleOpenHighlightsModal = (section) => {
|
||||
dispatch(setCurrentItem(section));
|
||||
dispatch(setCurrentSection(section));
|
||||
openHighlightsModal();
|
||||
};
|
||||
|
||||
const handleHighlightsFormSubmit = (highlights) => {
|
||||
const dataToSend = Object.values(highlights).filter(Boolean);
|
||||
dispatch(updateCourseSectionHighlightsQuery(currentSection.id, dataToSend));
|
||||
dispatch(updateCourseSectionHighlightsQuery(currentItem.id, dataToSend));
|
||||
|
||||
closeHighlightsModal();
|
||||
};
|
||||
|
||||
const handlePublishSectionSubmit = () => {
|
||||
dispatch(publishCourseSectionQuery(currentSection.id));
|
||||
const handlePublishItemSubmit = () => {
|
||||
dispatch(publishCourseItemQuery(currentItem.id, currentSection.id));
|
||||
|
||||
closePublishModal();
|
||||
};
|
||||
@@ -104,17 +119,37 @@ const useCourseOutline = ({ courseId }) => {
|
||||
closeConfigureModal();
|
||||
};
|
||||
|
||||
const handleEditSectionSubmit = (sectionId, displayName) => {
|
||||
dispatch(editCourseSectionQuery(sectionId, displayName));
|
||||
const handleEditSubmit = (itemId, sectionId, displayName) => {
|
||||
dispatch(editCourseItemQuery(itemId, sectionId, displayName));
|
||||
};
|
||||
|
||||
const handleDeleteSectionSubmit = () => {
|
||||
dispatch(deleteCourseSectionQuery(currentSection.id));
|
||||
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:
|
||||
dispatch(deleteCourseUnitQuery(
|
||||
currentItem.id,
|
||||
currentSubsection.id,
|
||||
currentSection.id,
|
||||
));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
closeDeleteModal();
|
||||
};
|
||||
|
||||
const handleDuplicateSectionSubmit = () => {
|
||||
dispatch(duplicateCourseSectionQuery(currentSection.id, courseStructure.id));
|
||||
dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id));
|
||||
};
|
||||
|
||||
const handleDuplicateSubsectionSubmit = () => {
|
||||
dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -151,9 +186,9 @@ const useCourseOutline = ({ courseId }) => {
|
||||
headerNavigationsActions,
|
||||
handleEnableHighlightsSubmit,
|
||||
handleHighlightsFormSubmit,
|
||||
handlePublishSectionSubmit,
|
||||
handleConfigureSectionSubmit,
|
||||
handleEditSectionSubmit,
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
statusBarData,
|
||||
isEnableHighlightsModalOpen,
|
||||
openEnableHighlightsModal,
|
||||
@@ -167,9 +202,11 @@ const useCourseOutline = ({ courseId }) => {
|
||||
isDeleteModalOpen,
|
||||
closeDeleteModal,
|
||||
openDeleteModal,
|
||||
handleDeleteSectionSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleNewSectionSubmit,
|
||||
handleNewSubsectionSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getCurrentSection } from '../data/selectors';
|
||||
import { getCurrentItem } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
const PublishModal = ({
|
||||
@@ -18,8 +18,8 @@ const PublishModal = ({
|
||||
onPublishSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { displayName, childInfo } = useSelector(getCurrentSection);
|
||||
const subSections = childInfo?.children || [];
|
||||
const { displayName, childInfo, category } = useSelector(getCurrentItem);
|
||||
const children = childInfo?.children || [];
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
@@ -35,23 +35,31 @@ const PublishModal = ({
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<p className="small">{intl.formatMessage(messages.description)}</p>
|
||||
{subSections.filter(subSection => subSection.hasChanges).map((subSection) => {
|
||||
const units = subSection.childInfo.children.filter(unit => unit.hasChanges);
|
||||
<p className="small">{intl.formatMessage(messages.description, { category })}</p>
|
||||
{children.filter(child => child.hasChanges).map((child) => {
|
||||
let grandChildren = child.childInfo?.children || [];
|
||||
grandChildren = grandChildren.filter(grandChild => grandChild.hasChanges);
|
||||
|
||||
return units.length ? (
|
||||
<React.Fragment key={subSection.id}>
|
||||
<span className="small text-gray-400">{subSection.displayName}</span>
|
||||
{units.map((unit) => (
|
||||
return grandChildren.length ? (
|
||||
<React.Fragment key={child.id}>
|
||||
<span className="small text-gray-400">{child.displayName}</span>
|
||||
{grandChildren.map((grandChild) => (
|
||||
<div
|
||||
key={unit.id}
|
||||
key={grandChild.id}
|
||||
className="small border border-light-400 p-2 publish-modal__subsection"
|
||||
>
|
||||
{unit.displayName}
|
||||
{grandChild.displayName}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
) : null;
|
||||
) : (
|
||||
<div
|
||||
key={child.id}
|
||||
className="small border border-light-400 p-2 publish-modal__subsection"
|
||||
>
|
||||
{child.displayName}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="pt-1">
|
||||
|
||||
@@ -14,21 +14,20 @@ 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('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const currentSectionMock = {
|
||||
const currentItemMock = {
|
||||
displayName: 'Publish',
|
||||
childInfo: {
|
||||
displayName: 'Subsection',
|
||||
@@ -102,13 +101,13 @@ describe('<PublishModal />', () => {
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
useSelector.mockReturnValue(currentSectionMock);
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
});
|
||||
|
||||
it('renders PublishModal component correctly', () => {
|
||||
const { getByText, getByRole, queryByText } = renderComponent();
|
||||
|
||||
expect(getByText(`Publish ${currentSectionMock.displayName}`)).toBeInTheDocument();
|
||||
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();
|
||||
|
||||
@@ -7,7 +7,7 @@ const messages = defineMessages({
|
||||
},
|
||||
description: {
|
||||
id: 'course-authoring.course-outline.publish-modal.description',
|
||||
defaultMessage: 'Publish all unpublished changes for this section?',
|
||||
defaultMessage: 'Publish all unpublished changes for this {category}?',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'course-authoring.course-outline.publish-modal.button.cancel',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
React,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useState,
|
||||
@@ -10,10 +9,10 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Badge, Button, useToggle } from '@edx/paragon';
|
||||
import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
|
||||
import { setCurrentSection } from '../data/slice';
|
||||
import { setCurrentItem, setCurrentSection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import { getSectionStatus } from '../utils';
|
||||
import { getItemStatus } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
const SectionCard = forwardRef(({
|
||||
@@ -27,6 +26,7 @@ const SectionCard = forwardRef(({
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onNewSubsectionSubmit,
|
||||
}, lastItemRef) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
@@ -49,7 +49,7 @@ const SectionCard = forwardRef(({
|
||||
highlights,
|
||||
} = section;
|
||||
|
||||
const sectionStatus = getSectionStatus({
|
||||
const sectionStatus = getItemStatus({
|
||||
published,
|
||||
releasedToStudents,
|
||||
visibleToStaffOnly,
|
||||
@@ -62,12 +62,14 @@ const SectionCard = forwardRef(({
|
||||
};
|
||||
|
||||
const handleClickMenuButton = () => {
|
||||
dispatch(setCurrentItem(section));
|
||||
dispatch(setCurrentSection(section));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue) => {
|
||||
if (displayName !== titleValue) {
|
||||
onEditSectionSubmit(id, titleValue);
|
||||
// both itemId and sectionId are same
|
||||
onEditSectionSubmit(id, id, titleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,6 +80,10 @@ const SectionCard = forwardRef(({
|
||||
onOpenHighlightsModal(section);
|
||||
};
|
||||
|
||||
const handleNewSubsectionSubmit = () => {
|
||||
onNewSubsectionSubmit(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
@@ -89,7 +95,7 @@ const SectionCard = forwardRef(({
|
||||
<CardHeader
|
||||
sectionId={id}
|
||||
title={displayName}
|
||||
sectionStatus={sectionStatus}
|
||||
status={sectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
isExpanded={isExpanded}
|
||||
onExpand={handleExpandContent}
|
||||
@@ -103,6 +109,7 @@ const SectionCard = forwardRef(({
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
namePrefix="section"
|
||||
/>
|
||||
<div className="section-card__content" data-testid="section-card__content">
|
||||
<div className="outline-section__status">
|
||||
@@ -118,20 +125,21 @@ const SectionCard = forwardRef(({
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div data-testid="section-card__subsections" className="section-card__subsections">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<Button
|
||||
data-testid="new-subsection-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
>
|
||||
{intl.formatMessage(messages.newSubsectionButton)}
|
||||
</Button>
|
||||
<>
|
||||
<div data-testid="section-card__subsections" className="section-card__subsections">
|
||||
{children}
|
||||
</div>
|
||||
<Button
|
||||
data-testid="new-subsection-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewSubsectionSubmit}
|
||||
>
|
||||
{intl.formatMessage(messages.newSubsectionButton)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -162,6 +170,7 @@ SectionCard.propTypes = {
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
isSectionsExpanded: PropTypes.bool.isRequired,
|
||||
onNewSubsectionSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SectionCard;
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
margin-top: $spacer;
|
||||
}
|
||||
|
||||
.section-card__subsections {
|
||||
margin-top: $spacer;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -38,6 +38,7 @@ const renderComponent = (props) => render(
|
||||
onEditSectionSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
namePrefix="section"
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
|
||||
145
src/course-outline/subsection-card/SubsectionCard.jsx
Normal file
145
src/course-outline/subsection-card/SubsectionCard.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, useToggle } from '@edx/paragon';
|
||||
import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import { getItemStatus } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
const SubsectionCard = forwardRef(({
|
||||
section,
|
||||
subsection,
|
||||
children,
|
||||
onOpenPublishModal,
|
||||
onEditSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
}, lastItemRef) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
|
||||
const {
|
||||
id,
|
||||
displayName,
|
||||
hasChanges,
|
||||
published,
|
||||
releasedToStudents,
|
||||
visibleToStaffOnly = false,
|
||||
visibilityState,
|
||||
staffOnlyMessage,
|
||||
} = subsection;
|
||||
|
||||
const subsectionStatus = getItemStatus({
|
||||
published,
|
||||
releasedToStudents,
|
||||
visibleToStaffOnly,
|
||||
visibilityState,
|
||||
staffOnlyMessage,
|
||||
});
|
||||
|
||||
const handleExpandContent = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const handleClickMenuButton = () => {
|
||||
dispatch(setCurrentSection(section));
|
||||
dispatch(setCurrentSubsection(subsection));
|
||||
dispatch(setCurrentItem(subsection));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue) => {
|
||||
if (displayName !== titleValue) {
|
||||
onEditSubmit(id, section.id, titleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
closeForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
return (
|
||||
<div className="subsection-card" data-testid="subsection-card" ref={lastItemRef}>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={subsectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
isExpanded={isExpanded}
|
||||
onExpand={handleExpandContent}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
namePrefix="subsection"
|
||||
/>
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div data-testid="subsection-card__units" className="subsection-card__units">
|
||||
{children}
|
||||
</div>
|
||||
<Button
|
||||
data-testid="new-unit-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SubsectionCard.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
SubsectionCard.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,
|
||||
}).isRequired,
|
||||
subsection: 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,
|
||||
}).isRequired,
|
||||
children: PropTypes.node,
|
||||
onOpenPublishModal: PropTypes.func.isRequired,
|
||||
onEditSubmit: PropTypes.func.isRequired,
|
||||
savingStatus: PropTypes.string.isRequired,
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SubsectionCard;
|
||||
20
src/course-outline/subsection-card/SubsectionCard.scss
Normal file
20
src/course-outline/subsection-card/SubsectionCard.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
.subsection-card {
|
||||
@include pgn-box-shadow(1, "centered");
|
||||
|
||||
padding: $spacer 2rem;
|
||||
cursor: move;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.subsection-card__content {
|
||||
margin: $spacer;
|
||||
}
|
||||
|
||||
|
||||
.subsection-card-title {
|
||||
font-size: $h4-font-size;
|
||||
font-family: $headings-font-family;
|
||||
font-weight: $headings-font-weight;
|
||||
line-height: $headings-line-height;
|
||||
color: $headings-color;
|
||||
}
|
||||
}
|
||||
81
src/course-outline/subsection-card/SubsectionCard.test.jsx
Normal file
81
src/course-outline/subsection-card/SubsectionCard.test.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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 SubsectionCard from './SubsectionCard';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const subsection = {
|
||||
id: '123',
|
||||
displayName: 'Subsection Name',
|
||||
published: true,
|
||||
releasedToStudents: true,
|
||||
visibleToStaffOnly: false,
|
||||
visibilityState: 'visible',
|
||||
staffOnlyMessage: false,
|
||||
hasChanges: false,
|
||||
};
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<SubsectionCard
|
||||
subsection={subsection}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onEditClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
namePrefix="subsection"
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SubsectionCard>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<SubsectionCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render SubsectionCard component correctly', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('subsection-card-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands/collapses the card when the subsection button is clicked', async () => {
|
||||
const { queryByTestId, findByTestId } = renderComponent();
|
||||
|
||||
const expandButton = await findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandButton);
|
||||
expect(queryByTestId('subsection-card__units')).toBeInTheDocument();
|
||||
expect(queryByTestId('new-unit-button')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(expandButton);
|
||||
expect(queryByTestId('subsection-card__units')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('new-unit-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
10
src/course-outline/subsection-card/messages.js
Normal file
10
src/course-outline/subsection-card/messages.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
newUnitButton: {
|
||||
id: 'course-authoring.course-outline.subsection.button.new-unit',
|
||||
defaultMessage: 'New unit',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
EditOutline as EditOutlineIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { SECTION_BADGE_STATUTES, STAFF_ONLY } from './constants';
|
||||
import { ITEM_BADGE_STATUS, STAFF_ONLY } from './constants';
|
||||
|
||||
/**
|
||||
* Get section status depended on section info
|
||||
@@ -13,9 +13,9 @@ import { SECTION_BADGE_STATUTES, STAFF_ONLY } from './constants';
|
||||
* @param {bool} visibleToStaffOnly - value from section info
|
||||
* @param {string} visibilityState - value from section info
|
||||
* @param {bool} staffOnlyMessage - value from section info
|
||||
* @returns {SECTION_BADGE_STATUTES[keyof SECTION_BADGE_STATUTES]}
|
||||
* @returns {ITEM_BADGE_STATUS[keyof ITEM_BADGE_STATUS]}
|
||||
*/
|
||||
const getSectionStatus = ({
|
||||
const getItemStatus = ({
|
||||
published,
|
||||
releasedToStudents,
|
||||
visibleToStaffOnly,
|
||||
@@ -24,13 +24,13 @@ const getSectionStatus = ({
|
||||
}) => {
|
||||
switch (true) {
|
||||
case published && releasedToStudents:
|
||||
return SECTION_BADGE_STATUTES.live;
|
||||
return ITEM_BADGE_STATUS.live;
|
||||
case published && !releasedToStudents:
|
||||
return SECTION_BADGE_STATUTES.publishedNotLive;
|
||||
return ITEM_BADGE_STATUS.publishedNotLive;
|
||||
case visibleToStaffOnly && staffOnlyMessage && visibilityState === STAFF_ONLY:
|
||||
return SECTION_BADGE_STATUTES.staffOnly;
|
||||
return ITEM_BADGE_STATUS.staffOnly;
|
||||
case !published:
|
||||
return SECTION_BADGE_STATUTES.draft;
|
||||
return ITEM_BADGE_STATUS.draft;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -38,30 +38,30 @@ const getSectionStatus = ({
|
||||
|
||||
/**
|
||||
* Get section badge status content
|
||||
* @param {string} status - value from on getSectionStatus util
|
||||
* @param {string} status - value from on getItemStatus util
|
||||
* @returns {
|
||||
* badgeTitle: string,
|
||||
* badgeIcon: node,
|
||||
* }
|
||||
*/
|
||||
const getSectionStatusBadgeContent = (status, messages, intl) => {
|
||||
const getItemStatusBadgeContent = (status, messages, intl) => {
|
||||
switch (status) {
|
||||
case SECTION_BADGE_STATUTES.live:
|
||||
case ITEM_BADGE_STATUS.live:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeLive),
|
||||
badgeIcon: CheckCircleIcon,
|
||||
};
|
||||
case SECTION_BADGE_STATUTES.publishedNotLive:
|
||||
case ITEM_BADGE_STATUS.publishedNotLive:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive),
|
||||
badgeIcon: '',
|
||||
};
|
||||
case SECTION_BADGE_STATUTES.staffOnly:
|
||||
case ITEM_BADGE_STATUS.staffOnly:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeStaffOnly),
|
||||
badgeIcon: LockIcon,
|
||||
};
|
||||
case SECTION_BADGE_STATUTES.draft:
|
||||
case ITEM_BADGE_STATUS.draft:
|
||||
return {
|
||||
badgeTitle: intl.formatMessage(messages.statusBadgeDraft),
|
||||
badgeIcon: EditOutlineIcon,
|
||||
@@ -110,12 +110,16 @@ const getHighlightsFormValues = (currentHighlights) => {
|
||||
};
|
||||
|
||||
const scrollToElement = (ref) => {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
ref.current?.scrollIntoView({
|
||||
block: 'end',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
getSectionStatus,
|
||||
getSectionStatusBadgeContent,
|
||||
getItemStatus,
|
||||
getItemStatusBadgeContent,
|
||||
getHighlightsFormValues,
|
||||
scrollToElement,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user