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,