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.
This commit is contained in:
Navin Karkera
2026-01-09 22:44:48 +05:30
committed by GitHub
parent a7cbfead75
commit 3c22e4bbe1
107 changed files with 2218 additions and 659 deletions

View File

@@ -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);
});

View File

@@ -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<typeof useCreateCourseBlock>;
handleAddSubsectionFromLibrary: ReturnType<typeof useCreateCourseBlock>;
handleAddUnitFromLibrary: ReturnType<typeof useCreateCourseBlock>;
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<CourseAuthoringContextData>(() => {
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<CourseAuthoringContextData>(() => ({
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 (

View File

@@ -57,6 +57,9 @@ const CourseAuthoringPage = ({ children }: Props) => {
org={courseOrg}
title={courseTitle}
contextId={courseId}
containerProps={{
size: 'fluid',
}}
/>
)
)}

View File

@@ -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<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: () => validateUserPermissions(permissions),
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken,
retry: false,
});

View File

@@ -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('<CourseOutline />', () => {
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('<CourseOutline />', () => {
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);

View File

@@ -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 (
<Container size="xl" className="px-4 mt-4">
<Container fluid className="px-3 mt-4">
<PageAlerts
courseId={courseId}
notificationDismissUrl={notificationDismissUrl}
@@ -292,7 +291,7 @@ const CourseOutline = () => {
<Helmet>
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
</Helmet>
<Container size="xl" className="px-4">
<Container fluid className="px-3">
<section className="course-outline-container mb-4 mt-5">
<PageAlerts
courseId={courseId}
@@ -413,9 +412,7 @@ const CourseOutline = () => {
onEditSectionSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
onNewSubsectionSubmit={handleNewSubsectionSubmit}
onOrderChange={updateSectionOrderByIndex}
onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync}
resetScrollState={resetScrollState}
>
<SortableContext
@@ -445,8 +442,6 @@ const CourseOutline = () => {
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"
>
<ComponentPicker
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['block_type = "section"']}
componentPickerMode="single"

View File

@@ -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<Object>}
* 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<object> {
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;
}

View File

@@ -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);
},
});

View File

@@ -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[],

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {};

View File

@@ -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('<HeaderActions />', () => {
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 () => {

View File

@@ -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 = ({
>
<Button
iconBefore={IconAdd}
onClick={handleNewSection}
onClick={() => setCurrentPageKey('add')}
disabled={!(errors?.outlineIndexApi === undefined || errors?.outlineIndexApi === null)}
variant="outline-primary"
>
@@ -80,17 +79,18 @@ const HeaderActions = ({
<Icon src={ViewSidebar} />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
{Object.entries(sidebarPages).map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => (
<Dropdown.Item
key={key}
onClick={() => setCurrentPageKey(key)}
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
{page.title}
</Stack>
</Dropdown.Item>
))}
{Object.entries(sidebarPages).filter(([, page]) => !page.hideFromActionMenu)
.map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => (
<Dropdown.Item
key={key}
onClick={() => setCurrentPageKey(key)}
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
{page.title}
</Stack>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>

View File

@@ -1,20 +1,17 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useQueryClient } from '@tanstack/react-query';
import moment from 'moment';
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
import { useWaffleFlags } from '@src/data/apiHooks';
import { RequestStatus } from '@src/data/constants';
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { COURSE_BLOCK_NAMES } from './constants';
import {
addSection,
addSubsection,
setCurrentItem,
setCurrentSection,
resetScrollField,
@@ -35,9 +32,6 @@ import {
getCreatedOn,
} from './data/selectors';
import {
addNewSectionQuery,
addNewSubsectionQuery,
addNewUnitQuery,
deleteCourseSectionQuery,
deleteCourseSubsectionQuery,
deleteCourseUnitQuery,
@@ -63,15 +57,12 @@ import {
dismissNotificationQuery,
syncDiscussionsTopics,
} from './data/thunk';
import { useCreateCourseBlock } from './data/apiHooks';
import { getCourseItem } from './data/api';
import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks';
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
const queryClient = useQueryClient();
const navigate = useNavigate();
const waffleFlags = useWaffleFlags(courseId);
const { handleNewSectionSubmit } = useCourseAuthoringContext();
const {
reindexLink,
@@ -120,65 +111,10 @@ const useCourseOutline = ({ courseId }) => {
dispatch(pasteClipboardContent(parentLocator, sectionId));
};
const handleNewSectionSubmit = () => {
dispatch(addNewSectionQuery(courseStructure.id));
};
const handleNewSubsectionSubmit = (sectionId) => {
dispatch(addNewSubsectionQuery(sectionId));
};
const getUnitUrl = (locator) => {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
return `/course/${courseId}/container/${locator}`;
}
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
};
const openUnitPage = (locator) => {
const url = getUnitUrl(locator);
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
navigate(url);
} else {
window.location.assign(url);
}
};
const handleNewUnitSubmit = (subsectionId) => {
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
};
/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage);
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 }));
}
});
const resetScrollState = () => {
dispatch(resetScrollField());
};
const handleAddSectionFromLibrary = useCreateCourseBlock(async (locator) => {
try {
const data = await getCourseItem(locator);
// Page should scroll to newly added section.
data.shouldScroll = true;
dispatch(addSection(data));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});
const headerNavigationsActions = {
handleNewSection: handleNewSectionSubmit,
handleReIndex: () => {
@@ -411,14 +347,6 @@ const useCourseOutline = ({ courseId }) => {
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
handleDuplicateUnitSubmit,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
getUnitUrl,
openUnitPage,
handleNewUnitSubmit,
handleAddUnitFromLibrary,
handleAddSubsectionFromLibrary,
handleAddSectionFromLibrary,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
notificationDismissUrl,

View File

@@ -0,0 +1,166 @@
import { courseOutlineIndexMock } from '@src/course-outline/__mocks__';
import { initializeMocks, render, screen } from '@src/testUtils';
import { userEvent } from '@testing-library/user-event';
import mockResult from '@src/library-authoring/__mocks__/library-search.json';
import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock';
import {
mockContentLibrary,
mockGetCollectionMetadata,
mockGetContainerMetadata,
mockGetContentLibraryV2List,
mockLibraryBlockMetadata,
} from '@src/library-authoring/data/api.mocks';
import { AddSidebar } from './AddSidebar';
const handleNewSectionSubmit = jest.fn();
const handleNewSubsectionSubmit = jest.fn();
const handleNewUnitSubmit = jest.fn();
const handleAddSectionFromLibrary = { mutateAsync: jest.fn() };
const handleAddSubsectionFromLibrary = { mutateAsync: jest.fn() };
const handleAddUnitFromLibrary = { mutateAsync: jest.fn() };
mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetCollectionMetadata.applyMock();
mockGetContentLibraryV2List.applyMock();
mockLibraryBlockMetadata.applyMock();
mockGetContainerMetadata.applyMock();
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
courseUsageKey: 'course-usage-key',
courseDetails: { name: 'Test course' },
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleNewSectionSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue(courseOutlineIndexMock.courseStructure.childInfo.children),
}));
jest.mock('@src/studio-home/hooks', () => ({
useStudioHome: () => ({
isLoadingPage: false,
isFailedLoadingPage: false,
librariesV2Enabled: true,
}),
}));
const renderComponent = () => render(<AddSidebar />);
const searchResult = {
...mockResult,
results: [
{
...mockResult.results[0],
hits: [
...mockResult.results[0].hits.slice(16, 19),
],
},
{
...mockResult.results[1],
},
],
};
describe('AddSidebar component', () => {
beforeEach(() => {
initializeMocks();
mockSearchResult({
...searchResult,
});
});
it('renders the AddSidebar component without any errors', async () => {
const user = userEvent.setup();
renderComponent();
// Check add new tab content
expect(await screen.findByRole('button', { name: 'Section' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Subsection' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Unit' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Add New' })).toBeInTheDocument();
const existingTab = await screen.findByRole('tab', { name: 'Add Existing' });
expect(existingTab).toBeInTheDocument();
// Check existing tab content
await user.click(existingTab);
expect(await screen.findByRole('button', { name: 'All libraries' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'See more' })).toBeInTheDocument();
expect(await screen.findByRole('search')).toBeInTheDocument();
});
it('show hide extra filters', async () => {
const user = userEvent.setup();
renderComponent();
const existingTab = await screen.findByRole('tab', { name: 'Add Existing' });
expect(existingTab).toBeInTheDocument();
await user.click(existingTab);
const toggleBtn = await screen.findByRole('button', { name: 'See more' });
expect(toggleBtn).toBeInTheDocument();
// hidden by default
expect(screen.queryByRole('button', { name: 'Type' })).not.toBeInTheDocument();
// show when clicked
await user.click(toggleBtn);
expect(await screen.findByRole('button', { name: 'Type' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Tags' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Collections filter' })).toBeInTheDocument();
});
it('calls appropriate handlers on new button click', async () => {
const user = userEvent.setup();
renderComponent();
// Validate handler for adding section, subsection and unit
const section = await screen.findByRole('button', { name: 'Section' });
const subsection = await screen.findByRole('button', { name: 'Subsection' });
const unit = await screen.findByRole('button', { name: 'Unit' });
await user.click(section);
expect(handleNewSectionSubmit).toHaveBeenCalled();
await user.click(subsection);
expect(handleNewSubsectionSubmit).toHaveBeenCalled();
await user.click(unit);
expect(handleNewUnitSubmit).toHaveBeenCalled();
});
it('calls appropriate handlers on existing button click', async () => {
const user = userEvent.setup();
const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children;
const lastSection = sectionList[3];
const lastSubsection = lastSection.childInfo.children[0];
renderComponent();
// Check existing tab content
await user.click(await screen.findByRole('tab', { name: 'Add Existing' }));
// Validate handler for adding section, subsection and unit
const addBtns = await screen.findAllByRole('button', { name: 'Add' });
// first one is unit as per mock
await user.click(addBtns[0]);
expect(handleAddUnitFromLibrary.mutateAsync).toHaveBeenCalledWith({
type: 'library_v2',
category: 'vertical',
parentLocator: lastSubsection.id,
libraryContentKey: searchResult.results[0].hits[0].usage_key,
});
// second one is subsection as per mock
await user.click(addBtns[1]);
expect(handleAddSubsectionFromLibrary.mutateAsync).toHaveBeenCalledWith({
type: 'library_v2',
category: 'sequential',
parentLocator: lastSection.id,
libraryContentKey: searchResult.results[0].hits[1].usage_key,
});
// third one is section as per mock
await user.click(addBtns[2]);
expect(handleAddSectionFromLibrary.mutateAsync).toHaveBeenCalledWith({
type: 'library_v2',
category: 'chapter',
parentLocator: 'course-usage-key',
libraryContentKey: searchResult.results[0].hits[2].usage_key,
});
});
});

View File

@@ -0,0 +1,235 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { SchoolOutline } from '@openedx/paragon/icons';
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
import contentMessages from '@src/library-authoring/add-content/messages';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { SidebarFilters } from '@src/library-authoring/library-filters/SidebarFilters';
import {
Button, Icon, Stack, Tab, Tabs,
} from '@openedx/paragon';
import { getIconBorderStyleColor, getItemIcon } from '@src/generic/block-type-utils';
import { useSelector } from 'react-redux';
import { getSectionsList } from '@src/course-outline/data/selectors';
import { useCallback, useMemo } from 'react';
import { ComponentSelectedEvent } from '@src/library-authoring/common/context/ComponentPickerContext';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { ContainerType } from '@src/generic/key-utils';
import type { XBlock } from '@src/data/types';
import { ContentType } from '@src/library-authoring/routes';
import { ComponentPicker } from '@src/library-authoring';
import { MultiLibraryProvider } from '@src/library-authoring/common/context/MultiLibraryContext';
import messages from './messages';
type ContainerTypes = 'unit' | 'subsection' | 'section';
type AddContentButtonProps = {
name: string,
blockType: ContainerTypes,
};
const getLastEditableParent = (blockList: Array<XBlock>) => {
let index = 1;
let lastBlock: XBlock;
while (index <= blockList.length) {
lastBlock = blockList[blockList.length - index];
if (lastBlock.actions.childAddable) {
return lastBlock;
}
index++;
}
return undefined;
};
/** Add Content Button */
const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
const sectionsList = useSelector(getSectionsList);
const lastSection = getLastEditableParent(sectionsList);
const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []);
const {
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
} = useCourseAuthoringContext();
const onCreateContent = useCallback(() => {
switch (blockType) {
case 'section':
handleNewSectionSubmit();
break;
case 'subsection':
if (lastSection) {
handleNewSubsectionSubmit(lastSection.id);
}
break;
case 'unit':
if (lastSubsection) {
handleNewUnitSubmit(lastSubsection.id);
}
break;
default:
// istanbul ignore next: unreachable
throw new Error(`Unrecognized block type ${blockType}`);
}
}, [
blockType,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
lastSection,
lastSubsection,
]);
return (
<Button
variant="tertiary shadow"
className="mx-2 justify-content-start px-4 font-weight-bold"
onClick={onCreateContent}
disabled={(!lastSection && blockType === 'subsection') || (!lastSubsection && blockType === 'unit')}
>
<Stack direction="horizontal" gap={3}>
<span className={`p-2 rounded ${getIconBorderStyleColor(blockType)}`}>
<Icon size="lg" src={getItemIcon(blockType)} />
</span>
{name}
</Stack>
</Button>
);
};
/** Add New Content Tab Section */
const AddNewContent = () => {
const intl = useIntl();
return (
<Stack gap={2}>
<AddContentButton
name={intl.formatMessage(contentMessages.sectionButton)}
blockType="section"
/>
<AddContentButton
name={intl.formatMessage(contentMessages.subsectionButton)}
blockType="subsection"
/>
<AddContentButton
name={intl.formatMessage(contentMessages.unitButton)}
blockType="unit"
/>
</Stack>
);
};
/** Add Existing Content Tab Section */
const ShowLibraryContent = () => {
const sectionsList: Array<XBlock> = 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 (
<MultiLibraryProvider>
<ComponentPicker
showOnlyPublished
extraFilter={[`block_type IN [${allowedBlocks.join(',')}]`]}
visibleTabs={[ContentType.home]}
FiltersComponent={SidebarFilters}
onComponentSelected={onComponentSelected}
/>
</MultiLibraryProvider>
);
};
/** Tabs Component */
const AddTabs = () => {
const intl = useIntl();
return (
<Tabs
variant="tabs"
defaultActiveKey="addNew"
className="my-2 d-flex justify-content-around"
id="add-content-tabs"
>
<Tab eventKey="addNew" title={intl.formatMessage(messages.sidebarTabsAddNew)}>
<AddNewContent />
</Tab>
<Tab eventKey="addExisting" title={intl.formatMessage(messages.sidebarTabsAddExisiting)}>
<ShowLibraryContent />
</Tab>
</Tabs>
);
};
/** Main Sidebar Component */
export const AddSidebar = () => {
const { courseDetails } = useCourseAuthoringContext();
return (
<div>
<SidebarTitle
title={courseDetails?.name || ''}
icon={SchoolOutline}
/>
<SidebarContent>
<SidebarSection>
<AddTabs />
</SidebarSection>
</SidebarContent>
</div>
);
};

View File

@@ -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';

View File

@@ -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<OutlineSidebarPageKeys, SidebarPage>;
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<OutlineSidebarContextData>(

View File

@@ -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;

View File

@@ -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}
>

View File

@@ -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 && (
<OutlineAddChildButtons
handleNewButtonClick={handleNewSubsectionSubmit}
handleNewButtonClick={() => handleNewSubsectionSubmit(id)}
handleUseFromLibraryClick={openAddLibrarySubsectionModal}
childType={ContainerType.Subsection}
/>
@@ -362,7 +359,7 @@ const SectionCard = ({
isOverflowVisible={false}
size="xl"
>
<ComponentPicker
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['block_type = "subsection"']}
componentPickerMode="single"

View File

@@ -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: () => ({
@@ -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('<SubsectionCard />', () => {
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',

View File

@@ -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"
>
<ComponentPicker
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['block_type = "unit"']}
componentPickerMode="single"

View File

@@ -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={{

View File

@@ -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 = (
<TitleLink
title={displayName}
titleLink={getTitleLink(id)}
titleLink={getUnitUrl(id)}
namePrefix={namePrefix}
prefixIcon={(
<UpstreamInfoIcon

View File

@@ -1,3 +1,4 @@
import type { IntlShape, MessageDescriptor } from 'react-intl';
import {
CheckCircle as CheckCircleIcon,
Lock as LockIcon,
@@ -5,19 +6,22 @@ import {
import DraftIcon from '@src/generic/DraftIcon';
import { VisibilityTypes } from '@src/data/constants';
import { ValueOf } from '@src/types';
import { ITEM_BADGE_STATUS, VIDEO_SHARING_OPTIONS } from './constants';
export type ItemBadgeStatusValue = ValueOf<typeof ITEM_BADGE_STATUS>;
/**
* 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<string, MessageDescriptor>,
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<string>} 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<string>): 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<typeof VIDEO_SHARING_OPTIONS>,
messages: Record<string, MessageDescriptor>,
intl: IntlShape,
): string => {
switch (id) {
case VIDEO_SHARING_OPTIONS.perVideo:
return intl.formatMessage(messages.videoSharingPerVideoText);

View File

@@ -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({

View File

@@ -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 = ({
)
}
>
<ComponentPicker
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['NOT block_type = "unit"', 'NOT block_type = "section"', 'NOT block_type = "subsection"']}
visibleTabs={[ContentType.home, ContentType.components, ContentType.collections]}

View File

@@ -41,42 +41,6 @@ export async function getVerticalData(unitId: string): Promise<object> {
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.

View File

@@ -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,

View File

@@ -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',
};

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -18,6 +18,7 @@ export interface SidebarPage {
component: React.ComponentType;
icon: React.ComponentType;
title: string;
hideFromActionMenu?: boolean;
}
type SidebarPages = Record<string, SidebarPage>;
@@ -88,7 +89,7 @@ export function Sidebar<T extends SidebarPages>({
const SidebarComponent = pages[currentPageKey].component;
return (
<Stack direction="horizontal" className="sidebar align-items-baseline mx-3" gap={2}>
<Stack direction="horizontal" className="sidebar align-items-baseline ml-3" gap={2}>
{isOpen && !!currentPageKey && (
<div className="sidebar-content p-2 bg-white border-right">
<Dropdown data-testid="sidebar-dropdown">

View File

@@ -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[];
}
/**

View File

@@ -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 = ({
<Stack gap={2}>
<Stack direction="horizontal" gap={2}>
{icon && <Icon src={icon} className="mr-1 text-primary" size="sm" />}
{title && (
<h3 className="h5 font-weight-bold text-primary mb-0">
{title}
</h3>
)}
{actions && (
<Dropdown className="ml-auto">
<Dropdown.Toggle

View File

@@ -1,6 +1,12 @@
.sidebar {
.sidebar-content {
width: 400px;
flex: 0 1 auto;
max-width: 700px;
min-width: 650px;
overflow-y: auto;
min-height: 100vh;
height: 100%;
max-height: 300vh;
}
.dropdown-toggle {
@@ -38,7 +44,8 @@
&:hover {
border-width: 2px;
}
// Add a triangle to the active button
&.btn-icon-primary-active::before {
content: "";
border-right: 5px solid var(--pgn-color-primary-base);

View File

@@ -190,3 +190,26 @@ export function useStateWithUrlSearchParam<Type>(
// 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<T>(
defaultValue: T,
key: string,
): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
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];
}

View File

@@ -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 = () => {
<Route
path="/component-picker"
element={(
<ComponentPicker
<LibraryAndComponentPicker
extraFilter={['NOT block_type = "unit"', 'NOT block_type = "section"', 'NOT block_type = "subsection"']}
visibleTabs={[ContentType.home, ContentType.components, ContentType.collections]}
/>
@@ -83,7 +83,7 @@ const App = () => {
<Route
path="/component-picker/multiple"
element={(
<ComponentPicker
<LibraryAndComponentPicker
componentPickerMode="multiple"
extraFilter={['NOT block_type = "unit"', 'NOT block_type = "section"', 'NOT block_type = "subsection"']}
visibleTabs={[ContentType.home, ContentType.components, ContentType.collections]}

View File

@@ -6,7 +6,7 @@ import {
import { Add } from '@openedx/paragon/icons';
import { ClearFiltersButton } from '../search-manager';
import messages from './messages';
import { useLibraryContext } from './common/context/LibraryContext';
import { useOptionalLibraryContext } from './common/context/LibraryContext';
export const NoComponents = ({
infoText = messages.noComponents,
@@ -15,14 +15,14 @@ export const NoComponents = ({
}: {
infoText?: MessageDescriptor;
addBtnText?: MessageDescriptor;
handleBtnClick: () => void;
handleBtnClick?: () => void;
}) => {
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
return (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
<FormattedMessage {...infoText} />
{!readOnly && (
{!readOnly && handleBtnClick && (
<Button iconBefore={Add} onClick={handleBtnClick}>
<FormattedMessage {...addBtnText} />
</Button>

View File

@@ -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<FiltersProps>;
}
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 <NotFoundAlert />;
}
@@ -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 (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet><title>{libraryData.title} | {process.env.SITE_NAME}</title></Helmet>
{!componentPickerMode && (
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
readOnly={readOnly}
isLibrary
containerProps={{
size: undefined,
}}
/>
)}
{libraryData
&& (
<>
<Helmet><title>{libraryData.title} | {process.env.SITE_NAME}</title></Helmet>
{!componentPickerMode && (
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
readOnly={readOnly}
isLibrary
containerProps={{
size: undefined,
}}
/>
)}
</>
)}
<Container className="px-4 mt-4 mb-5 library-authoring-page">
<SearchContextProvider
extraFilter={extraFilter}
overrideTypesFilter={overrideTypesFilter}
>
<SubHeader
title={<SubHeaderTitle title={libraryData.title} />}
subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined}
breadcrumbs={breadcumbs}
headerActions={<HeaderActions />}
hideBorder
/>
{libraryData
&& (
<SubHeader
title={<SubHeaderTitle title={libraryData.title} />}
subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined}
breadcrumbs={breadcumbs}
headerActions={<HeaderActions />}
hideBorder
/>
)}
{visibleTabs.length > 1 && (
<Tabs
variant="tabs"
@@ -372,22 +396,7 @@ const LibraryAuthoringPage = ({
{visibleTabsToRender}
</Tabs>
)}
<ActionRow className="my-3">
<SearchKeywordsField className="mr-3" />
<FilterByTags />
{!(onlyOneType) && <FilterByBlockType />}
<LibraryFilterByPublished key={
// It is necessary to re-render `LibraryFilterByPublished` every time `FilterByBlockType`
// appears or disappears, this is because when the menu is opened it is rendered
// in a previous state, causing an inconsistency in its position.
// By changing the key we can re-render the component.
!(insideCollections || insideUnits) ? 'filter-published-1' : 'filter-published-2'
}
/>
<ClearFiltersButton />
<ActionRow.Spacer />
<SearchSortWidget />
</ActionRow>
<FiltersComponent onlyOneType={onlyOneType} />
<LibraryContent contentType={activeKey} />
</SearchContextProvider>
</Container>

View File

@@ -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(

View File

@@ -43,8 +43,8 @@ const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ 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}
>
<SidebarProvider>

View File

@@ -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",

View File

@@ -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) && (
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
@@ -312,6 +313,7 @@ const AddContent = () => {
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}
/>
)}
</Stack>

View File

@@ -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<PickLibraryContentModalProps> = (
</ActionRow>
)}
>
<ComponentPicker
<LibraryProvider
libraryId={libraryId}
componentPickerMode="multiple"
onChangeComponentSelection={setSelectedComponents}
extraFilter={extraFilter}
visibleTabs={visibleTabs}
/>
skipUrlUpdate
>
<ComponentPicker
libraryId={libraryId}
componentPickerMode="multiple"
onChangeComponentSelection={setSelectedComponents}
extraFilter={extraFilter}
visibleTabs={visibleTabs}
/>
</LibraryProvider>
</StandardModal>
);
};

View File

@@ -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<HTMLTextAreaElement>) => {
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(() => {

View File

@@ -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 });
}

View File

@@ -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));

View File

@@ -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={<Icon src={ArrowBack} size="sm" />}
@@ -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');
}

View File

@@ -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<ComponentPickerContextData | undefined>(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<SelectedComponent[]>([]);
@@ -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 (

View File

@@ -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<LibraryContextData | undefined>(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 <LibraryProvider> ancestor.');
}
return ctx;
}
export function useOptionalLibraryContext(): Partial<LibraryContextData> {
const ctx = useContext(LibraryContext);
return ctx || { readOnly: true };
}

View File

@@ -0,0 +1,54 @@
import { useStickyState } from '@src/hooks';
import React from 'react';
interface MultiLibraryContextProps {
selectedLibraries: string[];
setSelectedLibraries: React.Dispatch<React.SetStateAction<string[]>>;
selectedCollections: string[];
setSelectedCollections: React.Dispatch<React.SetStateAction<string[]>>;
}
const Context = React.createContext<MultiLibraryContextProps | undefined>(undefined);
export const MultiLibraryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectedLibraries, setSelectedLibraries] = useStickyState<string[]>([], 'outline-library-filter');
const [selectedCollections, setSelectedCollections] = React.useState<string[]>([]);
React.useEffect(() => {
if (selectedLibraries.length !== 1) {
setSelectedCollections([]);
}
}, [selectedLibraries, setSelectedCollections]);
const context = React.useMemo(() => ({
selectedLibraries,
setSelectedLibraries,
selectedCollections,
setSelectedCollections,
}), [
selectedLibraries,
setSelectedLibraries,
selectedCollections,
setSelectedCollections,
]);
return (
<Context.Provider value={context}>
{children}
</Context.Provider>
);
};
export const useMultiLibraryContext = (): MultiLibraryContextProps => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
/* istanbul ignore next */
return {
selectedLibraries: [],
setSelectedLibraries: () => {},
selectedCollections: [],
setSelectedCollections: () => {},
};
}
return ctx;
};

View File

@@ -0,0 +1,31 @@
import React from 'react';
interface PublishedFilterContextProps {
showOnlyPublished?: boolean;
}
const Context = React.createContext<PublishedFilterContextProps | undefined>(undefined);
export const PublishedFilterContextProvider: React.FC<PublishedFilterContextProps & { children: React.ReactNode }> = ({
showOnlyPublished,
children,
}) => {
const context = React.useMemo(() => ({ showOnlyPublished }), [showOnlyPublished]);
return (
<Context.Provider value={context}>
{children}
</Context.Provider>
);
};
export const usePublishedFilterContext = (): PublishedFilterContextProps => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
/* istanbul ignore next */
return {
showOnlyPublished: false,
};
}
return ctx;
};

View File

@@ -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(() => {

View File

@@ -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<Record<never, never>> = () => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const { sidebarItemInfo } = useSidebarContext();
const usageKey = sidebarItemInfo?.id;

View File

@@ -1,3 +1,4 @@
import { PublishedFilterContextProvider } from '@src/library-authoring/common/context/PublishedFilterContext';
import {
fireEvent,
initializeMocks,
@@ -32,15 +33,17 @@ const render = (
<ComponentAdvancedInfo />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId} showOnlyPublished={showOnlyPublished}>
<SidebarProvider
initialSidebarItemInfo={{
id: usageKey,
type: SidebarBodyItemId.ComponentInfo,
}}
>
{children}
</SidebarProvider>
<LibraryProvider libraryId={libraryId}>
<PublishedFilterContextProvider showOnlyPublished={showOnlyPublished}>
<SidebarProvider
initialSidebarItemInfo={{
id: usageKey,
type: SidebarBodyItemId.ComponentInfo,
}}
>
{children}
</SidebarProvider>
</PublishedFilterContextProvider>
</LibraryProvider>
),
},

View File

@@ -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<Record<never, never>> = () => {
const intl = useIntl();
const { readOnly, showOnlyPublished } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const { sidebarItemInfo } = useSidebarContext();
const usageKey = sidebarItemInfo?.id;

View File

@@ -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 (
<div className="d-flex flex-wrap">
<Button
{...(canEdit ? { onClick: () => openComponentEditor(componentId) } : { disabled: true })}
{...(canEdit ? { onClick: () => openComponentEditor?.(componentId) } : { disabled: true })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
@@ -151,7 +151,7 @@ const ComponentActions = ({
const ComponentInfo = () => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const {
sidebarTab,

View File

@@ -1,9 +1,10 @@
import { useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
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 { useUpdateXBlockFields, useXBlockFields } from '../data/apiHooks';
import messages from './messages';
@@ -11,7 +12,8 @@ import messages from './messages';
const ComponentInfoHeader = () => {
const intl = useIntl();
const { readOnly, showOnlyPublished } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const { sidebarItemInfo } = useSidebarContext();
const usageKey = sidebarItemInfo?.id;

View File

@@ -6,7 +6,7 @@ import {
BookOpen, ExpandLess, ExpandMore, Tag,
} from '@openedx/paragon/icons';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useOptionalLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
import { useLibraryBlockMetadata, useUpdateComponentCollections } from '../data/apiHooks';
@@ -16,7 +16,7 @@ import messages from './messages';
const ComponentManagement = () => {
const intl = useIntl();
const { readOnly, isLoadingLibraryData } = useLibraryContext();
const { readOnly, isLoadingLibraryData } = useOptionalLibraryContext();
const { sidebarItemInfo, sidebarAction, resetSidebarAction } = useSidebarContext();
const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections;
const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags;

View File

@@ -2,7 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, StandardModal, useToggle } from '@openedx/paragon';
import { OpenInFull } from '@openedx/paragon/icons';
import { useLibraryContext } from '../common/context/LibraryContext';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import { LibraryBlock } from '../LibraryBlock';
import messages from './messages';
@@ -17,7 +17,7 @@ interface ModalComponentPreviewProps {
const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPreviewProps) => {
const intl = useIntl();
const { showOnlyPublished } = useLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
return (
<StandardModal
@@ -41,7 +41,7 @@ const ComponentPreview = () => {
const intl = useIntl();
const [isModalOpen, openModal, closeModal] = useToggle();
const { showOnlyPublished } = useLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const { sidebarItemInfo } = useSidebarContext();
const usageKey = sidebarItemInfo?.id;

View File

@@ -18,7 +18,7 @@ import {
mockGetContainerMetadata,
} from '../data/api.mocks';
import { ComponentPicker } from './ComponentPicker';
import { LibraryAndComponentPicker } from './ComponentPicker';
import { ContentType } from '../routes';
jest.mock('react-router-dom', () => ({
@@ -46,7 +46,7 @@ mockGetContainerMetadata.applyMock();
let postMessageSpy: jest.SpyInstance;
describe('<ComponentPicker />', () => {
describe('<LibraryAndComponentPicker />', () => {
beforeEach(() => {
initializeMocks();
postMessageSpy = jest.spyOn(window.parent, 'postMessage');
@@ -55,7 +55,7 @@ describe('<ComponentPicker />', () => {
});
it('should be able to switch tabs', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -99,7 +99,7 @@ describe('<ComponentPicker />', () => {
});
it('should pick component using the component card button', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -122,7 +122,7 @@ describe('<ComponentPicker />', () => {
});
it('should pick component using the component sidebar', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -150,7 +150,7 @@ describe('<ComponentPicker />', () => {
});
it('should open the unit sidebar', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -169,7 +169,7 @@ describe('<ComponentPicker />', () => {
it('double clicking a collection should open it', async () => {
const user = userEvent.setup();
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -193,7 +193,7 @@ describe('<ComponentPicker />', () => {
});
it('should pick component inside a collection using the card', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -231,7 +231,7 @@ describe('<ComponentPicker />', () => {
});
it('should pick component inside a collection using the sidebar', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -274,7 +274,7 @@ describe('<ComponentPicker />', () => {
});
it('should return to library selection', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -288,7 +288,7 @@ describe('<ComponentPicker />', () => {
it('should pick multiple components using the component card button', async () => {
const onChange = jest.fn();
render(<ComponentPicker componentPickerMode="multiple" onChangeComponentSelection={onChange} />);
render(<LibraryAndComponentPicker componentPickerMode="multiple" onChangeComponentSelection={onChange} />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -338,7 +338,7 @@ describe('<ComponentPicker />', () => {
it('should pick multilpe components using the component sidebar', async () => {
const onChange = jest.fn();
render(<ComponentPicker componentPickerMode="multiple" onChangeComponentSelection={onChange} />);
render(<LibraryAndComponentPicker componentPickerMode="multiple" onChangeComponentSelection={onChange} />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -374,7 +374,7 @@ describe('<ComponentPicker />', () => {
});
it('should display an alert banner when showOnlyPublished is true', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -385,7 +385,7 @@ describe('<ComponentPicker />', () => {
it('should display all tabs', async () => {
// Default `visibleTabs = allLibraryPageTabs`
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -397,7 +397,7 @@ describe('<ComponentPicker />', () => {
});
it('should display only units', async () => {
render(<ComponentPicker visibleTabs={[ContentType.units]} />);
render(<LibraryAndComponentPicker visibleTabs={[ContentType.units]} />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -408,7 +408,7 @@ describe('<ComponentPicker />', () => {
});
it('should not display never published filter', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
@@ -424,7 +424,7 @@ describe('<ComponentPicker />', () => {
});
it('should not display never published filter in collection page', async () => {
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));

View File

@@ -3,12 +3,14 @@ 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 { PublishedFilterContextProvider } from '@src/library-authoring/common/context/PublishedFilterContext';
import {
type ComponentSelectedEvent,
type ComponentSelectionChangedEvent,
ComponentPickerProvider,
} from '../common/context/ComponentPickerContext';
import { LibraryProvider, useLibraryContext } from '../common/context/LibraryContext';
import { LibraryProvider, useOptionalLibraryContext } from '../common/context/LibraryContext';
import { SidebarProvider } from '../common/context/SidebarContext';
import LibraryAuthoringPage from '../LibraryAuthoringPage';
import LibraryCollectionPage from '../collections/LibraryCollectionPage';
@@ -17,15 +19,17 @@ import messages from './messages';
import { ContentType, allLibraryPageTabs } from '../routes';
interface LibraryComponentPickerProps {
returnToLibrarySelection: () => void;
returnToLibrarySelection?: () => void;
visibleTabs: ContentType[],
FiltersComponent?: React.ComponentType<FiltersProps>;
}
const InnerComponentPicker: React.FC<LibraryComponentPickerProps> = ({
returnToLibrarySelection,
visibleTabs,
FiltersComponent,
}) => {
const { collectionId } = useLibraryContext();
const { collectionId } = useOptionalLibraryContext();
if (collectionId) {
return <LibraryCollectionPage />;
@@ -34,6 +38,7 @@ const InnerComponentPicker: React.FC<LibraryComponentPickerProps> = ({
<LibraryAuthoringPage
returnToLibrarySelection={returnToLibrarySelection}
visibleTabs={visibleTabs}
FiltersComponent={FiltersComponent}
/>
);
};
@@ -48,17 +53,18 @@ const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selecti
window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*');
};
type ComponentPickerProps = {
libraryId?: string,
type PickerProps = {
showOnlyPublished?: boolean,
extraFilter?: string[],
visibleTabs?: ContentType[],
componentPickerMode?: 'single' | 'multiple',
onComponentSelected?: ComponentSelectedEvent,
onChangeComponentSelection?: ComponentSelectionChangedEvent,
FiltersComponent?: React.ComponentType<FiltersProps>;
};
export const ComponentPicker: React.FC<ComponentPickerProps> = ({
/** A component picker that allows the user to select one or more components */
export const ComponentPicker = ({
/** Restrict the component picker to a specific library */
libraryId,
showOnlyPublished,
@@ -70,26 +76,15 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
*/
onComponentSelected = defaultComponentSelectedCallback,
onChangeComponentSelection = defaultSelectionChangedCallback,
}) => {
const [currentStep, setCurrentStep] = useState(!libraryId ? 'select-library' : 'pick-components');
const [selectedLibrary, setSelectedLibrary] = useState(libraryId || '');
FiltersComponent,
returnToLibrarySelection,
}: PickerProps & LibraryComponentPickerProps & { libraryId?: string }) => {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const variant = queryParams.get('variant') || 'draft';
const calcShowOnlyPublished = variant === 'published' || showOnlyPublished;
const handleLibrarySelection = (library: string) => {
setCurrentStep('pick-components');
setSelectedLibrary(library);
};
const returnToLibrarySelection = () => {
setCurrentStep('select-library');
setSelectedLibrary('');
};
const restrictToLibrary = !!libraryId;
const componentPickerProviderProps = componentPickerMode === 'single' ? {
@@ -102,6 +97,56 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
restrictToLibrary,
};
return (
<PublishedFilterContextProvider showOnlyPublished={calcShowOnlyPublished}>
<ComponentPickerProvider
{...componentPickerProviderProps}
extraFilter={extraFilter}
>
<SidebarProvider>
{ calcShowOnlyPublished
&& (
<Alert variant="info" className="m-2">
<FormattedMessage {...messages.pickerInfoBanner} />
</Alert>
)}
<InnerComponentPicker
returnToLibrarySelection={returnToLibrarySelection}
visibleTabs={visibleTabs}
FiltersComponent={FiltersComponent}
/>
</SidebarProvider>
</ComponentPickerProvider>
</PublishedFilterContextProvider>
);
};
/** A component picker that allows the user to select one or more components from a library. */
export const LibraryAndComponentPicker: React.FC<PickerProps> = ({
showOnlyPublished,
extraFilter,
componentPickerMode = 'single',
visibleTabs = allLibraryPageTabs,
/** This default callback is used to send the selected component back to the parent window,
* when the component picker is used in an iframe.
*/
onComponentSelected = defaultComponentSelectedCallback,
onChangeComponentSelection = defaultSelectionChangedCallback,
FiltersComponent,
}) => {
const [currentStep, setCurrentStep] = useState('select-library');
const [selectedLibrary, setSelectedLibrary] = useState<string | undefined>(undefined);
const handleLibrarySelection = (library: string) => {
setSelectedLibrary(library);
setCurrentStep('pick-components');
};
const returnToLibrarySelection = () => {
setCurrentStep('select-library');
setSelectedLibrary('');
};
return (
<Stepper
activeKey={currentStep}
@@ -113,29 +158,22 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
itemType={visibleTabs.length === 1 ? visibleTabs[0] : ContentType.components}
/>
</Stepper.Step>
<Stepper.Step eventKey="pick-components" title="Pick some components">
<ComponentPickerProvider {...componentPickerProviderProps}>
<LibraryProvider
libraryId={selectedLibrary}
showOnlyPublished={calcShowOnlyPublished}
<LibraryProvider
libraryId={selectedLibrary!}
skipUrlUpdate
>
<ComponentPicker
showOnlyPublished={showOnlyPublished}
returnToLibrarySelection={returnToLibrarySelection}
componentPickerMode={componentPickerMode}
visibleTabs={visibleTabs}
extraFilter={extraFilter}
skipUrlUpdate
>
<SidebarProvider>
{ calcShowOnlyPublished
&& (
<Alert variant="info" className="m-2">
<FormattedMessage {...messages.pickerInfoBanner} />
</Alert>
)}
<InnerComponentPicker
returnToLibrarySelection={returnToLibrarySelection}
visibleTabs={visibleTabs}
/>
</SidebarProvider>
</LibraryProvider>
</ComponentPickerProvider>
FiltersComponent={FiltersComponent}
onComponentSelected={onComponentSelected}
onChangeComponentSelection={onChangeComponentSelection}
/>
</LibraryProvider>
</Stepper.Step>
</Stepper>
);

View File

@@ -7,16 +7,16 @@ import {
import {
mockGetContentLibraryV2List,
} from '../data/api.mocks';
import { ComponentPicker } from './ComponentPicker';
import { LibraryAndComponentPicker } from './ComponentPicker';
describe('<ComponentPicker />', () => {
describe('<LibraryAndComponentPicker />', () => {
beforeEach(() => {
initializeMocks();
});
it('should render the library list', async () => {
mockGetContentLibraryV2List.applyMock();
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
@@ -24,21 +24,21 @@ describe('<ComponentPicker />', () => {
it('should render the loading status', async () => {
mockGetContentLibraryV2List.applyMockLoading();
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText('Loading...')).toBeInTheDocument();
});
it('should render the no library status', async () => {
mockGetContentLibraryV2List.applyMockEmpty();
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText(/you don't have any libraries created yet,/i)).toBeInTheDocument();
});
it('should render the no search result status', async () => {
mockGetContentLibraryV2List.applyMockEmpty();
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
const searchField = await screen.findByPlaceholderText('Search for a library');
fireEvent.change(searchField, { target: { value: 'test' } });
@@ -49,7 +49,7 @@ describe('<ComponentPicker />', () => {
it('should render the error status', async () => {
mockGetContentLibraryV2List.applyMockError();
render(<ComponentPicker />);
render(<LibraryAndComponentPicker />);
expect(await screen.findByText(/mocked request failed with status code 500/i)).toBeInTheDocument();
});

View File

@@ -39,7 +39,7 @@ const EmptyState = ({ hasSearchQuery }: EmptyStateProps) => (
);
interface SelectLibraryProps {
selectedLibrary: string;
selectedLibrary?: string;
setSelectedLibrary: (libraryKey: string) => void;
itemType: ContentType;
}

View File

@@ -1 +1 @@
export { ComponentPicker } from './ComponentPicker';
export { LibraryAndComponentPicker, ComponentPicker } from './ComponentPicker';

View File

@@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
import type MockAdapter from 'axios-mock-adapter';
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
import { PublishedFilterContextProvider } from '@src/library-authoring/common/context/PublishedFilterContext';
import {
initializeMocks, render as baseRender, screen, waitFor, within, fireEvent,
} from '../../testUtils';
@@ -54,12 +55,11 @@ const render = (
path: '/library/:libraryId',
params: { libraryId: libId },
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libId}
showOnlyPublished={showOnlyPublished}
>
{children}
</LibraryProvider>
<PublishedFilterContextProvider showOnlyPublished={showOnlyPublished}>
<LibraryProvider libraryId={libId}>
{children}
</LibraryProvider>
</PublishedFilterContextProvider>
),
});

View File

@@ -12,8 +12,9 @@ import { Delete, MoreVert } from '@openedx/paragon/icons';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { type CollectionHit } from '@src/search-manager';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
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 { useLibraryRoutes } from '../routes';
import BaseCard from './BaseCard';
@@ -28,7 +29,7 @@ const CollectionMenu = ({ hit } : CollectionMenuProps) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const { navigateTo } = useLibraryRoutes();
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const { closeLibrarySidebar, sidebarItemInfo } = useSidebarContext();
const {
@@ -119,7 +120,8 @@ type CollectionCardProps = {
const CollectionCard = ({ hit } : CollectionCardProps) => {
const { componentPickerMode } = useComponentPickerContext();
const { setCollectionId, showOnlyPublished } = useLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const { setCollectionId } = useOptionalLibraryContext();
const { openCollectionInfoSidebar, openItemSidebar, sidebarItemInfo } = useSidebarContext();
const {
@@ -154,7 +156,7 @@ const CollectionCard = ({ hit } : CollectionCardProps) => {
// In component picker mode, we want to open the sidebar or the collection
// without changing the URL
} else if (doubleClicked) {
setCollectionId(collectionId);
setCollectionId?.(collectionId);
} else {
openCollectionInfoSidebar(collectionId);
}

View File

@@ -3,9 +3,9 @@ import {
ActionRow,
} from '@openedx/paragon';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
import { type ContentHit, PublishStatus } from '../../search-manager';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
import AddComponentWidget from './AddComponentWidget';
import BaseCard from './BaseCard';
@@ -16,9 +16,9 @@ type ComponentCardProps = {
};
const ComponentCard = ({ hit }: ComponentCardProps) => {
const { showOnlyPublished } = useLibraryContext();
const { openComponentInfoSidebar, openItemSidebar, sidebarItemInfo } = useSidebarContext();
const { componentPickerMode } = useComponentPickerContext();
const { showOnlyPublished } = usePublishedFilterContext();
const {
blockType,

View File

@@ -8,7 +8,7 @@ import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { type ContentHit } from '@src/search-manager';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useOptionalLibraryContext } from '../common/context/LibraryContext';
import {
useContentFromSearchIndex,
useDeleteLibraryBlock,
@@ -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 } = useOptionalLibraryContext();
const sidebarComponentUsageKey = sidebarItemInfo?.id;
const restoreComponentMutation = useRestoreLibraryBlock();

View File

@@ -12,7 +12,7 @@ import { useClipboard } from '@src/generic/clipboard';
import { getBlockType } from '@src/generic/key-utils';
import { ToastContext } from '@src/generic/toast-context';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useOptionalLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
import containerMessages from '@src/library-authoring/containers/messages';
@@ -36,7 +36,7 @@ export const ComponentMenu = ({ usageKey, index }: Props) => {
containerId,
openComponentEditor,
readOnly,
} = useLibraryContext();
} = useOptionalLibraryContext();
const {
sidebarItemInfo,
@@ -71,7 +71,7 @@ export const ComponentMenu = ({ usageKey, index }: Props) => {
const handleEdit = useCallback(() => {
openItemSidebar(usageKey, SidebarBodyItemId.ComponentInfo);
openComponentEditor(usageKey);
openComponentEditor?.(usageKey);
}, [usageKey, openItemSidebar, openComponentEditor]);
const scheduleJumpToCollection = useRunOnNextRender(() => {

View File

@@ -4,7 +4,7 @@ import { Warning } from '@openedx/paragon/icons';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useOptionalLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import {
useContainer,
@@ -14,6 +14,7 @@ import {
useUpdateContainerChildren,
} from '@src/library-authoring/data/apiHooks';
import { LibraryBlockMetadata } from '@src/library-authoring/data/api';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
import messages from './messages';
interface Props {
@@ -25,8 +26,9 @@ interface Props {
const ComponentRemover = ({ usageKey, index, close }: Props) => {
const intl = useIntl();
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
const { containerId, showOnlyPublished } = useLibraryContext();
const { containerId } = useOptionalLibraryContext();
const { showToast } = useContext(ToastContext);
const { showOnlyPublished } = usePublishedFilterContext();
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);

View File

@@ -6,6 +6,7 @@ import {
initializeMocks, render as baseRender, screen, waitFor,
fireEvent,
} from '@src/testUtils';
import { PublishedFilterContextProvider } from '@src/library-authoring/common/context/PublishedFilterContext';
import { LibraryProvider } from '../common/context/LibraryContext';
import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
import { type ContainerHit, PublishStatus } from '../../search-manager';
@@ -73,12 +74,11 @@ const render = (
path,
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
showOnlyPublished={showOnlyPublished}
>
{children}
</LibraryProvider>
<PublishedFilterContextProvider showOnlyPublished={showOnlyPublished}>
<LibraryProvider libraryId={libraryId}>
{children}
</LibraryProvider>
</PublishedFilterContextProvider>
),
});
};

View File

@@ -18,12 +18,13 @@ import { ToastContext } from '@src/generic/toast-context';
import { useRunOnNextRender } from '@src/utils';
import { useComponentPickerContext } from '@src/library-authoring/common/context/ComponentPickerContext';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useOptionalLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
import { useLibraryRoutes } from '@src/library-authoring/routes';
import BaseCard from '@src/library-authoring/components/BaseCard';
import AddComponentWidget from '@src/library-authoring/components/AddComponentWidget';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
import messages from './messages';
import ContainerDeleter from './ContainerDeleter';
import ContainerRemover from './ContainerRemover';
@@ -38,7 +39,7 @@ export const ContainerMenu = ({ containerKey, displayName, index } : ContainerMe
const intl = useIntl();
const {
libraryId, collectionId, containerId, readOnly,
} = useLibraryContext();
} = useOptionalLibraryContext();
const {
sidebarItemInfo,
closeLibrarySidebar,
@@ -211,7 +212,7 @@ type ContainerCardPreviewProps = {
const ContainerCardPreview = ({ hit }: ContainerCardPreviewProps) => {
const intl = useIntl();
const { showOnlyPublished } = useLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const {
blockType: itemType,
published,
@@ -256,7 +257,7 @@ type ContainerCardProps = {
const ContainerCard = ({ hit } : ContainerCardProps) => {
const { componentPickerMode } = useComponentPickerContext();
const { showOnlyPublished } = useLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const { openContainerInfoSidebar, openItemSidebar, sidebarItemInfo } = useSidebarContext();
const {

View File

@@ -10,7 +10,7 @@ import { type ContainerHit } from '@src/search-manager';
import { useEntityLinks } from '@src/course-libraries/data/apiHooks';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useOptionalLibraryContext } from '../common/context/LibraryContext';
import { useContentFromSearchIndex, useDeleteContainer, useRestoreContainer } from '../data/apiHooks';
import messages from './messages';
@@ -42,7 +42,7 @@ const ContainerDeleter = ({
} = useSidebarContext();
const {
containerId: parentContainerId,
} = useLibraryContext();
} = useOptionalLibraryContext();
const deleteContainerMutation = useDeleteContainer(containerId);
const restoreContainerMutation = useRestoreContainer(containerId);
const { showToast } = useContext(ToastContext);

View File

@@ -1,8 +1,9 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
import { useContext } from 'react';
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 { useContainer, useUpdateContainer } from '../data/apiHooks';
import messages from './messages';
@@ -14,7 +15,8 @@ interface EditableTitleProps {
export const ContainerEditableTitle = ({ containerId, textClassName }: EditableTitleProps) => {
const intl = useIntl();
const { readOnly, showOnlyPublished } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const { data: container } = useContainer(containerId);

View File

@@ -9,6 +9,7 @@ import {
import { ContainerType } from '@src/generic/key-utils';
import type { ToastActionData } from '@src/generic/toast-context';
import { mockContentSearchConfig, mockSearchResult, hydrateSearchResult } from '@src/search-manager/data/api.mock';
import { PublishedFilterContextProvider } from '@src/library-authoring/common/context/PublishedFilterContext';
import {
mockContentLibrary,
mockGetContainerChildren,
@@ -111,19 +112,18 @@ const render = (
path,
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
showOnlyPublished={showOnlyPublished}
>
<SidebarProvider
initialSidebarItemInfo={{
id: containerId,
type: SidebarBodyItemId.ContainerInfo,
}}
>
{children}
</SidebarProvider>
</LibraryProvider>
<PublishedFilterContextProvider showOnlyPublished={showOnlyPublished}>
<LibraryProvider libraryId={libraryId}>
<SidebarProvider
initialSidebarItemInfo={{
id: containerId,
type: SidebarBodyItemId.ContainerInfo,
}}
>
{children}
</SidebarProvider>
</LibraryProvider>
</PublishedFilterContextProvider>
),
});
};
@@ -249,8 +249,8 @@ let mockShowToast: { (message: string, action?: ToastActionData): void; mock?: a
'i',
))).toBeInTheDocument();
}
expect(await screen.queryAllByText('Will Publish').length).toBe(willPublishCount);
expect(await screen.queryAllByText('Draft').length).toBe(4 - willPublishCount);
expect(screen.queryAllByText('Will Publish').length).toBe(willPublishCount);
expect(screen.queryAllByText('Draft').length).toBe(4 - willPublishCount);
// Click on the confirm Cancel button
const publishCancel = await screen.findByRole('button', { name: 'Cancel' });

View File

@@ -17,7 +17,7 @@ import { InfoOutline, MoreVert } from '@openedx/paragon/icons';
import { useClipboard } from '@src/generic/clipboard';
import { ContainerType, 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 ContainerInfoTab,
CONTAINER_INFO_TABS,
@@ -98,12 +98,12 @@ const ContainerActions = ({
hasUnpublishedChanges: boolean,
}) => {
const intl = useIntl();
const { libraryId } = useLibraryContext();
const { libraryId } = useOptionalLibraryContext();
const { componentPickerMode } = useComponentPickerContext();
const { insideUnit, insideSubsection, insideSection } = useLibraryRoutes();
const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
const showOpenButton = !componentPickerMode && !(
const showOpenButton = libraryId && !componentPickerMode && !(
insideUnit || insideSubsection || insideSection
);

View File

@@ -17,7 +17,7 @@ import {
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
import { ManageCollections } from '../generic/manage-collections';
import { useContainer, useUpdateContainerCollections } from '../data/apiHooks';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useOptionalLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import messages from './messages';
@@ -26,7 +26,7 @@ const ContainerOrganize = () => {
const [tagsCollapseIsOpen, ,setTagsCollapseClose, toggleTags] = useToggle(true);
const [collectionsCollapseIsOpen, setCollectionsCollapseOpen, , toggleCollections] = useToggle(true);
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const { sidebarItemInfo, sidebarAction } = useSidebarContext();
const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections;

View File

@@ -8,12 +8,13 @@ import { ToastContext } from '@src/generic/toast-context';
import { getBlockType } from '@src/generic/key-utils';
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useOptionalLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import {
useContainer, useContainerChildren, useRemoveContainerChildren, useUpdateContainerChildren,
} from '@src/library-authoring/data/apiHooks';
import messages from '@src/library-authoring/components/messages';
import { Container } from '@src/library-authoring/data/api';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
type ContainerRemoverProps = {
close: () => void,
@@ -33,7 +34,8 @@ const ContainerRemover = ({
sidebarItemInfo,
closeLibrarySidebar,
} = useSidebarContext();
const { containerId, showOnlyPublished } = useLibraryContext();
const { containerId } = useOptionalLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
const { showToast } = useContext(ToastContext);
const removeContainerMutation = useRemoveContainerChildren(containerId);

View File

@@ -2,7 +2,7 @@ import { Button, useToggle } from '@openedx/paragon';
import { Add } from '@openedx/paragon/icons';
import { type ContainerType } from '../../generic/key-utils';
import { PickLibraryContentModal } from '../add-content';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useOptionalLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
interface FooterActionsProps {
@@ -18,10 +18,10 @@ export const FooterActions = ({
}: FooterActionsProps) => {
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const { openAddContentSidebar } = useSidebarContext();
const { readOnly, setCreateContainerModalType } = useLibraryContext();
const { readOnly, setCreateContainerModalType } = useOptionalLibraryContext();
const addContent = () => {
if (addContentType) {
setCreateContainerModalType(addContentType);
setCreateContainerModalType?.(addContentType);
} else {
openAddContentSidebar();
}

View File

@@ -1,7 +1,7 @@
import { Button } from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { useCallback } from 'react';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useOptionalLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryRoutes } from '../routes';
@@ -16,7 +16,7 @@ export const HeaderActions = ({
infoBtnText,
addContentBtnText,
}: HeaderActionsProps) => {
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
const {
closeLibrarySidebar,
sidebarItemInfo,

View File

@@ -17,6 +17,10 @@ export const mockGetContentLibraryV2List = {
applyMock: () => jest.spyOn(api, 'getContentLibraryV2List').mockResolvedValue(
camelCaseObject(contentLibrariesListV2),
),
applyMockNoPagination: () => jest.spyOn(api, 'getContentLibraryV2List').mockResolvedValue(
camelCaseObject(contentLibrariesListV2.results),
),
applyMockNoPaginationEmpty: () => jest.spyOn(api, 'getContentLibraryV2List').mockResolvedValue([] as api.ContentLibrary[]),
applyMockError: () => jest.spyOn(api, 'getContentLibraryV2List').mockRejectedValue(
createAxiosError({ code: 500, message: 'Internal Error.', path: api.getContentLibraryV2ListApiUrl() }),
),

View File

@@ -266,6 +266,14 @@ export interface GetLibrariesV2CustomParams {
search?: string,
}
export interface GetLibrariesV2CustomParamsNoPagination extends GetLibrariesV2CustomParams {
pagination: false,
}
export interface GetLibrariesV2CustomParamsPagination extends GetLibrariesV2CustomParams {
pagination?: true,
}
export type LibraryAssetResponse = {
path: string,
size: number,
@@ -384,10 +392,27 @@ export async function updateLibraryMetadata(libraryData: UpdateLibraryDataReques
return camelCaseObject(data);
}
function isNoPagination(
params: GetLibrariesV2CustomParams,
): params is GetLibrariesV2CustomParamsNoPagination {
return params.pagination === false;
}
/**
* Get a list of content libraries.
*/
export async function getContentLibraryV2List(customParams: GetLibrariesV2CustomParams): Promise<LibrariesV2Response> {
export async function getContentLibraryV2List(
customParams: GetLibrariesV2CustomParamsNoPagination
): Promise<ContentLibrary[]>;
export async function getContentLibraryV2List(
customParams: GetLibrariesV2CustomParamsPagination
): Promise<LibrariesV2Response>;
export async function getContentLibraryV2List(
customParams: GetLibrariesV2CustomParams
): Promise<LibrariesV2Response | ContentLibrary[]>;
export async function getContentLibraryV2List(
customParams: GetLibrariesV2CustomParams,
): Promise<LibrariesV2Response | ContentLibrary[]> {
// Set default params if not passed in
const customParamsDefaults = {
type: customParams.type || 'complex',
@@ -400,7 +425,16 @@ export async function getContentLibraryV2List(customParams: GetLibrariesV2Custom
const customParamsFormated = snakeCaseObject(customParamsDefaults);
const { data } = await getAuthenticatedHttpClient()
.get(getContentLibraryV2ListApiUrl(), { params: customParamsFormated });
return camelCaseObject(data);
const camel = camelCaseObject(data);
// Narrow the return type based on pagination flag
if (isNoPagination(customParams)) {
// `camel` is known to be an array of ContentLibrary
return camel as ContentLibrary[];
}
// otherwise it matches the paginated response shape
return camel as LibrariesV2Response;
}
/**

View File

@@ -8,6 +8,7 @@ import {
replaceEqualDeep,
keepPreviousData,
skipToken,
UseQueryResult,
} from '@tanstack/react-query';
import { useCallback } from 'react';
import { type MeiliSearch } from 'meilisearch';
@@ -165,8 +166,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,
})
);
@@ -244,13 +244,24 @@ export const useUpdateLibraryMetadata = () => {
/**
* Builds the query to fetch list of V2 Libraries
*/
export const useContentLibraryV2List = (customParams: api.GetLibrariesV2CustomParams) => (
useQuery({
export function useContentLibraryV2List(
customParams: api.GetLibrariesV2CustomParamsPagination
): UseQueryResult<api.LibrariesV2Response, Error>;
export function useContentLibraryV2List(
customParams: api.GetLibrariesV2CustomParamsNoPagination
): UseQueryResult<api.ContentLibrary[], Error>;
export function useContentLibraryV2List(
customParams: api.GetLibrariesV2CustomParams
): UseQueryResult<api.LibrariesV2Response | api.ContentLibrary[], Error>;
export function useContentLibraryV2List(
customParams: api.GetLibrariesV2CustomParams,
): UseQueryResult<api.LibrariesV2Response | api.ContentLibrary[], Error> {
return useQuery({
queryKey: libraryAuthoringQueryKeys.contentLibraryList(customParams),
queryFn: () => api.getContentLibraryV2List(customParams),
placeholderData: keepPreviousData,
})
);
});
}
/** Publish all changes in the library. */
export const useCommitLibraryChanges = () => {
@@ -517,40 +528,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 });
},
});

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useLibraryContext } from '../../common/context/LibraryContext';
import { usePublishedFilterContext } from '@src/library-authoring/common/context/PublishedFilterContext';
import { FilterByPublished, PublishStatus } from '../../../search-manager';
/**
@@ -9,7 +9,7 @@ import { FilterByPublished, PublishStatus } from '../../../search-manager';
* when not relevant.
*/
const LibraryFilterByPublished : React.FC<Record<never, never>> = () => {
const { showOnlyPublished } = useLibraryContext();
const { showOnlyPublished } = usePublishedFilterContext();
if (showOnlyPublished) {
return (

View File

@@ -14,7 +14,7 @@ import {
} from '../../../search-manager';
import { ToastContext } from '../../../generic/toast-context';
import { CollectionMetadata } from '../../data/api';
import { useLibraryContext } from '../../common/context/LibraryContext';
import { useOptionalLibraryContext } from '../../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../../common/context/SidebarContext';
import genericMessages from '../messages';
import messages from './messages';
@@ -119,11 +119,15 @@ const AddToCollectionsDrawer = ({
onClose,
}: CollectionsDrawerProps) => {
const intl = useIntl();
const { libraryId } = useLibraryContext();
const { libraryId } = useOptionalLibraryContext();
const extraFilter = ['type = "collection"'];
if (libraryId) {
extraFilter.push(`context_key = "${libraryId}"`);
}
return (
<SearchContextProvider
extraFilter={[`context_key = "${libraryId}"`, 'type = "collection"']}
extraFilter={extraFilter}
skipUrlUpdate
skipBlockTypeFetch
>
@@ -156,7 +160,7 @@ const EntityCollections = ({ collections, onManageClick }: {
onManageClick: () => void;
}) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
if (!collections?.length) {
return (

View File

@@ -1,7 +1,7 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useOptionalLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import messages from './messages';
@@ -22,7 +22,7 @@ export const PublishDraftButton = ({
onClick,
}: PublishDraftButtonProps) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
const { readOnly } = useOptionalLibraryContext();
return (
<Button

View File

@@ -19,3 +19,9 @@
min-height: 1rem;
min-width: 1rem;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,5 +1,5 @@
export { default as LibraryLayout } from './LibraryLayout';
export { ComponentPicker } from './component-picker';
export { LibraryAndComponentPicker, ComponentPicker } from './component-picker';
export { type SelectedComponent } from './common/context/ComponentPickerContext';
export { CreateLibrary, CreateLibraryModal } from './create-library';
export { CreateLegacyLibrary } from './create-legacy-library';

View File

@@ -0,0 +1,202 @@
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
import { useGetContentHits } from '@src/search-manager';
import {
initializeMocks, render, screen,
} from '@src/testUtils';
import { userEvent } from '@testing-library/user-event';
import { CollectionDropdownFilter } from './CollectionDropdownFilter';
mockContentLibrary.applyMock();
let mockLibsValue: string[] = [];
const mockCollectionsSetValue = jest.fn();
let mockCollectionsValue: string[] = [];
jest.mock('@src/library-authoring/common/context/MultiLibraryContext', () => ({
useMultiLibraryContext: () => ({
selectedLibraries: mockLibsValue,
setSelectedLibraries: () => {},
selectedCollections: mockCollectionsValue,
setSelectedCollections: mockCollectionsSetValue,
}),
}));
// Mock the useGetBlockTypes hook
jest.mock('@src/search-manager', () => ({
useGetBlockTypes: jest.fn().mockReturnValue({ isPending: true, data: null }),
useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));
const renderComponent = () => render(<CollectionDropdownFilter />);
describe('CollectionDropdownFilter', () => {
beforeEach(() => {
initializeMocks();
mockLibsValue = ['some'];
});
it('should be disabled if no library is selected', async () => {
mockLibsValue = [];
renderComponent();
const dropdownTrigger = await screen.findByRole('button');
expect(dropdownTrigger).toBeDisabled();
});
it('should be disabled if more than one library is selected', async () => {
mockLibsValue = ['some', 'another'];
renderComponent();
const dropdownTrigger = await screen.findByRole('button');
expect(dropdownTrigger).toBeDisabled();
});
it('should render the loading status', async () => {
const user = userEvent.setup();
renderComponent();
const dropdownTrigger = await screen.findByRole('button');
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
expect(await screen.findByText('Loading...')).toBeInTheDocument();
});
it('should render the empty list', async () => {
const user = userEvent.setup();
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [],
estimatedTotalHits: 0,
},
});
renderComponent();
const dropdownTrigger = await screen.findByRole('button');
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
expect(await screen.findByText('No collections found!')).toBeInTheDocument();
});
it('should render CollectionDropdownFilter with dropdown options', async () => {
const user = userEvent.setup();
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [{
display_name: 'Test collection',
block_id: 'test-collection',
},
{
display_name: 'Sample Taxonomy Course',
block_id: 'sample-taxonomy-course',
}],
estimatedTotalHits: 0,
},
});
renderComponent();
const dropdownTrigger = await screen.findByRole('button');
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
const item = await screen.findByRole('checkbox', { name: 'Test collection' });
expect(item).toBeInTheDocument();
await user.click(item);
const passedFunction = mockCollectionsSetValue.mock.calls[0][0];
expect(passedFunction([])).toEqual(['test-collection']);
});
it('toggle selected value', async () => {
const user = userEvent.setup();
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [{
display_name: 'Test collection',
block_id: 'test-collection',
},
{
display_name: 'Sample Taxonomy Course',
block_id: 'sample-taxonomy-course',
}],
estimatedTotalHits: 0,
},
});
renderComponent();
const dropdownTrigger = await screen.findByRole('button');
await user.click(dropdownTrigger);
const item = await screen.findByRole('checkbox', { name: 'Test collection' });
await user.click(item);
const passedFunction = mockCollectionsSetValue.mock.calls[0][0];
// Should remove it from list if already selected, i.e., it means user unselected it.
expect(passedFunction([
'test-collection',
'sample-taxonomy-course',
])).toEqual(['sample-taxonomy-course']);
});
it('should update label to collection if one is selected', async () => {
mockCollectionsValue = ['sample-taxonomy-course'];
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [{
display_name: 'Test collection',
block_id: 'test-collection',
},
{
display_name: 'Sample Taxonomy Course',
block_id: 'sample-taxonomy-course',
}],
estimatedTotalHits: 0,
},
});
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: 'Sample Taxonomy Course' });
expect(dropdownTrigger).toBeInTheDocument();
});
it('should update label to n collections if more than one is selected', async () => {
mockCollectionsValue = ['test-collection', 'sample-taxonomy-course'];
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: '2 Collections' });
expect(dropdownTrigger).toBeInTheDocument();
});
it('should filter list by search', async () => {
const user = userEvent.setup();
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [{
display_name: 'Test collection',
block_id: 'test-collection',
},
{
display_name: 'Sample Taxonomy Course',
block_id: 'sample-taxonomy-course',
}],
estimatedTotalHits: 0,
},
});
renderComponent();
const dropdownTrigger = await screen.findByRole('button');
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
expect(await screen.findByText('Sample Taxonomy Course')).toBeInTheDocument();
const searchInput = await screen.findByPlaceholderText('Search Collection Name');
await user.type(searchInput, 'Test');
expect(screen.queryByText('Sample Taxonomy Course')).not.toBeInTheDocument();
const clearBtn = await screen.findByRole('button', { name: 'clear search' });
await user.click(clearBtn);
expect(await screen.findByText('Sample Taxonomy Course')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,147 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ButtonGroup, Dropdown, Form, OverlayTrigger, Scrollable, SearchField, Tooltip,
} from '@openedx/paragon';
import { Folder } from '@openedx/paragon/icons';
import Loading from '@src/generic/Loading';
import { useMultiLibraryContext } from '@src/library-authoring/common/context/MultiLibraryContext';
import { useGetContentHits } from '@src/search-manager';
import { truncate } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import messages from './messages';
interface CollectionItemsProps {
isPending: boolean;
data?: Record<string, any>[];
onChange: (id: string) => void;
}
const CollectionItems = ({ isPending, data, onChange }: CollectionItemsProps) => {
const { selectedCollections } = useMultiLibraryContext();
if (isPending) {
return <Loading />;
}
if (!data || data.length === 0) {
return (
<span className="p-3">
<FormattedMessage {...messages.collectionFilterBtnEmpty} />
</span>
);
}
return (
<Scrollable
className="m-0 p-0"
style={{ maxHeight: '25vh' }}
>
{data.map((collection) => (
<Dropdown.Item
key={collection.block_id}
as={Form.Checkbox}
value={collection.block_id}
onChange={() => onChange(collection.block_id)}
className="py-2 my-1 overflow-auto"
checked={selectedCollections.includes(collection.block_id)}
>
<div>
{truncate(collection.display_name, { length: 50 })}
</div>
</Dropdown.Item>
))}
</Scrollable>
);
};
export const CollectionDropdownFilter = () => {
const intl = useIntl();
const [search, setSearch] = useState('');
const { selectedLibraries, selectedCollections, setSelectedCollections } = useMultiLibraryContext();
const [label, setLabel] = useState(intl.formatMessage(messages.librariesFilterBtnText));
const { data: baseData, isPending } = useGetContentHits(
[
'type = "collection"',
`context_key = "${selectedLibraries[0]}"`,
'last_published IS NOT NULL',
],
selectedLibraries.length === 1,
['block_id', 'display_name'],
100,
false,
);
/** Filter the data based on search input */
const data = useMemo(() => {
if (!search.trim()) {
return baseData?.hits;
}
return baseData?.hits.filter((hit) => hit.display_name.toLowerCase().includes(search.trim().toLowerCase()));
}, [search, baseData]);
const onChange = (libraryId: string) => {
setSelectedCollections?.((prev) => {
if (prev.includes(libraryId)) {
return prev.filter((id) => id !== libraryId);
}
return [...prev, libraryId];
});
};
useEffect(() => {
const baseName = '';
if (!selectedCollections.length) {
setLabel(baseName);
} else if (selectedCollections.length === 1) {
setLabel(data?.find((lib) => lib.block_id === selectedCollections[0])?.display_name || baseName);
} else if (selectedCollections.length > 1) {
setLabel(intl.formatMessage(messages.collectionsFilterBtnCount, { count: selectedCollections.length }));
}
}, [label, selectedCollections, data]);
return (
<Dropdown
id="collection-filter-dropdown"
as={ButtonGroup}
autoClose="outside"
disabled={selectedLibraries.length !== 1}
>
<OverlayTrigger
placement="auto"
overlay={(
<Tooltip variant="light" id="library-filter-tooltip">
{selectedLibraries.length !== 1
? intl.formatMessage(messages.collectionFilterBtnHelp)
: label || intl.formatMessage(messages.collectionFilterBtnText)}
</Tooltip>
)}
>
<Dropdown.Toggle
id="collection-filter-dropdown-toggle"
iconBefore={Folder}
className="text-overflow text-primary-500 py-2 px-4 mr-2"
disabled={selectedLibraries.length !== 1}
size="sm"
>
{!label && (
<span className="sr-only">
{intl.formatMessage(messages.collectionFilterBtnText)}
</span>
)}
{truncate(label, { length: 30 })}
</Dropdown.Toggle>
</OverlayTrigger>
<Dropdown.Menu className="my-1">
<SearchField
onSubmit={setSearch}
onChange={setSearch}
onClear={() => setSearch('')}
value={search}
placeholder={intl.formatMessage(messages.collectionFilterBtnPlaceholder)}
className="mx-1 border-0"
/>
<Dropdown.Divider className="mb-0" />
<CollectionItems isPending={isPending} data={data} onChange={onChange} />
</Dropdown.Menu>
</Dropdown>
);
};

View File

@@ -0,0 +1,128 @@
import { mockContentLibrary, mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks';
import {
initializeMocks, render, screen, waitFor,
} from '@src/testUtils';
import { userEvent } from '@testing-library/user-event';
import { LibraryDropdownFilter } from './LibraryDropdownFilter';
mockContentLibrary.applyMock();
const mockSetValue = jest.fn();
let mockValue: string[] = [];
jest.mock('@src/library-authoring/common/context/MultiLibraryContext', () => ({
useMultiLibraryContext: () => ({
selectedLibraries: mockValue,
setSelectedLibraries: mockSetValue,
}),
}));
const renderComponent = () => render(<LibraryDropdownFilter />);
describe('LibraryDropdownFilter', () => {
beforeEach(() => {
initializeMocks();
mockValue = [];
});
it('should render the loading status', async () => {
const user = userEvent.setup();
mockGetContentLibraryV2List.applyMockLoading();
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: 'All libraries' });
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
expect(await screen.findByText('Loading...')).toBeInTheDocument();
});
it('should render the empty list', async () => {
const user = userEvent.setup();
mockGetContentLibraryV2List.applyMockNoPaginationEmpty();
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: 'All libraries' });
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
expect(await screen.findByText('No libraries found!')).toBeInTheDocument();
});
it('should render LibraryDropdownFilter with dropdown options', async () => {
const user = userEvent.setup();
mockGetContentLibraryV2List.applyMockNoPagination();
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: 'All libraries' });
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
const item = await screen.findByRole('checkbox', { name: 'Test Library 1' });
expect(item).toBeInTheDocument();
await user.click(item);
const passedFunction = mockSetValue.mock.calls[0][0];
expect(passedFunction([])).toEqual(['lib:SampleTaxonomyOrg1:TL1']);
});
it('toggle selected value', async () => {
const user = userEvent.setup();
mockGetContentLibraryV2List.applyMockNoPagination();
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: 'All libraries' });
await user.click(dropdownTrigger);
const item = await screen.findByRole('checkbox', { name: 'Test Library 1' });
await user.click(item);
const passedFunction = mockSetValue.mock.calls[0][0];
// Should remove it from list if already selected, i.e., it means user unselected it.
expect(passedFunction([
'lib:SampleTaxonomyOrg1:TL1',
'lib:SampleTaxonomyOrg1:TL2',
])).toEqual(['lib:SampleTaxonomyOrg1:TL2']);
});
it('should update label to library if one is selected', async () => {
mockGetContentLibraryV2List.applyMockNoPagination();
mockValue = ['lib:SampleTaxonomyOrg1:TL1'];
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: 'Test Library 1' });
expect(dropdownTrigger).toBeInTheDocument();
});
it('should update label to n libraries if more than one is selected', async () => {
mockGetContentLibraryV2List.applyMockNoPagination();
mockValue = ['lib:SampleTaxonomyOrg1:TL1', 'lib:SampleTaxonomyOrg1:AL1'];
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: '2 Libraries' });
expect(dropdownTrigger).toBeInTheDocument();
});
it('should filter list by search', async () => {
const user = userEvent.setup();
const mockApi = mockGetContentLibraryV2List.applyMockNoPagination();
renderComponent();
const dropdownTrigger = await screen.findByRole('button', { name: 'All libraries' });
expect(dropdownTrigger).toBeInTheDocument();
await user.click(dropdownTrigger);
const searchInput = await screen.findByPlaceholderText('Search Library Name');
await user.type(searchInput, 'Test Library');
await waitFor(() => expect(mockApi).toHaveBeenLastCalledWith({
pagination: false,
search: 'Test Library',
}), { timeout: 600 });
const clearBtn = await screen.findByRole('button', { name: 'clear search' });
await user.click(clearBtn);
await waitFor(() => expect(mockApi).toHaveBeenLastCalledWith({
pagination: false,
search: '',
}), { timeout: 600 });
});
});

View File

@@ -0,0 +1,126 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ButtonGroup, Dropdown, Form, OverlayTrigger, Scrollable, SearchField, Tooltip,
} from '@openedx/paragon';
import { Newsstand } from '@openedx/paragon/icons';
import Loading from '@src/generic/Loading';
import { useMultiLibraryContext } from '@src/library-authoring/common/context/MultiLibraryContext';
import { ContentLibrary } from '@src/library-authoring/data/api';
import { useContentLibraryV2List } from '@src/library-authoring/data/apiHooks';
import { debounce, truncate } from 'lodash';
import { useCallback, useEffect, useState } from 'react';
import messages from './messages';
interface LibraryItemsProps {
isPending: boolean;
data?: ContentLibrary[];
onChange: (id: string) => void;
}
const LibraryItems = ({ isPending, data, onChange }: LibraryItemsProps) => {
const { selectedLibraries } = useMultiLibraryContext();
if (isPending) {
return <Loading />;
}
if (!data || data.length === 0) {
return (
<span className="p-3">
<FormattedMessage {...messages.librariesFilterBtnEmpty} />
</span>
);
}
return (
<Scrollable
className="m-0 p-0"
style={{ maxHeight: '25vh' }}
>
{data.map((library) => (
<Dropdown.Item
key={library.id}
as={Form.Checkbox}
value={library.id}
onChange={() => onChange(library.id)}
className="py-2 my-1 overflow-auto"
checked={selectedLibraries.includes(library.id)}
>
<div>
{truncate(library.title, { length: 50 })}
</div>
</Dropdown.Item>
))}
</Scrollable>
);
};
export const LibraryDropdownFilter = () => {
const intl = useIntl();
const [search, setSearch] = useState('');
const { selectedLibraries, setSelectedLibraries } = useMultiLibraryContext();
const [label, setLabel] = useState(intl.formatMessage(messages.librariesFilterBtnText));
const { isPending, data } = useContentLibraryV2List({ pagination: false, search });
const handleSearch = useCallback(
// Perform search after 500ms
debounce((term) => setSearch(term.trim()), 500),
[setSearch],
);
const onChange = (libraryId: string) => {
setSelectedLibraries?.((prev) => {
if (prev.includes(libraryId)) {
return prev.filter((id) => id !== libraryId);
}
return [...prev, libraryId];
});
};
useEffect(() => {
const baseName = intl.formatMessage(messages.librariesFilterBtnText);
if (!selectedLibraries.length) {
setLabel(baseName);
} else if (selectedLibraries.length === 1) {
setLabel(data?.find((lib) => lib.id === selectedLibraries[0])?.title || baseName);
} else if (selectedLibraries.length > 1) {
setLabel(intl.formatMessage(messages.librariesFilterBtnCount, { count: selectedLibraries.length }));
}
}, [label, selectedLibraries, data]);
return (
<Dropdown
id="library-filter-dropdown"
as={ButtonGroup}
autoClose="outside"
>
<OverlayTrigger
placement="auto"
overlay={(
<Tooltip variant="light" id="library-filter-tooltip">
{label}
</Tooltip>
)}
>
<Dropdown.Toggle
id="library-filter-dropdown-toggle"
iconBefore={Newsstand}
className="text-overflow text-primary-500 p-2 px-4 mr-2"
>
{truncate(label, { length: 30 })}
</Dropdown.Toggle>
</OverlayTrigger>
<Dropdown.Menu className="my-1">
<SearchField
onSubmit={handleSearch}
onChange={handleSearch}
onClear={() => setSearch('')}
value={search}
placeholder={intl.formatMessage(messages.librariesFilterBtnPlaceholder)}
className="mx-1 border-0"
/>
<Dropdown.Divider className="mb-0" />
<LibraryItems isPending={isPending} data={data} onChange={onChange} />
</Dropdown.Menu>
</Dropdown>
);
};

View File

@@ -0,0 +1,33 @@
import { ActionRow } from '@openedx/paragon';
import LibraryFilterByPublished from '@src/library-authoring/generic/filter-by-published';
import { useLibraryRoutes } from '@src/library-authoring/routes';
import {
ClearFiltersButton, FilterByBlockType, FilterByTags, SearchKeywordsField, SearchSortWidget,
} from '@src/search-manager';
import { FiltersProps } from '.';
export const MainFilters = ({ onlyOneType }: FiltersProps) => {
const {
insideCollections,
insideUnits,
} = useLibraryRoutes();
return (
<ActionRow className="my-3">
<SearchKeywordsField className="mr-3" />
<FilterByTags />
{!(onlyOneType) && <FilterByBlockType />}
<LibraryFilterByPublished key={
// It is necessary to re-render `LibraryFilterByPublished` every time `FilterByBlockType`
// appears or disappears, this is because when the menu is opened it is rendered
// in a previous state, causing an inconsistency in its position.
// By changing the key we can re-render the component.
!(insideCollections || insideUnits) ? 'filter-published-1' : 'filter-published-2'
}
/>
<ClearFiltersButton />
<ActionRow.Spacer />
<SearchSortWidget />
</ActionRow>
);
};

View File

@@ -0,0 +1,46 @@
import {
IconButton, Stack, useToggle,
} from '@openedx/paragon';
import {
ClearFiltersButton, FilterByBlockType, FilterByTags, SearchKeywordsField,
} from '@src/search-manager';
import { FilterList } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useMultiLibraryContext } from '@src/library-authoring/common/context/MultiLibraryContext';
import { LibraryDropdownFilter } from './LibraryDropdownFilter';
import messages from './messages';
import { FiltersProps } from '.';
import { CollectionDropdownFilter } from './CollectionDropdownFilter';
export const SidebarFilters = ({ onlyOneType }: FiltersProps) => {
const intl = useIntl();
const [isOn,,, toggle] = useToggle(false);
const { selectedCollections, setSelectedCollections } = useMultiLibraryContext();
return (
<Stack gap={3} className="my-3">
<Stack direction="horizontal" gap={1}>
<LibraryDropdownFilter />
<SearchKeywordsField />
<IconButton
onClick={toggle}
alt={intl.formatMessage(messages.additionalFilterBtnAltText)}
size="md"
src={FilterList}
className="rounded-sm border ml-2"
/>
</Stack>
{isOn && (
<Stack direction="horizontal">
{!(onlyOneType) && <FilterByBlockType />}
<FilterByTags />
<CollectionDropdownFilter />
<ClearFiltersButton
onClear={() => setSelectedCollections([])}
canClear={selectedCollections.length > 0}
/>
</Stack>
)}
</Stack>
);
};

View File

@@ -0,0 +1,3 @@
export interface FiltersProps {
onlyOneType: boolean
}

View File

@@ -0,0 +1,56 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
librariesFilterBtnText: {
id: 'course-authoring.library-authoring.library-filters.libraries.filter.btn',
defaultMessage: 'All libraries',
description: 'Button text for libraries filter',
},
collectionFilterBtnText: {
id: 'course-authoring.library-authoring.library-filters.collection.filter.btn',
defaultMessage: 'Collections filter',
description: 'Button aria label text for collections filter',
},
collectionFilterBtnHelp: {
id: 'course-authoring.library-authoring.library-filters.collection.filter.btn-help',
defaultMessage: 'Select only one library to filter by collection',
description: 'Tooltip when more than one library is selected and collection filter is disabled',
},
librariesFilterBtnPlaceholder: {
id: 'course-authoring.library-authoring.library-filters.libraries.filter.placeholder',
defaultMessage: 'Search Library Name',
description: 'Placeholder text for libraries filter',
},
collectionFilterBtnPlaceholder: {
id: 'course-authoring.library-authoring.library-filters.collection.filter.placeholder',
defaultMessage: 'Search Collection Name',
description: 'Placeholder text for collection filter',
},
collectionFilterBtnEmpty: {
id: 'course-authoring.library-authoring.library-filters.collection.filter.empty',
defaultMessage: 'No collections found!',
description: 'When no collections are found',
},
librariesFilterBtnEmpty: {
id: 'course-authoring.library-authoring.library-filters.libraries.filter.empty',
defaultMessage: 'No libraries found!',
description: 'When no libraries are found',
},
librariesFilterBtnCount: {
id: 'course-authoring.library-authoring.library-filters.libraries.filter.count',
defaultMessage: '{count} Libraries',
description: 'When more than 1 libraries are selected',
},
collectionsFilterBtnCount: {
id: 'course-authoring.library-authoring.library-filters.collection.filter.count',
defaultMessage: '{count} Collections',
description: 'When more than 1 Collections are selected',
},
additionalFilterBtnAltText: {
id: 'course-authoring.library-authoring.library-filters.additional.filter.alt-btn',
defaultMessage: 'See more',
description: 'Alt text for more fitlers',
},
});
export default messages;

Some files were not shown because too many files have changed in this diff Show More