From 59071424b39b54cb959495b60b2ec64af0754ad0 Mon Sep 17 00:00:00 2001 From: vladislavkeblysh <138868841+vladislavkeblysh@users.noreply.github.com> Date: Thu, 3 Aug 2023 15:42:49 +0300 Subject: [PATCH 01/11] 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 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 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 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 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 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 --- src/course-outline/CourseOutline.jsx | 76 +++++++- src/course-outline/CourseOutline.scss | 5 + src/course-outline/CourseOutline.test.jsx | 181 +++++++++++++++++- src/course-outline/card-header/CardHeader.jsx | 165 ++++++++++++++++ .../card-header/CardHeader.scss | 65 +++++++ .../card-header/CardHeader.test.jsx | 176 +++++++++++++++++ src/course-outline/card-header/messages.js | 46 +++++ src/course-outline/constants.js | 11 ++ src/course-outline/data/api.js | 91 +++++++++ src/course-outline/data/selectors.js | 2 + src/course-outline/data/slice.js | 33 +++- src/course-outline/data/thunk.js | 133 ++++++++++++- .../delete-modal/DeleteModal.jsx | 48 +++++ .../delete-modal/DeleteModal.test.jsx | 47 +++++ src/course-outline/delete-modal/messages.js | 22 +++ .../empty-placeholder/EmptyContent.test.jsx | 31 +++ .../empty-placeholder/EmptyPlaceholder.jsx | 39 ++++ .../empty-placeholder/EmptyPlaceholder.scss | 10 + .../empty-placeholder/messages.js | 18 ++ .../EnableHighlightsModal.jsx | 9 +- .../EnableHighlightsModal.test.jsx | 57 ++++-- .../header-navigations/HeaderNavigations.jsx | 22 ++- .../HeaderNavigations.test.jsx | 42 +++- .../highlights-modal/HighlightsModal.jsx | 94 +++++++++ .../highlights-modal/HighlightsModal.scss | 15 ++ .../highlights-modal/HighlightsModal.test.jsx | 121 ++++++++++++ .../highlights-modal/messages.js | 30 +++ src/course-outline/hooks.jsx | 66 ++++++- src/course-outline/messages.js | 8 + .../publish-modal/PublishModal.jsx | 77 ++++++++ .../publish-modal/PublishModal.scss | 15 ++ .../publish-modal/PublishModal.test.jsx | 128 +++++++++++++ src/course-outline/publish-modal/messages.js | 22 +++ .../section-card/SectionCard.jsx | 156 +++++++++++++++ .../section-card/SectionCard.scss | 34 ++++ .../section-card/SectionCard.test.jsx | 82 ++++++++ src/course-outline/section-card/messages.js | 10 + src/course-outline/status-bar/StatusBar.jsx | 11 +- src/course-outline/status-bar/StatusBar.scss | 2 + src/course-outline/utils.jsx | 116 +++++++++++ .../utils/getChecklistForStatusBar.test.js | 96 ++++++++++ src/generic/sub-header/SubHeader.jsx | 5 +- src/hooks.js | 16 ++ 43 files changed, 2390 insertions(+), 43 deletions(-) create mode 100644 src/course-outline/card-header/CardHeader.jsx create mode 100644 src/course-outline/card-header/CardHeader.scss create mode 100644 src/course-outline/card-header/CardHeader.test.jsx create mode 100644 src/course-outline/card-header/messages.js create mode 100644 src/course-outline/delete-modal/DeleteModal.jsx create mode 100644 src/course-outline/delete-modal/DeleteModal.test.jsx create mode 100644 src/course-outline/delete-modal/messages.js create mode 100644 src/course-outline/empty-placeholder/EmptyContent.test.jsx create mode 100644 src/course-outline/empty-placeholder/EmptyPlaceholder.jsx create mode 100644 src/course-outline/empty-placeholder/EmptyPlaceholder.scss create mode 100644 src/course-outline/empty-placeholder/messages.js create mode 100644 src/course-outline/highlights-modal/HighlightsModal.jsx create mode 100644 src/course-outline/highlights-modal/HighlightsModal.scss create mode 100644 src/course-outline/highlights-modal/HighlightsModal.test.jsx create mode 100644 src/course-outline/highlights-modal/messages.js create mode 100644 src/course-outline/publish-modal/PublishModal.jsx create mode 100644 src/course-outline/publish-modal/PublishModal.scss create mode 100644 src/course-outline/publish-modal/PublishModal.test.jsx create mode 100644 src/course-outline/publish-modal/messages.js create mode 100644 src/course-outline/section-card/SectionCard.jsx create mode 100644 src/course-outline/section-card/SectionCard.scss create mode 100644 src/course-outline/section-card/SectionCard.test.jsx create mode 100644 src/course-outline/section-card/messages.js create mode 100644 src/course-outline/utils.jsx create mode 100644 src/course-outline/utils/getChecklistForStatusBar.test.js diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 51bf39f269..6c9df68a87 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -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} /> +
+ {/* TODO add create new section handler in EmptyPlaceholder */} + {sectionsList.length ? sectionsList.map((section) => ( + + )) : ( + ({})} /> + )} +
@@ -109,11 +159,29 @@ const CourseOutline = ({ courseId }) => { isOpen={isEnableHighlightsModalOpen} close={closeEnableHighlightsModal} onEnableHighlightsSubmit={handleEnableHighlightsSubmit} - highlightsDocUrl={statusBarData.highlightsDocUrl} /> + + +
+ ', () => { 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(); + + 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(); + + await waitFor(() => { + expect(getByTestId('empty-placeholder')).toBeInTheDocument(); + }); + }); + + it('check edit section when edit query is successfully', async () => { + const { getByText } = render(); + 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(); + 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(); + 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(); + 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(); + + 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(); + }); }); diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx new file mode 100644 index 0000000000..b8a61a44e1 --- /dev/null +++ b/src/course-outline/card-header/CardHeader.jsx @@ -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 ( +
+ {isFormOpen ? ( + + 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} + /> + + ) : ( + + {intl.formatMessage(messages.expandTooltip)} + + )} + > + + + )} +
+ {!isFormOpen && ( + + )} + + + + + {intl.formatMessage(messages.menuPublish)} + + {intl.formatMessage(messages.menuConfigure)} + {intl.formatMessage(messages.menuDuplicate)} + {intl.formatMessage(messages.menuDelete)} + + +
+
+ ); +}; + +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; diff --git a/src/course-outline/card-header/CardHeader.scss b/src/course-outline/card-header/CardHeader.scss new file mode 100644 index 0000000000..f12261e9c9 --- /dev/null +++ b/src/course-outline/card-header/CardHeader.scss @@ -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; + } +} diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.jsx new file mode 100644 index 0000000000..139a210351 --- /dev/null +++ b/src/course-outline/card-header/CardHeader.test.jsx @@ -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( + + + , +); + +describe('', () => { + 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(); + }); +}); diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js new file mode 100644 index 0000000000..a08e1eaa63 --- /dev/null +++ b/src/course-outline/card-header/messages.js @@ -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; diff --git a/src/course-outline/constants.js b/src/course-outline/constants.js index cd4c58eeb4..b575696720 100644 --- a/src/course-outline/constants.js +++ b/src/course-outline/constants.js @@ -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', diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 9b613bf81c..7e8035c49c 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -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} + */ +export async function getCourseSection(sectionId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseSectionApiUrl(sectionId)); + return camelCaseObject(data); +} + +/** + * Update course section highlights + * @param {string} sectionId + * @param {Array} highlights + * @returns {Promise} + */ +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} + */ +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} + */ +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} + */ +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} + */ +export async function duplicateCourseSection(sectionId, courseBlockId) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseSectionDuplicateApiUrl(), { + duplicate_source_locator: sectionId, + parent_locator: courseBlockId, + }); + + return data; +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index 096723ce5d..ca05ae6ffc 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -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; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index ea08b47026..b5cf66f7ab 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -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 { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 194ae03d79..9058e8b867 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -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 })); + } + }; +} diff --git a/src/course-outline/delete-modal/DeleteModal.jsx b/src/course-outline/delete-modal/DeleteModal.jsx new file mode 100644 index 0000000000..c8b72a0d98 --- /dev/null +++ b/src/course-outline/delete-modal/DeleteModal.jsx @@ -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 ( + + + + + )} + > +

