feat: course outline - sections list

* feat: [2u-259] add components

* feat: [2u-259] fix sidebar

* feat: [2u-259] add tests, fix links

* feat: [2u-259] fix messages

* feat: [2u-159] fix reducer and sidebar

* feat: [2u-259] fix reducer

* feat: [2u-259] remove warning from selectors

* feat: [2u-259] remove indents

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course Outline  - Sections list (#59)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* fix: [2u-342] fix translates and indents

* fix: [2u-342] fix constants and expand block

* feat: [2u-336] remove new section from menu

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course outline - Content empty (#72)

* feat: [2u-324] add component

* feat: [2u-324] add translates

* feat: [2u-324] update tests

* feat: [2u-324] update branch

* fix: [2u-324] fixed empty handler

feat: Course outline - Section Publish (#61)

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-354] refactor modal

* fix: [2u-354] removed comments

* fix: [2u-354] fix indents

* fix: [2u-354] removed translates duplicates

* fix: [2u-354] rename handlers

feat: Course outline - Update section card (#71)

* feat: [2u-615] update section card

* fix: [2u-615] fix handler names

* fix: [2u-615] fix indents

* fix: [2u-615] add empty handler

* fix: [2u-615] fix data test id name

* fix: [2u-615] fix styles

fix: [2u-696] add saving processing for higlights and enable highlights (#78)

feat: Course outline - Section Edit (#70)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* feat: [2u-342] add modal

* fix: [2u-342] fix translates and indents

* feat: [2u-342] add modal

* feat: [2u-342] add api

* feat: [2u-342] add tests and translates

* feat: [2u-342] fix indents

* fix: [2u-342] fix indents, variant and utils

* feat: [2u-342] fixed slice, thunks, hooks

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-615] update section card

* feat: [2u-348] add api, handlers, tests

* feat: [2u-348] add description for api

* fix: [2u-348] fix useEscapeClick

* fix: [2u-348] remove useEffect from CardHeader

* fix: [2u-348] fixed handlers and tests

* fix: [2u-348] fixed handlers and tests

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course outline - Section Delete (#74)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* feat: [2u-342] add modal

* fix: [2u-342] fix translates and indents

* feat: [2u-342] add modal

* feat: [2u-342] add api

* feat: [2u-342] add tests and translates

* feat: [2u-342] fix indents

* fix: [2u-342] fix indents, variant and utils

* feat: [2u-342] fixed slice, thunks, hooks

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-615] update section card

* feat: [2u-348] add api, handlers, tests

* feat: [2u-510] add delete api, add delete modal

* fix: [2u-510] fixed tests

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

feat: Course outline - Section duplicate (#88)

* feat: [2u-336] add tests

* feat: [2u-271] fix button

* feat: [2u-336] add component, refactor header

* feat: [2u-342] add modal

* fix: [2u-342] fix translates and indents

* feat: [2u-342] add modal

* feat: [2u-342] add api

* feat: [2u-342] add tests and translates

* feat: [2u-342] fix indents

* fix: [2u-342] fix indents, variant and utils

* feat: [2u-342] fixed slice, thunks, hooks

* feat: [2u-354] add publish modal, api and update tests

* feat: [2u-615] update section card

* feat: [2u-348] add api, handlers, tests

* feat: [2u-510] add delete api, add delete modal

* feat: [2u-360] add api

* feat: [2u-360] add slice

* feat: [2u-360] add tests

* fix: [2u-360] fixed tests

---------

Co-authored-by: Vladislav Keblysh <vladislavkeblysh@Vladislavs-MacBook-Pro.local>

fix: Course outline - Highlights links (#89)

* fix: fixed doc urls

* fix: fixed components

feat: Course outline - Collapse all sections (#75)

* feat: added collapse all section logic

* fix: fixed tests

fix: final revision commits

fix: increase code coverage on the page
This commit is contained in:
vladislavkeblysh
2023-08-03 15:42:49 +03:00
committed by Kristin Aoki
parent f938d08361
commit 59071424b3
43 changed files with 2390 additions and 43 deletions

View File

@@ -10,24 +10,35 @@ import {
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
} from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import SubHeader from '../generic/sub-header/SubHeader';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import { RequestStatus } from '../data/constants';
import SubHeader from '../generic/sub-header/SubHeader';
import ProcessingNotification from '../generic/processing-notification';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import AlertMessage from '../generic/alert-message';
import getPageHeadTitle from '../generic/utils';
import HeaderNavigations from './header-navigations/HeaderNavigations';
import OutlineSideBar from './outline-sidebar/OutlineSidebar';
import messages from './messages';
import { useCourseOutline } from './hooks';
import StatusBar from './status-bar/StatusBar';
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
import SectionCard from './section-card/SectionCard';
import HighlightsModal from './highlights-modal/HighlightsModal';
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
import PublishModal from './publish-modal/PublishModal';
import DeleteModal from './delete-modal/DeleteModal';
import { useCourseOutline } from './hooks';
import messages from './messages';
const CourseOutline = ({ courseId }) => {
const intl = useIntl();
const {
courseName,
savingStatus,
statusBarData,
sectionsList,
isLoading,
isReIndexShow,
showErrorAlert,
@@ -36,13 +47,34 @@ const CourseOutline = ({ courseId }) => {
isEnableHighlightsModalOpen,
isInternetConnectionAlertFailed,
isDisabledReindexButton,
isHighlightsModalOpen,
isPublishModalOpen,
isDeleteModalOpen,
closeHighlightsModal,
closePublishModal,
closeDeleteModal,
openPublishModal,
openDeleteModal,
headerNavigationsActions,
openEnableHighlightsModal,
closeEnableHighlightsModal,
handleEnableHighlightsSubmit,
handleInternetConnectionFailed,
handleOpenHighlightsModal,
handleHighlightsFormSubmit,
handlePublishSectionSubmit,
handleEditSectionSubmit,
handleDeleteSectionSubmit,
handleDuplicateSectionSubmit,
} = useCourseOutline({ courseId });
document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle));
const {
isShow: isShowProcessingNotification,
title: processingNotificationTitle,
} = useSelector(getProcessingNotification);
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
@@ -77,6 +109,7 @@ const CourseOutline = ({ courseId }) => {
isSectionsExpanded={isSectionsExpanded}
headerNavigationsActions={headerNavigationsActions}
isDisabledReindexButton={isDisabledReindexButton}
hasSections={Boolean(sectionsList.length)}
/>
)}
/>
@@ -97,6 +130,23 @@ const CourseOutline = ({ courseId }) => {
statusBarData={statusBarData}
openEnableHighlightsModal={openEnableHighlightsModal}
/>
<div className="pt-4">
{/* TODO add create new section handler in EmptyPlaceholder */}
{sectionsList.length ? sectionsList.map((section) => (
<SectionCard
section={section}
savingStatus={savingStatus}
onOpenHighlightsModal={handleOpenHighlightsModal}
onOpenPublishModal={openPublishModal}
onOpenDeleteModal={openDeleteModal}
onEditSectionSubmit={handleEditSectionSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
/>
)) : (
<EmptyPlaceholder onCreateNewSection={() => ({})} />
)}
</div>
</section>
</div>
</article>
@@ -109,11 +159,29 @@ const CourseOutline = ({ courseId }) => {
isOpen={isEnableHighlightsModalOpen}
close={closeEnableHighlightsModal}
onEnableHighlightsSubmit={handleEnableHighlightsSubmit}
highlightsDocUrl={statusBarData.highlightsDocUrl}
/>
</section>
<HighlightsModal
isOpen={isHighlightsModalOpen}
onClose={closeHighlightsModal}
onSubmit={handleHighlightsFormSubmit}
/>
<PublishModal
isOpen={isPublishModalOpen}
onClose={closePublishModal}
onPublishSubmit={handlePublishSectionSubmit}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={handleDeleteSectionSubmit}
/>
</Container>
<div className="alert-toast">
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
<InternetConnectionAlert
isFailed={isInternetConnectionAlertFailed}
isQueryPending={savingStatus === RequestStatus.PENDING}

View File

@@ -1,2 +1,7 @@
@import "./header-navigations/HeaderNavigations";
@import "./status-bar/StatusBar";
@import "./section-card/SectionCard";
@import "./card-header/CardHeader";
@import "./empty-placeholder/EmptyPlaceholder";
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { render, waitFor, 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';
@@ -11,18 +11,29 @@ import {
getCourseLaunchApiUrl,
getCourseOutlineIndexApiUrl,
getCourseReindexApiUrl,
getCourseReindexApiUrl,
getCourseSectionApiUrl,
getCourseSectionDuplicateApiUrl,
getEnableHighlightsEmailsApiUrl,
getUpdateCourseSectionApiUrl,
} from './data/api';
import {
deleteCourseSectionQuery,
duplicateCourseSectionQuery,
editCourseSectionQuery,
enableCourseHighlightsEmailsQuery,
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery,
fetchCourseReindexQuery,
fetchCourseSectionQuery,
publishCourseSectionQuery,
updateCourseSectionHighlightsQuery,
} from './data/thunk';
import initializeStore from '../store';
import {
courseOutlineIndexMock,
courseOutlineIndexWithoutSections,
courseBestPracticesMock,
courseLaunchMock,
} from './__mocks__';
@@ -147,4 +158,172 @@ describe('<CourseOutline />', () => {
await executeThunk(enableCourseHighlightsEmailsQuery(courseId), store.dispatch);
expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument();
});
it('should expand and collapse subsections, after click on subheader buttons', async () => {
const { queryAllByTestId, getByText } = render(<RootWrapper />);
await waitFor(() => {
const collapseBtn = getByText(messages.collapseAllButton.defaultMessage);
expect(collapseBtn).toBeInTheDocument();
fireEvent.click(collapseBtn);
const expendBtn = getByText(messages.expandAllButton.defaultMessage);
expect(expendBtn).toBeInTheDocument();
fireEvent.click(expendBtn);
const cardSubsections = queryAllByTestId('section-card__subsections');
cardSubsections.forEach(element => expect(element).toBeVisible());
fireEvent.click(collapseBtn);
cardSubsections.forEach(element => expect(element).not.toBeVisible());
});
});
it('render CourseOutline component without sections correctly', async () => {
cleanup();
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, courseOutlineIndexWithoutSections);
const { getByTestId } = render(<RootWrapper />);
await waitFor(() => {
expect(getByTestId('empty-placeholder')).toBeInTheDocument();
});
});
it('check edit section when edit query is successfully', async () => {
const { getByText } = render(<RootWrapper />);
const newDisplayName = 'New section name';
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
axiosMock
.onPost(getUpdateCourseSectionApiUrl(section.id, {
metadata: {
display_name: newDisplayName,
},
}))
.reply(200);
await executeThunk(editCourseSectionQuery(section.id, newDisplayName), store.dispatch);
axiosMock
.onGet(getCourseSectionApiUrl(section.id))
.reply(200);
await executeThunk(fetchCourseSectionQuery(section.id), store.dispatch);
await waitFor(() => {
expect(getByText(section.displayName)).toBeInTheDocument();
});
});
it('check delete section when edit query is successfully', async () => {
const { queryByText } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[1];
axiosMock.onDelete(getUpdateCourseSectionApiUrl(section.id)).reply(200);
await executeThunk(deleteCourseSectionQuery(section.id), store.dispatch);
await waitFor(() => {
expect(queryByText(section.displayName)).not.toBeInTheDocument();
});
});
it('check duplicate section when duplicate query is successfully', async () => {
const { getAllByTestId } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
axiosMock
.onPost(getCourseSectionDuplicateApiUrl())
.reply(200, {
duplicate_source_locator: section.id,
parent_locator: courseBlockId,
});
await executeThunk(duplicateCourseSectionQuery(section.id, courseBlockId), store.dispatch);
await waitFor(() => {
expect(getAllByTestId('section-card')).toHaveLength(4);
});
});
it('check publish section when publish query is successfully', async () => {
cleanup();
const { getAllByTestId } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId))
.reply(200, {
courseOutlineIndexMock,
courseStructure: {
childInfo: {
children: [
{
...section,
published: false,
},
],
},
},
});
axiosMock
.onPost(getUpdateCourseSectionApiUrl(section.id), {
publish: 'make_public',
})
.reply(200);
await executeThunk(publishCourseSectionQuery(section.id), store.dispatch);
axiosMock
.onGet(getCourseSectionApiUrl(section.id))
.reply(200, {
...section,
published: true,
releasedToStudents: false,
});
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');
});
it('check update highlights when update highlights query is successfully', async () => {
const { getByRole } = render(<RootWrapper />);
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
const highlights = [
'New Highlight 1',
'New Highlight 2',
'New Highlight 3',
'New Highlight 4',
'New Highlight 5',
];
axiosMock
.onPost(getUpdateCourseSectionApiUrl(section.id), {
publish: 'republish',
metadata: {
highlights,
},
})
.reply(200);
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
axiosMock
.onGet(getCourseSectionApiUrl(section.id))
.reply(200, {
...section,
highlights,
});
await executeThunk(fetchCourseSectionQuery(section.id), store.dispatch);
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Dropdown,
Form,
Icon,
IconButton,
OverlayTrigger,
Tooltip,
Truncate,
} from '@edx/paragon';
import {
ArrowDropDown as ArrowDownIcon,
MoreVert as MoveVertIcon,
EditOutline as EditIcon,
} from '@edx/paragon/icons';
import classNames from 'classnames';
import { useEscapeClick } from '../../hooks';
import { SECTION_BADGE_STATUTES } from '../constants';
import { getSectionStatusBadgeContent } from '../utils';
import messages from './messages';
const CardHeader = ({
title,
sectionStatus,
isExpanded,
onClickPublish,
onClickMenuButton,
onClickEdit,
onExpand,
isFormOpen,
onEditSubmit,
closeForm,
isDisabledEditField,
onClickDelete,
onClickDuplicate,
}) => {
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;
useEscapeClick({
onEscape: () => {
setTitleValue(title);
closeForm();
},
dependency: title,
});
return (
<div className="section-card-header" data-testid="section-card-header">
{isFormOpen ? (
<Form.Group className="m-0">
<Form.Control
data-testid="edit field"
ref={(e) => e && e.focus()}
value={titleValue}
name="displayName"
onChange={(e) => setTitleValue(e.target.value)}
aria-label="edit field"
onBlur={() => onEditSubmit(titleValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onEditSubmit(titleValue);
}
}}
disabled={isDisabledEditField}
/>
</Form.Group>
) : (
<OverlayTrigger
placement="bottom-start"
overlay={(
<Tooltip
id={intl.formatMessage(messages.expandTooltip)}
className="section-card-header-tooltip"
>
{intl.formatMessage(messages.expandTooltip)}
</Tooltip>
)}
>
<Button
iconBefore={ArrowDownIcon}
variant="tertiary"
data-testid="section-card-header__expanded-btn"
className={classNames('section-card-header__expanded-btn', {
collapsed: !isExpanded,
})}
onClick={() => onExpand((prevState) => !prevState)}
>
<Truncate lines={1} className="h3 mb-0">{title}</Truncate>
{badgeTitle && (
<div className="section-card-header__badge-status" data-testid="section-card-header__badge-status">
{badgeIcon && (
<Icon
src={badgeIcon}
size="sm"
className={classNames({ 'text-success-500': sectionStatus === SECTION_BADGE_STATUTES.live })}
/>
)}
<span className="small">{badgeTitle}</span>
</div>
)}
</Button>
</OverlayTrigger>
)}
<div className="ml-auto d-flex">
{!isFormOpen && (
<IconButton
data-testid="edit-button"
alt={intl.formatMessage(messages.altButtonEdit)}
iconAs={EditIcon}
onClick={onClickEdit}
/>
)}
<Dropdown data-testid="section-card-header__menu" onClick={onClickMenuButton}>
<Dropdown.Toggle
className="section-card-header__menu"
id="section-card-header__menu"
data-testid="section-card-header__menu-button"
as={IconButton}
src={MoveVertIcon}
alt="section-card-header__menu"
iconAs={Icon}
/>
<Dropdown.Menu>
<Dropdown.Item
disabled={isDisabledPublish}
onClick={onClickPublish}
>
{intl.formatMessage(messages.menuPublish)}
</Dropdown.Item>
<Dropdown.Item>{intl.formatMessage(messages.menuConfigure)}</Dropdown.Item>
<Dropdown.Item onClick={onClickDuplicate}>{intl.formatMessage(messages.menuDuplicate)}</Dropdown.Item>
<Dropdown.Item onClick={onClickDelete}>{intl.formatMessage(messages.menuDelete)}</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
</div>
);
};
CardHeader.propTypes = {
title: PropTypes.string.isRequired,
sectionStatus: PropTypes.string.isRequired,
isExpanded: PropTypes.bool.isRequired,
onExpand: PropTypes.func.isRequired,
onClickPublish: PropTypes.func.isRequired,
onClickMenuButton: PropTypes.func.isRequired,
onClickEdit: PropTypes.func.isRequired,
isFormOpen: PropTypes.bool.isRequired,
onEditSubmit: PropTypes.func.isRequired,
closeForm: PropTypes.func.isRequired,
isDisabledEditField: PropTypes.bool.isRequired,
onClickDelete: PropTypes.func.isRequired,
onClickDuplicate: PropTypes.func.isRequired,
};
export default CardHeader;

View File

@@ -0,0 +1,65 @@
.section-card-header {
display: flex;
align-items: center;
margin-right: -.5rem;
.section-card-header__expanded-btn {
justify-content: flex-start;
padding: 0;
width: 80%;
height: 1.5rem;
margin-right: .25rem;
background: transparent;
&::before {
display: none;
}
& svg {
width: 1.5rem;
height: 1.5rem;
}
&.collapsed > .pgn__icon {
transform: rotate(180deg);
}
& span:first-child {
color: $black;
}
}
.section-card-header__badge-status {
display: flex;
padding: 1px .5rem;
justify-content: center;
align-items: center;
gap: .25rem;
border-radius: .375rem;
border: 1px solid $light-300;
margin: 0 .75rem;
& span:last-child {
color: $primary-700;
}
}
.section-card-header__menu {
display: flex;
align-items: center;
}
.pgn__form-group {
width: 80%;
}
}
.section-card-header-tooltip {
.tooltip-inner {
max-width: 18.75rem;
}
.arrow {
transform: translate(5.75rem, 0) !important;
}
}

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { SECTION_BADGE_STATUTES } from '../constants';
import CardHeader from './CardHeader';
import messages from './messages';
const onExpandMock = jest.fn();
const onClickMenuButtonMock = jest.fn();
const onClickPublishMock = jest.fn();
const onClickEditMock = jest.fn();
const onClickDeleteMock = jest.fn();
const onClickDuplicateMock = jest.fn();
const closeFormMock = jest.fn();
const cardHeaderProps = {
title: 'Some title',
sectionStatus: SECTION_BADGE_STATUTES.live,
isExpanded: true,
onExpand: onExpandMock,
onClickMenuButton: onClickMenuButtonMock,
onClickPublish: onClickPublishMock,
onClickEdit: onClickEditMock,
isFormOpen: false,
onEditSubmit: jest.fn(),
closeForm: closeFormMock,
isDisabledEditField: false,
onClickDelete: onClickDeleteMock,
onClickDuplicate: onClickDuplicateMock,
};
const renderComponent = (props) => render(
<IntlProvider locale="en">
<CardHeader
{...cardHeaderProps}
{...props}
/>
</IntlProvider>,
);
describe('<CardHeader />', () => {
it('render CardHeader component correctly', () => {
const { getByText, getByTestId, queryByTestId } = renderComponent();
expect(getByText(cardHeaderProps.title)).toBeInTheDocument();
expect(getByTestId('section-card-header__expanded-btn')).toBeInTheDocument();
expect(getByTestId('section-card-header__badge-status')).toBeInTheDocument();
expect(getByTestId('section-card-header__menu')).toBeInTheDocument();
expect(queryByTestId('edit field')).not.toBeInTheDocument();
});
it('render status badge as live', () => {
const { getByText } = renderComponent();
expect(getByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
});
it('render status badge as published_not_live', () => {
const { getByText } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
});
expect(getByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
});
it('render status badge as staff_only', () => {
const { getByText } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.staffOnly,
});
expect(getByText(messages.statusBadgeStuffOnly.defaultMessage)).toBeInTheDocument();
});
it('render status badge as draft', () => {
const { getByText } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.draft,
});
expect(getByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
});
it('check publish menu item is disabled when section status is live or published not live', async () => {
const { getByText, getByTestId } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.publishedNotLive,
});
const menuButton = getByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
expect(getByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
});
it('calls handleExpanded when button is clicked', () => {
const { getByTestId } = renderComponent();
const expandButton = getByTestId('section-card-header__expanded-btn');
fireEvent.click(expandButton);
expect(onExpandMock).toHaveBeenCalled();
});
it('calls onClickMenuButton when menu is clicked', () => {
const { getByTestId } = renderComponent();
const menuButton = getByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
expect(onClickMenuButtonMock).toHaveBeenCalled();
});
it('calls onClickPublish when item is clicked', () => {
const { getByText, getByTestId } = renderComponent({
...cardHeaderProps,
sectionStatus: SECTION_BADGE_STATUTES.draft,
});
const menuButton = getByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
const publishMenuItem = getByText(messages.menuPublish.defaultMessage);
fireEvent.click(publishMenuItem);
expect(onClickPublishMock).toHaveBeenCalled();
});
it('calls onClickEdit when the button is clicked', () => {
const { getByTestId } = renderComponent();
const editButton = getByTestId('edit-button');
fireEvent.click(editButton);
expect(onClickEditMock).toHaveBeenCalled();
});
it('check is field visible when isFormOpen is true', () => {
const { getByTestId, queryByTestId } = renderComponent({
...cardHeaderProps,
isFormOpen: true,
});
expect(getByTestId('edit field')).toBeInTheDocument();
expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument();
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
});
it('check is field disabled when isDisabledEditField is true', () => {
const { getByTestId } = renderComponent({
...cardHeaderProps,
isFormOpen: true,
isDisabledEditField: true,
});
expect(getByTestId('edit field')).toBeDisabled();
});
it('calls onClickDelete when item is clicked', () => {
const { getByText, getByTestId } = renderComponent();
const menuButton = getByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
const deleteMenuItem = getByText(messages.menuDelete.defaultMessage);
fireEvent.click(deleteMenuItem);
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
});
it('calls onClickDuplicate when item is clicked', () => {
const { getByText, getByTestId } = renderComponent();
const menuButton = getByTestId('section-card-header__menu-button');
fireEvent.click(menuButton);
const duplicateMenuItem = getByText(messages.menuDuplicate.defaultMessage);
fireEvent.click(duplicateMenuItem);
expect(onClickDuplicateMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,46 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
expandTooltip: {
id: 'course-authoring.course-outline.section.expandTooltip',
defaultMessage: 'Collapse/Expand this section',
},
statusBadgeLive: {
id: 'course-authoring.course-outline.section.status-badge.live',
defaultMessage: 'Live',
},
statusBadgePublishedNotLive: {
id: 'course-authoring.course-outline.section.status-badge.published-not-live',
defaultMessage: 'Published not live',
},
statusBadgeStuffOnly: {
id: 'course-authoring.course-outline.section.status-badge.staff-only',
defaultMessage: 'Staff only',
},
statusBadgeDraft: {
id: 'course-authoring.course-outline.section.status-badge.draft',
defaultMessage: 'Draft',
},
altButtonEdit: {
id: 'course-authoring.course-outline.section.button.edit.alt',
defaultMessage: 'Edit',
},
menuPublish: {
id: 'course-authoring.course-outline.section.menu.publish',
defaultMessage: 'Publish',
},
menuConfigure: {
id: 'course-authoring.course-outline.section.menu.configure',
defaultMessage: 'Configure',
},
menuDuplicate: {
id: 'course-authoring.course-outline.section.menu.duplicate',
defaultMessage: 'Duplicate',
},
menuDelete: {
id: 'course-authoring.course-outline.section.menu.delete',
defaultMessage: 'Delete',
},
});
export default messages;

View File

@@ -1,3 +1,14 @@
export const SECTION_BADGE_STATUTES = {
live: 'live',
publishedNotLive: 'published_not_live',
staffOnly: 'staff_only',
draft: 'draft',
};
export const STAFF_ONLY = 'staff_only';
export const HIGHLIGHTS_FIELD_MAX_LENGTH = 250;
export const CHECKLIST_FILTERS = {
ALL: 'ALL',
SELF_PACED: 'SELF_PACED',

View File

@@ -24,6 +24,9 @@ export const getEnableHighlightsEmailsApiUrl = (courseId) => {
};
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
export const getUpdateCourseSectionApiUrl = (sectionId) => `${getApiBaseUrl()}/xblock/${sectionId}`;
export const getCourseSectionApiUrl = (sectionId) => `${getApiBaseUrl()}/xblock/outline/${sectionId}`;
export const getCourseSectionDuplicateApiUrl = () => `${getApiBaseUrl()}/xblock/`;
/**
* Get course outline index.
@@ -105,3 +108,91 @@ export async function restartIndexingOnCourse(reindexLink) {
return camelCaseObject(data);
}
/**
* Get course section
* @param {string} sectionId
* @returns {Promise<Object>}
*/
export async function getCourseSection(sectionId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionApiUrl(sectionId));
return camelCaseObject(data);
}
/**
* Update course section highlights
* @param {string} sectionId
* @param {Array<string>} highlights
* @returns {Promise<Object>}
*/
export async function updateCourseSectionHighlights(sectionId, highlights) {
const { data } = await getAuthenticatedHttpClient()
.post(getUpdateCourseSectionApiUrl(sectionId), {
publish: 'republish',
metadata: {
highlights,
},
});
return data;
}
/**
* Publish course section
* @param {string} sectionId
* @returns {Promise<Object>}
*/
export async function publishCourseSection(sectionId) {
const { data } = await getAuthenticatedHttpClient()
.post(getUpdateCourseSectionApiUrl(sectionId), {
publish: 'make_public',
});
return data;
}
/**
* Edit course section
* @param {string} sectionId
* @param {string} displayName
* @returns {Promise<Object>}
*/
export async function editCourseSection(sectionId, displayName) {
const { data } = await getAuthenticatedHttpClient()
.post(getUpdateCourseSectionApiUrl(sectionId), {
metadata: {
display_name: displayName,
},
});
return data;
}
/**
* Delete course section
* @param {string} sectionId
* @returns {Promise<Object>}
*/
export async function deleteCourseSection(sectionId) {
const { data } = await getAuthenticatedHttpClient()
.delete(getUpdateCourseSectionApiUrl(sectionId));
return data;
}
/**
* Duplicate course section
* @param {string} sectionId
* @param {string} courseBlockId
* @returns {Promise<Object>}
*/
export async function duplicateCourseSection(sectionId, courseBlockId) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseSectionDuplicateApiUrl(), {
duplicate_source_locator: sectionId,
parent_locator: courseBlockId,
});
return data;
}

View File

@@ -2,3 +2,5 @@ export const getOutlineIndexData = (state) => state.courseOutline.outlineIndexDa
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 getCurrentSection = (state) => state.courseOutline.currentSection;

View File

@@ -9,13 +9,13 @@ const slice = createSlice({
loadingStatus: {
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
},
outlineIndexData: {},
savingStatus: '',
statusBarData: {
courseReleaseDate: '',
highlightsEnabledForMessaging: false,
highlightsDocUrl: '',
isSelfPaced: false,
checklist: {
totalCourseLaunchChecks: 0,
@@ -24,10 +24,13 @@ const slice = createSlice({
completedCourseBestPracticesChecks: 0,
},
},
sectionsList: [],
currentSection: {},
},
reducers: {
fetchOutlineIndexSuccess: (state, { payload }) => {
state.outlineIndexData = payload;
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
},
updateOutlineIndexLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
@@ -41,6 +44,12 @@ const slice = createSlice({
reIndexLoadingStatus: payload.status,
};
},
updateFetchSectionLoadingStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
fetchSectionLoadingStatus: payload.status,
};
},
updateStatusBar: (state, { payload }) => {
state.statusBarData = {
...state.statusBarData,
@@ -59,6 +68,23 @@ const slice = createSlice({
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
updateSectionList: (state, { payload }) => {
state.sectionsList = state.sectionsList.map((section) => (section.id === payload.id ? payload : section));
},
setCurrentSection: (state, { payload }) => {
state.currentSection = payload;
},
deleteSection: (state, { payload }) => {
state.sectionsList = state.sectionsList.filter(({ id }) => id !== payload);
},
duplicateSection: (state, { payload }) => {
state.sectionsList = state.sectionsList.reduce((result, currentValue) => {
if (currentValue.id === payload.id) {
return [...result, currentValue, payload.duplicatedSection];
}
return [...result, currentValue];
}, []);
},
},
});
@@ -69,7 +95,12 @@ export const {
updateStatusBar,
fetchStatusBarChecklistSuccess,
fetchStatusBarSelPacedSuccess,
updateFetchSectionLoadingStatus,
updateSavingStatus,
updateSectionList,
setCurrentSection,
deleteSection,
duplicateSection,
} = slice.actions;
export const {

View File

@@ -1,14 +1,25 @@
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../../generic/processing-notification/data/slice';
import {
getCourseBestPracticesChecklist,
getCourseLaunchChecklist,
} from '../utils/getChecklistForStatusBar';
import {
deleteCourseSection,
duplicateCourseSection,
editCourseSection,
enableCourseHighlightsEmails,
getCourseBestPractices,
getCourseLaunch,
getCourseOutlineIndex,
getCourseSection,
publishCourseSection,
restartIndexingOnCourse,
updateCourseSectionHighlights,
} from './api';
import {
fetchOutlineIndexSuccess,
@@ -18,6 +29,10 @@ import {
fetchStatusBarChecklistSuccess,
fetchStatusBarSelPacedSuccess,
updateSavingStatus,
updateSectionList,
updateFetchSectionLoadingStatus,
deleteSection,
duplicateSection,
} from './slice';
export function fetchCourseOutlineIndexQuery(courseId) {
@@ -26,9 +41,9 @@ export function fetchCourseOutlineIndexQuery(courseId) {
try {
const outlineIndex = await getCourseOutlineIndex(courseId);
const { courseReleaseDate, courseStructure: { highlightsEnabledForMessaging, highlightsDocUrl } } = outlineIndex;
const { courseReleaseDate, courseStructure: { highlightsEnabledForMessaging } } = outlineIndex;
dispatch(fetchOutlineIndexSuccess(outlineIndex));
dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging, highlightsDocUrl }));
dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging }));
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -78,12 +93,14 @@ export function fetchCourseBestPracticesQuery({
export function enableCourseHighlightsEmailsQuery(courseId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await enableCourseHighlightsEmails(courseId);
dispatch(fetchCourseOutlineIndexQuery(courseId));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
@@ -102,3 +119,115 @@ export function fetchCourseReindexQuery(courseId, reindexLink) {
}
};
}
export function fetchCourseSectionQuery(sectionId) {
return async (dispatch) => {
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const data = await getCourseSection(sectionId);
dispatch(updateSectionList(data));
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await updateCourseSectionHighlights(sectionId, highlights).then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery(sectionId));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function publishCourseSectionQuery(sectionId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await publishCourseSection(sectionId).then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery(sectionId));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function editCourseSectionQuery(sectionId, displayName) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await editCourseSection(sectionId, displayName).then(async (result) => {
if (result) {
await dispatch(fetchCourseSectionQuery(sectionId));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function deleteCourseSectionQuery(sectionId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
try {
await deleteCourseSection(sectionId);
dispatch(deleteSection(sectionId));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function duplicateCourseSectionQuery(sectionId, courseBlockId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await duplicateCourseSection(sectionId, courseBlockId).then(async (result) => {
if (result) {
const duplicatedSection = await getCourseSection(result.locator);
dispatch(duplicateSection({ id: sectionId, duplicatedSection }));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
}
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ActionRow,
Button,
AlertModal,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => {
const intl = useIntl();
return (
<AlertModal
title={intl.formatMessage(messages.title)}
variant="warning"
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={close}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button
onClick={(e) => {
e.preventDefault();
onDeleteSubmit();
}}
>
{intl.formatMessage(messages.deleteButton)}
</Button>
</ActionRow>
)}
>
<p>{intl.formatMessage(messages.description)}</p>
</AlertModal>
);
};
DeleteModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
onDeleteSubmit: PropTypes.func.isRequired,
};
export default DeleteModal;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import DeleteModal from './DeleteModal';
import messages from './messages';
const onDeleteSubmitMock = jest.fn();
const closeMock = jest.fn();
const renderComponent = (props) => render(
<IntlProvider locale="en">
<DeleteModal
isOpen
close={closeMock}
onDeleteSubmit={onDeleteSubmitMock}
{...props}
/>
</IntlProvider>,
);
describe('<DeleteModal />', () => {
it('render DeleteModal component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.deleteButton.defaultMessage })).toBeInTheDocument();
});
it('calls onDeleteSubmit function when the "Delete" button is clicked', () => {
const { getByRole } = renderComponent();
const okButton = getByRole('button', { name: messages.deleteButton.defaultMessage });
fireEvent.click(okButton);
expect(onDeleteSubmitMock).toHaveBeenCalledTimes(1);
});
it('calls the close function when the "Cancel" button is clicked', () => {
const { getByRole } = renderComponent();
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
expect(closeMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.delete-modal.title',
defaultMessage: 'Delete this section?',
},
description: {
id: 'course-authoring.course-outline.delete-modal.description',
defaultMessage: 'Deleting this section is permanent and cannot be undone.',
},
deleteButton: {
id: 'course-authoring.course-outline.delete-modal.button.delete',
defaultMessage: 'Yes, delete this section',
},
cancelButton: {
id: 'course-authoring.course-outline.delete-modal.button.cancel',
defaultMessage: 'Cancel',
},
});
export default messages;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import EmptyPlaceholder from './EmptyPlaceholder';
import messages from './messages';
const onCreateNewSectionMock = jest.fn();
const renderComponent = () => render(
<IntlProvider locale="en">
<EmptyPlaceholder onCreateNewSection={onCreateNewSectionMock} />
</IntlProvider>,
);
describe('<EmptyPlaceholder />', () => {
it('renders EmptyPlaceholder component correctly', () => {
const { getByText, getByRole } = renderComponent();
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument();
});
it('calls the onCreateNewSection function when the button is clicked', () => {
const { getByRole } = renderComponent();
const addButton = getByRole('button', { name: messages.button.defaultMessage });
fireEvent.click(addButton);
expect(onCreateNewSectionMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Add as IconAdd } from '@edx/paragon/icons/es5';
import { Button, OverlayTrigger, Tooltip } from '@edx/paragon';
import messages from './messages';
const EmptyPlaceholder = ({ onCreateNewSection }) => {
const intl = useIntl();
return (
<div className="outline-empty-placeholder bg-gray-100" data-testid="empty-placeholder">
<p className="mb-0 text-gray-500">{intl.formatMessage(messages.title)}</p>
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id={intl.formatMessage(messages.tooltip)}>
{intl.formatMessage(messages.tooltip)}
</Tooltip>
)}
>
<Button
variant="outline-success"
iconBefore={IconAdd}
onClick={onCreateNewSection}
>
{intl.formatMessage(messages.button)}
</Button>
</OverlayTrigger>
</div>
);
};
EmptyPlaceholder.propTypes = {
onCreateNewSection: PropTypes.func.isRequired,
};
export default EmptyPlaceholder;

View File

@@ -0,0 +1,10 @@
.outline-empty-placeholder {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
border: .0625rem solid $gray-200;
border-radius: .375rem;
box-shadow: inset inset 0 1px .125rem 1px $gray-200;
padding: 2.5rem;
}

View File

@@ -0,0 +1,18 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.empty-placeholder.title',
defaultMessage: 'You haven\'t added any content to this course yet.',
},
button: {
id: 'course-authoring.course-outline.empty-placeholder.button.new-section',
defaultMessage: 'New section',
},
tooltip: {
id: 'course-authoring.course-outline.empty-placeholder.button.tooltip',
defaultMessage: 'Click to add a new section',
},
});
export default messages;

View File

@@ -6,15 +6,19 @@ import {
} from '@edx/paragon';
import messages from './messages';
import { useHelpUrls } from '../../help-urls/hooks';
const EnableHighlightsModal = ({
onEnableHighlightsSubmit,
isOpen,
close,
highlightsDocUrl,
}) => {
const intl = useIntl();
const {
contentHighlights: contentHighlightsUrl,
} = useHelpUrls(['contentHighlights']);
return (
<AlertModal
title={intl.formatMessage(messages.title)}
@@ -38,7 +42,7 @@ const EnableHighlightsModal = ({
{intl.formatMessage(messages.description_2)}
<Hyperlink
className="small ml-2 text-decoration-none"
destination={highlightsDocUrl}
destination={contentHighlightsUrl}
target="_blank"
showLaunchIcon={false}
>
@@ -53,7 +57,6 @@ EnableHighlightsModal.propTypes = {
onEnableHighlightsSubmit: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
highlightsDocUrl: PropTypes.string.isRequired,
};
export default EnableHighlightsModal;

View File

@@ -1,28 +1,58 @@
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 EnableHighlightsModal from './EnableHighlightsModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const onEnableHighlightsSubmitMock = jest.fn();
const closeMock = jest.fn();
const highlightsDocUrl = 'https://example.com/';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<EnableHighlightsModal
isOpen
close={closeMock}
onEnableHighlightsSubmit={onEnableHighlightsSubmitMock}
highlightsDocUrl={highlightsDocUrl}
{...props}
/>
</IntlProvider>,
<AppProvider store={store}>
<IntlProvider locale="en">
<EnableHighlightsModal
isOpen
close={closeMock}
onEnableHighlightsSubmit={onEnableHighlightsSubmitMock}
{...props}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<EnableHighlightsModal />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('renders EnableHighlightsModal component correctly', () => {
const { getByText, getByRole } = renderComponent();
@@ -31,10 +61,7 @@ describe('<EnableHighlightsModal />', () => {
expect(getByText(messages.description_2.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.submitButton.defaultMessage })).toBeInTheDocument();
const hyperlink = getByText(messages.link.defaultMessage);
expect(hyperlink).toBeInTheDocument();
expect(hyperlink.href).toBe(highlightsDocUrl);
expect(getByText(messages.link.defaultMessage)).toBeInTheDocument();
});
it('calls onEnableHighlightsSubmit function when the "Submit" button is clicked', () => {

View File

@@ -15,6 +15,7 @@ const HeaderNavigations = ({
isReIndexShow,
isSectionsExpanded,
isDisabledReindexButton,
hasSections,
}) => {
const intl = useIntl();
const {
@@ -56,15 +57,17 @@ const HeaderNavigations = ({
</Button>
</OverlayTrigger>
)}
<Button
variant="outline-primary"
iconBefore={isSectionsExpanded ? ArrowUpIcon : ArrowDownIcon}
onClick={handleExpandAll}
>
{isSectionsExpanded
? intl.formatMessage(messages.collapseAllButton)
: intl.formatMessage(messages.expandAllButton)}
</Button>
{hasSections ? (
<Button
variant="outline-primary"
iconBefore={isSectionsExpanded ? ArrowUpIcon : ArrowDownIcon}
onClick={handleExpandAll}
>
{isSectionsExpanded
? intl.formatMessage(messages.collapseAllButton)
: intl.formatMessage(messages.expandAllButton)}
</Button>
) : null}
<OverlayTrigger
placement="bottom"
overlay={(
@@ -95,6 +98,7 @@ HeaderNavigations.propTypes = {
handleExpandAll: PropTypes.func.isRequired,
lmsLink: PropTypes.string.isRequired,
}).isRequired,
hasSections: PropTypes.bool.isRequired,
};
export default HeaderNavigations;

View File

@@ -1,5 +1,8 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import {
render, fireEvent, waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import HeaderNavigations from './HeaderNavigations';
@@ -23,6 +26,7 @@ const renderComponent = (props) => render(
isSectionsExpanded={false}
isDisabledReindexButton={false}
isReIndexShow
hasSections
{...props}
/>
</IntlProvider>,
@@ -78,4 +82,40 @@ describe('<HeaderNavigations />', () => {
expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument();
});
it('render collapse button correctly', () => {
const { getByRole } = renderComponent({
isSectionsExpanded: true,
});
expect(getByRole('button', { name: messages.collapseAllButton.defaultMessage })).toBeInTheDocument();
});
it('render expand button correctly', () => {
const { getByRole } = renderComponent({
isSectionsExpanded: false,
});
expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument();
});
it('render reindex button tooltip correctly', async () => {
const { getByText, getByRole } = renderComponent({
isDisabledReindexButton: false,
});
userEvent.hover(getByRole('button', { name: messages.reindexButton.defaultMessage }));
await waitFor(() => {
expect(getByText(messages.reindexButtonTooltip.defaultMessage)).toBeInTheDocument();
});
});
it('not render reindex button tooltip when button is disabled correctly', async () => {
const { queryByText, getByRole } = renderComponent({
isDisabledReindexButton: true,
});
userEvent.hover(getByRole('button', { name: messages.reindexButton.defaultMessage }));
await waitFor(() => {
expect(queryByText(messages.reindexButtonTooltip.defaultMessage)).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ModalDialog,
Button,
ActionRow,
Hyperlink,
} from '@edx/paragon';
import { Formik } from 'formik';
import { useSelector } from 'react-redux';
import { useHelpUrls } from '../../help-urls/hooks';
import FormikControl from '../../generic/FormikControl';
import { getCurrentSection } from '../data/selectors';
import { HIGHLIGHTS_FIELD_MAX_LENGTH } from '../constants';
import { getHighlightsFormValues } from '../utils';
import messages from './messages';
const HighlightsModal = ({
isOpen,
onClose,
onSubmit,
}) => {
const intl = useIntl();
const { highlights = [], displayName } = useSelector(getCurrentSection);
const initialFormValues = getHighlightsFormValues(highlights);
const {
contentHighlights: contentHighlightsUrl,
} = useHelpUrls(['contentHighlights']);
return (
<ModalDialog
title={displayName}
className="highlights-modal"
isOpen={isOpen}
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header className="highlights-modal__header">
<ModalDialog.Title>
{intl.formatMessage(messages.title, {
title: displayName,
})}
</ModalDialog.Title>
</ModalDialog.Header>
<Formik initialValues={initialFormValues} onSubmit={onSubmit}>
{({ values, dirty, handleSubmit }) => (
<>
<ModalDialog.Body>
<p className="mb-4.5 pb-2">
{intl.formatMessage(messages.description, {
documentation: (
<Hyperlink destination={contentHighlightsUrl} target="_blank" showLaunchIcon={false}>
{intl.formatMessage(messages.documentationLink)}
</Hyperlink>),
})}
</p>
{Object.entries(initialFormValues).map(([key], index) => (
<FormikControl
key={key}
name={key}
value={values[key]}
floatingLabel={intl.formatMessage(messages.highlight, { index: index + 1 })}
maxLength={HIGHLIGHTS_FIELD_MAX_LENGTH}
/>
))}
</ModalDialog.Body>
<ModalDialog.Footer className="pt-1">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancelButton)}
</ModalDialog.CloseButton>
<Button disabled={!dirty} onClick={handleSubmit}>
{intl.formatMessage(messages.saveButton)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</>
)}
</Formik>
</ModalDialog>
);
};
HighlightsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
export default HighlightsModal;

View File

@@ -0,0 +1,15 @@
.highlights-modal {
max-width: 33.6875rem;
.highlights-modal__header {
padding-top: 1.5rem;
}
.form-control {
color: $black;
}
.pgn__form-control-decorator-group {
margin-inline-end: 0;
}
}

View File

@@ -0,0 +1,121 @@
import React from 'react';
import {
render, fireEvent, act, waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import HighlightsModal from './HighlightsModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const currentSectionMock = {
highlights: ['Highlight 1', 'Highlight 2'],
displayName: 'Test Section',
};
const onCloseMock = jest.fn();
const onSubmitMock = jest.fn();
const renderComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<HighlightsModal
isOpen
onClose={onCloseMock}
onSubmit={onSubmitMock}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<HighlightsModal />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentSectionMock);
});
it('renders HighlightsModal component correctly', () => {
const { getByText, getByRole, getByLabelText } = renderComponent();
expect(getByText(`Highlights for ${currentSectionMock.displayName}`)).toBeInTheDocument();
expect(getByText(/Enter 3-5 highlights to include in the email message that learners receive for this section/i)).toBeInTheDocument();
expect(getByText(/For more information and an example of the email template, read our/i)).toBeInTheDocument();
expect(getByText(messages.documentationLink.defaultMessage)).toBeInTheDocument();
expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '1'))).toBeInTheDocument();
expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '2'))).toBeInTheDocument();
expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '3'))).toBeInTheDocument();
expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '4'))).toBeInTheDocument();
expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '5'))).toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
it('calls the onClose function when the cancel button is clicked', () => {
const { getByRole } = renderComponent();
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it('calls the onSubmit function with correct values when the save button is clicked', async () => {
const { getByRole, getByLabelText } = renderComponent();
const field1 = getByLabelText(messages.highlight.defaultMessage.replace('{index}', '1'));
const field2 = getByLabelText(messages.highlight.defaultMessage.replace('{index}', '2'));
fireEvent.change(field1, { target: { value: 'New highlight 1' } });
fireEvent.change(field2, { target: { value: 'New highlight 2' } });
const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
await act(async () => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
expect(onSubmitMock).toHaveBeenCalledWith(
{
highlight_1: 'New highlight 1',
highlight_2: 'New highlight 2',
highlight_3: '',
highlight_4: '',
highlight_5: '',
},
expect.objectContaining({ submitForm: expect.any(Function) }),
);
});
});
});

View File

@@ -0,0 +1,30 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.highlights-modal.title',
defaultMessage: 'Highlights for {title}',
},
description: {
id: 'course-authoring.course-outline.highlights-modal.description',
defaultMessage: 'Enter 3-5 highlights to include in the email message that learners receive for this section (250 character limit). For more information and an example of the email template, read our {documentation}.',
},
documentationLink: {
id: 'course-authoring.course-outline.highlights-modal.documentation-link',
defaultMessage: 'documentation',
},
highlight: {
id: 'course-authoring.course-outline.highlights-modal.highlight',
defaultMessage: 'Highlight {index}',
},
cancelButton: {
id: 'course-authoring.course-outline.highlights-modal.button.cancel',
defaultMessage: 'Cancel',
},
saveButton: {
id: 'course-authoring.course-outline.highlights-modal.button.save',
defaultMessage: 'Save highlights',
},
});
export default messages;

View File

@@ -3,19 +3,29 @@ import { useDispatch, useSelector } from 'react-redux';
import { useToggle } from '@edx/paragon';
import { RequestStatus } from '../data/constants';
import { updateSavingStatus } from './data/slice';
import {
setCurrentSection,
updateSavingStatus,
} from './data/slice';
import {
getLoadingStatus,
getOutlineIndexData,
getSavingStatus,
getStatusBarData,
getSectionsList,
getCurrentSection,
} from './data/selectors';
import {
deleteCourseSectionQuery,
editCourseSectionQuery,
duplicateCourseSectionQuery,
enableCourseHighlightsEmailsQuery,
fetchCourseBestPracticesQuery,
fetchCourseLaunchQuery,
fetchCourseOutlineIndexQuery,
fetchCourseReindexQuery,
publishCourseSectionQuery,
updateCourseSectionHighlightsQuery,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -25,12 +35,17 @@ const useCourseOutline = ({ courseId }) => {
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
const statusBarData = useSelector(getStatusBarData);
const savingStatus = useSelector(getSavingStatus);
const sectionsList = useSelector(getSectionsList);
const currentSection = useSelector(getCurrentSection);
const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false);
const [isSectionsExpanded, setSectionsExpanded] = useState(false);
const [isSectionsExpanded, setSectionsExpanded] = useState(true);
const [isDisabledReindexButton, setDisableReindexButton] = useState(false);
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
const [showErrorAlert, setShowErrorAlert] = useState(false);
const [isHighlightsModalOpen, openHighlightsModal, closeHighlightsModal] = useToggle(false);
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const headerNavigationsActions = {
handleNewSection: () => {
@@ -60,6 +75,37 @@ const useCourseOutline = ({ courseId }) => {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
};
const handleOpenHighlightsModal = (section) => {
dispatch(setCurrentSection(section));
openHighlightsModal();
};
const handleHighlightsFormSubmit = (highlights) => {
const dataToSend = Object.values(highlights).filter(Boolean);
dispatch(updateCourseSectionHighlightsQuery(currentSection.id, dataToSend));
closeHighlightsModal();
};
const handlePublishSectionSubmit = () => {
dispatch(publishCourseSectionQuery(currentSection.id));
closePublishModal();
};
const handleEditSectionSubmit = (sectionId, displayName) => {
dispatch(editCourseSectionQuery(sectionId, displayName));
};
const handleDeleteSectionSubmit = () => {
dispatch(deleteCourseSectionQuery(currentSection.id));
closeDeleteModal();
};
const handleDuplicateSectionSubmit = () => {
dispatch(duplicateCourseSectionQuery(currentSection.id, courseStructure.id));
};
useEffect(() => {
dispatch(fetchCourseOutlineIndexQuery(courseId));
dispatch(fetchCourseBestPracticesQuery({ courseId }));
@@ -78,20 +124,36 @@ const useCourseOutline = ({ courseId }) => {
return {
savingStatus,
sectionsList,
isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS,
isReIndexShow: Boolean(reindexLink),
showSuccessAlert,
showErrorAlert,
isDisabledReindexButton,
isSectionsExpanded,
isPublishModalOpen,
openPublishModal,
closePublishModal,
headerNavigationsActions,
handleEnableHighlightsSubmit,
handleHighlightsFormSubmit,
handlePublishSectionSubmit,
handleEditSectionSubmit,
statusBarData,
isEnableHighlightsModalOpen,
openEnableHighlightsModal,
closeEnableHighlightsModal,
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
handleInternetConnectionFailed,
handleOpenHighlightsModal,
isHighlightsModalOpen,
closeHighlightsModal,
courseName: courseStructure?.displayName,
isDeleteModalOpen,
closeDeleteModal,
openDeleteModal,
handleDeleteSectionSubmit,
handleDuplicateSectionSubmit,
};
};

View File

@@ -29,6 +29,14 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.reindex.alert.error.title',
defaultMessage: 'There were errors reindexing course.',
},
expandAllButton: {
id: 'course-authoring.course-outline.header-navigations.button.expand-all',
defaultMessage: 'Expand all',
},
collapseAllButton: {
id: 'course-authoring.course-outline.header-navigations.button.collapse-all',
defaultMessage: 'Collapse all',
},
});
export default messages;

View File

@@ -0,0 +1,77 @@
/* eslint-disable import/named */
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ModalDialog,
Button,
ActionRow,
} from '@edx/paragon';
import { useSelector } from 'react-redux';
import { getCurrentSection } from '../data/selectors';
import messages from './messages';
const PublishModal = ({
isOpen,
onClose,
onPublishSubmit,
}) => {
const intl = useIntl();
const { displayName, childInfo } = useSelector(getCurrentSection);
const subSections = childInfo?.children || [];
return (
<ModalDialog
className="publish-modal"
isOpen={isOpen}
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Header className="publish-modal__header">
<ModalDialog.Title>
{intl.formatMessage(messages.title, { title: displayName })}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<p className="small">{intl.formatMessage(messages.description)}</p>
{subSections.length ? subSections.map((subSection) => {
const units = subSection.childInfo.children;
return units.length ? (
<React.Fragment key={subSection.id}>
<span className="small text-gray-400">{subSection.displayName}</span>
{units.map((unit) => (
<div
key={unit.id}
className="small border border-light-400 p-2 publish-modal__subsection"
>
{unit.displayName}
</div>
))}
</React.Fragment>
) : null;
}) : null}
</ModalDialog.Body>
<ModalDialog.Footer className="pt-1">
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancelButton)}
</ModalDialog.CloseButton>
<Button onClick={onPublishSubmit}>
{intl.formatMessage(messages.publishButton)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
PublishModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onPublishSubmit: PropTypes.func.isRequired,
};
export default PublishModal;

View File

@@ -0,0 +1,15 @@
.publish-modal {
max-width: 33.6875rem;
.pgn__modal-close-container {
transform: translateY(.5rem);
}
.publish-modal__header {
padding-top: 1.5rem;
}
.publish-modal__subsection:not(:last-child) {
margin-bottom: .5rem;
}
}

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import PublishModal from './PublishModal';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const mockPathname = '/foo-bar';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const currentSectionMock = {
displayName: 'Publish',
childInfo: {
displayName: 'Subsection',
children: [
{
displayName: 'Subsection 1',
childInfo: {
displayName: 'Unit',
children: [
{
displayName: 'Subsection_1 Unit 1',
},
],
},
},
{
displayName: 'Subsection 2',
childInfo: {
displayName: 'Unit',
children: [
{
displayName: 'Subsection_2 Unit 1',
},
],
},
},
{
displayName: 'Subsection 3',
childInfo: {
children: [],
},
},
],
},
};
const onCloseMock = jest.fn();
const onPublishSubmitMock = jest.fn();
const renderComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<PublishModal
isOpen
onClose={onCloseMock}
onPublishSubmit={onPublishSubmitMock}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<PublishModal />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
useSelector.mockReturnValue(currentSectionMock);
});
it('renders PublishModal component correctly', () => {
const { getByText, getByRole, queryByText } = renderComponent();
expect(getByText(`Publish ${currentSectionMock.displayName}`)).toBeInTheDocument();
expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
expect(getByText(/Subsection 1/i)).toBeInTheDocument();
expect(getByText(/Subsection_1 Unit 1/i)).toBeInTheDocument();
expect(getByText(/Subsection 2/i)).toBeInTheDocument();
expect(getByText(/Subsection_2 Unit 1/i)).toBeInTheDocument();
expect(queryByText(/Subsection 3/i)).not.toBeInTheDocument();
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.publishButton.defaultMessage })).toBeInTheDocument();
});
it('calls the onClose function when the cancel button is clicked', () => {
const { getByRole } = renderComponent();
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
fireEvent.click(cancelButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it('calls the onPublishSubmit function when save button is clicked', async () => {
const { getByRole } = renderComponent();
const publishButton = getByRole('button', { name: messages.publishButton.defaultMessage });
fireEvent.click(publishButton);
expect(onPublishSubmitMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,22 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.publish-modal.title',
defaultMessage: 'Publish {title}',
},
description: {
id: 'course-authoring.course-outline.publish-modal.description',
defaultMessage: 'Publish all unpublished changes for this section?',
},
cancelButton: {
id: 'course-authoring.course-outline.publish-modal.button.cancel',
defaultMessage: 'Cancel',
},
publishButton: {
id: 'course-authoring.course-outline.publish-modal.button.label',
defaultMessage: 'Publish',
},
});
export default messages;

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Badge, Button, useToggle } from '@edx/paragon';
import { Add as IconAdd } from '@edx/paragon/icons';
import { setCurrentSection } from '../data/slice';
import { RequestStatus } from '../../data/constants';
import CardHeader from '../card-header/CardHeader';
import { getSectionStatus } from '../utils';
import messages from './messages';
const SectionCard = ({
section,
children,
onOpenHighlightsModal,
onOpenPublishModal,
onEditSectionSubmit,
savingStatus,
onOpenDeleteModal,
onDuplicateSubmit,
isSectionsExpanded,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const [isExpanded, setIsExpanded] = useState(isSectionsExpanded);
const [isFormOpen, openForm, closeForm] = useToggle(false);
useEffect(() => {
setIsExpanded(isSectionsExpanded);
}, [isSectionsExpanded]);
const {
id,
displayName,
published,
releasedToStudents,
visibleToStaffOnly,
visibilityState,
staffOnlyMessage,
highlights,
} = section;
const sectionStatus = getSectionStatus({
published,
releasedToStudents,
visibleToStaffOnly,
visibilityState,
staffOnlyMessage,
});
const handleExpandContent = () => {
setIsExpanded((prevState) => !prevState);
};
const handleClickMenuButton = () => {
dispatch(setCurrentSection(section));
};
const handleEditSubmit = (titleValue) => {
if (displayName !== titleValue) {
onEditSectionSubmit(id, titleValue);
return;
}
closeForm();
};
const handleOpenHighlightsModal = () => {
onOpenHighlightsModal(section);
};
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
closeForm();
}
}, [savingStatus]);
return (
<div className="section-card" data-testid="section-card">
<CardHeader
sectionId={id}
title={displayName}
sectionStatus={sectionStatus}
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}
/>
<div className="section-card__content" data-testid="section-card__content">
<div className="outline-section__status">
<Button
className="section-card__highlights"
data-destid="section-card-highlights-button"
variant="tertiary"
onClick={handleOpenHighlightsModal}
>
<Badge className="highlights-badge">{highlights.length}</Badge>
<p className="m-0 text-black">Section highlights</p>
</Button>
</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>
);
};
SectionCard.defaultProps = {
children: null,
};
SectionCard.propTypes = {
section: PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
published: PropTypes.bool.isRequired,
releasedToStudents: PropTypes.bool.isRequired,
visibleToStaffOnly: PropTypes.bool.isRequired,
visibilityState: PropTypes.string.isRequired,
staffOnlyMessage: PropTypes.bool.isRequired,
highlights: PropTypes.arrayOf(PropTypes.string).isRequired,
}).isRequired,
children: PropTypes.node,
onOpenHighlightsModal: PropTypes.func.isRequired,
onOpenPublishModal: PropTypes.func.isRequired,
onEditSectionSubmit: PropTypes.func.isRequired,
savingStatus: PropTypes.string.isRequired,
onOpenDeleteModal: PropTypes.func.isRequired,
onDuplicateSubmit: PropTypes.func.isRequired,
isSectionsExpanded: PropTypes.bool.isRequired,
};
export default SectionCard;

