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