diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx
index 59b4511b32..e374388cde 100644
--- a/src/course-outline/CourseOutline.jsx
+++ b/src/course-outline/CourseOutline.jsx
@@ -103,6 +103,7 @@ const CourseOutline = ({ courseId }) => {
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
+ handleAddUnitFromLibrary,
getUnitUrl,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
@@ -383,6 +384,7 @@ const CourseOutline = ({ courseId }) => {
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onOpenConfigureModal={openConfigureModal}
onNewUnitSubmit={handleNewUnitSubmit}
+ onAddUnitFromLibrary={handleAddUnitFromLibrary}
onOrderChange={updateSubsectionOrderByIndex}
onPasteClick={handlePasteClipboardClick}
>
diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx
index 2ca68dc44a..055f56cdb5 100644
--- a/src/course-outline/CourseOutline.test.jsx
+++ b/src/course-outline/CourseOutline.test.jsx
@@ -60,11 +60,14 @@ import {
moveSubsection,
moveUnit,
} from './drag-helper/utils';
+import { postXBlockBaseApiUrl } from '../course-unit/data/api';
+import { COMPONENT_TYPES } from '../generic/block-type-utils/constants';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
+const containerKey = 'lct:org:lib:unit:1';
window.HTMLElement.prototype.scrollIntoView = jest.fn();
@@ -94,6 +97,30 @@ jest.mock('./data/api', () => ({
getTagsCount: () => jest.fn().mockResolvedValue({}),
}));
+jest.mock('../studio-home/hooks', () => ({
+ useStudioHome: () => ({
+ librariesV2Enabled: true,
+ }),
+}));
+
+// Mock ComponentPicker to call onComponentSelected on click
+jest.mock('../library-authoring/component-picker', () => ({
+ ComponentPicker: (props) => {
+ const onClick = () => {
+ // eslint-disable-next-line react/prop-types
+ props.onComponentSelected({
+ usageKey: containerKey,
+ blockType: 'unti',
+ });
+ };
+ return (
+
+ );
+ },
+}));
+
const queryClient = new QueryClient();
jest.mock('@dnd-kit/core', () => ({
@@ -390,6 +417,42 @@ describe('', () => {
}));
});
+ it('adds a unit from library correctly', async () => {
+ render();
+ const [sectionElement] = await screen.findAllByTestId('section-card');
+ const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
+ const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
+ fireEvent.click(expandBtn);
+ const units = await within(subsectionElement).findAllByTestId('unit-card');
+ expect(units.length).toBe(1);
+
+ axiosMock
+ .onPost(postXBlockBaseApiUrl())
+ .reply(200, {
+ locator: 'some',
+ });
+
+ const addUnitFromLibraryButton = within(subsectionElement).getByRole('button', {
+ name: /use unit from library/i,
+ });
+ fireEvent.click(addUnitFromLibraryButton);
+
+ // click dummy button to execute onComponentSelected prop.
+ const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
+ fireEvent.click(dummyBtn);
+
+ waitFor(() => expect(axiosMock.history.post.length).toBe(1));
+
+ const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
+ const [subsection] = section.childInfo.children;
+ expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
+ type: COMPONENT_TYPES.libraryV2,
+ category: 'vertical',
+ parent_locator: subsection.id,
+ library_content_key: containerKey,
+ }));
+ });
+
it('render checklist value correctly', async () => {
const { getByText } = render();
diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js
index fc54998961..3091f3ec90 100644
--- a/src/course-outline/data/thunk.js
+++ b/src/course-outline/data/thunk.js
@@ -53,6 +53,7 @@ import {
setPasteFileNotices,
updateCourseLaunchQueryStatus,
} from './slice';
+import { createCourseXblock } from '../../course-unit/data/api';
export function fetchCourseOutlineIndexQuery(courseId) {
return async (dispatch) => {
@@ -540,6 +541,26 @@ export function addNewUnitQuery(parentLocator, callback) {
};
}
+export function addUnitFromLibrary(body, callback) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
+
+ try {
+ await createCourseXblock(body).then(async (result) => {
+ if (result) {
+ dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
+ dispatch(hideProcessingNotification());
+ callback(result.locator);
+ }
+ });
+ } catch (error) /* istanbul ignore next */ {
+ dispatch(hideProcessingNotification());
+ dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
+ }
+ };
+}
+
function setBlockOrderListQuery(
parentId,
blockIds,
diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx
index 62638fcfd4..55cdc69add 100644
--- a/src/course-outline/hooks.jsx
+++ b/src/course-outline/hooks.jsx
@@ -53,6 +53,7 @@ import {
setUnitOrderListQuery,
pasteClipboardContent,
dismissNotificationQuery,
+ addUnitFromLibrary,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -128,6 +129,10 @@ const useCourseOutline = ({ courseId }) => {
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
};
+ const handleAddUnitFromLibrary = (body) => {
+ dispatch(addUnitFromLibrary(body, openUnitPage));
+ };
+
const headerNavigationsActions = {
handleNewSection: handleNewSectionSubmit,
handleReIndex: () => {
@@ -336,6 +341,7 @@ const useCourseOutline = ({ courseId }) => {
getUnitUrl,
openUnitPage,
handleNewUnitSubmit,
+ handleAddUnitFromLibrary,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
notificationDismissUrl,
diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx
index 86d35231b3..5430ef5d01 100644
--- a/src/course-outline/subsection-card/SubsectionCard.jsx
+++ b/src/course-outline/subsection-card/SubsectionCard.jsx
@@ -1,12 +1,12 @@
// @ts-check
import React, {
- useContext, useEffect, useState, useRef,
+ useContext, useEffect, useState, useRef, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { Button, useToggle } from '@openedx/paragon';
+import { Button, StandardModal, useToggle } from '@openedx/paragon';
import { Add as IconAdd } from '@openedx/paragon/icons';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
@@ -22,6 +22,11 @@ import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
import messages from './messages';
+import { ComponentPicker } from '../../library-authoring';
+import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
+import { ContainerType } from '../../generic/key-utils';
+import { useStudioHome } from '../../studio-home/hooks';
+import { ContentType } from '../../library-authoring/routes';
const SubsectionCard = ({
section,
@@ -37,6 +42,7 @@ const SubsectionCard = ({
onOpenDeleteModal,
onDuplicateSubmit,
onNewUnitSubmit,
+ onAddUnitFromLibrary,
onOrderChange,
onOpenConfigureModal,
onPasteClick,
@@ -51,6 +57,12 @@ const SubsectionCard = ({
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'subsection';
const { sharedClipboardData, showPasteUnit } = useClipboard();
+ const { librariesV2Enabled } = useStudioHome();
+ const [
+ isAddLibraryUnitModalOpen,
+ openAddLibraryUnitModal,
+ closeAddLibraryUnitModal,
+ ] = useToggle(false);
const {
id,
@@ -172,90 +184,129 @@ const SubsectionCard = ({
&& !(isHeaderVisible === false)
);
+ const handleSelectLibraryUnit = useCallback((selectedUnit) => {
+ onAddUnitFromLibrary({
+ type: COMPONENT_TYPES.libraryV2,
+ category: ContainerType.Vertical,
+ parentLocator: id,
+ libraryContentKey: selectedUnit.usageKey,
+ });
+ closeAddLibraryUnitModal();
+ }, []);
+
return (
-
-
+
- {isHeaderVisible && (
- <>
-
-
-
+ {isHeaderVisible && (
+ <>
+
-
- >
- )}
- {isExpanded && (
-
- {children}
- {actions.childAddable && (
- <>
-
- {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
-
+
+
+ >
+ )}
+ {isExpanded && (
+
+ {children}
+ {actions.childAddable && (
+ <>
+
- )}
- >
- )}
-
- )}
-
-
+ variant="outline-primary"
+ iconBefore={IconAdd}
+ block
+ onClick={handleNewButtonClick}
+ >
+ {intl.formatMessage(messages.newUnitButton)}
+
+ {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
+
+ )}
+ {librariesV2Enabled && (
+
+ )}
+ >
+ )}
+
+ )}
+
+
+
+
+
+ >
);
};
@@ -306,6 +357,7 @@ SubsectionCard.propTypes = {
onOpenDeleteModal: PropTypes.func.isRequired,
onDuplicateSubmit: PropTypes.func.isRequired,
onNewUnitSubmit: PropTypes.func.isRequired,
+ onAddUnitFromLibrary: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
getPossibleMoves: PropTypes.func.isRequired,
onOrderChange: PropTypes.func.isRequired,
diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx
index 4979506ab2..ba15189381 100644
--- a/src/course-outline/subsection-card/SubsectionCard.test.jsx
+++ b/src/course-outline/subsection-card/SubsectionCard.test.jsx
@@ -1,6 +1,6 @@
import { MemoryRouter } from 'react-router-dom';
import {
- act, render, fireEvent, within,
+ act, render, fireEvent, within, screen,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -10,9 +10,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import initializeStore from '../../store';
import SubsectionCard from './SubsectionCard';
import cardHeaderMessages from '../card-header/messages';
+import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
let store;
const mockPathname = '/foo-bar';
+const containerKey = 'lct:org:lib:unit:1';
+const handleOnAddUnitFromLibrary = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -21,6 +24,30 @@ jest.mock('react-router-dom', () => ({
}),
}));
+jest.mock('../../studio-home/hooks', () => ({
+ useStudioHome: () => ({
+ librariesV2Enabled: true,
+ }),
+}));
+
+// Mock ComponentPicker to call onComponentSelected on click
+jest.mock('../../library-authoring/component-picker', () => ({
+ ComponentPicker: (props) => {
+ const onClick = () => {
+ // eslint-disable-next-line react/prop-types
+ props.onComponentSelected({
+ usageKey: containerKey,
+ blockType: 'unti',
+ });
+ };
+ return (
+
+ );
+ },
+}));
+
const unit = {
id: 'unit-1',
};
@@ -80,6 +107,7 @@ const renderComponent = (props, entry = '/') => render(
onOpenHighlightsModal={jest.fn()}
onOpenDeleteModal={jest.fn()}
onNewUnitSubmit={jest.fn()}
+ onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
isCustomRelativeDatesActive={false}
onEditClick={jest.fn()}
savingStatus=""
@@ -247,4 +275,31 @@ describe('', () => {
expect(cardUnits).toBeNull();
expect(newUnitButton).toBeNull();
});
+
+ it('should add unit from library', async () => {
+ renderComponent();
+
+ const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn');
+ fireEvent.click(expandButton);
+
+ const useUnitFromLibraryButton = screen.getByRole('button', {
+ name: /use unit from library/i,
+ });
+ expect(useUnitFromLibraryButton).toBeInTheDocument();
+ fireEvent.click(useUnitFromLibraryButton);
+
+ expect(await screen.findByText('Select unit'));
+
+ // click dummy button to execute onComponentSelected prop.
+ const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
+ fireEvent.click(dummyBtn);
+
+ expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
+ expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
+ type: COMPONENT_TYPES.libraryV2,
+ parentLocator: '123',
+ category: 'vertical',
+ libraryContentKey: containerKey,
+ });
+ });
});
diff --git a/src/course-outline/subsection-card/messages.js b/src/course-outline/subsection-card/messages.js
index f741b02ecc..d932382d43 100644
--- a/src/course-outline/subsection-card/messages.js
+++ b/src/course-outline/subsection-card/messages.js
@@ -4,10 +4,22 @@ const messages = defineMessages({
newUnitButton: {
id: 'course-authoring.course-outline.subsection.button.new-unit',
defaultMessage: 'New unit',
+ description: 'Message of the button to create a new unit in a subsection.',
},
pasteButton: {
id: 'course-authoring.course-outline.subsection.button.paste-unit',
defaultMessage: 'Paste unit',
+ description: 'Message of the button to paste a new unit in a subsection.',
+ },
+ useUnitFromLibraryButton: {
+ id: 'course-authoring.course-outline.subsection.button.use-unit-from-library',
+ defaultMessage: 'Use unit from library',
+ description: 'Message of the button to add a new unit from a library in a subsection.',
+ },
+ unitPickerModalTitle: {
+ id: 'course-authoring.course-outline.subsection.unit.modal.single-title.text',
+ defaultMessage: 'Select unit',
+ description: 'Library unit picker modal title.',
},
});
diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts
index 32da743daf..eeb5202d20 100644
--- a/src/generic/key-utils.ts
+++ b/src/generic/key-utils.ts
@@ -52,6 +52,15 @@ export const buildCollectionUsageKey = (learningContextKey: string, collectionId
export enum ContainerType {
Unit = 'unit',
+ /**
+ * Vertical is the old name for Unit. Generally, **please avoid using this term entirely in any libraries code** or
+ * anything based on the new Learning Core "Containers" framework - just call it a unit. We do still need to use this
+ * in the modulestore-based courseware, and currently the /xblock/ API used to copy library containers into courses
+ * also requires specifying this, though that should change to a better API that does the unit->vertical conversion
+ * automatically in the future.
+ * TODO: we should probably move this to a separate enum/mapping, and keep this for the new container types only.
+ */
+ Vertical = 'vertical',
}
/**
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index f7c242a742..43823a3a5b 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -43,7 +43,7 @@ import { LibrarySidebar } from './library-sidebar';
import { useComponentPickerContext } from './common/context/ComponentPickerContext';
import { useLibraryContext } from './common/context/LibraryContext';
import { SidebarBodyComponentId, useSidebarContext } from './common/context/SidebarContext';
-import { ContentType, useLibraryRoutes } from './routes';
+import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes';
import messages from './messages';
@@ -129,9 +129,13 @@ export const SubHeaderTitle = ({ title }: { title: ReactNode }) => {
interface LibraryAuthoringPageProps {
returnToLibrarySelection?: () => void,
+ visibleTabs?: ContentType[],
}
-const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPageProps) => {
+const LibraryAuthoringPage = ({
+ returnToLibrarySelection,
+ visibleTabs = allLibraryPageTabs,
+}: LibraryAuthoringPageProps) => {
const intl = useIntl();
const {
@@ -163,7 +167,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
// The activeKey determines the currently selected tab.
const getActiveKey = () => {
if (componentPickerMode) {
- return ContentType.home;
+ return visibleTabs[0];
}
if (insideCollections) {
return ContentType.collections;
@@ -245,6 +249,16 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
// Disable filtering by block/problem type when viewing the Collections tab.
const overrideTypesFilter = (insideCollections || insideUnits) ? new TypesFilterData() : undefined;
+ const tabTitles = {
+ [ContentType.home]: intl.formatMessage(messages.homeTab),
+ [ContentType.collections]: intl.formatMessage(messages.collectionsTab),
+ [ContentType.components]: intl.formatMessage(messages.componentsTab),
+ [ContentType.units]: intl.formatMessage(messages.unitsTab),
+ };
+ const visibleTabsToRender = visibleTabs.map((contentType) => (
+
+ ));
+
return (
@@ -279,10 +293,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
onSelect={handleTabChange}
className="my-3"
>
-
-
-
-
+ {visibleTabsToRender}
diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx
index f9f9fc132e..8e9fbbaf9d 100644
--- a/src/library-authoring/component-picker/ComponentPicker.test.tsx
+++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx
@@ -17,6 +17,7 @@ import {
} from '../data/api.mocks';
import { ComponentPicker } from './ComponentPicker';
+import { ContentType } from '../routes';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -276,4 +277,29 @@ describe('', () => {
// Wait for the content library to load
await screen.findByText(/Only published content is visible and available for reuse./i);
});
+
+ it('should display all tabs', async () => {
+ // Default `visibleTabs = allLibraryPageTabs`
+ render();
+
+ expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
+ fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
+
+ expect(await screen.findByRole('tab', { name: /all content/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /collections/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /components/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /units/i })).toBeInTheDocument();
+ });
+
+ it('should display only unit tab', async () => {
+ render();
+
+ expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
+ fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
+
+ expect(await screen.findByRole('tab', { name: /units/i })).toBeInTheDocument();
+ expect(screen.queryByRole('tab', { name: /all content/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('tab', { name: /collections/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('tab', { name: /components/i })).not.toBeInTheDocument();
+ });
});
diff --git a/src/library-authoring/component-picker/ComponentPicker.tsx b/src/library-authoring/component-picker/ComponentPicker.tsx
index ec99b5fe42..9509832861 100644
--- a/src/library-authoring/component-picker/ComponentPicker.tsx
+++ b/src/library-authoring/component-picker/ComponentPicker.tsx
@@ -14,18 +14,28 @@ import LibraryAuthoringPage from '../LibraryAuthoringPage';
import LibraryCollectionPage from '../collections/LibraryCollectionPage';
import SelectLibrary from './SelectLibrary';
import messages from './messages';
+import { ContentType, allLibraryPageTabs } from '../routes';
interface LibraryComponentPickerProps {
returnToLibrarySelection: () => void;
+ visibleTabs: ContentType[],
}
-const InnerComponentPicker: React.FC = ({ returnToLibrarySelection }) => {
+const InnerComponentPicker: React.FC = ({
+ returnToLibrarySelection,
+ visibleTabs,
+}) => {
const { collectionId } = useLibraryContext();
if (collectionId) {
return ;
}
- return ;
+ return (
+
+ );
};
/** Default handler in single-select mode. Used by the legacy UI for adding a single selected component to a course. */
@@ -38,7 +48,12 @@ const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selecti
window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*');
};
-type ComponentPickerProps = { libraryId?: string, showOnlyPublished?: boolean, extraFilter?: string[] } & (
+type ComponentPickerProps = {
+ libraryId?: string,
+ showOnlyPublished?: boolean,
+ extraFilter?: string[],
+ visibleTabs?: ContentType[],
+} & (
{
componentPickerMode?: 'single',
onComponentSelected?: ComponentSelectedEvent,
@@ -56,6 +71,7 @@ export const ComponentPicker: React.FC = ({
showOnlyPublished,
extraFilter,
componentPickerMode = 'single',
+ visibleTabs = allLibraryPageTabs,
/** This default callback is used to send the selected component back to the parent window,
* when the component picker is used in an iframe.
*/
@@ -116,7 +132,10 @@ export const ComponentPicker: React.FC = ({
)}
-
+
diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts
index 586c3df883..1bee5219ab 100644
--- a/src/library-authoring/routes.ts
+++ b/src/library-authoring/routes.ts
@@ -41,6 +41,8 @@ export enum ContentType {
units = 'units',
}
+export const allLibraryPageTabs: ContentType[] = Object.values(ContentType);
+
export type NavigateToData = {
componentId?: string,
collectionId?: string,