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:
Navin Karkera
2023-11-23 17:41:07 +05:30
committed by Kristin Aoki
parent 91ba00346c
commit f79bebceeb
30 changed files with 943 additions and 214 deletions

View File

@@ -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">

View File

@@ -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";

View File

@@ -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,

View 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: '',
},
};

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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);

View File

@@ -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} */ ({

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 }),
));
};
}

View File

@@ -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>
);
};

View File

@@ -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();

View File

@@ -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',

View File

@@ -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();

View File

@@ -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,
};
};

View File

@@ -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">

View File

@@ -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();

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;

View File

@@ -38,6 +38,7 @@ const renderComponent = (props) => render(
onEditSectionSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
namePrefix="section"
{...props}
>
<span>children</span>

View 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;

View 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;
}
}

View 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();
});
});

View 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;

View File

@@ -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,
};