View File

@@ -0,0 +1,34 @@
.section-card {
@include pgn-box-shadow(1, "centered");
padding: $spacer 1.5rem 1.5rem;
cursor: move;
margin-bottom: 1.5rem;
.section-card__content {
margin-top: $spacer;
}
.section-card__highlights {
display: flex;
align-items: center;
gap: .5rem;
padding: 0;
background: transparent;
&::before {
display: none;
}
.highlights-badge {
display: flex;
justify-content: center;
align-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 1.375rem;
font-size: 1.125rem;
font-weight: 700;
}
}
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../../store';
import SectionCard from './SectionCard';
// eslint-disable-next-line no-unused-vars
let axiosMock;
let store;
const section = {
id: '123',
displayName: 'Section Name',
published: true,
releasedToStudents: true,
visibleToStaffOnly: false,
visibilityState: 'visible',
staffOnlyMessage: false,
highlights: ['highlight 1', 'highlight 2'],
};
const renderComponent = (props) => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<SectionCard
section={section}
onOpenPublishModal={jest.fn()}
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onEditClick={jest.fn()}
savingStatus=""
onEditSectionSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
{...props}
>
<span>children</span>
</SectionCard>
</IntlProvider>,
</AppProvider>,
);
describe('<SectionCard />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render SectionCard component correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('section-card-header')).toBeInTheDocument();
expect(getByTestId('section-card__content')).toBeInTheDocument();
});
it('expands/collapses the card when the expand button is clicked', () => {
const { queryByTestId, getByTestId } = renderComponent();
const expandButton = getByTestId('section-card-header__expanded-btn');
fireEvent.click(expandButton);
expect(queryByTestId('section-card__subsections')).not.toBeInTheDocument();
expect(queryByTestId('new-subsection-button')).not.toBeInTheDocument();
fireEvent.click(expandButton);
expect(queryByTestId('section-card__subsections')).toBeInTheDocument();
expect(queryByTestId('new-subsection-button')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
newSubsectionButton: {
id: 'course-authoring.course-outline.section.button.new-subsection',
defaultMessage: 'New subsection',
},
});
export default messages;

View File

@@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink, Stack } from '@edx/paragon';
import { AppContext } from '@edx/frontend-platform/react';
import { useHelpUrls } from '../../help-urls/hooks';
import messages from './messages';
const StatusBar = ({
@@ -18,7 +19,6 @@ const StatusBar = ({
const {
courseReleaseDate,
highlightsEnabledForMessaging,
highlightsDocUrl,
checklist,
isSelfPaced,
} = statusBarData;
@@ -32,7 +32,11 @@ const StatusBar = ({
const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`;
const checklistDestination = new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href;
const scheduleDestination = new URL(`course/${courseId}/settings/details#schedule`, config.BASE_URL).href;
const scheduleDestination = new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href;
const {
contentHighlights: contentHighlightsUrl,
} = useHelpUrls(['contentHighlights']);
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
@@ -83,7 +87,7 @@ const StatusBar = ({
)}
<Hyperlink
className="small ml-2"
destination={highlightsDocUrl}
destination={contentHighlightsUrl}
target="_blank"
showLaunchIcon={false}
>
@@ -109,7 +113,6 @@ StatusBar.propTypes = {
completedCourseBestPracticesChecks: PropTypes.number.isRequired,
}),
highlightsEnabledForMessaging: PropTypes.bool.isRequired,
highlightsDocUrl: PropTypes.string.isRequired,
}).isRequired,
};

View File

@@ -1,4 +1,6 @@
.outline-status-bar {
margin-bottom: .25rem;
.outline-status-bar__item {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,116 @@
import {
CheckCircle as CheckCircleIcon,
Lock as LockIcon,
EditOutline as EditOutlineIcon,
} from '@edx/paragon/icons';
import { SECTION_BADGE_STATUTES, STAFF_ONLY } from './constants';
/**
* Get section status depended on section info
* @param {bool} published - value from section info
* @param {bool} releasedToStudents - value from section info
* @param {bool} visibleToStaffOnly - value from section info
* @param {string} visibilityState - value from section info
* @param {bool} staffOnlyMessage - value from section info
* @returns {typeof SECTION_BADGE_STATUTES}
*/
const getSectionStatus = ({
published,
releasedToStudents,
visibleToStaffOnly,
visibilityState,
staffOnlyMessage,
}) => {
switch (true) {
case published && releasedToStudents:
return SECTION_BADGE_STATUTES.live;
case published && !releasedToStudents:
return SECTION_BADGE_STATUTES.publishedNotLive;
case visibleToStaffOnly && staffOnlyMessage && visibilityState === STAFF_ONLY:
return SECTION_BADGE_STATUTES.staffOnly;
case !published:
return SECTION_BADGE_STATUTES.draft;
default:
return '';
}
};
/**
* Get section badge status content
* @param {string} status - value from on getSectionStatus util
* @returns {
* badgeTitle: string,
* badgeIcon: node,
* }
*/
const getSectionStatusBadgeContent = (status, messages, intl) => {
switch (status) {
case SECTION_BADGE_STATUTES.live:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeLive),
badgeIcon: CheckCircleIcon,
};
case SECTION_BADGE_STATUTES.publishedNotLive:
return {
badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive),
badgeIcon: '',
};
case SECTION_BADGE_STATUTES.staffOnly:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeStuffOnly),
badgeIcon: LockIcon,
};
case SECTION_BADGE_STATUTES.draft:
return {
badgeTitle: intl.formatMessage(messages.statusBadgeDraft),
badgeIcon: EditOutlineIcon,
};
default:
return {
badgeTitle: '',
badgeIcon: '',
};
}
};
/**
* Get formatted highlights form values
* @param {Array<string>} currentHighlights - section highlights
* @returns {
* highlight_1: string,
* highlight_2: string,
* highlight_3: string,
* highlight_4: string,
* highlight_5: string,
* }
*/
const getHighlightsFormValues = (currentHighlights) => {
const initialFormValues = {
highlight_1: '',
highlight_2: '',
highlight_3: '',
highlight_4: '',
highlight_5: '',
};
const formValues = currentHighlights.length
? Object.entries(initialFormValues).reduce((result, [key], index) => {
if (currentHighlights[index]) {
return {
...result,
[key]: currentHighlights[index],
};
}
return result;
}, initialFormValues)
: initialFormValues;
return formValues;
};
export {
getSectionStatus,
getSectionStatusBadgeContent,
getHighlightsFormValues,
};

