From bb6b2ab33c70d3f6a77eb42136b75d958446e6cb Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 9 Feb 2026 21:33:27 +0530 Subject: [PATCH] feat: container info sidebar and add sidebar updates (#2830) * Adds section, subsection and unit sidebar info tab in course outline as described in https://github.com/openedx/frontend-app-authoring/issues/2638 * Updates the sidebar design and behaviour as per https://github.com/openedx/frontend-app-authoring/issues/2826 * Updates course outline to use react query and removes redux store usage as much as possible. Updated parts that require absolutely cannot work without redux without heavy refactoring (will require quiet some time) to work in tandem with react-query. --- src/CourseAuthoringContext.tsx | 96 +++++- src/CourseAuthoringRoutes.tsx | 10 +- src/course-outline/CourseOutline.test.tsx | 284 +++++++--------- src/course-outline/CourseOutline.tsx | 48 +-- .../OutlineAddChildButtons.test.tsx | 30 +- src/course-outline/OutlineAddChildButtons.tsx | 40 +-- .../card-header/CardHeader.test.tsx | 54 ++- src/course-outline/card-header/CardHeader.tsx | 66 ++-- src/course-outline/data/api.ts | 52 ++- src/course-outline/data/apiHooks.ts | 127 ++++++- src/course-outline/data/selectors.ts | 3 - src/course-outline/data/slice.ts | 36 +- src/course-outline/data/thunk.ts | 95 ------ src/course-outline/data/types.ts | 18 +- .../header-navigations/HeaderActions.test.tsx | 13 +- .../header-navigations/HeaderActions.tsx | 10 +- .../highlights-modal/HighlightsModal.jsx | 8 +- ...odal.test.jsx => HighlightsModal.test.tsx} | 63 ++-- src/course-outline/hooks.jsx | 234 +++++++------ src/course-outline/index.ts | 1 + .../outline-sidebar/AddSidebar.test.tsx | 189 +++++++++-- .../outline-sidebar/AddSidebar.tsx | 313 +++++++++++------- .../LibraryReferenceCard.test.tsx | 215 ++++++++++++ .../outline-sidebar/LibraryReferenceCard.tsx | 257 ++++++++++++++ .../OutlineAlignSidebar.test.tsx | 30 +- .../outline-sidebar/OutlineAlignSidebar.tsx | 28 +- .../outline-sidebar/OutlineSidebar.test.tsx | 1 + .../outline-sidebar/OutlineSidebarContext.tsx | 183 +++++++--- .../OutlineSidebarPagesContext.tsx | 11 +- .../CourseInfoSidebar.tsx} | 6 +- .../info-sidebar/InfoSection.tsx | 57 ++++ .../info-sidebar/InfoSidebar.test.tsx | 134 ++++++++ .../info-sidebar/InfoSidebar.tsx | 30 ++ .../info-sidebar/PublishButon.tsx | 22 ++ .../info-sidebar/SectionInfoSidebar.tsx | 66 ++++ .../info-sidebar/SubsectionInfoSidebar.tsx | 67 ++++ .../info-sidebar/UnitInfoSidebar.tsx | 106 ++++++ .../outline-sidebar/messages.ts | 110 ++++++ .../publish-modal/PublishModal.jsx | 93 ------ .../publish-modal/PublishModal.test.jsx | 131 -------- .../publish-modal/PublishModal.test.tsx | 121 +++++++ .../publish-modal/PublishModal.tsx | 126 +++++++ .../section-card/SectionCard.test.tsx | 91 +++-- .../section-card/SectionCard.tsx | 73 ++-- .../status-bar/LegacyStatusBar.test.tsx | 1 - .../status-bar/StatusBar.test.tsx | 17 +- src/course-outline/status-bar/StatusBar.tsx | 32 +- .../subsection-card/SubsectionCard.test.tsx | 73 ++-- .../subsection-card/SubsectionCard.tsx | 78 ++--- .../unit-card/UnitCard.test.tsx | 58 ++-- src/course-outline/unit-card/UnitCard.tsx | 94 +++--- .../xblock-status/XBlockStatus.tsx | 4 +- src/course-unit/CourseUnit.test.jsx | 21 +- .../SubsectionUnitRedirect.test.tsx | 2 +- .../xblock-container-iframe/index.tsx | 10 +- .../xblock-container-iframe/types.ts | 3 +- src/data/types.ts | 11 +- .../configure-modal/ConfigureModal.jsx | 6 +- src/generic/resizable/Resizable.tsx | 73 ++++ src/generic/resizable/index.scss | 21 ++ src/generic/sidebar/Sidebar.tsx | 57 ++-- src/generic/sidebar/SidebarContent.tsx | 2 +- src/generic/sidebar/SidebarSection.tsx | 2 +- src/generic/sidebar/SidebarTitle.tsx | 33 +- src/generic/sidebar/index.scss | 3 +- src/generic/sidebar/messages.ts | 5 + src/generic/styles.scss | 1 + src/generic/unlink-modal/data/apiHooks.ts | 7 + src/hooks.ts | 15 + .../generic/publish-status-buttons/index.scss | 2 +- .../generic/status-widget/StatusWidget.scss | 2 +- .../library-filters/LibraryDropdownFilter.tsx | 1 + .../library-filters/SidebarFilters.tsx | 22 +- src/search-manager/SearchFilterWidget.tsx | 2 +- 74 files changed, 3033 insertions(+), 1373 deletions(-) rename src/course-outline/highlights-modal/{HighlightsModal.test.jsx => HighlightsModal.test.tsx} (78%) create mode 100644 src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx create mode 100644 src/course-outline/outline-sidebar/LibraryReferenceCard.tsx rename src/course-outline/outline-sidebar/{OutlineInfoSidebar.tsx => info-sidebar/CourseInfoSidebar.tsx} (92%) create mode 100644 src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx delete mode 100644 src/course-outline/publish-modal/PublishModal.jsx delete mode 100644 src/course-outline/publish-modal/PublishModal.test.jsx create mode 100644 src/course-outline/publish-modal/PublishModal.test.tsx create mode 100644 src/course-outline/publish-modal/PublishModal.tsx create mode 100644 src/generic/resizable/Resizable.tsx create mode 100644 src/generic/resizable/index.scss diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index e51484da3..776626bc0 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,15 +1,27 @@ import { getConfig } from '@edx/frontend-platform'; -import { createContext, useContext, useMemo } from 'react'; +import { + createContext, useContext, useMemo, useState, +} from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; import { getCourseItem } from '@src/course-outline/data/api'; import { useDispatch, useSelector } from 'react-redux'; -import { addSection, addSubsection, updateSavingStatus } from '@src/course-outline/data/slice'; +import { + addSection, addSubsection, addUnit, updateSavingStatus, +} from '@src/course-outline/data/slice'; import { useNavigate } from 'react-router'; import { getOutlineIndexData } from '@src/course-outline/data/selectors'; -import { RequestStatus, RequestStatusType } from './data/constants'; -import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; +import { useToggleWithValue } from '@src/hooks'; +import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types'; import { CourseDetailsData } from './data/api'; +import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; +import { RequestStatus, RequestStatusType } from './data/constants'; + +type ModalState = { + value: XBlock | UnitXBlock; + subsectionId?: string; + sectionId?: string; +}; export type CourseAuthoringContextData = { /** The ID of the current course */ @@ -20,9 +32,20 @@ export type CourseAuthoringContextData = { canChangeProviders: boolean; handleAddSection: ReturnType; handleAddSubsection: ReturnType; + handleAddAndOpenUnit: ReturnType; handleAddUnit: ReturnType; openUnitPage: (locator: string) => void; getUnitUrl: (locator: string) => string; + isUnlinkModalOpen: boolean; + currentUnlinkModalData?: ModalState; + openUnlinkModal: (value: ModalState) => void; + closeUnlinkModal: () => void; + isPublishModalOpen: boolean; + currentPublishModalData?: ModalState; + openPublishModal: (value: ModalState) => void; + closePublishModal: () => void; + currentSelection?: SelectionState; + setCurrentSelection: React.Dispatch>; }; /** @@ -50,6 +73,26 @@ export const CourseAuthoringProvider = ({ const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date(); const { courseStructure } = useSelector(getOutlineIndexData); const { id: courseUsageKey } = courseStructure || {}; + const [ + isUnlinkModalOpen, + currentUnlinkModalData, + openUnlinkModal, + closeUnlinkModal, + ] = useToggleWithValue(); + const [ + isPublishModalOpen, + currentPublishModalData, + openPublishModal, + closePublishModal, + ] = useToggleWithValue(); + /** + * This will hold the state of current item that is being operated on, + * For example: + * - the details of container that is being edited. + * - the details of container of which see more dropdown is open. + * It is mostly used in modals which should be soon be replaced with its equivalent in sidebar. + */ + const [currentSelection, setCurrentSelection] = useState(); const getUnitUrl = (locator: string) => { if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { @@ -62,7 +105,7 @@ export const CourseAuthoringProvider = ({ /** * Open the unit page for a given locator. */ - const openUnitPage = (locator: string) => { + const openUnitPage = async (locator: string) => { const url = getUnitUrl(locator); if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { // instanbul ignore next @@ -72,10 +115,9 @@ export const CourseAuthoringProvider = ({ } }; - const addSectionToCourse = async (locator: string) => { + const addSectionToCourse = /* istanbul ignore next */ async (locator: string) => { try { const data = await getCourseItem(locator); - // instanbul ignore next // Page should scroll to newly added section. data.shouldScroll = true; dispatch(addSection(data)); @@ -84,23 +126,35 @@ export const CourseAuthoringProvider = ({ } }; - const addSubsectionToCourse = async (locator: string, parentLocator: string) => { + const addSubsectionToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => { try { const data = await getCourseItem(locator); - data.shouldScroll = true; // Page should scroll to newly added subsection. + data.shouldScroll = true; dispatch(addSubsection({ parentLocator, data })); } catch { dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); } }; + const addUnitToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => { + try { + const data = await getCourseItem(locator); + // Page should scroll to newly added subsection. + data.shouldScroll = true; + dispatch(addUnit({ parentLocator, data })); + } catch { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; + const handleAddSection = useCreateCourseBlock(addSectionToCourse); const handleAddSubsection = useCreateCourseBlock(addSubsectionToCourse); /** * import a unit block from library and redirect user to this unit page. */ - const handleAddUnit = useCreateCourseBlock(openUnitPage); + const handleAddAndOpenUnit = useCreateCourseBlock(openUnitPage); + const handleAddUnit = useCreateCourseBlock(addUnitToCourse); const context = useMemo(() => ({ courseId, @@ -111,8 +165,19 @@ export const CourseAuthoringProvider = ({ handleAddSection, handleAddSubsection, handleAddUnit, + handleAddAndOpenUnit, getUnitUrl, openUnitPage, + isUnlinkModalOpen, + openUnlinkModal, + closeUnlinkModal, + currentUnlinkModalData, + isPublishModalOpen, + currentPublishModalData, + openPublishModal, + closePublishModal, + currentSelection, + setCurrentSelection, }), [ courseId, courseUsageKey, @@ -122,8 +187,19 @@ export const CourseAuthoringProvider = ({ handleAddSection, handleAddSubsection, handleAddUnit, + handleAddAndOpenUnit, getUnitUrl, openUnitPage, + isUnlinkModalOpen, + openUnlinkModal, + closeUnlinkModal, + currentUnlinkModalData, + isPublishModalOpen, + currentPublishModalData, + openPublishModal, + closePublishModal, + currentSelection, + setCurrentSelection, ]); return ( diff --git a/src/CourseAuthoringRoutes.tsx b/src/CourseAuthoringRoutes.tsx index 9bdcbeb75..3a330377b 100644 --- a/src/CourseAuthoringRoutes.tsx +++ b/src/CourseAuthoringRoutes.tsx @@ -11,7 +11,11 @@ import VideoSelectorContainer from './selectors/VideoSelectorContainer'; import CustomPages from './custom-pages'; import { FilesPage, VideosPage } from './files-and-videos'; import { AdvancedSettings } from './advanced-settings'; -import { CourseOutline, OutlineSidebarPagesProvider } from './course-outline'; +import { + CourseOutline, + OutlineSidebarProvider, + OutlineSidebarPagesProvider, +} from './course-outline'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; @@ -61,7 +65,9 @@ const CourseAuthoringRoutes = () => { element={( - + + + )} diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 68784f1d4..db0c5bb54 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -18,6 +18,8 @@ import { } from '@src/testUtils'; import { XBlock } from '@src/data/types'; import { userEvent } from '@testing-library/user-event'; +import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext'; +import { OutlineSidebarPagesProvider } from './outline-sidebar/OutlineSidebarPagesContext'; import { getCourseBestPracticesApiUrl, getCourseLaunchApiUrl, @@ -46,7 +48,6 @@ import { } from './__mocks__'; import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants'; import CourseOutline from './CourseOutline'; -import { OutlineSidebarPagesProvider } from './outline-sidebar/OutlineSidebarPagesContext'; import messages from './messages'; import headerMessages from './header-navigations/messages'; @@ -68,8 +69,18 @@ const mockPathname = '/foo-bar'; const courseId = '123'; const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1'); const getContainerType = jest.fn().mockReturnValue('unit'); +const clearSelection = jest.fn(); +let selectedContainerId: string | undefined; window.HTMLElement.prototype.scrollIntoView = jest.fn(); +jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ + ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext'), + useOutlineSidebarContext: () => ({ + ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), + clearSelection, + selectedContainerState: (() => (selectedContainerId ? { currentId: selectedContainerId } : undefined))(), + }), +})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -141,7 +152,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const renderComponent = () => render( - + + + , ); @@ -149,6 +162,7 @@ const renderComponent = () => render( describe('', () => { beforeEach(async () => { const mocks = initializeMocks(); + selectedContainerId = undefined; jest.mocked(useLocation).mockReturnValue({ pathname: mockPathname, @@ -434,7 +448,7 @@ describe('', () => { axiosMock .onPost(getXBlockBaseApiUrl()) .reply(200, { - locator: 'some', + locator: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical1e842129', }); const newUnitButton = await within(subsectionElement).findByRole('button', { name: 'New unit' }); await act(async () => fireEvent.click(newUnitButton)); @@ -461,7 +475,7 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl()) .reply(200, { - locator: 'some', + locator: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical1e842129', parent_locator: 'parent', }); @@ -499,8 +513,8 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl()) .reply(200, { - locator: 'some', - parent_locator: 'parent', + locator: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a', + parent_locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersda1', }); const addSubsectionFromLibraryButton = within(sectionElement).getByRole('button', { @@ -535,8 +549,8 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl()) .reply(200, { - locator: 'some', - parent_locator: 'parent', + locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd', + courseKey: 'course-v1:UNIX+UX1+2025_T3', }); const addSectionFromLibraryButton = await screen.findByRole('button', { @@ -692,7 +706,7 @@ describe('', () => { it('check edit title works for section, subsection and unit', async () => { const { findAllByTestId } = renderComponent(); - const checkEditTitle = async (section, element, item, newName, elementName) => { + const checkEditTitle = async (element, item, newName, elementName) => { axiosMock.reset(); axiosMock .onPost(getCourseItemApiUrl(item.id)) @@ -700,26 +714,10 @@ describe('', () => { // mock section, subsection and unit name and check within the elements. // this is done to avoid adding conditions to this mock. axiosMock - .onGet(getXBlockApiUrl(section.id)) + .onGet(getXBlockApiUrl(item.id)) .reply(200, { - ...section, + ...item, display_name: newName, - childInfo: { - children: [ - { - ...section.childInfo.children[0], - display_name: newName, - childInfo: { - children: [ - { - ...section.childInfo.children[0].childInfo.children[0], - display_name: newName, - }, - ], - }, - }, - ], - }, }); const editButton = await within(element).findByTestId(`${elementName}-edit-button`); @@ -741,17 +739,17 @@ describe('', () => { // check section const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); - await checkEditTitle(section, sectionElement, section, 'New section name', 'section'); + await checkEditTitle(sectionElement, section, 'New section name', 'section'); // check subsection const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection'); + await checkEditTitle(subsectionElement, subsection, 'New subsection name', 'subsection'); // check unit const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); - await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit'); + await checkEditTitle(unitElement, unit, 'New unit name', 'unit'); }); it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => { @@ -763,6 +761,7 @@ describe('', () => { const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + selectedContainerId = section.id; const checkDeleteBtn = async (item, element, elementName) => { await waitFor(() => { @@ -790,6 +789,7 @@ describe('', () => { await checkDeleteBtn(subsection, subsectionElement, 'subsection'); // check section await checkDeleteBtn(section, sectionElement, 'section'); + expect(clearSelection).toHaveBeenCalledTimes(1); }); it('check whether section, subsection and unit is duplicated successfully', async () => { @@ -877,47 +877,12 @@ describe('', () => { publish: 'make_public', }) .reply(200, { dummy: 'value' }); - - let mockReturnValue = { - ...section, - childInfo: { - children: [ - { - ...section.childInfo.children[0], - published: true, - visibilityState: 'live', - }, - ...section.childInfo.children.slice(1), - ], - }, - }; - if (elementName === 'unit') { - mockReturnValue = { - ...section, - childInfo: { - children: [ - { - ...section.childInfo.children[0], - childInfo: { - displayName: 'Unit Tests', - children: [ - { - ...section.childInfo.children[0].childInfo.children[0], - published: true, - visibilityState: 'live', - }, - ...section.childInfo.children[0].childInfo.children.slice(1), - ], - }, - }, - ...section.childInfo.children.slice(1), - ], - }, - }; - } axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, mockReturnValue); + .onGet(getXBlockApiUrl(item.id)) + .reply(200, { + ...item, + visibilityState: 'live', + }); const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`); fireEvent.click(menu); @@ -944,6 +909,17 @@ describe('', () => { const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; const newReleaseDateIso = '2025-09-10T22:00:00Z'; const newReleaseDate = '09/10/2025'; + + const [firstSection] = await findAllByTestId('section-card'); + + const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button'); + await act(async () => fireEvent.click(sectionDropdownButton)); + const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button'); + await act(async () => fireEvent.click(configureBtn)); + let releaseDateStack = await findByTestId('release-date-stack'); + let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); + expect(releaseDatePicker).toHaveValue('08/10/2023'); + axiosMock .onPost(getCourseItemApiUrl(section.id), { publish: 'republish', @@ -961,16 +937,6 @@ describe('', () => { start: newReleaseDateIso, }); - const [firstSection] = await findAllByTestId('section-card'); - - const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button'); - await act(async () => fireEvent.click(sectionDropdownButton)); - const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button'); - await act(async () => fireEvent.click(configureBtn)); - let releaseDateStack = await findByTestId('release-date-stack'); - let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); - expect(releaseDatePicker).toHaveValue('08/10/2023'); - await act(async () => fireEvent.change(releaseDatePicker, { target: { value: newReleaseDate } })); expect(releaseDatePicker).toHaveValue(newReleaseDate); const saveButton = await findByTestId('configure-save-button'); @@ -993,6 +959,7 @@ describe('', () => { }); it('check configure modal for subsection', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1034,14 +1001,14 @@ describe('', () => { subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; subsection.hideAfterDue = expectedRequestData.metadata.hide_after_due; - section.childInfo.children[0] = subsection; axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); + section.childInfo.children[0] = subsection; - fireEvent.click(subsectionDropdownButton); + await user.click(subsectionDropdownButton); const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1061,27 +1028,27 @@ describe('', () => { // visibility tab const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[1]); + await user.click(visibilityRadioButtons[1]); let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[1]); + await user.click(radioButtons[1]); let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); fireEvent.change(hours, { target: { value: '54:30' } }); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); @@ -1098,7 +1065,7 @@ describe('', () => { expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', true); @@ -1108,6 +1075,7 @@ describe('', () => { }); it('check prereq and proctoring settings in configure modal for subsection', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1155,13 +1123,10 @@ describe('', () => { subsection.prereqMinScore = expectedRequestData.prereqMinScore; subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion; section.childInfo.children[0] = subsection; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); - fireEvent.click(subsectionDropdownButton); + await user.click(subsectionDropdownButton); const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1172,13 +1137,13 @@ describe('', () => { // visibility tab const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[2]); + await user.click(visibilityRadioButtons[2]); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[2]); + await user.click(radioButtons[2]); let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); fireEvent.change(hours, { target: { value: '00:30' } }); @@ -1200,7 +1165,7 @@ describe('', () => { let prereqCheckbox = await within(configureModal).findByLabelText( configureModalMessages.prereqCheckboxLabel.defaultMessage, ); - fireEvent.click(prereqCheckbox); + await user.click(prereqCheckbox); // fill some rules for proctored exams let examsRulesInput = await within(configureModal).findByLabelText( @@ -1208,22 +1173,25 @@ describe('', () => { ); fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } }); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage, }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1253,6 +1221,7 @@ describe('', () => { }); it('check practice proctoring settings in configure modal', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1295,13 +1264,10 @@ describe('', () => { subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; section.childInfo.children[0] = subsection; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); - fireEvent.click(subsectionDropdownButton); + await user.click(subsectionDropdownButton); const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1311,14 +1277,14 @@ describe('', () => { ); // visibility tab const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[4]); + await user.click(visibilityRadioButtons[4]); // advancedTab - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[3]); + await user.click(radioButtons[3]); let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); fireEvent.change(hours, { target: { value: '00:30' } }); @@ -1328,20 +1294,23 @@ describe('', () => { configureModalMessages.reviewRulesLabel.defaultMessage, )).not.toBeInTheDocument(); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1353,6 +1322,7 @@ describe('', () => { }); it('check onboarding proctoring settings in configure modal', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1395,30 +1365,27 @@ describe('', () => { subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; section.childInfo.children[1] = subsection; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); - fireEvent.click(subsectionDropdownButton); + await user.click(subsectionDropdownButton); const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); // visibility tab const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); + await user.click(visibilityTab); const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(visibilityRadioButtons[5]); + await user.click(visibilityRadioButtons[5]); // advancedTab let advancedTab = await within(configureModal).findByRole( 'tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }, ); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[3]); + await user.click(radioButtons[3]); let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); fireEvent.change(hours, { target: { value: '00:30' } }); @@ -1428,20 +1395,23 @@ describe('', () => { configureModalMessages.reviewRulesLabel.defaultMessage, )).not.toBeInTheDocument(); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', false); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1453,6 +1423,7 @@ describe('', () => { }); it('check no special exam setting in configure modal', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId, @@ -1494,13 +1465,10 @@ describe('', () => { subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; section.childInfo.children[0] = subsection; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); - fireEvent.click(subsectionDropdownButton); + await user.click(subsectionDropdownButton); const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); // update fields let configureModal = await findByTestId('configure-modal'); @@ -1510,9 +1478,9 @@ describe('', () => { 'tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }, ); - fireEvent.click(advancedTab); + await user.click(advancedTab); let radioButtons = await within(configureModal).findAllByRole('radio'); - fireEvent.click(radioButtons[0]); + await user.click(radioButtons[0]); // time box should not be visible expect(within(configureModal).queryByLabelText( @@ -1524,20 +1492,23 @@ describe('', () => { configureModalMessages.reviewRulesLabel.defaultMessage, )).not.toBeInTheDocument(); + axiosMock + .onGet(getXBlockApiUrl(subsection.id)) + .reply(200, subsection); const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // verify request expect(axiosMock.history.post.length).toBe(3); expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values - await act(async () => fireEvent.click(subsectionDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(subsectionDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + await user.click(advancedTab); radioButtons = await within(configureModal).findAllByRole('radio'); expect(radioButtons[0]).toHaveProperty('checked', true); expect(radioButtons[1]).toHaveProperty('checked', false); @@ -1546,6 +1517,7 @@ describe('', () => { }); it('check configure modal for unit', async () => { + const user = userEvent.setup(); const { findAllByTestId, findByTestId } = renderComponent(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; const [subsection] = section.childInfo.children; @@ -1605,37 +1577,37 @@ describe('', () => { subsection.childInfo.children[0] = unit; section.childInfo.children[0] = subsection; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, section); - - fireEvent.click(unitDropdownButton); + await user.click(unitDropdownButton); const configureBtn = await within(firstUnit).findByTestId('unit-card-header__menu-configure-button'); - fireEvent.click(configureBtn); + await user.click(configureBtn); let configureModal = await findByTestId('configure-modal'); expect(await within(configureModal).findByText( configureModalMessages.unitVisibility.defaultMessage, )).toBeInTheDocument(); let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); - await act(async () => fireEvent.click(visibilityCheckbox)); + await user.click(visibilityCheckbox); let discussionCheckbox = await within(configureModal).findByLabelText( configureModalMessages.discussionEnabledCheckbox.defaultMessage, ); expect(discussionCheckbox).toBeChecked(); - await act(async () => fireEvent.click(discussionCheckbox)); + await user.click(discussionCheckbox); let groupeType = await within(configureModal).findByTestId('group-type-select'); fireEvent.change(groupeType, { target: { value: '0' } }); let checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox'); - fireEvent.click(checkboxes[1]); + await user.click(checkboxes[1]); + axiosMock + .onGet(getXBlockApiUrl(unit.id)) + .reply(200, unit); + const saveButton = await within(configureModal).findByTestId('configure-save-button'); - await act(async () => fireEvent.click(saveButton)); + await user.click(saveButton); // reopen modal and check values - await act(async () => fireEvent.click(unitDropdownButton)); - await act(async () => fireEvent.click(configureBtn)); + await user.click(unitDropdownButton); + await user.click(configureBtn); configureModal = await findByTestId('configure-modal'); visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 68f440637..6708c5d6d 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -36,8 +36,8 @@ import { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert'; import { ContainerType } from '@src/generic/key-utils'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import { - getCurrentItem, getProctoredExamsFlag, getTimedExamsFlag, } from './data/selectors'; @@ -61,7 +61,6 @@ import messages from './messages'; import headerMessages from './header-navigations/messages'; import { getTagsExportFile } from './data/api'; import OutlineAddChildButtons from './OutlineAddChildButtons'; -import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext'; import { StatusBar } from './status-bar/StatusBar'; import { LegacyStatusBar } from './status-bar/LegacyStatusBar'; import { isOutlineNewDesignEnabled } from './utils'; @@ -74,7 +73,11 @@ const CourseOutline = () => { courseUsageKey, handleAddSubsection, handleAddUnit, + handleAddAndOpenUnit, handleAddSection, + isUnlinkModalOpen, + closeUnlinkModal, + currentSelection, } = useCourseAuthoringContext(); const { @@ -93,19 +96,13 @@ const CourseOutline = () => { isInternetConnectionAlertFailed, isDisabledReindexButton, isHighlightsModalOpen, - isPublishModalOpen, isConfigureModalOpen, isDeleteModalOpen, - isUnlinkModalOpen, closeHighlightsModal, - closePublishModal, handleConfigureModalClose, closeDeleteModal, - closeUnlinkModal, - openPublishModal, openConfigureModal, openDeleteModal, - openUnlinkModal, headerNavigationsActions, openEnableHighlightsModal, closeEnableHighlightsModal, @@ -114,10 +111,7 @@ const CourseOutline = () => { handleOpenHighlightsModal, handleHighlightsFormSubmit, handleConfigureItemSubmit, - handlePublishItemSubmit, - handleEditSubmit, handleDeleteItemSubmit, - handleUnlinkItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, handleDuplicateUnitSubmit, @@ -136,6 +130,7 @@ const CourseOutline = () => { handleUnitDragAndDrop, errors, resetScrollState, + handleUnlinkItemSubmit, } = useCourseOutline({ courseId }); // Show the new actions bar if it is enabled in the configuration. @@ -170,9 +165,9 @@ const CourseOutline = () => { title: processingNotificationTitle, } = useSelector(getProcessingNotification); - const currentItemData = useSelector(getCurrentItem); + const { data: currentItemData } = useCourseItemData(currentSelection?.currentId); - const itemCategory = currentItemData?.category; + const itemCategory = currentItemData?.category || ''; const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase(); const enableProctoredExams = useSelector(getProctoredExamsFlag); @@ -269,7 +264,7 @@ const CourseOutline = () => { } return ( - + <> {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))} @@ -338,7 +333,7 @@ const CourseOutline = () => { /> )}
-
+
@@ -385,13 +380,9 @@ const CourseOutline = () => { canMoveItem={canMoveSection(sections)} isSelfPaced={statusBarData.isSelfPaced} isCustomRelativeDatesActive={isCustomRelativeDatesActive} - savingStatus={savingStatus} onOpenHighlightsModal={handleOpenHighlightsModal} - onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} - onOpenUnlinkModal={openUnlinkModal} - onEditSectionSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSectionSubmit} isSectionsExpanded={isSectionsExpanded} onOrderChange={updateSectionOrderByIndex} @@ -417,11 +408,7 @@ const CourseOutline = () => { isSectionsExpanded={isSectionsExpanded} isSelfPaced={statusBarData.isSelfPaced} isCustomRelativeDatesActive={isCustomRelativeDatesActive} - savingStatus={savingStatus} - onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} - onOpenUnlinkModal={openUnlinkModal} - onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSubsectionSubmit} onOpenConfigureModal={openConfigureModal} onOrderChange={updateSubsectionOrderByIndex} @@ -450,12 +437,8 @@ const CourseOutline = () => { subsection, subsection.childInfo.children, )} - savingStatus={savingStatus} - onOpenPublishModal={openPublishModal} onOpenConfigureModal={openConfigureModal} onOpenDeleteModal={openDeleteModal} - onOpenUnlinkModal={openUnlinkModal} - onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateUnitSubmit} onOrderChange={updateUnitOrderByIndex} discussionsSettings={discussionsSettings} @@ -473,7 +456,6 @@ const CourseOutline = () => { )} @@ -483,7 +465,6 @@ const CourseOutline = () => { @@ -513,11 +494,7 @@ const CourseOutline = () => { onClose={closeHighlightsModal} onSubmit={handleHighlightsFormSubmit} /> - + { isShow={ isShowProcessingNotification || handleAddUnit.isPending + || handleAddAndOpenUnit.isPending || handleAddSubsection.isPending || handleAddSection.isPending } @@ -568,7 +546,7 @@ const CourseOutline = () => { {toastMessage} )} - + ); }; diff --git a/src/course-outline/OutlineAddChildButtons.test.tsx b/src/course-outline/OutlineAddChildButtons.test.tsx index 044414245..2b0ff929f 100644 --- a/src/course-outline/OutlineAddChildButtons.test.tsx +++ b/src/course-outline/OutlineAddChildButtons.test.tsx @@ -4,18 +4,22 @@ import { ContainerType } from '@src/generic/key-utils'; import { initializeMocks, render, screen, waitFor, } from '@src/testUtils'; -import { OutlineFlow, OutlineFlowType, OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { OutlineFlow, OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import OutlineAddChildButtons from './OutlineAddChildButtons'; -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }), +jest.mock('@src/studio-home/data/selectors', () => ({ + ...jest.requireActual('@src/studio-home/data/selectors'), + getStudioHomeData: () => ({ + librariesV2Enabled: true, + }), })); const handleAddSection = { mutateAsync: jest.fn() }; const handleAddSubsection = { mutateAsync: jest.fn() }; +const handleAddAndOpenUnit = { mutateAsync: jest.fn() }; const handleAddUnit = { mutateAsync: jest.fn() }; const courseUsageKey = 'some/usage/key'; +const setCurrentSelection = jest.fn(); jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ courseId: 5, @@ -23,7 +27,9 @@ jest.mock('@src/CourseAuthoringContext', () => ({ getUnitUrl: (id: string) => `/some/${id}`, handleAddSection, handleAddSubsection, + handleAddAndOpenUnit, handleAddUnit, + setCurrentSelection, }), })); @@ -35,6 +41,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), startCurrentFlow, currentFlow, + isCurrentFlowOn: !!currentFlow, }), })); @@ -60,7 +67,6 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ handleUseFromLibraryClick={useFromLibClickHandler} childType={containerType} parentLocator="" - parentTitle="" />, { extraWrapper: OutlineSidebarProvider }); const newBtn = await screen.findByRole('button', { name: `New ${containerType}` }); @@ -75,11 +81,9 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ it('calls appropriate new handlers', async () => { const parentLocator = `parent-of-${containerType}`; - const parentTitle = `parent-title-of-${containerType}`; render(, { extraWrapper: OutlineSidebarProvider }); const newBtn = await screen.findByRole('button', { name: `New ${containerType}` }); @@ -101,7 +105,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ })); break; case ContainerType.Unit: - await waitFor(() => expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({ + await waitFor(() => expect(handleAddAndOpenUnit.mutateAsync).toHaveBeenCalledWith({ type: ContainerType.Vertical, parentLocator, displayName: 'Unit', @@ -114,34 +118,28 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ it('calls appropriate use handlers', async () => { const parentLocator = `parent-of-${containerType}`; - const parentTitle = `parent-title-of-${containerType}`; render(, { extraWrapper: OutlineSidebarProvider }); const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` }); expect(useBtn).toBeInTheDocument(); await userEvent.click(useBtn); await waitFor(() => expect(startCurrentFlow).toHaveBeenCalledWith({ - flowType: `use-${containerType}`, + flowType: containerType, parentLocator, - parentTitle, })); }); it('shows appropriate static placeholder', async () => { const parentLocator = `parent-of-${containerType}`; - const parentTitle = `parent-title-of-${containerType}`; currentFlow = { - flowType: `use-${containerType}` as OutlineFlowType, + flowType: containerType, parentLocator, - parentTitle, }; render(, { extraWrapper: OutlineSidebarProvider }); // should show placeholder when use button is clicked expect(await screen.findByRole('heading', { diff --git a/src/course-outline/OutlineAddChildButtons.tsx b/src/course-outline/OutlineAddChildButtons.tsx index e06409275..ef8c583b4 100644 --- a/src/course-outline/OutlineAddChildButtons.tsx +++ b/src/course-outline/OutlineAddChildButtons.tsx @@ -6,7 +6,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useSelector } from 'react-redux'; import { getStudioHomeData } from '@src/studio-home/data/selectors'; import { ContainerType } from '@src/generic/key-utils'; -import { type OutlineFlowType, useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { LoadingSpinner } from '@src/generic/Loading'; import { useCallback } from 'react'; @@ -26,24 +26,25 @@ import messages from './messages'; */ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => { const intl = useIntl(); - const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext(); + const { isCurrentFlowOn, currentFlow, stopCurrentFlow } = useOutlineSidebarContext(); const { handleAddSection, handleAddSubsection, handleAddUnit, + handleAddAndOpenUnit, } = useCourseAuthoringContext(); - if (!currentFlow || currentFlow.parentLocator !== parentLocator) { + if (!isCurrentFlowOn || currentFlow?.parentLocator !== parentLocator) { return null; } const getTitle = () => { switch (currentFlow?.flowType) { - case 'use-section': + case ContainerType.Section: return intl.formatMessage(messages.placeholderSectionText); - case 'use-subsection': + case ContainerType.Subsection: return intl.formatMessage(messages.placeholderSubsectionText); - case 'use-unit': + case ContainerType.Unit: return intl.formatMessage(messages.placeholderUnitText); default: // istanbul ignore next: this should never happen @@ -59,6 +60,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => { {(handleAddSection.isPending || handleAddSubsection.isPending + || handleAddAndOpenUnit.isPending || handleAddUnit.isPending) && ( )} @@ -88,7 +90,7 @@ interface BaseProps { interface NewChildButtonsProps extends BaseProps { handleUseFromLibraryClick?: () => void; - parentTitle: string; + grandParentLocator?: string; } const NewOutlineAddChildButtons = ({ @@ -100,7 +102,7 @@ const NewOutlineAddChildButtons = ({ btnClasses = 'mt-4 border-gray-500 rounded-0', btnSize, parentLocator, - parentTitle, + grandParentLocator, }: NewChildButtonsProps) => { // WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below, // as it has a useEffect that fetches course waffle flags whenever @@ -113,7 +115,7 @@ const NewOutlineAddChildButtons = ({ courseUsageKey, handleAddSection, handleAddSubsection, - handleAddUnit, + handleAddAndOpenUnit, } = useCourseAuthoringContext(); const { startCurrentFlow } = useOutlineSidebarContext(); let messageMap = { @@ -121,7 +123,7 @@ const NewOutlineAddChildButtons = ({ importButton: messages.useUnitFromLibraryButton, }; let onNewCreateContent: () => Promise; - let flowType: OutlineFlowType; + let flowType: ContainerType; // Based on the childType, determine the correct action and messages to display. switch (childType) { @@ -135,7 +137,7 @@ const NewOutlineAddChildButtons = ({ parentLocator: courseUsageKey, displayName: COURSE_BLOCK_NAMES.chapter.name, }); - flowType = 'use-section'; + flowType = ContainerType.Section; break; case ContainerType.Subsection: messageMap = { @@ -147,19 +149,19 @@ const NewOutlineAddChildButtons = ({ parentLocator, displayName: COURSE_BLOCK_NAMES.sequential.name, }); - flowType = 'use-subsection'; + flowType = ContainerType.Subsection; break; case ContainerType.Unit: messageMap = { newButton: messages.newUnitButton, importButton: messages.useUnitFromLibraryButton, }; - onNewCreateContent = () => handleAddUnit.mutateAsync({ + onNewCreateContent = () => handleAddAndOpenUnit.mutateAsync({ type: ContainerType.Vertical, parentLocator, displayName: COURSE_BLOCK_NAMES.vertical.name, }); - flowType = 'use-unit'; + flowType = ContainerType.Unit; break; default: // istanbul ignore next: unreachable @@ -173,12 +175,12 @@ const NewOutlineAddChildButtons = ({ startCurrentFlow({ flowType, parentLocator, - parentTitle, + grandParentLocator, }); }, [ childType, parentLocator, - parentTitle, + grandParentLocator, startCurrentFlow, ]); @@ -237,7 +239,7 @@ const LegacyOutlineAddChildButtons = ({ courseUsageKey, handleAddSection, handleAddSubsection, - handleAddUnit, + handleAddAndOpenUnit, } = useCourseAuthoringContext(); const [ isAddLibrarySectionModalOpen, @@ -301,12 +303,12 @@ const LegacyOutlineAddChildButtons = ({ importButton: messages.useUnitFromLibraryButton, modalTitle: messages.unitPickerModalTitle, }; - onNewCreateContent = () => handleAddUnit.mutateAsync({ + onNewCreateContent = () => handleAddAndOpenUnit.mutateAsync({ type: ContainerType.Vertical, parentLocator, displayName: COURSE_BLOCK_NAMES.vertical.name, }); - onUseLibraryContent = (selected: SelectedComponent) => handleAddUnit.mutateAsync({ + onUseLibraryContent = (selected: SelectedComponent) => handleAddAndOpenUnit.mutateAsync({ type: COMPONENT_TYPES.libraryV2, category: ContainerType.Vertical, parentLocator, diff --git a/src/course-outline/card-header/CardHeader.test.tsx b/src/course-outline/card-header/CardHeader.test.tsx index 05d5c4386..840493c7a 100644 --- a/src/course-outline/card-header/CardHeader.test.tsx +++ b/src/course-outline/card-header/CardHeader.test.tsx @@ -4,16 +4,17 @@ import { ITEM_BADGE_STATUS } from '@src/course-outline/constants'; import { act, fireEvent, initializeMocks, render, screen, waitFor, } from '@src/testUtils'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { courseId } from '@src/schedule-and-details/__mocks__/courseDetails'; +import { userEvent } from '@testing-library/user-event'; import CardHeader from './CardHeader'; import TitleButton from './TitleButton'; import messages from './messages'; -import { RequestStatus } from '../../data/constants'; import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; const onExpandMock = jest.fn(); const onClickMenuButtonMock = jest.fn(); const onClickPublishMock = jest.fn(); -const onClickEditMock = jest.fn(); const onClickDeleteMock = jest.fn(); const onClickUnlinkMock = jest.fn(); const onClickDuplicateMock = jest.fn(); @@ -29,6 +30,12 @@ jest.mock('../../generic/data/api', () => ({ getTagsCount: () => mockGetTagsCount(), })); +const useUpdateCourseBlockNameMock = { mutateAsync: jest.fn(), isPending: false }; +jest.mock('@src/course-outline/data/apiHooks', () => ({ + ...jest.requireActual('@src/course-outline/data/apiHooks'), + useUpdateCourseBlockName: () => useUpdateCourseBlockNameMock, +})); + const cardHeaderProps = { title: 'Some title', status: ITEM_BADGE_STATUS.live, @@ -36,8 +43,6 @@ const cardHeaderProps = { hasChanges: false, onClickMenuButton: onClickMenuButtonMock, onClickPublish: onClickPublishMock, - onClickEdit: onClickEditMock, - isFormOpen: false, onEditSubmit: jest.fn(), closeForm: closeFormMock, isDisabledEditField: false, @@ -80,7 +85,13 @@ const renderComponent = (props?: object, entry = '/') => { routerProps: { initialEntries: [entry], }, - extraWrapper: OutlineSidebarProvider, + extraWrapper: ({ children }) => ( + + + {children} + + + ), }, ); }; @@ -214,20 +225,21 @@ describe('', () => { expect(screen.getAllByText('Manage tags').length).toBe(2); }); - it('calls onClickEdit when the button is clicked', async () => { + it('calls onClickMenu when the edit button is clicked', async () => { + const user = userEvent.setup(); renderComponent(); const editButton = await screen.findByTestId('subsection-edit-button'); - await act(async () => fireEvent.click(editButton)); - expect(onClickEditMock).toHaveBeenCalled(); + await user.click(editButton); + expect(onClickMenuButtonMock).toHaveBeenCalled(); }); - it('check is field visible when isFormOpen is true', async () => { - renderComponent({ - ...cardHeaderProps, - isFormOpen: true, - }); + it('check is field visible when edit is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + const editButton = await screen.findByTestId('subsection-edit-button'); + await user.click(editButton); expect(await screen.findByTestId('subsection-edit-field')).toBeInTheDocument(); await waitFor(() => { expect(screen.queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument(); @@ -248,15 +260,21 @@ describe('', () => { }); it('check editing is disabled when saving is in progress', async () => { - renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS }); + setConfig({ + ...getConfig(), + ENABLE_TAGGING_TAXONOMY_PAGES: 'true', + }); + useUpdateCourseBlockNameMock.isPending = true; + const user = userEvent.setup(); + renderComponent(); - expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled(); + expect(await screen.findByLabelText('Rename')).toBeDisabled(); // Ensure menu items related to editing are disabled const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); - await act(async () => fireEvent.click(menuButton)); - expect(await screen.findByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true'); - expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true'); + await user.click(menuButton); + expect(await screen.findByText('Configure')).toHaveAttribute('aria-disabled', 'true'); + expect(await screen.findByText('Manage tags')).toHaveAttribute('aria-disabled', 'true'); }); it('calls onClickDelete when item is clicked', async () => { diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 1fcf35a05..0d7027237 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -21,11 +21,12 @@ import { } from '@openedx/paragon/icons'; import { useContentTagsCount } from '@src/generic/data/apiHooks'; +import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; import TagCount from '@src/generic/tag-count'; import { useEscapeClick } from '@src/hooks'; import { XBlockActions } from '@src/data/types'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; -import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; +import { useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { ITEM_BADGE_STATUS } from '../constants'; import { scrollToElement } from '../utils'; import CardStatus from './CardStatus'; @@ -35,15 +36,11 @@ import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarConte interface CardHeaderProps { title: string; status: string; - cardId?: string, + cardId: string, hasChanges: boolean; onClickPublish: () => void; onClickConfigure: () => void; onClickMenuButton: () => void; - onClickEdit: () => void; - isFormOpen: boolean; - onEditSubmit: (titleValue: string) => void; - closeForm: () => void; onClickDelete: () => void; onClickUnlink: () => void; onClickDuplicate: () => void; @@ -72,7 +69,6 @@ interface CardHeaderProps { extraActionsComponent?: ReactNode, onClickSync?: () => void; readyToSync?: boolean; - savingStatus?: RequestStatusType; } const CardHeader = ({ @@ -83,10 +79,6 @@ const CardHeader = ({ onClickPublish, onClickConfigure, onClickMenuButton, - onClickEdit, - isFormOpen, - onEditSubmit, - closeForm, onClickDelete, onClickUnlink, onClickDuplicate, @@ -107,7 +99,6 @@ const CardHeader = ({ extraActionsComponent, onClickSync, readyToSync, - savingStatus, }: CardHeaderProps) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -118,12 +109,16 @@ const CardHeader = ({ const openManageTagsDrawer = useCallback(() => { const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true'; - if (showNewSidebar) { - setCurrentPageKey('align', cardId); + const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'; + if (showNewSidebar && showAlignSidebar) { + setCurrentPageKey('align'); + onClickMenuButton(); } else { openLegacyTagsDrawer(); } }, [setCurrentPageKey, openLegacyTagsDrawer, cardId]); + const { courseId, currentSelection } = useCourseAuthoringContext(); + const [isFormOpen, openForm, closeForm] = useToggle(false); // Use studio url as base if proctoringExamConfigurationLink is a relative link const fullProctoringExamConfigurationLink = () => ( @@ -134,7 +129,11 @@ const CardHeader = ({ || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; const { data: contentTagCount } = useContentTagsCount(cardId); - const isSaving = savingStatus === RequestStatus.IN_PROGRESS; + + const onEditClick = () => { + onClickMenuButton(); + openForm(); + }; useEffect(() => { const locatorId = searchParams.get('show'); @@ -159,13 +158,29 @@ const CardHeader = ({ ); useEscapeClick({ - onEscape: () => { + onEscape: /* istanbul ignore next */ () => { setTitleValue(title); closeForm(); }, - dependency: title, + dependency: [title], }); + const editMutation = useUpdateCourseBlockName(courseId); + const handleEditSubmit = useCallback(() => { + if (title !== titleValue) { + editMutation.mutate({ + itemId: cardId, + displayName: titleValue, + subsectionId: currentSelection?.subsectionId, + sectionId: currentSelection?.sectionId, + }, { + onSettled: () => closeForm(), + }); + } else { + closeForm(); + } + }, [title, titleValue, cardId, editMutation]); + return ( <> { @@ -188,10 +203,10 @@ const CardHeader = ({ name="displayName" onChange={(e) => setTitleValue(e.target.value)} aria-label={intl.formatMessage(messages.editFieldAriaLabel)} - onBlur={() => onEditSubmit(titleValue)} + onBlur={handleEditSubmit} onKeyDown={/* istanbul ignore next */ (e) => { if (e.key === 'Enter') { - onEditSubmit(titleValue); + handleEditSubmit(); } else if (e.key === ' ') { // Avoid passing propagation to the `SortableItem` in the card, // which executes a `preventDefault`. If propagation is not prevented, @@ -199,7 +214,7 @@ const CardHeader = ({ e.stopPropagation(); } }} - disabled={isSaving} + disabled={editMutation.isPending} /> ) : ( @@ -211,9 +226,8 @@ const CardHeader = ({ alt={intl.formatMessage(messages.altButtonRename)} tooltipContent={
{intl.formatMessage(messages.altButtonRename)}
} iconAs={EditIcon} - onClick={onClickEdit} - // @ts-ignore - disabled={isSaving} + onClick={onEditClick} + disabled={editMutation.isPending} />
)} @@ -265,7 +279,7 @@ const CardHeader = ({ {intl.formatMessage(messages.menuConfigure)} @@ -273,7 +287,7 @@ const CardHeader = ({ {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( {intl.formatMessage(messages.menuManageTags)} diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index f07d64cb5..f162c55a8 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -1,7 +1,7 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { XBlock } from '@src/data/types'; -import { CourseOutline, CourseDetails } from './types'; +import { CourseOutline, CourseDetails, CourseItemUpdateResult } from './types'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -170,10 +170,8 @@ export async function restartIndexingOnCourse(reindexLink: string): Promise} */ -export async function getCourseItem(itemId: string): Promise { +export async function getCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() .get(getXBlockApiUrl(itemId)); return camelCaseObject(data); @@ -201,13 +199,11 @@ export async function updateCourseSectionHighlights( } /** - * Publish course section - * @param {string} sectionId - * @returns {Promise} + * Publish course item */ -export async function publishCourseSection(sectionId: string): Promise { +export async function publishCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(sectionId), { + .post(getCourseItemApiUrl(itemId), { publish: 'make_public', }); @@ -335,14 +331,11 @@ export async function configureCourseUnit( /** * Edit course section - * @param {string} itemId - * @param {string} displayName - * @returns {Promise} */ -export async function editItemDisplayName( - itemId: string, - displayName: string, -): Promise { +export async function editItemDisplayName({ itemId, displayName }: { + itemId: string; + displayName: string; +}): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(itemId), { metadata: { @@ -367,9 +360,6 @@ export async function deleteCourseItem(itemId: string): Promise { /** * Duplicate course section - * @param {string} itemId - * @param {string} parentId - * @returns {Promise} */ export async function duplicateCourseItem(itemId: string, parentId: string): Promise { const { data } = await getAuthenticatedHttpClient() @@ -381,6 +371,18 @@ export async function duplicateCourseItem(itemId: string, parentId: string): Pro return data; } +export type CreateCourseXBlockType = { + type: string, + /** The category of the XBlock. Defaults to the type if not provided. */ + category?: string, + parentLocator: string, + displayName?: string, + boilerplate?: string, + stagedContent?: string, + /** component key from library if being imported. */ + libraryContentKey?: string, +}; + /** * Creates a new course XBlock. Can be used to create any type of block * and also import a content from library. @@ -393,17 +395,7 @@ export async function createCourseXblock({ boilerplate, stagedContent, libraryContentKey, -}: { - type: string, - /** The category of the XBlock. Defaults to the type if not provided. */ - category?: string, - parentLocator: string, - displayName?: string, - boilerplate?: string, - stagedContent?: string, - /** component key from library if being imported. */ - libraryContentKey?: string, -}) { +}: CreateCourseXBlockType) { const body = { type, boilerplate, diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 9a67ecf32..0b934e52c 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,15 +1,39 @@ -import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; -import { createCourseXblock, getCourseDetails, getCourseItem } from './api'; +import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks'; +import type { XBlock } from '@src/data/types'; +import { getCourseKey } from '@src/generic/key-utils'; +import { handleResponseErrors } from '@src/generic/saving-error-alert'; +import { + QueryClient, + skipToken, useMutation, useQuery, useQueryClient, +} from '@tanstack/react-query'; +import { + createCourseXblock, + type CreateCourseXBlockType, + deleteCourseItem, + editItemDisplayName, + getCourseDetails, + getCourseItem, + publishCourseItem, +} from './api'; export const courseOutlineQueryKeys = { all: ['courseOutline'], /** * Base key for data specific to a course in outline */ - contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], - courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId], - courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'], - legacyLibReadyToMigrateBlocks: (courseId: string) => [...courseOutlineQueryKeys.all, courseId, 'legacyLibReadyToMigrateBlocks'], + course: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], + courseItemId: (itemId?: string) => [ + ...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId) : undefined), + itemId, + ], + courseDetails: (courseId?: string) => [ + ...courseOutlineQueryKeys.course(courseId), + 'details', + ], + legacyLibReadyToMigrateBlocks: (courseId: string) => [ + ...courseOutlineQueryKeys.course(courseId), + 'legacyLibReadyToMigrateBlocks', + ], legacyLibReadyToMigrateBlocksStatus: (courseId: string, taskId?: string) => [ ...courseOutlineQueryKeys.legacyLibReadyToMigrateBlocks(courseId), 'status', @@ -17,24 +41,54 @@ export const courseOutlineQueryKeys = { ], }; +type ParentIds = { + /** This id will be used to invalidate data of parent subsection */ + subsectionId?: string; + /** This id will be used to invalidate data of parent section */ + sectionId?: string; +}; + +/** + * Invalidate parent Subsection and Section data. + */ +const invalidateParentQueries = async (queryClient: QueryClient, variables: ParentIds) => { + if (variables.subsectionId) { + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) }); + } + if (variables.sectionId) { + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.sectionId) }); + } +}; + +type CreateCourseXBlockMutationProps = CreateCourseXBlockType & ParentIds; + /** * Hook to create an XBLOCK in a course . * The `locator` is the ID of the parent block where this new XBLOCK should be created. * Can also be used to import block from library by passing `libraryContentKey` in request body */ export const useCreateCourseBlock = ( - callback?: ((locator: string, parentLocator: string) => void), -) => useMutation({ - mutationFn: createCourseXblock, - onSettled: async (data: { locator: string }, _err, variables) => { - callback?.(data.locator, variables.parentLocator); - }, -}); + callback?: ((locator: string, parentLocator: string) => Promise), +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables), + onSettled: async (data: { locator: string; }, _err, variables) => { + await callback?.(data.locator, variables.parentLocator); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)), + }); + invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e)); + }, + }); +}; -export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( +export const useCourseItemData = (itemId?: string, initialData?: T, enabled: boolean = true) => ( useQuery({ + initialData, queryKey: courseOutlineQueryKeys.courseItemId(itemId), - queryFn: enabled && itemId !== undefined ? () => getCourseItem(itemId!) : skipToken, + queryFn: enabled && itemId ? () => getCourseItem(itemId!) : skipToken, }) ); @@ -44,3 +98,46 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) => queryFn: enabled && courseId ? () => getCourseDetails(courseId) : skipToken, }) ); + +export const useUpdateCourseBlockName = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (variables:{ + itemId: string; + displayName: string; + } & ParentIds) => editItemDisplayName({ itemId: variables.itemId, displayName: variables.displayName }), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) }); + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) }); + await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); + await invalidateParentQueries(queryClient, variables); + }, + }); +}; + +export const usePublishCourseItem = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (variables:{ + itemId: string; + } & ParentIds) => publishCourseItem(variables.itemId), + onSettled: (_data, _err, variables) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) }); + invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e)); + }, + }); +}; + +export const useDeleteCourseItem = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (variables:{ + itemId: string; + } & ParentIds) => deleteCourseItem(variables.itemId), + onSettled: (_data, _err, variables) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) }); + invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e)); + }, + }); +}; diff --git a/src/course-outline/data/selectors.ts b/src/course-outline/data/selectors.ts index 587badfcc..b5ab44022 100644 --- a/src/course-outline/data/selectors.ts +++ b/src/course-outline/data/selectors.ts @@ -3,9 +3,6 @@ export const getLoadingStatus = (state) => state.courseOutline.loadingStatus; export const getStatusBarData = (state) => state.courseOutline.statusBarData; export const getSavingStatus = (state) => state.courseOutline.savingStatus; export const getSectionsList = (state) => state.courseOutline.sectionsList; -export const getCurrentItem = (state) => state.courseOutline.currentItem; -export const getCurrentSection = (state) => state.courseOutline.currentSection; -export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection; export const getCourseActions = (state) => state.courseOutline.actions; export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; diff --git a/src/course-outline/data/slice.ts b/src/course-outline/data/slice.ts index c56d66470..9b23f65a0 100644 --- a/src/course-outline/data/slice.ts +++ b/src/course-outline/data/slice.ts @@ -33,13 +33,9 @@ const initialState = { }, videoSharingEnabled: false, videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo, - hasChanges: false, }, sectionsList: [], isCustomRelativeDatesActive: false, - currentSection: {}, - currentSubsection: {}, - currentItem: {}, actions: { deletable: true, unlinkable: false, @@ -125,21 +121,12 @@ const slice = createSlice({ updateSectionList: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.map((section) => (section.id in payload ? payload[section.id] : section)); }, - setCurrentItem: (state: CourseOutlineState, { payload }) => { - state.currentItem = payload; - }, reorderSectionList: (state: CourseOutlineState, { payload }) => { const sectionsList = [...state.sectionsList]; sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id)); state.sectionsList = [...sectionsList]; }, - setCurrentSection: (state: CourseOutlineState, { payload }) => { - state.currentSection = payload; - }, - setCurrentSubsection: (state: CourseOutlineState, { payload }) => { - state.currentSubsection = payload; - }, addSection: (state: CourseOutlineState, { payload }) => { state.sectionsList = [ ...state.sectionsList, @@ -183,6 +170,25 @@ const slice = createSlice({ return section; }); }, + // FIXME: This is a temporary measure to add unit using redux even while we are + // actively trying to get rid of it. + // To remove this and other add functions, we need to migrate course outline data + // to a react-query and perform optimistic updates to add/remove content. + addUnit: /* istanbul ignore next */ (state: CourseOutlineState, { payload }) => { + state.sectionsList = state.sectionsList.map((section) => { + section.childInfo.children = section.childInfo.children.map((subsection) => { + if (subsection.id !== payload.parentLocator) { + return subsection; + } + subsection.childInfo.children = [ + ...subsection.childInfo.children.filter(({ id }) => id !== payload.data.id), + payload.data, + ]; + return subsection; + }); + return section; + }); + }, deleteUnit: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.map((section) => { if (section.id !== payload.sectionId) { @@ -233,12 +239,10 @@ export const { updateCourseLaunchQueryStatus, updateSavingStatus, updateSectionList, - setCurrentItem, - setCurrentSection, - setCurrentSubsection, deleteSection, deleteSubsection, deleteUnit, + addUnit, duplicateSection, reorderSectionList, setPasteFileNotices, diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 5c975fe24..8441715cf 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -11,15 +11,12 @@ import { } from '../utils/getChecklistForStatusBar'; import { getErrorDetails } from '../utils/getErrorDetails'; import { - deleteCourseItem, duplicateCourseItem, - editItemDisplayName, enableCourseHighlightsEmails, getCourseBestPractices, getCourseLaunch, getCourseOutlineIndex, getCourseItem, - publishCourseSection, configureCourseSection, configureCourseSubsection, configureCourseUnit, @@ -42,9 +39,6 @@ import { updateSavingStatus, updateSectionList, updateFetchSectionLoadingStatus, - deleteSection, - deleteSubsection, - deleteUnit, duplicateSection, reorderSectionList, setPasteFileNotices, @@ -266,26 +260,6 @@ export function updateCourseSectionHighlightsQuery(sectionId: string, highlights }; } -export function publishCourseItemQuery(itemId: string, sectionId: string) { - 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 { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -376,75 +350,6 @@ export function configureCourseUnitQuery( }; } -export function editCourseItemQuery(itemId: string, sectionId: string, displayName: string) { - 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 { - 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 - */ -function deleteCourseItemQuery(itemId: string, 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 { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - -export function deleteCourseSectionQuery(sectionId: string) { - return async (dispatch) => { - dispatch(deleteCourseItemQuery( - sectionId, - () => deleteSection({ itemId: sectionId }), - )); - }; -} - -export function deleteCourseSubsectionQuery(subsectionId: string, sectionId: string) { - return async (dispatch) => { - dispatch(deleteCourseItemQuery( - subsectionId, - () => deleteSubsection({ itemId: subsectionId, sectionId }), - )); - }; -} - -export function deleteCourseUnitQuery(unitId: string, subsectionId: string, sectionId: string) { - return async (dispatch) => { - dispatch(deleteCourseItemQuery( - unitId, - () => deleteUnit({ itemId: unitId, subsectionId, sectionId }), - )); - }; -} - /** * Generic function to duplicate any course item. See wrapper functions below for specific implementations. * @param {string} itemId diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index ea0b7496f..55323104f 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -33,6 +33,7 @@ export interface CourseDetails { subtitle?: string; org: string; description?: string; + hasChanges: boolean; } export interface ChecklistType { @@ -50,7 +51,6 @@ export interface CourseOutlineStatusBar { checklist: ChecklistType; videoSharingEnabled: boolean; videoSharingOptions: string; - hasChanges: boolean; } export interface CourseOutlineState { @@ -71,12 +71,22 @@ export interface CourseOutlineState { statusBarData: CourseOutlineStatusBar; sectionsList: Array; isCustomRelativeDatesActive: boolean; - currentSection: XBlock | {}; - currentSubsection: XBlock | {}; - currentItem: XBlock | {}; actions: XBlockActions; enableProctoredExams: boolean; enableTimedExams: boolean; pasteFileNotices: object; createdOn: null | Date; } + +export interface CourseItemUpdateResult { + id: string; + data?: object | null; + metadata: { + downstreamCustomized?: string[]; + topLevelDownstreamParentKey?: string; + upstream?: string; + upstreamDisplayName?: string; + upstreamVersion?: number; + displayName?: string; + } +} diff --git a/src/course-outline/header-navigations/HeaderActions.test.tsx b/src/course-outline/header-navigations/HeaderActions.test.tsx index c992548c3..772339f29 100644 --- a/src/course-outline/header-navigations/HeaderActions.test.tsx +++ b/src/course-outline/header-navigations/HeaderActions.test.tsx @@ -3,9 +3,10 @@ import { fireEvent, initializeMocks, render, screen, } from '@src/testUtils'; +import { OutlineSidebarProvider } from '@src/course-outline'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import messages from './messages'; import HeaderActions, { HeaderActionsProps } from './HeaderActions'; -import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; const headerNavigationsActions = { lmsLink: '', @@ -34,7 +35,15 @@ const renderComponent = (props?: Partial) => render( courseActions={courseActions} {...props} />, - { extraWrapper: OutlineSidebarProvider }, + { + extraWrapper: ({ children }) => ( + + + {children} + + + ), + }, ); describe('', () => { diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx index bddd876e9..5406c96b1 100644 --- a/src/course-outline/header-navigations/HeaderActions.tsx +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -28,7 +28,13 @@ const HeaderActions = ({ const intl = useIntl(); const { lmsLink } = actions; - const { setCurrentPageKey } = useOutlineSidebarContext(); + const { clearSelection, open, setCurrentPageKey } = useOutlineSidebarContext(); + + const handleCourseInfoClick = () => { + clearSelection(); + setCurrentPageKey('info'); + open(); + }; return ( @@ -42,7 +48,7 @@ const HeaderActions = ({ > + + ); + } + + if (parentData?.upstreamInfo?.readyToSync) { + return ( + + + + + ); + } + + return ( + + + + + ); +}; + +const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => { + const { upstreamInfo } = blockData; + const { selectedContainerState } = useOutlineSidebarContext(); + const { openUnlinkModal } = useCourseAuthoringContext(); + const messageValues = { + name: displayName, + }; + + const handleUnlinkClick = () => { + // istanbul ignore if + if (!selectedContainerState?.sectionId) { + return; + } + openUnlinkModal({ value: blockData, sectionId: selectedContainerState.sectionId }); + }; + + const handleSyncClick = () => { + openSyncModal(blockData); + }; + + if (upstreamInfo?.errorMessage) { + return ( + + + + + ); + } + + if (upstreamInfo?.readyToSync) { + return ( + + + + + ); + } + + if ((upstreamInfo?.downstreamCustomized.length || 0) > 0) { + return ( + + ); + } + + return null; +}; + +interface Props { + itemId?: string; +} + +export const LibraryReferenceCard = ({ itemId }: Props) => { + const { data: itemData, isPending } = useCourseItemData(itemId); + const { selectedContainerState } = useOutlineSidebarContext(); + const { courseId } = useCourseAuthoringContext(); + const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue(); + const dispatch = useDispatch(); + const queryClient = useQueryClient(); + + const blockSyncData = useMemo(() => { + if (!syncModalData?.upstreamInfo?.readyToSync) { + return undefined; + } + return { + displayName: syncModalData.displayName, + downstreamBlockId: syncModalData.id, + upstreamBlockId: syncModalData.upstreamInfo.upstreamRef, + upstreamBlockVersionSynced: syncModalData.upstreamInfo.versionSynced, + isReadyToSyncIndividually: syncModalData.upstreamInfo.isReadyToSyncIndividually, + isContainer: ['vertical', 'sequential', 'chapter'].includes(syncModalData.category), + blockType: normalizeContainerType(syncModalData.category), + }; + }, [syncModalData]); + + // istanbul ignore next + const handleOnPostChangeSync = useCallback(() => { + if (selectedContainerState?.sectionId) { + dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId])); + } + if (courseId) { + invalidateLinksQuery(queryClient, courseId); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.course(courseId), + }); + } + }, [dispatch, selectedContainerState, queryClient, courseId]); + + if (!itemData?.upstreamInfo?.upstreamRef) { + return null; + } + + return ( +
+ + + + + +

+
+ + +
+
+
+ {blockSyncData && ( + + )} +
+ ); +}; diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx index 1c1b13320..904b0cf6e 100644 --- a/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { initializeMocks, render, screen } from '@src/testUtils'; import * as CourseAuthoringContext from '@src/CourseAuthoringContext'; import * as CourseDetailsApi from '@src/data/apiHooks'; @@ -17,6 +16,7 @@ jest.mock('@src/content-tags-drawer', () => ({ describe('OutlineAlignSidebar', () => { beforeEach(() => { + initializeMocks(); jest .spyOn(CourseAuthoringContext, 'useCourseAuthoringContext') .mockReturnValue({ @@ -25,8 +25,9 @@ describe('OutlineAlignSidebar', () => { jest .spyOn(OutlineSidebarContext, 'useOutlineSidebarContext') .mockReturnValue({ - currentContainerId: - 'block-v1:test+course+run+type@sequential+block@seq1', + selectedContainerState: { + currentId: 'block-v1:test+course+run+type@sequential+block@seq1', + }, } as any); jest .spyOn(CourseDetailsApi, 'useCourseDetails') @@ -54,4 +55,25 @@ describe('OutlineAlignSidebar', () => { 'drawer-mock-block-v1:test+course+run+type@sequential+block@seq1-component', ); }); + + it('renders ContentTagsDrawer with the course name', async () => { + jest + .spyOn(OutlineSidebarContext, 'useOutlineSidebarContext') + .mockReturnValue({ + selectedContainerState: undefined, + } as any); + jest + .spyOn(CourseDetailsApi, 'useCourseDetails') + .mockReturnValue({ + data: { courseDisplayNameWithDefault: 'Test Course' }, + } as any); + jest + .spyOn(ContentDataApi, 'useContentData') + .mockReturnValue({ + data: { courseDisplayNameWithDefault: 'Test Course' }, + } as any); + render(); + + expect(await screen.findByText('Test Course')).toBeInTheDocument(); + }); }); diff --git a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx index 73593d057..acf10b88f 100644 --- a/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineAlignSidebar.tsx @@ -2,23 +2,26 @@ import { SchoolOutline } from '@openedx/paragon/icons'; import { ContentTagsDrawer } from '@src/content-tags-drawer'; import { useContentData } from '@src/content-tags-drawer/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { useCourseDetails } from '@src/data/apiHooks'; import { SidebarTitle } from '@src/generic/sidebar'; import { useOutlineSidebarContext } from './OutlineSidebarContext'; export const OutlineAlignSidebar = () => { - const { courseId } = useCourseAuthoringContext(); - const { currentContainerId } = useOutlineSidebarContext(); - - const sidebarContentId = currentContainerId || courseId; - const { - data: courseData, - } = useCourseDetails(courseId); + courseId, + currentSelection, + setCurrentSelection, + } = useCourseAuthoringContext(); + const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); - const { - data: contentData, - } = useContentData(currentContainerId); + const sidebarContentId = currentSelection?.currentId || selectedContainerState?.currentId || courseId; + + const { data: contentData } = useContentData(sidebarContentId); + + // istanbul ignore next + const handleBack = () => { + clearSelection(); + setCurrentSelection(undefined); + }; return (
@@ -26,9 +29,10 @@ export const OutlineAlignSidebar = () => { title={ contentData && 'displayName' in contentData ? contentData.displayName - : courseData?.name || '' + : contentData?.courseDisplayNameWithDefault || '' } icon={SchoolOutline} + onBackBtnClick={(sidebarContentId !== courseId) ? handleBack : undefined} /> ({ useCourseDetails: jest.fn().mockReturnValue({ isPending: false, data: { title: 'Test Course' } }), useCreateCourseBlock: jest.fn(), + useCourseItemData: jest.fn().mockReturnValue({ data: {} }), })); const courseId = '123'; diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index 2151bc41d..993e2e012 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -8,73 +8,130 @@ import { } from 'react'; import { useToggle } from '@openedx/paragon'; -import { useStateWithUrlSearchParam } from '@src/hooks'; +import { useEscapeClick, useStateWithUrlSearchParam, useToggleWithValue } from '@src/hooks'; +import { SelectionState, XBlock } from '@src/data/types'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { useSelector } from 'react-redux'; +import { getSectionsList } from '@src/course-outline/data/selectors'; +import { findLast, findLastIndex } from 'lodash'; +import { ContainerType } from '@src/generic/key-utils'; import { isOutlineNewDesignEnabled } from '../utils'; export type OutlineSidebarPageKeys = 'help' | 'info' | 'add' | 'align'; -export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null; export type OutlineFlow = { - flowType: 'use-section'; - parentLocator?: string; - parentTitle?: string; -} | { - flowType: OutlineFlowType; + flowType: ContainerType; parentLocator: string; - parentTitle: string; + grandParentLocator?: string; }; interface OutlineSidebarContextData { currentPageKey: OutlineSidebarPageKeys; - setCurrentPageKey: (pageKey: OutlineSidebarPageKeys, containerId?: string) => void; - currentFlow: OutlineFlow | null; + setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void; + isCurrentFlowOn?: boolean; + currentFlow?: OutlineFlow; startCurrentFlow: (flow: OutlineFlow) => void; stopCurrentFlow: () => void; isOpen: boolean; open: () => void; toggle: () => void; - selectedContainerId?: string; - // The Id of the container used in the current sidebar page - // The container is not necessarily selected to open a selected sidebar. - // Example: Align sidebar - currentContainerId?: string; - openContainerInfoSidebar: (containerId: string) => void; + selectedContainerState?: SelectionState; + openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void; + clearSelection: () => void; + /** Stores last section that allows adding subsections inside it. */ + lastEditableSection?: XBlock; + /** Stores last subsection that allows adding units inside it and its parent sectionId */ + lastEditableSubsection?: { data?: XBlock, sectionId?: string }; + /** XBlock data of selectedContainerState.currentId */ + currentItemData?: XBlock; } const OutlineSidebarContext = createContext(undefined); +const getLastEditableItem = (blockList: Array) => findLast(blockList, (item) => item.actions.childAddable); + +const getLastEditableSubsection = ( + blockList: Array, + startIndex?: number, +): { data: XBlock, sectionId: string } | undefined => { + const lastSectionIndex = findLastIndex(blockList, (item) => item.actions.childAddable, startIndex); + if (lastSectionIndex !== -1) { + const lastSubsectionIndex = findLastIndex( + blockList[lastSectionIndex].childInfo.children, + (item) => item.actions.childAddable, + ); + if (lastSubsectionIndex !== -1) { + return { + data: blockList[lastSectionIndex].childInfo.children[lastSubsectionIndex], + sectionId: blockList[lastSectionIndex].id, + }; + } + if (lastSectionIndex > 0) { + return getLastEditableSubsection(blockList, lastSectionIndex - 1); + } + } + return undefined; +}; + export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => { - const [currentContainerId, setCurrentContainerId] = useState(); const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam( 'info', 'sidebar', (value: string) => value as OutlineSidebarPageKeys, (value: OutlineSidebarPageKeys) => value, ); - const [currentFlow, setCurrentFlow] = useState(null); + const [ + isCurrentFlowOn, + currentFlow, + setCurrentFlow, + stopCurrentFlow, + ] = useToggleWithValue(); const [isOpen, open, , toggle] = useToggle(true); - const [selectedContainerId, setSelectedContainerId] = useState(); - - const openContainerInfoSidebar = useCallback((containerId: string) => { - if (isOutlineNewDesignEnabled()) { - setSelectedContainerId(containerId); - } - }, [setSelectedContainerId]); + /** + * Use this to store the selected container's information and should always contain full ancestor info. + * If selected container is a section, set containerId and sectionId to same value and subsectionId should + * be undefined. + * If selected container is a subsection, set containerId and subsectionId to same value and sectionId + * should be set to its parent section id. + * If selected container is an unit, set containerId as unitId, subsectionId as its parent subsection's id + * and sectionId should be set to its top parent section's id. + */ + const [selectedContainerState, setSelectedContainerState] = useState(); + const { setCurrentSelection } = useCourseAuthoringContext(); /** - * Stops current add content flow. - * This will cause the sidebar to switch back to its normal state and clear out any placeholder containers. + * Set currentSelection to same as selectedContainerState whenever + * selectedContainerState or currentPageKey changes. + * This allows us to reset the currentSelection. */ - const stopCurrentFlow = useCallback(() => { - setCurrentFlow(null); - }, [setCurrentFlow]); + useEffect(() => { + // To allow tag buttons on other cards to jump to align page and not loose its selection + if (currentPageKey !== 'align') { + setCurrentSelection(selectedContainerState); + } + }, [currentPageKey, selectedContainerState]); - const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys, containerId?: string) => { + const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => { setCurrentPageKeyState(pageKey); - setCurrentFlow(null); - setCurrentContainerId(containerId); + stopCurrentFlow(); open(); - }, [open, setCurrentFlow]); + }, [open, stopCurrentFlow]); + + const openContainerInfoSidebar = useCallback(( + containerId: string, + subsectionId?: string, + sectionId?: string, + ) => { + if (isOutlineNewDesignEnabled()) { + setSelectedContainerState({ currentId: containerId, subsectionId, sectionId }); + setCurrentPageKey('info'); + } + }, [setSelectedContainerState, setCurrentPageKey]); + + const clearSelection = useCallback(() => { + setSelectedContainerState(undefined); + }, [selectedContainerState]); /** * Starts add content flow. @@ -86,45 +143,73 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod setCurrentFlow(flow); }, [setCurrentFlow, setCurrentPageKey]); - useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - stopCurrentFlow(); - } - }; - window.addEventListener('keydown', handleEsc); + const { data: currentItemData } = useCourseItemData(selectedContainerState?.currentId); + const sectionsList = useSelector(getSectionsList); - return () => { - window.removeEventListener('keydown', handleEsc); - }; - }, []); + /** Stores last section that allows adding subsections inside it. */ + const lastEditableSection = useMemo(() => { + if (currentItemData?.category === 'chapter' && currentItemData.actions.childAddable) { + return currentItemData; + } + return currentItemData ? undefined : getLastEditableItem(sectionsList); + }, [currentItemData, sectionsList]); + + /** Stores last subsection that allows adding units inside it. */ + const lastEditableSubsection = useMemo(() => { + if (currentItemData?.category === 'sequential' && currentItemData.actions.childAddable) { + return { data: currentItemData, sectionId: selectedContainerState?.sectionId }; + } + if (currentItemData?.category === 'chapter') { + return { + data: getLastEditableItem(currentItemData?.childInfo.children || []), + sectionId: selectedContainerState?.currentId, + }; + } + return currentItemData ? undefined : getLastEditableSubsection(sectionsList); + }, [currentItemData, sectionsList, selectedContainerState]); + + useEscapeClick({ + onEscape: () => { + stopCurrentFlow(); + setSelectedContainerState(undefined); + }, + dependency: [stopCurrentFlow], + }); const context = useMemo( () => ({ currentPageKey, setCurrentPageKey, + isCurrentFlowOn, currentFlow, startCurrentFlow, stopCurrentFlow, isOpen, open, toggle, - selectedContainerId, - currentContainerId, + selectedContainerState, openContainerInfoSidebar, + clearSelection, + lastEditableSection, + lastEditableSubsection, + currentItemData, }), [ currentPageKey, setCurrentPageKey, + isCurrentFlowOn, currentFlow, startCurrentFlow, stopCurrentFlow, isOpen, open, toggle, - selectedContainerId, - currentContainerId, + selectedContainerState, openContainerInfoSidebar, + clearSelection, + lastEditableSection, + lastEditableSubsection, + currentItemData, ], ); diff --git a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx index 8aaf0412a..97eebd89b 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx @@ -9,7 +9,7 @@ import type { SidebarPage } from '@src/generic/sidebar'; import { AddSidebar } from './AddSidebar'; import { OutlineAlignSidebar } from './OutlineAlignSidebar'; import OutlineHelpSidebar from './OutlineHelpSidebar'; -import { OutlineInfoSidebar } from './OutlineInfoSidebar'; +import { InfoSidebar } from './info-sidebar/InfoSidebar'; import messages from './messages'; export type OutlineSidebarPages = { @@ -19,9 +19,9 @@ export type OutlineSidebarPages = { align?: SidebarPage; }; -const getOutlineSidebarPages = () => ({ +export const getOutlineSidebarPages = () => ({ info: { - component: OutlineInfoSidebar, + component: InfoSidebar, icon: Info, title: messages.sidebarButtonInfo, }, @@ -55,9 +55,9 @@ const getOutlineSidebarPages = () => ({ * export function CourseOutlineSidebarWrapper( * { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps }, * ) { - * const sidebarPages = useOutlineSidebarPagesContext(); * * const AnalyticsPage = React.useCallback(() => , [pluginProps]); + * const sidebarPages = useOutlineSidebarPagesContext(); * * const overridedPages = useMemo(() => ({ * ...sidebarPages, @@ -72,7 +72,6 @@ const getOutlineSidebarPages = () => ({ * * {component} * - * ); *} */ export const OutlineSidebarPagesContext = createContext(undefined); @@ -82,6 +81,8 @@ type OutlineSidebarPagesProviderProps = { }; export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesProviderProps) => { + // align page is sometimes not added when getOutlineSidebarPages() is called at the top level. + // So if we call it inside the hook, getConfig has updated values and align page is added. const sidebarPages = useMemo(getOutlineSidebarPages, []); return ( diff --git a/src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx similarity index 92% rename from src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx rename to src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index b75dad5a8..e02afd49c 100644 --- a/src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -8,11 +8,11 @@ import { useGetBlockTypes } from '@src/search-manager'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; -import { useCourseDetails } from '../data/apiHooks'; -import messages from './messages'; +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; +import messages from '../messages'; -export const OutlineInfoSidebar = () => { +export const CourseInfoSidebar = () => { const intl = useIntl(); const { courseId } = useCourseAuthoringContext(); const { data: courseDetails } = useCourseDetails(courseId); diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx new file mode 100644 index 000000000..d35417bcb --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSection.tsx @@ -0,0 +1,57 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useToggle } from '@openedx/paragon'; +import { SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard'; +import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; +import { normalizeContainerType } from '@src/generic/key-utils'; +import { SidebarContent, SidebarSection } from '@src/generic/sidebar'; +import { useGetBlockTypes } from '@src/search-manager'; +import messages from '../messages'; + +interface Props { + itemId: string; +} + +export const InfoSection = ({ itemId }: Props) => { + const intl = useIntl(); + const { data: itemData } = useCourseItemData(itemId); + const { data: componentData } = useGetBlockTypes( + [`breadcrumbs.usage_key = "${itemId}"`], + ); + const category = normalizeContainerType(itemData?.category || ''); + + const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + + return ( + <> + + + + {componentData && } + + + + + + + + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx new file mode 100644 index 000000000..a8a1d415b --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx @@ -0,0 +1,134 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import { SelectionState } from '@src/data/types'; +import { OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { getXBlockApiUrl } from '@src/course-outline/data/api'; +import userEvent from '@testing-library/user-event'; +import { InfoSidebar } from './InfoSidebar'; + +let selectedContainerState: SelectionState | undefined; +jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ + ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext'), + useOutlineSidebarContext: () => ({ + ...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), + selectedContainerState, + }), +})); + +jest.mock('@src/course-outline/data/apiHooks', () => ({ + ...jest.requireActual('@src/course-outline/data/apiHooks'), + useCourseDetails: () => ({ + data: { title: 'Course name' }, + isLoading: false, + }), +})); + +const openPublishModal = jest.fn(); +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + setCurrentSelection: jest.fn(), + openPublishModal, + getUnitUrl: jest.fn(), + }), +})); + +jest.mock('@src/search-manager', () => ({ + useGetBlockTypes: () => ({ data: [] }), +})); + +const renderComponent = () => render(, { extraWrapper: OutlineSidebarProvider }); +let axiosMock; + +describe('InfoSidebar component', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + }); + + it('renders InfoSidebar with course info if selectedContainerState is undefined', async () => { + renderComponent(); + expect(await screen.findByText('Course name')).toBeInTheDocument(); + }); + + it('renders InfoSidebar with section info', async () => { + const user = userEvent.setup(); + selectedContainerState = { + currentId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123', + }; + const data = { + id: selectedContainerState.currentId, + displayName: 'section name', + category: 'chapter', + hasChanges: true, + }; + axiosMock + .onGet(getXBlockApiUrl(selectedContainerState.currentId)) + .reply(200, data); + renderComponent(); + expect(await screen.findByText('section name')).toBeInTheDocument(); + expect(await screen.findByText('Section Content Summary')).toBeInTheDocument(); + const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(openPublishModal).toHaveBeenCalledWith({ + value: data, + sectionId: data.id, + }); + }); + + it('renders InfoSidebar with subsection info', async () => { + const user = userEvent.setup(); + selectedContainerState = { + currentId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123', + sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123', + }; + const data = { + id: selectedContainerState.currentId, + displayName: 'subsection name', + category: 'sequential', + hasChanges: true, + }; + axiosMock + .onGet(getXBlockApiUrl(selectedContainerState.currentId)) + .reply(200, data); + renderComponent(); + expect(await screen.findByText('subsection name')).toBeInTheDocument(); + expect(await screen.findByText('Subsection Content Summary')).toBeInTheDocument(); + const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(openPublishModal).toHaveBeenCalledWith({ + value: data, + sectionId: selectedContainerState.sectionId, + }); + }); + + it('renders InfoSidebar with unit info', async () => { + const user = userEvent.setup(); + selectedContainerState = { + currentId: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@123', + subsectionId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123', + sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123', + }; + const data = { + id: selectedContainerState.currentId, + displayName: 'unit name', + category: 'vertical', + hasChanges: true, + }; + axiosMock + .onGet(getXBlockApiUrl(selectedContainerState.currentId)) + .reply(200, data); + renderComponent(); + expect(await screen.findByText('unit name')).toBeInTheDocument(); + expect(await screen.findByText('Unit Content Summary')).toBeInTheDocument(); + const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(openPublishModal).toHaveBeenCalledWith({ + value: data, + subsectionId: selectedContainerState.subsectionId, + sectionId: selectedContainerState.sectionId, + }); + }); +}); diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx new file mode 100644 index 000000000..faf8f1193 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx @@ -0,0 +1,30 @@ +import { ContainerType, getBlockType } from '@src/generic/key-utils'; +import { useOutlineSidebarContext } from '../OutlineSidebarContext'; +import { CourseInfoSidebar } from './CourseInfoSidebar'; +import { SectionSidebar } from './SectionInfoSidebar'; +import { SubsectionSidebar } from './SubsectionInfoSidebar'; +import { UnitSidebar } from './UnitInfoSidebar'; + +export const InfoSidebar = () => { + const { selectedContainerState } = useOutlineSidebarContext(); + if (!selectedContainerState) { + return ( + + ); + } + const itemType = getBlockType(selectedContainerState.currentId); + + switch (itemType) { + case ContainerType.Chapter: + case ContainerType.Section: + return ; + case ContainerType.Sequential: + case ContainerType.Subsection: + return ; + case ContainerType.Vertical: + case ContainerType.Unit: + return ; + default: + return ; + } +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx b/src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx new file mode 100644 index 000000000..c932a577d --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx @@ -0,0 +1,22 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Button } from '@openedx/paragon'; +import messages from '../messages'; + +interface Props { + onClick: () => void; +} + +export const PublishButon = ({ onClick }: Props) => ( + +); diff --git a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx new file mode 100644 index 000000000..c1666d496 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs } from '@openedx/paragon'; + +import { getItemIcon } from '@src/generic/block-type-utils'; + +import { SidebarTitle } from '@src/generic/sidebar'; + +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { InfoSection } from './InfoSection'; +import messages from '../messages'; +import { PublishButon } from './PublishButon'; + +interface Props { + sectionId: string; +} + +export const SectionSidebar = ({ sectionId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'info' | 'settings'>('info'); + const { data: sectionData, isLoading } = useCourseItemData(sectionId); + const { openPublishModal } = useCourseAuthoringContext(); + const { clearSelection } = useOutlineSidebarContext(); + + const handlePublish = () => { + if (sectionData?.hasChanges) { + openPublishModal({ + value: sectionData, + sectionId: sectionData.id, + }); + } + }; + + if (isLoading) { + return ; + } + + return ( + <> + + {sectionData?.hasChanges && } + + + + + +
Settings
+
+
+ + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx new file mode 100644 index 000000000..5fe2f57be --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs } from '@openedx/paragon'; + +import { getItemIcon } from '@src/generic/block-type-utils'; + +import { SidebarTitle } from '@src/generic/sidebar'; + +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { InfoSection } from './InfoSection'; +import { PublishButon } from './PublishButon'; +import messages from '../messages'; + +interface Props { + subsectionId: string; +} + +export const SubsectionSidebar = ({ subsectionId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'info' | 'settings'>('info'); + const { data: subsectionData, isLoading } = useCourseItemData(subsectionId); + const { selectedContainerState } = useOutlineSidebarContext(); + const { openPublishModal } = useCourseAuthoringContext(); + const { clearSelection } = useOutlineSidebarContext(); + + const handlePublish = () => { + if (selectedContainerState?.sectionId && subsectionData?.hasChanges) { + openPublishModal({ + value: subsectionData, + sectionId: selectedContainerState?.sectionId, + }); + } + }; + + if (isLoading) { + return ; + } + + return ( + <> + + {subsectionData?.hasChanges && } + + + + + +
Settings
+
+
+ + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx new file mode 100644 index 000000000..203981aa6 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, Stack, Tab, Tabs, +} from '@openedx/paragon'; +import { + OpenInFull, +} from '@openedx/paragon/icons'; + +import { getItemIcon } from '@src/generic/block-type-utils'; + +import { SidebarTitle } from '@src/generic/sidebar'; + +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; +import { Link } from 'react-router-dom'; +import { useOutlineSidebarContext } from '../OutlineSidebarContext'; +import { PublishButon } from './PublishButon'; +import messages from '../messages'; +import { InfoSection } from './InfoSection'; + +interface Props { + unitId: string; +} + +export const UnitSidebar = ({ unitId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); + const { data: unitData, isLoading } = useCourseItemData(unitId); + const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); + const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); + + const handlePublish = () => { + if (unitData?.hasChanges) { + openPublishModal({ + value: unitData, + sectionId: selectedContainerState?.sectionId, + subsectionId: selectedContainerState?.subsectionId, + }); + } + }; + + if (isLoading) { + return ; + } + + return ( + <> + + + + {unitData?.hasChanges && ( + + )} + + + + + {}, handleDuplicate: () => {}, handleUnlink: () => {} }} + courseVerticalChildren={[]} + handleConfigureSubmit={() => {}} + readonly + /> + + + + + + +
Settings
+
+
+ + ); +}; diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index dd98edbe9..dcf59640d 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -125,6 +125,116 @@ const messages = defineMessages({ defaultMessage: 'Adding unit to {name}', description: 'Tab title for adding existing library unit to a specific parent in outline using sidebar', }, + sectionContentSummaryText: { + id: 'course-authoring.course-outline.sidebar.section.content-summary-text', + defaultMessage: 'Section Content Summary', + description: 'Title of the summary section in the section info sidebar', + }, + subsectionContentSummaryText: { + id: 'course-authoring.course-outline.sidebar.subsection.content-summary-text', + defaultMessage: 'Subsection Content Summary', + description: 'Title of the summary section in the subsection info sidebar', + }, + unitContentSummaryText: { + id: 'course-authoring.course-outline.sidebar.unit.content-summary-text', + defaultMessage: 'Unit Content Summary', + description: 'Title of the summary section in the unit info sidebar', + }, + openUnitPage: { + id: 'course-authoring.course-outline.sidebar.unit.open-btn-text', + defaultMessage: 'Open', + description: 'Button to open unit page from sidebar', + }, + publishContainerButton: { + id: 'course-authoring.course-outline.sidebar.generic.publish.button', + defaultMessage: 'Publish Changes', + description: 'Publish button text', + }, + draftText: { + id: 'course-authoring.course-outline.sidebar.generic.draft.button', + defaultMessage: '(Draft)', + description: 'Draft text in publish button', + }, + previewTabText: { + id: 'course-authoring.course-outline.sidebar.generic.preview.tab.text', + defaultMessage: 'Preview', + description: 'Preview tab title in container sidebar', + }, + infoTabText: { + id: 'course-authoring.course-outline.sidebar.generic.info.tab.text', + defaultMessage: 'Details', + description: 'Information tab title in container sidebar', + }, + settingsTabText: { + id: 'course-authoring.course-outline.sidebar.generic.info.settings.text', + defaultMessage: 'Settings', + description: 'Settings tab title in container sidebar', + }, + libraryReferenceCardText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.text', + defaultMessage: 'Library Reference', + description: 'Library reference card text in sidebar', + }, + hasTopParentText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text', + defaultMessage: '{name} was reused as part of a {parentType}.', + description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block', + }, + hasTopParentBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn', + defaultMessage: 'View {parentType}', + description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block', + }, + hasTopParentReadyToSyncText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text', + defaultMessage: '{name} was reused as part of a {parentType} which has updates available.', + description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block', + }, + hasTopParentReadyToSyncBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn', + defaultMessage: 'Review Updates', + description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block', + }, + hasTopParentBrokenLinkText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text', + defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.', + description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.', + }, + hasTopParentBrokenLinkBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn', + defaultMessage: 'Unlink {parentType}', + description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.', + }, + topParentBrokenLinkText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text', + defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.', + description: 'Text displayed in sidebar library reference card when a block has a broken link.', + }, + topParentBrokenLinkBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn', + defaultMessage: 'Unlink from library', + description: 'Text displayed in sidebar library reference card button when a block has a broken link.', + }, + topParentModifiedText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text', + defaultMessage: '{name} has been modified in this course.', + description: 'Text displayed in sidebar library reference card when it is modified in course.', + }, + topParentReaadyToSyncText: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text', + defaultMessage: '{name} has available updates', + description: 'Text displayed in sidebar library reference card when it is has updates available.', + }, + topParentReaadyToSyncBtn: { + id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn', + defaultMessage: 'Review Updates', + description: 'Text displayed in sidebar library reference card button when it is has updates available.', + }, + cannotAddAlertMsg: { + id: 'course-authoring.course-outline.sidebar.library.reference.add-sidebar.alert.text', + defaultMessage: '{name} is a library {category}. Content cannot be added to Library referenced {category}s.', + description: 'Alert displayed in sidebar when author tries to add content in library referenced blocks', + }, }); export default messages; diff --git a/src/course-outline/publish-modal/PublishModal.jsx b/src/course-outline/publish-modal/PublishModal.jsx deleted file mode 100644 index b56488c05..000000000 --- a/src/course-outline/publish-modal/PublishModal.jsx +++ /dev/null @@ -1,93 +0,0 @@ -/* 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 '@openedx/paragon'; -import { useSelector } from 'react-redux'; - -import { getCurrentItem } from '../data/selectors'; -import { COURSE_BLOCK_NAMES } from '../constants'; -import messages from './messages'; - -const PublishModal = ({ - isOpen, - onClose, - onPublishSubmit, -}) => { - const intl = useIntl(); - const { displayName, childInfo, category } = useSelector(getCurrentItem); - const categoryName = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); - const children = childInfo?.children || []; - - return ( - - - - {intl.formatMessage(messages.title, { title: displayName })} - - - -

- {intl.formatMessage(messages.description, { category: categoryName })} -

- {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.test.jsx b/src/course-outline/publish-modal/PublishModal.test.jsx deleted file mode 100644 index f83a993d7..000000000 --- a/src/course-outline/publish-modal/PublishModal.test.jsx +++ /dev/null @@ -1,131 +0,0 @@ -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 { AppProvider } from '@edx/frontend-platform/react'; - -import initializeStore from '../../store'; -import PublishModal from './PublishModal'; -import messages from './messages'; - -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(); - 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/PublishModal.test.tsx b/src/course-outline/publish-modal/PublishModal.test.tsx new file mode 100644 index 000000000..08c42d480 --- /dev/null +++ b/src/course-outline/publish-modal/PublishModal.test.tsx @@ -0,0 +1,121 @@ +import { initializeMocks, screen, render } from '@src/testUtils'; + +import userEvent from '@testing-library/user-event'; +import PublishModal from './PublishModal'; +import messages from './messages'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +const currentItemMock = { + id: 'section-id-1', + 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(); + +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + courseUsageKey: 'course-usage-key', + isPublishModalOpen: true, + currentPublishModalData: { value: currentItemMock }, + closePublishModal: onCloseMock, + }), +})); + +jest.mock('@src/course-outline/data/apiHooks', () => ({ + ...jest.requireActual('@src/course-outline/data/apiHooks'), + usePublishCourseItem: () => ({ + mutateAsync: onPublishSubmitMock, + }), +})); + +const renderComponent = () => render( + , +); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('renders PublishModal component correctly', async () => { + renderComponent(); + + expect(await screen.findByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.description.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(/Subsection 1/i)).toBeInTheDocument(); + expect(await screen.findByText(/Subsection_1 Unit 1/i)).toBeInTheDocument(); + expect(await screen.findByText(/Subsection 2/i)).toBeInTheDocument(); + expect(await screen.findByText(/Subsection_2 Unit 1/i)).toBeInTheDocument(); + expect(screen.queryByText(/Subsection 3/i)).not.toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.publishButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls the onClose function when the cancel button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const cancelButton = await screen.findByRole('button', { name: messages.cancelButton.defaultMessage }); + await user.click(cancelButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('calls the onPublishSubmit function when save button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const publishButton = await screen.findByRole('button', { name: messages.publishButton.defaultMessage }); + await user.click(publishButton); + expect(onPublishSubmitMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-outline/publish-modal/PublishModal.tsx b/src/course-outline/publish-modal/PublishModal.tsx new file mode 100644 index 000000000..741282471 --- /dev/null +++ b/src/course-outline/publish-modal/PublishModal.tsx @@ -0,0 +1,126 @@ +/* eslint-disable import/named */ +import React, { useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ModalDialog, + ActionRow, +} from '@openedx/paragon'; + +import { courseOutlineQueryKeys, usePublishCourseItem } from '@src/course-outline/data/apiHooks'; +import type { UnitXBlock, XBlock } from '@src/data/types'; +import LoadingButton from '@src/generic/loading-button'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useQueryClient } from '@tanstack/react-query'; +import messages from './messages'; +import { COURSE_BLOCK_NAMES } from '../constants'; + +const PublishModal = () => { + const intl = useIntl(); + const { isPublishModalOpen, currentPublishModalData, closePublishModal } = useCourseAuthoringContext(); + const { + id, displayName, category, + } = currentPublishModalData?.value || {}; + const categoryName = COURSE_BLOCK_NAMES[category || '']?.name.toLowerCase(); + const childInfo = (currentPublishModalData?.value && 'childInfo' in currentPublishModalData.value) + ? currentPublishModalData?.value.childInfo + : undefined; + const children: Array | undefined = childInfo?.children; + const publishMutation = usePublishCourseItem(); + const queryClient = useQueryClient(); + + const childrenIds = useMemo(() => children?.reduce(( + result: string[], + current: XBlock | UnitXBlock, + ): string[] => { + let temp = [...result]; + if ('childInfo' in current) { + const grandChildren = current.childInfo.children.filter((child) => child.hasChanges); + temp = [...temp, ...grandChildren.map((child) => child.id)]; + } + if (current.hasChanges) { + temp.push(current.id); + } + return temp; + }, []), [children]); + + const onPublishSubmit = async () => { + if (id) { + await publishMutation.mutateAsync({ + itemId: id, + subsectionId: currentPublishModalData?.subsectionId, + sectionId: currentPublishModalData?.sectionId, + }, { + onSettled: () => { + closePublishModal(); + // Update query client to refresh the data of all children blocks + childrenIds?.forEach((blockId) => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) }); + }); + }, + }); + } + }; + + return ( + + + + {intl.formatMessage(messages.title, { title: displayName })} + + + +

+ {intl.formatMessage(messages.description, { category: categoryName })} +

+ {children?.filter(child => child.hasChanges).map((child) => { + let grandChildren = 'childInfo' in child ? child.childInfo?.children : undefined; + grandChildren = grandChildren?.filter(grandChild => grandChild.hasChanges); + + return grandChildren?.length ? ( + + {child.displayName} + {grandChildren.map((grandChild) => ( +
+ {grandChild.displayName} +
+ ))} +
+ ) : ( +
+ {child.displayName} +
+ ); + })} +
+ + + + {intl.formatMessage(messages.cancelButton)} + + + + + +
+ ); +}; + +export default PublishModal; diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index 22d4c3082..dc35405e8 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -4,12 +4,15 @@ import { } from '@src/testUtils'; import { XBlock } from '@src/data/types'; import { Info } from '@openedx/paragon/icons'; +import userEvent from '@testing-library/user-event'; +import { getXBlockApiUrl } from '@src/course-outline/data/api'; +import { CourseInfoSidebar } from '@src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar'; import SectionCard from './SectionCard'; import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext'; -import { OutlineInfoSidebar } from '../outline-sidebar/OutlineInfoSidebar'; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); +const setCurrentSelection = jest.fn(); jest.mock('@src/course-unit/data/apiHooks', () => ({ useAcceptLibraryBlockChanges: () => ({ @@ -25,6 +28,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({ courseId: 5, handleAddSubsectionFromLibrary: jest.fn(), handleNewSubsectionSubmit: jest.fn(), + setCurrentSelection, }), })); @@ -48,9 +52,7 @@ const subsection = { isHeaderVisible: true, releasedToStudents: true, childInfo: { - children: [{ - id: unit.id, - }], + children: [unit], } as any, // 'as any' because we are omitting a lot of fields from 'childInfo' } satisfies Partial as XBlock; @@ -70,14 +72,7 @@ const section = { }, isHeaderVisible: true, childInfo: { - children: [{ - id: subsection.id, - childInfo: { - children: [{ - id: unit.id, - }], - }, - }], + children: [subsection], } as any, // 'as any' because we are omitting a lot of fields from 'childInfo' upstreamInfo: { readyToSync: true, @@ -91,20 +86,15 @@ const section = { }, } satisfies Partial as XBlock; -const onEditSectionSubmit = jest.fn(); - const renderComponent = (props?: object, entry = '/course/:courseId') => render( render( extraWrapper: OutlineSidebarContext.OutlineSidebarProvider, }, ); +let axiosMock; describe('', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); }); it('render SectionCard component correctly', () => { @@ -140,7 +135,8 @@ describe('', () => { expect(card).not.toHaveClass('outline-card-selected'); }); - it('render SectionCard component in selected state', () => { + it('render SectionCard component in selected state', async () => { + const user = userEvent.setup(); setConfig({ ...getConfig(), ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', @@ -150,16 +146,15 @@ describe('', () => { expect(screen.getByTestId('section-card-header')).toBeInTheDocument(); // The card is not selected - const card = screen.getByTestId('section-card'); - expect(card).not.toHaveClass('outline-card-selected'); + expect((await screen.findByTestId('section-card'))).not.toHaveClass('outline-card-selected'); // Get the that contains the card and click it to select the card const el = container.querySelector('div.row.mx-0') as HTMLInputElement; expect(el).not.toBeNull(); - fireEvent.click(el!); + await user.click(el!); // The card is selected - expect(card).toHaveClass('outline-card-selected'); + expect(await screen.findByTestId('section-card')).toHaveClass('outline-card-selected'); }); it('expands/collapses the card when the expand button is clicked', () => { @@ -175,24 +170,6 @@ describe('', () => { expect(screen.queryByRole('button', { name: 'New subsection' })).toBeInTheDocument(); }); - it('title only updates if changed', async () => { - renderComponent(); - - let editButton = await screen.findByTestId('section-edit-button'); - fireEvent.click(editButton); - let editField = await screen.findByTestId('section-edit-field'); - fireEvent.blur(editField); - - expect(onEditSectionSubmit).not.toHaveBeenCalled(); - - editButton = await screen.findByTestId('section-edit-button'); - fireEvent.click(editButton); - editField = await screen.findByTestId('section-edit-field'); - fireEvent.change(editField, { target: { value: 'some random value' } }); - fireEvent.blur(editField); - expect(onEditSectionSubmit).toHaveBeenCalled(); - }); - it('hides header based on isHeaderVisible flag', async () => { const { queryByTestId } = renderComponent({ section: { @@ -204,6 +181,17 @@ describe('', () => { }); it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => { + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + actions: { + draggable: true, + childAddable: false, + deletable: false, + duplicable: false, + }, + }); renderComponent({ section: { ...section, @@ -310,6 +298,7 @@ describe('', () => { }); it('should open legacy manage tags', async () => { + const user = userEvent.setup(); setConfig({ ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'true', @@ -318,22 +307,23 @@ describe('', () => { renderComponent(); const element = await screen.findByTestId('section-card'); const menu = await within(element).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); + await user.click(menu); const manageTagsBtn = await within(element).findByTestId('section-card-header__menu-manage-tags-button'); expect(manageTagsBtn).toBeInTheDocument(); - fireEvent.click(manageTagsBtn); + await user.click(manageTagsBtn); const drawer = await screen.findByRole('alert'); expect(within(drawer).getByText(/manage tags/i)); }); it('should open align sidebar', async () => { + const user = userEvent.setup(); const mockSetCurrentPageKey = jest.fn(); const testSidebarPage = { - component: OutlineInfoSidebar, + component: CourseInfoSidebar, icon: Info, title: '', }; @@ -351,10 +341,11 @@ describe('', () => { isOpen: true, open: jest.fn(), toggle: jest.fn(), - currentFlow: null, + currentFlow: undefined, startCurrentFlow: jest.fn(), stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), + clearSelection: jest.fn(), })); setConfig({ ...getConfig(), @@ -364,15 +355,19 @@ describe('', () => { renderComponent(); const element = await screen.findByTestId('section-card'); const menu = await within(element).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); + await user.click(menu); const manageTagsBtn = await within(element).findByTestId('section-card-header__menu-manage-tags-button'); expect(manageTagsBtn).toBeInTheDocument(); - fireEvent.click(manageTagsBtn); + await user.click(manageTagsBtn); await waitFor(() => { - expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', section.id); + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align'); + }); + expect(setCurrentSelection).toHaveBeenCalledWith({ + currentId: section.id, + sectionId: section.id, }); }); }); diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 70261be21..9673ec4f9 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -9,8 +9,6 @@ import { useSearchParams } from 'react-router-dom'; import classNames from 'classnames'; import { useQueryClient } from '@tanstack/react-query'; -import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; @@ -26,6 +24,8 @@ import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; +import moment from 'moment'; import messages from './messages'; interface SectionCardProps { @@ -34,12 +34,8 @@ interface SectionCardProps { isCustomRelativeDatesActive: boolean, children: ReactNode, onOpenHighlightsModal: (section: XBlock) => void, - onOpenPublishModal: () => void, onOpenConfigureModal: () => void, - onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void, - savingStatus?: RequestStatusType, onOpenDeleteModal: () => void, - onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, isSectionsExpanded: boolean, index: number, @@ -49,19 +45,15 @@ interface SectionCardProps { } const SectionCard = ({ - section, + section: initialData, isSelfPaced, isCustomRelativeDatesActive, children, index, canMoveItem, onOpenHighlightsModal, - onOpenPublishModal, onOpenConfigureModal, - onEditSectionSubmit, - savingStatus, onOpenDeleteModal, - onOpenUnlinkModal, onDuplicateSubmit, isSectionsExpanded, onOrderChange, @@ -70,12 +62,16 @@ const SectionCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === section.id; - const { courseId } = useCourseAuthoringContext(); + const { + courseId, openUnlinkModal, openPublishModal, setCurrentSelection, + } = useCourseAuthoringContext(); const queryClient = useQueryClient(); + // Set initialData state from course outline and subsequently depend on its own state + const { data: section = initialData } = useCourseItemData(initialData.id, initialData); + const isScrolledToElement = locatorId === section?.id; // Expand the section if a search result should be shown/scrolled to const containsSearchResult = () => { @@ -103,7 +99,6 @@ const SectionCard = ({ return false; }; const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded); - const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'section'; @@ -111,6 +106,15 @@ const SectionCard = ({ setIsExpanded(isSectionsExpanded); }, [isSectionsExpanded]); + /** + Temporary measure to keep the react-query state updated with redux state */ + useEffect(() => { + // istanbul ignore if + if (moment(initialData.editedOnRaw).isAfter(moment(section.editedOnRaw))) { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + } + }, [initialData, section]); + const { id, category, @@ -189,18 +193,10 @@ const SectionCard = ({ }; const handleClickMenuButton = () => { - dispatch(setCurrentItem(section)); - dispatch(setCurrentSection(section)); - }; - - const handleEditSubmit = (titleValue: string) => { - if (displayName !== titleValue) { - // both itemId and sectionId are same - onEditSectionSubmit(id, id, titleValue); - return; - } - - closeForm(); + setCurrentSelection({ + currentId: section.id, + sectionId: section.id, + }); }; const handleOpenHighlightsModal = () => { @@ -215,12 +211,6 @@ const SectionCard = ({ onOrderChange(index, index + 1); }; - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL) { - closeForm(); - } - }, [savingStatus]); - const titleComponent = ( { if (!preventNodeEvents || e.target === e.currentTarget) { - openContainerInfoSidebar(section.id); + openContainerInfoSidebar(section.id, undefined, section.id); setIsExpanded(true); } }, [openContainerInfoSidebar]); @@ -268,7 +258,7 @@ const SectionCard = ({ 'section-card', { highlight: isScrolledToElement, - 'outline-card-selected': section.id === selectedContainerId, + 'outline-card-selected': section.id === selectedContainerState?.currentId, }, )} data-testid="section-card" @@ -282,19 +272,17 @@ const SectionCard = ({ status={sectionStatus} hasChanges={hasChanges} onClickMenuButton={handleClickMenuButton} - onClickPublish={onOpenPublishModal} + onClickPublish={/* istanbul ignore next */ () => openPublishModal({ + value: section, + sectionId: section.id, + })} onClickConfigure={onOpenConfigureModal} - onClickEdit={openForm} onClickDelete={onOpenDeleteModal} - onClickUnlink={onOpenUnlinkModal} + onClickUnlink={() => openUnlinkModal({ value: section, sectionId: section.id })} onClickMoveUp={handleSectionMoveUp} onClickMoveDown={handleSectionMoveDown} onClickSync={openSyncModal} onClickCard={(e) => onClickCard(e, true)} - isFormOpen={isFormOpen} - closeForm={closeForm} - onEditSubmit={handleEditSubmit} - savingStatus={savingStatus} onClickDuplicate={onDuplicateSubmit} titleComponent={titleComponent} namePrefix={namePrefix} @@ -356,7 +344,6 @@ const SectionCard = ({ onClickCard={(e) => onClickCard(e, true)} childType={ContainerType.Subsection} parentLocator={section.id} - parentTitle={section.displayName} /> )}
diff --git a/src/course-outline/status-bar/LegacyStatusBar.test.tsx b/src/course-outline/status-bar/LegacyStatusBar.test.tsx index 1aa12d7a7..e6669d7f3 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.test.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.test.tsx @@ -50,7 +50,6 @@ const statusBarData: CourseOutlineStatusBar = { highlightsEnabledForMessaging: true, videoSharingEnabled: true, videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn, - hasChanges: true, }; const queryClient = new QueryClient(); diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/StatusBar.test.tsx index 4bd967222..25d1fb360 100644 --- a/src/course-outline/status-bar/StatusBar.test.tsx +++ b/src/course-outline/status-bar/StatusBar.test.tsx @@ -20,7 +20,6 @@ const statusBarData: CourseOutlineStatusBar = { highlightsEnabledForMessaging: true, videoSharingEnabled: true, videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn, - hasChanges: false, }; jest.mock('@src/course-libraries/data/apiHooks', () => ({ @@ -30,6 +29,14 @@ jest.mock('@src/course-libraries/data/apiHooks', () => ({ }), })); +let mockHasChanges = false; +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useCourseDetails: () => ({ + data: { hasChanges: mockHasChanges }, + isLoading: false, + }), +})); + const renderComponent = (props?: Partial) => render( ', () => { }); it('renders unpublished badge', async () => { - renderComponent({ - statusBarData: { - ...statusBarData, - hasChanges: true, - }, - }); + mockHasChanges = true; + renderComponent(); expect(await screen.findByText('Unpublished Changes')).toBeInTheDocument(); }); diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 13b458f99..d5f0918fd 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -10,6 +10,7 @@ import { } from '@openedx/paragon/icons'; import { useWaffleFlags } from '@src/data/apiHooks'; import { useEntityLinksSummaryByDownstreamContext } from '@src/course-libraries/data/apiHooks'; +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; import messages from './messages'; import { NotificationStatusIcon } from './NotificationStatusIcon'; @@ -42,17 +43,23 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen } }; -const UnpublishedBadgeStatus = () => ( - - - - - - -); +const UnpublishedBadgeStatus = ({ courseId }: { courseId: string }) => { + const { data } = useCourseDetails(courseId); + if (!data?.hasChanges) { + return null; + } + return ( + + + + + + + ); +}; const LibraryUpdates = ({ courseId }: { courseId: string }) => { const { data } = useEntityLinksSummaryByDownstreamContext(courseId); @@ -178,7 +185,6 @@ export const StatusBar = ({ endDate, courseReleaseDate, checklist, - hasChanges, } = statusBarData; const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); @@ -192,7 +198,7 @@ export const StatusBar = ({ return ( - {hasChanges && } + ({ jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ courseId: 5, - handleAddUnit: handleOnAddUnitFromLibrary, + handleAddAndOpenUnit: handleOnAddUnitFromLibrary, handleAddSubsection: {}, handleAddSection: {}, + setCurrentSelection, }), })); -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: () => ({ +jest.mock('@src/studio-home/data/selectors', () => ({ + ...jest.requireActual('@src/studio-home/data/selectors'), + getStudioHomeData: () => ({ librariesV2Enabled: true, }), })); @@ -81,9 +82,7 @@ const subsection: XBlock = { isHeaderVisible: true, releasedToStudents: true, childInfo: { - children: [{ - id: unit.id, - }], + children: [unit], } as any, // 'as any' because we are omitting a lot of fields from 'childInfo' upstreamInfo: { readyToSync: true, @@ -105,9 +104,7 @@ const section: XBlock = { hasChanges: false, highlights: ['highlight 1', 'highlight 2'], childInfo: { - children: [{ - id: subsection.id, - }], + children: [subsection], } as any, // 'as any' because we are omitting a lot of fields from 'childInfo' actions: { draggable: true, @@ -117,8 +114,6 @@ const section: XBlock = { }, } satisfies Partial as XBlock; -const onEditSubectionSubmit = jest.fn(); - const renderComponent = (props?: object, entry = '/course/:courseId') => render( render( isSelfPaced={false} getPossibleMoves={jest.fn()} onOrderChange={jest.fn()} - onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} - onOpenUnlinkModal={jest.fn()} isCustomRelativeDatesActive={false} - onEditSubmit={onEditSubectionSubmit} onDuplicateSubmit={jest.fn()} onOpenConfigureModal={jest.fn()} onPasteClick={jest.fn()} @@ -153,8 +145,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( describe('', () => { beforeEach(() => { - const mocks = initializeMocks(); - store = mocks.reduxStore; + initializeMocks(); }); it('render SubsectionCard component correctly', () => { @@ -207,28 +198,11 @@ describe('', () => { const menu = await screen.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 () => { - renderComponent(); - - let editButton = await screen.findByTestId('subsection-edit-button'); - fireEvent.click(editButton); - let editField = await screen.findByTestId('subsection-edit-field'); - fireEvent.blur(editField); - - expect(onEditSubectionSubmit).not.toHaveBeenCalled(); - - editButton = await screen.findByTestId('subsection-edit-button'); - fireEvent.click(editButton); - editField = await screen.findByTestId('subsection-edit-field'); - fireEvent.change(editField, { target: { value: 'some random value' } }); - fireEvent.keyDown(editField, { key: 'Enter', keyCode: 13 }); - expect(onEditSubectionSubmit).toHaveBeenCalled(); + expect(setCurrentSelection).toHaveBeenCalledWith({ + currentId: subsection.id, + subsectionId: subsection.id, + sectionId: section.id, + }); }); it('hides header based on isHeaderVisible flag', async () => { @@ -440,10 +414,11 @@ describe('', () => { }); it('should open align sidebar', async () => { + const user = userEvent.setup(); const mockSetCurrentPageKey = jest.fn(); const testSidebarPage = { - component: OutlineInfoSidebar, + component: CourseInfoSidebar, icon: Info, title: '', }; @@ -461,10 +436,11 @@ describe('', () => { isOpen: true, open: jest.fn(), toggle: jest.fn(), - currentFlow: null, + currentFlow: undefined, startCurrentFlow: jest.fn(), stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), + clearSelection: jest.fn(), })); setConfig({ ...getConfig(), @@ -474,15 +450,20 @@ describe('', () => { renderComponent(); const element = await screen.findByTestId('subsection-card'); const menu = await within(element).findByTestId('subsection-card-header__menu-button'); - fireEvent.click(menu); + await user.click(menu); const manageTagsBtn = await within(element).findByTestId('subsection-card-header__menu-manage-tags-button'); expect(manageTagsBtn).toBeInTheDocument(); - fireEvent.click(manageTagsBtn); + await user.click(manageTagsBtn); await waitFor(() => { - expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', subsection.id); + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align'); + }); + expect(setCurrentSelection).toHaveBeenCalledWith({ + currentId: subsection.id, + subsectionId: subsection.id, + sectionId: section.id, }); }); }); diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 4031b198c..3df7f949e 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -10,8 +10,6 @@ import classNames from 'classnames'; import { isEmpty } from 'lodash'; import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot'; -import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; @@ -28,6 +26,8 @@ import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; +import moment from 'moment'; import messages from './messages'; interface SubsectionCardProps { @@ -37,11 +37,7 @@ interface SubsectionCardProps { isSectionsExpanded: boolean, isSelfPaced: boolean, isCustomRelativeDatesActive: boolean, - onOpenPublishModal: () => void, - onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, - savingStatus?: RequestStatusType, onOpenDeleteModal: () => void, - onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, index: number, getPossibleMoves: (index: number, step: number) => void, @@ -52,19 +48,15 @@ interface SubsectionCardProps { } const SubsectionCard = ({ - section, - subsection, + section: initialSectionData, + subsection: initialData, isSectionsExpanded, isSelfPaced, isCustomRelativeDatesActive, children, index, getPossibleMoves, - onOpenPublishModal, - onEditSubmit, - savingStatus, onOpenDeleteModal, - onOpenUnlinkModal, onDuplicateSubmit, onOrderChange, onOpenConfigureModal, @@ -75,16 +67,20 @@ const SubsectionCard = ({ const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); - const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === subsection.id; - const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); - const { courseId } = useCourseAuthoringContext(); + const { + courseId, openUnlinkModal, openPublishModal, setCurrentSelection, + } = useCourseAuthoringContext(); const queryClient = useQueryClient(); + // Set initialData state from course outline and subsequently depend on its own state + const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); + const { data: subsection = initialData } = useCourseItemData(initialData.id, initialData); + const isScrolledToElement = locatorId === subsection.id; const { id, @@ -145,14 +141,25 @@ const SubsectionCard = ({ setIsExpanded(isSectionsExpanded); }, [isSectionsExpanded]); + /** + Temporary measure to keep the react-query state updated with redux state */ + useEffect(() => { + // istanbul ignore if + if (moment(initialData.editedOnRaw).isAfter(moment(subsection.editedOnRaw))) { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + } + }, [initialData, subsection]); + const handleExpandContent = () => { setIsExpanded((prevState) => !prevState); }; const handleClickMenuButton = () => { - dispatch(setCurrentSection(section)); - dispatch(setCurrentSubsection(subsection)); - dispatch(setCurrentItem(subsection)); + setCurrentSelection({ + currentId: subsection.id, + subsectionId: subsection.id, + sectionId: section.id, + }); }; const handleOnPostChangeSync = useCallback(() => { @@ -162,15 +169,6 @@ const SubsectionCard = ({ } }, [dispatch, section, queryClient, courseId]); - const handleEditSubmit = (titleValue: string) => { - if (displayName !== titleValue) { - onEditSubmit(id, section.id, titleValue); - return; - } - - closeForm(); - }; - const handleSubsectionMoveUp = () => { onOrderChange(section, moveUpDetails); }; @@ -228,12 +226,6 @@ const SubsectionCard = ({ setIsExpanded((prevState) => (containsSearchResult() || prevState)); }, [locatorId, setIsExpanded]); - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL) { - closeForm(); - } - }, [savingStatus]); - const isDraggable = ( actions.draggable && (actions.allowMoveUp || actions.allowMoveDown) @@ -243,7 +235,7 @@ const SubsectionCard = ({ const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { if (!preventNodeEvents || e.target === e.currentTarget) { - openContainerInfoSidebar(subsection.id); + openContainerInfoSidebar(subsection.id, subsection.id, section.id); setIsExpanded(true); } }, [openContainerInfoSidebar]); @@ -272,7 +264,7 @@ const SubsectionCard = ({ 'subsection-card', { highlight: isScrolledToElement, - 'outline-card-selected': subsection.id === selectedContainerId, + 'outline-card-selected': subsection.id === selectedContainerState?.currentId, }, )} data-testid="subsection-card" @@ -286,19 +278,17 @@ const SubsectionCard = ({ cardId={id} hasChanges={hasChanges} onClickMenuButton={handleClickMenuButton} - onClickPublish={onOpenPublishModal} - onClickEdit={openForm} + onClickPublish={() => openPublishModal({ value: subsection, sectionId: section.id })} onClickDelete={onOpenDeleteModal} - onClickUnlink={onOpenUnlinkModal} + onClickUnlink={/* istanbul ignore next */ () => openUnlinkModal({ + value: subsection, + sectionId: section.id, + })} onClickMoveUp={handleSubsectionMoveUp} onClickMoveDown={handleSubsectionMoveDown} onClickConfigure={onOpenConfigureModal} onClickSync={openSyncModal} onClickCard={(e) => onClickCard(e, true)} - isFormOpen={isFormOpen} - closeForm={closeForm} - onEditSubmit={handleEditSubmit} - savingStatus={savingStatus} onClickDuplicate={onDuplicateSubmit} titleComponent={titleComponent} namePrefix={namePrefix} @@ -341,7 +331,7 @@ const SubsectionCard = ({ onClickCard={(e) => onClickCard(e, true)} childType={ContainerType.Unit} parentLocator={subsection.id} - parentTitle={subsection.displayName} + grandParentLocator={section.id} /> {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && ( ({ useAcceptLibraryBlockChanges: () => ({ @@ -26,6 +28,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ courseId: 5, getUnitUrl: (id: string) => `/some/${id}`, + setCurrentSelection, }), })); @@ -92,11 +95,8 @@ const renderComponent = (props?: object) => render( index={1} getPossibleMoves={jest.fn()} onOrderChange={jest.fn()} - onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} - onOpenUnlinkModal={jest.fn()} onOpenConfigureModal={jest.fn()} - onEditSubmit={jest.fn()} onDuplicateSubmit={jest.fn()} isSelfPaced={false} isCustomRelativeDatesActive={false} @@ -132,7 +132,8 @@ describe('', () => { expect(card).not.toHaveClass('outline-card-selected'); }); - it('render UnitCard component in selected state', () => { + it('render UnitCard component in selected state', async () => { + const user = userEvent.setup(); setConfig({ ...getConfig(), ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', @@ -149,7 +150,7 @@ describe('', () => { // Get the that contains the card and click it to select the card const el = container.querySelector('div.row.mx-0') as HTMLInputElement; expect(el).not.toBeNull(); - fireEvent.click(el!); + await user.click(el!); // The card is selected expect(card).toHaveClass('outline-card-selected'); @@ -166,6 +167,7 @@ describe('', () => { }); it('hides duplicate & delete option based on duplicable & deletable action flag', async () => { + const user = userEvent.setup(); const { findByTestId } = renderComponent({ unit: { ...unit, @@ -179,12 +181,13 @@ describe('', () => { }); const element = await findByTestId('unit-card'); const menu = await within(element).findByTestId('unit-card-header__menu-button'); - await act(async () => fireEvent.click(menu)); + await user.click(menu); expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument(); expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument(); }); it('hides move, duplicate & delete options if parent was imported from library', async () => { + const user = userEvent.setup(); const { findByTestId } = renderComponent({ subsection: { ...subsection, @@ -197,7 +200,7 @@ describe('', () => { }); const element = await findByTestId('unit-card'); const menu = await within(element).findByTestId('unit-card-header__menu-button'); - await act(async () => fireEvent.click(menu)); + await user.click(menu); expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument(); expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument(); expect( @@ -209,6 +212,7 @@ describe('', () => { }); it('shows copy option based on enableCopyPasteUnits flag', async () => { + const user = userEvent.setup(); const { findByTestId } = renderComponent({ unit: { ...unit, @@ -217,7 +221,7 @@ describe('', () => { }); const element = await findByTestId('unit-card'); const menu = await within(element).findByTestId('unit-card-header__menu-button'); - await act(async () => fireEvent.click(menu)); + await user.click(menu); expect(within(element).queryByText(cardMessages.menuCopy.defaultMessage)).toBeInTheDocument(); }); @@ -233,51 +237,54 @@ describe('', () => { }); it('should sync unit changes from upstream', async () => { + const user = userEvent.setup(); renderComponent(); expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument(); // Click on sync button const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); - fireEvent.click(syncButton); + await user.click(syncButton); // Should open compare preview modal expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument(); // Click on accept changes const acceptChangesButton = screen.getByText(/accept changes/i); - fireEvent.click(acceptChangesButton); + await user.click(acceptChangesButton); await waitFor(() => expect(mockUseAcceptLibraryBlockChanges).toHaveBeenCalled()); }); it('should decline sync unit changes from upstream', async () => { + const user = userEvent.setup(); renderComponent(); expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument(); // Click on sync button const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); - fireEvent.click(syncButton); + await user.click(syncButton); // Should open compare preview modal expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument(); // Click on ignore changes const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i }); - fireEvent.click(ignoreChangesButton); + await user.click(ignoreChangesButton); // Should open the confirmation modal expect(screen.getByRole('heading', { name: /ignore these changes\?/i })).toBeInTheDocument(); // Click on ignore button const ignoreButton = screen.getByRole('button', { name: /ignore/i }); - fireEvent.click(ignoreButton); + await user.click(ignoreButton); await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled()); }); it('should open legacy manage tags', async () => { + const user = userEvent.setup(); setConfig({ ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'true', @@ -286,22 +293,23 @@ describe('', () => { renderComponent(); const element = await screen.findByTestId('unit-card'); const menu = await within(element).findByTestId('unit-card-header__menu-button'); - fireEvent.click(menu); + await user.click(menu); const manageTagsBtn = await within(element).findByTestId('unit-card-header__menu-manage-tags-button'); expect(manageTagsBtn).toBeInTheDocument(); - fireEvent.click(manageTagsBtn); + await user.click(manageTagsBtn); const drawer = await screen.findByRole('alert'); expect(within(drawer).getByText(/manage tags/i)); }); it('should open align sidebar', async () => { + const user = userEvent.setup(); const mockSetCurrentPageKey = jest.fn(); const testSidebarPage = { - component: OutlineInfoSidebar, + component: CourseInfoSidebar, icon: Info, title: '', }; @@ -319,10 +327,11 @@ describe('', () => { isOpen: true, open: jest.fn(), toggle: jest.fn(), - currentFlow: null, + currentFlow: undefined, startCurrentFlow: jest.fn(), stopCurrentFlow: jest.fn(), openContainerInfoSidebar: jest.fn(), + clearSelection: jest.fn(), })); setConfig({ ...getConfig(), @@ -332,15 +341,20 @@ describe('', () => { renderComponent(); const element = await screen.findByTestId('unit-card'); const menu = await within(element).findByTestId('unit-card-header__menu-button'); - fireEvent.click(menu); + await user.click(menu); const manageTagsBtn = await within(element).findByTestId('unit-card-header__menu-manage-tags-button'); expect(manageTagsBtn).toBeInTheDocument(); - fireEvent.click(manageTagsBtn); + await user.click(manageTagsBtn); await waitFor(() => { - expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', unit.id); + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align'); + }); + expect(setCurrentSelection).toHaveBeenCalledWith({ + currentId: unit.id, + subsectionId: subsection.id, + sectionId: section.id, }); }); }); diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 922a6ab38..f185ae6f7 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -12,9 +12,7 @@ import { useSearchParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; -import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; -import { RequestStatus, RequestStatusType } from '@src/data/constants'; import CardHeader from '@src/course-outline/card-header/CardHeader'; import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import TitleLink from '@src/course-outline/card-header/TitleLink'; @@ -24,20 +22,18 @@ import { useClipboard } from '@src/generic/clipboard'; import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; -import type { XBlock } from '@src/data/types'; +import type { UnitXBlock, XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; +import moment from 'moment'; import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface UnitCardProps { - unit: XBlock; + unit: UnitXBlock; subsection: XBlock; section: XBlock; - onOpenPublishModal: () => void; onOpenConfigureModal: () => void; - onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, - savingStatus?: RequestStatusType; onOpenDeleteModal: () => void; - onOpenUnlinkModal: () => void; onDuplicateSubmit: () => void; index: number; getPossibleMoves: (index: number, step: number) => void, @@ -51,19 +47,15 @@ interface UnitCardProps { } const UnitCard = ({ - unit, - subsection, - section, + unit: initialData, + subsection: initialSubsectionData, + section: initialSectionData, isSelfPaced, isCustomRelativeDatesActive, index, getPossibleMoves, - onOpenPublishModal, onOpenConfigureModal, - onEditSubmit, - savingStatus, onOpenDeleteModal, - onOpenUnlinkModal, onDuplicateSubmit, onOrderChange, discussionsSettings, @@ -71,16 +63,23 @@ const UnitCard = ({ const currentRef = useRef(null); const dispatch = useDispatch(); const [searchParams] = useSearchParams(); - const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); + const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext(); const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === unit.id; - const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); - const { courseId, getUnitUrl } = useCourseAuthoringContext(); + const { + courseId, getUnitUrl, openUnlinkModal, openPublishModal, setCurrentSelection, + } = useCourseAuthoringContext(); const queryClient = useQueryClient(); + const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData); + const { data: subsection = initialSubsectionData } = useCourseItemData( + initialSubsectionData.id, + initialSubsectionData, + ); + const { data: unit = initialData } = useCourseItemData(initialData.id, initialData); + const isScrolledToElement = locatorId === unit.id; const { id, @@ -133,19 +132,12 @@ const UnitCard = ({ }); const borderStyle = getItemStatusBorder(unitStatus); - const handleClickMenuButton = () => { - dispatch(setCurrentItem(unit)); - dispatch(setCurrentSection(section)); - dispatch(setCurrentSubsection(subsection)); - }; - - const handleEditSubmit = (titleValue: string) => { - if (displayName !== titleValue) { - onEditSubmit(id, section.id, titleValue); - return; - } - - closeForm(); + const selectAndTrigger = () => { + setCurrentSelection({ + currentId: unit.id, + subsectionId: subsection.id, + sectionId: section.id, + }); }; const handleUnitMoveUp = () => { @@ -170,7 +162,7 @@ const UnitCard = ({ const onClickCard = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { - openContainerInfoSidebar(unit.id); + openContainerInfoSidebar(unit.id, subsection.id, section.id); } }, [openContainerInfoSidebar]); @@ -197,6 +189,15 @@ const UnitCard = ({ /> ); + /** + Temporary measure to keep the react-query state updated with redux state */ + useEffect(() => { + // istanbul ignore if + if (moment(initialData.editedOnRaw).isAfter(moment(unit.editedOnRaw))) { + queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData); + } + }, [initialData, unit]); + useEffect(() => { // if this items has been newly added, scroll to it. if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) { @@ -206,12 +207,6 @@ const UnitCard = ({ } }, [isScrolledToElement]); - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL) { - closeForm(); - } - }, [savingStatus]); - if (!isHeaderVisible) { return null; } @@ -245,7 +240,7 @@ const UnitCard = ({ 'unit-card', { highlight: isScrolledToElement, - 'outline-card-selected': unit.id === selectedContainerId, + 'outline-card-selected': unit.id === selectedContainerState?.currentId, }, )} data-testid="unit-card" @@ -256,20 +251,23 @@ const UnitCard = ({ status={unitStatus} hasChanges={hasChanges} cardId={id} - onClickMenuButton={handleClickMenuButton} - onClickPublish={onOpenPublishModal} + onClickMenuButton={selectAndTrigger} + onClickPublish={() => openPublishModal({ + value: unit, + sectionId: section.id, + subsectionId: subsection.id, + })} onClickConfigure={onOpenConfigureModal} - onClickEdit={openForm} onClickDelete={onOpenDeleteModal} - onClickUnlink={onOpenUnlinkModal} + onClickUnlink={/* istanbul ignore next */ () => openUnlinkModal({ + value: unit, + sectionId: section.id, + subsectionId: subsection.id, + })} onClickMoveUp={handleUnitMoveUp} onClickMoveDown={handleUnitMoveDown} onClickSync={openSyncModal} onClickCard={onClickCard} - isFormOpen={isFormOpen} - closeForm={closeForm} - onEditSubmit={handleEditSubmit} - savingStatus={savingStatus} onClickDuplicate={onDuplicateSubmit} titleComponent={titleComponent} namePrefix={namePrefix} diff --git a/src/course-outline/xblock-status/XBlockStatus.tsx b/src/course-outline/xblock-status/XBlockStatus.tsx index 1f30f49ce..20ecd82c2 100644 --- a/src/course-outline/xblock-status/XBlockStatus.tsx +++ b/src/course-outline/xblock-status/XBlockStatus.tsx @@ -1,5 +1,5 @@ import { ShowAnswerTypesKeys } from '@src/editors/data/constants/problem'; -import { XBlock } from '@src/data/types'; +import type { UnitXBlock, XBlock } from '@src/data/types'; import { COURSE_BLOCK_NAMES } from '../constants'; import ReleaseStatus from './ReleaseStatus'; import GradingPolicyAlert from './GradingPolicyAlert'; @@ -11,7 +11,7 @@ import NeverShowAssessmentResultMessage from './NeverShowAssessmentResultMessage interface XBlockStatusProps { isSelfPaced: boolean; isCustomRelativeDatesActive: boolean, - blockData: XBlock, + blockData: XBlock | UnitXBlock, } const XBlockStatus = ({ diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 5e1edd70f..8dd56ea40 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -582,12 +582,11 @@ describe('', () => { } = courseSectionVerticalMock; const viewLiveButton = await screen.findByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); - await user.click(viewLiveButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank'); - const previewButton = screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); + const previewButton = await screen.findByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); await user.click(previewButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank'); @@ -664,16 +663,14 @@ describe('', () => { axiosMock .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .reply(500, {}); - const { getByRole } = render(); + render(); - await waitFor(async () => { - const videoButton = getByRole('button', { - name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), - }); - - await user.click(videoButton); - expect(mockedUsedNavigate).not.toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); + const videoButton = await screen.findByRole('button', { + name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), }); + + await user.click(videoButton); + expect(mockedUsedNavigate).not.toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); }); it('handle creating Problem xblock and showing editor modal', async () => { @@ -683,9 +680,7 @@ describe('', () => { .reply(200, courseCreateXblockMock); render(); - await waitFor(async () => { - await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })); - }); + await user.click(await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })); axiosMock .onPost(getXBlockBaseApiUrl(blockId), { diff --git a/src/course-unit/SubsectionUnitRedirect.test.tsx b/src/course-unit/SubsectionUnitRedirect.test.tsx index db5fe8a8c..8a8f14f08 100644 --- a/src/course-unit/SubsectionUnitRedirect.test.tsx +++ b/src/course-unit/SubsectionUnitRedirect.test.tsx @@ -7,7 +7,7 @@ import { getXBlockApiUrl } from '../course-outline/data/api'; let axiosMock; const courseId = '123'; -const subsectionId = 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5'; +const subsectionId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5'; const path = '/subsection/:subsectionId'; const expectedCourseItemDataWithUnit = { diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 5b67b340a..2d449b0d0 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -41,7 +41,13 @@ import { import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils'; const XBlockContainerIframe: FC = ({ - courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, + courseId, + blockId, + unitXBlockActions, + courseVerticalChildren, + handleConfigureSubmit, + isUnitVerticalType, + readonly, }) => { const intl = useIntl(); const dispatch = useDispatch(); @@ -210,7 +216,7 @@ const XBlockContainerIframe: FC = ({ handleRefreshIframe, }); - useIframeMessages(messageHandlers); + useIframeMessages(readonly ? {} : messageHandlers); return ( <> diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index a4051cbe3..f09df1360 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -37,6 +37,7 @@ export interface XBlockContainerIframeProps { }; courseVerticalChildren: Array; handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void; + readonly?: boolean; } export type AccessManagedXBlockDataTypes = { @@ -57,7 +58,7 @@ export type AccessManagedXBlockDataTypes = { ancestorHasStaffLock?: boolean; isPrereq?: boolean; prereqs?: XBlockPrereqs[]; - prereq?: number; + prereq?: string; prereqMinScore?: number; prereqMinCompletion?: number; releasedToStudents?: boolean; diff --git a/src/data/types.ts b/src/data/types.ts index 246f8c8c9..d92bb63a8 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -65,7 +65,7 @@ export interface UpstreamInfo { versionDeclined: number | null, errorMessage: string | null, downstreamCustomized: string[], - hasTopLevelParent?: boolean, + topLevelParentKey?: string, readyToSyncChildren?: UpstreamChildrenInfo[], isReadyToSyncIndividually?: boolean, } @@ -78,6 +78,7 @@ export interface XBlock { category: string; hasChildren: boolean; editedOn: string; + editedOnRaw: string; published: boolean; publishedOn: string; studioUrl: string; @@ -126,6 +127,8 @@ export interface XBlock { upstreamInfo?: UpstreamInfo; } +export type UnitXBlock = Omit; + interface OutlineError { data?: string; type: string; @@ -153,3 +156,9 @@ export interface UserTaskStatusWithUuid { modified: string; uuid: string; } + +export type SelectionState = { + currentId: string; + sectionId?: string; + subsectionId?: string; +}; diff --git a/src/generic/configure-modal/ConfigureModal.jsx b/src/generic/configure-modal/ConfigureModal.jsx index e34dfbcc1..d41458b8a 100644 --- a/src/generic/configure-modal/ConfigureModal.jsx +++ b/src/generic/configure-modal/ConfigureModal.jsx @@ -61,7 +61,7 @@ const ConfigureModal = ({ showReviewRules, onlineProctoringRules, discussionEnabled, - } = currentItemData; + } = currentItemData || {}; const getSelectedGroups = () => { if (userPartitionInfo?.selectedPartitionIndex >= 0) { @@ -361,7 +361,7 @@ ConfigureModal.propTypes = { blockDisplayName: PropTypes.string, blockUsageKey: PropTypes.string, }), - prereq: PropTypes.number, + prereq: PropTypes.string, prereqMinScore: PropTypes.number, prereqMinCompletion: PropTypes.number, releasedToStudents: PropTypes.bool, @@ -374,7 +374,7 @@ ConfigureModal.propTypes = { showReviewRules: PropTypes.bool, onlineProctoringRules: PropTypes.string, discussionEnabled: PropTypes.bool, - }).isRequired, + }), isXBlockComponent: PropTypes.bool, isSelfPaced: PropTypes.bool.isRequired, }; diff --git a/src/generic/resizable/Resizable.tsx b/src/generic/resizable/Resizable.tsx new file mode 100644 index 000000000..cf9608037 --- /dev/null +++ b/src/generic/resizable/Resizable.tsx @@ -0,0 +1,73 @@ +import { useWindowSize } from '@openedx/paragon'; +import React, { + useRef, useState, useCallback, useMemo, +} from 'react'; + +const MIN_WIDTH = 440; // px + +interface ResizableBoxProps { + children: React.ReactNode; + minWidth?: number; + maxWidth?: number +} + +/** + * Creates a resizable box that can be dragged to resize the width from the left side. + */ +export const ResizableBox = ({ + children, + minWidth = MIN_WIDTH, + maxWidth, +}: ResizableBoxProps) => { + const boxRef = useRef(null); + const [width, setWidth] = useState(minWidth); // initial width + const { width: windowWidth } = useWindowSize(); + + // Store the start values while dragging + const startXRef = useRef(0); + const startWidthRef = useRef(0); + const defaultMaxWidth = useMemo(() => { + if (!windowWidth) { + return Infinity; + } + return Math.abs(windowWidth * 0.65); + }, [windowWidth]); + + const onMouseMove = useCallback((e: MouseEvent) => { + const dx = e.clientX - startXRef.current; // positive = mouse moved right + const newWidth = Math.min( + Math.max(startWidthRef.current - dx, minWidth), + maxWidth || defaultMaxWidth, + ); + setWidth(newWidth); + }, [maxWidth, minWidth, defaultMaxWidth]); + + const onMouseUp = useCallback(() => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }, [onMouseMove]); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); // prevent text selection + startXRef.current = e.clientX; + startWidthRef.current = width; + + // Attach listeners to the whole document so dragging works even outside the box + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, [width]); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */} +
+
+ {children} +
+
+ ); +}; diff --git a/src/generic/resizable/index.scss b/src/generic/resizable/index.scss new file mode 100644 index 000000000..c565f8475 --- /dev/null +++ b/src/generic/resizable/index.scss @@ -0,0 +1,21 @@ +/* The box that will be resized */ +.resizable { + position: relative; + display: flex; +} + +/* Custom left‑hand handle */ +.resizable-handle { + position: absolute; + left: 0; + top: 0; + width: 2px; + height: 100%; + cursor: ew-resize; + background: var(--pgn-color-dark-200); +} + +.resizable-handle:hover { + background: var(--pgn-color-dark-400); +} + diff --git a/src/generic/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx index 2e08b5d9f..60584d561 100644 --- a/src/generic/sidebar/Sidebar.tsx +++ b/src/generic/sidebar/Sidebar.tsx @@ -11,6 +11,7 @@ import { FormatIndentDecrease, FormatIndentIncrease, } from '@openedx/paragon/icons'; +import { ResizableBox } from '@src/generic/resizable/Resizable'; import type { MessageDescriptor } from 'react-intl'; import messages from './messages'; @@ -91,33 +92,35 @@ export function Sidebar({ return ( {isOpen && !!currentPageKey && ( -
- - - {intl.formatMessage(pages[currentPageKey].title)} - - - - {Object.entries(pages).map(([key, page]) => ( - setCurrentPageKey(key)} - > - - - {intl.formatMessage(page.title)} - - - ))} - - - -
+ +
+ + + {intl.formatMessage(pages[currentPageKey].title)} + + + + {Object.entries(pages).map(([key, page]) => ( + setCurrentPageKey(key)} + > + + + {intl.formatMessage(page.title)} + + + ))} + + + +
+
)}
( - + {Array.isArray(children) ? children.map((child, index) => ( // eslint-disable-next-line react/no-array-index-key diff --git a/src/generic/sidebar/SidebarSection.tsx b/src/generic/sidebar/SidebarSection.tsx index 7f407f744..1e7c1833b 100644 --- a/src/generic/sidebar/SidebarSection.tsx +++ b/src/generic/sidebar/SidebarSection.tsx @@ -48,7 +48,7 @@ export const SidebarSection = ({ {icon && } {title && ( -

+

{title}

)} diff --git a/src/generic/sidebar/SidebarTitle.tsx b/src/generic/sidebar/SidebarTitle.tsx index 93b29c373..a565e31eb 100644 --- a/src/generic/sidebar/SidebarTitle.tsx +++ b/src/generic/sidebar/SidebarTitle.tsx @@ -1,10 +1,14 @@ -import { Icon, Stack } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton, Stack } from '@openedx/paragon'; +import { ArrowBack } from '@openedx/paragon/icons'; +import messages from './messages'; interface SidebarTitleProps { /** Title of the section */ title: string; /** Icon to be displayed in the section title */ icon?: React.ComponentType; + onBackBtnClick?: () => void; } /** @@ -16,9 +20,24 @@ interface SidebarTitleProps { * This is meant to standardize the look and feel of the sidebar section titles, * so that it can be reused across different parts of the application. */ -export const SidebarTitle = ({ title, icon }: SidebarTitleProps) => ( - - -

{title}

-
-); +export const SidebarTitle = ({ + title, + icon, + onBackBtnClick, +}: SidebarTitleProps) => { + const intl = useIntl(); + return ( + + {onBackBtnClick && ( + + )} + +

{title}

+
+ ); +}; diff --git a/src/generic/sidebar/index.scss b/src/generic/sidebar/index.scss index 22d71a094..2611e97ed 100644 --- a/src/generic/sidebar/index.scss +++ b/src/generic/sidebar/index.scss @@ -1,8 +1,7 @@ .sidebar { .sidebar-content { flex: 0 1 auto; - max-width: 700px; - min-width: 400px; + min-width: 440px; overflow-y: auto; min-height: 100vh; height: 100%; diff --git a/src/generic/sidebar/messages.ts b/src/generic/sidebar/messages.ts index 05657c30a..869e12d50 100644 --- a/src/generic/sidebar/messages.ts +++ b/src/generic/sidebar/messages.ts @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'Toggle', description: 'Toggle button alt', }, + backBtnText: { + id: 'course-authoring.sidebar.back.btn.alt-text', + defaultMessage: 'Back', + description: 'Alternate text of Back button in sidebar title', + }, }); export default messages; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 4764f9bb8..084c66fe7 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -17,3 +17,4 @@ @import "./inplace-text-editor/InplaceTextEditor"; @import "./upstream-info-icon/UpstreamInfoIcon"; @import "./sidebar/"; +@import "./resizable/"; diff --git a/src/generic/unlink-modal/data/apiHooks.ts b/src/generic/unlink-modal/data/apiHooks.ts index 8cf7639cc..b5da9face 100644 --- a/src/generic/unlink-modal/data/apiHooks.ts +++ b/src/generic/unlink-modal/data/apiHooks.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { courseLibrariesQueryKeys } from '@src/course-libraries'; import { getCourseKey } from '@src/generic/key-utils'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; import { unlinkDownstream } from './api'; export const useUnlinkDownstream = () => { @@ -13,6 +14,12 @@ export const useUnlinkDownstream = () => { queryClient.invalidateQueries({ queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey), }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(contentId), + }); + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseDetails(courseKey), + }); }, }); }; diff --git a/src/hooks.ts b/src/hooks.ts index b1dacca6e..c697e01fb 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -5,6 +5,7 @@ import { useEffect, useRef, useState, + useMemo, } from 'react'; import { history } from '@edx/frontend-platform'; import { useLocation, useSearchParams } from 'react-router-dom'; @@ -213,3 +214,17 @@ export function useStickyState( return [value, setValue]; } + +export function useToggleWithValue(defaultValue?: T): [ + isDefined: boolean, value: T | undefined, define: ((val: T) => void), undefine: () => void, +] { + const [value, setValue] = useState(defaultValue); + const define = useCallback((val: T) => { + setValue(val); + }, []); + const undefine = useCallback(() => { + setValue(undefined); + }, []); + const isDefined = useMemo(() => value !== undefined, [value]); + return [isDefined, value, define, undefine]; +} diff --git a/src/library-authoring/generic/publish-status-buttons/index.scss b/src/library-authoring/generic/publish-status-buttons/index.scss index ddeddbe72..0cd546354 100644 --- a/src/library-authoring/generic/publish-status-buttons/index.scss +++ b/src/library-authoring/generic/publish-status-buttons/index.scss @@ -1,6 +1,6 @@ .status-button { border: 1px solid; - border-left: 4px solid; + border-left: 6px solid; text-align: center; white-space: pre-wrap; diff --git a/src/library-authoring/generic/status-widget/StatusWidget.scss b/src/library-authoring/generic/status-widget/StatusWidget.scss index fcbe24c52..0095326c1 100644 --- a/src/library-authoring/generic/status-widget/StatusWidget.scss +++ b/src/library-authoring/generic/status-widget/StatusWidget.scss @@ -1,6 +1,6 @@ %draft-status { background-color: #FDF3E9; - border-color: #F4B57B !important; + border-color: #B4610E !important; color: #00262B; } diff --git a/src/library-authoring/library-filters/LibraryDropdownFilter.tsx b/src/library-authoring/library-filters/LibraryDropdownFilter.tsx index 657535762..748184749 100644 --- a/src/library-authoring/library-filters/LibraryDropdownFilter.tsx +++ b/src/library-authoring/library-filters/LibraryDropdownFilter.tsx @@ -92,6 +92,7 @@ export const LibraryDropdownFilter = () => { id="library-filter-dropdown" as={ButtonGroup} autoClose="outside" + className="flex-fill mw-xs" > { return ( - + - - + + + + {isOn && ( - + {!(onlyOneType) && } diff --git a/src/search-manager/SearchFilterWidget.tsx b/src/search-manager/SearchFilterWidget.tsx index 5e1e1598c..36fa8fe83 100644 --- a/src/search-manager/SearchFilterWidget.tsx +++ b/src/search-manager/SearchFilterWidget.tsx @@ -41,7 +41,7 @@ const SearchFilterWidget: React.FC<{ return ( <> -
+