From a23a4da0a206ded9813bacf43dce343b75caeee2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 15 Jan 2026 17:59:26 +0530 Subject: [PATCH] feat: add content in location in course outline [FC-0114] (#2820) - Add container in-location in course outline using new add sidebar. - Creates the placeholder card while creating a container --- src/CourseAuthoringContext.tsx | 52 +-- .../LegacyLibContentBlockAlert.tsx | 2 + src/course-outline/CourseOutline.scss | 4 + src/course-outline/CourseOutline.test.tsx | 9 +- src/course-outline/CourseOutline.tsx | 59 +-- .../OutlineAddChildButtons.test.tsx | 112 +++++- src/course-outline/OutlineAddChildButtons.tsx | 347 ++++++++++++++++-- src/course-outline/data/apiHooks.ts | 4 +- src/course-outline/data/thunk.ts | 80 +--- .../header-navigations/HeaderActions.tsx | 9 +- src/course-outline/hooks.jsx | 19 +- src/course-outline/messages.ts | 25 ++ .../outline-sidebar/AddSidebar.test.tsx | 130 +++++-- .../outline-sidebar/AddSidebar.tsx | 195 +++++++--- .../outline-sidebar/OutlineSidebar.tsx | 4 +- .../outline-sidebar/OutlineSidebarContext.tsx | 94 +++-- .../outline-sidebar/constants.ts | 28 ++ .../outline-sidebar/messages.ts | 15 + .../section-card/SectionCard.tsx | 54 +-- src/course-outline/section-card/messages.js | 5 - .../subsection-card/SubsectionCard.test.tsx | 20 +- .../subsection-card/SubsectionCard.tsx | 46 +-- .../subsection-card/messages.js | 5 - src/generic/sidebar/Sidebar.test.tsx | 10 +- src/generic/sidebar/Sidebar.tsx | 13 +- .../common/context/ComponentPickerContext.tsx | 13 +- .../components/AddComponentWidget.tsx | 17 +- 27 files changed, 936 insertions(+), 435 deletions(-) create mode 100644 src/course-outline/outline-sidebar/constants.ts diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index e8161e582..e51484da3 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -5,7 +5,6 @@ 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 { addNewSectionQuery, addNewSubsectionQuery, addNewUnitQuery } from '@src/course-outline/data/thunk'; import { useNavigate } from 'react-router'; import { getOutlineIndexData } from '@src/course-outline/data/selectors'; import { RequestStatus, RequestStatusType } from './data/constants'; @@ -19,12 +18,9 @@ export type CourseAuthoringContextData = { courseDetails?: CourseDetailsData; courseDetailStatus: RequestStatusType; canChangeProviders: boolean; - handleAddSectionFromLibrary: ReturnType; - handleAddSubsectionFromLibrary: ReturnType; - handleAddUnitFromLibrary: ReturnType; - handleNewSectionSubmit: () => void; - handleNewSubsectionSubmit: (sectionId: string) => void; - handleNewUnitSubmit: (subsectionId: string) => void; + handleAddSection: ReturnType; + handleAddSubsection: ReturnType; + handleAddUnit: ReturnType; openUnitPage: (locator: string) => void; getUnitUrl: (locator: string) => string; }; @@ -76,19 +72,7 @@ export const CourseAuthoringProvider = ({ } }; - const handleNewSectionSubmit = () => { - dispatch(addNewSectionQuery(courseUsageKey)); - }; - - const handleNewSubsectionSubmit = (sectionId: string) => { - dispatch(addNewSubsectionQuery(sectionId)); - }; - - const handleNewUnitSubmit = (subsectionId: string) => { - dispatch(addNewUnitQuery(subsectionId, openUnitPage)); - }; - - const handleAddSectionFromLibrary = useCreateCourseBlock(async (locator) => { + const addSectionToCourse = async (locator: string) => { try { const data = await getCourseItem(locator); // instanbul ignore next @@ -98,9 +82,9 @@ export const CourseAuthoringProvider = ({ } catch { dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); } - }); + }; - const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => { + const addSubsectionToCourse = async (locator: string, parentLocator: string) => { try { const data = await getCourseItem(locator); data.shouldScroll = true; @@ -109,12 +93,14 @@ export const CourseAuthoringProvider = ({ } 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 handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage); + const handleAddUnit = useCreateCourseBlock(openUnitPage); const context = useMemo(() => ({ courseId, @@ -122,12 +108,9 @@ export const CourseAuthoringProvider = ({ courseDetails, courseDetailStatus, canChangeProviders, - handleNewSectionSubmit, - handleNewSubsectionSubmit, - handleNewUnitSubmit, - handleAddSectionFromLibrary, - handleAddSubsectionFromLibrary, - handleAddUnitFromLibrary, + handleAddSection, + handleAddSubsection, + handleAddUnit, getUnitUrl, openUnitPage, }), [ @@ -136,12 +119,9 @@ export const CourseAuthoringProvider = ({ courseDetails, courseDetailStatus, canChangeProviders, - handleNewSectionSubmit, - handleNewSubsectionSubmit, - handleNewUnitSubmit, - handleAddSectionFromLibrary, - handleAddSubsectionFromLibrary, - handleAddUnitFromLibrary, + handleAddSection, + handleAddSubsection, + handleAddUnit, getUnitUrl, openUnitPage, ]); diff --git a/src/course-libraries/LegacyLibContentBlockAlert.tsx b/src/course-libraries/LegacyLibContentBlockAlert.tsx index a5092ff0a..2aaad37b0 100644 --- a/src/course-libraries/LegacyLibContentBlockAlert.tsx +++ b/src/course-libraries/LegacyLibContentBlockAlert.tsx @@ -70,6 +70,7 @@ const LegacyLibContentBlockAlert = ({ courseId }: Props) => { target="_blank" as={Hyperlink} variant="tertiary" + key="learn-more" showLaunchIcon={false} destination={learnMoreUrl} > @@ -77,6 +78,7 @@ const LegacyLibContentBlockAlert = ({ courseId }: Props) => { , , ]} diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 101d61ff5..9f65f6e60 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -8,3 +8,7 @@ @import "./publish-modal/PublishModal"; @import "./xblock-status/XBlockStatus"; @import "./drag-helper/SortableItem"; + +.border-dashed { + border: dashed; +} diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 44379cbe9..27ca70066 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -354,8 +354,9 @@ describe('', () => { }); it('adds new section correctly', async () => { - const { findAllByTestId } = renderComponent(); - let elements = await findAllByTestId('section-card'); + const user = userEvent.setup(); + renderComponent(); + let elements = await screen.findAllByTestId('section-card'); window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ top: 0, bottom: 4000, @@ -378,9 +379,9 @@ describe('', () => { .onGet(getXBlockApiUrl(courseSectionMock.id)) .reply(200, courseSectionMock); const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0]; - await act(async () => fireEvent.click(newSectionButton)); + await user.click(newSectionButton); - elements = await findAllByTestId('section-card'); + elements = await screen.findAllByTestId('section-card'); expect(elements.length).toBe(5); expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); }); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 5198dc1fc..68f440637 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -1,11 +1,10 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Container, Row, TransitionReplace, Toast, - StandardModal, Button, ActionRow, } from '@openedx/paragon'; @@ -32,14 +31,11 @@ import { UnlinkModal } from '@src/generic/unlink-modal'; import AlertMessage from '@src/generic/alert-message'; import getPageHeadTitle from '@src/generic/utils'; import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot'; -import { ContainerType } from '@src/generic/key-utils'; -import { LibraryAndComponentPicker, SelectedComponent } from '@src/library-authoring'; -import { ContentType } from '@src/library-authoring/routes'; import { NOTIFICATION_MESSAGES } from '@src/constants'; -import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; 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 { getCurrentItem, getProctoredExamsFlag, @@ -75,14 +71,13 @@ const CourseOutline = () => { const location = useLocation(); const { courseId, - handleAddSubsectionFromLibrary, - handleAddUnitFromLibrary, - handleAddSectionFromLibrary, - handleNewSectionSubmit, + courseUsageKey, + handleAddSubsection, + handleAddUnit, + handleAddSection, } = useCourseAuthoringContext(); const { - courseUsageKey, courseName, savingStatus, statusBarData, @@ -114,9 +109,6 @@ const CourseOutline = () => { headerNavigationsActions, openEnableHighlightsModal, closeEnableHighlightsModal, - isAddLibrarySectionModalOpen, - openAddLibrarySectionModal, - closeAddLibrarySectionModal, handleEnableHighlightsSubmit, handleInternetConnectionFailed, handleOpenHighlightsModal, @@ -243,16 +235,6 @@ const CourseOutline = () => { } }; - const handleSelectLibrarySection = useCallback((selectedSection: SelectedComponent) => { - handleAddSectionFromLibrary.mutateAsync({ - type: COMPONENT_TYPES.libraryV2, - category: ContainerType.Chapter, - parentLocator: courseUsageKey, - libraryContentKey: selectedSection.usageKey, - }); - closeAddLibrarySectionModal(); - }, [closeAddLibrarySectionModal, handleAddSectionFromLibrary.mutateAsync, courseId, courseUsageKey]); - useEffect(() => { setSections(sectionsList); }, [sectionsList]); @@ -489,9 +471,9 @@ const CourseOutline = () => { {courseActions.childAddable && ( )} @@ -499,9 +481,9 @@ const CourseOutline = () => { {courseActions.childAddable && ( @@ -558,30 +540,15 @@ const CourseOutline = () => { close={closeUnlinkModal} onUnlinkSubmit={handleUnlinkItemSubmit} /> - - -
({ @@ -10,6 +12,32 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }), })); +const handleAddSection = { mutateAsync: jest.fn() }; +const handleAddSubsection = { mutateAsync: jest.fn() }; +const handleAddUnit = { mutateAsync: jest.fn() }; +const courseUsageKey = 'some/usage/key'; +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + courseUsageKey, + getUnitUrl: (id: string) => `/some/${id}`, + handleAddSection, + handleAddSubsection, + handleAddUnit, + }), +})); + +const startCurrentFlow = jest.fn(); +let currentFlow: OutlineFlow | null = null; +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(), + startCurrentFlow, + currentFlow, + }), +})); + [ { containerType: ContainerType.Section }, { containerType: ContainerType.Subsection }, @@ -18,6 +46,10 @@ jest.mock('react-redux', () => ({ describe(` for ${containerType}`, () => { beforeEach(() => { initializeMocks(); + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true', + }); }); it('renders and behaves correctly', async () => { @@ -27,7 +59,9 @@ jest.mock('react-redux', () => ({ handleNewButtonClick={newClickHandler} handleUseFromLibraryClick={useFromLibClickHandler} childType={containerType} - />); + parentLocator="" + parentTitle="" + />, { extraWrapper: OutlineSidebarProvider }); const newBtn = await screen.findByRole('button', { name: `New ${containerType}` }); expect(newBtn).toBeInTheDocument(); @@ -38,5 +72,81 @@ jest.mock('react-redux', () => ({ await userEvent.click(useBtn); await waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled()); }); + + 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}` }); + expect(newBtn).toBeInTheDocument(); + await userEvent.click(newBtn); + switch (containerType) { + case ContainerType.Section: + await waitFor(() => expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({ + type: ContainerType.Chapter, + parentLocator: courseUsageKey, + displayName: 'Section', + })); + break; + case ContainerType.Subsection: + await waitFor(() => expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({ + type: ContainerType.Sequential, + parentLocator, + displayName: 'Subsection', + })); + break; + case ContainerType.Unit: + await waitFor(() => expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({ + type: ContainerType.Vertical, + parentLocator, + displayName: 'Unit', + })); + break; + default: + throw new Error(`Unknown container type: ${containerType}`); + } + }); + + 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}`, + 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, + parentLocator, + parentTitle, + }; + render(, { extraWrapper: OutlineSidebarProvider }); + // should show placeholder when use button is clicked + expect(await screen.findByRole('heading', { + name: new RegExp(`Adding Library ${containerType}`, 'i'), + })).toBeInTheDocument(); + }); }); }); diff --git a/src/course-outline/OutlineAddChildButtons.tsx b/src/course-outline/OutlineAddChildButtons.tsx index 197a15f89..ceaa2ad1f 100644 --- a/src/course-outline/OutlineAddChildButtons.tsx +++ b/src/course-outline/OutlineAddChildButtons.tsx @@ -1,22 +1,97 @@ -import { Button, Stack } from '@openedx/paragon'; -import { Add as IconAdd, Newsstand } from '@openedx/paragon/icons'; +import { + Button, Col, IconButton, Row, Stack, StandardModal, useToggle, +} from '@openedx/paragon'; +import { Add as IconAdd, Close, Newsstand } from '@openedx/paragon/icons'; 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 { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { LoadingSpinner } from '@src/generic/Loading'; +import { useCallback } from 'react'; +import { COURSE_BLOCK_NAMES } from '@src/constants'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { LibraryAndComponentPicker, type SelectedComponent } from '@src/library-authoring'; +import { ContentType } from '@src/library-authoring/routes'; +import { isOutlineNewDesignEnabled } from '@src/course-outline/utils'; import messages from './messages'; -interface NewChildButtonsProps { - handleNewButtonClick: () => void; - handleUseFromLibraryClick: () => void; +/** + * Placeholder component that is displayed when a user clicks the "Use content from library" button. + * Shows a loading spinner when the component is selected and being added to the course. + * Finally it is hidden once the add component operation is complete and the content is successfully + * added to the course. + * @param props.parentLocator The locator of the parent flow item to which the content will be added. + */ +const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => { + const intl = useIntl(); + const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext(); + const { + handleAddSection, + handleAddSubsection, + handleAddUnit, + } = useCourseAuthoringContext(); + + if (!currentFlow || currentFlow.parentLocator !== parentLocator) { + return null; + } + + const getTitle = () => { + switch (currentFlow?.flowType) { + case 'use-section': + return intl.formatMessage(messages.placeholderSectionText); + case 'use-subsection': + return intl.formatMessage(messages.placeholderSubsectionText); + case 'use-unit': + return intl.formatMessage(messages.placeholderUnitText); + default: + // istanbul ignore next: this should never happen + throw new Error('Unknown flow type'); + } + }; + + return ( + + + + {(handleAddSection.isPending + || handleAddSubsection.isPending + || handleAddUnit.isPending) && ( + + )} +

{getTitle()}

+ +
+ +
+ ); +}; + +interface BaseProps { + handleNewButtonClick?: () => void; onClickCard?: (e: React.MouseEvent) => void; childType: ContainerType; btnVariant?: string; btnClasses?: string; btnSize?: 'sm' | 'md' | 'lg' | 'inline'; + parentLocator: string; } -const OutlineAddChildButtons = ({ +interface NewChildButtonsProps extends BaseProps { + handleUseFromLibraryClick?: () => void; + parentTitle: string; +} + +const NewOutlineAddChildButtons = ({ handleNewButtonClick, handleUseFromLibraryClick, onClickCard, @@ -24,6 +99,8 @@ const OutlineAddChildButtons = ({ btnVariant = 'outline-primary', btnClasses = 'mt-4 border-gray-500 rounded-0', btnSize, + parentLocator, + parentTitle, }: NewChildButtonsProps) => { // WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below, // as it has a useEffect that fetches course waffle flags whenever @@ -32,59 +109,279 @@ const OutlineAddChildButtons = ({ // See https://github.com/openedx/frontend-app-authoring/pull/1938. const { librariesV2Enabled } = useSelector(getStudioHomeData); const intl = useIntl(); + const { + courseUsageKey, + handleAddSection, + handleAddSubsection, + handleAddUnit, + } = useCourseAuthoringContext(); + const { startCurrentFlow } = useOutlineSidebarContext(); let messageMap = { newButton: messages.newUnitButton, importButton: messages.useUnitFromLibraryButton, }; + let onNewCreateContent: () => Promise; + let flowType: OutlineFlowType; + // Based on the childType, determine the correct action and messages to display. switch (childType) { case ContainerType.Section: messageMap = { newButton: messages.newSectionButton, importButton: messages.useSectionFromLibraryButton, }; + onNewCreateContent = () => handleAddSection.mutateAsync({ + type: ContainerType.Chapter, + parentLocator: courseUsageKey, + displayName: COURSE_BLOCK_NAMES.chapter.name, + }); + flowType = 'use-section'; break; case ContainerType.Subsection: messageMap = { newButton: messages.newSubsectionButton, importButton: messages.useSubsectionFromLibraryButton, }; + onNewCreateContent = () => handleAddSubsection.mutateAsync({ + type: ContainerType.Sequential, + parentLocator, + displayName: COURSE_BLOCK_NAMES.sequential.name, + }); + flowType = 'use-subsection'; break; case ContainerType.Unit: messageMap = { newButton: messages.newUnitButton, importButton: messages.useUnitFromLibraryButton, }; + onNewCreateContent = () => handleAddUnit.mutateAsync({ + type: ContainerType.Vertical, + parentLocator, + displayName: COURSE_BLOCK_NAMES.vertical.name, + }); + flowType = 'use-unit'; break; default: - break; + // istanbul ignore next: unreachable + throw new Error(`Unrecognized block type ${childType}`); } + /** + * Starts add flow in sidebar when `Use content from library` button is clicked. + */ + const onUseLibraryContent = useCallback(async () => { + startCurrentFlow({ + flowType, + parentLocator, + parentTitle, + }); + }, [ + childType, + parentLocator, + parentTitle, + startCurrentFlow, + ]); + return ( - - - {librariesV2Enabled && ( + <> + + - )} - + {librariesV2Enabled && ( + + )} + + + ); +}; + +/** + * Legacy component for adding child blocks in Studio. + * Uses the old flow of opening a modal to allow user to select content from library. + */ +const LegacyOutlineAddChildButtons = ({ + handleNewButtonClick, + childType, + btnVariant = 'outline-primary', + btnClasses = 'mt-4 border-gray-500 rounded-0', + btnSize, + parentLocator, + onClickCard, +}: BaseProps) => { + // WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below, + // as it has a useEffect that fetches course waffle flags whenever + // location.search is updated. Course search updates location.search when + // user types, which will then trigger the useEffect and reload the page. + // See https://github.com/openedx/frontend-app-authoring/pull/1938. + const { librariesV2Enabled } = useSelector(getStudioHomeData); + const intl = useIntl(); + const { + courseUsageKey, + handleAddSection, + handleAddSubsection, + handleAddUnit, + } = useCourseAuthoringContext(); + const [ + isAddLibrarySectionModalOpen, + openAddLibrarySectionModal, + closeAddLibrarySectionModal, + ] = useToggle(false); + let messageMap = { + newButton: messages.newUnitButton, + importButton: messages.useUnitFromLibraryButton, + modalTitle: messages.unitPickerModalTitle, + }; + let onNewCreateContent: () => Promise; + let onUseLibraryContent: (selected: SelectedComponent) => Promise; + let visibleTabs: ContentType[] = []; + let query: string[] = []; + + switch (childType) { + case ContainerType.Section: + messageMap = { + newButton: messages.newSectionButton, + importButton: messages.useSectionFromLibraryButton, + modalTitle: messages.sectionPickerModalTitle, + }; + onNewCreateContent = () => handleAddSection.mutateAsync({ + type: ContainerType.Chapter, + parentLocator: courseUsageKey, + displayName: COURSE_BLOCK_NAMES.chapter.name, + }); + onUseLibraryContent = (selected: SelectedComponent) => handleAddSection.mutateAsync({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Chapter, + parentLocator: courseUsageKey, + libraryContentKey: selected.usageKey, + }); + visibleTabs = [ContentType.sections]; + query = ['block_type = "section"']; + break; + case ContainerType.Subsection: + messageMap = { + newButton: messages.newSubsectionButton, + importButton: messages.useSubsectionFromLibraryButton, + modalTitle: messages.subsectionPickerModalTitle, + }; + onNewCreateContent = () => handleAddSubsection.mutateAsync({ + type: ContainerType.Sequential, + parentLocator, + displayName: COURSE_BLOCK_NAMES.sequential.name, + }); + onUseLibraryContent = (selected: SelectedComponent) => handleAddSubsection.mutateAsync({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Sequential, + parentLocator, + libraryContentKey: selected.usageKey, + }); + visibleTabs = [ContentType.subsections]; + query = ['block_type = "subsection"']; + break; + case ContainerType.Unit: + messageMap = { + newButton: messages.newUnitButton, + importButton: messages.useUnitFromLibraryButton, + modalTitle: messages.unitPickerModalTitle, + }; + onNewCreateContent = () => handleAddUnit.mutateAsync({ + type: ContainerType.Vertical, + parentLocator, + displayName: COURSE_BLOCK_NAMES.vertical.name, + }); + onUseLibraryContent = (selected: SelectedComponent) => handleAddUnit.mutateAsync({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Vertical, + parentLocator, + libraryContentKey: selected.usageKey, + }); + visibleTabs = [ContentType.units]; + query = ['block_type = "unit"']; + break; + default: + // istanbul ignore next: unreachable + throw new Error(`Unrecognized block type ${childType}`); + } + + const handleOnComponentSelected = (selected: SelectedComponent) => { + onUseLibraryContent(selected); + closeAddLibrarySectionModal(); + }; + + return ( + <> + + + {librariesV2Enabled && ( + + )} + + + + + + ); +}; + +/** + * Wrapper component that displays the correct component based on the configuration. + */ +const OutlineAddChildButtons = (props: NewChildButtonsProps) => { + const showNewActionsBar = isOutlineNewDesignEnabled(); + if (showNewActionsBar) { + return ( + + ); + } + return ( + ); }; diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index d9774a336..9a67ecf32 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -26,8 +26,8 @@ export const useCreateCourseBlock = ( callback?: ((locator: string, parentLocator: string) => void), ) => useMutation({ mutationFn: createCourseXblock, - onSettled: async (data: { locator: string, parent_locator: string }) => { - callback?.(data.locator, data.parent_locator); + onSettled: async (data: { locator: string }, _err, variables) => { + callback?.(data.locator, variables.parentLocator); }, }); diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 72a37968f..26c7c460a 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -5,7 +5,6 @@ import { hideProcessingNotification, showProcessingNotification, } from '@src/generic/processing-notification/data/slice'; -import { COURSE_BLOCK_NAMES } from '../constants'; import { getCourseBestPracticesChecklist, getCourseLaunchChecklist, @@ -30,11 +29,9 @@ import { setVideoSharingOption, setCourseItemOrderList, pasteBlock, - dismissNotification, createDiscussionsTopics, createCourseXblock, + dismissNotification, createDiscussionsTopics, } from './api'; import { - addSection, - addSubsection, fetchOutlineIndexSuccess, updateOutlineIndexLoadingStatus, updateReindexLoadingStatus, @@ -516,81 +513,6 @@ export function duplicateUnitQuery(unitId: string, subsectionId: string, section }; } -/** - * Generic function to add any course item. See wrapper functions below for specific implementations. - */ -function addNewCourseItemQuery( - parentLocator: string, - category: string, - displayName: string, - addItemFn: (data: any) => Promise, -) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); - - try { - await createCourseXblock({ - parentLocator, - type: category, - displayName, - }).then(async (result) => { - if (result) { - await addItemFn(result); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(hideProcessingNotification()); - } - }); - } catch { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - -export function addNewSectionQuery(parentLocator: string) { - return async (dispatch) => { - dispatch(addNewCourseItemQuery( - parentLocator, - COURSE_BLOCK_NAMES.chapter.id, - COURSE_BLOCK_NAMES.chapter.name, - async (result) => { - const data = await getCourseItem(result.locator); - // Page should scroll to newly created section. - data.shouldScroll = true; - dispatch(addSection(data)); - }, - )); - }; -} - -export function addNewSubsectionQuery(parentLocator: string) { - return async (dispatch) => { - dispatch(addNewCourseItemQuery( - parentLocator, - COURSE_BLOCK_NAMES.sequential.id, - COURSE_BLOCK_NAMES.sequential.name, - async (result) => { - const data = await getCourseItem(result.locator); - // Page should scroll to newly created subsection. - data.shouldScroll = true; - dispatch(addSubsection({ parentLocator, data })); - }, - )); - }; -} - -export function addNewUnitQuery(parentLocator: string, callback: { (locator: any): void }) { - return async (dispatch) => { - dispatch(addNewCourseItemQuery( - parentLocator, - COURSE_BLOCK_NAMES.vertical.id, - COURSE_BLOCK_NAMES.vertical.name, - async (result) => callback(result.locator), - )); - }; -} - function setBlockOrderListQuery( parentId: string, blockIds: string[], diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx index 464827bcc..b1f758bf6 100644 --- a/src/course-outline/header-navigations/HeaderActions.tsx +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -5,11 +5,12 @@ import { import { Add as IconAdd, FindInPage, ViewSidebar, } from '@openedx/paragon/icons'; +import { OUTLINE_SIDEBAR_PAGES } from '@src/course-outline/outline-sidebar/constants'; import { OutlinePageErrors, XBlockActions } from '@src/data/types'; import type { SidebarPage } from '@src/generic/sidebar'; -import { useOutlineSidebarContext, OutlineSidebarPageKeys } from '../outline-sidebar/OutlineSidebarContext'; +import { type OutlineSidebarPageKeys, useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; import messages from './messages'; @@ -29,7 +30,7 @@ const HeaderActions = ({ const intl = useIntl(); const { lmsLink } = actions; - const { setCurrentPageKey, sidebarPages } = useOutlineSidebarContext(); + const { setCurrentPageKey } = useOutlineSidebarContext(); return ( @@ -79,7 +80,7 @@ const HeaderActions = ({ - {Object.entries(sidebarPages).filter(([, page]) => !page.hideFromActionMenu) + {Object.entries(OUTLINE_SIDEBAR_PAGES).filter(([, page]) => !page.hideFromActionMenu) .map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => ( - {page.title} + {intl.formatMessage(page.title)} ))} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 83855d73f..1a486d9fd 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -10,6 +10,7 @@ import { RequestStatus } from '@src/data/constants'; import { useUnlinkDownstream } from '@src/generic/unlink-modal'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { ContainerType } from '@src/generic/key-utils'; import { COURSE_BLOCK_NAMES } from './constants'; import { setCurrentItem, @@ -62,7 +63,7 @@ import { containerComparisonQueryKeys } from '../container-comparison/data/apiHo const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); const queryClient = useQueryClient(); - const { handleNewSectionSubmit } = useCourseAuthoringContext(); + const { handleAddSection } = useCourseAuthoringContext(); const { reindexLink, @@ -99,11 +100,6 @@ const useCourseOutline = ({ courseId }) => { const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false); - const [ - isAddLibrarySectionModalOpen, - openAddLibrarySectionModal, - closeAddLibrarySectionModal, - ] = useToggle(false); const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; @@ -116,7 +112,13 @@ const useCourseOutline = ({ courseId }) => { }; const headerNavigationsActions = { - handleNewSection: handleNewSectionSubmit, + handleNewSection: () => { + handleAddSection.mutateAsync({ + type: ContainerType.Chapter, + parentLocator: courseStructure?.id, + displayName: COURSE_BLOCK_NAMES.chapter.name, + }); + }, handleReIndex: () => { setDisableReindexButton(true); setShowSuccessAlert(false); @@ -316,9 +318,6 @@ const useCourseOutline = ({ courseId }) => { closePublishModal, isConfigureModalOpen, openConfigureModal, - isAddLibrarySectionModalOpen, - openAddLibrarySectionModal, - closeAddLibrarySectionModal, handleConfigureModalClose, headerNavigationsActions, handleEnableHighlightsSubmit, diff --git a/src/course-outline/messages.ts b/src/course-outline/messages.ts index 8638497ec..9bad0c290 100644 --- a/src/course-outline/messages.ts +++ b/src/course-outline/messages.ts @@ -76,6 +76,31 @@ const messages = defineMessages({ defaultMessage: 'Select section', description: 'Section modal picker title text in outline', }, + unitPickerModalTitle: { + id: 'course-authoring.course-outline.subsection.unit.modal.single-title.text', + defaultMessage: 'Select unit', + description: 'Library unit picker modal title.', + }, + subsectionPickerModalTitle: { + id: 'course-authoring.course-outline.section.subsection-modal.title', + defaultMessage: 'Select subsection', + description: 'Subsection modal picker title text in outline', + }, + placeholderSectionText: { + id: 'course-authoring.course-outline.placeholder.section.title', + defaultMessage: 'Adding Library Section', + description: 'Placeholder section text while adding library section', + }, + placeholderSubsectionText: { + id: 'course-authoring.course-outline.placeholder.subsection.title', + defaultMessage: 'Adding Library Subsection', + description: 'Placeholder subsection text while adding library subsection', + }, + placeholderUnitText: { + id: 'course-authoring.course-outline.placeholder.unit.title', + defaultMessage: 'Adding Library Unit', + description: 'Placeholder unit text while adding library unit', + }, }); export default messages; diff --git a/src/course-outline/outline-sidebar/AddSidebar.test.tsx b/src/course-outline/outline-sidebar/AddSidebar.test.tsx index 5eac2301d..a8ab575d9 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.test.tsx @@ -1,8 +1,10 @@ import { courseOutlineIndexMock } from '@src/course-outline/__mocks__'; -import { initializeMocks, render, screen } from '@src/testUtils'; +import { + initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; import { userEvent } from '@testing-library/user-event'; import mockResult from '@src/library-authoring/__mocks__/library-search.json'; -import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock'; +import { mockContentSearchConfig } from '@src/search-manager/data/api.mock'; import { mockContentLibrary, mockGetCollectionMetadata, @@ -10,14 +12,17 @@ import { mockGetContentLibraryV2List, mockLibraryBlockMetadata, } from '@src/library-authoring/data/api.mocks'; +import { + type OutlineFlow, + type OutlineFlowType, + OutlineSidebarProvider, +} from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import fetchMock from 'fetch-mock-jest'; import { AddSidebar } from './AddSidebar'; -const handleNewSectionSubmit = jest.fn(); -const handleNewSubsectionSubmit = jest.fn(); -const handleNewUnitSubmit = jest.fn(); -const handleAddSectionFromLibrary = { mutateAsync: jest.fn() }; -const handleAddSubsectionFromLibrary = { mutateAsync: jest.fn() }; -const handleAddUnitFromLibrary = { mutateAsync: jest.fn() }; +const handleAddSection = { mutateAsync: jest.fn() }; +const handleAddSubsection = { mutateAsync: jest.fn() }; +const handleAddUnit = { mutateAsync: jest.fn() }; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockGetCollectionMetadata.applyMock(); @@ -25,17 +30,15 @@ mockGetContentLibraryV2List.applyMock(); mockLibraryBlockMetadata.applyMock(); mockGetContainerMetadata.applyMock(); +const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ courseId: 5, courseUsageKey: 'course-usage-key', courseDetails: { name: 'Test course' }, - handleNewSubsectionSubmit, - handleNewUnitSubmit, - handleNewSectionSubmit, - handleAddSectionFromLibrary, - handleAddSubsectionFromLibrary, - handleAddUnitFromLibrary, + handleAddSection, + handleAddSubsection, + handleAddUnit, }), })); @@ -52,7 +55,16 @@ jest.mock('@src/studio-home/hooks', () => ({ }), })); -const renderComponent = () => render(); +let currentFlow: OutlineFlow | null = null; +jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({ + ...jest.requireActual('../outline-sidebar/OutlineSidebarContext'), + useOutlineSidebarContext: () => ({ + ...jest.requireActual('../outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(), + currentFlow, + }), +})); + +const renderComponent = () => render(, { extraWrapper: OutlineSidebarProvider }); const searchResult = { ...mockResult, results: [ @@ -71,8 +83,20 @@ const searchResult = { describe('AddSidebar component', () => { beforeEach(() => { initializeMocks(); - mockSearchResult({ - ...searchResult, + // The Meilisearch client-side API uses fetch, not Axios. + fetchMock.mockReset(); + fetchMock.post(searchEndpoint, (_url, req) => { + const requestData = JSON.parse((req.body ?? '') as string); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise Instantsearch will update the UI and change the query, + // leading to unexpected results in the test cases. + const newMockResult = { ...searchResult }; + newMockResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return newMockResult; }); }); @@ -113,6 +137,9 @@ describe('AddSidebar component', () => { it('calls appropriate handlers on new button click', async () => { const user = userEvent.setup(); + const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children; + const lastSection = sectionList[3]; + const lastSubsection = lastSection.childInfo.children[0]; renderComponent(); // Validate handler for adding section, subsection and unit @@ -120,11 +147,23 @@ describe('AddSidebar component', () => { const subsection = await screen.findByRole('button', { name: 'Subsection' }); const unit = await screen.findByRole('button', { name: 'Unit' }); await user.click(section); - expect(handleNewSectionSubmit).toHaveBeenCalled(); + expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({ + type: 'chapter', + parentLocator: 'course-usage-key', + displayName: 'Section', + }); await user.click(subsection); - expect(handleNewSubsectionSubmit).toHaveBeenCalled(); + expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({ + type: 'sequential', + parentLocator: lastSection.id, + displayName: 'Subsection', + }); await user.click(unit); - expect(handleNewUnitSubmit).toHaveBeenCalled(); + expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({ + type: 'vertical', + parentLocator: lastSubsection.id, + displayName: 'Unit', + }); }); it('calls appropriate handlers on existing button click', async () => { @@ -140,7 +179,7 @@ describe('AddSidebar component', () => { const addBtns = await screen.findAllByRole('button', { name: 'Add' }); // first one is unit as per mock await user.click(addBtns[0]); - expect(handleAddUnitFromLibrary.mutateAsync).toHaveBeenCalledWith({ + expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({ type: 'library_v2', category: 'vertical', parentLocator: lastSubsection.id, @@ -148,7 +187,7 @@ describe('AddSidebar component', () => { }); // second one is subsection as per mock await user.click(addBtns[1]); - expect(handleAddSubsectionFromLibrary.mutateAsync).toHaveBeenCalledWith({ + expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({ type: 'library_v2', category: 'sequential', parentLocator: lastSection.id, @@ -156,11 +195,56 @@ describe('AddSidebar component', () => { }); // third one is section as per mock await user.click(addBtns[2]); - expect(handleAddSectionFromLibrary.mutateAsync).toHaveBeenCalledWith({ + expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({ type: 'library_v2', category: 'chapter', parentLocator: 'course-usage-key', libraryContentKey: searchResult.results[0].hits[2].usage_key, }); }); + + ['section', 'subsection', 'unit'].forEach((category) => { + it(`shows appropriate existing and new content based on ${category} use button click`, async () => { + const user = userEvent.setup(); + const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children; + const firstSection = sectionList[0]; + const firstSubsection = firstSection.childInfo.children[0]; + currentFlow = { + flowType: `use-${category}` as OutlineFlowType, + parentLocator: category === 'subsection' ? firstSection.id : firstSubsection.id, + parentTitle: category === 'subsection' ? firstSection.displayName : firstSubsection.displayName!, + }; + renderComponent(); + // Check existing tab content is rendered by default + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + expect(fetchMock).toHaveLastFetched((_url, req) => { + const requestData = JSON.parse((req.body ?? '') as string); + const requestedFilter = requestData?.queries[0].filter; + return requestedFilter?.[2] === `block_type IN ["${category}"]`; + }); + + await user.click(await screen.findByRole('tab', { name: 'Add New' })); + // Only category button should be visible + const section = screen.queryByRole('button', { name: 'Section' }); + const subsection = screen.queryByRole('button', { name: 'Subsection' }); + const unit = screen.queryByRole('button', { name: 'Unit' }); + switch (category) { + case 'section': + expect(section).toBeInTheDocument(); + expect(subsection).not.toBeInTheDocument(); + expect(unit).not.toBeInTheDocument(); + break; + case 'subsection': + expect(section).not.toBeInTheDocument(); + expect(subsection).toBeInTheDocument(); + expect(unit).not.toBeInTheDocument(); + break; + default: + expect(section).not.toBeInTheDocument(); + expect(subsection).not.toBeInTheDocument(); + expect(unit).toBeInTheDocument(); + break; + } + }); + }); }); diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index 24cc56ac9..9e3acab5a 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -12,7 +12,9 @@ import { import { getIconBorderStyleColor, getItemIcon } from '@src/generic/block-type-utils'; import { useSelector } from 'react-redux'; import { getSectionsList } from '@src/course-outline/data/selectors'; -import { useCallback, useMemo } from 'react'; +import { + useCallback, useEffect, useMemo, useState, +} from 'react'; import { ComponentSelectedEvent } from '@src/library-authoring/common/context/ComponentPickerContext'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { ContainerType } from '@src/generic/key-utils'; @@ -20,7 +22,9 @@ import type { XBlock } from '@src/data/types'; import { ContentType } from '@src/library-authoring/routes'; import { ComponentPicker } from '@src/library-authoring'; import { MultiLibraryProvider } from '@src/library-authoring/common/context/MultiLibraryContext'; +import { COURSE_BLOCK_NAMES } from '@src/constants'; import messages from './messages'; +import { useOutlineSidebarContext } from './OutlineSidebarContext'; type ContainerTypes = 'unit' | 'subsection' | 'section'; @@ -47,36 +51,55 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { const sectionsList = useSelector(getSectionsList); const lastSection = getLastEditableParent(sectionsList); const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []); + const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext(); + const sectionParentId = currentFlow?.parentLocator || lastSection?.id; + const subsectionParentId = currentFlow?.parentLocator || lastSubsection?.id; const { - handleNewSectionSubmit, - handleNewSubsectionSubmit, - handleNewUnitSubmit, + courseUsageKey, + handleAddSection, + handleAddSubsection, + handleAddUnit, } = useCourseAuthoringContext(); - const onCreateContent = useCallback(() => { + const onCreateContent = useCallback(async () => { switch (blockType) { case 'section': - handleNewSectionSubmit(); + await handleAddSection.mutateAsync({ + type: ContainerType.Chapter, + parentLocator: courseUsageKey, + displayName: COURSE_BLOCK_NAMES.chapter.name, + }); break; case 'subsection': - if (lastSection) { - handleNewSubsectionSubmit(lastSection.id); + if (sectionParentId) { + await handleAddSubsection.mutateAsync({ + type: ContainerType.Sequential, + parentLocator: sectionParentId, + displayName: COURSE_BLOCK_NAMES.sequential.name, + }); } break; case 'unit': - if (lastSubsection) { - handleNewUnitSubmit(lastSubsection.id); + if (subsectionParentId) { + await handleAddUnit.mutateAsync({ + type: ContainerType.Vertical, + parentLocator: subsectionParentId, + displayName: COURSE_BLOCK_NAMES.vertical.name, + }); } break; default: // istanbul ignore next: unreachable throw new Error(`Unrecognized block type ${blockType}`); } + stopCurrentFlow(); }, [ blockType, - handleNewSectionSubmit, - handleNewSubsectionSubmit, - handleNewUnitSubmit, + courseUsageKey, + handleAddSection, + handleAddSubsection, + handleAddUnit, + currentFlow, lastSection, lastSubsection, ]); @@ -101,40 +124,77 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { /** Add New Content Tab Section */ const AddNewContent = () => { const intl = useIntl(); + const { currentFlow } = useOutlineSidebarContext(); + const btns = useCallback(() => { + switch (currentFlow?.flowType) { + case 'use-section': + return ( + + ); + case 'use-subsection': + return ( + + ); + case 'use-unit': + return ( + + ); + default: + return ( + <> + + + + + ); + } + }, [currentFlow, intl]); + return ( - - - + {btns()} ); }; /** Add Existing Content Tab Section */ const ShowLibraryContent = () => { - const sectionsList: Array = useSelector(getSectionsList); - const lastSection = getLastEditableParent(sectionsList); - const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []); const { courseUsageKey, - handleAddSectionFromLibrary, - handleAddSubsectionFromLibrary, - handleAddUnitFromLibrary, + handleAddSection, + handleAddSubsection, + handleAddUnit, } = useCourseAuthoringContext(); + const sectionsList: Array = useSelector(getSectionsList); + const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext(); - const onComponentSelected: ComponentSelectedEvent = useCallback(({ usageKey, blockType }) => { + const lastSection = getLastEditableParent(sectionsList); + const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []); + const sectionParentId = currentFlow?.parentLocator || lastSection?.id; + const subsectionParentId = currentFlow?.parentLocator || lastSubsection?.id; + + const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => { switch (blockType) { case 'section': - handleAddSectionFromLibrary.mutateAsync({ + await handleAddSection.mutateAsync({ type: COMPONENT_TYPES.libraryV2, category: ContainerType.Chapter, parentLocator: courseUsageKey, @@ -142,50 +202,62 @@ const ShowLibraryContent = () => { }); break; case 'subsection': - if (lastSection) { - handleAddSubsectionFromLibrary.mutateAsync({ + if (sectionParentId) { + await handleAddSubsection.mutateAsync({ type: COMPONENT_TYPES.libraryV2, category: ContainerType.Sequential, - parentLocator: lastSection.id, + parentLocator: sectionParentId, libraryContentKey: usageKey, }); } break; case 'unit': - if (lastSubsection) { - handleAddUnitFromLibrary.mutateAsync({ + if (subsectionParentId) { + await handleAddUnit.mutateAsync({ type: COMPONENT_TYPES.libraryV2, category: ContainerType.Vertical, - parentLocator: lastSubsection.id, + parentLocator: subsectionParentId, libraryContentKey: usageKey, }); } break; default: - // istanbul ignore next: unreachable + // istanbul ignore next: should not happen throw new Error(`Unrecognized block type ${blockType}`); } + stopCurrentFlow(); }, [ courseUsageKey, - handleAddSectionFromLibrary, - handleAddSubsectionFromLibrary, - handleAddUnitFromLibrary, + handleAddSection, + handleAddSubsection, + handleAddUnit, lastSection, lastSubsection, + currentFlow, + stopCurrentFlow, ]); const allowedBlocks = useMemo(() => { const blocks: ContainerTypes[] = ['section']; - if (lastSection) { blocks.push('subsection'); } - if (lastSubsection) { blocks.push('unit'); } - return blocks; - }, [lastSection, lastSubsection, sectionsList]); + switch (currentFlow?.flowType) { + case 'use-section': + return ['section']; + case 'use-subsection': + return ['subsection']; + case 'use-unit': + return ['unit']; + default: + if (lastSection) { blocks.push('subsection'); } + if (lastSubsection) { blocks.push('unit'); } + return blocks; + } + }, [lastSection, lastSubsection, sectionsList, currentFlow]); return ( { /** Tabs Component */ const AddTabs = () => { const intl = useIntl(); + const { currentFlow } = useOutlineSidebarContext(); + const [key, setKey] = useState('addNew'); + useEffect(() => { + if (currentFlow) { + setKey('addExisting'); + } + }, [currentFlow, setKey]); return ( @@ -217,13 +298,25 @@ const AddTabs = () => { /** Main Sidebar Component */ export const AddSidebar = () => { + const intl = useIntl(); const { courseDetails } = useCourseAuthoringContext(); + const { currentFlow } = useOutlineSidebarContext(); + const titleAndIcon = useMemo(() => { + switch (currentFlow?.flowType) { + case 'use-subsection': + return { title: currentFlow.parentTitle, icon: getItemIcon('section') }; + case 'use-unit': + return { title: currentFlow.parentTitle, icon: getItemIcon('subsection') }; + default: + return { title: courseDetails?.name || '', icon: SchoolOutline }; + } + }, [currentFlow, intl, getItemIcon]); return (
diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.tsx b/src/course-outline/outline-sidebar/OutlineSidebar.tsx index b320f1fa0..bc1c23bfb 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebar.tsx @@ -3,6 +3,7 @@ import { useMediaQuery } from 'react-responsive'; import { Sidebar } from '@src/generic/sidebar'; +import { OUTLINE_SIDEBAR_PAGES } from '@src/course-outline/outline-sidebar/constants'; import OutlineHelpSidebar from './OutlineHelpSidebar'; import { useOutlineSidebarContext } from './OutlineSidebarContext'; import { isOutlineNewDesignEnabled } from '../utils'; @@ -15,7 +16,6 @@ const OutlineSideBar = () => { setCurrentPageKey, isOpen, toggle, - sidebarPages, } = useOutlineSidebarContext(); // Returns the previous help sidebar component if the waffle flag is disabled @@ -31,7 +31,7 @@ const OutlineSideBar = () => { return ( ; +export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null; +export type OutlineFlow = { + flowType: 'use-section'; + parentLocator?: string; + parentTitle?: string; +} | { + flowType: OutlineFlowType; + parentLocator: string; + parentTitle: string; +}; interface OutlineSidebarContextData { currentPageKey: OutlineSidebarPageKeys; setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void; + currentFlow: OutlineFlow | null; + startCurrentFlow: (flow: OutlineFlow) => void; + stopCurrentFlow: () => void; isOpen: boolean; open: () => void; toggle: () => void; - sidebarPages: OutlineSidebarPages; selectedContainerId?: string; openContainerInfoSidebar: (containerId: string) => void; } @@ -34,9 +39,13 @@ interface OutlineSidebarContextData { const OutlineSidebarContext = createContext(undefined); export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => { - const intl = useIntl(); - - const [currentPageKey, setCurrentPageKeyState] = useState('info'); + const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam( + 'info', + 'sidebar', + (value: string) => value as OutlineSidebarPageKeys, + (value: OutlineSidebarPageKeys) => value, + ); + const [currentFlow, setCurrentFlow] = useState(null); const [isOpen, open, , toggle] = useToggle(true); const [selectedContainerId, setSelectedContainerId] = useState(); @@ -47,35 +56,50 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod } }, [setSelectedContainerId]); + /** + * Stops current add content flow. + * This will cause the sidebar to switch back to its normal state and clear out any placeholder containers. + */ + const stopCurrentFlow = useCallback(() => { + setCurrentFlow(null); + }, [setCurrentFlow]); + const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => { setCurrentPageKeyState(pageKey); + setCurrentFlow(null); open(); - }, [open]); + }, [open, setCurrentFlow]); - const sidebarPages = { - info: { - component: OutlineInfoSidebar, - icon: Info, - title: intl.formatMessage(messages.sidebarButtonInfo), - }, - help: { - component: OutlineHelpSidebar, - icon: HelpOutline, - title: intl.formatMessage(messages.sidebarButtonHelp), - }, - add: { - component: AddSidebar, - icon: Plus, - title: intl.formatMessage(messages.sidebarButtonAdd), - hideFromActionMenu: true, - }, - } satisfies OutlineSidebarPages; + /** + * Starts add content flow. + * The sidebar enters an add content flow which allows user to add content in a specific container. + * A placeholder container is added in the location when the flow is started. + */ + const startCurrentFlow = useCallback((flow: OutlineFlow) => { + setCurrentPageKey('add'); + setCurrentFlow(flow); + }, [setCurrentFlow, setCurrentPageKey]); + + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + stopCurrentFlow(); + } + }; + window.addEventListener('keydown', handleEsc); + + return () => { + window.removeEventListener('keydown', handleEsc); + }; + }, []); const context = useMemo( () => ({ currentPageKey, setCurrentPageKey, - sidebarPages, + currentFlow, + startCurrentFlow, + stopCurrentFlow, isOpen, open, toggle, @@ -85,7 +109,9 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod [ currentPageKey, setCurrentPageKey, - sidebarPages, + currentFlow, + startCurrentFlow, + stopCurrentFlow, isOpen, open, toggle, diff --git a/src/course-outline/outline-sidebar/constants.ts b/src/course-outline/outline-sidebar/constants.ts new file mode 100644 index 000000000..a91410799 --- /dev/null +++ b/src/course-outline/outline-sidebar/constants.ts @@ -0,0 +1,28 @@ +import { HelpOutline, Info, Plus } from '@openedx/paragon/icons'; +import type { SidebarPage } from '@src/generic/sidebar'; +import OutlineHelpSidebar from './OutlineHelpSidebar'; +import { OutlineInfoSidebar } from './OutlineInfoSidebar'; +import messages from './messages'; +import { AddSidebar } from './AddSidebar'; +import type { OutlineSidebarPageKeys } from './OutlineSidebarContext'; + +export type OutlineSidebarPages = Record; + +export const OUTLINE_SIDEBAR_PAGES: OutlineSidebarPages = { + info: { + component: OutlineInfoSidebar, + icon: Info, + title: messages.sidebarButtonInfo, + }, + help: { + component: OutlineHelpSidebar, + icon: HelpOutline, + title: messages.sidebarButtonHelp, + }, + add: { + component: AddSidebar, + icon: Plus, + title: messages.sidebarButtonAdd, + hideFromActionMenu: true, + }, +}; diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index 42027a1b5..210469432 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -105,6 +105,21 @@ const messages = defineMessages({ defaultMessage: 'Add Existing', description: 'Tab title for adding existing library components in outline using sidebar', }, + sidebarTabsAddExisitingSectionToParent: { + id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-existing-tab', + defaultMessage: 'Adding section to course', + description: 'Tab title for adding existing library section to a specific parent in outline using sidebar', + }, + sidebarTabsAddExisitingSubsectionToParent: { + id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-existing-tab', + defaultMessage: 'Adding subsection to {name}', + description: 'Tab title for adding existing library subsection to a specific parent in outline using sidebar', + }, + sidebarTabsAddExisitingUnitToParent: { + id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-existing-tab', + defaultMessage: 'Adding unit to {name}', + description: 'Tab title for adding existing library unit to a specific parent in outline using sidebar', + }, }); export default messages; diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 838580ec2..70261be21 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -2,9 +2,8 @@ import { useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo, } from 'react'; import { useDispatch } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; import { - Bubble, Button, StandardModal, useToggle, + Bubble, Button, useToggle, } from '@openedx/paragon'; import { useSearchParams } from 'react-router-dom'; import classNames from 'classnames'; @@ -21,16 +20,13 @@ import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils'; import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons'; import { ContainerType } from '@src/generic/key-utils'; -import { LibraryAndComponentPicker, SelectedComponent } from '@src/library-authoring'; -import { ContentType } from '@src/library-authoring/routes'; -import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; 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 messages from './messages'; -import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface SectionCardProps { section: XBlock, @@ -72,23 +68,13 @@ const SectionCard = ({ resetScrollState, }: SectionCardProps) => { const currentRef = useRef(null); - const intl = useIntl(); const dispatch = useDispatch(); const { activeId, overId } = useContext(DragContext); const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext(); const [searchParams] = useSearchParams(); const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === section.id; - const [ - isAddLibrarySubsectionModalOpen, - openAddLibrarySubsectionModal, - closeAddLibrarySubsectionModal, - ] = useToggle(false); - const { - courseId, - handleAddSubsectionFromLibrary, - handleNewSubsectionSubmit, - } = useCourseAuthoringContext(); + const { courseId } = useCourseAuthoringContext(); const queryClient = useQueryClient(); // Expand the section if a search result should be shown/scrolled to @@ -229,21 +215,6 @@ const SectionCard = ({ onOrderChange(index, index + 1); }; - /** - * Callback to handle the selection of a library subsection to be imported to course. - * @param {Object} selectedSubection - The selected subsection details. - * @returns {void} - */ - const handleSelectLibrarySubsection = useCallback((selectedSubection: SelectedComponent) => { - handleAddSubsectionFromLibrary.mutateAsync({ - type: COMPONENT_TYPES.libraryV2, - category: ContainerType.Sequential, - parentLocator: id, - libraryContentKey: selectedSubection.usageKey, - }); - closeAddLibrarySubsectionModal(); - }, [id, handleAddSubsectionFromLibrary, closeAddLibrarySubsectionModal]); - useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { closeForm(); @@ -382,10 +353,10 @@ const SectionCard = ({ {children} {actions.childAddable && ( handleNewSubsectionSubmit(id)} - handleUseFromLibraryClick={openAddLibrarySubsectionModal} onClickCard={(e) => onClickCard(e, true)} childType={ContainerType.Subsection} + parentLocator={section.id} + parentTitle={section.displayName} /> )}
@@ -393,21 +364,6 @@ const SectionCard = ({
- - - {blockSyncData && ( ({ jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ courseId: 5, - handleNewUnitSubmit: jest.fn(), - handleAddUnitFromLibrary: handleOnAddUnitFromLibrary, + handleAddUnit: handleOnAddUnitFromLibrary, + handleAddSubsection: {}, + handleAddSection: {}, }), })); @@ -46,7 +48,7 @@ jest.mock('@src/library-authoring/component-picker', () => ({ // eslint-disable-next-line react/prop-types props.onComponentSelected({ usageKey: containerKey, - blockType: 'unti', + blockType: 'unit', }); }; return ( @@ -340,6 +342,11 @@ describe('', () => { }); it('should add unit from library', async () => { + setConfig({ + ...getConfig(), + ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'false', + }); + const user = userEvent.setup(); renderComponent(); const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn'); @@ -349,15 +356,14 @@ describe('', () => { name: /use unit from library/i, }); expect(useUnitFromLibraryButton).toBeInTheDocument(); - fireEvent.click(useUnitFromLibraryButton); + await user.click(useUnitFromLibraryButton); expect(await screen.findByText('Select unit')); // click dummy button to execute onComponentSelected prop. const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' }); - fireEvent.click(dummyBtn); + await user.click(dummyBtn); - expect(handleOnAddUnitFromLibrary.mutateAsync).toHaveBeenCalled(); expect(handleOnAddUnitFromLibrary.mutateAsync).toHaveBeenCalledWith({ type: COMPONENT_TYPES.libraryV2, parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0', diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index d6c4879cd..4031b198c 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -1,10 +1,10 @@ -import React, { +import { useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo, } from 'react'; import { useDispatch } from 'react-redux'; import { useSearchParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { StandardModal, useToggle } from '@openedx/paragon'; +import { useToggle } from '@openedx/paragon'; import { useQueryClient } from '@tanstack/react-query'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; @@ -20,18 +20,15 @@ import TitleButton from '@src/course-outline/card-header/TitleButton'; import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils'; -import { LibraryAndComponentPicker, SelectedComponent } from '@src/library-authoring'; -import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { ContainerType } from '@src/generic/key-utils'; import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; -import { ContentType } from '@src/library-authoring/routes'; import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; 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 messages from './messages'; -import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext'; interface SubsectionCardProps { section: XBlock, @@ -86,12 +83,7 @@ const SubsectionCard = ({ const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); - const [ - isAddLibraryUnitModalOpen, - openAddLibraryUnitModal, - closeAddLibraryUnitModal, - ] = useToggle(false); - const { courseId, handleNewUnitSubmit, handleAddUnitFromLibrary } = useCourseAuthoringContext(); + const { courseId } = useCourseAuthoringContext(); const queryClient = useQueryClient(); const { @@ -187,7 +179,6 @@ const SubsectionCard = ({ onOrderChange(section, moveDownDetails); }; - const handleNewButtonClick = () => handleNewUnitSubmit(id); const handlePasteButtonClick = () => onPasteClick(id, section.id); const titleComponent = ( @@ -250,16 +241,6 @@ const SubsectionCard = ({ && !section.upstreamInfo?.upstreamRef ); - const handleSelectLibraryUnit = useCallback((selectedUnit: SelectedComponent) => { - handleAddUnitFromLibrary.mutateAsync({ - type: COMPONENT_TYPES.libraryV2, - category: ContainerType.Vertical, - parentLocator: id, - libraryContentKey: selectedUnit.usageKey, - }); - closeAddLibraryUnitModal(); - }, [id, handleAddUnitFromLibrary, closeAddLibraryUnitModal]); - const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => { if (!preventNodeEvents || e.target === e.currentTarget) { openContainerInfoSidebar(subsection.id); @@ -357,10 +338,10 @@ const SubsectionCard = ({ {actions.childAddable && ( <> onClickCard(e, true)} childType={ContainerType.Unit} + parentLocator={subsection.id} + parentTitle={subsection.displayName} /> {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && ( - - - {blockSyncData && (
Icon 1
; const Icon2 = () =>
Icon 2
; const pages = { page1: { - title: 'Page 1', + title: { + id: 'page-1', + defaultMessage: 'Page 1', + }, component: Component1, icon: Icon1, }, page2: { - title: 'Page 2', + title: { + id: 'page-2', + defaultMessage: 'Page 2', + }, component: Component2, icon: Icon2, }, diff --git a/src/generic/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx index e28615d5f..7db1964bc 100644 --- a/src/generic/sidebar/Sidebar.tsx +++ b/src/generic/sidebar/Sidebar.tsx @@ -11,13 +11,14 @@ import { FormatIndentDecrease, FormatIndentIncrease, } from '@openedx/paragon/icons'; +import type { MessageDescriptor } from 'react-intl'; import messages from './messages'; export interface SidebarPage { component: React.ComponentType; icon: React.ComponentType; - title: string; + title: MessageDescriptor; hideFromActionMenu?: boolean; } @@ -55,12 +56,12 @@ interface SidebarProps { * help: { * component: OutlineHelpSidebar, * icon: HelpOutline, - * title: intl.formatMessage(messages.sidebarButtonHelp), + * title: messages.sidebarButtonHelp, * }, * info: { * component: OutlineInfoSidebar, * icon: Info, - * title: intl.formatMessage(messages.sidebarButtonInfo), + * title: messages.sidebarButtonInfo, * }, * } satisfies SidebarPages; * @@ -99,7 +100,7 @@ export function Sidebar({ variant="tertiary" className="x-small text-primary font-weight-bold pl-0" > - {pages[currentPageKey].title} + {intl.formatMessage(pages[currentPageKey].title)} @@ -110,7 +111,7 @@ export function Sidebar({ > - {page.title} + {intl.formatMessage(page.title)} ))} @@ -138,7 +139,7 @@ export function Sidebar({ // @ts-ignore value={key} src={page.icon} - alt={page.title} + alt={intl.formatMessage(page.title)} className="rounded-iconbutton" /> ))} diff --git a/src/library-authoring/common/context/ComponentPickerContext.tsx b/src/library-authoring/common/context/ComponentPickerContext.tsx index a5f46112f..28b15a39b 100644 --- a/src/library-authoring/common/context/ComponentPickerContext.tsx +++ b/src/library-authoring/common/context/ComponentPickerContext.tsx @@ -11,7 +11,7 @@ export interface SelectedComponent { blockType: string; } -export type ComponentSelectedEvent = (selectedComponent: SelectedComponent) => void; +export type ComponentSelectedEvent = (selectedComponent: SelectedComponent) => void | Promise; export type ComponentSelectionChangedEvent = (selectedComponents: SelectedComponent[]) => void; type NoComponentPickerType = { @@ -25,11 +25,15 @@ type NoComponentPickerType = { removeComponentFromSelectedComponents?: never; restrictToLibrary?: never; extraFilter?: never; + isLoading?: never; + setIsLoading?: never; }; type BasePickerType = { restrictToLibrary: boolean; extraFilter: string[], + isLoading?: boolean; + setIsLoading?: React.Dispatch>; }; type ComponentPickerSingleType = BasePickerType & { @@ -94,6 +98,7 @@ export const ComponentPickerProvider = ({ extraFilter, }: ComponentPickerProviderProps) => { const [selectedComponents, setSelectedComponents] = useState([]); + const [isLoading, setIsLoading] = useState(false); const addComponentToSelectedComponents = useCallback(( selectedComponent: SelectedComponent, @@ -133,6 +138,8 @@ export const ComponentPickerProvider = ({ restrictToLibrary, onComponentSelected, extraFilter: extraFilter || [], + isLoading, + setIsLoading, }; case 'multiple': return { @@ -142,6 +149,8 @@ export const ComponentPickerProvider = ({ addComponentToSelectedComponents, removeComponentFromSelectedComponents, extraFilter: extraFilter || [], + isLoading, + setIsLoading, }; default: // istanbul ignore next: this should never happen @@ -156,6 +165,8 @@ export const ComponentPickerProvider = ({ selectedComponents, onChangeComponentSelection, extraFilter, + isLoading, + setIsLoading, ]); return ( diff --git a/src/library-authoring/components/AddComponentWidget.tsx b/src/library-authoring/components/AddComponentWidget.tsx index a6e1c0f9b..05bcfd194 100644 --- a/src/library-authoring/components/AddComponentWidget.tsx +++ b/src/library-authoring/components/AddComponentWidget.tsx @@ -23,6 +23,8 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => addComponentToSelectedComponents, removeComponentFromSelectedComponents, selectedComponents, + isLoading, + setIsLoading, } = useComponentPickerContext(); // istanbul ignore if: this should never happen @@ -35,14 +37,23 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => return null; } + /** disables button while the onComponentSelected operation is pending */ + const onClick = async () => { + setIsLoading?.(true); + try { + await onComponentSelected?.({ usageKey, blockType }); + } finally { + setIsLoading?.(false); + } + }; + if (componentPickerMode === 'single') { return (