{intl.formatMessage(messages.description)}

+
+ ); +}; + +DeleteModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + onDeleteSubmit: PropTypes.func.isRequired, +}; + +export default DeleteModal; diff --git a/src/course-outline/delete-modal/DeleteModal.test.jsx b/src/course-outline/delete-modal/DeleteModal.test.jsx new file mode 100644 index 0000000000..5034eeae27 --- /dev/null +++ b/src/course-outline/delete-modal/DeleteModal.test.jsx @@ -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( + + + , +); + +describe('', () => { + 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); + }); +}); diff --git a/src/course-outline/delete-modal/messages.js b/src/course-outline/delete-modal/messages.js new file mode 100644 index 0000000000..2577301f1b --- /dev/null +++ b/src/course-outline/delete-modal/messages.js @@ -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; diff --git a/src/course-outline/empty-placeholder/EmptyContent.test.jsx b/src/course-outline/empty-placeholder/EmptyContent.test.jsx new file mode 100644 index 0000000000..f76a1178c7 --- /dev/null +++ b/src/course-outline/empty-placeholder/EmptyContent.test.jsx @@ -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( + + + , +); + +describe('', () => { + 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); + }); +}); diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx new file mode 100644 index 0000000000..be113a1c12 --- /dev/null +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx @@ -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 ( +
+

{intl.formatMessage(messages.title)}

+ + {intl.formatMessage(messages.tooltip)} + + )} + > + + +
+ ); +}; + +EmptyPlaceholder.propTypes = { + onCreateNewSection: PropTypes.func.isRequired, +}; + +export default EmptyPlaceholder; diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.scss b/src/course-outline/empty-placeholder/EmptyPlaceholder.scss new file mode 100644 index 0000000000..cf7c54ca41 --- /dev/null +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.scss @@ -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; +} diff --git a/src/course-outline/empty-placeholder/messages.js b/src/course-outline/empty-placeholder/messages.js new file mode 100644 index 0000000000..eccb564a86 --- /dev/null +++ b/src/course-outline/empty-placeholder/messages.js @@ -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; diff --git a/src/course-outline/enable-highlights-modal/EnableHighlightsModal.jsx b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.jsx index c7e4258aaa..4c25f01497 100644 --- a/src/course-outline/enable-highlights-modal/EnableHighlightsModal.jsx +++ b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.jsx @@ -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 ( @@ -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; diff --git a/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx index 833631d034..0adb0ebc19 100644 --- a/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx +++ b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx @@ -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( - - - , + + + + , + , ); describe('', () => { + 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('', () => { 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', () => { diff --git a/src/course-outline/header-navigations/HeaderNavigations.jsx b/src/course-outline/header-navigations/HeaderNavigations.jsx index 57cdd693e2..580e9996e9 100644 --- a/src/course-outline/header-navigations/HeaderNavigations.jsx +++ b/src/course-outline/header-navigations/HeaderNavigations.jsx @@ -15,6 +15,7 @@ const HeaderNavigations = ({ isReIndexShow, isSectionsExpanded, isDisabledReindexButton, + hasSections, }) => { const intl = useIntl(); const { @@ -56,15 +57,17 @@ const HeaderNavigations = ({ )} - + {hasSections ? ( + + ) : null} render( isSectionsExpanded={false} isDisabledReindexButton={false} isReIndexShow + hasSections {...props} /> , @@ -78,4 +82,40 @@ describe('', () => { 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(); + }); + }); }); diff --git a/src/course-outline/highlights-modal/HighlightsModal.jsx b/src/course-outline/highlights-modal/HighlightsModal.jsx new file mode 100644 index 0000000000..625cd4c856 --- /dev/null +++ b/src/course-outline/highlights-modal/HighlightsModal.jsx @@ -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 ( + + + + {intl.formatMessage(messages.title, { + title: displayName, + })} + + + + {({ values, dirty, handleSubmit }) => ( + <> + +

+ {intl.formatMessage(messages.description, { + documentation: ( + + {intl.formatMessage(messages.documentationLink)} + ), + })} +

+ {Object.entries(initialFormValues).map(([key], index) => ( + + ))} +
+ + + + {intl.formatMessage(messages.cancelButton)} + + + + + + )} +
+
+ ); +}; + +HighlightsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default HighlightsModal; diff --git a/src/course-outline/highlights-modal/HighlightsModal.scss b/src/course-outline/highlights-modal/HighlightsModal.scss new file mode 100644 index 0000000000..ef991d8be2 --- /dev/null +++ b/src/course-outline/highlights-modal/HighlightsModal.scss @@ -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; + } +} diff --git a/src/course-outline/highlights-modal/HighlightsModal.test.jsx b/src/course-outline/highlights-modal/HighlightsModal.test.jsx new file mode 100644 index 0000000000..0e6a8b3237 --- /dev/null +++ b/src/course-outline/highlights-modal/HighlightsModal.test.jsx @@ -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( + + + + , + , +); + +describe('', () => { + 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) }), + ); + }); + }); +}); diff --git a/src/course-outline/highlights-modal/messages.js b/src/course-outline/highlights-modal/messages.js new file mode 100644 index 0000000000..d39c905ce2 --- /dev/null +++ b/src/course-outline/highlights-modal/messages.js @@ -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; diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index f96b84641c..64faaf7774 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -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, }; }; diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index 387b7f8ded..33368b5031 100644 --- a/src/course-outline/messages.js +++ b/src/course-outline/messages.js @@ -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; diff --git a/src/course-outline/publish-modal/PublishModal.jsx b/src/course-outline/publish-modal/PublishModal.jsx new file mode 100644 index 0000000000..7574b83f1a --- /dev/null +++ b/src/course-outline/publish-modal/PublishModal.jsx @@ -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 ( + + + + {intl.formatMessage(messages.title, { title: displayName })} + + + +

{intl.formatMessage(messages.description)}

+ {subSections.length ? subSections.map((subSection) => { + const units = subSection.childInfo.children; + + return units.length ? ( + + {subSection.displayName} + {units.map((unit) => ( +
+ {unit.displayName} +
+ ))} +
+ ) : null; + }) : null} +
+ + + + {intl.formatMessage(messages.cancelButton)} + + + + +
+ ); +}; + +PublishModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onPublishSubmit: PropTypes.func.isRequired, +}; + +export default PublishModal; diff --git a/src/course-outline/publish-modal/PublishModal.scss b/src/course-outline/publish-modal/PublishModal.scss new file mode 100644 index 0000000000..08a8b0a573 --- /dev/null +++ b/src/course-outline/publish-modal/PublishModal.scss @@ -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; + } +} diff --git a/src/course-outline/publish-modal/PublishModal.test.jsx b/src/course-outline/publish-modal/PublishModal.test.jsx new file mode 100644 index 0000000000..b08d355375 --- /dev/null +++ b/src/course-outline/publish-modal/PublishModal.test.jsx @@ -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( + + + + , + , +); + +describe('', () => { + 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); + }); +}); diff --git a/src/course-outline/publish-modal/messages.js b/src/course-outline/publish-modal/messages.js new file mode 100644 index 0000000000..4db16bf635 --- /dev/null +++ b/src/course-outline/publish-modal/messages.js @@ -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; diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx new file mode 100644 index 0000000000..9aadbb4752 --- /dev/null +++ b/src/course-outline/section-card/SectionCard.jsx @@ -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 ( +
+ +
+
+ +
+
+ {isExpanded && ( +
+ {children} +
+ )} + {isExpanded && ( + + )} +
+ ); +}; + +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; diff --git a/src/course-outline/section-card/SectionCard.scss b/src/course-outline/section-card/SectionCard.scss new file mode 100644 index 0000000000..3544ae56a4 --- /dev/null +++ b/src/course-outline/section-card/SectionCard.scss @@ -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; + } + } +} diff --git a/src/course-outline/section-card/SectionCard.test.jsx b/src/course-outline/section-card/SectionCard.test.jsx new file mode 100644 index 0000000000..4a68489632 --- /dev/null +++ b/src/course-outline/section-card/SectionCard.test.jsx @@ -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( + + + + children + + , + , +); + +describe('', () => { + 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(); + }); +}); diff --git a/src/course-outline/section-card/messages.js b/src/course-outline/section-card/messages.js new file mode 100644 index 0000000000..3c10ec1e71 --- /dev/null +++ b/src/course-outline/section-card/messages.js @@ -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; diff --git a/src/course-outline/status-bar/StatusBar.jsx b/src/course-outline/status-bar/StatusBar.jsx index 62d28f0430..8963242bb4 100644 --- a/src/course-outline/status-bar/StatusBar.jsx +++ b/src/course-outline/status-bar/StatusBar.jsx @@ -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 = ({ )} @@ -109,7 +113,6 @@ StatusBar.propTypes = { completedCourseBestPracticesChecks: PropTypes.number.isRequired, }), highlightsEnabledForMessaging: PropTypes.bool.isRequired, - highlightsDocUrl: PropTypes.string.isRequired, }).isRequired, }; diff --git a/src/course-outline/status-bar/StatusBar.scss b/src/course-outline/status-bar/StatusBar.scss index 873abef83b..e6e294accc 100644 --- a/src/course-outline/status-bar/StatusBar.scss +++ b/src/course-outline/status-bar/StatusBar.scss @@ -1,4 +1,6 @@ .outline-status-bar { + margin-bottom: .25rem; + .outline-status-bar__item { display: flex; flex-direction: column; diff --git a/src/course-outline/utils.jsx b/src/course-outline/utils.jsx new file mode 100644 index 0000000000..0ff718a0cf --- /dev/null +++ b/src/course-outline/utils.jsx @@ -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} 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, +}; diff --git a/src/course-outline/utils/getChecklistForStatusBar.test.js b/src/course-outline/utils/getChecklistForStatusBar.test.js new file mode 100644 index 0000000000..b7bc1cd145 --- /dev/null +++ b/src/course-outline/utils/getChecklistForStatusBar.test.js @@ -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, + }); + }); +}); diff --git a/src/generic/sub-header/SubHeader.jsx b/src/generic/sub-header/SubHeader.jsx index 3f166ac2d0..53030ff863 100644 --- a/src/generic/sub-header/SubHeader.jsx +++ b/src/generic/sub-header/SubHeader.jsx @@ -11,6 +11,7 @@ const SubHeader = ({ headerActions, titleActions, hideBorder, + withSubHeaderContent, }) => (
@@ -29,7 +30,7 @@ const SubHeader = ({ )}
- {contentTitle && ( + {contentTitle && withSubHeaderContent && (

{contentTitle}

{description} @@ -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; diff --git a/src/hooks.js b/src/hooks.js index d929bb35d3..8c649ea06b 100644 --- a/src/hooks.js +++ b/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]); +}; From 134b75568abcc346e7eca386d52f3b4c3391770d Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 20 Nov 2023 15:52:20 +0530 Subject: [PATCH 02/11] feat: section list and new section button Also refactor api and hooks fix: publish button behaviour and card header tests fix: warning in highlights and publish modal test fix: courseoutline tests test: add test for new section functionality fix(lint): lint issues refactor: remove unnecessary css in CardHeader refactor: rename emptyPlaceholder test file refactor: replace ternary operator with 'and' condition refactor: add black color to expand/collapse button refactor: display only changed subsection and units in publish modal refactor: update messages and css refactor: wrap urls in function call refactor: fix jsdoc types refactor: use helmet for document title --- src/constants.js | 1 + src/course-outline/CourseOutline.jsx | 51 ++++-- src/course-outline/CourseOutline.test.jsx | 52 +++++-- src/course-outline/__mocks__/courseSection.js | 93 +++++++++++ src/course-outline/__mocks__/index.js | 1 + src/course-outline/card-header/CardHeader.jsx | 13 +- .../card-header/CardHeader.scss | 23 +-- .../card-header/CardHeader.test.jsx | 145 +++++++++++------- src/course-outline/card-header/messages.js | 2 +- src/course-outline/constants.js | 21 ++- src/course-outline/data/api.js | 111 ++++++++++++-- src/course-outline/data/slice.js | 7 + src/course-outline/data/thunk.js | 28 ++++ .../delete-modal/DeleteModal.jsx | 1 - src/course-outline/delete-modal/messages.js | 2 +- .../empty-placeholder/EmptyPlaceholder.jsx | 3 +- ...ent.test.jsx => EmptyPlaceholder.test.jsx} | 0 .../EnableHighlightsModal.test.jsx | 6 + .../header-navigations/HeaderNavigations.jsx | 4 +- .../HeaderNavigations.test.jsx | 4 +- .../highlights-modal/HighlightsModal.test.jsx | 6 + .../highlights-modal/messages.js | 2 +- src/course-outline/hooks.jsx | 12 +- src/course-outline/messages.js | 10 +- .../publish-modal/PublishModal.jsx | 6 +- .../publish-modal/PublishModal.test.jsx | 9 ++ .../section-card/SectionCard.jsx | 9 +- .../section-card/SectionCard.scss | 1 + .../section-card/SectionCard.test.jsx | 1 + src/course-outline/section-card/messages.js | 4 + src/course-outline/status-bar/StatusBar.jsx | 8 +- .../status-bar/StatusBar.test.jsx | 6 + src/course-outline/utils.jsx | 4 +- src/generic/course-upload-image/index.jsx | 8 +- .../processing-notification/data/slice.js | 4 +- src/studio-home/card-item/index.jsx | 4 +- 36 files changed, 485 insertions(+), 177 deletions(-) create mode 100644 src/course-outline/__mocks__/courseSection.js rename src/course-outline/empty-placeholder/{EmptyContent.test.jsx => EmptyPlaceholder.test.jsx} (100%) diff --git a/src/constants.js b/src/constants.js index 6305606e70..7c293fe122 100644 --- a/src/constants.js +++ b/src/constants.js @@ -23,6 +23,7 @@ export const NOTIFICATION_MESSAGES = { saving: 'Saving', duplicating: 'Duplicating', deleting: 'Deleting', + empty: '', }; export const DEFAULT_TIME_STAMP = '00:00'; diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 6c9df68a87..a33beb4839 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -2,11 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { + Button, Container, Layout, TransitionReplace, } from '@edx/paragon'; +import { Helmet } from 'react-helmet'; import { + Add as IconAdd, CheckCircle as CheckCircleIcon, Warning as WarningIcon, } from '@edx/paragon/icons'; @@ -66,10 +69,9 @@ const CourseOutline = ({ courseId }) => { handleEditSectionSubmit, handleDeleteSectionSubmit, handleDuplicateSectionSubmit, + handleNewSectionSubmit, } = useCourseOutline({ courseId }); - document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle)); - const { isShow: isShowProcessingNotification, title: processingNotificationTitle, @@ -82,6 +84,9 @@ const CourseOutline = ({ courseId }) => { return ( <> + + {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))} +
@@ -131,20 +136,34 @@ const CourseOutline = ({ courseId }) => { openEnableHighlightsModal={openEnableHighlightsModal} />
- {/* TODO add create new section handler in EmptyPlaceholder */} - {sectionsList.length ? sectionsList.map((section) => ( - - )) : ( - ({})} /> + {sectionsList.length ? ( + <> + {sectionsList.map((section) => ( + + ))} + + + ) : ( + )}
diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 429978025b..83c215f927 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -1,5 +1,7 @@ import React from 'react'; -import { render, waitFor, fireEvent } from '@testing-library/react'; +import { + render, waitFor, cleanup, 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,13 +13,13 @@ import { getCourseLaunchApiUrl, getCourseOutlineIndexApiUrl, getCourseReindexApiUrl, - getCourseReindexApiUrl, - getCourseSectionApiUrl, - getCourseSectionDuplicateApiUrl, + getXBlockApiUrl, getEnableHighlightsEmailsApiUrl, getUpdateCourseSectionApiUrl, + getXBlockBaseApiUrl, } from './data/api'; import { + addNewCourseSectionQuery, deleteCourseSectionQuery, duplicateCourseSectionQuery, editCourseSectionQuery, @@ -36,10 +38,12 @@ import { courseOutlineIndexWithoutSections, courseBestPracticesMock, courseLaunchMock, + courseSectionMock, } from './__mocks__'; import { executeThunk } from '../utils'; import CourseOutline from './CourseOutline'; import messages from './messages'; +import headerMessages from './header-navigations/messages'; let axiosMock; let store; @@ -53,6 +57,15 @@ jest.mock('react-router-dom', () => ({ }), })); +jest.mock('../help-urls/hooks', () => ({ + useHelpUrls: () => ({ + contentHighlights: 'some', + visibility: 'some', + grading: 'some', + outline: 'some', + }), +})); + const RootWrapper = () => ( @@ -100,6 +113,25 @@ describe('', () => { expect(getByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument(); }); + it('adds new section correctly', async () => { + const { findAllByTestId } = render(); + let element = await findAllByTestId('section-card'); + expect(element.length).toBe(4); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: courseSectionMock.id, + }); + axiosMock + .onGet(getXBlockApiUrl(courseSectionMock.id)) + .reply(200, courseSectionMock); + await executeThunk(addNewCourseSectionQuery(courseId), store.dispatch); + + element = await findAllByTestId('section-card'); + expect(element.length).toBe(5); + }); + it('render error alert after failed reindex correctly', async () => { const { getByText } = render(); @@ -163,11 +195,11 @@ describe('', () => { const { queryAllByTestId, getByText } = render(); await waitFor(() => { - const collapseBtn = getByText(messages.collapseAllButton.defaultMessage); + const collapseBtn = getByText(headerMessages.collapseAllButton.defaultMessage); expect(collapseBtn).toBeInTheDocument(); fireEvent.click(collapseBtn); - const expendBtn = getByText(messages.expandAllButton.defaultMessage); + const expendBtn = getByText(headerMessages.expandAllButton.defaultMessage); expect(expendBtn).toBeInTheDocument(); fireEvent.click(expendBtn); @@ -210,7 +242,7 @@ describe('', () => { await executeThunk(editCourseSectionQuery(section.id, newDisplayName), store.dispatch); axiosMock - .onGet(getCourseSectionApiUrl(section.id)) + .onGet(getXBlockApiUrl(section.id)) .reply(200); await executeThunk(fetchCourseSectionQuery(section.id), store.dispatch); @@ -237,7 +269,7 @@ describe('', () => { const courseBlockId = courseOutlineIndexMock.courseStructure.id; axiosMock - .onPost(getCourseSectionDuplicateApiUrl()) + .onPost(getXBlockBaseApiUrl()) .reply(200, { duplicate_source_locator: section.id, parent_locator: courseBlockId, @@ -279,7 +311,7 @@ describe('', () => { await executeThunk(publishCourseSectionQuery(section.id), store.dispatch); axiosMock - .onGet(getCourseSectionApiUrl(section.id)) + .onGet(getXBlockApiUrl(section.id)) .reply(200, { ...section, published: true, @@ -316,7 +348,7 @@ describe('', () => { await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch); axiosMock - .onGet(getCourseSectionApiUrl(section.id)) + .onGet(getXBlockApiUrl(section.id)) .reply(200, { ...section, highlights, diff --git a/src/course-outline/__mocks__/courseSection.js b/src/course-outline/__mocks__/courseSection.js new file mode 100644 index 0000000000..2e4e6f8de9 --- /dev/null +++ b/src/course-outline/__mocks__/courseSection.js @@ -0,0 +1,93 @@ +module.exports = { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d0e78d363a424da6be5c22704c34f7a7', + display_name: 'Section', + category: 'chapter', + has_children: true, + edited_on: 'Nov 22, 2023 at 07:45 UTC', + published: true, + published_on: 'Nov 22, 2023 at 07:45 UTC', + studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d0e78d363a424da6be5c22704c34f7a7', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + highlights: [], + highlights_enabled: true, + highlights_preview_only: false, + highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + child_info: { + category: 'sequential', + display_name: 'Subsection', + children: [], + }, + ancestor_has_staff_lock: false, + staff_only_message: false, + enable_copy_paste_units: false, + has_partition_group_components: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, +}; diff --git a/src/course-outline/__mocks__/index.js b/src/course-outline/__mocks__/index.js index e699605ef6..9421452577 100644 --- a/src/course-outline/__mocks__/index.js +++ b/src/course-outline/__mocks__/index.js @@ -2,3 +2,4 @@ export { default as courseOutlineIndexMock } from './courseOutlineIndex'; export { default as courseOutlineIndexWithoutSections } from './courseOutlineIndexWithoutSections'; export { default as courseBestPracticesMock } from './courseBestPractices'; export { default as courseLaunchMock } from './courseLaunch'; +export { default as courseSectionMock } from './courseSection'; diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index b8a61a44e1..a6d87171a9 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -13,6 +13,7 @@ import { } from '@edx/paragon'; import { ArrowDropDown as ArrowDownIcon, + ArrowDropUp as ArrowUpIcon, MoreVert as MoveVertIcon, EditOutline as EditIcon, } from '@edx/paragon/icons'; @@ -26,6 +27,7 @@ import messages from './messages'; const CardHeader = ({ title, sectionStatus, + hasChanges, isExpanded, onClickPublish, onClickMenuButton, @@ -42,8 +44,8 @@ const CardHeader = ({ const [titleValue, setTitleValue] = useState(title); const { badgeTitle, badgeIcon } = getSectionStatusBadgeContent(sectionStatus, messages, intl); - const isDisabledPublish = sectionStatus === SECTION_BADGE_STATUTES.live - || sectionStatus === SECTION_BADGE_STATUTES.publishedNotLive; + const isDisabledPublish = (sectionStatus === SECTION_BADGE_STATUTES.live + || sectionStatus === SECTION_BADGE_STATUTES.publishedNotLive) && !hasChanges; useEscapeClick({ onEscape: () => { @@ -86,12 +88,10 @@ const CardHeader = ({ )} >