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:
committed by
Kristin Aoki
parent
f938d08361
commit
59071424b3
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
165
src/course-outline/card-header/CardHeader.jsx
Normal file
165
src/course-outline/card-header/CardHeader.jsx
Normal 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;
|
||||
65
src/course-outline/card-header/CardHeader.scss
Normal file
65
src/course-outline/card-header/CardHeader.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
176
src/course-outline/card-header/CardHeader.test.jsx
Normal file
176
src/course-outline/card-header/CardHeader.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
46
src/course-outline/card-header/messages.js
Normal file
46
src/course-outline/card-header/messages.js
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
48
src/course-outline/delete-modal/DeleteModal.jsx
Normal file
48
src/course-outline/delete-modal/DeleteModal.jsx
Normal 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;
|
||||
47
src/course-outline/delete-modal/DeleteModal.test.jsx
Normal file
47
src/course-outline/delete-modal/DeleteModal.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
22
src/course-outline/delete-modal/messages.js
Normal file
22
src/course-outline/delete-modal/messages.js
Normal 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;
|
||||
31
src/course-outline/empty-placeholder/EmptyContent.test.jsx
Normal file
31
src/course-outline/empty-placeholder/EmptyContent.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
39
src/course-outline/empty-placeholder/EmptyPlaceholder.jsx
Normal file
39
src/course-outline/empty-placeholder/EmptyPlaceholder.jsx
Normal 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;
|
||||
10
src/course-outline/empty-placeholder/EmptyPlaceholder.scss
Normal file
10
src/course-outline/empty-placeholder/EmptyPlaceholder.scss
Normal 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;
|
||||
}
|
||||
18
src/course-outline/empty-placeholder/messages.js
Normal file
18
src/course-outline/empty-placeholder/messages.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
94
src/course-outline/highlights-modal/HighlightsModal.jsx
Normal file
94
src/course-outline/highlights-modal/HighlightsModal.jsx
Normal 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;
|
||||
15
src/course-outline/highlights-modal/HighlightsModal.scss
Normal file
15
src/course-outline/highlights-modal/HighlightsModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
121
src/course-outline/highlights-modal/HighlightsModal.test.jsx
Normal file
121
src/course-outline/highlights-modal/HighlightsModal.test.jsx
Normal 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) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/course-outline/highlights-modal/messages.js
Normal file
30
src/course-outline/highlights-modal/messages.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
77
src/course-outline/publish-modal/PublishModal.jsx
Normal file
77
src/course-outline/publish-modal/PublishModal.jsx
Normal 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;
|
||||
15
src/course-outline/publish-modal/PublishModal.scss
Normal file
15
src/course-outline/publish-modal/PublishModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
128
src/course-outline/publish-modal/PublishModal.test.jsx
Normal file
128
src/course-outline/publish-modal/PublishModal.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
22
src/course-outline/publish-modal/messages.js
Normal file
22
src/course-outline/publish-modal/messages.js
Normal 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;
|
||||
156
src/course-outline/section-card/SectionCard.jsx
Normal file
156
src/course-outline/section-card/SectionCard.jsx
Normal 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;
|
||||
34
src/course-outline/section-card/SectionCard.scss
Normal file
34
src/course-outline/section-card/SectionCard.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/course-outline/section-card/SectionCard.test.jsx
Normal file
82
src/course-outline/section-card/SectionCard.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
10
src/course-outline/section-card/messages.js
Normal file
10
src/course-outline/section-card/messages.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.outline-status-bar {
|
||||
margin-bottom: .25rem;
|
||||
|
||||
.outline-status-bar__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
116
src/course-outline/utils.jsx
Normal file
116
src/course-outline/utils.jsx
Normal 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,
|
||||
};
|
||||
96
src/course-outline/utils/getChecklistForStatusBar.test.js
Normal file
96
src/course-outline/utils/getChecklistForStatusBar.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
16
src/hooks.js
16
src/hooks.js
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user