View File

@@ -0,0 +1,96 @@
import {
getCourseLaunchChecklist,
getCourseBestPracticesChecklist,
} from './getChecklistForStatusBar';
describe('getChecklistForStatusBar util functions', () => {
it('getCourseLaunchChecklist', () => {
const data = {
isSelfPaced: false,
dates: {
hasStartDate: true,
hasEndDate: false,
},
assignments: {
totalNumber: 11,
totalVisible: 7,
assignmentsWithDatesBeforeStart: [],
assignmentsWithDatesAfterEnd: [],
assignmentsWithOraDatesBeforeStart: [],
assignmentsWithOraDatesAfterEnd: [],
},
grades: {
hasGradingPolicy: true,
sumOfWeights: 1,
},
certificates: {
isActivated: false,
hasCertificate: false,
isEnabled: true,
},
updates: {
hasUpdate: true,
},
proctoring: {
needsProctoringEscalationEmail: false,
hasProctoringEscalationEmail: false,
},
};
expect(getCourseLaunchChecklist(data)).toEqual({
totalCourseLaunchChecks: 5,
completedCourseLaunchChecks: 2,
});
});
it('getCourseBestPracticesChecklist', () => {
const data = {
isSelfPaced: false,
sections: {
totalNumber: 6,
totalVisible: 4,
numberWithHighlights: 2,
highlightsActiveForCourse: true,
highlightsEnabled: true,
},
subsections: {
totalVisible: 5,
numWithOneBlockType: 2,
numBlockTypes: {
min: 0,
max: 3,
mean: 1,
median: 1,
mode: 1,
},
},
units: {
totalVisible: 9,
numBlocks: {
min: 1,
max: 2,
mean: 2,
median: 2,
mode: 2,
},
},
videos: {
totalNumber: 7,
numMobileEncoded: 0,
numWithValId: 3,
durations: {
min: null,
max: null,
mean: null,
median: null,
mode: null,
},
},
};
expect(getCourseBestPracticesChecklist(data)).toEqual({
totalCourseBestPracticesChecks: 4,
completedCourseBestPracticesChecks: 2,
});
});
});

