Skip to content

Commit

Permalink
feat: add existing content to a collection [FC-0062] (#1416)
Browse files Browse the repository at this point in the history
Allows library components to be added to a collection using the add-content
sidebar. For the Libraries Relaunch Beta.

For: #1173
  • Loading branch information
rpenido authored Oct 23, 2024
1 parent c0c74de commit a8aa495
Show file tree
Hide file tree
Showing 13 changed files with 351 additions and 19 deletions.
2 changes: 2 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,8 @@ describe('<LibraryAuthoringPage />', () => {
expect(mockResult0.display_name).toStrictEqual(displayName);
await renderLibraryPage();

waitFor(() => expect(screen.getAllByTestId('component-card-menu-toggle').length).toBeGreaterThan(0));

// Open menu
fireEvent.click((await screen.findAllByTestId('component-card-menu-toggle'))[0]);
// Click add to collection
Expand Down
3 changes: 2 additions & 1 deletion src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
libraryData,
isLoadingLibraryData,
componentPickerMode,
restrictToLibrary,
showOnlyPublished,
sidebarComponentInfo,
openInfoSidebar,
Expand Down Expand Up @@ -196,7 +197,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
}
};

const breadcumbs = componentPickerMode ? (
const breadcumbs = componentPickerMode && !restrictToLibrary ? (
<Breadcrumb
links={[
{
Expand Down
12 changes: 11 additions & 1 deletion src/library-authoring/LibraryLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +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 { ComponentEditorModal } from './components/ComponentEditorModal';

const LibraryLayout = () => {
Expand All @@ -25,7 +26,16 @@ const LibraryLayout = () => {
}

return (
<LibraryProvider key={collectionId} libraryId={libraryId} collectionId={collectionId}>
<LibraryProvider
key={collectionId}
libraryId={libraryId}
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}
>
<Routes>
<Route
path="collection/:collectionId"
Expand Down
33 changes: 30 additions & 3 deletions src/library-authoring/add-content/AddContentContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import {
Stack,
Button,
useToggle,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
Expand All @@ -11,20 +12,21 @@ import {
AutoAwesome,
BookOpen,
Create,
Folder,
ThumbUpOutline,
Question,
VideoCamera,
ContentPaste,
} from '@openedx/paragon/icons';
import { v4 as uuid4 } from 'uuid';
import { useParams } from 'react-router-dom';

import { ToastContext } from '../../generic/toast-context';
import { useCopyToClipboard } from '../../generic/clipboard';
import { getCanEdit } from '../../course-unit/data/selectors';
import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks';
import { useLibraryContext } from '../common/context';
import { canEditComponent } from '../components/ComponentEditorModal';
import { PickLibraryContentModal } from './PickLibraryContentModal';

import messages from './messages';

Expand Down Expand Up @@ -62,11 +64,12 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro

const AddContentContainer = () => {
const intl = useIntl();
const { collectionId } = useParams();
const {
libraryId,
collectionId,
openCreateCollectionModal,
openComponentEditor,
componentPickerModal,
} = useLibraryContext();
const createBlockMutation = useCreateLibraryBlock();
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
Expand All @@ -75,6 +78,8 @@ const AddContentContainer = () => {
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit);

const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();

const parsePasteErrorMsg = (error: any) => {
let errMsg: string;
try {
Expand All @@ -94,6 +99,14 @@ const AddContentContainer = () => {
icon: BookOpen,
blockType: 'collection',
};

const libraryContentButtonData = {
name: intl.formatMessage(messages.libraryContentButton),
disabled: false,
icon: Folder,
blockType: 'libraryContent',
};

const contentTypes = [
{
name: intl.formatMessage(messages.textTypeButton),
Expand Down Expand Up @@ -186,6 +199,8 @@ const AddContentContainer = () => {
onPaste();
} else if (blockType === 'collection') {
openCreateCollectionModal();
} else if (blockType === 'libraryContent') {
showAddLibraryContentModal();
} else {
onCreateBlock(blockType);
}
Expand All @@ -197,7 +212,19 @@ const AddContentContainer = () => {

return (
<Stack direction="vertical">
{!collectionId && <AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />}
{collectionId ? (
componentPickerModal && (
<>
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
/>
</>
)
) : (
<AddContentButton contentType={collectionButtonData} 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) => (
Expand Down
106 changes: 106 additions & 0 deletions src/library-authoring/add-content/PickLibraryContentModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
import {
fireEvent,
render as baseRender,
waitFor,
screen,
initializeMocks,
} from '../../testUtils';
import mockResult from '../__mocks__/library-search.json';
import { LibraryProvider } from '../common/context';
import { ComponentPickerModal } from '../component-picker';
import * as api from '../data/api';
import {
mockContentLibrary,
mockGetCollectionMetadata,
} from '../data/api.mocks';
import { PickLibraryContentModal } from './PickLibraryContentModal';

initializeMocks();
mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetCollectionMetadata.applyMock();
mockSearchResult(mockResult);

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' },
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
collectionId="collectionId"
componentPickerModal={ComponentPickerModal}
>
{children}
</LibraryProvider>
),
});

describe('<PickLibraryContentModal />', () => {
beforeEach(() => {
const mocks = initializeMocks();
mockShowToast = mocks.mockShowToast;
});

it('can pick components from the modal', async () => {
const mockAddComponentsToCollection = jest.fn();
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);

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(mockAddComponentsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['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 mockAddComponentsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
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(mockAddComponentsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['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.');
});
});
});
81 changes: 81 additions & 0 deletions src/library-authoring/add-content/PickLibraryContentModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React, { useCallback, useContext, useState } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, Button } from '@openedx/paragon';

import { ToastContext } from '../../generic/toast-context';
import { type SelectedComponent, useLibraryContext } from '../common/context';
import { useAddComponentsToCollection } from '../data/apiHooks';
import messages from './messages';

interface PickLibraryContentModalFooterProps {
onSubmit: () => void;
selectedComponents: SelectedComponent[];
}

const PickLibraryContentModalFooter: React.FC<PickLibraryContentModalFooterProps> = ({
onSubmit,
selectedComponents,
}) => (
<ActionRow>
<FormattedMessage {...messages.selectedComponents} values={{ count: selectedComponents.length }} />
<ActionRow.Spacer />
<Button variant="primary" onClick={onSubmit}>
<FormattedMessage {...messages.addToCollectionButton} />
</Button>
</ActionRow>
);

interface PickLibraryContentModalProps {
isOpen: boolean;
onClose: () => void;
}

// eslint-disable-next-line import/prefer-default-export
export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({
isOpen,
onClose,
}) => {
const intl = useIntl();

const {
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,
} = useLibraryContext();

// istanbul ignore if: this should never happen
if (!collectionId || !ComponentPickerModal) {
throw new Error('libraryId and componentPickerModal are required');
}

const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);

const { showToast } = useContext(ToastContext);

const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);

const onSubmit = useCallback(() => {
const usageKeys = selectedComponents.map(({ usageKey }) => usageKey);
onClose();
updateComponentsMutation.mutateAsync(usageKeys)
.then(() => {
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
})
.catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
});
}, [selectedComponents]);

return (
<ComponentPickerModal
libraryId={libraryId}
isOpen={isOpen}
onClose={onClose}
onChangeComponentSelection={setSelectedComponents}
footerNode={<PickLibraryContentModalFooter onSubmit={onSubmit} selectedComponents={selectedComponents} />}
/>
);
};
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 @@
// eslint-disable-next-line import/prefer-default-export
export { default as AddContentContainer } from './AddContentContainer';
export { default as AddContentHeader } from './AddContentHeader';
20 changes: 20 additions & 0 deletions src/library-authoring/add-content/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ const messages = defineMessages({
defaultMessage: 'Collection',
description: 'Content of button to create a Collection.',
},
libraryContentButton: {
id: 'course-authoring.library-authoring.add-content.buttons.library-content',
defaultMessage: 'Existing Library Content',
description: 'Content of button to add existing library content to a collection.',
},
addToCollectionButton: {
id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-collection',
defaultMessage: 'Add to Collection',
description: 'Button to add library content to a collection.',
},
selectedComponents: {
id: 'course-authoring.library-authoring.add-content.selected-components',
defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}',
description: 'Title for selected components in library.',
},
textTypeButton: {
id: 'course-authoring.library-authoring.add-content.buttons.types.text',
defaultMessage: 'Text',
Expand Down Expand Up @@ -51,6 +66,11 @@ const messages = defineMessages({
defaultMessage: 'There was an error creating the content.',
description: 'Message when creation of content in library is on error',
},
successAssociateComponentMessage: {
id: 'course-authoring.library-authoring.associate-collection-content.success.text',
defaultMessage: 'Content linked successfully.',
description: 'Message when linking of content to a collection in library is success',
},
errorAssociateComponentMessage: {
id: 'course-authoring.library-authoring.associate-collection-content.error.text',
defaultMessage: 'There was an error linking the content to this collection.',
Expand Down
Loading

0 comments on commit a8aa495

Please sign in to comment.