diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 39d85ac0a8..30e837a023 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 d497749500..e8161e582f 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 c8668ad90a..e8b328d483 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 b91d582f57..5b4a0da19a 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 d821df9cd8..ed8a6f27e8 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -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 813ab24f88..1bed94bcc3 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -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} /> diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index a8464b1fa5..f07d64cb5e 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -382,19 +382,40 @@ export async function duplicateCourseItem(itemId: string, parentId: string): Pro } /** - * Add new course item like section, subsection or unit. - * @param {string} parentLocator - * @param {string} category - * @param {string} displayName - * @returns {Promise} + * 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 67a20acde2..d9774a336b 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 2e0d7a99e9..72a37968fb 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 a61673bf8a..f9093492b4 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 86fc6f1a89..bb6a2242ae 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 d554bd59d0..40a41f1e9d 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 ee5ed15152..a65e5d1510 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 71367b248d..464827bcc5 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 b0a6d4deca..eb80c02ce6 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 fbe03d99b3..60f28785fa 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 c1514648f6..42027a1b58 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 aca9cdd15d..dc698a2baf 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 5547fc46bb..312436dd3f 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'; @@ -28,6 +28,7 @@ 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} /> diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index ce6ad09360..33c41f7d40 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -8,7 +8,7 @@ import SubsectionCard from './SubsectionCard'; let store; const containerKey = 'lct:org:lib:unit:1'; -const handleOnAddUnitFromLibrary = jest.fn(); +const handleOnAddUnitFromLibrary = { mutateAsync: jest.fn() }; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); @@ -22,6 +22,14 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({ }), })); +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ + courseId: 5, + handleNewUnitSubmit: jest.fn(), + handleAddUnitFromLibrary: handleOnAddUnitFromLibrary, + }), +})); + jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: () => ({ @@ -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 0e95a273ff..6cd3b4b48a 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'; @@ -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 ( <> diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index 9d8ef2b0d7..96b17724d9 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -18,6 +18,13 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({ }), })); +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 9c153b4fdf..8ab7ba809b 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/data/api.ts b/src/course-unit/data/api.ts index 7b6fa9b7bc..c8c3e6f98d 100644 --- a/src/course-unit/data/api.ts +++ b/src/course-unit/data/api.ts @@ -41,42 +41,6 @@ export async function getVerticalData(unitId: string): Promise { 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 f953bc1626..cd75aec924 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/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx index e6974cf051..e28615d5f3 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 718275bd39..fcd28a5523 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 5a6fed5223..7f407f7449 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 && ( void; }) => { - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); return ( diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 5557fdd73f..fd8711da29 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,17 +30,14 @@ 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 LibraryContent from './LibraryContent'; import { LibrarySidebar } from './library-sidebar'; import { useComponentPickerContext } from './common/context/ComponentPickerContext'; @@ -49,13 +45,12 @@ import { useLibraryContext } 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 } = useLibraryContext(false); const { openAddContentSidebar, @@ -113,7 +108,7 @@ const HeaderActions = () => { export const SubHeaderTitle = ({ title }: { title: ReactNode }) => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); const { componentPickerMode } = useComponentPickerContext(); const showReadOnlyBadge = readOnly && !componentPickerMode; @@ -133,13 +128,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(); @@ -163,12 +160,13 @@ const LibraryAuthoringPage = ({ const { componentPickerMode, restrictToLibrary } = useComponentPickerContext(); const { libraryId, + libraryIds, libraryData, isLoadingLibraryData, showOnlyPublished, extraFilter: contextExtraFilter, readOnly, - } = useLibraryContext(); + } = useLibraryContext(false); const { sidebarItemInfo } = useSidebarContext(); const { @@ -223,7 +221,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 +271,7 @@ const LibraryAuthoringPage = ({ ); } - if (!libraryData) { + if (libraryId && !libraryData) { return ; } @@ -289,7 +287,13 @@ const LibraryAuthoringPage = ({ /> ) : undefined; - const extraFilter = [`context_key = "${libraryId}"`]; + const extraFilter: string[] = []; + if (libraryId) { + extraFilter.push(`context_key = "${libraryId}"`); + } + if (libraryIds && libraryIds.length > 0) { + extraFilter.push(`context_key IN ["${libraryIds.join('","')}"]`); + } if (showOnlyPublished) { extraFilter.push('last_published IS NOT NULL'); } @@ -336,32 +340,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 c1f419e15b..1eb3781bd6 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -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 } = useLibraryContext(false); 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/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json index ba27af2185..37ef2e72c5 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 771acb53b1..237d4908e4 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, - } = useLibraryContext(); + componentPicker, + } = useLibraryContext(false); const { insideCollection, insideUnit, @@ -431,7 +433,7 @@ const AddContent = () => { const clipboardBlockType = sharedClipboardData?.content?.blockType; // istanbul ignore if: this should never happen - if (!clipboardBlockType) { + if (!clipboardBlockType || !libraryId) { return; } @@ -459,7 +461,7 @@ const AddContent = () => { if (suportedEditorTypes.includes(blockType)) { // linkComponent on editor close. openComponentEditor('', (data) => data && linkComponent(data.id), blockType); - } else { + } else if (libraryId) { createBlockMutation.mutateAsync({ libraryId, blockType, @@ -519,6 +521,7 @@ const AddContent = () => { onCreateContent={onCreateContent} isAddLibraryContentModalOpen={isAddLibraryContentModalOpen} closeAddLibraryContentModal={closeAddLibraryContentModal} + isComponentPicker={!!componentPicker} /> )} diff --git a/src/library-authoring/collections/CollectionDetails.tsx b/src/library-authoring/collections/CollectionDetails.tsx index a98f99ca0f..8593416418 100644 --- a/src/library-authoring/collections/CollectionDetails.tsx +++ b/src/library-authoring/collections/CollectionDetails.tsx @@ -38,14 +38,15 @@ const BlockCount = ({ }; const CollectionStatsWidget = () => { - const { libraryId } = useLibraryContext(); + const { libraryId } = useLibraryContext(false); 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 } = useLibraryContext(false); 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 ed34e5816e..7d1beea1d8 100644 --- a/src/library-authoring/collections/CollectionInfo.tsx +++ b/src/library-authoring/collections/CollectionInfo.tsx @@ -26,7 +26,7 @@ const CollectionInfo = () => { const intl = useIntl(); const { componentPickerMode } = useComponentPickerContext(); - const { libraryId, setCollectionId } = useLibraryContext(); + const { libraryId, setCollectionId } = useLibraryContext(false); const { sidebarItemInfo, sidebarTab, setSidebarTab } = useSidebarContext(); const tab: CollectionInfoTab = ( @@ -39,7 +39,7 @@ 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; diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx index ffe22778f4..80707f7d63 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -11,7 +11,7 @@ import messages from './messages'; const CollectionInfoHeader = () => { const intl = useIntl(); - const { libraryId, readOnly } = useLibraryContext(); + const { libraryId, readOnly } = useLibraryContext(false); 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 05a209a63a..1e2dd7e1eb 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -40,7 +40,7 @@ const HeaderActions = () => { const intl = useIntl(); const { componentPickerMode } = useComponentPickerContext(); - const { collectionId, readOnly } = useLibraryContext(); + const { collectionId, readOnly } = useLibraryContext(false); const { closeLibrarySidebar, openAddContentSidebar, @@ -108,7 +108,7 @@ const LibraryCollectionPage = () => { extraFilter: contextExtraFilter, setCollectionId, readOnly, - } = useLibraryContext(); + } = useLibraryContext(!!componentPickerMode); const { sidebarItemInfo } = useSidebarContext(); const { @@ -120,7 +120,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'); } @@ -176,7 +176,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/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 7b775a279c..2e5b865bfe 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -9,6 +9,7 @@ import { import { useParams } from 'react-router-dom'; import { useUserPermissions } from '@src/authz/data/apiHooks'; import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants'; +import { AtLeastOne, RequireIf } from '@src/types'; import { ContainerType } from '../../../generic/key-utils'; import type { ComponentPicker } from '../../component-picker'; @@ -22,9 +23,13 @@ export interface ComponentEditorInfo { onClose?: (data?:any) => void; } -export type LibraryContextData = { - /** The ID of the current library */ +export type LibraryIdOneOrMore = { libraryId: string; + libraryIds: string[]; +}; + +export type LibraryContextData = AtLeastOne & { + /** The ID of the current library */ libraryData?: ContentLibrary; readOnly: boolean; canPublish: boolean; @@ -65,9 +70,8 @@ export type LibraryContextData = { */ const LibraryContext = createContext(undefined); -type LibraryProviderProps = { +type LibraryProviderProps = AtLeastOne & { children?: React.ReactNode; - libraryId: string; showOnlyPublished?: boolean; extraFilter?: string[] // If set, will initialize the current collection and/or component from the current URL @@ -86,6 +90,7 @@ type LibraryProviderProps = { export const LibraryProvider = ({ children, libraryId, + libraryIds, showOnlyPublished = false, extraFilter = [], skipUrlUpdate = false, @@ -115,9 +120,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(); @@ -135,6 +140,7 @@ export const LibraryProvider = ({ const context = useMemo(() => { const contextValue = { libraryId, + libraryIds: libraryIds || [], libraryData, collectionId, setCollectionId, @@ -159,6 +165,7 @@ export const LibraryProvider = ({ return contextValue; }, [ libraryId, + libraryIds, libraryData, collectionId, setCollectionId, @@ -186,19 +193,23 @@ 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 { +/** + * @param requireLibraryId - Optional flag indicating whether to require the library ID i.e., + * the component only works when used inside a library. + * @returns The context data with the library ID or undefined. + */ +export function useLibraryContext(requireLibraryId?: true | undefined): RequireIf; +export function useLibraryContext(requireLibraryId: false): RequireIf; +export function useLibraryContext(requireLibraryId: boolean): LibraryContextData; +export function useLibraryContext(requireLibraryId: boolean = true): 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.'); } + if (requireLibraryId && !ctx.libraryId) { + /* istanbul ignore next */ + throw new Error('useLibraryContext() was used in a component without a libraryId'); + } return ctx; } diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index c7695880c9..280f2c3cb1 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -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 } = useLibraryContext(false); const { componentPickerMode } = useComponentPickerContext(); useEffect(() => { diff --git a/src/library-authoring/component-info/ComponentAdvancedAssets.tsx b/src/library-authoring/component-info/ComponentAdvancedAssets.tsx index b258f5b39e..6f661ac4b4 100644 --- a/src/library-authoring/component-info/ComponentAdvancedAssets.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedAssets.tsx @@ -18,7 +18,7 @@ import messages from './messages'; export const ComponentAdvancedAssets: React.FC> = () => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); const { sidebarItemInfo } = useSidebarContext(); const usageKey = sidebarItemInfo?.id; diff --git a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx index e1dbcea9b7..adf00db661 100644 --- a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx @@ -22,7 +22,7 @@ import { ComponentAdvancedAssets } from './ComponentAdvancedAssets'; const ComponentAdvancedInfoInner: React.FC> = () => { const intl = useIntl(); - const { readOnly, showOnlyPublished } = useLibraryContext(); + const { readOnly, showOnlyPublished } = useLibraryContext(false); 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 8e8d8adaa2..6beec48b6f 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -107,7 +107,7 @@ const ComponentActions = ({ hasUnpublishedChanges: boolean, }) => { const intl = useIntl(); - const { openComponentEditor } = useLibraryContext(); + const { openComponentEditor } = useLibraryContext(false); const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false); const canEdit = canEditComponent(componentId); @@ -151,7 +151,7 @@ const ComponentActions = ({ const ComponentInfo = () => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); const { sidebarTab, diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx index 24afe0991c..91a278da19 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -11,7 +11,7 @@ import messages from './messages'; const ComponentInfoHeader = () => { const intl = useIntl(); - const { readOnly, showOnlyPublished } = useLibraryContext(); + const { readOnly, showOnlyPublished } = useLibraryContext(false); const { sidebarItemInfo } = useSidebarContext(); const usageKey = sidebarItemInfo?.id; diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx index ee16908bc1..0063f642d3 100644 --- a/src/library-authoring/component-info/ComponentManagement.tsx +++ b/src/library-authoring/component-info/ComponentManagement.tsx @@ -16,7 +16,7 @@ import messages from './messages'; const ComponentManagement = () => { const intl = useIntl(); - const { readOnly, isLoadingLibraryData } = useLibraryContext(); + const { readOnly, isLoadingLibraryData } = useLibraryContext(false); const { sidebarItemInfo, sidebarAction, resetSidebarAction } = useSidebarContext(); const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags; diff --git a/src/library-authoring/component-info/ComponentPreview.tsx b/src/library-authoring/component-info/ComponentPreview.tsx index 8e4804a7d7..94cf787838 100644 --- a/src/library-authoring/component-info/ComponentPreview.tsx +++ b/src/library-authoring/component-info/ComponentPreview.tsx @@ -17,7 +17,7 @@ interface ModalComponentPreviewProps { const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPreviewProps) => { const intl = useIntl(); - const { showOnlyPublished } = useLibraryContext(); + const { showOnlyPublished } = useLibraryContext(false); return ( { const intl = useIntl(); const [isModalOpen, openModal, closeModal] = useToggle(); - const { showOnlyPublished } = useLibraryContext(); + const { showOnlyPublished } = useLibraryContext(false); const { sidebarItemInfo } = useSidebarContext(); const usageKey = sidebarItemInfo?.id; diff --git a/src/library-authoring/component-picker/ComponentPicker.tsx b/src/library-authoring/component-picker/ComponentPicker.tsx index 170de643d4..5ada2feede 100644 --- a/src/library-authoring/component-picker/ComponentPicker.tsx +++ b/src/library-authoring/component-picker/ComponentPicker.tsx @@ -3,12 +3,13 @@ import { useLocation } from 'react-router-dom'; import { Alert, Stepper } from '@openedx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { FiltersProps } from '@src/library-authoring/library-filters'; import { type ComponentSelectedEvent, type ComponentSelectionChangedEvent, ComponentPickerProvider, } from '../common/context/ComponentPickerContext'; -import { LibraryProvider, useLibraryContext } from '../common/context/LibraryContext'; +import { type LibraryIdOneOrMore, LibraryProvider, useLibraryContext } from '../common/context/LibraryContext'; import { SidebarProvider } from '../common/context/SidebarContext'; import LibraryAuthoringPage from '../LibraryAuthoringPage'; import LibraryCollectionPage from '../collections/LibraryCollectionPage'; @@ -19,13 +20,15 @@ import { ContentType, allLibraryPageTabs } from '../routes'; interface LibraryComponentPickerProps { returnToLibrarySelection: () => void; visibleTabs: ContentType[], + FiltersComponent?: React.ComponentType; } const InnerComponentPicker: React.FC = ({ returnToLibrarySelection, visibleTabs, + FiltersComponent, }) => { - const { collectionId } = useLibraryContext(); + const { collectionId } = useLibraryContext(false); if (collectionId) { return ; @@ -34,6 +37,7 @@ const InnerComponentPicker: React.FC = ({ ); }; @@ -48,19 +52,21 @@ const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selecti window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*'); }; -type ComponentPickerProps = { - libraryId?: string, +type ComponentPickerProps = Partial & { showOnlyPublished?: boolean, extraFilter?: string[], visibleTabs?: ContentType[], componentPickerMode?: 'single' | 'multiple', onComponentSelected?: ComponentSelectedEvent, onChangeComponentSelection?: ComponentSelectionChangedEvent, + selectLibrary?: boolean; + FiltersComponent?: React.ComponentType; }; export const ComponentPicker: React.FC = ({ /** Restrict the component picker to a specific library */ libraryId, + libraryIds, showOnlyPublished, extraFilter, componentPickerMode = 'single', @@ -70,9 +76,11 @@ export const ComponentPicker: React.FC = ({ */ onComponentSelected = defaultComponentSelectedCallback, onChangeComponentSelection = defaultSelectionChangedCallback, + selectLibrary = true, + FiltersComponent, }) => { - const [currentStep, setCurrentStep] = useState(!libraryId ? 'select-library' : 'pick-components'); - const [selectedLibrary, setSelectedLibrary] = useState(libraryId || ''); + const [currentStep, setCurrentStep] = useState(!libraryId && selectLibrary ? 'select-library' : 'pick-components'); + const [selectedLibrary, setSelectedLibrary] = useState(libraryId); const location = useLocation(); @@ -118,6 +126,7 @@ export const ComponentPicker: React.FC = ({ = ({ diff --git a/src/library-authoring/component-picker/SelectLibrary.tsx b/src/library-authoring/component-picker/SelectLibrary.tsx index d9f2e0a196..59cfac6ddf 100644 --- a/src/library-authoring/component-picker/SelectLibrary.tsx +++ b/src/library-authoring/component-picker/SelectLibrary.tsx @@ -39,7 +39,7 @@ const EmptyState = ({ hasSearchQuery }: EmptyStateProps) => ( ); interface SelectLibraryProps { - selectedLibrary: string; + selectedLibrary?: string; setSelectedLibrary: (libraryKey: string) => void; itemType: ContentType; } diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx index 2552b611f3..0206166954 100644 --- a/src/library-authoring/components/CollectionCard.tsx +++ b/src/library-authoring/components/CollectionCard.tsx @@ -28,7 +28,7 @@ const CollectionMenu = ({ hit } : CollectionMenuProps) => { const intl = useIntl(); const { showToast } = useContext(ToastContext); const { navigateTo } = useLibraryRoutes(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const { closeLibrarySidebar, sidebarItemInfo } = useSidebarContext(); const { @@ -119,7 +119,7 @@ type CollectionCardProps = { const CollectionCard = ({ hit } : CollectionCardProps) => { const { componentPickerMode } = useComponentPickerContext(); - const { setCollectionId, showOnlyPublished } = useLibraryContext(); + const { setCollectionId, showOnlyPublished } = useLibraryContext(false); const { openCollectionInfoSidebar, openItemSidebar, sidebarItemInfo } = useSidebarContext(); const { diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index d3fa33b86e..db169d34af 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -16,7 +16,7 @@ type ComponentCardProps = { }; const ComponentCard = ({ hit }: ComponentCardProps) => { - const { showOnlyPublished } = useLibraryContext(); + const { showOnlyPublished } = useLibraryContext(false); const { openComponentInfoSidebar, openItemSidebar, sidebarItemInfo } = useSidebarContext(); const { componentPickerMode } = useComponentPickerContext(); diff --git a/src/library-authoring/components/ComponentDeleter.tsx b/src/library-authoring/components/ComponentDeleter.tsx index d5385a1d86..dc59557637 100644 --- a/src/library-authoring/components/ComponentDeleter.tsx +++ b/src/library-authoring/components/ComponentDeleter.tsx @@ -25,7 +25,7 @@ const ComponentDeleter = ({ usageKey, close }: Props) => { const intl = useIntl(); const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext(); const { showToast } = useContext(ToastContext); - const { containerId: currentUnitId } = useLibraryContext(); + const { containerId: currentUnitId } = useLibraryContext(false); const sidebarComponentUsageKey = sidebarItemInfo?.id; const restoreComponentMutation = useRestoreLibraryBlock(); diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index af90456cd2..44fffb56ba 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -36,7 +36,7 @@ export const ComponentMenu = ({ usageKey, index }: Props) => { containerId, openComponentEditor, readOnly, - } = useLibraryContext(); + } = useLibraryContext(false); const { sidebarItemInfo, diff --git a/src/library-authoring/components/ComponentRemover.tsx b/src/library-authoring/components/ComponentRemover.tsx index b776978b53..83252f1f7f 100644 --- a/src/library-authoring/components/ComponentRemover.tsx +++ b/src/library-authoring/components/ComponentRemover.tsx @@ -25,7 +25,7 @@ interface Props { const ComponentRemover = ({ usageKey, index, close }: Props) => { const intl = useIntl(); const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext(); - const { containerId, showOnlyPublished } = useLibraryContext(); + const { containerId, showOnlyPublished } = useLibraryContext(false); const { showToast } = useContext(ToastContext); const removeContainerItemMutation = useRemoveContainerChildren(containerId); diff --git a/src/library-authoring/containers/ContainerCard.tsx b/src/library-authoring/containers/ContainerCard.tsx index cc012bd27a..6b89dfde06 100644 --- a/src/library-authoring/containers/ContainerCard.tsx +++ b/src/library-authoring/containers/ContainerCard.tsx @@ -38,7 +38,7 @@ export const ContainerMenu = ({ containerKey, displayName, index } : ContainerMe const intl = useIntl(); const { libraryId, collectionId, containerId, readOnly, - } = useLibraryContext(); + } = useLibraryContext(false); const { sidebarItemInfo, closeLibrarySidebar, @@ -211,7 +211,7 @@ type ContainerCardPreviewProps = { const ContainerCardPreview = ({ hit }: ContainerCardPreviewProps) => { const intl = useIntl(); - const { showOnlyPublished } = useLibraryContext(); + const { showOnlyPublished } = useLibraryContext(false); const { blockType: itemType, published, @@ -256,7 +256,7 @@ type ContainerCardProps = { const ContainerCard = ({ hit } : ContainerCardProps) => { const { componentPickerMode } = useComponentPickerContext(); - const { showOnlyPublished } = useLibraryContext(); + const { showOnlyPublished } = useLibraryContext(false); const { openContainerInfoSidebar, openItemSidebar, sidebarItemInfo } = useSidebarContext(); const { diff --git a/src/library-authoring/containers/ContainerDeleter.tsx b/src/library-authoring/containers/ContainerDeleter.tsx index c8e5629782..a4d24bb0d5 100644 --- a/src/library-authoring/containers/ContainerDeleter.tsx +++ b/src/library-authoring/containers/ContainerDeleter.tsx @@ -42,7 +42,7 @@ const ContainerDeleter = ({ } = useSidebarContext(); const { containerId: parentContainerId, - } = useLibraryContext(); + } = useLibraryContext(false); const deleteContainerMutation = useDeleteContainer(containerId); const restoreContainerMutation = useRestoreContainer(containerId); const { showToast } = useContext(ToastContext); diff --git a/src/library-authoring/containers/ContainerEditableTitle.tsx b/src/library-authoring/containers/ContainerEditableTitle.tsx index 6bd9cfa62a..8d3b829f00 100644 --- a/src/library-authoring/containers/ContainerEditableTitle.tsx +++ b/src/library-authoring/containers/ContainerEditableTitle.tsx @@ -14,7 +14,7 @@ interface EditableTitleProps { export const ContainerEditableTitle = ({ containerId, textClassName }: EditableTitleProps) => { const intl = useIntl(); - const { readOnly, showOnlyPublished } = useLibraryContext(); + const { readOnly, showOnlyPublished } = useLibraryContext(false); const { data: container } = useContainer(containerId); diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx index d2c7c4c78c..2176e7613f 100644 --- a/src/library-authoring/containers/ContainerInfo.tsx +++ b/src/library-authoring/containers/ContainerInfo.tsx @@ -98,12 +98,12 @@ const ContainerActions = ({ hasUnpublishedChanges: boolean, }) => { const intl = useIntl(); - const { libraryId } = useLibraryContext(); + const { libraryId } = useLibraryContext(false); const { componentPickerMode } = useComponentPickerContext(); const { insideUnit, insideSubsection, insideSection } = useLibraryRoutes(); const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false); - const showOpenButton = !componentPickerMode && !( + const showOpenButton = libraryId && !componentPickerMode && !( insideUnit || insideSubsection || insideSection ); diff --git a/src/library-authoring/containers/ContainerOrganize.tsx b/src/library-authoring/containers/ContainerOrganize.tsx index b76670efd0..3115958304 100644 --- a/src/library-authoring/containers/ContainerOrganize.tsx +++ b/src/library-authoring/containers/ContainerOrganize.tsx @@ -26,7 +26,7 @@ const ContainerOrganize = () => { const [tagsCollapseIsOpen, ,setTagsCollapseClose, toggleTags] = useToggle(true); const [collectionsCollapseIsOpen, setCollectionsCollapseOpen, , toggleCollections] = useToggle(true); - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); const { sidebarItemInfo, sidebarAction } = useSidebarContext(); const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; diff --git a/src/library-authoring/containers/ContainerRemover.tsx b/src/library-authoring/containers/ContainerRemover.tsx index dfb8af5a8b..1289f9972f 100644 --- a/src/library-authoring/containers/ContainerRemover.tsx +++ b/src/library-authoring/containers/ContainerRemover.tsx @@ -33,7 +33,7 @@ const ContainerRemover = ({ sidebarItemInfo, closeLibrarySidebar, } = useSidebarContext(); - const { containerId, showOnlyPublished } = useLibraryContext(); + const { containerId, showOnlyPublished } = useLibraryContext(false); const { showToast } = useContext(ToastContext); const removeContainerMutation = useRemoveContainerChildren(containerId); diff --git a/src/library-authoring/containers/FooterActions.tsx b/src/library-authoring/containers/FooterActions.tsx index 2fd3767e0b..0eeae55d95 100644 --- a/src/library-authoring/containers/FooterActions.tsx +++ b/src/library-authoring/containers/FooterActions.tsx @@ -18,7 +18,7 @@ export const FooterActions = ({ }: FooterActionsProps) => { const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const { openAddContentSidebar } = useSidebarContext(); - const { readOnly, setCreateContainerModalType } = useLibraryContext(); + const { readOnly, setCreateContainerModalType } = useLibraryContext(false); const addContent = () => { if (addContentType) { setCreateContainerModalType(addContentType); diff --git a/src/library-authoring/containers/HeaderActions.tsx b/src/library-authoring/containers/HeaderActions.tsx index be1871f716..b671ddab1b 100644 --- a/src/library-authoring/containers/HeaderActions.tsx +++ b/src/library-authoring/containers/HeaderActions.tsx @@ -16,7 +16,7 @@ export const HeaderActions = ({ infoBtnText, addContentBtnText, }: HeaderActionsProps) => { - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); const { closeLibrarySidebar, sidebarItemInfo, diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 9f516f82a4..c3e03935de 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -165,8 +165,7 @@ export function invalidateComponentData(queryClient: QueryClient, contentLibrary export const useContentLibrary = (libraryId: string | undefined) => ( useQuery({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId), - queryFn: () => api.getContentLibrary(libraryId!), - enabled: libraryId !== undefined, + queryFn: libraryId ? () => api.getContentLibrary(libraryId!) : skipToken, }) ); @@ -517,40 +516,47 @@ export const useDeleteXBlockAsset = (usageKey: string) => { /** * Get the metadata for a collection in a library */ -export const useCollection = (libraryId: string, collectionId?: string) => ( +export const useCollection = (libraryId?: string, collectionId?: string) => ( useQuery({ - enabled: !!libraryId && !!collectionId, queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId), - queryFn: () => api.getCollectionMetadata(libraryId!, collectionId!), + queryFn: (!!libraryId && !!collectionId) + ? () => api.getCollectionMetadata(libraryId!, collectionId!) + : skipToken, }) ); /** * Use this mutation to update the fields of a collection in a library */ -export const useUpdateCollection = (libraryId: string, collectionId: string) => { +export const useUpdateCollection = () => { const queryClient = useQueryClient(); - const collectionQueryKey = libraryAuthoringQueryKeys.collection(libraryId, collectionId); return useMutation({ - mutationFn: (data: api.UpdateCollectionComponentsRequest) => ( + mutationFn: async ({ libraryId, collectionId, data }:{ + libraryId: string; + collectionId: string; + data: api.UpdateCollectionComponentsRequest; + }) => ( api.updateCollectionMetadata(libraryId, collectionId, data) ), - onMutate: (data) => { + onMutate: (variables) => { + const collectionQueryKey = libraryAuthoringQueryKeys.collection(variables.libraryId, variables.collectionId); const previousData = queryClient.getQueryData(collectionQueryKey) as api.CollectionMetadata; queryClient.setQueryData(collectionQueryKey, { ...previousData, - ...data, + ...variables.data, }); return { previousData }; }, - onError: (_err, _data, context) => { + onError: (_err, variables, context) => { + const collectionQueryKey = libraryAuthoringQueryKeys.collection(variables.libraryId, variables.collectionId); queryClient.setQueryData(collectionQueryKey, context?.previousData); }, - onSettled: () => { + onSettled: (_data, _err, variables) => { // NOTE: We invalidate the library query here because we need to update the library's // collection list. - queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + const collectionQueryKey = libraryAuthoringQueryKeys.collection(variables.libraryId, variables.collectionId); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, variables.libraryId) }); queryClient.invalidateQueries({ queryKey: collectionQueryKey }); }, }); diff --git a/src/library-authoring/generic/filter-by-published/index.tsx b/src/library-authoring/generic/filter-by-published/index.tsx index 825ac56f4d..4eec4b00e4 100644 --- a/src/library-authoring/generic/filter-by-published/index.tsx +++ b/src/library-authoring/generic/filter-by-published/index.tsx @@ -9,7 +9,7 @@ import { FilterByPublished, PublishStatus } from '../../../search-manager'; * when not relevant. */ const LibraryFilterByPublished : React.FC> = () => { - const { showOnlyPublished } = useLibraryContext(); + const { showOnlyPublished } = useLibraryContext(false); if (showOnlyPublished) { return ( diff --git a/src/library-authoring/generic/manage-collections/ManageCollections.tsx b/src/library-authoring/generic/manage-collections/ManageCollections.tsx index 4cafadfa47..03218e43c6 100644 --- a/src/library-authoring/generic/manage-collections/ManageCollections.tsx +++ b/src/library-authoring/generic/manage-collections/ManageCollections.tsx @@ -119,11 +119,15 @@ const AddToCollectionsDrawer = ({ onClose, }: CollectionsDrawerProps) => { const intl = useIntl(); - const { libraryId } = useLibraryContext(); + const { libraryId } = useLibraryContext(false); + const extraFilter = ['type = "collection"']; + if (libraryId) { + extraFilter.push(`context_key = "${libraryId}"`); + } return ( @@ -156,7 +160,7 @@ const EntityCollections = ({ collections, onManageClick }: { onManageClick: () => void; }) => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); if (!collections?.length) { return ( diff --git a/src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx b/src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx index ed2d50b6d8..fed4d71451 100644 --- a/src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx +++ b/src/library-authoring/generic/publish-status-buttons/PublishDraftButton.tsx @@ -22,7 +22,7 @@ export const PublishDraftButton = ({ onClick, }: PublishDraftButtonProps) => { const intl = useIntl(); - const { readOnly } = useLibraryContext(); + const { readOnly } = useLibraryContext(false); return (