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 51bf39f269..678b721093 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -1,33 +1,56 @@ -import React from 'react'; +import { + React, useState, useEffect, +} 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'; +import { useSelector } from 'react-redux'; +import { + DraggableList, + SortableItem, + ErrorAlert, +} from '@edx/frontend-lib-content-components'; -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 SubsectionCard from './subsection-card/SubsectionCard'; +import HighlightsModal from './highlights-modal/HighlightsModal'; +import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; +import PublishModal from './publish-modal/PublishModal'; +import ConfigureModal from './configure-modal/ConfigureModal'; +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 +59,54 @@ const CourseOutline = ({ courseId }) => { isEnableHighlightsModalOpen, isInternetConnectionAlertFailed, isDisabledReindexButton, + isHighlightsModalOpen, + isPublishModalOpen, + isConfigureModalOpen, + isDeleteModalOpen, + closeHighlightsModal, + closePublishModal, + closeConfigureModal, + closeDeleteModal, + openPublishModal, + openConfigureModal, + openDeleteModal, headerNavigationsActions, openEnableHighlightsModal, closeEnableHighlightsModal, handleEnableHighlightsSubmit, handleInternetConnectionFailed, + handleOpenHighlightsModal, + handleHighlightsFormSubmit, + handleConfigureSectionSubmit, + handlePublishItemSubmit, + handleEditSubmit, + handleDeleteItemSubmit, + handleDuplicateSectionSubmit, + handleDuplicateSubsectionSubmit, + handleNewSectionSubmit, + handleNewSubsectionSubmit, + handleDragNDrop, } = useCourseOutline({ courseId }); + const [sections, setSections] = useState(sectionsList); + + const initialSections = [...sectionsList]; + + const { + isShow: isShowProcessingNotification, + title: processingNotificationTitle, + } = useSelector(getProcessingNotification); + + const finalizeSectionOrder = () => (newSections) => { + handleDragNDrop(newSections.map((section) => section.id), () => { + setSections(() => initialSections); + }); + }; + + useEffect(() => { + setSections(sectionsList); + }, [sectionsList]); + if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; @@ -50,8 +114,14 @@ const CourseOutline = ({ courseId }) => { return ( <> + + {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))} +
+ + {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} + {showSuccessAlert ? ( { isSectionsExpanded={isSectionsExpanded} headerNavigationsActions={headerNavigationsActions} isDisabledReindexButton={isDisabledReindexButton} + hasSections={Boolean(sectionsList.length)} /> )} /> @@ -97,6 +168,66 @@ const CourseOutline = ({ courseId }) => { statusBarData={statusBarData} openEnableHighlightsModal={openEnableHighlightsModal} /> +
+ {sections.length ? ( + <> + + {sections.map((section) => ( + + + {section.childInfo.children.map((subsection) => ( + + ))} + + + ))} + + + + ) : ( + + )} +
@@ -109,11 +240,34 @@ const CourseOutline = ({ courseId }) => { isOpen={isEnableHighlightsModalOpen} close={closeEnableHighlightsModal} onEnableHighlightsSubmit={handleEnableHighlightsSubmit} - highlightsDocUrl={statusBarData.highlightsDocUrl} /> + + + +
+ ({ ...jest.requireActual('react-router-dom'), useLocation: () => ({ @@ -42,6 +56,15 @@ jest.mock('react-router-dom', () => ({ }), })); +jest.mock('../help-urls/hooks', () => ({ + useHelpUrls: () => ({ + contentHighlights: 'some', + visibility: 'some', + grading: 'some', + outline: 'some', + }), +})); + const RootWrapper = () => ( @@ -79,25 +102,84 @@ describe('', () => { }); it('check reindex and render success alert is correctly', async () => { - const { getByText } = render(); + const { findByText, findByTestId } = render(); axiosMock .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink)) .reply(200); - await executeThunk(fetchCourseReindexQuery(courseId, courseOutlineIndexMock.reindexLink), store.dispatch); + const reindexButton = await findByTestId('course-reindex'); + fireEvent.click(reindexButton); - expect(getByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument(); + expect(await findByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument(); }); it('render error alert after failed reindex correctly', async () => { - const { getByText } = render(); + const { findByText, findByTestId } = render(); axiosMock - .onGet(getCourseReindexApiUrl('some link')) + .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink)) .reply(500); - await executeThunk(fetchCourseReindexQuery(courseId, 'some link'), store.dispatch); + const reindexButton = await findByTestId('course-reindex'); + await act(async () => { + fireEvent.click(reindexButton); + }); + + expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument(); + }); - expect(getByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument(); + it('adds new section correctly', async () => { + const { findAllByTestId, findByTestId } = render(); + let elements = await findAllByTestId('section-card'); + window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + top: 0, + bottom: 4000, + })); + expect(elements.length).toBe(4); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: courseSectionMock.id, + }); + axiosMock + .onGet(getXBlockApiUrl(courseSectionMock.id)) + .reply(200, courseSectionMock); + const newSectionButton = await findByTestId('new-section-button'); + await act(async () => { + fireEvent.click(newSectionButton); + }); + + elements = await findAllByTestId('section-card'); + expect(elements.length).toBe(5); + expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled(); + }); + + it('adds new subsection correctly', async () => { + const { findAllByTestId } = render(); + const [section] = await findAllByTestId('section-card'); + let subsections = await within(section).findAllByTestId('subsection-card'); + expect(subsections.length).toBe(1); + window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + top: 0, + bottom: 4000, + })); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: courseSubsectionMock.id, + }); + axiosMock + .onGet(getXBlockApiUrl(courseSubsectionMock.id)) + .reply(200, courseSubsectionMock); + const newSubsectionButton = await within(section).findByTestId('new-subsection-button'); + await act(async () => { + fireEvent.click(newSubsectionButton); + }); + + subsections = await within(section).findAllByTestId('subsection-card'); + expect(subsections.length).toBe(2); + expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled(); }); it('render checklist value correctly', async () => { @@ -126,25 +208,356 @@ describe('', () => { }); it('check highlights are enabled after enable highlights query is successful', async () => { - const { findByTestId } = render(); + const { findByTestId, findByText } = render(); + axiosMock.reset(); + axiosMock + .onPost(getEnableHighlightsEmailsApiUrl(courseId), { + publish: 'republish', + metadata: { + highlights_enabled_for_messaging: true, + }, + }) + .reply(200); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, { ...courseOutlineIndexMock, - highlightsEnabledForMessaging: false, + courseStructure: { + ...courseOutlineIndexMock.courseStructure, + highlightsEnabledForMessaging: true, + }, }); + const enableButton = await findByTestId('highlights-enable-button'); + fireEvent.click(enableButton); + const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage); + await act(async () => { + fireEvent.click(saveButton); + }); + expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument(); + }); + + it('should expand and collapse subsections, after click on subheader buttons', async () => { + const { queryAllByTestId, findByText } = render(); + + const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage); + expect(collapseBtn).toBeInTheDocument(); + fireEvent.click(collapseBtn); + + const expandBtn = await findByText(headerMessages.expandAllButton.defaultMessage); + expect(expandBtn).toBeInTheDocument(); + fireEvent.click(expandBtn); + + await waitFor(() => { + const 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 - .onPost(getEnableHighlightsEmailsApiUrl(courseId), { + .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 { findAllByTestId, findByText } = render(); + const newDisplayName = 'New section name'; + + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + + axiosMock + .onPost(getCourseItemApiUrl(section.id, { + metadata: { + display_name: newDisplayName, + }, + })) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + display_name: newDisplayName, + }); + + const [sectionElement] = await findAllByTestId('section-card'); + const editButton = await within(sectionElement).findByTestId('section-edit-button'); + fireEvent.click(editButton); + const editField = await within(sectionElement).findByTestId('section-edit-field'); + fireEvent.change(editField, { target: { value: newDisplayName } }); + await act(async () => { + fireEvent.blur(editField); + }); + + expect(await findByText(newDisplayName)).toBeInTheDocument(); + }); + + it('check whether section is deleted when delete button is clicked', async () => { + const { findAllByTestId, findByTestId, queryByText } = render(); + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + await waitFor(() => { + expect(queryByText(section.displayName)).toBeInTheDocument(); + }); + + axiosMock.onDelete(getCourseItemApiUrl(section.id)).reply(200); + + const [sectionElement] = await findAllByTestId('section-card'); + const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); + fireEvent.click(menu); + const deleteButton = await within(sectionElement).findByTestId('section-card-header__menu-delete-button'); + fireEvent.click(deleteButton); + const confirmButton = await findByTestId('delete-confirm-button'); + await act(async () => { + fireEvent.click(confirmButton); + }); + + await waitFor(() => { + expect(queryByText(section.displayName)).not.toBeInTheDocument(); + }); + }); + + it('check whether subsection is deleted when delete button is clicked', async () => { + const { findAllByTestId, findByTestId, queryByText } = render(); + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [subsection] = section.childInfo.children; + await waitFor(() => { + expect(queryByText(subsection.displayName)).toBeInTheDocument(); + }); + + axiosMock.onDelete(getCourseItemApiUrl(subsection.id)).reply(200); + + const [sectionElement] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); + fireEvent.click(menu); + const deleteButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-delete-button'); + fireEvent.click(deleteButton); + const confirmButton = await findByTestId('delete-confirm-button'); + await act(async () => { + fireEvent.click(confirmButton); + }); + + await waitFor(() => { + expect(queryByText(subsection.displayName)).not.toBeInTheDocument(); + }); + }); + + it('check whether section is duplicated successfully', async () => { + const { findAllByTestId } = render(); + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + expect(await findAllByTestId('section-card')).toHaveLength(4); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: courseSectionMock.id, + }); + section.id = courseSectionMock.id; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + }); + + const [sectionElement] = await findAllByTestId('section-card'); + const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); + fireEvent.click(menu); + const duplicateButton = await within(sectionElement).findByTestId('section-card-header__menu-duplicate-button'); + await act(async () => { + fireEvent.click(duplicateButton); + }); + expect(await findAllByTestId('section-card')).toHaveLength(5); + }); + + it('check whether subsection is duplicated successfully', async () => { + const { findAllByTestId } = render(); + const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; + let [sectionElement] = await findAllByTestId('section-card'); + const [subsection] = section.childInfo.children; + let subsections = await within(sectionElement).findAllByTestId('subsection-card'); + expect(subsections.length).toBe(1); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: courseSubsectionMock.id, + }); + subsection.id = courseSubsectionMock.id; + section.childInfo.children = [...section.childInfo.children, subsection]; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + }); + + const menu = await within(subsections[0]).findByTestId('subsection-card-header__menu-button'); + fireEvent.click(menu); + const duplicateButton = await within(subsections[0]).findByTestId('subsection-card-header__menu-duplicate-button'); + await act(async () => { + fireEvent.click(duplicateButton); + }); + + [sectionElement] = await findAllByTestId('section-card'); + subsections = await within(sectionElement).findAllByTestId('subsection-card'); + expect(subsections.length).toBe(2); + }); + + it('check section is published when publish button is clicked', async () => { + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const { findAllByTestId, findByTestId } = render(); + + axiosMock + .onPost(getCourseItemApiUrl(section.id), { + publish: 'make_public', + }) + .reply(200, { dummy: 'value' }); + + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + published: true, + releasedToStudents: false, + }); + + const [sectionElement] = await findAllByTestId('section-card'); + const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); + fireEvent.click(menu); + const publishButton = await within(sectionElement).findByTestId('section-card-header__menu-publish-button'); + await act(async () => fireEvent.click(publishButton)); + const confirmButton = await findByTestId('publish-confirm-button'); + await act(async () => fireEvent.click(confirmButton)); + + expect( + sectionElement.querySelector('.item-card-header__badge-status'), + ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage); + }); + + it('check configure section when configure query is successful', async () => { + const { findAllByTestId, findByPlaceholderText } = render(); + const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; + const newReleaseDate = '2025-08-10T10:00:00Z'; + axiosMock + .onPost(getCourseItemApiUrl(section.id), { publish: 'republish', metadata: { - highlights_enabled_for_messaging: true, + visible_to_staff_only: true, + start: newReleaseDate, }, }) - .reply(200); + .reply(200, { dummy: 'value' }); - await executeThunk(enableCourseHighlightsEmailsQuery(courseId), store.dispatch); - expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument(); + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + const [firstSection] = await findAllByTestId('section-card'); + + const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button'); + fireEvent.click(sectionDropdownButton); + + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + start: newReleaseDate, + }); + + await executeThunk(configureCourseSectionQuery(section.id, true, newReleaseDate), store.dispatch); + fireEvent.click(sectionDropdownButton); + const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + const datePicker = await findByPlaceholderText('MM/DD/YYYY'); + expect(datePicker).toHaveValue('08/10/2025'); + }); + + 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(getCourseItemApiUrl(section.id), { + publish: 'republish', + metadata: { + highlights, + }, + }) + .reply(200, { dummy: 'value' }); + + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + highlights, + }); + + await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch); + + await waitFor(() => { + expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument(); + }); + }); + + it('check section list is ordered successfully', async () => { + const { getAllByTestId } = render(); + const courseBlockId = courseOutlineIndexMock.courseStructure.id; + let { children } = courseOutlineIndexMock.courseStructure.childInfo; + children = children.splice(2, 0, children.splice(0, 1)[0]); + + axiosMock + .onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children }) + .reply(200, { dummy: 'value' }); + + await executeThunk(setSectionOrderListQuery(courseBlockId, children, () => {}), store.dispatch); + + await waitFor(() => { + expect(getAllByTestId('section-card')).toHaveLength(4); + const newSections = getAllByTestId('section-card'); + for (let i; i < children.length; i++) { + expect(children[i].id === newSections[i].id); + } + }); + }); + + it('check section list is restored to original order when API call fails', async () => { + const { getAllByTestId } = render(); + const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const { children } = courseOutlineIndexMock.courseStructure.childInfo; + const newChildren = children.splice(2, 0, children.splice(0, 1)[0]); + + axiosMock + .onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children }) + .reply(500); + + await executeThunk(setSectionOrderListQuery(courseBlockId, undefined, () => children), store.dispatch); + + await waitFor(() => { + expect(getAllByTestId('section-card')).toHaveLength(4); + const newSections = getAllByTestId('section-card'); + for (let i; i < children.length; i++) { + expect(children[i].id === newSections[i].id); + expect(newChildren[i].id !== newSections[i].id); + } + }); }); }); diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index b65784c9be..d7ed4ed35f 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -55,7 +55,7 @@ module.exports = { }, ], showCorrectness: 'always', - highlightsEnabledForMessaging: true, + highlightsEnabledForMessaging: false, highlightsEnabled: true, highlightsPreviewOnly: false, highlightsDocUrl: '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', @@ -72,7 +72,7 @@ module.exports = { category: 'chapter', hasChildren: true, editedOn: 'Aug 23, 2023 at 12:35 UTC', - published: true, + published: false, publishedOn: 'Aug 23, 2023 at 12:35 UTC', studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b', releasedToStudents: true, 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__/courseSubsection.js b/src/course-outline/__mocks__/courseSubsection.js new file mode 100644 index 0000000000..a7e72d0efa --- /dev/null +++ b/src/course-outline/__mocks__/courseSubsection.js @@ -0,0 +1,101 @@ +module.exports = { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@b713bc2830f34f6f87554028c3068729', + display_name: 'Subsection', + category: 'sequential', + has_children: true, + edited_on: 'Dec 05, 2023 at 10:35 UTC', + published: true, + published_on: 'Dec 05, 2023 at 10:35 UTC', + studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40b713bc2830f34f6f87554028c3068729', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + hide_after_due: false, + is_proctored_exam: false, + was_exam_ever_linked_with_external: false, + online_proctoring_rules: '', + is_practice_exam: false, + is_onboarding_exam: false, + is_time_limited: false, + exam_review_rules: '', + default_time_limit_minutes: null, + proctoring_exam_configuration_link: null, + supports_onboarding: false, + show_review_rules: true, + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [], + }, + ancestor_has_staff_lock: false, + staff_only_message: false, + enable_copy_paste_units: false, + has_partition_group_components: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, +}; diff --git a/src/course-outline/__mocks__/index.js b/src/course-outline/__mocks__/index.js index e699605ef6..15c6504cb1 100644 --- a/src/course-outline/__mocks__/index.js +++ b/src/course-outline/__mocks__/index.js @@ -2,3 +2,5 @@ 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'; +export { default as courseSubsectionMock } from './courseSubsection'; diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx new file mode 100644 index 0000000000..2c06e02220 --- /dev/null +++ b/src/course-outline/card-header/CardHeader.jsx @@ -0,0 +1,186 @@ +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, + ArrowDropUp as ArrowUpIcon, + MoreVert as MoveVertIcon, + EditOutline as EditIcon, +} from '@edx/paragon/icons'; +import classNames from 'classnames'; + +import { useEscapeClick } from '../../hooks'; +import { ITEM_BADGE_STATUS } from '../constants'; +import { getItemStatusBadgeContent } from '../utils'; +import messages from './messages'; + +const CardHeader = ({ + title, + status, + hasChanges, + isExpanded, + onClickPublish, + onClickConfigure, + onClickMenuButton, + onClickEdit, + onExpand, + isFormOpen, + onEditSubmit, + closeForm, + isDisabledEditField, + onClickDelete, + onClickDuplicate, + namePrefix, +}) => { + const intl = useIntl(); + const [titleValue, setTitleValue] = useState(title); + + const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); + const isDisabledPublish = (status === ITEM_BADGE_STATUS.live + || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; + + 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, + status: PropTypes.string.isRequired, + hasChanges: PropTypes.bool.isRequired, + isExpanded: PropTypes.bool.isRequired, + onExpand: PropTypes.func.isRequired, + onClickPublish: PropTypes.func.isRequired, + onClickConfigure: 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, + namePrefix: PropTypes.string.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..12744ba9f6 --- /dev/null +++ b/src/course-outline/card-header/CardHeader.scss @@ -0,0 +1,44 @@ +.item-card-header { + display: flex; + align-items: center; + margin-right: -.5rem; + + .item-card-header__expanded-btn { + justify-content: flex-start; + padding: 0; + width: 80%; + height: 1.5rem; + margin-right: .25rem; + background: transparent; + color: $black; + } + + .item-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; + } + } + + .pgn__form-group { + width: 80%; + } +} + +.item-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..704d69baad --- /dev/null +++ b/src/course-outline/card-header/CardHeader.test.jsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { ITEM_BADGE_STATUS } 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', + status: ITEM_BADGE_STATUS.live, + hasChanges: false, + isExpanded: true, + onExpand: onExpandMock, + onClickMenuButton: onClickMenuButtonMock, + onClickPublish: onClickPublishMock, + onClickEdit: onClickEditMock, + isFormOpen: false, + onEditSubmit: jest.fn(), + closeForm: closeFormMock, + isDisabledEditField: false, + onClickDelete: onClickDeleteMock, + onClickDuplicate: onClickDuplicateMock, + namePrefix: 'section', +}; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render CardHeader component correctly', async () => { + const { findByText, findByTestId, queryByTestId } = renderComponent(); + + expect(await findByText(cardHeaderProps.title)).toBeInTheDocument(); + expect(await findByTestId('section-card-header__expanded-btn')).toBeInTheDocument(); + expect(await findByTestId('section-card-header__badge-status')).toBeInTheDocument(); + expect(await findByTestId('section-card-header__menu')).toBeInTheDocument(); + await waitFor(() => { + expect(queryByTestId('edit field')).not.toBeInTheDocument(); + }); + }); + + it('render status badge as live', async () => { + const { findByText } = renderComponent(); + expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument(); + }); + + it('render status badge as published_not_live', async () => { + const { findByText } = renderComponent({ + ...cardHeaderProps, + status: ITEM_BADGE_STATUS.publishedNotLive, + }); + + expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument(); + }); + + it('render status badge as staff_only', async () => { + const { findByText } = renderComponent({ + ...cardHeaderProps, + status: ITEM_BADGE_STATUS.staffOnly, + }); + + expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument(); + }); + + it('render status badge as draft', async () => { + const { findByText } = renderComponent({ + ...cardHeaderProps, + status: ITEM_BADGE_STATUS.draft, + }); + + expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); + }); + + it('check publish menu item is disabled when section status is live or published not live and it has no changes', async () => { + const { findByText, findByTestId } = renderComponent({ + ...cardHeaderProps, + status: ITEM_BADGE_STATUS.publishedNotLive, + }); + + const menuButton = await findByTestId('section-card-header__menu-button'); + fireEvent.click(menuButton); + expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true'); + }); + + it('check publish menu item is enabled when section status is live or published not live and it has changes', async () => { + const { findByText, findByTestId } = renderComponent({ + ...cardHeaderProps, + status: ITEM_BADGE_STATUS.publishedNotLive, + hasChanges: true, + }); + + const menuButton = await findByTestId('section-card-header__menu-button'); + fireEvent.click(menuButton); + expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled'); + }); + + it('calls handleExpanded when button is clicked', async () => { + const { findByTestId } = renderComponent(); + + const expandButton = await findByTestId('section-card-header__expanded-btn'); + fireEvent.click(expandButton); + expect(onExpandMock).toHaveBeenCalled(); + }); + + it('calls onClickMenuButton when menu is clicked', async () => { + const { findByTestId } = renderComponent(); + + const menuButton = await findByTestId('section-card-header__menu-button'); + fireEvent.click(menuButton); + waitFor(() => { + expect(onClickMenuButtonMock).toHaveBeenCalled(); + }); + }); + + it('calls onClickPublish when item is clicked', async () => { + const { findByText, findByTestId } = renderComponent({ + ...cardHeaderProps, + status: ITEM_BADGE_STATUS.draft, + }); + + const menuButton = await findByTestId('section-card-header__menu-button'); + fireEvent.click(menuButton); + + const publishMenuItem = await findByText(messages.menuPublish.defaultMessage); + fireEvent.click(publishMenuItem); + waitFor(() => { + expect(onClickPublishMock).toHaveBeenCalled(); + }); + }); + + it('calls onClickEdit when the button is clicked', async () => { + const { findByTestId } = renderComponent(); + + const editButton = await findByTestId('section-edit-button'); + fireEvent.click(editButton); + waitFor(() => { + expect(onClickEditMock).toHaveBeenCalled(); + }); + }); + + it('check is field visible when isFormOpen is true', async () => { + const { findByTestId, queryByTestId } = renderComponent({ + ...cardHeaderProps, + isFormOpen: true, + }); + + expect(await findByTestId('section-edit-field')).toBeInTheDocument(); + waitFor(() => { + expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument(); + expect(queryByTestId('edit-button')).not.toBeInTheDocument(); + }); + }); + + it('check is field disabled when isDisabledEditField is true', async () => { + const { findByTestId } = renderComponent({ + ...cardHeaderProps, + isFormOpen: true, + isDisabledEditField: true, + }); + + expect(await findByTestId('section-edit-field')).toBeDisabled(); + }); + + it('calls onClickDelete when item is clicked', async () => { + const { findByText, findByTestId } = renderComponent(); + + const menuButton = await findByTestId('section-card-header__menu-button'); + fireEvent.click(menuButton); + + const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage); + fireEvent.click(deleteMenuItem); + waitFor(() => { + expect(onClickDeleteMock).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onClickDuplicate when item is clicked', async () => { + const { findByText, findByTestId } = renderComponent(); + + const menuButton = await findByTestId('section-card-header__menu-button'); + fireEvent.click(menuButton); + + const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage); + fireEvent.click(duplicateMenuItem); + waitFor(() => { + 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..0197722dd9 --- /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.card.expandTooltip', + defaultMessage: 'Collapse/Expand this card', + }, + statusBadgeLive: { + id: 'course-authoring.course-outline.card.status-badge.live', + defaultMessage: 'Live', + }, + statusBadgePublishedNotLive: { + id: 'course-authoring.course-outline.card.status-badge.published-not-live', + defaultMessage: 'Published not live', + }, + statusBadgeStaffOnly: { + id: 'course-authoring.course-outline.card.status-badge.staff-only', + defaultMessage: 'Staff only', + }, + statusBadgeDraft: { + id: 'course-authoring.course-outline.card.status-badge.draft', + defaultMessage: 'Draft', + }, + altButtonEdit: { + id: 'course-authoring.course-outline.card.button.edit.alt', + defaultMessage: 'Edit', + }, + menuPublish: { + id: 'course-authoring.course-outline.card.menu.publish', + defaultMessage: 'Publish', + }, + menuConfigure: { + id: 'course-authoring.course-outline.card.menu.configure', + defaultMessage: 'Configure', + }, + menuDuplicate: { + id: 'course-authoring.course-outline.card.menu.duplicate', + defaultMessage: 'Duplicate', + }, + menuDelete: { + id: 'course-authoring.course-outline.card.menu.delete', + defaultMessage: 'Delete', + }, +}); + +export default messages; diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/course-outline/configure-modal/BasicTab.jsx new file mode 100644 index 0000000000..ffdfc81c5a --- /dev/null +++ b/src/course-outline/configure-modal/BasicTab.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Stack } from '@edx/paragon'; +import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control'; + +const BasicTab = ({ releaseDate, setReleaseDate }) => { + const intl = useIntl(); + const onChange = (value) => { + setReleaseDate(value); + }; + + return ( + <> +

