diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index e51484da37..776626bc03 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 9bdcbeb75f..3a330377bb 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 68784f1d47..db0c5bb547 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 68f4406378..6708c5d6d7 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 044414245e..2b0ff929f7 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 e064092754..ef8c583b45 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 05d5c43860..840493c7a6 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 1fcf35a05d..0d7027237f 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 f07d64cb5e..f162c55a8d 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 9a67ecf32f..0b934e52c1 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); - }, -}); - -export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( + 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, 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 587badfcc6..b5ab44022b 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 c56d66470d..9b23f65a01 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 5c975fe247..8441715cfa 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 ea0b7496f3..55323104fc 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 c992548c34..772339f298 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 bddd876e94..5406c96b15 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 1c1b133206..904b0cf6e9 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 73593d0572..acf10b88f1 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 { + courseId, + currentSelection, + setCurrentSelection, + } = useCourseAuthoringContext(); + const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); - const sidebarContentId = currentContainerId || courseId; + const sidebarContentId = currentSelection?.currentId || selectedContainerState?.currentId || courseId; - const { - data: courseData, - } = useCourseDetails(courseId); + const { data: contentData } = useContentData(sidebarContentId); - const { - data: contentData, - } = useContentData(currentContainerId); + // 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 2151bc41df..993e2e012c 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); - - return () => { - window.removeEventListener('keydown', handleEsc); - }; - }, []); + const { data: currentItemData } = useCourseItemData(selectedContainerState?.currentId); + const sectionsList = useSelector(getSectionsList); + + /** 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 8aaf0412ab..97eebd89b9 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 b75dad5a89..e02afd49c3 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 0000000000..d35417bcbb --- /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 0000000000..a8a1d415b8 --- /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 0000000000..faf8f1193a --- /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 0000000000..c932a577d1 --- /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 0000000000..c1666d496d --- /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 0000000000..5fe2f57bea --- /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 0000000000..203981aa62 --- /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 dd98edbe9e..dcf59640df 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 b56488c050..0000000000 --- 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 f83a993d70..0000000000 --- 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 0000000000..08c42d4801 --- /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 0000000000..7412824710 --- /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 22d4c30821..dc35405e86 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 70261be217..9673ec4f98 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 1aa12d7a77..e6669d7f31 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 4bd9672226..25d1fb3604 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 13b458f99f..d5f0918fd6 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 4031b198c6..3df7f949e1 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 922a6ab38d..f185ae6f7d 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 1f30f49ce2..20ecd82c2a 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 5e1edd70f8..8dd56ea400 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(); - - await waitFor(async () => { - const videoButton = getByRole('button', { - name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), - }); + render(); - 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 db5fe8a8c9..8a8f14f082 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 5b67b340af..2d449b0d0b 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 a4051cbe3c..f09df13604 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 246f8c8c9e..d92bb63a8e 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 e34dfbcc15..d41458b8a2 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 0000000000..cf96080375 --- /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 0000000000..c565f84756 --- /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 2e08b5d9fd..60584d5611 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 7f407f7449..1e7c1833b1 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 93b29c373b..a565e31eb9 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 22d71a094e..2611e97ed4 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 05657c30a4..869e12d509 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 4764f9bb8a..084c66fe79 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 8cf7639cc8..b5da9faceb 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 b1dacca6ed..c697e01fbf 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 ddeddbe72a..0cd5463541 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 fcbe24c527..0095326c14 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 657535762e..748184749d 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 5e1e1598cb..36fa8fe835 100644 --- a/src/search-manager/SearchFilterWidget.tsx +++ b/src/search-manager/SearchFilterWidget.tsx @@ -41,7 +41,7 @@ const SearchFilterWidget: React.FC<{ return ( <> -
+