diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index 270e4dcbf3..84e50c70fc 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -485,6 +485,8 @@ describe('', () => {
expect(mockResult0.display_name).toStrictEqual(displayName);
await renderLibraryPage();
+ waitFor(() => expect(screen.getAllByTestId('component-card-menu-toggle').length).toBeGreaterThan(0));
+
// Open menu
fireEvent.click((await screen.findAllByTestId('component-card-menu-toggle'))[0]);
// Click add to collection
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index d52cbbb014..57ca633ab1 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -148,6 +148,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
libraryData,
isLoadingLibraryData,
componentPickerMode,
+ restrictToLibrary,
showOnlyPublished,
sidebarComponentInfo,
openInfoSidebar,
@@ -196,7 +197,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
}
};
- const breadcumbs = componentPickerMode ? (
+ const breadcumbs = componentPickerMode && !restrictToLibrary ? (
{
@@ -25,7 +26,16 @@ const LibraryLayout = () => {
}
return (
-
+ ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
+ * Sidebar > AddContentContainer > ComponentPickerModal */
+ componentPickerModal={ComponentPickerModal}
+ >
{
const intl = useIntl();
- const { collectionId } = useParams();
const {
libraryId,
+ collectionId,
openCreateCollectionModal,
openComponentEditor,
+ componentPickerModal,
} = useLibraryContext();
const createBlockMutation = useCreateLibraryBlock();
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
@@ -75,6 +78,8 @@ const AddContentContainer = () => {
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit);
+ const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
+
const parsePasteErrorMsg = (error: any) => {
let errMsg: string;
try {
@@ -94,6 +99,14 @@ const AddContentContainer = () => {
icon: BookOpen,
blockType: 'collection',
};
+
+ const libraryContentButtonData = {
+ name: intl.formatMessage(messages.libraryContentButton),
+ disabled: false,
+ icon: Folder,
+ blockType: 'libraryContent',
+ };
+
const contentTypes = [
{
name: intl.formatMessage(messages.textTypeButton),
@@ -186,6 +199,8 @@ const AddContentContainer = () => {
onPaste();
} else if (blockType === 'collection') {
openCreateCollectionModal();
+ } else if (blockType === 'libraryContent') {
+ showAddLibraryContentModal();
} else {
onCreateBlock(blockType);
}
@@ -197,7 +212,19 @@ const AddContentContainer = () => {
return (
- {!collectionId && }
+ {collectionId ? (
+ componentPickerModal && (
+ <>
+
+
+ >
+ )
+ ) : (
+
+ )}
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
diff --git a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx
new file mode 100644
index 0000000000..d59baa5692
--- /dev/null
+++ b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx
@@ -0,0 +1,106 @@
+import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
+import {
+ fireEvent,
+ render as baseRender,
+ waitFor,
+ screen,
+ initializeMocks,
+} from '../../testUtils';
+import mockResult from '../__mocks__/library-search.json';
+import { LibraryProvider } from '../common/context';
+import { ComponentPickerModal } from '../component-picker';
+import * as api from '../data/api';
+import {
+ mockContentLibrary,
+ mockGetCollectionMetadata,
+} from '../data/api.mocks';
+import { PickLibraryContentModal } from './PickLibraryContentModal';
+
+initializeMocks();
+mockContentSearchConfig.applyMock();
+mockContentLibrary.applyMock();
+mockGetCollectionMetadata.applyMock();
+mockSearchResult(mockResult);
+
+const { libraryId } = mockContentLibrary;
+
+const onClose = jest.fn();
+let mockShowToast: (message: string) => void;
+
+const render = () => baseRender(, {
+ path: '/library/:libraryId/collection/:collectionId/*',
+ params: { libraryId, collectionId: 'collectionId' },
+ extraWrapper: ({ children }) => (
+
+ {children}
+
+ ),
+});
+
+describe('', () => {
+ beforeEach(() => {
+ const mocks = initializeMocks();
+ mockShowToast = mocks.mockShowToast;
+ });
+
+ it('can pick components from the modal', async () => {
+ const mockAddComponentsToCollection = jest.fn();
+ jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
+
+ render();
+
+ // Wait for the content library to load
+ await waitFor(() => {
+ expect(screen.getByText('Test Library')).toBeInTheDocument();
+ expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
+ });
+
+ // Select the first component
+ fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
+ expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
+
+ fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
+
+ await waitFor(() => {
+ expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
+ libraryId,
+ 'collectionId',
+ ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
+ );
+ expect(onClose).toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith('Content linked successfully.');
+ });
+ });
+
+ it('show error when api call fails', async () => {
+ const mockAddComponentsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
+ jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
+ render();
+
+ // Wait for the content library to load
+ await waitFor(() => {
+ expect(screen.getByText('Test Library')).toBeInTheDocument();
+ expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
+ });
+
+ // Select the first component
+ fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
+ expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
+
+ fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
+
+ await waitFor(() => {
+ expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
+ libraryId,
+ 'collectionId',
+ ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
+ );
+ expect(onClose).toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.');
+ });
+ });
+});
diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx
new file mode 100644
index 0000000000..f8a8ac3965
--- /dev/null
+++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx
@@ -0,0 +1,81 @@
+import React, { useCallback, useContext, useState } from 'react';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+import { ActionRow, Button } from '@openedx/paragon';
+
+import { ToastContext } from '../../generic/toast-context';
+import { type SelectedComponent, useLibraryContext } from '../common/context';
+import { useAddComponentsToCollection } from '../data/apiHooks';
+import messages from './messages';
+
+interface PickLibraryContentModalFooterProps {
+ onSubmit: () => void;
+ selectedComponents: SelectedComponent[];
+}
+
+const PickLibraryContentModalFooter: React.FC = ({
+ onSubmit,
+ selectedComponents,
+}) => (
+
+
+
+
+
+);
+
+interface PickLibraryContentModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export const PickLibraryContentModal: React.FC = ({
+ isOpen,
+ onClose,
+}) => {
+ const intl = useIntl();
+
+ const {
+ libraryId,
+ collectionId,
+ /** We need to get it as a reference instead of directly importing it to avoid the import cycle:
+ * ComponentPickerModal > ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
+ * Sidebar > AddContentContainer > ComponentPickerModal */
+ componentPickerModal: ComponentPickerModal,
+ } = useLibraryContext();
+
+ // istanbul ignore if: this should never happen
+ if (!collectionId || !ComponentPickerModal) {
+ throw new Error('libraryId and componentPickerModal are required');
+ }
+
+ const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
+
+ const { showToast } = useContext(ToastContext);
+
+ const [selectedComponents, setSelectedComponents] = useState([]);
+
+ const onSubmit = useCallback(() => {
+ const usageKeys = selectedComponents.map(({ usageKey }) => usageKey);
+ onClose();
+ updateComponentsMutation.mutateAsync(usageKeys)
+ .then(() => {
+ showToast(intl.formatMessage(messages.successAssociateComponentMessage));
+ })
+ .catch(() => {
+ showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
+ });
+ }, [selectedComponents]);
+
+ return (
+ }
+ />
+ );
+};
diff --git a/src/library-authoring/add-content/index.ts b/src/library-authoring/add-content/index.ts
index ae4e4ac7b1..e1495c3bd9 100644
--- a/src/library-authoring/add-content/index.ts
+++ b/src/library-authoring/add-content/index.ts
@@ -1,2 +1,3 @@
+// eslint-disable-next-line import/prefer-default-export
export { default as AddContentContainer } from './AddContentContainer';
export { default as AddContentHeader } from './AddContentHeader';
diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts
index 6a8946aae5..6971849d7f 100644
--- a/src/library-authoring/add-content/messages.ts
+++ b/src/library-authoring/add-content/messages.ts
@@ -6,6 +6,21 @@ const messages = defineMessages({
defaultMessage: 'Collection',
description: 'Content of button to create a Collection.',
},
+ libraryContentButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.library-content',
+ defaultMessage: 'Existing Library Content',
+ description: 'Content of button to add existing library content to a collection.',
+ },
+ addToCollectionButton: {
+ id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-collection',
+ defaultMessage: 'Add to Collection',
+ description: 'Button to add library content to a collection.',
+ },
+ selectedComponents: {
+ id: 'course-authoring.library-authoring.add-content.selected-components',
+ defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}',
+ description: 'Title for selected components in library.',
+ },
textTypeButton: {
id: 'course-authoring.library-authoring.add-content.buttons.types.text',
defaultMessage: 'Text',
@@ -51,6 +66,11 @@ const messages = defineMessages({
defaultMessage: 'There was an error creating the content.',
description: 'Message when creation of content in library is on error',
},
+ successAssociateComponentMessage: {
+ id: 'course-authoring.library-authoring.associate-collection-content.success.text',
+ defaultMessage: 'Content linked successfully.',
+ description: 'Message when linking of content to a collection in library is success',
+ },
errorAssociateComponentMessage: {
id: 'course-authoring.library-authoring.associate-collection-content.error.text',
defaultMessage: 'There was an error linking the content to this collection.',
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index b16082bc0f..be229065f5 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -6,10 +6,11 @@ import React, {
useState,
} from 'react';
+import type { ComponentPickerModal } from '../component-picker';
import type { ContentLibrary } from '../data/api';
import { useContentLibrary } from '../data/apiHooks';
-interface SelectedComponent {
+export interface SelectedComponent {
usageKey: string;
blockType: string;
}
@@ -23,6 +24,12 @@ type NoComponentPickerType = {
selectedComponents?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
+ restrictToLibrary?: never;
+ /** The component picker modal to use. We need to pass it as a reference instead of
+ * directly importing it to avoid the import cycle:
+ * ComponentPickerModal > ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
+ * Sidebar > AddContentContainer > ComponentPickerModal */
+ componentPickerModal?: typeof ComponentPickerModal;
};
type ComponentPickerSingleType = {
@@ -31,6 +38,8 @@ type ComponentPickerSingleType = {
selectedComponents?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
+ restrictToLibrary: boolean;
+ componentPickerModal?: never;
};
type ComponentPickerMultipleType = {
@@ -39,6 +48,8 @@ type ComponentPickerMultipleType = {
selectedComponents: SelectedComponent[];
addComponentToSelectedComponents: ComponentSelectedEvent;
removeComponentFromSelectedComponents: ComponentSelectedEvent;
+ restrictToLibrary: boolean;
+ componentPickerModal?: never;
};
type ComponentPickerType = NoComponentPickerType | ComponentPickerSingleType | ComponentPickerMultipleType;
@@ -109,18 +120,24 @@ type NoComponentPickerProps = {
componentPickerMode?: undefined;
onComponentSelected?: never;
onChangeComponentSelection?: never;
+ restrictToLibrary?: never;
+ componentPickerModal?: typeof ComponentPickerModal;
};
export type ComponentPickerSingleProps = {
componentPickerMode: 'single';
onComponentSelected: ComponentSelectedEvent;
onChangeComponentSelection?: never;
+ restrictToLibrary?: boolean;
+ componentPickerModal?: never;
};
export type ComponentPickerMultipleProps = {
componentPickerMode: 'multiple';
onComponentSelected?: never;
onChangeComponentSelection?: ComponentSelectionChangedEvent;
+ restrictToLibrary?: boolean;
+ componentPickerModal?: never;
};
type ComponentPickerProps = NoComponentPickerProps | ComponentPickerSingleProps | ComponentPickerMultipleProps;
@@ -133,6 +150,7 @@ type LibraryProviderProps = {
showOnlyPublished?: boolean;
/** Only used for testing */
initialSidebarComponentInfo?: SidebarComponentInfo;
+ componentPickerModal?: typeof ComponentPickerModal;
} & ComponentPickerProps;
/**
@@ -143,10 +161,12 @@ export const LibraryProvider = ({
libraryId,
collectionId: collectionIdProp,
componentPickerMode,
+ restrictToLibrary = false,
onComponentSelected,
onChangeComponentSelection,
showOnlyPublished = false,
initialSidebarComponentInfo,
+ componentPickerModal,
}: LibraryProviderProps) => {
const [collectionId, setCollectionId] = useState(collectionIdProp);
const [sidebarComponentInfo, setSidebarComponentInfo] = useState(
@@ -253,10 +273,17 @@ export const LibraryProvider = ({
closeComponentEditor,
resetSidebarAdditionalActions,
};
+ if (!componentPickerMode) {
+ return {
+ ...contextValue,
+ componentPickerModal,
+ };
+ }
if (componentPickerMode === 'single') {
return {
...contextValue,
componentPickerMode,
+ restrictToLibrary,
onComponentSelected,
};
}
@@ -264,6 +291,7 @@ export const LibraryProvider = ({
return {
...contextValue,
componentPickerMode,
+ restrictToLibrary,
selectedComponents,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
@@ -279,6 +307,7 @@ export const LibraryProvider = ({
isLoadingLibraryData,
showOnlyPublished,
componentPickerMode,
+ restrictToLibrary,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
@@ -300,6 +329,7 @@ export const LibraryProvider = ({
openComponentEditor,
closeComponentEditor,
resetSidebarAdditionalActions,
+ componentPickerModal,
]);
return (
diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx
index 72cfd7cc34..4c06a9846e 100644
--- a/src/library-authoring/component-picker/ComponentPicker.test.tsx
+++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx
@@ -51,7 +51,10 @@ describe('', () => {
// Wait for the content library to load
await screen.findByText(/Change Library/i);
- expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('Test Library 1')).toBeInTheDocument();
+ expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
+ });
// Click the add component from the component card
fireEvent.click(screen.queryAllByRole('button', { name: 'Add' })[0]);
diff --git a/src/library-authoring/component-picker/ComponentPicker.tsx b/src/library-authoring/component-picker/ComponentPicker.tsx
index b40c2bd9af..8f8cc77bd1 100644
--- a/src/library-authoring/component-picker/ComponentPicker.tsx
+++ b/src/library-authoring/component-picker/ComponentPicker.tsx
@@ -35,18 +35,22 @@ const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selecti
window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*');
};
-type ComponentPickerProps = {
- componentPickerMode?: 'single',
- onComponentSelected?: ComponentSelectedEvent,
- onChangeComponentSelection?: never,
-} | {
- componentPickerMode: 'multiple'
- onComponentSelected?: never,
- onChangeComponentSelection?: ComponentSelectionChangedEvent,
-};
+type ComponentPickerProps = { libraryId?: string } & (
+ {
+ componentPickerMode?: 'single',
+ onComponentSelected?: ComponentSelectedEvent,
+ onChangeComponentSelection?: never,
+ } | {
+ componentPickerMode: 'multiple'
+ onComponentSelected?: never,
+ onChangeComponentSelection?: ComponentSelectionChangedEvent,
+ }
+);
// eslint-disable-next-line import/prefer-default-export
export const ComponentPicker: React.FC = ({
+ /** Restrict the component picker to a specific library */
+ libraryId,
componentPickerMode = 'single',
/** This default callback is used to send the selected component back to the parent window,
* when the component picker is used in an iframe.
@@ -54,8 +58,8 @@ export const ComponentPicker: React.FC = ({
onComponentSelected = defaultComponentSelectedCallback,
onChangeComponentSelection = defaultSelectionChangedCallback,
}) => {
- const [currentStep, setCurrentStep] = useState('select-library');
- const [selectedLibrary, setSelectedLibrary] = useState('');
+ const [currentStep, setCurrentStep] = useState(!libraryId ? 'select-library' : 'pick-components');
+ const [selectedLibrary, setSelectedLibrary] = useState(libraryId || '');
const location = useLocation();
@@ -72,12 +76,16 @@ export const ComponentPicker: React.FC = ({
setSelectedLibrary('');
};
+ const restrictToLibrary = !!libraryId;
+
const libraryProviderProps = componentPickerMode === 'single' ? {
componentPickerMode,
onComponentSelected,
+ restrictToLibrary,
} : {
componentPickerMode,
onChangeComponentSelection,
+ restrictToLibrary,
};
return (
diff --git a/src/library-authoring/component-picker/ComponentPickerModal.tsx b/src/library-authoring/component-picker/ComponentPickerModal.tsx
new file mode 100644
index 0000000000..5d6e6ae30b
--- /dev/null
+++ b/src/library-authoring/component-picker/ComponentPickerModal.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { StandardModal } from '@openedx/paragon';
+
+import type { ComponentSelectionChangedEvent } from '../common/context';
+import { ComponentPicker } from './ComponentPicker';
+
+interface ComponentPickerModalProps {
+ libraryId?: string;
+ isOpen: boolean;
+ onClose: () => void;
+ onChangeComponentSelection: ComponentSelectionChangedEvent;
+ footerNode?: React.ReactNode;
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export const ComponentPickerModal: React.FC = ({
+ libraryId,
+ isOpen,
+ onClose,
+ onChangeComponentSelection,
+ footerNode,
+}) => {
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/src/library-authoring/component-picker/index.ts b/src/library-authoring/component-picker/index.ts
index 458946220c..5ffe86d0f8 100644
--- a/src/library-authoring/component-picker/index.ts
+++ b/src/library-authoring/component-picker/index.ts
@@ -1,2 +1,2 @@
-// eslint-disable-next-line import/prefer-default-export
export { ComponentPicker } from './ComponentPicker';
+export { ComponentPickerModal } from './ComponentPickerModal';