From dbd513f898f7bfe1bf29e091886074032e8678c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 15 Apr 2025 15:40:22 -0300 Subject: [PATCH 1/3] feat: add existing components to unit --- .../add-content/AddContent.tsx | 32 ++--- .../PickLibraryContentModal.test.tsx | 133 +++++++++++------- .../add-content/PickLibraryContentModal.tsx | 53 +++++-- src/library-authoring/add-content/index.ts | 1 + src/library-authoring/add-content/messages.ts | 5 + .../units/LibraryUnitBlocks.tsx | 12 +- .../units/LibraryUnitPage.tsx | 6 +- 7 files changed, 159 insertions(+), 83 deletions(-) diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index d29a805aaf..6097fb7f7c 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -116,25 +116,25 @@ const AddContentView = ({ return ( <> - {upstreamContainerType !== ContainerType.Unit && ( + {(collectionId || unitId) && componentPicker && ( + /// Show the "Add Library Content" button for units and collections <> - {collectionId ? ( - componentPicker && ( - <> - - - - ) - ) : ( - - )} - -
+ + )} + {!collectionId && !unitId && ( + // Doesn't show the "Collection" button if we are in a unit or collection + + )} + {upstreamContainerType !== ContainerType.Unit && ( + // Doesn't show the "Unit" button if we are in a unit + + )} +
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */} {contentTypes.filter(ct => !ct.disabled).map((contentType) => ( void; -const render = () => baseRender(, { - path: '/library/:libraryId/collection/:collectionId/*', - params: { libraryId, collectionId: 'collectionId' }, +const mockAddItemsToCollection = jest.fn(); +const mockAddComponentsToContainer = jest.fn(); +jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection); +jest.spyOn(api, 'addComponentsToContainer').mockImplementation(mockAddComponentsToContainer); + +const render = (context: 'collection' | 'unit') => baseRender(, { + path: context === 'collection' + ? '/library/:libraryId/collection/:collectionId/*' + : '/library/:libraryId/container/:unitId/*', + params: { + libraryId, + ...(context === 'collection' && { collectionId: 'collectionId' }), + ...(context === 'unit' && { unitId: 'unitId' }), + }, extraWrapper: ({ children }) => ( ', () => { const mocks = initializeMocks(); mockShowToast = mocks.mockShowToast; mocks.axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); + jest.clearAllMocks(); }); - it('can pick components from the modal', async () => { - const mockAddItemsToCollection = jest.fn(); - jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection); - - 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(mockAddItemsToCollection).toHaveBeenCalledWith( - libraryId, - 'collectionId', - ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], - ); + ['collection' as const, 'unit' as const].forEach((context) => { + it(`can pick components from the modal (${context})`, async () => { + render(context); + + // 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.getByRole('button', { name: /add to .*/i })); + + await waitFor(() => { + if (context === 'collection') { + expect(mockAddItemsToCollection).toHaveBeenCalledWith( + libraryId, + 'collectionId', + ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], + ); + } else { + expect(mockAddComponentsToContainer).toHaveBeenCalledWith( + 'unitId', + ['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 mockAddItemsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components')); - jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection); - 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(mockAddItemsToCollection).toHaveBeenCalledWith( - libraryId, - 'collectionId', - ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], - ); + it(`show error when api call fails (${context})`, async () => { + if (context === 'collection') { + mockAddItemsToCollection.mockRejectedValueOnce(new Error('Error')); + } else { + mockAddComponentsToContainer.mockRejectedValueOnce(new Error('Error')); + } + render(context); + + // 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.getByRole('button', { name: /add to .*/i })); + + await waitFor(() => { + if (context === 'collection') { + expect(mockAddItemsToCollection).toHaveBeenCalledWith( + libraryId, + 'collectionId', + ['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'], + ); + } else { + expect(mockAddComponentsToContainer).toHaveBeenCalledWith( + 'unitId', + ['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.'); + const name = context === 'collection' ? 'collection' : 'container'; + expect(mockShowToast).toHaveBeenCalledWith(`There was an error linking the content to this ${name}.`); }); }); }); diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx index 176dba8ba7..f71f40d081 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx @@ -5,23 +5,25 @@ import { ActionRow, Button, StandardModal } from '@openedx/paragon'; import { ToastContext } from '../../generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import type { SelectedComponent } from '../common/context/ComponentPickerContext'; -import { useAddItemsToCollection } from '../data/apiHooks'; +import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks'; import messages from './messages'; interface PickLibraryContentModalFooterProps { onSubmit: () => void; selectedComponents: SelectedComponent[]; + buttonText: React.ReactNode; } const PickLibraryContentModalFooter: React.FC = ({ onSubmit, selectedComponents, + buttonText, }) => ( ); @@ -29,17 +31,20 @@ const PickLibraryContentModalFooter: React.FC void; + extraFilter?: string[]; } export const PickLibraryContentModal: React.FC = ({ isOpen, onClose, + extraFilter, }) => { const intl = useIntl(); const { libraryId, collectionId, + unitId, /** We need to get it as a reference instead of directly importing it to avoid the import cycle: * ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage > * Sidebar > AddContent > ComponentPicker */ @@ -47,11 +52,12 @@ export const PickLibraryContentModal: React.FC = ( } = useLibraryContext(); // istanbul ignore if: this should never happen - if (!collectionId || !ComponentPicker) { - throw new Error('libraryId and componentPicker are required'); + if (!(collectionId || unitId) || !ComponentPicker) { + throw new Error('collectionId/unitId and componentPicker are required'); } - const updateComponentsMutation = useAddItemsToCollection(libraryId, collectionId); + const updateCollectionItemsMutation = useAddItemsToCollection(libraryId, collectionId); + const updateUnitComponentsMutation = useAddComponentsToContainer(unitId); const { showToast } = useContext(ToastContext); @@ -60,13 +66,24 @@ export const PickLibraryContentModal: React.FC = ( const onSubmit = useCallback(() => { const usageKeys = selectedComponents.map(({ usageKey }) => usageKey); onClose(); - updateComponentsMutation.mutateAsync(usageKeys) - .then(() => { - showToast(intl.formatMessage(messages.successAssociateComponentMessage)); - }) - .catch(() => { - showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); - }); + if (collectionId) { + updateCollectionItemsMutation.mutateAsync(usageKeys) + .then(() => { + showToast(intl.formatMessage(messages.successAssociateComponentMessage)); + }) + .catch(() => { + showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); + }); + } + if (unitId) { + updateUnitComponentsMutation.mutateAsync(usageKeys) + .then(() => { + showToast(intl.formatMessage(messages.successAssociateComponentMessage)); + }) + .catch(() => { + showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage)); + }); + } }, [selectedComponents]); return ( @@ -76,12 +93,22 @@ export const PickLibraryContentModal: React.FC = ( size="xl" isOpen={isOpen} onClose={onClose} - footerNode={} + footerNode={( + + )} > ); diff --git a/src/library-authoring/add-content/index.ts b/src/library-authoring/add-content/index.ts index a3bd82be41..9dd2de3456 100644 --- a/src/library-authoring/add-content/index.ts +++ b/src/library-authoring/add-content/index.ts @@ -1,2 +1,3 @@ export { default as AddContent } from './AddContent'; export { default as AddContentHeader } from './AddContentHeader'; +export { PickLibraryContentModal } from './PickLibraryContentModal'; diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index c42146aa49..120b5896fb 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -21,6 +21,11 @@ const messages = defineMessages({ defaultMessage: 'Add to Collection', description: 'Button to add library content to a collection.', }, + addToUnitButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-unit', + defaultMessage: 'Add to Unit', + description: 'Button to add library content to a unit.', + }, selectedComponents: { id: 'course-authoring.library-authoring.add-content.selected-components', defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}', diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 4e8b261e6a..51c93c0da6 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -17,6 +17,7 @@ import { IframeProvider } from '../../generic/hooks/context/iFrameContext'; import Loading from '../../generic/Loading'; import TagCount from '../../generic/tag-count'; import { useLibraryContext } from '../common/context/LibraryContext'; +import { PickLibraryContentModal } from '../add-content'; import ComponentMenu from '../components'; import { LibraryBlockMetadata } from '../data/api'; import { libraryAuthoringQueryKeys, useContainerChildren } from '../data/apiHooks'; @@ -38,6 +39,8 @@ export const LibraryUnitBlocks = () => { const intl = useIntl(); const [orderedBlocks, setOrderedBlocks] = useState([]); const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + const { navigateTo } = useLibraryRoutes(); const { @@ -148,6 +151,7 @@ export const LibraryUnitBlocks = () => { )); + return (
@@ -171,11 +175,17 @@ export const LibraryUnitBlocks = () => { className="ml-2" iconBefore={Add} variant="outline-primary rounded-0" - disabled + disabled={readOnly} + onClick={showAddLibraryContentModal} block > {intl.formatMessage(messages.addExistingContentButton)} +
Date: Tue, 15 Apr 2025 16:44:21 -0300 Subject: [PATCH 2/3] feat: add preview mode to LibraryUnitBlocks --- src/library-authoring/containers/UnitInfo.tsx | 2 +- .../units/LibraryUnitBlocks.tsx | 69 ++++++++++--------- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index a6385009a2..9b5aef8e41 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -147,7 +147,7 @@ const UnitInfo = () => { activeKey={tab} onSelect={setSidebarTab} > - {renderTab(UNIT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} + {renderTab(UNIT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} {renderTab(UNIT_INFO_TABS.Organize, , intl.formatMessage(messages.organizeTabTitle))} {renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))} diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 51c93c0da6..f1873fb08b 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -35,7 +35,11 @@ const LARGE_COMPONENTS = [ 'lti_consumer', ]; -export const LibraryUnitBlocks = () => { +interface LibraryUnitBlocksProps { + preview?: boolean; +} + +export const LibraryUnitBlocks = ({ preview = false }: LibraryUnitBlocksProps) => { const intl = useIntl(); const [orderedBlocks, setOrderedBlocks] = useState([]); const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); @@ -151,43 +155,44 @@ export const LibraryUnitBlocks = () => { )); - return (
{renderedBlocks} -
-
- -
-
- - + { !preview && ( +
+
+ +
+
+ + +
-
+ )} Date: Tue, 15 Apr 2025 16:48:53 -0300 Subject: [PATCH 3/3] fix: unit open inside collection --- src/library-authoring/containers/UnitInfo.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index 9b5aef8e41..a991f3d163 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -10,6 +10,7 @@ import { useToggle, } from '@openedx/paragon'; import { useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { MoreVert } from '@openedx/paragon/icons'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; @@ -70,7 +71,7 @@ const UnitMenu = ({ containerId, displayName }: ContainerMenuProps) => { const UnitInfo = () => { const intl = useIntl(); - const { setUnitId } = useLibraryContext(); + const { libraryId } = useLibraryContext(); const { componentPickerMode } = useComponentPickerContext(); const { defaultTab, @@ -81,7 +82,7 @@ const UnitInfo = () => { sidebarAction, } = useSidebarContext(); const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; - const { insideUnit, navigateTo } = useLibraryRoutes(); + const { insideUnit } = useLibraryRoutes(); const tab: UnitInfoTab = ( sidebarTab && isUnitInfoTab(sidebarTab) @@ -90,15 +91,7 @@ const UnitInfo = () => { const unitId = sidebarComponentInfo?.id; const { data: container } = useContainer(unitId); - const handleOpenUnit = useCallback(() => { - if (componentPickerMode) { - setUnitId(unitId); - } else { - navigateTo({ unitId }); - } - }, [componentPickerMode, navigateTo, unitId]); - - const showOpenUnitButton = !insideUnit || componentPickerMode; + const showOpenUnitButton = !insideUnit && !componentPickerMode; const renderTab = useCallback((infoTab: UnitInfoTab, component: React.ReactNode, title: string) => { if (hiddenTabs.includes(infoTab)) { @@ -130,7 +123,8 @@ const UnitInfo = () => {