Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions src/library-authoring/add-content/AddContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 81 additions & 52 deletions src/library-authoring/add-content/PickLibraryContentModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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);

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}.`);
});
});
});
53 changes: 40 additions & 13 deletions src/library-authoring/add-content/PickLibraryContentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,59 @@ 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>
);

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 */
componentPicker: ComponentPicker,
} = 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);

Expand All @@ -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 (
Expand All @@ -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>
);
Expand Down
1 change: 1 addition & 0 deletions src/library-authoring/add-content/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as AddContent } from './AddContent';
export { default as AddContentHeader } from './AddContentHeader';
export { PickLibraryContentModal } from './PickLibraryContentModal';
5 changes: 5 additions & 0 deletions src/library-authoring/add-content/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}',
Expand Down
20 changes: 7 additions & 13 deletions src/library-authoring/containers/UnitInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)) {
Expand Down Expand Up @@ -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>
Expand All @@ -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>
Expand Down
Loading