feat: add existing components to unit [FC-0083] (#1811)

allows adding existing components to units
This commit is contained in:
Rômulo Penido
2025-04-15 18:49:53 -03:00
committed by GitHub
parent 990073cb38
commit d9dcdfe1e3
8 changed files with 187 additions and 112 deletions

View File

@@ -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 && (
<>
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
/>
</>
)
) : (
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
)}
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
<hr className="w-100 bg-gray-500" />
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
/>
</>
)}
{!collectionId && !unitId && (
// Doesn't show the "Collection" button if we are in a unit or collection
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
)}
{upstreamContainerType !== ContainerType.Unit && (
// Doesn't show the "Unit" button if we are in a unit
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
)}
<hr className="w-100 bg-gray-500" />
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
<AddContentButton

View File

@@ -28,9 +28,20 @@ const { libraryId } = mockContentLibrary;
const onClose = jest.fn();
let mockShowToast: (message: string) => void;
const render = () => baseRender(<PickLibraryContentModal isOpen onClose={onClose} />, {
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(<PickLibraryContentModal isOpen onClose={onClose} />, {
path: context === 'collection'
? '/library/:libraryId/collection/:collectionId/*'
: '/library/:libraryId/container/:unitId/*',
params: {
libraryId,
...(context === 'collection' && { collectionId: 'collectionId' }),
...(context === 'unit' && { unitId: 'unitId' }),
},
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
@@ -46,62 +57,80 @@ describe('<PickLibraryContentModal />', () => {
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);
['collection' as const, 'unit' as const].forEach((context) => {
it(`can pick components from the modal (${context})`, async () => {
render(context);
render();
// Wait for the content library to load
await waitFor(() => {
expect(screen.getByText('Test Library')).toBeInTheDocument();
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
});
// 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();
// 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 }));
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'],
);
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();
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();
});
// 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();
// 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]);
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
await waitFor(() => {
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
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}.`);
});
});
});

View File

@@ -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<PickLibraryContentModalFooterProps> = ({
onSubmit,
selectedComponents,
buttonText,
}) => (
<ActionRow>
<FormattedMessage {...messages.selectedComponents} values={{ count: selectedComponents.length }} />
<ActionRow.Spacer />
<Button variant="primary" onClick={onSubmit}>
<FormattedMessage {...messages.addToCollectionButton} />
{buttonText}
</Button>
</ActionRow>
);
@@ -29,17 +31,20 @@ const PickLibraryContentModalFooter: React.FC<PickLibraryContentModalFooterProps
interface PickLibraryContentModalProps {
isOpen: boolean;
onClose: () => void;
extraFilter?: string[];
}
export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({
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<PickLibraryContentModalProps> = (
} = 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<PickLibraryContentModalProps> = (
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<PickLibraryContentModalProps> = (
size="xl"
isOpen={isOpen}
onClose={onClose}
footerNode={<PickLibraryContentModalFooter onSubmit={onSubmit} selectedComponents={selectedComponents} />}
footerNode={(
<PickLibraryContentModalFooter
onSubmit={onSubmit}
selectedComponents={selectedComponents}
buttonText={(collectionId
? intl.formatMessage(messages.addToCollectionButton)
: intl.formatMessage(messages.addToUnitButton)
)}
/>
)}
>
<ComponentPicker
libraryId={libraryId}
componentPickerMode="multiple"
onChangeComponentSelection={setSelectedComponents}
extraFilter={extraFilter}
/>
</StandardModal>
);

View File

@@ -1,2 +1,3 @@
export { default as AddContent } from './AddContent';
export { default as AddContentHeader } from './AddContentHeader';
export { PickLibraryContentModal } from './PickLibraryContentModal';

View File

@@ -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}}',

View File

@@ -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 = () => {
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
onClick={handleOpenUnit}
as={Link}
to={`/library/${libraryId}/unit/${unitId}`}
>
{intl.formatMessage(messages.openUnitButton)}
</Button>
@@ -147,7 +141,7 @@ const UnitInfo = () => {
activeKey={tab}
onSelect={setSidebarTab}
>
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks />, intl.formatMessage(messages.previewTabTitle))}
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks preview />, intl.formatMessage(messages.previewTabTitle))}
{renderTab(UNIT_INFO_TABS.Organize, <ContainerOrganize />, intl.formatMessage(messages.organizeTabTitle))}
{renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))}
</Tabs>

View File

@@ -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';
@@ -34,10 +35,16 @@ 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<LibraryBlockMetadata[]>([]);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const { navigateTo } = useLibraryRoutes();
const {
@@ -153,31 +160,39 @@ export const LibraryUnitBlocks = () => {
<DraggableList itemList={orderedBlocks} setState={setOrderedBlocks} updateOrder={handleReorder}>
{renderedBlocks}
</DraggableList>
<div className="d-flex">
<div className="w-100 mr-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled={readOnly}
onClick={openAddContentSidebar}
block
>
{intl.formatMessage(messages.newContentButton)}
</Button>
{ !preview && (
<div className="d-flex">
<div className="w-100 mr-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled={readOnly}
onClick={openAddContentSidebar}
block
>
{intl.formatMessage(messages.newContentButton)}
</Button>
</div>
<div className="w-100 ml-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled={readOnly}
onClick={showAddLibraryContentModal}
block
>
{intl.formatMessage(messages.addExistingContentButton)}
</Button>
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
extraFilter={['NOT block_type = "unit"']}
/>
</div>
</div>
<div className="w-100 ml-2">
<Button
className="ml-2"
iconBefore={Add}
variant="outline-primary rounded-0"
disabled
block
>
{intl.formatMessage(messages.addExistingContentButton)}
</Button>
</div>
</div>
)}
<ContentTagsDrawerSheet
id={componentId}
onClose={onTagSidebarClose}

View File

@@ -1,5 +1,9 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Breadcrumb, Button, Container } from '@openedx/paragon';
import {
Breadcrumb,
Button,
Container,
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { useCallback, useContext, useEffect } from 'react';
import { Helmet } from 'react-helmet';