+
+ + onChange(date)} + /> + onChange(date)} + /> + + + ); +}; + +BasicTab.propTypes = { + releaseDate: PropTypes.string.isRequired, + setReleaseDate: PropTypes.func.isRequired, +}; + +export default injectIntl(BasicTab); diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/course-outline/configure-modal/ConfigureModal.jsx new file mode 100644 index 0000000000..5490ef8942 --- /dev/null +++ b/src/course-outline/configure-modal/ConfigureModal.jsx @@ -0,0 +1,95 @@ +/* eslint-disable import/named */ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ModalDialog, + Button, + ActionRow, + Tab, + Tabs, +} from '@edx/paragon'; +import { useSelector } from 'react-redux'; + +import { VisibilityTypes } from '../../data/constants'; +import { getCurrentItem } from '../data/selectors'; +import messages from './messages'; +import BasicTab from './BasicTab'; +import VisibilityTab from './VisibilityTab'; + +const ConfigureModal = ({ + isOpen, + onClose, + onConfigureSubmit, +}) => { + const intl = useIntl(); + const { displayName, start: sectionStartDate, visibilityState } = useSelector(getCurrentItem); + const [releaseDate, setReleaseDate] = useState(sectionStartDate); + const [isVisibleToStaffOnly, setIsVisibleToStaffOnly] = useState(visibilityState === VisibilityTypes.STAFF_ONLY); + const [saveButtonDisabled, setSaveButtonDisabled] = useState(true); + + useEffect(() => { + setReleaseDate(sectionStartDate); + }, [sectionStartDate]); + + useEffect(() => { + setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY); + }, [visibilityState]); + + useEffect(() => { + const visibilityUnchanged = isVisibleToStaffOnly === (visibilityState === VisibilityTypes.STAFF_ONLY); + setSaveButtonDisabled(visibilityUnchanged && releaseDate === sectionStartDate); + }, [releaseDate, isVisibleToStaffOnly]); + + const handleSave = () => { + onConfigureSubmit(isVisibleToStaffOnly, releaseDate); + }; + + return ( + + + + {intl.formatMessage(messages.title, { title: displayName })} + + + + + + + + + + + + + + + + {intl.formatMessage(messages.cancelButton)} + + + + + + ); +}; + +ConfigureModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfigureSubmit: PropTypes.func.isRequired, +}; + +export default ConfigureModal; diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/course-outline/configure-modal/ConfigureModal.scss new file mode 100644 index 0000000000..1fad13926a --- /dev/null +++ b/src/course-outline/configure-modal/ConfigureModal.scss @@ -0,0 +1,12 @@ +.configure-modal { + max-width: 33.6875rem; + overflow: visible; + + .configure-modal__header { + padding-top: 1.5rem; + } + + .configure-modal__body { + overflow: visible; + } +} diff --git a/src/course-outline/configure-modal/ConfigureModal.test.jsx b/src/course-outline/configure-modal/ConfigureModal.test.jsx new file mode 100644 index 0000000000..b9a331a4eb --- /dev/null +++ b/src/course-outline/configure-modal/ConfigureModal.test.jsx @@ -0,0 +1,139 @@ +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 ConfigureModal from './ConfigureModal'; +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: 'Section1', + childInfo: { + displayName: 'Subsection', + children: [ + { + displayName: 'Subsection 1', + id: 1, + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 2', + id: 2, + childInfo: { + displayName: 'Unit', + children: [ + { + id: 21, + displayName: 'Subsection_2 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 3', + id: 3, + childInfo: { + children: [], + }, + }, + ], + }, +}; + +const onCloseMock = jest.fn(); +const onConfigureSubmitMock = 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 ConfigureModal component correctly', () => { + const { getByText, getByRole } = renderComponent(); + expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.releaseTimeUTC.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('switches to the Visibility tab and renders correctly', () => { + const { getByRole, getByText } = renderComponent(); + + const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + expect(getByText(messages.sectionVisibility.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); + }); + + it('disables the Save button and enables it if there is a change', () => { + const { getByRole, getByPlaceholderText, getByTestId } = renderComponent(); + + const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); + expect(saveButton).toBeDisabled(); + + const input = getByPlaceholderText('MM/DD/YYYY'); + fireEvent.change(input, { target: { value: '12/15/2023' } }); + + const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const checkbox = getByTestId('visibility-checkbox'); + fireEvent.click(checkbox); + expect(saveButton).not.toBeDisabled(); + }); +}); diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/course-outline/configure-modal/VisibilityTab.jsx new file mode 100644 index 0000000000..033f58018e --- /dev/null +++ b/src/course-outline/configure-modal/VisibilityTab.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Form } from '@edx/paragon'; +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +const VisibilityTab = ({ isVisibleToStaffOnly, setIsVisibleToStaffOnly, showWarning }) => { + const handleChange = (e) => { + setIsVisibleToStaffOnly(e.target.checked); + }; + + return ( + <> +

+
+ + + + {showWarning && ( + <> +
+ + + + + + )} + + ); +}; + +VisibilityTab.propTypes = { + isVisibleToStaffOnly: PropTypes.bool.isRequired, + showWarning: PropTypes.bool.isRequired, + setIsVisibleToStaffOnly: PropTypes.func.isRequired, +}; + +export default injectIntl(VisibilityTab); diff --git a/src/course-outline/configure-modal/messages.js b/src/course-outline/configure-modal/messages.js new file mode 100644 index 0000000000..3fd9f50bc8 --- /dev/null +++ b/src/course-outline/configure-modal/messages.js @@ -0,0 +1,50 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.course-outline.configure-modal.title', + defaultMessage: '{title} Settings', + }, + basicTabTitle: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.title', + defaultMessage: 'Basic', + }, + releaseDateAndTime: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time', + defaultMessage: 'Release Date and Time', + }, + releaseDate: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date', + defaultMessage: 'Release Date:', + }, + releaseTimeUTC: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC', + defaultMessage: 'Release Time in UTC:', + }, + visibilityTabTitle: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.title', + defaultMessage: 'Visibility', + }, + sectionVisibility: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility', + defaultMessage: 'Section Visibility', + }, + hideFromLearners: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.hide-from-learners', + defaultMessage: 'Hide from learners', + }, + visibilityWarning: { + id: 'course-authoring.course-outline.configure-modal.visibility-tab.visibility-warning', + defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the unit. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.', + }, + cancelButton: { + id: 'course-authoring.course-outline.configure-modal.button.cancel', + defaultMessage: 'Cancel', + }, + saveButton: { + id: 'course-authoring.course-outline.configure-modal.button.label', + defaultMessage: 'Save', + }, +}); + +export default messages; diff --git a/src/course-outline/constants.js b/src/course-outline/constants.js index cd4c58eeb4..f9a2c8821e 100644 --- a/src/course-outline/constants.js +++ b/src/course-outline/constants.js @@ -1,10 +1,27 @@ -export const CHECKLIST_FILTERS = { +export const ITEM_BADGE_STATUS = /** @type {const} */ ({ + 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 = /** @type {const} */ ({ ALL: 'ALL', SELF_PACED: 'SELF_PACED', INSTRUCTOR_PACED: 'INSTRUCTOR_PACED', -}; +}); + +export const COURSE_BLOCK_NAMES = /** @type {const} */ ({ + chapter: { id: 'chapter', name: 'Section' }, + sequential: { id: 'sequential', name: 'Subsection' }, + vertical: { id: 'vertical', name: 'Unit' }, +}); -export const LAUNCH_CHECKLIST = { +export const LAUNCH_CHECKLIST = /** @type {const} */ ({ data: [ { id: 'welcomeMessage', @@ -31,9 +48,9 @@ export const LAUNCH_CHECKLIST = { pacingTypeFilter: CHECKLIST_FILTERS.ALL, }, ], -}; +}); -export const BEST_PRACTICES_CHECKLIST = { +export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({ data: [ { id: 'videoDuration', @@ -56,4 +73,4 @@ export const BEST_PRACTICES_CHECKLIST = { pacingTypeFilter: CHECKLIST_FILTERS.ALL, }, ], -}; +}); diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 9b613bf81c..702715ce8f 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -1,3 +1,4 @@ +// @ts-check import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -24,11 +25,32 @@ export const getEnableHighlightsEmailsApiUrl = (courseId) => { }; export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`; +export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; +export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; +export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`; + +/** + * @typedef {Object} courseOutline + * @property {string} courseReleaseDate + * @property {Object} courseStructure + * @property {Object} deprecatedBlocksInfo + * @property {string} discussionsIncontextFeedbackUrl + * @property {string} discussionsIncontextLearnmoreUrl + * @property {Object} initialState + * @property {Object} initialUserClipboard + * @property {string} languageCode + * @property {string} lmsLink + * @property {string} mfeProctoredExamSettingsUrl + * @property {string} notificationDismissUrl + * @property {string[]} proctoringErrors + * @property {string} reindexLink + * @property {null} rerunNotificationId + */ /** * Get course outline index. * @param {string} courseId - * @returns {Promise} + * @returns {Promise} */ export async function getCourseOutlineIndex(courseId) { const { data } = await getAuthenticatedHttpClient() @@ -39,10 +61,8 @@ export async function getCourseOutlineIndex(courseId) { /** * Get course best practices. - * @param {string} courseId - * @param {boolean} excludeGraded - * @param {boolean} all - * @returns {Promise} + * @param {{courseId: string, excludeGraded: boolean, all: boolean}} options + * @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>} */ export async function getCourseBestPractices({ courseId, @@ -55,13 +75,21 @@ export async function getCourseBestPractices({ return camelCaseObject(data); } +/** @typedef {object} courseLaunchData + * @property {boolean} isSelfPaced + * @property {object} dates + * @property {object} assignments + * @property {object} grades + * @property {number} grades.sum_of_weights + * @property {object} certificates + * @property {object} updates + * @property {object} proctoring + */ + /** * Get course launch. - * @param {string} courseId - * @param {boolean} gradedOnly - * @param {boolean} validateOras - * @param {boolean} all - * @returns {Promise} + * @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options + * @returns {Promise} */ export async function getCourseLaunch({ courseId, @@ -105,3 +133,181 @@ export async function restartIndexingOnCourse(reindexLink) { return camelCaseObject(data); } + +/** + * @typedef {Object} section + * @property {string} id + * @property {string} displayName + * @property {string} category + * @property {boolean} hasChildren + * @property {string} editedOn + * @property {boolean} published + * @property {string} publishedOn + * @property {string} studioUrl + * @property {boolean} releasedToStudents + * @property {string} releaseDate + * @property {string} visibilityState + * @property {boolean} hasExplicitStaffLock + * @property {string} start + * @property {boolean} graded + * @property {string} dueDate + * @property {null} due + * @property {null} relativeWeeksDue + * @property {null} format + * @property {string[]} courseGraders + * @property {boolean} hasChanges + * @property {object} actions + * @property {null} explanatoryMessage + * @property {object[]} userPartitions + * @property {string} showCorrectness + * @property {string[]} highlights + * @property {boolean} highlightsEnabled + * @property {boolean} highlightsPreviewOnly + * @property {string} highlightsDocUrl + * @property {object} childInfo + * @property {boolean} ancestorHasStaffLock + * @property {boolean} staffOnlyMessage + * @property {boolean} hasPartitionGroupComponents + * @property {object} userPartitionInfo + * @property {boolean} enableCopyPasteUnits + */ + +/** + * Get course section + * @param {string} itemId + * @returns {Promise
} + */ +export async function getCourseItem(itemId) { + const { data } = await getAuthenticatedHttpClient() + .get(getXBlockApiUrl(itemId)); + 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(getCourseItemApiUrl(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(getCourseItemApiUrl(sectionId), { + publish: 'make_public', + }); + + return data; +} + +/** + * Configure course section + * @param {string} sectionId + * @returns {Promise} + */ +export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseItemApiUrl(sectionId), { + publish: 'republish', + metadata: { + // The backend expects metadata.visible_to_staff_only to either true or null + visible_to_staff_only: isVisibleToStaffOnly ? true : null, + start: startDatetime, + }, + }); + + return data; +} + +/** + * Edit course section + * @param {string} itemId + * @param {string} displayName + * @returns {Promise} + */ +export async function editItemDisplayName(itemId, displayName) { + const { data } = await getAuthenticatedHttpClient() + .post(getCourseItemApiUrl(itemId), { + metadata: { + display_name: displayName, + }, + }); + + return data; +} + +/** + * Delete course section + * @param {string} itemId + * @returns {Promise} + */ +export async function deleteCourseItem(itemId) { + const { data } = await getAuthenticatedHttpClient() + .delete(getCourseItemApiUrl(itemId)); + + return data; +} + +/** + * Duplicate course section + * @param {string} itemId + * @param {string} parentId + * @returns {Promise} + */ +export async function duplicateCourseItem(itemId, parentId) { + const { data } = await getAuthenticatedHttpClient() + .post(getXBlockBaseApiUrl(), { + duplicate_source_locator: itemId, + parent_locator: parentId, + }); + + return data; +} + +/** + * Add new course item like section, subsection or unit. + * @param {string} parentLocator + * @param {string} category + * @param {string} displayName + * @returns {Promise} + */ +export async function addNewCourseItem(parentLocator, category, displayName) { + const { data } = await getAuthenticatedHttpClient() + .post(getXBlockBaseApiUrl(), { + parent_locator: parentLocator, + category, + display_name: displayName, + }); + + return data; +} + +/** + * Set order for the list of the sections + * @param {string} courseId + * @param {Array} children list of sections id's + * @returns {Promise} +*/ +export async function setSectionOrderList(courseId, children) { + const { data } = await getAuthenticatedHttpClient() + .put(getEnableHighlightsEmailsApiUrl(courseId), { + children, + }); + + return data; +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index 096723ce5d..4e0c28375e 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -2,3 +2,7 @@ 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 getCurrentItem = (state) => state.courseOutline.currentItem; +export const getCurrentSection = (state) => state.courseOutline.currentSection; +export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index ea08b47026..bd408c958e 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,15 @@ const slice = createSlice({ completedCourseBestPracticesChecks: 0, }, }, + sectionsList: [], + currentSection: {}, + currentSubsection: {}, + currentItem: {}, }, reducers: { fetchOutlineIndexSuccess: (state, { payload }) => { state.outlineIndexData = payload; + state.sectionsList = payload.courseStructure?.childInfo?.children || []; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { state.loadingStatus = { @@ -41,6 +46,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,17 +70,87 @@ 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)); + }, + setCurrentItem: (state, { payload }) => { + state.currentItem = payload; + }, + reorderSectionList: (state, { payload }) => { + const sectionsList = [...state.sectionsList]; + sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id)); + + state.sectionsList = [...sectionsList]; + }, + setCurrentSection: (state, { payload }) => { + state.currentSection = payload; + }, + setCurrentSubsection: (state, { payload }) => { + state.currentSubsection = payload; + }, + addSection: (state, { payload }) => { + state.sectionsList = [ + ...state.sectionsList, + payload, + ]; + }, + addSubsection: (state, { payload }) => { + state.sectionsList = state.sectionsList.map((section) => { + if (section.id === payload.parentLocator) { + section.childInfo.children = [ + ...section.childInfo.children, + payload.data, + ]; + } + return section; + }); + }, + deleteSection: (state, { payload }) => { + state.sectionsList = state.sectionsList.filter( + ({ id }) => id !== payload.itemId, + ); + }, + deleteSubsection: (state, { payload }) => { + state.sectionsList = state.sectionsList.map((section) => { + if (section.id !== payload.sectionId) { + return section; + } + section.childInfo.children = section.childInfo.children.filter( + ({ id }) => id !== payload.itemId, + ); + return section; + }); + }, + duplicateSection: (state, { payload }) => { + state.sectionsList = state.sectionsList.reduce((result, currentValue) => { + if (currentValue.id === payload.id) { + return [...result, currentValue, payload.duplicatedItem]; + } + return [...result, currentValue]; + }, []); + }, }, }); export const { + addSection, + addSubsection, fetchOutlineIndexSuccess, updateOutlineIndexLoadingStatus, updateReindexLoadingStatus, updateStatusBar, fetchStatusBarChecklistSuccess, fetchStatusBarSelPacedSuccess, + updateFetchSectionLoadingStatus, updateSavingStatus, + updateSectionList, + setCurrentItem, + setCurrentSection, + setCurrentSubsection, + deleteSection, + deleteSubsection, + duplicateSection, + reorderSectionList, } = slice.actions; export const { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 194ae03d79..50909f5a90 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -1,16 +1,33 @@ import { RequestStatus } from '../../data/constants'; +import { NOTIFICATION_MESSAGES } from '../../constants'; +import { COURSE_BLOCK_NAMES } from '../constants'; +import { + hideProcessingNotification, + showProcessingNotification, +} from '../../generic/processing-notification/data/slice'; import { getCourseBestPracticesChecklist, getCourseLaunchChecklist, } from '../utils/getChecklistForStatusBar'; import { + addNewCourseItem, + deleteCourseItem, + duplicateCourseItem, + editItemDisplayName, enableCourseHighlightsEmails, getCourseBestPractices, getCourseLaunch, getCourseOutlineIndex, + getCourseItem, + publishCourseSection, + configureCourseSection, restartIndexingOnCourse, + updateCourseSectionHighlights, + setSectionOrderList, } from './api'; import { + addSection, + addSubsection, fetchOutlineIndexSuccess, updateOutlineIndexLoadingStatus, updateReindexLoadingStatus, @@ -18,6 +35,12 @@ import { fetchStatusBarChecklistSuccess, fetchStatusBarSelPacedSuccess, updateSavingStatus, + updateSectionList, + updateFetchSectionLoadingStatus, + deleteSection, + deleteSubsection, + duplicateSection, + reorderSectionList, } from './slice'; export function fetchCourseOutlineIndexQuery(courseId) { @@ -26,9 +49,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 +101,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 +127,269 @@ export function fetchCourseReindexQuery(courseId, reindexLink) { } }; } + +export function fetchCourseSectionQuery(sectionId, shouldScroll = false) { + return async (dispatch) => { + dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const data = await getCourseItem(sectionId); + data.shouldScroll = shouldScroll; + 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 publishCourseItemQuery(itemId, sectionId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await publishCourseSection(itemId).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 configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime).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 editCourseItemQuery(itemId, sectionId, displayName) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await editItemDisplayName(itemId, 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 })); + } + }; +} + +/** + * Generic function to delete course item, see below wrapper funcs for specific implementations. + * @param {string} itemId + * @param {() => {}} deleteItemFn + * @returns {} + */ +function deleteCourseItemQuery(itemId, deleteItemFn) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + + try { + await deleteCourseItem(itemId); + dispatch(deleteItemFn()); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function deleteCourseSectionQuery(sectionId) { + return async (dispatch) => { + dispatch(deleteCourseItemQuery( + sectionId, + () => deleteSection({ itemId: sectionId }), + )); + }; +} + +export function deleteCourseSubsectionQuery(subsectionId, sectionId) { + return async (dispatch) => { + dispatch(deleteCourseItemQuery( + subsectionId, + () => deleteSubsection({ itemId: subsectionId, sectionId }), + )); + }; +} + +/** + * Generic function to duplicate any course item. See wrapper functions below for specific implementations. + * @param {string} itemId + * @param {string} parentLocator + * @param {(locator) => Promise} duplicateFn + * @returns {} + */ +function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await duplicateCourseItem(itemId, parentLocator).then(async (result) => { + if (result) { + await duplicateFn(result.locator); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function duplicateSectionQuery(sectionId, courseBlockId) { + return async (dispatch) => { + dispatch(duplicateCourseItemQuery( + sectionId, + courseBlockId, + async (locator) => { + const duplicatedItem = await getCourseItem(locator); + // Page should scroll to newly duplicated item. + duplicatedItem.shouldScroll = true; + dispatch(duplicateSection({ id: sectionId, duplicatedItem })); + }, + )); + }; +} + +export function duplicateSubsectionQuery(subsectionId, sectionId) { + return async (dispatch) => { + dispatch(duplicateCourseItemQuery( + subsectionId, + sectionId, + async () => dispatch(fetchCourseSectionQuery(sectionId, true)), + )); + }; +} + +/** + * Generic function to add any course item. See wrapper functions below for specific implementations. + * @param {string} parentLocator + * @param {string} category + * @param {string} displayName + * @param {(data) => {}} addItemFn + * @returns {} + */ +function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await addNewCourseItem( + parentLocator, + category, + displayName, + ).then(async (result) => { + if (result) { + const data = await getCourseItem(result.locator); + // Page should scroll to newly created item. + data.shouldScroll = true; + dispatch(addItemFn(data)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function addNewSectionQuery(parentLocator) { + return async (dispatch) => { + dispatch(addNewCourseItemQuery( + parentLocator, + COURSE_BLOCK_NAMES.chapter.id, + COURSE_BLOCK_NAMES.chapter.name, + (data) => addSection(data), + )); + }; +} + +export function addNewSubsectionQuery(parentLocator) { + return async (dispatch) => { + dispatch(addNewCourseItemQuery( + parentLocator, + COURSE_BLOCK_NAMES.sequential.id, + COURSE_BLOCK_NAMES.sequential.name, + (data) => addSubsection({ parentLocator, data }), + )); + }; +} + +export function setSectionOrderListQuery(courseId, newListId, restoreCallback) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + await setSectionOrderList(courseId, newListId).then(async (result) => { + if (result) { + dispatch(reorderSectionList(newListId)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } + }); + } catch (error) { + restoreCallback(); + 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..4569ac3248 --- /dev/null +++ b/src/course-outline/delete-modal/DeleteModal.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + ActionRow, + Button, + AlertModal, +} from '@edx/paragon'; +import { useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { COURSE_BLOCK_NAMES } from '../constants'; +import { getCurrentItem } from '../data/selectors'; +import messages from './messages'; + +const DeleteModal = ({ isOpen, close, onDeleteSubmit }) => { + const intl = useIntl(); + let { category } = useSelector(getCurrentItem); + category = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); + + return ( + + + + + )} + > +

{intl.formatMessage(messages.description, { category })}

+
+ ); +}; + +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..e03e4a4b8b --- /dev/null +++ b/src/course-outline/delete-modal/DeleteModal.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { useSelector } from 'react-redux'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import initializeStore from '../../store'; +import DeleteModal from './DeleteModal'; +import messages from './messages'; + +// eslint-disable-next-line no-unused-vars +let axiosMock; +let store; + +const onDeleteSubmitMock = jest.fn(); +const closeMock = jest.fn(); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +const currentItemMock = { + displayName: 'Delete', +}; + +const renderComponent = (props) => render( + + + + , + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + useSelector.mockReturnValue(currentItemMock); + }); + + it('render DeleteModal component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + 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..748120551e --- /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 {category}?', + }, + description: { + id: 'course-authoring.course-outline.delete-modal.description', + defaultMessage: 'Deleting this {category} is permanent and cannot be undone.', + }, + deleteButton: { + id: 'course-authoring.course-outline.delete-modal.button.delete', + defaultMessage: 'Delete', + }, + cancelButton: { + id: 'course-authoring.course-outline.delete-modal.button.cancel', + defaultMessage: 'Cancel', + }, +}); + +export default messages; diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx new file mode 100644 index 0000000000..746dabed8c --- /dev/null +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx @@ -0,0 +1,40 @@ +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/EmptyPlaceholder.test.jsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx new file mode 100644 index 0000000000..f76a1178c7 --- /dev/null +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.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/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..4566ee1701 100644 --- a/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx +++ b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx @@ -1,28 +1,64 @@ 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, + }), +})); + +jest.mock('../../help-urls/hooks', () => ({ + useHelpUrls: () => ({ + contentHighlights: 'some', + }), +})); + 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 +67,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..5315085c3e 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 { @@ -49,6 +50,7 @@ const HeaderNavigations = ({ > )} - + {hasSections && ( + + )} render( isSectionsExpanded={false} isDisabledReindexButton={false} isReIndexShow + hasSections {...props} /> , @@ -78,4 +80,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..8d1a73ec20 --- /dev/null +++ b/src/course-outline/highlights-modal/HighlightsModal.jsx @@ -0,0 +1,95 @@ +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..0a767c2a60 --- /dev/null +++ b/src/course-outline/highlights-modal/HighlightsModal.test.jsx @@ -0,0 +1,127 @@ +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, + }), +})); + +jest.mock('../../help-urls/hooks', () => ({ + useHelpUrls: () => ({ + contentHighlights: 'some', + }), +})); + +const currentItemMock = { + 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(currentItemMock); + }); + + it('renders HighlightsModal component correctly', () => { + const { getByText, getByRole, getByLabelText } = renderComponent(); + + expect(getByText(`Highlights for ${currentItemMock.displayName}`)).toBeInTheDocument(); + expect(getByText(/Enter 3-5 highlights to include in the email message that learners receive for this section/i)).toBeInTheDocument(); + expect(getByText(/For more information and an example of the email template, read our/i)).toBeInTheDocument(); + expect(getByText(messages.documentationLink.defaultMessage)).toBeInTheDocument(); + 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..e8c083a9dc --- /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', + }, +}); + +export default messages; diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index f96b84641c..ec361ce6ce 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -3,39 +3,73 @@ import { useDispatch, useSelector } from 'react-redux'; import { useToggle } from '@edx/paragon'; import { RequestStatus } from '../data/constants'; -import { updateSavingStatus } from './data/slice'; +import { COURSE_BLOCK_NAMES } from './constants'; +import { + setCurrentItem, + setCurrentSection, + updateSavingStatus, +} from './data/slice'; import { getLoadingStatus, getOutlineIndexData, getSavingStatus, getStatusBarData, + getSectionsList, + getCurrentItem, + getCurrentSection, + getCurrentSubsection, } from './data/selectors'; import { + addNewSectionQuery, + addNewSubsectionQuery, + deleteCourseSectionQuery, + deleteCourseSubsectionQuery, + editCourseItemQuery, + duplicateSectionQuery, + duplicateSubsectionQuery, enableCourseHighlightsEmailsQuery, fetchCourseBestPracticesQuery, fetchCourseLaunchQuery, fetchCourseOutlineIndexQuery, fetchCourseReindexQuery, + publishCourseItemQuery, + updateCourseSectionHighlightsQuery, + configureCourseSectionQuery, + setSectionOrderListQuery, } from './data/thunk'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); - const { reindexLink, lmsLink } = useSelector(getOutlineIndexData); + const { reindexLink, courseStructure, lmsLink } = useSelector(getOutlineIndexData); const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); const statusBarData = useSelector(getStatusBarData); const savingStatus = useSelector(getSavingStatus); + const sectionsList = useSelector(getSectionsList); + const currentItem = useSelector(getCurrentItem); + const currentSection = useSelector(getCurrentSection); + const currentSubsection = useSelector(getCurrentSubsection); const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false); - const [isSectionsExpanded, setSectionsExpanded] = useState(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 [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + + const handleNewSectionSubmit = () => { + dispatch(addNewSectionQuery(courseStructure.id)); + }; + + const handleNewSubsectionSubmit = (sectionId) => { + dispatch(addNewSubsectionQuery(sectionId)); + }; const headerNavigationsActions = { - handleNewSection: () => { - // TODO add handler - }, + handleNewSection: handleNewSectionSubmit, handleReIndex: () => { setDisableReindexButton(true); setShowSuccessAlert(false); @@ -60,6 +94,64 @@ const useCourseOutline = ({ courseId }) => { dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); }; + const handleOpenHighlightsModal = (section) => { + dispatch(setCurrentItem(section)); + dispatch(setCurrentSection(section)); + openHighlightsModal(); + }; + + const handleHighlightsFormSubmit = (highlights) => { + const dataToSend = Object.values(highlights).filter(Boolean); + dispatch(updateCourseSectionHighlightsQuery(currentItem.id, dataToSend)); + + closeHighlightsModal(); + }; + + const handlePublishItemSubmit = () => { + dispatch(publishCourseItemQuery(currentItem.id, currentSection.id)); + + closePublishModal(); + }; + + const handleConfigureSectionSubmit = (isVisibleToStaffOnly, startDatetime) => { + dispatch(configureCourseSectionQuery(currentSection.id, isVisibleToStaffOnly, startDatetime)); + + closeConfigureModal(); + }; + + const handleEditSubmit = (itemId, sectionId, displayName) => { + dispatch(editCourseItemQuery(itemId, sectionId, displayName)); + }; + + const handleDeleteItemSubmit = () => { + switch (currentItem.category) { + case COURSE_BLOCK_NAMES.chapter.id: + dispatch(deleteCourseSectionQuery(currentItem.id)); + break; + case COURSE_BLOCK_NAMES.sequential.id: + dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id)); + break; + case COURSE_BLOCK_NAMES.vertical.id: + // delete unit + break; + default: + return; + } + closeDeleteModal(); + }; + + const handleDuplicateSectionSubmit = () => { + dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id)); + }; + + const handleDuplicateSubsectionSubmit = () => { + dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id)); + }; + + const handleDragNDrop = (newListId, restoreCallback) => { + dispatch(setSectionOrderListQuery(courseId, newListId, restoreCallback)); + }; + useEffect(() => { dispatch(fetchCourseOutlineIndexQuery(courseId)); dispatch(fetchCourseBestPracticesQuery({ courseId })); @@ -78,20 +170,44 @@ const useCourseOutline = ({ courseId }) => { return { savingStatus, + sectionsList, isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS, isReIndexShow: Boolean(reindexLink), showSuccessAlert, showErrorAlert, isDisabledReindexButton, isSectionsExpanded, + isPublishModalOpen, + openPublishModal, + closePublishModal, + isConfigureModalOpen, + openConfigureModal, + closeConfigureModal, headerNavigationsActions, handleEnableHighlightsSubmit, + handleHighlightsFormSubmit, + handleConfigureSectionSubmit, + handlePublishItemSubmit, + handleEditSubmit, statusBarData, isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal, isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, handleInternetConnectionFailed, + handleOpenHighlightsModal, + isHighlightsModalOpen, + closeHighlightsModal, + courseName: courseStructure?.displayName, + isDeleteModalOpen, + closeDeleteModal, + openDeleteModal, + handleDeleteItemSubmit, + handleDuplicateSectionSubmit, + handleDuplicateSubsectionSubmit, + handleNewSectionSubmit, + handleNewSubsectionSubmit, + handleDragNDrop, }; }; diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index 387b7f8ded..b1ff8cde6e 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.', }, + newSectionButton: { + id: 'course-authoring.course-outline.section-list.button.new-section', + defaultMessage: 'New section', + }, + alertFailedGeneric: { + id: 'course-authoring.course-outline.general.alert.error.description', + defaultMessage: 'Unable to {actionName} {type}. Please try again.', + }, }); 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..d14057370f --- /dev/null +++ b/src/course-outline/publish-modal/PublishModal.jsx @@ -0,0 +1,88 @@ +/* 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 { getCurrentItem } from '../data/selectors'; +import messages from './messages'; + +const PublishModal = ({ + isOpen, + onClose, + onPublishSubmit, +}) => { + const intl = useIntl(); + const { displayName, childInfo, category } = useSelector(getCurrentItem); + const children = childInfo?.children || []; + + return ( + + + + {intl.formatMessage(messages.title, { title: displayName })} + + + +

{intl.formatMessage(messages.description, { category })}

+ {children.filter(child => child.hasChanges).map((child) => { + let grandChildren = child.childInfo?.children || []; + grandChildren = grandChildren.filter(grandChild => grandChild.hasChanges); + + return grandChildren.length ? ( + + {child.displayName} + {grandChildren.map((grandChild) => ( +
+ {grandChild.displayName} +
+ ))} +
+ ) : ( +
+ {child.displayName} +
+ ); + })} +
+ + + + {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..9c47a84c71 --- /dev/null +++ b/src/course-outline/publish-modal/PublishModal.test.jsx @@ -0,0 +1,136 @@ +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; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +const currentItemMock = { + displayName: 'Publish', + childInfo: { + displayName: 'Subsection', + children: [ + { + displayName: 'Subsection 1', + id: 1, + hasChanges: true, + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + hasChanges: true, + }, + ], + }, + }, + { + displayName: 'Subsection 2', + id: 2, + hasChanges: true, + childInfo: { + displayName: 'Unit', + children: [ + { + id: 21, + displayName: 'Subsection_2 Unit 1', + hasChanges: true, + }, + ], + }, + }, + { + displayName: 'Subsection 3', + id: 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(currentItemMock); + }); + + it('renders PublishModal component correctly', () => { + const { getByText, getByRole, queryByText } = renderComponent(); + + expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.description.defaultMessage)).toBeInTheDocument(); + expect(getByText(/Subsection 1/i)).toBeInTheDocument(); + expect(getByText(/Subsection_1 Unit 1/i)).toBeInTheDocument(); + 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..7ea14a73b7 --- /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 {category}?', + }, + 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..8b6730d37b --- /dev/null +++ b/src/course-outline/section-card/SectionCard.jsx @@ -0,0 +1,187 @@ +import React, { + useEffect, useState, useRef, +} 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 { setCurrentItem, setCurrentSection } from '../data/slice'; +import { RequestStatus } from '../../data/constants'; +import CardHeader from '../card-header/CardHeader'; +import { getItemStatus, scrollToElement } from '../utils'; +import messages from './messages'; + +const SectionCard = ({ + section, + children, + onOpenHighlightsModal, + onOpenPublishModal, + onOpenConfigureModal, + onEditSectionSubmit, + savingStatus, + onOpenDeleteModal, + onDuplicateSubmit, + isSectionsExpanded, + onNewSubsectionSubmit, +}) => { + const currentRef = useRef(null); + const intl = useIntl(); + const dispatch = useDispatch(); + const [isExpanded, setIsExpanded] = useState(isSectionsExpanded); + const [isFormOpen, openForm, closeForm] = useToggle(false); + + useEffect(() => { + setIsExpanded(isSectionsExpanded); + }, [isSectionsExpanded]); + + useEffect(() => { + // if this items has been newly added, scroll to it. + if (currentRef.current && section.shouldScroll) { + scrollToElement(currentRef.current); + } + }, []); + + const { + id, + displayName, + hasChanges, + published, + releasedToStudents, + visibleToStaffOnly = false, + visibilityState, + staffOnlyMessage, + highlights, + } = section; + + const sectionStatus = getItemStatus({ + published, + releasedToStudents, + visibleToStaffOnly, + visibilityState, + staffOnlyMessage, + }); + + const handleExpandContent = () => { + setIsExpanded((prevState) => !prevState); + }; + + const handleClickMenuButton = () => { + dispatch(setCurrentItem(section)); + dispatch(setCurrentSection(section)); + }; + + const handleEditSubmit = (titleValue) => { + if (displayName !== titleValue) { + // both itemId and sectionId are same + onEditSectionSubmit(id, id, titleValue); + return; + } + + closeForm(); + }; + + const handleOpenHighlightsModal = () => { + onOpenHighlightsModal(section); + }; + + const handleNewSubsectionSubmit = () => { + onNewSubsectionSubmit(id); + }; + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + closeForm(); + } + }, [savingStatus]); + + return ( +
+
+ +
+
+ +
+
+ {isExpanded && ( +
+ {children} + +
+ )} +
+
+ ); +}; + +SectionCard.defaultProps = { + children: null, +}; + +SectionCard.propTypes = { + section: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + published: PropTypes.bool.isRequired, + hasChanges: PropTypes.bool.isRequired, + releasedToStudents: PropTypes.bool.isRequired, + visibleToStaffOnly: PropTypes.bool, + visibilityState: PropTypes.string.isRequired, + staffOnlyMessage: PropTypes.bool.isRequired, + highlights: PropTypes.arrayOf(PropTypes.string).isRequired, + shouldScroll: PropTypes.bool, + }).isRequired, + children: PropTypes.node, + onOpenHighlightsModal: PropTypes.func.isRequired, + onOpenPublishModal: PropTypes.func.isRequired, + onOpenConfigureModal: PropTypes.func.isRequired, + onEditSectionSubmit: PropTypes.func.isRequired, + savingStatus: PropTypes.string.isRequired, + onOpenDeleteModal: PropTypes.func.isRequired, + onDuplicateSubmit: PropTypes.func.isRequired, + isSectionsExpanded: PropTypes.bool.isRequired, + onNewSubsectionSubmit: PropTypes.func.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..f386297a11 --- /dev/null +++ b/src/course-outline/section-card/SectionCard.scss @@ -0,0 +1,39 @@ +.section-card { + flex-grow: 1; + + .section-card__subsections { + margin-top: $spacer; + margin-right: -2.75rem; + } + + .section-card-title { + font-size: $h3-font-size; + font-family: $headings-font-family; + font-weight: $headings-font-weight; + line-height: $headings-line-height; + color: $headings-color; + } + + .section-card__highlights { + display: flex; + align-items: center; + 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..d391e48ffc --- /dev/null +++ b/src/course-outline/section-card/SectionCard.test.jsx @@ -0,0 +1,151 @@ +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'; +import cardHeaderMessages from '../card-header/messages'; + +// 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, + hasChanges: false, + highlights: ['highlight 1', 'highlight 2'], +}; + +const onEditSectionSubmit = jest.fn(); + +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(); + }); + + it('title only updates if changed', async () => { + const { findByTestId } = renderComponent(); + + let editButton = await findByTestId('section-edit-button'); + fireEvent.click(editButton); + let editField = await findByTestId('section-edit-field'); + fireEvent.blur(editField); + + expect(onEditSectionSubmit).not.toHaveBeenCalled(); + + editButton = await findByTestId('section-edit-button'); + fireEvent.click(editButton); + editField = await findByTestId('section-edit-field'); + fireEvent.change(editField, { target: { value: 'some random value' } }); + fireEvent.blur(editField); + expect(onEditSectionSubmit).toHaveBeenCalled(); + }); + + it('renders live status', async () => { + const { findByText } = renderComponent(); + expect(await findByText(cardHeaderMessages.statusBadgeLive.defaultMessage)).toBeInTheDocument(); + }); + + it('renders published but live status', async () => { + const { findByText } = renderComponent({ + section: { + ...section, + published: true, + releasedToStudents: false, + visibleToStaffOnly: false, + visibilityState: 'visible', + staffOnlyMessage: false, + }, + }); + expect(await findByText(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument(); + }); + + it('renders staff status', async () => { + const { findByText } = renderComponent({ + section: { + ...section, + published: false, + releasedToStudents: false, + visibleToStaffOnly: true, + visibilityState: 'staff_only', + staffOnlyMessage: true, + }, + }); + expect(await findByText(cardHeaderMessages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument(); + }); + + it('renders draft status', async () => { + const { findByText } = renderComponent({ + section: { + ...section, + published: false, + releasedToStudents: false, + visibleToStaffOnly: false, + visibilityState: 'staff_only', + staffOnlyMessage: false, + }, + }); + expect(await findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).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..4730c31a3b --- /dev/null +++ b/src/course-outline/section-card/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + newSubsectionButton: { + id: 'course-authoring.course-outline.section.button.new-subsection', + defaultMessage: 'New subsection', + }, + sectionHighlightsBadge: { + id: 'course-authoring.course-outline.section.badge.section-highlights', + defaultMessage: 'Section highlights', + }, +}); + +export default messages; diff --git a/src/course-outline/status-bar/StatusBar.jsx b/src/course-outline/status-bar/StatusBar.jsx index 62d28f0430..3e08d5b414 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; @@ -31,8 +31,12 @@ const StatusBar = ({ } = checklist; 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 checklistDestination = () => new URL(`checklists/${courseId}`, config.STUDIO_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 @@ -45,7 +49,7 @@ const StatusBar = ({
{intl.formatMessage(messages.startDateTitle)}
{courseReleaseDate} @@ -63,7 +67,7 @@ const StatusBar = ({
{intl.formatMessage(messages.checklistTitle)}
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)} @@ -77,13 +81,13 @@ const StatusBar = ({ {intl.formatMessage(messages.highlightEmailsEnabled)} ) : ( - )} @@ -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/status-bar/StatusBar.test.jsx b/src/course-outline/status-bar/StatusBar.test.jsx index 64cd617a5e..9745a5c492 100644 --- a/src/course-outline/status-bar/StatusBar.test.jsx +++ b/src/course-outline/status-bar/StatusBar.test.jsx @@ -21,6 +21,12 @@ jest.mock('react-router-dom', () => ({ }), })); +jest.mock('../../help-urls/hooks', () => ({ + useHelpUrls: () => ({ + contentHighlights: 'some', + }), +})); + const statusBarData = { courseReleaseDate: 'Feb 05, 2013 at 05:00 UTC', isSelfPaced: true, diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx new file mode 100644 index 0000000000..2dbf1e5e01 --- /dev/null +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -0,0 +1,157 @@ +import { useEffect, useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, useToggle } from '@edx/paragon'; +import { Add as IconAdd } from '@edx/paragon/icons'; + +import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; +import { RequestStatus } from '../../data/constants'; +import CardHeader from '../card-header/CardHeader'; +import { getItemStatus, scrollToElement } from '../utils'; +import messages from './messages'; + +const SubsectionCard = ({ + section, + subsection, + children, + onOpenPublishModal, + onEditSubmit, + savingStatus, + onOpenDeleteModal, + onDuplicateSubmit, +}) => { + const currentRef = useRef(null); + const intl = useIntl(); + const dispatch = useDispatch(); + const [isExpanded, setIsExpanded] = useState(false); + const [isFormOpen, openForm, closeForm] = useToggle(false); + + const { + id, + displayName, + hasChanges, + published, + releasedToStudents, + visibleToStaffOnly = false, + visibilityState, + staffOnlyMessage, + } = subsection; + + const subsectionStatus = getItemStatus({ + published, + releasedToStudents, + visibleToStaffOnly, + visibilityState, + staffOnlyMessage, + }); + + const handleExpandContent = () => { + setIsExpanded((prevState) => !prevState); + }; + + const handleClickMenuButton = () => { + dispatch(setCurrentSection(section)); + dispatch(setCurrentSubsection(subsection)); + dispatch(setCurrentItem(subsection)); + }; + + const handleEditSubmit = (titleValue) => { + if (displayName !== titleValue) { + onEditSubmit(id, section.id, titleValue); + return; + } + + closeForm(); + }; + + useEffect(() => { + // if this items has been newly added, scroll to it. + // we need to check section.shouldScroll as whole section is fetched when a + // subsection is duplicated under it. + if (currentRef.current && (section.shouldScroll || subsection.shouldScroll)) { + scrollToElement(currentRef.current); + } + }, []); + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + closeForm(); + } + }, [savingStatus]); + + return ( +
+ + {isExpanded && ( + <> +
+ {children} +
+ + + )} +
+ ); +}; + +SubsectionCard.defaultProps = { + children: null, +}; + +SubsectionCard.propTypes = { + section: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + published: PropTypes.bool.isRequired, + hasChanges: PropTypes.bool.isRequired, + releasedToStudents: PropTypes.bool.isRequired, + visibleToStaffOnly: PropTypes.bool, + visibilityState: PropTypes.string.isRequired, + staffOnlyMessage: PropTypes.bool.isRequired, + shouldScroll: PropTypes.bool, + }).isRequired, + subsection: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + published: PropTypes.bool.isRequired, + hasChanges: PropTypes.bool.isRequired, + releasedToStudents: PropTypes.bool.isRequired, + visibleToStaffOnly: PropTypes.bool, + visibilityState: PropTypes.string.isRequired, + staffOnlyMessage: PropTypes.bool.isRequired, + shouldScroll: PropTypes.bool, + }).isRequired, + children: PropTypes.node, + onOpenPublishModal: PropTypes.func.isRequired, + onEditSubmit: PropTypes.func.isRequired, + savingStatus: PropTypes.string.isRequired, + onOpenDeleteModal: PropTypes.func.isRequired, + onDuplicateSubmit: PropTypes.func.isRequired, +}; + +export default SubsectionCard; diff --git a/src/course-outline/subsection-card/SubsectionCard.scss b/src/course-outline/subsection-card/SubsectionCard.scss new file mode 100644 index 0000000000..c9c0afc74e --- /dev/null +++ b/src/course-outline/subsection-card/SubsectionCard.scss @@ -0,0 +1,23 @@ +.subsection-card { + @include pgn-box-shadow(1, "centered"); + + padding: $spacer 2rem; + margin-bottom: 1.5rem; + background: $light-200; + + .subsection-card__content { + margin: $spacer; + } + + .item-card-header__badge-status { + background: $light-100; + } + + .subsection-card-title { + font-size: $h4-font-size; + font-family: $headings-font-family; + font-weight: $headings-font-weight; + line-height: $headings-line-height; + color: $headings-color; + } +} diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx new file mode 100644 index 0000000000..080ae40da0 --- /dev/null +++ b/src/course-outline/subsection-card/SubsectionCard.test.jsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import initializeStore from '../../store'; +import SubsectionCard from './SubsectionCard'; + +// eslint-disable-next-line no-unused-vars +let axiosMock; +let store; + +const section = { + id: '123', + displayName: 'Section Name', + published: true, + releasedToStudents: true, + visibleToStaffOnly: false, + visibilityState: 'visible', + staffOnlyMessage: false, + hasChanges: false, + highlights: ['highlight 1', 'highlight 2'], +}; + +const subsection = { + id: '123', + displayName: 'Subsection Name', + published: true, + releasedToStudents: true, + visibleToStaffOnly: false, + visibilityState: 'visible', + staffOnlyMessage: false, + hasChanges: false, +}; + +const onEditSubectionSubmit = jest.fn(); + +const renderComponent = (props) => render( + + + + children + + , + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('render SubsectionCard component correctly', () => { + const { getByTestId } = renderComponent(); + + expect(getByTestId('subsection-card-header')).toBeInTheDocument(); + }); + + it('expands/collapses the card when the subsection button is clicked', async () => { + const { queryByTestId, findByTestId } = renderComponent(); + + const expandButton = await findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandButton); + expect(queryByTestId('subsection-card__units')).toBeInTheDocument(); + expect(queryByTestId('new-unit-button')).toBeInTheDocument(); + + fireEvent.click(expandButton); + expect(queryByTestId('subsection-card__units')).not.toBeInTheDocument(); + expect(queryByTestId('new-unit-button')).not.toBeInTheDocument(); + }); + + it('updates current section, subsection and item', async () => { + const { findByTestId } = renderComponent(); + + const menu = await findByTestId('subsection-card-header__menu'); + fireEvent.click(menu); + const { currentSection, currentSubsection, currentItem } = store.getState().courseOutline; + expect(currentSection).toEqual(section); + expect(currentSubsection).toEqual(subsection); + expect(currentItem).toEqual(subsection); + }); + + it('title only updates if changed', async () => { + const { findByTestId } = renderComponent(); + + let editButton = await findByTestId('subsection-edit-button'); + fireEvent.click(editButton); + let editField = await findByTestId('subsection-edit-field'); + fireEvent.blur(editField); + + expect(onEditSubectionSubmit).not.toHaveBeenCalled(); + + editButton = await findByTestId('subsection-edit-button'); + fireEvent.click(editButton); + editField = await findByTestId('subsection-edit-field'); + fireEvent.change(editField, { target: { value: 'some random value' } }); + fireEvent.keyDown(editField, { key: 'Enter', keyCode: 13 }); + expect(onEditSubectionSubmit).toHaveBeenCalled(); + }); +}); diff --git a/src/course-outline/subsection-card/messages.js b/src/course-outline/subsection-card/messages.js new file mode 100644 index 0000000000..90ca407b1b --- /dev/null +++ b/src/course-outline/subsection-card/messages.js @@ -0,0 +1,10 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + newUnitButton: { + id: 'course-authoring.course-outline.subsection.button.new-unit', + defaultMessage: 'New unit', + }, +}); + +export default messages; diff --git a/src/course-outline/utils.jsx b/src/course-outline/utils.jsx new file mode 100644 index 0000000000..7853eb3109 --- /dev/null +++ b/src/course-outline/utils.jsx @@ -0,0 +1,136 @@ +import { + CheckCircle as CheckCircleIcon, + Lock as LockIcon, + EditOutline as EditOutlineIcon, +} from '@edx/paragon/icons'; + +import { ITEM_BADGE_STATUS, 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 {ITEM_BADGE_STATUS[keyof ITEM_BADGE_STATUS]} + */ +const getItemStatus = ({ + published, + releasedToStudents, + visibleToStaffOnly, + visibilityState, + staffOnlyMessage, +}) => { + switch (true) { + case published && releasedToStudents: + return ITEM_BADGE_STATUS.live; + case published && !releasedToStudents: + return ITEM_BADGE_STATUS.publishedNotLive; + case visibleToStaffOnly && staffOnlyMessage && visibilityState === STAFF_ONLY: + return ITEM_BADGE_STATUS.staffOnly; + case !published: + return ITEM_BADGE_STATUS.draft; + default: + return ''; + } +}; + +/** + * Get section badge status content + * @param {string} status - value from on getItemStatus util + * @returns { + * badgeTitle: string, + * badgeIcon: node, + * } + */ +const getItemStatusBadgeContent = (status, messages, intl) => { + switch (status) { + case ITEM_BADGE_STATUS.live: + return { + badgeTitle: intl.formatMessage(messages.statusBadgeLive), + badgeIcon: CheckCircleIcon, + }; + case ITEM_BADGE_STATUS.publishedNotLive: + return { + badgeTitle: intl.formatMessage(messages.statusBadgePublishedNotLive), + badgeIcon: '', + }; + case ITEM_BADGE_STATUS.staffOnly: + return { + badgeTitle: intl.formatMessage(messages.statusBadgeStaffOnly), + badgeIcon: LockIcon, + }; + case ITEM_BADGE_STATUS.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; +}; + +/** + * Method to scroll into view port, if it's outside the viewport + * + * @param {Object} target - DOM Element + * @returns {undefined} + */ +const scrollToElement = target => { + if (target.getBoundingClientRect().bottom > window.innerHeight) { + // The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor. + target.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); + } + + // Target is outside the view from the top + if (target.getBoundingClientRect().top < 0) { + // The top of the target will be aligned to the top of the visible area of the scrollable ancestor + target.scrollIntoView({ behavior: 'smooth' }); + } +}; + +export { + getItemStatus, + getItemStatusBadgeContent, + getHighlightsFormValues, + scrollToElement, +}; 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/data/constants.js b/src/data/constants.js index d379233ac7..5191ea1dfa 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -41,3 +41,8 @@ export const DivisionSchemes = { NONE: 'none', COHORT: 'cohort', }; + +export const VisibilityTypes = { + STAFF_ONLY: 'staff_only', + HIDE_AFTER_DUE: 'hide_after_due', +}; diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx index a8dfa2c98e..3e1742e26d 100644 --- a/src/generic/course-upload-image/index.jsx +++ b/src/generic/course-upload-image/index.jsx @@ -31,8 +31,8 @@ const CourseUploadImage = ({ }) => { const { courseId } = useParams(); const intl = useIntl(); - const imageAbsolutePath = new URL(assetImagePath, getConfig().LMS_BASE_URL); - const assetsUrl = new URL(`/assets/${courseId}`, getConfig().STUDIO_BASE_URL); + const imageAbsolutePath = () => new URL(assetImagePath, getConfig().LMS_BASE_URL); + const assetsUrl = () => new URL(`/assets/${courseId}`, getConfig().STUDIO_BASE_URL); const handleChangeImageAsset = (path) => { const assetPath = _.last(path.split('/')); @@ -59,7 +59,7 @@ const CourseUploadImage = ({ const inputComponent = assetImagePath ? (
{intl.formatMessage(messages.uploadImageDropzoneAlt)} @@ -88,7 +88,7 @@ const CourseUploadImage = ({ values={{ hyperlink: ( diff --git a/src/generic/processing-notification/data/slice.js b/src/generic/processing-notification/data/slice.js index 4090524a9d..03e4e243f8 100644 --- a/src/generic/processing-notification/data/slice.js +++ b/src/generic/processing-notification/data/slice.js @@ -1,9 +1,11 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +import { NOTIFICATION_MESSAGES } from '../../../constants'; + const initialState = { isShow: false, - title: '', + title: NOTIFICATION_MESSAGES.empty, }; const slice = createSlice({ 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]); +}; diff --git a/src/studio-home/card-item/index.jsx b/src/studio-home/card-item/index.jsx index 53b45aff77..c87b02f0fd 100644 --- a/src/studio-home/card-item/index.jsx +++ b/src/studio-home/card-item/index.jsx @@ -17,7 +17,7 @@ const CardItem = ({ courseCreatorStatus, rerunCreatorStatus, } = useSelector(getStudioHomeData); - const courseUrl = new URL(url, getConfig().STUDIO_BASE_URL); + const courseUrl = () => new URL(url, getConfig().STUDIO_BASE_URL); const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`; const readOnlyItem = !(lmsLink || rerunLink || url); const showActions = !(readOnlyItem || isLibraries); @@ -32,7 +32,7 @@ const CardItem = ({ title={!readOnlyItem ? ( {displayName}