feat: add existing components to unit [FC-0083] (#1811)
allows adding existing components to units
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}.`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as AddContent } from './AddContent';
|
||||
export { default as AddContentHeader } from './AddContentHeader';
|
||||
export { PickLibraryContentModal } from './PickLibraryContentModal';
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user