feat: Add unit from library into course (#1829)
* feat: Initial worflow to add unit to course * test: Add initial tests * feat: Show only published units * test: Update Subsection card test and ComponentPicker tests * feat: Connect add unit from library API * test: Test for Add unit from library in CourseOutline * fix: create a new Vertical from a Library Unit * docs: add a little note about avoiding 'vertical' where possible * refactor: Use visibleTabs instead of showOnlyHomeTab --------- Co-authored-by: Jillian Vogel <jill@opencraft.com> Co-authored-by: Braden MacDonald <braden@opencraft.com>
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<button type="submit" onClick={onClick}>
|
||||
Dummy button
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
@@ -390,6 +417,42 @@ describe('<CourseOutline />', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('adds a unit from library correctly', async () => {
|
||||
render(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
key={id}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#f8f7f6',
|
||||
...borderStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`subsection-card ${isScrolledToElement ? 'highlight' : ''}`}
|
||||
data-testid="subsection-card"
|
||||
ref={currentRef}
|
||||
<>
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
key={id}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#f8f7f6',
|
||||
...borderStyle,
|
||||
}}
|
||||
>
|
||||
{isHeaderVisible && (
|
||||
<>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={subsectionStatus}
|
||||
cardId={id}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleSubsectionMoveUp}
|
||||
onClickMoveDown={handleSubsectionMoveDown}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
proctoringExamConfigurationLink={proctoringExamConfigurationLink}
|
||||
isSequential
|
||||
extraActionsComponent={extraActionsComponent}
|
||||
/>
|
||||
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={subsection}
|
||||
<div
|
||||
className={`subsection-card ${isScrolledToElement ? 'highlight' : ''}`}
|
||||
data-testid="subsection-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
{isHeaderVisible && (
|
||||
<>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={subsectionStatus}
|
||||
cardId={id}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleSubsectionMoveUp}
|
||||
onClickMoveDown={handleSubsectionMoveDown}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
proctoringExamConfigurationLink={proctoringExamConfigurationLink}
|
||||
isSequential
|
||||
extraActionsComponent={extraActionsComponent}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div
|
||||
data-testid="subsection-card__units"
|
||||
className={classNames('subsection-card__units', { 'item-children': isDraggable })}
|
||||
>
|
||||
{children}
|
||||
{actions.childAddable && (
|
||||
<>
|
||||
<Button
|
||||
data-testid="new-unit-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewButtonClick}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={subsection}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div
|
||||
data-testid="subsection-card__units"
|
||||
className={classNames('subsection-card__units', { 'item-children': isDraggable })}
|
||||
>
|
||||
{children}
|
||||
{actions.childAddable && (
|
||||
<>
|
||||
<Button
|
||||
data-testid="new-unit-button"
|
||||
className="mt-4"
|
||||
text={intl.formatMessage(messages.pasteButton)}
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handlePasteButtonClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SortableItem>
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewButtonClick}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
className="mt-4"
|
||||
text={intl.formatMessage(messages.pasteButton)}
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handlePasteButtonClick}
|
||||
/>
|
||||
)}
|
||||
{librariesV2Enabled && (
|
||||
<Button
|
||||
data-testid="use-unit-from-library"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={openAddLibraryUnitModal}
|
||||
>
|
||||
{intl.formatMessage(messages.useUnitFromLibraryButton)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SortableItem>
|
||||
<StandardModal
|
||||
title={intl.formatMessage(messages.unitPickerModalTitle)}
|
||||
isOpen={isAddLibraryUnitModalOpen}
|
||||
onClose={closeAddLibraryUnitModal}
|
||||
isOverflowVisible={false}
|
||||
size="xl"
|
||||
>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={['block_type = "unit"']}
|
||||
componentPickerMode="single"
|
||||
onComponentSelected={handleSelectLibraryUnit}
|
||||
visibleTabs={[ContentType.units]}
|
||||
/>
|
||||
</StandardModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<button type="submit" onClick={onClick}>
|
||||
Dummy button
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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('<SubsectionCard />', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => (
|
||||
<Tab eventKey={contentType} title={tabTitles[contentType]} />
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
@@ -279,10 +293,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
onSelect={handleTabChange}
|
||||
className="my-3"
|
||||
>
|
||||
<Tab eventKey={ContentType.home} title={intl.formatMessage(messages.homeTab)} />
|
||||
<Tab eventKey={ContentType.collections} title={intl.formatMessage(messages.collectionsTab)} />
|
||||
<Tab eventKey={ContentType.components} title={intl.formatMessage(messages.componentsTab)} />
|
||||
<Tab eventKey={ContentType.units} title={intl.formatMessage(messages.unitsTab)} />
|
||||
{visibleTabsToRender}
|
||||
</Tabs>
|
||||
<ActionRow className="my-3">
|
||||
<SearchKeywordsField className="mr-3" />
|
||||
|
||||
@@ -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('<ComponentPicker />', () => {
|
||||
// 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(<ComponentPicker />);
|
||||
|
||||
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(<ComponentPicker visibleTabs={[ContentType.units]} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<LibraryComponentPickerProps> = ({ returnToLibrarySelection }) => {
|
||||
const InnerComponentPicker: React.FC<LibraryComponentPickerProps> = ({
|
||||
returnToLibrarySelection,
|
||||
visibleTabs,
|
||||
}) => {
|
||||
const { collectionId } = useLibraryContext();
|
||||
|
||||
if (collectionId) {
|
||||
return <LibraryCollectionPage />;
|
||||
}
|
||||
return <LibraryAuthoringPage returnToLibrarySelection={returnToLibrarySelection} />;
|
||||
return (
|
||||
<LibraryAuthoringPage
|
||||
returnToLibrarySelection={returnToLibrarySelection}
|
||||
visibleTabs={visibleTabs}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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<ComponentPickerProps> = ({
|
||||
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<ComponentPickerProps> = ({
|
||||
<FormattedMessage {...messages.pickerInfoBanner} />
|
||||
</Alert>
|
||||
)}
|
||||
<InnerComponentPicker returnToLibrarySelection={returnToLibrarySelection} />
|
||||
<InnerComponentPicker
|
||||
returnToLibrarySelection={returnToLibrarySelection}
|
||||
visibleTabs={visibleTabs}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</LibraryProvider>
|
||||
</ComponentPickerProvider>
|
||||
|
||||
@@ -41,6 +41,8 @@ export enum ContentType {
|
||||
units = 'units',
|
||||
}
|
||||
|
||||
export const allLibraryPageTabs: ContentType[] = Object.values(ContentType);
|
||||
|
||||
export type NavigateToData = {
|
||||
componentId?: string,
|
||||
collectionId?: string,
|
||||
|
||||
Reference in New Issue
Block a user