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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -57,6 +57,9 @@ const CourseAuthoringPage = ({ children }: Props) => {
|
||||
org={courseOrg}
|
||||
title={courseTitle}
|
||||
contextId={courseId}
|
||||
containerProps={{
|
||||
size: 'fluid',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
166
src/course-outline/outline-sidebar/AddSidebar.test.tsx
Normal file
166
src/course-outline/outline-sidebar/AddSidebar.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
235
src/course-outline/outline-sidebar/AddSidebar.tsx
Normal file
235
src/course-outline/outline-sidebar/AddSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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({
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
23
src/hooks.ts
23
src/hooks.ts
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
54
src/library-authoring/common/context/MultiLibraryContext.tsx
Normal file
54
src/library-authoring/common/context/MultiLibraryContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ const EmptyState = ({ hasSearchQuery }: EmptyStateProps) => (
|
||||
);
|
||||
|
||||
interface SelectLibraryProps {
|
||||
selectedLibrary: string;
|
||||
selectedLibrary?: string;
|
||||
setSelectedLibrary: (libraryKey: string) => void;
|
||||
itemType: ContentType;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { ComponentPicker } from './ComponentPicker';
|
||||
export { LibraryAndComponentPicker, ComponentPicker } from './ComponentPicker';
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,3 +19,9 @@
|
||||
min-height: 1rem;
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
126
src/library-authoring/library-filters/LibraryDropdownFilter.tsx
Normal file
126
src/library-authoring/library-filters/LibraryDropdownFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
src/library-authoring/library-filters/MainFilters.tsx
Normal file
33
src/library-authoring/library-filters/MainFilters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
46
src/library-authoring/library-filters/SidebarFilters.tsx
Normal file
46
src/library-authoring/library-filters/SidebarFilters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
3
src/library-authoring/library-filters/index.tsx
Normal file
3
src/library-authoring/library-filters/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface FiltersProps {
|
||||
onlyOneType: boolean
|
||||
}
|
||||
56
src/library-authoring/library-filters/messages.ts
Normal file
56
src/library-authoring/library-filters/messages.ts
Normal 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
Reference in New Issue
Block a user