From b6ee72735f331b912d384a8010bed997763f0908 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 14 Nov 2024 10:49:06 +0530 Subject: [PATCH] feat: add library v2 component in course (#75) * feat: add library v2 component in course * test: add test for lib v2 component * test: fix failing tests * refactor: remove ComponentPickerModal component Replace with standard modal and component picker components. --- src/course-unit/CourseUnit.test.jsx | 2 +- .../add-component/AddComponent.jsx | 29 ++++++++++++- .../add-component/AddComponent.test.jsx | 35 +++++++++++++-- src/course-unit/data/api.js | 10 ++++- src/library-authoring/LibraryLayout.tsx | 8 ++-- .../add-content/AddContentContainer.tsx | 4 +- .../PickLibraryContentModal.test.tsx | 4 +- .../add-content/PickLibraryContentModal.tsx | 27 +++++++----- src/library-authoring/common/context.tsx | 26 +++++------ .../component-picker/ComponentPicker.tsx | 5 ++- .../component-picker/ComponentPickerModal.tsx | 43 ------------------- .../component-picker/index.ts | 2 +- 12 files changed, 112 insertions(+), 83 deletions(-) delete mode 100644 src/library-authoring/component-picker/ComponentPickerModal.tsx diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 5f569ef3d1..26e8befef3 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -282,7 +282,7 @@ describe('', () => { await waitFor(() => { const problemButton = getByRole('button', { - name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), + name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), }); userEvent.click(problemButton); diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index 6abb3d647c..1a0676239c 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -2,13 +2,14 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useToggle } from '@openedx/paragon'; +import { StandardModal, useToggle } from '@openedx/paragon'; import { getCourseSectionVertical } from '../data/selectors'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import ComponentModalView from './add-component-modals/ComponentModalView'; import AddComponentButton from './add-component-btn'; import messages from './messages'; +import { ComponentPicker } from '../../library-authoring/component-picker'; const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { const navigate = useNavigate(); @@ -17,6 +18,17 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { const [isOpenHtml, openHtml, closeHtml] = useToggle(false); const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false); const { componentTemplates } = useSelector(getCourseSectionVertical); + const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + + const handleLibraryV2Selection = (selection) => { + handleCreateNewCourseXBlock({ + type: COMPONENT_TYPES.libraryV2, + category: selection.blockType, + parentLocator: blockId, + libraryContentKey: selection.usageKey, + }); + closeAddLibraryContentModal(); + }; const handleCreateNewXBlock = (type, moduleName) => { switch (type) { @@ -38,6 +50,9 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { case COMPONENT_TYPES.itembank: handleCreateNewCourseXBlock({ type, category: 'itembank', parentLocator: blockId }); break; + case COMPONENT_TYPES.libraryV2: + showAddLibraryContentModal(); + break; case COMPONENT_TYPES.advanced: handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId, @@ -122,6 +137,18 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => { ); })} + + + ); }; diff --git a/src/course-unit/add-component/AddComponent.test.jsx b/src/course-unit/add-component/AddComponent.test.jsx index 237cd77f46..9cc27acf55 100644 --- a/src/course-unit/add-component/AddComponent.test.jsx +++ b/src/course-unit/add-component/AddComponent.test.jsx @@ -23,6 +23,11 @@ let axiosMock; const blockId = '123'; const handleCreateNewCourseXBlockMock = jest.fn(); +// Mock ComponentPicker to call onComponentSelected on load +jest.mock('../../library-authoring/component-picker', () => ({ + ComponentPicker: (props) => props.onComponentSelected({ usageKey: 'test-usage-key', blockType: 'html' }), +})); + const renderComponent = (props) => render( @@ -61,7 +66,11 @@ describe('', () => { expect(getByRole('heading', { name: messages.title.defaultMessage })).toBeInTheDocument(); Object.keys(componentTemplates).forEach((component) => { const btn = getByRole('button', { - name: new RegExp(`${messages.buttonText.defaultMessage} ${componentTemplates[component].display_name}`, 'i'), + name: new RegExp( + `${componentTemplates[component].type + } ${messages.buttonText.defaultMessage} ${componentTemplates[component].display_name}`, + 'i', + ), }); expect(btn).toBeInTheDocument(); if (component.beta) { @@ -115,7 +124,11 @@ describe('', () => { } return expect(getByRole('button', { - name: new RegExp(`${messages.buttonText.defaultMessage} ${componentTemplates[component].display_name}`, 'i'), + name: new RegExp( + `${componentTemplates[component].type + } ${messages.buttonText.defaultMessage} ${componentTemplates[component].display_name}`, + 'i', + ), })).toBeInTheDocument(); }); }); @@ -180,7 +193,7 @@ describe('', () => { const { getByRole } = renderComponent(); const discussionButton = getByRole('button', { - name: new RegExp(`${messages.buttonText.defaultMessage} Problem`, 'i'), + name: new RegExp(`problem ${messages.buttonText.defaultMessage} Problem`, 'i'), }); userEvent.click(discussionButton); @@ -399,6 +412,22 @@ describe('', () => { }); }); + it('shows library picker on clicking v2 library content btn', async () => { + const { findByRole } = renderComponent(); + const libBtn = await findByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Library content`, 'i'), + }); + + userEvent.click(libBtn); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ + type: COMPONENT_TYPES.libraryV2, + parentLocator: '123', + category: 'html', + libraryContentKey: 'test-usage-key', + }); + }); + describe('component support label', () => { it('component support label is hidden if component support legend is disabled', async () => { const supportLevels = ['fs', 'ps']; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 6e35b70a3f..02b7ab89f2 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -63,9 +63,16 @@ export async function getCourseSectionVerticalData(unitId) { * @param {string} [options.displayName] - The display name. * @param {string} [options.boilerplate] - The boilerplate. * @param {string} [options.stagedContent] - The staged content. + * @param {string} [options.libraryContentKey] - component key from library if being imported. */ export async function createCourseXblock({ - type, category, parentLocator, displayName, boilerplate, stagedContent, + type, + category, + parentLocator, + displayName, + boilerplate, + stagedContent, + libraryContentKey, }) { const body = { type, @@ -74,6 +81,7 @@ export async function createCourseXblock({ parent_locator: parentLocator, display_name: displayName, staged_content: stagedContent, + library_content_key: libraryContentKey, }; const { data } = await getAuthenticatedHttpClient() diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 09cb45b3d3..04bfc25c64 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -10,7 +10,7 @@ import { LibraryProvider } from './common/context'; import { CreateCollectionModal } from './create-collection'; import { LibraryTeamModal } from './library-team'; import LibraryCollectionPage from './collections/LibraryCollectionPage'; -import { ComponentPickerModal } from './component-picker'; +import { ComponentPicker } from './component-picker'; import { ComponentEditorModal } from './components/ComponentEditorModal'; const LibraryLayout = () => { @@ -32,9 +32,9 @@ const LibraryLayout = () => { collectionId={collectionId} /** 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={ComponentPickerModal} + * ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > + * Sidebar > AddContentContainer > ComponentPicker */ + componentPicker={ComponentPicker} > { collectionId, openCreateCollectionModal, openComponentEditor, - componentPickerModal, + componentPicker, } = useLibraryContext(); const createBlockMutation = useCreateLibraryBlock(); const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId); @@ -239,7 +239,7 @@ const AddContentContainer = () => { return ( {collectionId ? ( - componentPickerModal && ( + componentPicker && ( <> baseRender( {children} diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx index f8a8ac3965..ef08b68ec0 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useState } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { ActionRow, Button } from '@openedx/paragon'; +import { ActionRow, Button, StandardModal } from '@openedx/paragon'; import { ToastContext } from '../../generic/toast-context'; import { type SelectedComponent, useLibraryContext } from '../common/context'; @@ -41,14 +41,14 @@ export const PickLibraryContentModal: React.FC = ( 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, + * ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > + * Sidebar > AddContentContainer > ComponentPicker */ + componentPicker: ComponentPicker, } = useLibraryContext(); // istanbul ignore if: this should never happen - if (!collectionId || !ComponentPickerModal) { - throw new Error('libraryId and componentPickerModal are required'); + if (!collectionId || !ComponentPicker) { + throw new Error('libraryId and componentPicker are required'); } const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId); @@ -70,12 +70,19 @@ export const PickLibraryContentModal: React.FC = ( }, [selectedComponents]); return ( - } - /> + > + + ); }; diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index be229065f5..4e3ac78cc5 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -6,7 +6,7 @@ import React, { useState, } from 'react'; -import type { ComponentPickerModal } from '../component-picker'; +import type { ComponentPicker } from '../component-picker'; import type { ContentLibrary } from '../data/api'; import { useContentLibrary } from '../data/apiHooks'; @@ -27,9 +27,9 @@ type NoComponentPickerType = { 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; + * ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > + * Sidebar > AddContentContainer > ComponentPicker */ + componentPicker?: typeof ComponentPicker; }; type ComponentPickerSingleType = { @@ -39,7 +39,7 @@ type ComponentPickerSingleType = { addComponentToSelectedComponents?: never; removeComponentFromSelectedComponents?: never; restrictToLibrary: boolean; - componentPickerModal?: never; + componentPicker?: never; }; type ComponentPickerMultipleType = { @@ -49,7 +49,7 @@ type ComponentPickerMultipleType = { addComponentToSelectedComponents: ComponentSelectedEvent; removeComponentFromSelectedComponents: ComponentSelectedEvent; restrictToLibrary: boolean; - componentPickerModal?: never; + componentPicker?: never; }; type ComponentPickerType = NoComponentPickerType | ComponentPickerSingleType | ComponentPickerMultipleType; @@ -121,7 +121,7 @@ type NoComponentPickerProps = { onComponentSelected?: never; onChangeComponentSelection?: never; restrictToLibrary?: never; - componentPickerModal?: typeof ComponentPickerModal; + componentPicker?: typeof ComponentPicker; }; export type ComponentPickerSingleProps = { @@ -129,7 +129,7 @@ export type ComponentPickerSingleProps = { onComponentSelected: ComponentSelectedEvent; onChangeComponentSelection?: never; restrictToLibrary?: boolean; - componentPickerModal?: never; + componentPicker?: never; }; export type ComponentPickerMultipleProps = { @@ -137,7 +137,7 @@ export type ComponentPickerMultipleProps = { onComponentSelected?: never; onChangeComponentSelection?: ComponentSelectionChangedEvent; restrictToLibrary?: boolean; - componentPickerModal?: never; + componentPicker?: never; }; type ComponentPickerProps = NoComponentPickerProps | ComponentPickerSingleProps | ComponentPickerMultipleProps; @@ -150,7 +150,7 @@ type LibraryProviderProps = { showOnlyPublished?: boolean; /** Only used for testing */ initialSidebarComponentInfo?: SidebarComponentInfo; - componentPickerModal?: typeof ComponentPickerModal; + componentPicker?: typeof ComponentPicker; } & ComponentPickerProps; /** @@ -166,7 +166,7 @@ export const LibraryProvider = ({ onChangeComponentSelection, showOnlyPublished = false, initialSidebarComponentInfo, - componentPickerModal, + componentPicker, }: LibraryProviderProps) => { const [collectionId, setCollectionId] = useState(collectionIdProp); const [sidebarComponentInfo, setSidebarComponentInfo] = useState( @@ -276,7 +276,7 @@ export const LibraryProvider = ({ if (!componentPickerMode) { return { ...contextValue, - componentPickerModal, + componentPicker, }; } if (componentPickerMode === 'single') { @@ -329,7 +329,7 @@ export const LibraryProvider = ({ openComponentEditor, closeComponentEditor, resetSidebarAdditionalActions, - componentPickerModal, + componentPicker, ]); return ( diff --git a/src/library-authoring/component-picker/ComponentPicker.tsx b/src/library-authoring/component-picker/ComponentPicker.tsx index 8f8cc77bd1..553b2e3d97 100644 --- a/src/library-authoring/component-picker/ComponentPicker.tsx +++ b/src/library-authoring/component-picker/ComponentPicker.tsx @@ -35,7 +35,7 @@ const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selecti window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*'); }; -type ComponentPickerProps = { libraryId?: string } & ( +type ComponentPickerProps = { libraryId?: string, showOnlyPublished?: boolean } & ( { componentPickerMode?: 'single', onComponentSelected?: ComponentSelectedEvent, @@ -51,6 +51,7 @@ type ComponentPickerProps = { libraryId?: string } & ( export const ComponentPicker: React.FC = ({ /** Restrict the component picker to a specific library */ libraryId, + showOnlyPublished, 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. @@ -99,7 +100,7 @@ export const ComponentPicker: React.FC = ({ diff --git a/src/library-authoring/component-picker/ComponentPickerModal.tsx b/src/library-authoring/component-picker/ComponentPickerModal.tsx deleted file mode 100644 index 5d6e6ae30b..0000000000 --- a/src/library-authoring/component-picker/ComponentPickerModal.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 5ffe86d0f8..24d8920e03 100644 --- a/src/library-authoring/component-picker/index.ts +++ b/src/library-authoring/component-picker/index.ts @@ -1,2 +1,2 @@ +/* eslint-disable import/prefer-default-export */ export { ComponentPicker } from './ComponentPicker'; -export { ComponentPickerModal } from './ComponentPickerModal';