View File

@@ -11,6 +11,7 @@ const SubHeader = ({
headerActions,
titleActions,
hideBorder,
withSubHeaderContent,
}) => (
<div className={`${!hideBorder && 'border-bottom border-light-400'} mb-3`}>
<header className="sub-header">
@@ -29,7 +30,7 @@ const SubHeader = ({
</ActionRow>
)}
</header>
{contentTitle && (
{contentTitle && withSubHeaderContent && (
<header className="sub-header-content">
<h2 className="sub-header-content-title">{contentTitle}</h2>
<span className="small text-gray-700">{description}</span>
@@ -48,6 +49,7 @@ SubHeader.defaultProps = {
headerActions: null,
titleActions: null,
hideBorder: false,
withSubHeaderContent: true,
};
SubHeader.propTypes = {
title: PropTypes.string.isRequired,
@@ -61,5 +63,6 @@ SubHeader.propTypes = {
headerActions: PropTypes.node,
titleActions: PropTypes.node,
hideBorder: PropTypes.bool,
withSubHeaderContent: PropTypes.bool,
};
export default SubHeader;

View File

@@ -16,3 +16,19 @@ export const useScrollToHashElement = ({ isLoading }) => {
}
}, [isLoading]);
};
export const useEscapeClick = ({ onEscape, dependency }) => {
useEffect(() => {
const handleEscapeClick = (event) => {
if (event.key === 'Escape') {
onEscape();
}
};
window.addEventListener('keydown', handleEscapeClick);
return () => {
window.removeEventListener('keydown', handleEscapeClick);
};
}, [dependency]);
};