From 3c22e4bbe1c47a838b69722f85ea6035b5710eb4 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 9 Jan 2026 22:44:48 +0530 Subject: [PATCH] feat: Add sidebar and library dropdown filter [FC-0114] (#2778) * Add flow in course outline sidebar. Allows author to add new section/subsection/unit or any container from existing libraries via sidebar. * Adds library dropdown filter and collections dropdown filter in add sidebar. Allows authors to filter containers by selected libraries and collections. --- .../course-apps/proctoring/Settings.test.jsx | 5 +- src/CourseAuthoringContext.tsx | 119 ++++++++- src/CourseAuthoringPage.tsx | 3 + src/authz/data/apiHooks.ts | 5 +- src/course-outline/CourseOutline.test.tsx | 9 +- src/course-outline/CourseOutline.tsx | 28 +-- src/course-outline/data/api.ts | 43 +++- src/course-outline/data/apiHooks.ts | 16 +- src/course-outline/data/thunk.ts | 38 +-- .../drag-helper/CourseItemOverlay.tsx | 4 +- src/course-outline/drag-helper/utils.test.ts | 4 +- src/course-outline/drag-helper/utils.ts | 2 +- .../header-navigations/HeaderActions.test.tsx | 5 +- .../header-navigations/HeaderActions.tsx | 28 +-- src/course-outline/hooks.jsx | 76 +----- .../outline-sidebar/AddSidebar.test.tsx | 166 +++++++++++++ .../outline-sidebar/AddSidebar.tsx | 235 ++++++++++++++++++ .../outline-sidebar/OutlineSidebar.test.tsx | 1 + .../outline-sidebar/OutlineSidebarContext.tsx | 11 +- .../outline-sidebar/messages.ts | 15 ++ .../section-card/SectionCard.test.tsx | 10 +- .../section-card/SectionCard.tsx | 29 +-- .../subsection-card/SubsectionCard.test.tsx | 20 +- .../subsection-card/SubsectionCard.tsx | 27 +- .../unit-card/UnitCard.test.tsx | 8 +- src/course-outline/unit-card/UnitCard.tsx | 9 +- src/course-outline/{utils.jsx => utils.tsx} | 58 ++--- .../add-component/AddComponent.test.tsx | 4 +- .../add-component/AddComponent.tsx | 4 +- src/course-unit/data/api.ts | 36 --- src/course-unit/data/thunk.js | 2 +- src/generic/block-type-utils/constants.ts | 10 + src/generic/block-type-utils/index.scss | 36 +++ src/generic/block-type-utils/index.tsx | 5 + src/generic/sidebar/Sidebar.tsx | 3 +- src/generic/sidebar/SidebarContent.tsx | 2 +- src/generic/sidebar/SidebarSection.tsx | 4 +- src/generic/sidebar/index.scss | 11 +- src/hooks.ts | 23 ++ src/index.jsx | 6 +- src/library-authoring/EmptyStates.tsx | 8 +- .../LibraryAuthoringPage.tsx | 119 +++++---- src/library-authoring/LibraryContent.tsx | 10 +- src/library-authoring/LibraryLayout.tsx | 4 +- .../__mocks__/library-search.json | 90 ++++++- .../add-content/AddContent.tsx | 13 +- .../add-content/PickLibraryContentModal.tsx | 19 +- .../collections/CollectionDetails.tsx | 23 +- .../collections/CollectionInfo.tsx | 8 +- .../collections/CollectionInfoHeader.tsx | 13 +- .../collections/LibraryCollectionPage.tsx | 18 +- .../common/context/ComponentPickerContext.tsx | 28 ++- .../common/context/LibraryContext.tsx | 35 +-- .../common/context/MultiLibraryContext.tsx | 54 ++++ .../common/context/PublishedFilterContext.tsx | 31 +++ .../common/context/SidebarContext.tsx | 4 +- .../ComponentAdvancedAssets.tsx | 4 +- .../ComponentAdvancedInfo.test.tsx | 21 +- .../component-info/ComponentAdvancedInfo.tsx | 6 +- .../component-info/ComponentInfo.tsx | 8 +- .../component-info/ComponentInfoHeader.tsx | 6 +- .../component-info/ComponentManagement.tsx | 4 +- .../component-info/ComponentPreview.tsx | 6 +- .../component-picker/ComponentPicker.test.tsx | 34 +-- .../component-picker/ComponentPicker.tsx | 120 ++++++--- .../component-picker/SelectLibrary.test.tsx | 14 +- .../component-picker/SelectLibrary.tsx | 2 +- .../component-picker/index.ts | 2 +- .../components/CollectionCard.test.tsx | 12 +- .../components/CollectionCard.tsx | 10 +- .../components/ComponentCard.tsx | 4 +- .../components/ComponentDeleter.tsx | 4 +- .../components/ComponentMenu.tsx | 6 +- .../components/ComponentRemover.tsx | 6 +- .../containers/ContainerCard.test.tsx | 12 +- .../containers/ContainerCard.tsx | 9 +- .../containers/ContainerDeleter.tsx | 4 +- .../containers/ContainerEditableTitle.tsx | 6 +- .../containers/ContainerInfo.test.tsx | 30 +-- .../containers/ContainerInfo.tsx | 6 +- .../containers/ContainerOrganize.tsx | 4 +- .../containers/ContainerRemover.tsx | 6 +- .../containers/FooterActions.tsx | 6 +- .../containers/HeaderActions.tsx | 4 +- src/library-authoring/data/api.mocks.ts | 4 + src/library-authoring/data/api.ts | 38 ++- src/library-authoring/data/apiHooks.ts | 52 ++-- .../generic/filter-by-published/index.tsx | 4 +- .../manage-collections/ManageCollections.tsx | 12 +- .../PublishDraftButton.tsx | 4 +- src/library-authoring/index.scss | 6 + src/library-authoring/index.tsx | 2 +- .../CollectionDropdownFilter.test.tsx | 202 +++++++++++++++ .../CollectionDropdownFilter.tsx | 147 +++++++++++ .../LibraryDropdownFilter.test.tsx | 128 ++++++++++ .../library-filters/LibraryDropdownFilter.tsx | 126 ++++++++++ .../library-filters/MainFilters.tsx | 33 +++ .../library-filters/SidebarFilters.tsx | 46 ++++ .../library-filters/index.tsx | 3 + .../library-filters/messages.ts | 56 +++++ .../LibraryContainerChildren.tsx | 8 +- .../units/LibraryUnitBlocks.tsx | 13 +- .../discussions/DiscussionsSettings.test.jsx | 12 +- src/search-manager/ClearFiltersButton.tsx | 13 +- src/search-manager/SearchFilterWidget.tsx | 3 +- .../courses-imported-filter-modal/context.tsx | 2 +- src/types.ts | 10 + 107 files changed, 2218 insertions(+), 659 deletions(-) create mode 100644 src/course-outline/outline-sidebar/AddSidebar.test.tsx create mode 100644 src/course-outline/outline-sidebar/AddSidebar.tsx rename src/course-outline/{utils.jsx => utils.tsx} (79%) create mode 100644 src/library-authoring/common/context/MultiLibraryContext.tsx create mode 100644 src/library-authoring/common/context/PublishedFilterContext.tsx create mode 100644 src/library-authoring/library-filters/CollectionDropdownFilter.test.tsx create mode 100644 src/library-authoring/library-filters/CollectionDropdownFilter.tsx create mode 100644 src/library-authoring/library-filters/LibraryDropdownFilter.test.tsx create mode 100644 src/library-authoring/library-filters/LibraryDropdownFilter.tsx create mode 100644 src/library-authoring/library-filters/MainFilters.tsx create mode 100644 src/library-authoring/library-filters/SidebarFilters.tsx create mode 100644 src/library-authoring/library-filters/index.tsx create mode 100644 src/library-authoring/library-filters/messages.ts create mode 100644 src/types.ts diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 39d85ac0a..30e837a02 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -460,8 +460,9 @@ describe('ProctoredExamSettings', () => { screen.getByDisplayValue('mockproc'); }); // (1) for studio settings - // (2) for course details - expect(axiosMock.history.get.length).toBe(2); + // (2) waffle flags + // (3) for course details + expect(axiosMock.history.get.length).toBe(3); expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); }); diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index d49774950..e8161e582 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,15 +1,32 @@ +import { getConfig } from '@edx/frontend-platform'; import { createContext, useContext, useMemo } 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 { 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'; +import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; import { CourseDetailsData } from './data/api'; -import { useCourseDetails } from './data/apiHooks'; -import { RequestStatusType } from './data/constants'; export type CourseAuthoringContextData = { /** The ID of the current course */ courseId: string; + courseUsageKey: string; courseDetails?: CourseDetailsData; courseDetailStatus: RequestStatusType; canChangeProviders: boolean; + handleAddSectionFromLibrary: ReturnType; + handleAddSubsectionFromLibrary: ReturnType; + handleAddUnitFromLibrary: ReturnType; + handleNewSectionSubmit: () => void; + handleNewSubsectionSubmit: (sectionId: string) => void; + handleNewUnitSubmit: (subsectionId: string) => void; + openUnitPage: (locator: string) => void; + getUnitUrl: (locator: string) => string; }; /** @@ -30,23 +47,103 @@ export const CourseAuthoringProvider = ({ children, courseId, }: CourseAuthoringProviderProps) => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const waffleFlags = useWaffleFlags(); const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId); const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date(); + const { courseStructure } = useSelector(getOutlineIndexData); + const { id: courseUsageKey } = courseStructure || {}; - const context = useMemo(() => { - const contextValue = { - courseId, - courseDetails, - courseDetailStatus, - canChangeProviders, - }; + const getUnitUrl = (locator: string) => { + if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { + // instanbul ignore next + return `/course/${courseId}/container/${locator}`; + } + return `${getConfig().STUDIO_BASE_URL}/container/${locator}`; + }; - return contextValue; - }, [ + /** + * Open the unit page for a given locator. + */ + const openUnitPage = (locator: string) => { + const url = getUnitUrl(locator); + if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { + // instanbul ignore next + navigate(url); + } else { + window.location.assign(url); + } + }; + + 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) => { + try { + const data = await getCourseItem(locator); + // instanbul ignore next + // Page should scroll to newly added section. + data.shouldScroll = true; + dispatch(addSection(data)); + } catch { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }); + + const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => { + try { + const data = await getCourseItem(locator); + data.shouldScroll = true; + // Page should scroll to newly added subsection. + dispatch(addSubsection({ parentLocator, data })); + } catch { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }); + + /** + * import a unit block from library and redirect user to this unit page. + */ + const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage); + + const context = useMemo(() => ({ courseId, + courseUsageKey, courseDetails, courseDetailStatus, canChangeProviders, + handleNewSectionSubmit, + handleNewSubsectionSubmit, + handleNewUnitSubmit, + handleAddSectionFromLibrary, + handleAddSubsectionFromLibrary, + handleAddUnitFromLibrary, + getUnitUrl, + openUnitPage, + }), [ + courseId, + courseUsageKey, + courseDetails, + courseDetailStatus, + canChangeProviders, + handleNewSectionSubmit, + handleNewSubsectionSubmit, + handleNewUnitSubmit, + handleAddSectionFromLibrary, + handleAddSubsectionFromLibrary, + handleAddUnitFromLibrary, + getUnitUrl, + openUnitPage, ]); return ( diff --git a/src/CourseAuthoringPage.tsx b/src/CourseAuthoringPage.tsx index c8668ad90..e8b328d48 100644 --- a/src/CourseAuthoringPage.tsx +++ b/src/CourseAuthoringPage.tsx @@ -57,6 +57,9 @@ const CourseAuthoringPage = ({ children }: Props) => { org={courseOrg} title={courseTitle} contextId={courseId} + containerProps={{ + size: 'fluid', + }} /> ) )} diff --git a/src/authz/data/apiHooks.ts b/src/authz/data/apiHooks.ts index b91d582f5..5b4a0da19 100644 --- a/src/authz/data/apiHooks.ts +++ b/src/authz/data/apiHooks.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { skipToken, useQuery } from '@tanstack/react-query'; import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types'; import { validateUserPermissions } from './api'; @@ -29,8 +29,9 @@ const adminConsoleQueryKeys = { */ export const useUserPermissions = ( permissions: PermissionValidationQuery, + enabled: boolean = true, ) => useQuery({ queryKey: adminConsoleQueryKeys.permissions(permissions), - queryFn: () => validateUserPermissions(permissions), + queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken, retry: false, }); diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index d821df9cd..44379cbe9 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -96,9 +96,9 @@ jest.mock('./data/api', () => ({ getTagsCount: () => jest.fn().mockResolvedValue({}), })); -// Mock ComponentPicker to call onComponentSelected on click +// Mock LibraryAndComponentPicker to call onComponentSelected on click jest.mock('@src/library-authoring/component-picker', () => ({ - ComponentPicker: (props) => { + LibraryAndComponentPicker: (props) => { const onClick = () => { // eslint-disable-next-line react/prop-types props.onComponentSelected({ @@ -438,8 +438,9 @@ describe('', () => { const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [subsection] = section.childInfo.children; expect(axiosMock.history.post[2].data).toBe(JSON.stringify({ - parent_locator: subsection.id, + type: COURSE_BLOCK_NAMES.vertical.id, category: COURSE_BLOCK_NAMES.vertical.id, + parent_locator: subsection.id, display_name: COURSE_BLOCK_NAMES.vertical.name, })); }); @@ -2495,7 +2496,7 @@ describe('', () => { const btn = await screen.findByRole('button', { name: 'Collapse all' }); expect(btn).toBeInTheDocument(); expect(await screen.findByRole('link', { name: 'View live' })).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect((await screen.findAllByRole('button', { name: 'Add' })).length).toEqual(2); expect(await screen.findByRole('button', { name: 'More actions' })).toBeInTheDocument(); const user = userEvent.setup(); await user.click(btn); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 813ab24f8..8e8e4e21e 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -34,7 +34,7 @@ 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 { ComponentPicker, SelectedComponent } from '@src/library-authoring'; +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'; @@ -73,7 +73,13 @@ import { LegacyStatusBar } from './status-bar/LegacyStatusBar'; const CourseOutline = () => { const intl = useIntl(); const location = useLocation(); - const { courseId } = useCourseAuthoringContext(); + const { + courseId, + handleAddSubsectionFromLibrary, + handleAddUnitFromLibrary, + handleAddSectionFromLibrary, + handleNewSectionSubmit, + } = useCourseAuthoringContext(); const { courseUsageKey, @@ -123,13 +129,6 @@ const CourseOutline = () => { handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, handleDuplicateUnitSubmit, - handleNewSectionSubmit, - handleNewSubsectionSubmit, - handleNewUnitSubmit, - handleAddUnitFromLibrary, - handleAddSubsectionFromLibrary, - handleAddSectionFromLibrary, - getUnitUrl, handleVideoSharingOptionChange, handlePasteClipboardClick, notificationDismissUrl, @@ -269,7 +268,7 @@ const CourseOutline = () => { if (isLoadingDenied) { return ( - + { {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))} - +
{ onEditSectionSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSectionSubmit} isSectionsExpanded={isSectionsExpanded} - onNewSubsectionSubmit={handleNewSubsectionSubmit} onOrderChange={updateSectionOrderByIndex} - onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync} resetScrollState={resetScrollState} > { onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSubsectionSubmit} onOpenConfigureModal={openConfigureModal} - onNewUnitSubmit={handleNewUnitSubmit} - onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync} onOrderChange={updateSubsectionOrderByIndex} onPasteClick={handlePasteClipboardClick} resetScrollState={resetScrollState} @@ -480,7 +475,6 @@ const CourseOutline = () => { onOpenUnlinkModal={openUnlinkModal} onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateUnitSubmit} - getTitleLink={getUnitUrl} onOrderChange={updateUnitOrderByIndex} discussionsSettings={discussionsSettings} /> @@ -571,7 +565,7 @@ const CourseOutline = () => { isOverflowVisible={false} size="xl" > - } + * Creates a new course XBlock. Can be used to create any type of block + * and also import a content from library. */ -export async function addNewCourseItem(parentLocator: string, category: string, displayName: string): Promise { +export async function createCourseXblock({ + type, + category, + parentLocator, + displayName, + 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, +}) { + const body = { + type, + boilerplate, + category: category || type, + parent_locator: parentLocator, + display_name: displayName, + staged_content: stagedContent, + library_content_key: libraryContentKey, + }; + const { data } = await getAuthenticatedHttpClient() - .post(getXBlockBaseApiUrl(), { - parent_locator: parentLocator, - category, - display_name: displayName, - }); + .post(getXBlockBaseApiUrl(), body); return data; } diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 67a20acde..d9774a336 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,11 +1,5 @@ -import { - skipToken, useMutation, useQuery, -} from '@tanstack/react-query'; -import { createCourseXblock } from '@src/course-unit/data/api'; -import { - getCourseDetails, - getCourseItem, -} from './api'; +import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; +import { createCourseXblock, getCourseDetails, getCourseItem } from './api'; export const courseOutlineQueryKeys = { all: ['courseOutline'], @@ -29,11 +23,11 @@ export const courseOutlineQueryKeys = { * Can also be used to import block from library by passing `libraryContentKey` in request body */ export const useCreateCourseBlock = ( - callback?: ((locator?: string, parentLocator?: string) => void), + callback?: ((locator: string, parentLocator: string) => void), ) => useMutation({ mutationFn: createCourseXblock, - onSettled: async (data) => { - callback?.(data?.locator, data.parent_locator); + onSettled: async (data: { locator: string, parent_locator: string }) => { + callback?.(data.locator, data.parent_locator); }, }); diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index 2e0d7a99e..72a37968f 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 { createCourseXblock } from '@src/course-unit/data/api'; import { COURSE_BLOCK_NAMES } from '../constants'; import { getCourseBestPracticesChecklist, @@ -13,7 +12,6 @@ import { } from '../utils/getChecklistForStatusBar'; import { getErrorDetails } from '../utils/getErrorDetails'; import { - addNewCourseItem, deleteCourseItem, duplicateCourseItem, editItemDisplayName, @@ -32,7 +30,7 @@ import { setVideoSharingOption, setCourseItemOrderList, pasteBlock, - dismissNotification, createDiscussionsTopics, + dismissNotification, createDiscussionsTopics, createCourseXblock, } from './api'; import { addSection, @@ -532,11 +530,11 @@ function addNewCourseItemQuery( dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); try { - await addNewCourseItem( + await createCourseXblock({ parentLocator, - category, + type: category, displayName, - ).then(async (result) => { + }).then(async (result) => { if (result) { await addItemFn(result); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); @@ -593,34 +591,6 @@ export function addNewUnitQuery(parentLocator: string, callback: { (locator: any }; } -export function addUnitFromLibrary(body: { - type: string; - category?: string; - parentLocator: string; - displayName?: string; - boilerplate?: string; - stagedContent?: string; - libraryContentKey?: string; -}, callback: (arg0: any) => void) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); - - try { - await createCourseXblock(body).then(async (result) => { - if (result) { - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(hideProcessingNotification()); - callback(result.locator); - } - }); - } catch /* istanbul ignore next */ { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - function setBlockOrderListQuery( parentId: string, blockIds: string[], diff --git a/src/course-outline/drag-helper/CourseItemOverlay.tsx b/src/course-outline/drag-helper/CourseItemOverlay.tsx index a61673bf8..f9093492b 100644 --- a/src/course-outline/drag-helper/CourseItemOverlay.tsx +++ b/src/course-outline/drag-helper/CourseItemOverlay.tsx @@ -1,11 +1,11 @@ import { Col, Icon, Row } from '@openedx/paragon'; import { ArrowRight, DragIndicator } from '@openedx/paragon/icons'; import { ContainerType } from '@src/generic/key-utils'; -import { getItemStatusBorder } from '../utils'; +import { getItemStatusBorder, type ItemBadgeStatusValue } from '../utils'; interface ItemProps { displayName: string; - status: string; + status: ItemBadgeStatusValue; } interface CourseItemOverlayProps extends ItemProps { diff --git a/src/course-outline/drag-helper/utils.test.ts b/src/course-outline/drag-helper/utils.test.ts index 86fc6f1a8..bb6a2242a 100644 --- a/src/course-outline/drag-helper/utils.test.ts +++ b/src/course-outline/drag-helper/utils.test.ts @@ -26,7 +26,7 @@ describe('possibleSubsectionMoves', () => { { actions: { draggable: true } }, { actions: { draggable: true } }, { actions: { draggable: true } }, - ]; + ] as unknown as XBlock[]; const createMoveFunction = possibleSubsectionMoves( mockSections, @@ -39,7 +39,7 @@ describe('possibleSubsectionMoves', () => { const mockNonDraggableSubsections = [ { actions: { draggable: false } }, { actions: { draggable: true } }, - ]; + ] as unknown as XBlock[]; const createMove = possibleSubsectionMoves( mockSections, diff --git a/src/course-outline/drag-helper/utils.ts b/src/course-outline/drag-helper/utils.ts index d554bd59d..40a41f1e9 100644 --- a/src/course-outline/drag-helper/utils.ts +++ b/src/course-outline/drag-helper/utils.ts @@ -170,7 +170,7 @@ export const possibleSubsectionMoves = ( sections: XBlock[], sectionIndex: number, section: XBlock, - subsections: string | any[], + subsections: XBlock[], ) => (index: number, step: number) => { if (!subsections[index]?.actions?.draggable) { return {}; diff --git a/src/course-outline/header-navigations/HeaderActions.test.tsx b/src/course-outline/header-navigations/HeaderActions.test.tsx index ee5ed1515..a65e5d151 100644 --- a/src/course-outline/header-navigations/HeaderActions.test.tsx +++ b/src/course-outline/header-navigations/HeaderActions.test.tsx @@ -7,10 +7,7 @@ import messages from './messages'; import HeaderActions, { HeaderActionsProps } from './HeaderActions'; import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext'; -const handleNewSectionMock = jest.fn(); - const headerNavigationsActions = { - handleNewSection: handleNewSectionMock, lmsLink: '', }; @@ -58,7 +55,7 @@ describe('', () => { const addButton = await screen.findByRole('button', { name: messages.addButton.defaultMessage }); fireEvent.click(addButton); - expect(handleNewSectionMock).toHaveBeenCalledTimes(1); + expect(setCurrentPageKeyMock).toHaveBeenCalledWith('add'); }); it('disables new section button if course outline fetch fails', async () => { diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx index 71367b248..464827bcc 100644 --- a/src/course-outline/header-navigations/HeaderActions.tsx +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -15,7 +15,6 @@ import messages from './messages'; export interface HeaderActionsProps { actions: { - handleNewSection: () => void, lmsLink: string, }, courseActions: XBlockActions, @@ -28,7 +27,7 @@ const HeaderActions = ({ errors, }: HeaderActionsProps) => { const intl = useIntl(); - const { handleNewSection, lmsLink } = actions; + const { lmsLink } = actions; const { setCurrentPageKey, sidebarPages } = useOutlineSidebarContext(); @@ -45,7 +44,7 @@ const HeaderActions = ({ > + ); +}; + +/** Add New Content Tab Section */ +const AddNewContent = () => { + const intl = useIntl(); + return ( + + + + + + ); +}; + +/** 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, + } = useCourseAuthoringContext(); + + const onComponentSelected: ComponentSelectedEvent = useCallback(({ usageKey, blockType }) => { + switch (blockType) { + case 'section': + handleAddSectionFromLibrary.mutateAsync({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Chapter, + parentLocator: courseUsageKey, + libraryContentKey: usageKey, + }); + break; + case 'subsection': + if (lastSection) { + handleAddSubsectionFromLibrary.mutateAsync({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Sequential, + parentLocator: lastSection.id, + libraryContentKey: usageKey, + }); + } + break; + case 'unit': + if (lastSubsection) { + handleAddUnitFromLibrary.mutateAsync({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Vertical, + parentLocator: lastSubsection.id, + libraryContentKey: usageKey, + }); + } + break; + default: + // istanbul ignore next: unreachable + throw new Error(`Unrecognized block type ${blockType}`); + } + }, [ + courseUsageKey, + handleAddSectionFromLibrary, + handleAddSubsectionFromLibrary, + handleAddUnitFromLibrary, + lastSection, + lastSubsection, + ]); + + const allowedBlocks = useMemo(() => { + const blocks: ContainerTypes[] = ['section']; + if (lastSection) { blocks.push('subsection'); } + if (lastSubsection) { blocks.push('unit'); } + return blocks; + }, [lastSection, lastSubsection, sectionsList]); + + return ( + + + + ); +}; + +/** Tabs Component */ +const AddTabs = () => { + const intl = useIntl(); + + return ( + + + + + + + + + ); +}; + +/** Main Sidebar Component */ +export const AddSidebar = () => { + const { courseDetails } = useCourseAuthoringContext(); + + return ( +
+ + + + + + +
+ ); +}; diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx b/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx index b0a6d4dec..eb80c02ce 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx @@ -12,6 +12,7 @@ import OutlineSidebar from './OutlineSidebar'; // Mock the useCourseDetails hook jest.mock('@src/course-outline/data/apiHooks', () => ({ useCourseDetails: jest.fn().mockReturnValue({ isPending: false, data: { title: 'Test Course' } }), + useCreateCourseBlock: jest.fn(), })); const courseId = '123'; diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index fbe03d99b..60f28785f 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -7,15 +7,16 @@ import { } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useToggle } from '@openedx/paragon'; -import { HelpOutline, Info } from '@openedx/paragon/icons'; +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'; -export type OutlineSidebarPageKeys = 'help' | 'info'; +export type OutlineSidebarPageKeys = 'help' | 'info' | 'add'; export type OutlineSidebarPages = Record; interface OutlineSidebarContextData { @@ -51,6 +52,12 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod icon: HelpOutline, title: intl.formatMessage(messages.sidebarButtonHelp), }, + add: { + component: AddSidebar, + icon: Plus, + title: intl.formatMessage(messages.sidebarButtonAdd), + hideFromActionMenu: true, + }, } satisfies OutlineSidebarPages; const context = useMemo( diff --git a/src/course-outline/outline-sidebar/messages.ts b/src/course-outline/outline-sidebar/messages.ts index c1514648f..42027a1b5 100644 --- a/src/course-outline/outline-sidebar/messages.ts +++ b/src/course-outline/outline-sidebar/messages.ts @@ -70,6 +70,11 @@ const messages = defineMessages({ defaultMessage: 'Help', description: 'Button label for the help sidebar', }, + sidebarButtonAdd: { + id: 'course-authoring.course-outline.sidebar.sidebar-button-add', + defaultMessage: 'Add', + description: 'Button text for add button in sidebar', + }, sidebarButtonInfo: { id: 'course-authoring.course-outline.sidebar.sidebar-button-info', defaultMessage: 'Info', @@ -90,6 +95,16 @@ const messages = defineMessages({ defaultMessage: 'Manage tags', description: 'Action to open the tags drawer', }, + sidebarTabsAddNew: { + id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-new-tab', + defaultMessage: 'Add New', + description: 'Tab title for adding new components in outline using sidebar', + }, + sidebarTabsAddExisiting: { + id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-existing-tab', + defaultMessage: 'Add Existing', + description: 'Tab title for adding existing library components in outline using sidebar', + }, }); export default messages; diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index aca9cdd15..dc698a2ba 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -16,6 +16,14 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({ }), })); +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + handleAddSubsectionFromLibrary: jest.fn(), + handleNewSubsectionSubmit: jest.fn(), + }), +})); + const unit = { id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0', }; @@ -95,10 +103,8 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( onEditSectionSubmit={onEditSectionSubmit} onDuplicateSubmit={jest.fn()} isSectionsExpanded - onNewSubsectionSubmit={jest.fn()} isSelfPaced={false} isCustomRelativeDatesActive={false} - onAddSubsectionFromLibrary={jest.fn()} resetScrollState={jest.fn()} {...props} > diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 5547fc46b..281b71230 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -6,7 +6,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Bubble, Button, StandardModal, useToggle, } from '@openedx/paragon'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import classNames from 'classnames'; import { useQueryClient } from '@tanstack/react-query'; @@ -21,13 +21,14 @@ 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 { ComponentPicker, SelectedComponent } from '@src/library-authoring'; +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 messages from './messages'; interface SectionCardProps { @@ -44,8 +45,6 @@ interface SectionCardProps { onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, isSectionsExpanded: boolean, - onNewSubsectionSubmit: (id: string) => void, - onAddSubsectionFromLibrary: (props: object) => {}, index: number, canMoveItem: (oldIndex: number, newIndex: number) => boolean, onOrderChange: (oldIndex: number, newIndex: number) => void, @@ -68,8 +67,6 @@ const SectionCard = ({ onOpenUnlinkModal, onDuplicateSubmit, isSectionsExpanded, - onNewSubsectionSubmit, - onAddSubsectionFromLibrary, onOrderChange, resetScrollState, }: SectionCardProps) => { @@ -85,7 +82,11 @@ const SectionCard = ({ openAddLibrarySubsectionModal, closeAddLibrarySubsectionModal, ] = useToggle(false); - const { courseId } = useParams(); + const { + courseId, + handleAddSubsectionFromLibrary, + handleNewSubsectionSubmit, + } = useCourseAuthoringContext(); const queryClient = useQueryClient(); // Expand the section if a search result should be shown/scrolled to @@ -193,7 +194,7 @@ const SectionCard = ({ }); // remove border when section is expanded - const borderStyle = getItemStatusBorder(!isExpanded ? sectionStatus : ''); + const borderStyle = getItemStatusBorder(!isExpanded ? sectionStatus : undefined); const handleExpandContent = () => { setIsExpanded((prevState) => !prevState); @@ -218,10 +219,6 @@ const SectionCard = ({ onOpenHighlightsModal(section); }; - const handleNewSubsectionSubmit = () => { - onNewSubsectionSubmit(id); - }; - const handleSectionMoveUp = () => { onOrderChange(index, index - 1); }; @@ -236,14 +233,14 @@ const SectionCard = ({ * @returns {void} */ const handleSelectLibrarySubsection = useCallback((selectedSubection: SelectedComponent) => { - onAddSubsectionFromLibrary({ + handleAddSubsectionFromLibrary.mutateAsync({ type: COMPONENT_TYPES.libraryV2, category: ContainerType.Sequential, parentLocator: id, libraryContentKey: selectedSubection.usageKey, }); closeAddLibrarySubsectionModal(); - }, [id, onAddSubsectionFromLibrary, closeAddLibrarySubsectionModal]); + }, [id, handleAddSubsectionFromLibrary, closeAddLibrarySubsectionModal]); useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { @@ -345,7 +342,7 @@ const SectionCard = ({ {children} {actions.childAddable && ( handleNewSubsectionSubmit(id)} handleUseFromLibraryClick={openAddLibrarySubsectionModal} childType={ContainerType.Subsection} /> @@ -362,7 +359,7 @@ const SectionCard = ({ isOverflowVisible={false} size="xl" > - ({ }), })); +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + handleNewUnitSubmit: jest.fn(), + handleAddUnitFromLibrary: handleOnAddUnitFromLibrary, + }), +})); + jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: () => ({ @@ -29,9 +37,9 @@ jest.mock('react-redux', () => ({ }), })); -// Mock ComponentPicker to call onComponentSelected on click +// Mock LibraryAndComponentPicker to call onComponentSelected on click jest.mock('@src/library-authoring/component-picker', () => ({ - ComponentPicker: (props) => { + LibraryAndComponentPicker: (props) => { const onClick = () => { // eslint-disable-next-line react/prop-types props.onComponentSelected({ @@ -116,8 +124,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render( onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} onOpenUnlinkModal={jest.fn()} - onNewUnitSubmit={jest.fn()} - onAddUnitFromLibrary={handleOnAddUnitFromLibrary} isCustomRelativeDatesActive={false} onEditSubmit={onEditSubectionSubmit} onDuplicateSubmit={jest.fn()} @@ -322,8 +328,8 @@ describe('', () => { const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' }); fireEvent.click(dummyBtn); - expect(handleOnAddUnitFromLibrary).toHaveBeenCalled(); - expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({ + expect(handleOnAddUnitFromLibrary.mutateAsync).toHaveBeenCalled(); + expect(handleOnAddUnitFromLibrary.mutateAsync).toHaveBeenCalledWith({ type: COMPONENT_TYPES.libraryV2, parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0', category: 'vertical', diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 0e95a273f..7fefb098c 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo, } from 'react'; import { useDispatch } from 'react-redux'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { StandardModal, useToggle } from '@openedx/paragon'; import { useQueryClient } from '@tanstack/react-query'; @@ -20,7 +20,7 @@ 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 { ComponentPicker, SelectedComponent } from '@src/library-authoring'; +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'; @@ -29,6 +29,7 @@ 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 messages from './messages'; interface SubsectionCardProps { @@ -44,16 +45,6 @@ interface SubsectionCardProps { onOpenDeleteModal: () => void, onOpenUnlinkModal: () => void, onDuplicateSubmit: () => void, - onNewUnitSubmit: (subsectionId: string) => void, - onAddUnitFromLibrary: (options: { - type: string, - category?: string, - parentLocator: string, - displayName?: string, - boilerplate?: string, - stagedContent?: string, - libraryContentKey: string, - }) => void, index: number, getPossibleMoves: (index: number, step: number) => void, onOrderChange: (section: XBlock, moveDetails: any) => void, @@ -77,8 +68,6 @@ const SubsectionCard = ({ onOpenDeleteModal, onOpenUnlinkModal, onDuplicateSubmit, - onNewUnitSubmit, - onAddUnitFromLibrary, onOrderChange, onOpenConfigureModal, onPasteClick, @@ -100,7 +89,7 @@ const SubsectionCard = ({ openAddLibraryUnitModal, closeAddLibraryUnitModal, ] = useToggle(false); - const { courseId } = useParams(); + const { courseId, handleNewUnitSubmit, handleAddUnitFromLibrary } = useCourseAuthoringContext(); const queryClient = useQueryClient(); const { @@ -196,7 +185,7 @@ const SubsectionCard = ({ onOrderChange(section, moveDownDetails); }; - const handleNewButtonClick = () => onNewUnitSubmit(id); + const handleNewButtonClick = () => handleNewUnitSubmit(id); const handlePasteButtonClick = () => onPasteClick(id, section.id); const titleComponent = ( @@ -260,14 +249,14 @@ const SubsectionCard = ({ ); const handleSelectLibraryUnit = useCallback((selectedUnit: SelectedComponent) => { - onAddUnitFromLibrary({ + handleAddUnitFromLibrary.mutateAsync({ type: COMPONENT_TYPES.libraryV2, category: ContainerType.Vertical, parentLocator: id, libraryContentKey: selectedUnit.usageKey, }); closeAddLibraryUnitModal(); - }, [id, onAddUnitFromLibrary, closeAddLibraryUnitModal]); + }, [id, handleAddUnitFromLibrary, closeAddLibraryUnitModal]); return ( <> @@ -364,7 +353,7 @@ const SubsectionCard = ({ isOverflowVisible={false} size="xl" > - ({ }), })); +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + getUnitUrl: (id: string) => `/some/${id}`, + }), +})); + const section = { id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0', displayName: 'Section Name', @@ -87,7 +94,6 @@ const renderComponent = (props?: object) => render( onOpenConfigureModal={jest.fn()} onEditSubmit={jest.fn()} onDuplicateSubmit={jest.fn()} - getTitleLink={(id) => `/some/${id}`} isSelfPaced={false} isCustomRelativeDatesActive={false} discussionsSettings={{ diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 9c153b4fd..8ab7ba809 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -7,7 +7,7 @@ import { import { useDispatch } from 'react-redux'; import { useToggle } from '@openedx/paragon'; import { isEmpty } from 'lodash'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; @@ -24,6 +24,7 @@ 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 { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface UnitCardProps { unit: XBlock; @@ -36,7 +37,6 @@ interface UnitCardProps { onOpenDeleteModal: () => void; onOpenUnlinkModal: () => void; onDuplicateSubmit: () => void; - getTitleLink: (locator: string) => string; index: number; getPossibleMoves: (index: number, step: number) => void, onOrderChange: (section: XBlock, moveDetails: any) => void, @@ -63,7 +63,6 @@ const UnitCard = ({ onOpenDeleteModal, onOpenUnlinkModal, onDuplicateSubmit, - getTitleLink, onOrderChange, discussionsSettings, }: UnitCardProps) => { @@ -77,7 +76,7 @@ const UnitCard = ({ const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); - const { courseId } = useParams(); + const { courseId, getUnitUrl } = useCourseAuthoringContext(); const queryClient = useQueryClient(); const { @@ -168,7 +167,7 @@ const UnitCard = ({ const titleComponent = ( ; /** * Get section status depended on section info - * @param {bool} published - value from section info - * @param {string} visibilityState - value from section info - * @returns {ITEM_BADGE_STATUS[keyof ITEM_BADGE_STATUS]} */ const getItemStatus = ({ published, visibilityState, hasChanges, -}) => { +}: { + published: boolean; + visibilityState: string; + hasChanges?: boolean; +}): ItemBadgeStatusValue => { switch (true) { case visibilityState === VisibilityTypes.STAFF_ONLY: return ITEM_BADGE_STATUS.staffOnly; @@ -38,13 +42,12 @@ const getItemStatus = ({ /** * Get section badge status content - * @param {string} status - value from on getItemStatus util - * @returns { - * badgeTitle: string, - * badgeIcon: node, - * } */ -const getItemStatusBadgeContent = (status, messages, intl) => { +const getItemStatusBadgeContent = ( + status: ItemBadgeStatusValue, + messages: Record, + intl: IntlShape, +) => { switch (status) { case ITEM_BADGE_STATUS.gated: return { @@ -86,12 +89,8 @@ const getItemStatusBadgeContent = (status, messages, intl) => { /** * Get section border color - * @param {string} status - value from on getItemStatus util - * @returns { - * borderLeft: string, - * } */ -const getItemStatusBorder = (status) => { +const getItemStatusBorder = (status?: ItemBadgeStatusValue) => { switch (status) { case ITEM_BADGE_STATUS.live: return { @@ -128,16 +127,8 @@ const getItemStatusBorder = (status) => { /** * Get formatted highlights form values - * @param {Array} currentHighlights - section highlights - * @returns { - * highlight_1: string, - * highlight_2: string, - * highlight_3: string, - * highlight_4: string, - * highlight_5: string, - * } */ -const getHighlightsFormValues = (currentHighlights) => { +const getHighlightsFormValues = (currentHighlights: Array): any => { const initialFormValues = { highlight_1: '', highlight_2: '', @@ -163,15 +154,12 @@ const getHighlightsFormValues = (currentHighlights) => { /** * Method to scroll into view port, if it's outside the viewport - * - * @param {Object} target - DOM Element - * @param {boolean} alignWithTop (optional) - Whether top of the target will be aligned to - * the top of viewpoint. (default: false) - * @param {boolean} highlight (optional) - Whether highlight the target after scrolling. - * (default: false) - * @returns {undefined} */ -const scrollToElement = (target, alignWithTop = false, highlight = false) => { +const scrollToElement = ( + target: HTMLElement, + alignWithTop: boolean = false, + highlight: boolean = false, +) => { if (target.getBoundingClientRect().bottom > window.innerHeight) { // if alignWithTop is set, the top of the target will be aligned to the top of visible area // of the scrollable ancestor, Otherwise, the bottom of the target will be aligned to the @@ -199,7 +187,11 @@ const scrollToElement = (target, alignWithTop = false, highlight = false) => { * @param {string} id - option id * @returns {string} - text to display */ -const getVideoSharingOptionText = (id, messages, intl) => { +const getVideoSharingOptionText = ( + id: ValueOf, + messages: Record, + intl: IntlShape, +): string => { switch (id) { case VIDEO_SHARING_OPTIONS.perVideo: return intl.formatMessage(messages.videoSharingPerVideoText); diff --git a/src/course-unit/add-component/AddComponent.test.tsx b/src/course-unit/add-component/AddComponent.test.tsx index a9c55b905..9691fbe34 100644 --- a/src/course-unit/add-component/AddComponent.test.tsx +++ b/src/course-unit/add-component/AddComponent.test.tsx @@ -25,9 +25,9 @@ const blockId = '123'; const handleCreateNewCourseXBlockMock = jest.fn(); const usageKey = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fddest-usage-key'; -// Mock ComponentPicker to call onComponentSelected on click +// Mock LibraryAndComponentPicker to call onComponentSelected on click jest.mock('../../library-authoring/component-picker', () => ({ - ComponentPicker: (props) => { + LibraryAndComponentPicker: (props) => { const onClick = () => { if (props.componentPickerMode === 'single') { props.onComponentSelected({ diff --git a/src/course-unit/add-component/AddComponent.tsx b/src/course-unit/add-component/AddComponent.tsx index 78297237f..89d3dfbf0 100644 --- a/src/course-unit/add-component/AddComponent.tsx +++ b/src/course-unit/add-component/AddComponent.tsx @@ -8,7 +8,7 @@ import { import { useWaffleFlags } from '@src/data/apiHooks'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; -import { ComponentPicker } from '@src/library-authoring/component-picker'; +import { LibraryAndComponentPicker } from '@src/library-authoring/component-picker'; import { ContentType } from '@src/library-authoring/routes'; import { useIframe } from '@src/generic/hooks/context/hooks'; import { useEventListener } from '@src/generic/hooks'; @@ -276,7 +276,7 @@ const AddComponent = ({ ) } > - { return courseSectionVerticalData; } -/** - * Creates a new course XBlock. - */ -export async function createCourseXblock({ - type, - category, - parentLocator, - displayName, - boilerplate, - stagedContent, - libraryContentKey, -}: { - type: string, - category?: string, // The category of the XBlock. Defaults to the type if not provided. - parentLocator: string, - displayName?: string, - boilerplate?: string, - stagedContent?: string, - libraryContentKey?: string, // component key from library if being imported. -}) { - const body = { - type, - boilerplate, - category: category || type, - parent_locator: parentLocator, - display_name: displayName, - staged_content: stagedContent, - library_content_key: libraryContentKey, - }; - - const { data } = await getAuthenticatedHttpClient() - .post(postXBlockBaseApiUrl(), body); - - return data; -} - /** * Handles the visibility and data of a course unit, such as publishing, resetting to default values, * and toggling visibility to students. diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index f953bc162..cd75aec92 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -8,11 +8,11 @@ import { handleResponseErrors } from '@src/generic/saving-error-alert'; import { RequestStatus } from '@src/data/constants'; import { NOTIFICATION_MESSAGES } from '@src/constants'; import { updateModel, updateModels } from '@src/generic/model-store'; +import { createCourseXblock } from '@src/course-outline/data/api'; import { messageTypes } from '../constants'; import { editUnitDisplayName, getVerticalData, - createCourseXblock, getCourseContainerChildren, handleCourseUnitVisibilityAndData, deleteUnitItem, diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index f9dd23cfd..93c1bf8ea 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -89,3 +89,13 @@ export const COMPONENT_TYPE_STYLE_COLOR_MAP = { collection: 'component-style-collection', other: 'component-style-other', }; + +export const ICON_BORDER_STYLE_COLOR_MAP = { + vertical: 'icon-with-border-vertical', + unit: 'icon-with-border-vertical', + sequential: 'icon-with-border-sequential', + subsection: 'icon-with-border-sequential', + chapter: 'icon-with-border-chapter', + section: 'icon-with-border-chapter', + other: 'icon-with-border-other', +}; diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss index c68dcf303..eed04b4b7 100644 --- a/src/generic/block-type-utils/index.scss +++ b/src/generic/block-type-utils/index.scss @@ -230,3 +230,39 @@ color: white; } } + +.icon-with-border-chapter { + background-color: white; + border: 1px solid #45009E; + + .pgn__icon { + color: #45009E; + } +} + +.icon-with-border-sequential { + background-color: white; + border: 1px solid #EA3E3E; + + .pgn__icon { + color: #EA3E3E; + } +} + +.icon-with-border-vertical { + background-color: white; + border: 1px solid #0B8E77; + + .pgn__icon { + color: #0B8E77; + } +} + +.icon-with-border-default { + background-color: white; + border: 1px solid #005C9E; + + .pgn__icon { + color: #005C9E; + } +} diff --git a/src/generic/block-type-utils/index.tsx b/src/generic/block-type-utils/index.tsx index a4e2ce4b6..103f90c7f 100644 --- a/src/generic/block-type-utils/index.tsx +++ b/src/generic/block-type-utils/index.tsx @@ -6,6 +6,7 @@ import { COMPONENT_TYPE_ICON_MAP, STRUCTURAL_TYPE_ICONS, COMPONENT_TYPE_STYLE_COLOR_MAP, + ICON_BORDER_STYLE_COLOR_MAP, } from './constants'; import messages from './messages'; @@ -18,6 +19,10 @@ export function getComponentStyleColor(blockType: string): string { return COMPONENT_TYPE_STYLE_COLOR_MAP[blockType] ?? COMPONENT_TYPE_STYLE_COLOR_MAP.other; } +export function getIconBorderStyleColor(blockType: string): string { + return ICON_BORDER_STYLE_COLOR_MAP[blockType] ?? ICON_BORDER_STYLE_COLOR_MAP.other; +} + interface ComponentIconProps { blockType: string; iconTitle: string; diff --git a/src/generic/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx index e6974cf05..e28615d5f 100644 --- a/src/generic/sidebar/Sidebar.tsx +++ b/src/generic/sidebar/Sidebar.tsx @@ -18,6 +18,7 @@ export interface SidebarPage { component: React.ComponentType; icon: React.ComponentType; title: string; + hideFromActionMenu?: boolean; } type SidebarPages = Record; @@ -88,7 +89,7 @@ export function Sidebar({ const SidebarComponent = pages[currentPageKey].component; return ( - + {isOpen && !!currentPageKey && (
diff --git a/src/generic/sidebar/SidebarContent.tsx b/src/generic/sidebar/SidebarContent.tsx index 718275bd3..fcd28a552 100644 --- a/src/generic/sidebar/SidebarContent.tsx +++ b/src/generic/sidebar/SidebarContent.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Stack } from '@openedx/paragon'; interface SidebarContentProps { - children: React.ReactNode | React.ReactNode[], + children: React.ReactNode | React.ReactNode[]; } /** diff --git a/src/generic/sidebar/SidebarSection.tsx b/src/generic/sidebar/SidebarSection.tsx index 5a6fed522..7f407f744 100644 --- a/src/generic/sidebar/SidebarSection.tsx +++ b/src/generic/sidebar/SidebarSection.tsx @@ -8,7 +8,7 @@ import { MoreVert } from '@openedx/paragon/icons'; export interface SidebarSectionProps { /** Title of the section */ - title: string; + title?: string; /** Icon to be displayed in the section */ icon?: React.ComponentType; /** Actions to be displayed in the section */ @@ -47,9 +47,11 @@ export const SidebarSection = ({ {icon && } + {title && (

{title}

+ )} {actions && ( ( // Return the computed value and wrapped set state function return [returnValue, returnSetter]; } + +/** + * Creates a custom React hook that manages the state of a given value persistently across sessions. + * The stored value is kept in `window.localStorage`. + */ +export function useStickyState( + defaultValue: T, + key: string, +): [T, React.Dispatch>] { + const [value, setValue] = useState(() => { + const stickyValue = window.localStorage.getItem(key); + + return stickyValue !== null + ? JSON.parse(stickyValue) + : defaultValue; + }); + + useEffect(() => { + window.localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} diff --git a/src/index.jsx b/src/index.jsx index 558e53e81..516695cc6 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -17,7 +17,7 @@ import { logError } from '@edx/frontend-platform/logging'; import messages from './i18n'; import { - ComponentPicker, + LibraryAndComponentPicker, CreateLibrary, CreateLegacyLibrary, LibraryLayout, @@ -74,7 +74,7 @@ const App = () => { @@ -83,7 +83,7 @@ const App = () => { void; + handleBtnClick?: () => void; }) => { - const { readOnly } = useLibraryContext(); + const { readOnly } = useOptionalLibraryContext(); return ( - {!readOnly && ( + {!readOnly && handleBtnClick && ( diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 5557fdd73..ffbbdbc29 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -10,7 +10,6 @@ import classNames from 'classnames'; import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - ActionRow, Alert, Badge, Breadcrumb, @@ -31,31 +30,29 @@ import Header from '@src/header'; import NotFoundAlert from '@src/generic/NotFoundAlert'; import { useStudioHome } from '@src/studio-home/hooks'; import { - ClearFiltersButton, - FilterByBlockType, - FilterByTags, SearchContextProvider, - SearchKeywordsField, - SearchSortWidget, TypesFilterData, } from '@src/search-manager'; import { ToastContext } from '@src/generic/toast-context'; import migrationMessages from '@src/legacy-libraries-migration/messages'; +import { FiltersProps } from '@src/library-authoring/library-filters'; +import { MainFilters } from '@src/library-authoring/library-filters/MainFilters'; +import { useMultiLibraryContext } from '@src/library-authoring/common/context/MultiLibraryContext'; +import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext'; import LibraryContent from './LibraryContent'; import { LibrarySidebar } from './library-sidebar'; import { useComponentPickerContext } from './common/context/ComponentPickerContext'; -import { useLibraryContext } from './common/context/LibraryContext'; +import { useOptionalLibraryContext } from './common/context/LibraryContext'; import { SidebarBodyItemId, useSidebarContext } from './common/context/SidebarContext'; import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes'; import messages from './messages'; -import LibraryFilterByPublished from './generic/filter-by-published'; import { libraryQueryPredicate } from './data/apiHooks'; const HeaderActions = () => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useOptionalLibraryContext(); const { openAddContentSidebar, @@ -113,7 +110,7 @@ const HeaderActions = () => { export const SubHeaderTitle = ({ title }: { title: ReactNode }) => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useOptionalLibraryContext(); const { componentPickerMode } = useComponentPickerContext(); const showReadOnlyBadge = readOnly && !componentPickerMode; @@ -133,13 +130,15 @@ export const SubHeaderTitle = ({ title }: { title: ReactNode }) => { }; interface LibraryAuthoringPageProps { - returnToLibrarySelection?: () => void, - visibleTabs?: ContentType[], + returnToLibrarySelection?: () => void; + visibleTabs?: ContentType[]; + FiltersComponent?: React.ComponentType; } const LibraryAuthoringPage = ({ returnToLibrarySelection, visibleTabs = allLibraryPageTabs, + FiltersComponent = MainFilters, }: LibraryAuthoringPageProps) => { const intl = useIntl(); const location = useLocation(); @@ -160,15 +159,20 @@ const LibraryAuthoringPage = ({ librariesV2Enabled, } = useStudioHome(); - const { componentPickerMode, restrictToLibrary } = useComponentPickerContext(); + const { + componentPickerMode, + restrictToLibrary, + extraFilter: pickerExtraFilter, + } = useComponentPickerContext(); + const { showOnlyPublished } = usePublishedFilterContext(); const { libraryId, libraryData, isLoadingLibraryData, - showOnlyPublished, extraFilter: contextExtraFilter, readOnly, - } = useLibraryContext(); + } = useOptionalLibraryContext(); + const { selectedLibraries, selectedCollections } = useMultiLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); const { @@ -223,7 +227,7 @@ const LibraryAuthoringPage = ({ }, [navigateTo]); // Verify the migration task status - if (migrationId) { + if (migrationId && libraryId) { let deleteMigrationIdParam = false; if (migrationStatusData?.state === 'Succeeded') { // Check if any library migrations failed. @@ -273,7 +277,7 @@ const LibraryAuthoringPage = ({ ); } - if (!libraryData) { + if (libraryId && !libraryData) { return ; } @@ -289,7 +293,16 @@ const LibraryAuthoringPage = ({ /> ) : undefined; - const extraFilter = [`context_key = "${libraryId}"`]; + const extraFilter: string[] = []; + if (libraryId) { + extraFilter.push(`context_key = "${libraryId}"`); + } + if (selectedLibraries && selectedLibraries.length > 0) { + extraFilter.push(`context_key IN ["${selectedLibraries.join('","')}"]`); + } + if (selectedCollections && selectedCollections.length > 0) { + extraFilter.push(`collections.key IN ["${selectedCollections.join('","')}"]`); + } if (showOnlyPublished) { extraFilter.push('last_published IS NOT NULL'); } @@ -297,6 +310,9 @@ const LibraryAuthoringPage = ({ if (contextExtraFilter) { extraFilter.push(...contextExtraFilter); } + if (pickerExtraFilter) { + extraFilter.push(...pickerExtraFilter); + } const activeTypeFilters = { components: 'type = "library_block"', @@ -336,32 +352,40 @@ const LibraryAuthoringPage = ({ return (
- {libraryData.title} | {process.env.SITE_NAME} - {!componentPickerMode && ( -
- )} + {libraryData + && ( + <> + {libraryData.title} | {process.env.SITE_NAME} + {!componentPickerMode && ( +
+ )} + + )} - } - subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined} - breadcrumbs={breadcumbs} - headerActions={} - hideBorder - /> + {libraryData + && ( + } + subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined} + breadcrumbs={breadcumbs} + headerActions={} + hideBorder + /> + )} {visibleTabs.length > 1 && ( )} - - - - {!(onlyOneType) && } - - - - - + diff --git a/src/library-authoring/LibraryContent.tsx b/src/library-authoring/LibraryContent.tsx index c1f419e15..416d1a57b 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -3,7 +3,7 @@ import { LoadingSpinner } from '@src/generic/Loading'; import { useGetContentHits, useSearchContext } from '@src/search-manager'; import { useLoadOnScroll } from '@src/hooks'; import { NoComponents, NoSearchResults } from './EmptyStates'; -import { useLibraryContext } from './common/context/LibraryContext'; +import { useOptionalLibraryContext } from './common/context/LibraryContext'; import { useSidebarContext } from './common/context/SidebarContext'; import CollectionCard from './components/CollectionCard'; import ComponentCard from './components/ComponentCard'; @@ -25,7 +25,7 @@ type LibraryContentProps = { contentType?: ContentType; }; -const LibraryItemCard = { +export const LibraryItemCard = { collection: CollectionCard, library_block: ComponentCard, library_container: ContainerCard, @@ -42,7 +42,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) isFiltered, usageKey, } = useSearchContext(); - const { libraryId, openCreateCollectionModal, collectionId } = useLibraryContext(); + const { libraryId, openCreateCollectionModal, collectionId } = useOptionalLibraryContext(); const { openAddContentSidebar, openComponentInfoSidebar } = useSidebarContext(); const { insideCollection } = useLibraryRoutes(); /** @@ -53,11 +53,11 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) */ const showPlaceholderBlocks = ([ContentType.home].includes(contentType) || insideCollection) && !isFiltered; const { data: placeholderBlocks } = useMigrationBlocksInfo( - libraryId, + libraryId!, collectionId, true, undefined, - showPlaceholderBlocks, + !!libraryId && showPlaceholderBlocks, ); // Fetch unsupported blocks usage_key information from meilisearch index. const { data: placeholderData } = useGetContentHits( diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 047269e42..5dee9176c 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -43,8 +43,8 @@ const LibraryLayoutWrapper: React.FC = ({ children }) = libraryId={libraryId} /** NOTE: The component picker modal to use. We need to pass it as a reference instead of * directly importing it to avoid the import cycle: - * ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > - * Sidebar > AddContent > ComponentPicker */ + * LibraryAndComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > + * Sidebar > AddContent > LibraryAndComponentPicker */ componentPicker={ComponentPicker} > diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json index ba27af218..37ef2e72c 100644 --- a/src/library-authoring/__mocks__/library-search.json +++ b/src/library-authoring/__mocks__/library-search.json @@ -525,13 +525,101 @@ "published": { "display_name": "Published Test Unit" } + }, + { + "display_name": "Test subsection", + "block_id": "test-subsection-9284e2", + "id": "lctAximTESTunittest-subsection-9284e2-a9a4386e", + "type": "library_container", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1742221203.895054, + "modified": 1742221203.895054, + "usage_key": "lct:org:lib:subsection:test-unit-9a207", + "block_type": "subsection", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15, + "num_children": 0, + "_formatted": { + "display_name": "Test subsection", + "block_id": "test-subsection-9284e2", + "id": "lctAximTESTunittest-subsection-9284e2-a9a4386e", + "type": "library_container", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": "1742221203.895054", + "modified": "1742221203.895054", + "usage_key": "lct:org:lib:unit:test-subsection-9a207", + "block_type": "subsection", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": "15", + "num_children": "0", + "published": { + "display_name": "Published Test subsection" + } + }, + "published": { + "display_name": "Published Test subsection" + } + }, + { + "display_name": "Test section", + "block_id": "test-section-9284e2", + "id": "lctAximTESTunittest-section-9284e2-a9a4386e", + "type": "library_container", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1742221203.895054, + "modified": 1742221203.895054, + "usage_key": "lct:org:lib:section:test-unit-9a207", + "block_type": "section", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15, + "num_children": 0, + "_formatted": { + "display_name": "Test section", + "block_id": "test-section-9284e2", + "id": "lctAximTESTunittest-section-9284e2-a9a4386e", + "type": "library_container", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": "1742221203.895054", + "modified": "1742221203.895054", + "usage_key": "lct:org:lib:unit:test-section-9a207", + "block_type": "section", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": "15", + "num_children": "0", + "published": { + "display_name": "Published Test section" + } + }, + "published": { + "display_name": "Published Test section" + } } ], "query": "", "processingTimeMs": 1, "limit": 20, "offset": 0, - "estimatedTotalHits": 10 + "estimatedTotalHits": 19 }, { "indexUid": "studio_content", diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index 771acb53b..735ca07e0 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -53,6 +53,7 @@ type AddContentViewProps = { onCreateContent: (blockType: string) => void, isAddLibraryContentModalOpen: boolean, closeAddLibraryContentModal: () => void, + isComponentPicker?: boolean, }; type AddAdvancedContentViewProps = { @@ -87,9 +88,9 @@ const AddContentView = ({ onCreateContent, isAddLibraryContentModalOpen, closeAddLibraryContentModal, + isComponentPicker, }: AddContentViewProps) => { const intl = useIntl(); - const { componentPicker } = useLibraryContext(); const { insideCollection, insideUnit, @@ -231,7 +232,7 @@ const AddContentView = ({ return ( <> {visibleButtons} - {componentPicker && visibleButtons.includes(existingContentButton) && ( + {isComponentPicker && visibleButtons.includes(existingContentButton) && ( { openCreateCollectionModal, setCreateContainerModalType, openComponentEditor, + componentPicker, } = useLibraryContext(); const { insideCollection, @@ -458,7 +460,7 @@ const AddContent = () => { const suportedEditorTypes = Object.values(blockTypes); if (suportedEditorTypes.includes(blockType)) { // linkComponent on editor close. - openComponentEditor('', (data) => data && linkComponent(data.id), blockType); + openComponentEditor?.('', (data) => data && linkComponent(data.id), blockType); } else { createBlockMutation.mutateAsync({ libraryId, @@ -483,7 +485,7 @@ const AddContent = () => { if (blockType === 'paste') { onPaste(); } else if (blockType === 'collection') { - openCreateCollectionModal(); + openCreateCollectionModal?.(); } else if (blockType === 'libraryContent') { showAddLibraryContentModal(); } else if (blockType === 'advancedXBlock') { @@ -493,7 +495,7 @@ const AddContent = () => { ContainerType.Subsection, ContainerType.Section, ].includes(blockType as ContainerType)) { - setCreateContainerModalType(blockType as ContainerType); + setCreateContainerModalType?.(blockType as ContainerType); } else { onCreateBlock(blockType); } @@ -519,6 +521,7 @@ const AddContent = () => { onCreateContent={onCreateContent} isAddLibraryContentModalOpen={isAddLibraryContentModalOpen} closeAddLibraryContentModal={closeAddLibraryContentModal} + isComponentPicker={!!componentPicker} /> )} diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx index 43993d375..f74460e47 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx @@ -8,7 +8,7 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, StandardModal } from '@openedx/paragon'; import { ToastContext } from '../../generic/toast-context'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { LibraryProvider, useLibraryContext } from '../common/context/LibraryContext'; import type { SelectedComponent } from '../common/context/ComponentPickerContext'; import { useAddItemsToCollection, useAddItemsToContainer } from '../data/apiHooks'; import genericMessages from '../generic/messages'; @@ -123,13 +123,18 @@ export const PickLibraryContentModal: React.FC = ( )} > - + skipUrlUpdate + > + + ); }; diff --git a/src/library-authoring/collections/CollectionDetails.tsx b/src/library-authoring/collections/CollectionDetails.tsx index a98f99ca0..13a85a4b7 100644 --- a/src/library-authoring/collections/CollectionDetails.tsx +++ b/src/library-authoring/collections/CollectionDetails.tsx @@ -6,7 +6,7 @@ import classNames from 'classnames'; import { getItemIcon } from '../../generic/block-type-utils'; import { ToastContext } from '../../generic/toast-context'; import { BlockTypeLabel, useGetBlockTypes } from '../../search-manager'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { useOptionalLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; import { useCollection, useUpdateCollection } from '../data/apiHooks'; import HistoryWidget from '../generic/history-widget'; @@ -38,14 +38,15 @@ const BlockCount = ({ }; const CollectionStatsWidget = () => { - const { libraryId } = useLibraryContext(); + const { libraryId } = useOptionalLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); const collectionId = sidebarItemInfo?.id; - const { data: blockTypes } = useGetBlockTypes([ - `context_key = "${libraryId}"`, - `collections.key = "${collectionId}"`, - ]); + const blockQuery = [`collections.key = "${collectionId}"`]; + if (libraryId) { + blockQuery.splice(0, 0, `context_key = "${libraryId}"`); + } + const { data: blockTypes } = useGetBlockTypes(blockQuery); if (!blockTypes) { return null; @@ -99,7 +100,7 @@ const CollectionStatsWidget = () => { const CollectionDetails = () => { const intl = useIntl(); const { showToast } = useContext(ToastContext); - const { libraryId, readOnly } = useLibraryContext(); + const { libraryId, readOnly } = useOptionalLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); const collectionId = sidebarItemInfo?.id; @@ -108,7 +109,7 @@ const CollectionDetails = () => { throw new Error('collectionId is required'); } - const updateMutation = useUpdateCollection(libraryId, collectionId); + const updateMutation = useUpdateCollection(); const { data: collection } = useCollection(libraryId, collectionId); const [description, setDescription] = useState(collection?.description || ''); @@ -125,11 +126,13 @@ const CollectionDetails = () => { const onSubmit = (e: React.FocusEvent) => { const newDescription = e.target.value; - if (newDescription === collection.description) { + if (!libraryId || newDescription === collection.description) { return; } updateMutation.mutateAsync({ - description: newDescription, + libraryId, + collectionId, + data: { description: newDescription }, }).then(() => { showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); }).catch(() => { diff --git a/src/library-authoring/collections/CollectionInfo.tsx b/src/library-authoring/collections/CollectionInfo.tsx index ed34e5816..fc0709fb7 100644 --- a/src/library-authoring/collections/CollectionInfo.tsx +++ b/src/library-authoring/collections/CollectionInfo.tsx @@ -8,7 +8,7 @@ import { import { useCallback } from 'react'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { useOptionalLibraryContext } from '../common/context/LibraryContext'; import { type CollectionInfoTab, COLLECTION_INFO_TABS, @@ -26,7 +26,7 @@ const CollectionInfo = () => { const intl = useIntl(); const { componentPickerMode } = useComponentPickerContext(); - const { libraryId, setCollectionId } = useLibraryContext(); + const { libraryId, setCollectionId } = useOptionalLibraryContext(); const { sidebarItemInfo, sidebarTab, setSidebarTab } = useSidebarContext(); const tab: CollectionInfoTab = ( @@ -39,14 +39,14 @@ const CollectionInfo = () => { throw new Error('collectionId is required'); } - const collectionUsageKey = buildCollectionUsageKey(libraryId, collectionId); + const collectionUsageKey = libraryId ? buildCollectionUsageKey(libraryId, collectionId) : undefined; const { insideCollection, navigateTo } = useLibraryRoutes(); const showOpenCollectionButton = !insideCollection || componentPickerMode; const handleOpenCollection = useCallback(() => { if (componentPickerMode) { - setCollectionId(collectionId); + setCollectionId?.(collectionId); } else { navigateTo({ collectionId }); } diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx index ffe22778f..f91bcf6d4 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -3,7 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { InplaceTextEditor } from '../../generic/inplace-text-editor'; import { ToastContext } from '../../generic/toast-context'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { useOptionalLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; import { useCollection, useUpdateCollection } from '../data/apiHooks'; import messages from './messages'; @@ -11,7 +11,7 @@ import messages from './messages'; const CollectionInfoHeader = () => { const intl = useIntl(); - const { libraryId, readOnly } = useLibraryContext(); + const { libraryId, readOnly } = useOptionalLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); const collectionId = sidebarItemInfo?.id; @@ -23,14 +23,15 @@ const CollectionInfoHeader = () => { const { data: collection } = useCollection(libraryId, collectionId); - const updateMutation = useUpdateCollection(libraryId, collectionId); + const updateMutation = useUpdateCollection(); const { showToast } = useContext(ToastContext); const handleSaveTitle = async (newTitle: string) => { + if (!libraryId) { + return; + } try { - await updateMutation.mutateAsync({ - title: newTitle, - }); + await updateMutation.mutateAsync({ libraryId, collectionId, data: { title: newTitle } }); showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); } catch { showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx index 05a209a63..017d32e2b 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -12,6 +12,7 @@ import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; +import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext'; import { useLibraryRoutes } from '../routes'; import Loading from '../../generic/Loading'; import ErrorAlert from '../../generic/alert-error'; @@ -29,7 +30,7 @@ import { import { SubHeaderTitle } from '../LibraryAuthoringPage'; import { useCollection, useContentLibrary } from '../data/apiHooks'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { useOptionalLibraryContext } from '../common/context/LibraryContext'; import { SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext'; import messages from './messages'; import { LibrarySidebar } from '../library-sidebar'; @@ -40,7 +41,7 @@ const HeaderActions = () => { const intl = useIntl(); const { componentPickerMode } = useComponentPickerContext(); - const { collectionId, readOnly } = useLibraryContext(); + const { collectionId, readOnly } = useOptionalLibraryContext(); const { closeLibrarySidebar, openAddContentSidebar, @@ -101,14 +102,14 @@ const LibraryCollectionPage = () => { const intl = useIntl(); const { componentPickerMode } = useComponentPickerContext(); + const { showOnlyPublished } = usePublishedFilterContext(); const { libraryId, collectionId, - showOnlyPublished, extraFilter: contextExtraFilter, setCollectionId, readOnly, - } = useLibraryContext(); + } = useOptionalLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); const { @@ -120,7 +121,7 @@ const LibraryCollectionPage = () => { const { data: libraryData, isPending: isLibLoading } = useContentLibrary(libraryId); - if (!collectionId || !libraryId) { + if (!collectionId || (!componentPickerMode && !libraryId)) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. throw new Error('Rendered without collectionId or libraryId URL parameter'); } @@ -168,7 +169,7 @@ const LibraryCollectionPage = () => { }, { label: intl.formatMessage(messages.returnToLibrary), - onClick: () => { setCollectionId(undefined); }, + onClick: () => { setCollectionId?.(undefined); }, }, ]} spacer={} @@ -176,7 +177,10 @@ const LibraryCollectionPage = () => { /> ); - const extraFilter = [`context_key = "${libraryId}"`, `collections.key = "${collectionId}"`]; + const extraFilter = [`collections.key = "${collectionId}"`]; + if (libraryId) { + extraFilter.splice(0, 0, `context_key = "${libraryId}"`); + } if (showOnlyPublished) { extraFilter.push('last_published IS NOT NULL'); } diff --git a/src/library-authoring/common/context/ComponentPickerContext.tsx b/src/library-authoring/common/context/ComponentPickerContext.tsx index 83c258ec1..a5f46112f 100644 --- a/src/library-authoring/common/context/ComponentPickerContext.tsx +++ b/src/library-authoring/common/context/ComponentPickerContext.tsx @@ -24,24 +24,28 @@ type NoComponentPickerType = { addComponentToSelectedComponents?: never; removeComponentFromSelectedComponents?: never; restrictToLibrary?: never; + extraFilter?: never; }; -type ComponentPickerSingleType = { +type BasePickerType = { + restrictToLibrary: boolean; + extraFilter: string[], +}; + +type ComponentPickerSingleType = BasePickerType & { componentPickerMode: 'single'; onComponentSelected: ComponentSelectedEvent; selectedComponents?: never; addComponentToSelectedComponents?: never; removeComponentFromSelectedComponents?: never; - restrictToLibrary: boolean; }; -type ComponentPickerMultipleType = { +type ComponentPickerMultipleType = BasePickerType & { componentPickerMode: 'multiple'; onComponentSelected?: never; selectedComponents: SelectedComponent[]; addComponentToSelectedComponents: ComponentSelectedEvent; removeComponentFromSelectedComponents: ComponentSelectedEvent; - restrictToLibrary: boolean; }; type ComponentPickerContextData = ComponentPickerSingleType | ComponentPickerMultipleType; @@ -54,18 +58,22 @@ type ComponentPickerContextData = ComponentPickerSingleType | ComponentPickerMul */ const ComponentPickerContext = createContext(undefined); -export type ComponentPickerSingleProps = { +type BasePickerProps = { + restrictToLibrary?: boolean; + /** Only show published components */ + extraFilter?: string[], +}; + +export type ComponentPickerSingleProps = BasePickerProps & { componentPickerMode: 'single'; onComponentSelected: ComponentSelectedEvent; onChangeComponentSelection?: never; - restrictToLibrary?: boolean; }; -export type ComponentPickerMultipleProps = { +export type ComponentPickerMultipleProps = BasePickerProps & { componentPickerMode: 'multiple'; onComponentSelected?: never; onChangeComponentSelection?: ComponentSelectionChangedEvent; - restrictToLibrary?: boolean; }; type ComponentPickerProps = ComponentPickerSingleProps | ComponentPickerMultipleProps; @@ -83,6 +91,7 @@ export const ComponentPickerProvider = ({ restrictToLibrary = false, onComponentSelected, onChangeComponentSelection, + extraFilter, }: ComponentPickerProviderProps) => { const [selectedComponents, setSelectedComponents] = useState([]); @@ -123,6 +132,7 @@ export const ComponentPickerProvider = ({ componentPickerMode, restrictToLibrary, onComponentSelected, + extraFilter: extraFilter || [], }; case 'multiple': return { @@ -131,6 +141,7 @@ export const ComponentPickerProvider = ({ selectedComponents, addComponentToSelectedComponents, removeComponentFromSelectedComponents, + extraFilter: extraFilter || [], }; default: // istanbul ignore next: this should never happen @@ -144,6 +155,7 @@ export const ComponentPickerProvider = ({ removeComponentFromSelectedComponents, selectedComponents, onChangeComponentSelection, + extraFilter, ]); return ( diff --git a/src/library-authoring/common/context/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 7b775a279..1e6a80cdb 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -34,8 +34,6 @@ export type LibraryContextData = { setCollectionId: (collectionId?: string) => void; containerId: string | undefined; setContainerId: (containerId?: string) => void; - // Only show published components - showOnlyPublished: boolean; // Additional filtering extraFilter?: string[]; // "Create New Collection" modal @@ -66,17 +64,16 @@ export type LibraryContextData = { const LibraryContext = createContext(undefined); type LibraryProviderProps = { - children?: React.ReactNode; libraryId: string; - showOnlyPublished?: boolean; + children?: React.ReactNode; extraFilter?: string[] // If set, will initialize the current collection and/or component from the current URL skipUrlUpdate?: boolean; /** The component picker modal to use. We need to pass it as a reference instead of * directly importing it to avoid the import cycle: - * ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > - * Sidebar > AddContent > ComponentPicker */ + * LibraryAndComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > + * Sidebar > AddContent > LibraryAndComponentPicker */ componentPicker?: typeof ComponentPicker; }; @@ -86,7 +83,6 @@ type LibraryProviderProps = { export const LibraryProvider = ({ children, libraryId, - showOnlyPublished = false, extraFilter = [], skipUrlUpdate = false, componentPicker, @@ -115,9 +111,9 @@ export const LibraryProvider = ({ action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, scope: libraryId, }, - }); - const canPublish = userPermissions?.canPublish || false; - const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary; + }, typeof libraryId !== 'undefined'); + const canPublish = !libraryId || userPermissions?.canPublish || false; + const readOnly = !libraryId || !!componentPickerMode || !libraryData?.canEditLibrary; // Parse the initial collectionId and/or container ID(s) from the current URL params const params = useParams(); @@ -143,7 +139,6 @@ export const LibraryProvider = ({ readOnly, canPublish, isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions, - showOnlyPublished, extraFilter, isCreateCollectionModalOpen, openCreateCollectionModal, @@ -168,7 +163,6 @@ export const LibraryProvider = ({ canPublish, isLoadingLibraryData, isLoadingUserPermissions, - showOnlyPublished, extraFilter, isCreateCollectionModalOpen, openCreateCollectionModal, @@ -186,19 +180,16 @@ export const LibraryProvider = ({ ); }; -export function useLibraryContext( - allowEmtpy?: false, -): LibraryContextData; // never undefined -export function useLibraryContext( - allowEmtpy: true, -): LibraryContextData | undefined; // may be undefined -export function useLibraryContext( - allowEmtpy?: boolean, -): LibraryContextData | undefined { +export function useLibraryContext(): LibraryContextData { const ctx = useContext(LibraryContext); - if (!allowEmtpy && ctx === undefined) { + if (ctx === undefined) { /* istanbul ignore next */ throw new Error('useLibraryContext() was used in a component without a ancestor.'); } return ctx; } + +export function useOptionalLibraryContext(): Partial { + const ctx = useContext(LibraryContext); + return ctx || { readOnly: true }; +} diff --git a/src/library-authoring/common/context/MultiLibraryContext.tsx b/src/library-authoring/common/context/MultiLibraryContext.tsx new file mode 100644 index 000000000..c2a310bb0 --- /dev/null +++ b/src/library-authoring/common/context/MultiLibraryContext.tsx @@ -0,0 +1,54 @@ +import { useStickyState } from '@src/hooks'; +import React from 'react'; + +interface MultiLibraryContextProps { + selectedLibraries: string[]; + setSelectedLibraries: React.Dispatch>; + selectedCollections: string[]; + setSelectedCollections: React.Dispatch>; +} + +const Context = React.createContext(undefined); + +export const MultiLibraryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [selectedLibraries, setSelectedLibraries] = useStickyState([], 'outline-library-filter'); + const [selectedCollections, setSelectedCollections] = React.useState([]); + + React.useEffect(() => { + if (selectedLibraries.length !== 1) { + setSelectedCollections([]); + } + }, [selectedLibraries, setSelectedCollections]); + + const context = React.useMemo(() => ({ + selectedLibraries, + setSelectedLibraries, + selectedCollections, + setSelectedCollections, + }), [ + selectedLibraries, + setSelectedLibraries, + selectedCollections, + setSelectedCollections, + ]); + + return ( + + {children} + + ); +}; + +export const useMultiLibraryContext = (): MultiLibraryContextProps => { + const ctx = React.useContext(Context); + if (ctx === undefined) { + /* istanbul ignore next */ + return { + selectedLibraries: [], + setSelectedLibraries: () => {}, + selectedCollections: [], + setSelectedCollections: () => {}, + }; + } + return ctx; +}; diff --git a/src/library-authoring/common/context/PublishedFilterContext.tsx b/src/library-authoring/common/context/PublishedFilterContext.tsx new file mode 100644 index 000000000..443595ea3 --- /dev/null +++ b/src/library-authoring/common/context/PublishedFilterContext.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface PublishedFilterContextProps { + showOnlyPublished?: boolean; +} + +const Context = React.createContext(undefined); + +export const PublishedFilterContextProvider: React.FC = ({ + showOnlyPublished, + children, +}) => { + const context = React.useMemo(() => ({ showOnlyPublished }), [showOnlyPublished]); + + return ( + + {children} + + ); +}; + +export const usePublishedFilterContext = (): PublishedFilterContextProps => { + const ctx = React.useContext(Context); + if (ctx === undefined) { + /* istanbul ignore next */ + return { + showOnlyPublished: false, + }; + } + return ctx; +}; diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index c7695880c..5e09985d7 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom'; import { useStateWithUrlSearchParam } from '@src/hooks'; import { LibQueryParamKeys, useLibraryRoutes } from '@src/library-authoring/routes'; import { useComponentPickerContext } from './ComponentPickerContext'; -import { useLibraryContext } from './LibraryContext'; +import { useOptionalLibraryContext } from './LibraryContext'; export enum SidebarBodyItemId { AddContent = 'add-content', @@ -187,7 +187,7 @@ export const SidebarProvider = ({ // Set the initial sidebar state based on the URL parameters and context. const { selectedItemId, index: indexParam } = useParams(); - const { collectionId, containerId } = useLibraryContext(); + const { collectionId, containerId } = useOptionalLibraryContext(); const { componentPickerMode } = useComponentPickerContext(); useEffect(() => { diff --git a/src/library-authoring/component-info/ComponentAdvancedAssets.tsx b/src/library-authoring/component-info/ComponentAdvancedAssets.tsx index b258f5b39..cddac1aa6 100644 --- a/src/library-authoring/component-info/ComponentAdvancedAssets.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedAssets.tsx @@ -10,7 +10,7 @@ import { FormattedMessage, FormattedNumber, useIntl } from '@edx/frontend-platfo import { LoadingSpinner } from '../../generic/Loading'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { useOptionalLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; import { getXBlockAssetsApiUrl } from '../data/api'; import { useDeleteXBlockAsset, useInvalidateXBlockAssets, useXBlockAssets } from '../data/apiHooks'; @@ -18,7 +18,7 @@ import messages from './messages'; export const ComponentAdvancedAssets: React.FC> = () => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useOptionalLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); const usageKey = sidebarItemInfo?.id; diff --git a/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx index 01d2befa3..1ddf5707b 100644 --- a/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx @@ -1,3 +1,4 @@ +import { PublishedFilterContextProvider } from '@src/library-authoring/common/context/PublishedFilterContext'; import { fireEvent, initializeMocks, @@ -32,15 +33,17 @@ const render = ( , { extraWrapper: ({ children }: { children: React.ReactNode }) => ( - - - {children} - + + + + {children} + + ), }, diff --git a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx index e1dbcea9b..2cdc3f435 100644 --- a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx @@ -9,9 +9,10 @@ import { } from '@openedx/paragon'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext'; import { LoadingSpinner } from '../../generic/Loading'; import { CodeEditor, EditorAccessor } from '../../generic/CodeEditor'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { useOptionalLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; import { useUpdateXBlockOLX, @@ -22,7 +23,8 @@ import { ComponentAdvancedAssets } from './ComponentAdvancedAssets'; const ComponentAdvancedInfoInner: React.FC> = () => { const intl = useIntl(); - const { readOnly, showOnlyPublished } = useLibraryContext(); + const { readOnly } = useOptionalLibraryContext(); + const { showOnlyPublished } = usePublishedFilterContext(); const { sidebarItemInfo } = useSidebarContext(); const usageKey = sidebarItemInfo?.id; diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 8e8d8adaa..55c2a8983 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -14,7 +14,7 @@ import { import { getBlockType } from '@src/generic/key-utils'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; -import { useLibraryContext } from '../common/context/LibraryContext'; +import { useOptionalLibraryContext } from '../common/context/LibraryContext'; import { type ComponentInfoTab, COMPONENT_INFO_TABS, @@ -107,7 +107,7 @@ const ComponentActions = ({ hasUnpublishedChanges: boolean, }) => { const intl = useIntl(); - const { openComponentEditor } = useLibraryContext(); + const { openComponentEditor } = useOptionalLibraryContext(); const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false); const canEdit = canEditComponent(componentId); @@ -125,7 +125,7 @@ const ComponentActions = ({ return (
); diff --git a/src/search-manager/SearchFilterWidget.tsx b/src/search-manager/SearchFilterWidget.tsx index c2a6a3536..5e1e1598c 100644 --- a/src/search-manager/SearchFilterWidget.tsx +++ b/src/search-manager/SearchFilterWidget.tsx @@ -28,6 +28,7 @@ const SearchFilterWidget: React.FC<{ clearFilter: () => void, icon: React.ComponentType; skipLabelUpdate?: boolean; + btnSize?: 'sm' | 'md' | 'lg'; }> = ({ appliedFilters, ...props }) => { const intl = useIntl(); const [isOpen, open, close] = useToggle(false); @@ -44,7 +45,7 @@ const SearchFilterWidget: React.FC<{