feat: add content in location in course outline [FC-0114] (#2820)

- Add container in-location in course outline using new add sidebar.
- Creates the placeholder card while creating a container
This commit is contained in:
Navin Karkera
2026-01-15 17:59:26 +05:30
committed by GitHub
parent 4cda17e046
commit a23a4da0a2
27 changed files with 936 additions and 435 deletions

View File

@@ -5,7 +5,6 @@ import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
import { getCourseItem } from '@src/course-outline/data/api';
import { useDispatch, useSelector } from 'react-redux';
import { addSection, addSubsection, updateSavingStatus } from '@src/course-outline/data/slice';
import { addNewSectionQuery, addNewSubsectionQuery, addNewUnitQuery } from '@src/course-outline/data/thunk';
import { useNavigate } from 'react-router';
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
import { RequestStatus, RequestStatusType } from './data/constants';
@@ -19,12 +18,9 @@ export type CourseAuthoringContextData = {
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
handleAddSectionFromLibrary: ReturnType<typeof useCreateCourseBlock>;
handleAddSubsectionFromLibrary: ReturnType<typeof useCreateCourseBlock>;
handleAddUnitFromLibrary: ReturnType<typeof useCreateCourseBlock>;
handleNewSectionSubmit: () => void;
handleNewSubsectionSubmit: (sectionId: string) => void;
handleNewUnitSubmit: (subsectionId: string) => void;
handleAddSection: ReturnType<typeof useCreateCourseBlock>;
handleAddSubsection: ReturnType<typeof useCreateCourseBlock>;
handleAddUnit: ReturnType<typeof useCreateCourseBlock>;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
};
@@ -76,19 +72,7 @@ export const CourseAuthoringProvider = ({
}
};
const handleNewSectionSubmit = () => {
dispatch(addNewSectionQuery(courseUsageKey));
};
const handleNewSubsectionSubmit = (sectionId: string) => {
dispatch(addNewSubsectionQuery(sectionId));
};
const handleNewUnitSubmit = (subsectionId: string) => {
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
};
const handleAddSectionFromLibrary = useCreateCourseBlock(async (locator) => {
const addSectionToCourse = async (locator: string) => {
try {
const data = await getCourseItem(locator);
// instanbul ignore next
@@ -98,9 +82,9 @@ export const CourseAuthoringProvider = ({
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});
};
const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => {
const addSubsectionToCourse = async (locator: string, parentLocator: string) => {
try {
const data = await getCourseItem(locator);
data.shouldScroll = true;
@@ -109,12 +93,14 @@ export const CourseAuthoringProvider = ({
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});
};
const handleAddSection = useCreateCourseBlock(addSectionToCourse);
const handleAddSubsection = useCreateCourseBlock(addSubsectionToCourse);
/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage);
const handleAddUnit = useCreateCourseBlock(openUnitPage);
const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
@@ -122,12 +108,9 @@ export const CourseAuthoringProvider = ({
courseDetails,
courseDetailStatus,
canChangeProviders,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSection,
handleAddSubsection,
handleAddUnit,
getUnitUrl,
openUnitPage,
}), [
@@ -136,12 +119,9 @@ export const CourseAuthoringProvider = ({
courseDetails,
courseDetailStatus,
canChangeProviders,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSection,
handleAddSubsection,
handleAddUnit,
getUnitUrl,
openUnitPage,
]);

View File

@@ -70,6 +70,7 @@ const LegacyLibContentBlockAlert = ({ courseId }: Props) => {
target="_blank"
as={Hyperlink}
variant="tertiary"
key="learn-more"
showLaunchIcon={false}
destination={learnMoreUrl}
>
@@ -77,6 +78,7 @@ const LegacyLibContentBlockAlert = ({ courseId }: Props) => {
</Button>,
<LoadingButton
onClick={migrateFn}
key="migrate-button"
label={intl.formatMessage(messages.legacyLibReadyToMigrateAlertActionBtn)}
/>,
]}

View File

@@ -8,3 +8,7 @@
@import "./publish-modal/PublishModal";
@import "./xblock-status/XBlockStatus";
@import "./drag-helper/SortableItem";
.border-dashed {
border: dashed;
}

View File

@@ -354,8 +354,9 @@ describe('<CourseOutline />', () => {
});
it('adds new section correctly', async () => {
const { findAllByTestId } = renderComponent();
let elements = await findAllByTestId('section-card');
const user = userEvent.setup();
renderComponent();
let elements = await screen.findAllByTestId('section-card');
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
top: 0,
bottom: 4000,
@@ -378,9 +379,9 @@ describe('<CourseOutline />', () => {
.onGet(getXBlockApiUrl(courseSectionMock.id))
.reply(200, courseSectionMock);
const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0];
await act(async () => fireEvent.click(newSectionButton));
await user.click(newSectionButton);
elements = await findAllByTestId('section-card');
elements = await screen.findAllByTestId('section-card');
expect(elements.length).toBe(5);
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
});

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container,
Row,
TransitionReplace,
Toast,
StandardModal,
Button,
ActionRow,
} from '@openedx/paragon';
@@ -32,14 +31,11 @@ import { UnlinkModal } from '@src/generic/unlink-modal';
import AlertMessage from '@src/generic/alert-message';
import getPageHeadTitle from '@src/generic/utils';
import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot';
import { ContainerType } from '@src/generic/key-utils';
import { LibraryAndComponentPicker, SelectedComponent } from '@src/library-authoring';
import { ContentType } from '@src/library-authoring/routes';
import { NOTIFICATION_MESSAGES } from '@src/constants';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { XBlock } from '@src/data/types';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert';
import { ContainerType } from '@src/generic/key-utils';
import {
getCurrentItem,
getProctoredExamsFlag,
@@ -75,14 +71,13 @@ const CourseOutline = () => {
const location = useLocation();
const {
courseId,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSectionFromLibrary,
handleNewSectionSubmit,
courseUsageKey,
handleAddSubsection,
handleAddUnit,
handleAddSection,
} = useCourseAuthoringContext();
const {
courseUsageKey,
courseName,
savingStatus,
statusBarData,
@@ -114,9 +109,6 @@ const CourseOutline = () => {
headerNavigationsActions,
openEnableHighlightsModal,
closeEnableHighlightsModal,
isAddLibrarySectionModalOpen,
openAddLibrarySectionModal,
closeAddLibrarySectionModal,
handleEnableHighlightsSubmit,
handleInternetConnectionFailed,
handleOpenHighlightsModal,
@@ -243,16 +235,6 @@ const CourseOutline = () => {
}
};
const handleSelectLibrarySection = useCallback((selectedSection: SelectedComponent) => {
handleAddSectionFromLibrary.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Chapter,
parentLocator: courseUsageKey,
libraryContentKey: selectedSection.usageKey,
});
closeAddLibrarySectionModal();
}, [closeAddLibrarySectionModal, handleAddSectionFromLibrary.mutateAsync, courseId, courseUsageKey]);
useEffect(() => {
setSections(sectionsList);
}, [sectionsList]);
@@ -489,9 +471,9 @@ const CourseOutline = () => {
</DraggableList>
{courseActions.childAddable && (
<OutlineAddChildButtons
handleNewButtonClick={handleNewSectionSubmit}
handleUseFromLibraryClick={openAddLibrarySectionModal}
childType={ContainerType.Section}
parentLocator={courseUsageKey}
parentTitle={courseName}
/>
)}
</>
@@ -499,9 +481,9 @@ const CourseOutline = () => {
<EmptyPlaceholder>
{courseActions.childAddable && (
<OutlineAddChildButtons
handleNewButtonClick={handleNewSectionSubmit}
handleUseFromLibraryClick={openAddLibrarySectionModal}
childType={ContainerType.Section}
parentLocator={courseUsageKey}
parentTitle={courseName}
btnVariant="primary"
btnClasses="mt-1"
/>
@@ -558,30 +540,15 @@ const CourseOutline = () => {
close={closeUnlinkModal}
onUnlinkSubmit={handleUnlinkItemSubmit}
/>
<StandardModal
title={intl.formatMessage(messages.sectionPickerModalTitle)}
isOpen={isAddLibrarySectionModalOpen}
onClose={closeAddLibrarySectionModal}
isOverflowVisible={false}
size="xl"
>
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['block_type = "section"']}
componentPickerMode="single"
onComponentSelected={handleSelectLibrarySection}
visibleTabs={[ContentType.sections]}
/>
</StandardModal>
</Container>
<div className="alert-toast">
<ProcessingNotification
// Show processing toast if any mutation is running
isShow={
isShowProcessingNotification
|| handleAddUnitFromLibrary.isPending
|| handleAddSubsectionFromLibrary.isPending
|| handleAddSectionFromLibrary.isPending
|| handleAddUnit.isPending
|| handleAddSubsection.isPending
|| handleAddSection.isPending
}
// HACK: Use saving as default title till we have a need for better messages
title={processingNotificationTitle || NOTIFICATION_MESSAGES.saving}

View File

@@ -1,8 +1,10 @@
import userEvent from '@testing-library/user-event';
import { getConfig, setConfig } from '@edx/frontend-platform';
import { ContainerType } from '@src/generic/key-utils';
import {
initializeMocks, render, screen, waitFor,
} from '@src/testUtils';
import { OutlineFlow, OutlineFlowType, OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import OutlineAddChildButtons from './OutlineAddChildButtons';
jest.mock('react-redux', () => ({
@@ -10,6 +12,32 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }),
}));
const handleAddSection = { mutateAsync: jest.fn() };
const handleAddSubsection = { mutateAsync: jest.fn() };
const handleAddUnit = { mutateAsync: jest.fn() };
const courseUsageKey = 'some/usage/key';
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
courseUsageKey,
getUnitUrl: (id: string) => `/some/${id}`,
handleAddSection,
handleAddSubsection,
handleAddUnit,
}),
}));
const startCurrentFlow = jest.fn();
let currentFlow: OutlineFlow | null = null;
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext'),
useOutlineSidebarContext: () => ({
...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(),
startCurrentFlow,
currentFlow,
}),
}));
[
{ containerType: ContainerType.Section },
{ containerType: ContainerType.Subsection },
@@ -18,6 +46,10 @@ jest.mock('react-redux', () => ({
describe(`<OutlineAddChildButtons> for ${containerType}`, () => {
beforeEach(() => {
initializeMocks();
setConfig({
...getConfig(),
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true',
});
});
it('renders and behaves correctly', async () => {
@@ -27,7 +59,9 @@ jest.mock('react-redux', () => ({
handleNewButtonClick={newClickHandler}
handleUseFromLibraryClick={useFromLibClickHandler}
childType={containerType}
/>);
parentLocator=""
parentTitle=""
/>, { extraWrapper: OutlineSidebarProvider });
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
expect(newBtn).toBeInTheDocument();
@@ -38,5 +72,81 @@ jest.mock('react-redux', () => ({
await userEvent.click(useBtn);
await waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
});
it('calls appropriate new handlers', async () => {
const parentLocator = `parent-of-${containerType}`;
const parentTitle = `parent-title-of-${containerType}`;
render(<OutlineAddChildButtons
childType={containerType}
parentLocator={parentLocator}
parentTitle={parentTitle}
/>, { extraWrapper: OutlineSidebarProvider });
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
expect(newBtn).toBeInTheDocument();
await userEvent.click(newBtn);
switch (containerType) {
case ContainerType.Section:
await waitFor(() => expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: 'Section',
}));
break;
case ContainerType.Subsection:
await waitFor(() => expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
type: ContainerType.Sequential,
parentLocator,
displayName: 'Subsection',
}));
break;
case ContainerType.Unit:
await waitFor(() => expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({
type: ContainerType.Vertical,
parentLocator,
displayName: 'Unit',
}));
break;
default:
throw new Error(`Unknown container type: ${containerType}`);
}
});
it('calls appropriate use handlers', async () => {
const parentLocator = `parent-of-${containerType}`;
const parentTitle = `parent-title-of-${containerType}`;
render(<OutlineAddChildButtons
childType={containerType}
parentLocator={parentLocator}
parentTitle={parentTitle}
/>, { extraWrapper: OutlineSidebarProvider });
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
expect(useBtn).toBeInTheDocument();
await userEvent.click(useBtn);
await waitFor(() => expect(startCurrentFlow).toHaveBeenCalledWith({
flowType: `use-${containerType}`,
parentLocator,
parentTitle,
}));
});
it('shows appropriate static placeholder', async () => {
const parentLocator = `parent-of-${containerType}`;
const parentTitle = `parent-title-of-${containerType}`;
currentFlow = {
flowType: `use-${containerType}` as OutlineFlowType,
parentLocator,
parentTitle,
};
render(<OutlineAddChildButtons
childType={containerType}
parentLocator={parentLocator}
parentTitle={parentTitle}
/>, { extraWrapper: OutlineSidebarProvider });
// should show placeholder when use button is clicked
expect(await screen.findByRole('heading', {
name: new RegExp(`Adding Library ${containerType}`, 'i'),
})).toBeInTheDocument();
});
});
});

View File

@@ -1,22 +1,97 @@
import { Button, Stack } from '@openedx/paragon';
import { Add as IconAdd, Newsstand } from '@openedx/paragon/icons';
import {
Button, Col, IconButton, Row, Stack, StandardModal, useToggle,
} from '@openedx/paragon';
import { Add as IconAdd, Close, Newsstand } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { getStudioHomeData } from '@src/studio-home/data/selectors';
import { ContainerType } from '@src/generic/key-utils';
import { type OutlineFlowType, useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { LoadingSpinner } from '@src/generic/Loading';
import { useCallback } from 'react';
import { COURSE_BLOCK_NAMES } from '@src/constants';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { LibraryAndComponentPicker, type SelectedComponent } from '@src/library-authoring';
import { ContentType } from '@src/library-authoring/routes';
import { isOutlineNewDesignEnabled } from '@src/course-outline/utils';
import messages from './messages';
interface NewChildButtonsProps {
handleNewButtonClick: () => void;
handleUseFromLibraryClick: () => void;
/**
* Placeholder component that is displayed when a user clicks the "Use content from library" button.
* Shows a loading spinner when the component is selected and being added to the course.
* Finally it is hidden once the add component operation is complete and the content is successfully
* added to the course.
* @param props.parentLocator The locator of the parent flow item to which the content will be added.
*/
const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
const intl = useIntl();
const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
const {
handleAddSection,
handleAddSubsection,
handleAddUnit,
} = useCourseAuthoringContext();
if (!currentFlow || currentFlow.parentLocator !== parentLocator) {
return null;
}
const getTitle = () => {
switch (currentFlow?.flowType) {
case 'use-section':
return intl.formatMessage(messages.placeholderSectionText);
case 'use-subsection':
return intl.formatMessage(messages.placeholderSubsectionText);
case 'use-unit':
return intl.formatMessage(messages.placeholderUnitText);
default:
// istanbul ignore next: this should never happen
throw new Error('Unknown flow type');
}
};
return (
<Row
className="mx-0 py-3 px-4 border-dashed border-gray-500 shadow-lg rounded bg-white w-100"
>
<Col className="py-3">
<Stack direction="horizontal" gap={3}>
{(handleAddSection.isPending
|| handleAddSubsection.isPending
|| handleAddUnit.isPending) && (
<LoadingSpinner />
)}
<h3 className="mb-0">{getTitle()}</h3>
<IconButton
src={Close}
alt="Close"
onClick={stopCurrentFlow}
variant="dark"
className="ml-auto"
/>
</Stack>
</Col>
</Row>
);
};
interface BaseProps {
handleNewButtonClick?: () => void;
onClickCard?: (e: React.MouseEvent) => void;
childType: ContainerType;
btnVariant?: string;
btnClasses?: string;
btnSize?: 'sm' | 'md' | 'lg' | 'inline';
parentLocator: string;
}
const OutlineAddChildButtons = ({
interface NewChildButtonsProps extends BaseProps {
handleUseFromLibraryClick?: () => void;
parentTitle: string;
}
const NewOutlineAddChildButtons = ({
handleNewButtonClick,
handleUseFromLibraryClick,
onClickCard,
@@ -24,6 +99,8 @@ const OutlineAddChildButtons = ({
btnVariant = 'outline-primary',
btnClasses = 'mt-4 border-gray-500 rounded-0',
btnSize,
parentLocator,
parentTitle,
}: NewChildButtonsProps) => {
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
// as it has a useEffect that fetches course waffle flags whenever
@@ -32,59 +109,279 @@ const OutlineAddChildButtons = ({
// See https://github.com/openedx/frontend-app-authoring/pull/1938.
const { librariesV2Enabled } = useSelector(getStudioHomeData);
const intl = useIntl();
const {
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddUnit,
} = useCourseAuthoringContext();
const { startCurrentFlow } = useOutlineSidebarContext();
let messageMap = {
newButton: messages.newUnitButton,
importButton: messages.useUnitFromLibraryButton,
};
let onNewCreateContent: () => Promise<void>;
let flowType: OutlineFlowType;
// Based on the childType, determine the correct action and messages to display.
switch (childType) {
case ContainerType.Section:
messageMap = {
newButton: messages.newSectionButton,
importButton: messages.useSectionFromLibraryButton,
};
onNewCreateContent = () => handleAddSection.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
});
flowType = 'use-section';
break;
case ContainerType.Subsection:
messageMap = {
newButton: messages.newSubsectionButton,
importButton: messages.useSubsectionFromLibraryButton,
};
onNewCreateContent = () => handleAddSubsection.mutateAsync({
type: ContainerType.Sequential,
parentLocator,
displayName: COURSE_BLOCK_NAMES.sequential.name,
});
flowType = 'use-subsection';
break;
case ContainerType.Unit:
messageMap = {
newButton: messages.newUnitButton,
importButton: messages.useUnitFromLibraryButton,
};
onNewCreateContent = () => handleAddUnit.mutateAsync({
type: ContainerType.Vertical,
parentLocator,
displayName: COURSE_BLOCK_NAMES.vertical.name,
});
flowType = 'use-unit';
break;
default:
break;
// istanbul ignore next: unreachable
throw new Error(`Unrecognized block type ${childType}`);
}
/**
* Starts add flow in sidebar when `Use content from library` button is clicked.
*/
const onUseLibraryContent = useCallback(async () => {
startCurrentFlow({
flowType,
parentLocator,
parentTitle,
});
}, [
childType,
parentLocator,
parentTitle,
startCurrentFlow,
]);
return (
<Stack direction="horizontal" gap={3} onClick={onClickCard}>
<Button
className={btnClasses}
variant={btnVariant}
iconBefore={IconAdd}
size={btnSize}
block
onClick={handleNewButtonClick}
>
{intl.formatMessage(messageMap.newButton)}
</Button>
{librariesV2Enabled && (
<>
<AddPlaceholder parentLocator={parentLocator} />
<Stack direction="horizontal" gap={3} onClick={onClickCard}>
<Button
className={btnClasses}
variant={btnVariant}
iconBefore={Newsstand}
block
iconBefore={IconAdd}
size={btnSize}
onClick={handleUseFromLibraryClick}
block
onClick={handleNewButtonClick || onNewCreateContent}
>
{intl.formatMessage(messageMap.importButton)}
{intl.formatMessage(messageMap.newButton)}
</Button>
)}
</Stack>
{librariesV2Enabled && (
<Button
className={btnClasses}
variant={btnVariant}
iconBefore={Newsstand}
block
size={btnSize}
onClick={handleUseFromLibraryClick || onUseLibraryContent}
>
{intl.formatMessage(messageMap.importButton)}
</Button>
)}
</Stack>
</>
);
};
/**
* Legacy component for adding child blocks in Studio.
* Uses the old flow of opening a modal to allow user to select content from library.
*/
const LegacyOutlineAddChildButtons = ({
handleNewButtonClick,
childType,
btnVariant = 'outline-primary',
btnClasses = 'mt-4 border-gray-500 rounded-0',
btnSize,
parentLocator,
onClickCard,
}: BaseProps) => {
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
// as it has a useEffect that fetches course waffle flags whenever
// location.search is updated. Course search updates location.search when
// user types, which will then trigger the useEffect and reload the page.
// See https://github.com/openedx/frontend-app-authoring/pull/1938.
const { librariesV2Enabled } = useSelector(getStudioHomeData);
const intl = useIntl();
const {
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddUnit,
} = useCourseAuthoringContext();
const [
isAddLibrarySectionModalOpen,
openAddLibrarySectionModal,
closeAddLibrarySectionModal,
] = useToggle(false);
let messageMap = {
newButton: messages.newUnitButton,
importButton: messages.useUnitFromLibraryButton,
modalTitle: messages.unitPickerModalTitle,
};
let onNewCreateContent: () => Promise<void>;
let onUseLibraryContent: (selected: SelectedComponent) => Promise<void>;
let visibleTabs: ContentType[] = [];
let query: string[] = [];
switch (childType) {
case ContainerType.Section:
messageMap = {
newButton: messages.newSectionButton,
importButton: messages.useSectionFromLibraryButton,
modalTitle: messages.sectionPickerModalTitle,
};
onNewCreateContent = () => handleAddSection.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddSection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Chapter,
parentLocator: courseUsageKey,
libraryContentKey: selected.usageKey,
});
visibleTabs = [ContentType.sections];
query = ['block_type = "section"'];
break;
case ContainerType.Subsection:
messageMap = {
newButton: messages.newSubsectionButton,
importButton: messages.useSubsectionFromLibraryButton,
modalTitle: messages.subsectionPickerModalTitle,
};
onNewCreateContent = () => handleAddSubsection.mutateAsync({
type: ContainerType.Sequential,
parentLocator,
displayName: COURSE_BLOCK_NAMES.sequential.name,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddSubsection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Sequential,
parentLocator,
libraryContentKey: selected.usageKey,
});
visibleTabs = [ContentType.subsections];
query = ['block_type = "subsection"'];
break;
case ContainerType.Unit:
messageMap = {
newButton: messages.newUnitButton,
importButton: messages.useUnitFromLibraryButton,
modalTitle: messages.unitPickerModalTitle,
};
onNewCreateContent = () => handleAddUnit.mutateAsync({
type: ContainerType.Vertical,
parentLocator,
displayName: COURSE_BLOCK_NAMES.vertical.name,
});
onUseLibraryContent = (selected: SelectedComponent) => handleAddUnit.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Vertical,
parentLocator,
libraryContentKey: selected.usageKey,
});
visibleTabs = [ContentType.units];
query = ['block_type = "unit"'];
break;
default:
// istanbul ignore next: unreachable
throw new Error(`Unrecognized block type ${childType}`);
}
const handleOnComponentSelected = (selected: SelectedComponent) => {
onUseLibraryContent(selected);
closeAddLibrarySectionModal();
};
return (
<>
<Stack direction="horizontal" gap={3} onClick={onClickCard}>
<Button
className={btnClasses}
variant={btnVariant}
iconBefore={IconAdd}
size={btnSize}
block
onClick={handleNewButtonClick || onNewCreateContent}
>
{intl.formatMessage(messageMap.newButton)}
</Button>
{librariesV2Enabled && (
<Button
className={btnClasses}
variant={btnVariant}
iconBefore={Newsstand}
block
size={btnSize}
onClick={openAddLibrarySectionModal}
>
{intl.formatMessage(messageMap.importButton)}
</Button>
)}
</Stack>
<StandardModal
title={intl.formatMessage(messageMap.modalTitle)}
isOpen={isAddLibrarySectionModalOpen}
onClose={closeAddLibrarySectionModal}
isOverflowVisible={false}
size="xl"
>
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={query}
componentPickerMode="single"
onComponentSelected={handleOnComponentSelected}
visibleTabs={visibleTabs}
/>
</StandardModal>
</>
);
};
/**
* Wrapper component that displays the correct component based on the configuration.
*/
const OutlineAddChildButtons = (props: NewChildButtonsProps) => {
const showNewActionsBar = isOutlineNewDesignEnabled();
if (showNewActionsBar) {
return (
<NewOutlineAddChildButtons {...props} />
);
}
return (
<LegacyOutlineAddChildButtons {...props} />
);
};

View File

@@ -26,8 +26,8 @@ export const useCreateCourseBlock = (
callback?: ((locator: string, parentLocator: string) => void),
) => useMutation({
mutationFn: createCourseXblock,
onSettled: async (data: { locator: string, parent_locator: string }) => {
callback?.(data.locator, data.parent_locator);
onSettled: async (data: { locator: string }, _err, variables) => {
callback?.(data.locator, variables.parentLocator);
},
});

View File

@@ -5,7 +5,6 @@ import {
hideProcessingNotification,
showProcessingNotification,
} from '@src/generic/processing-notification/data/slice';
import { COURSE_BLOCK_NAMES } from '../constants';
import {
getCourseBestPracticesChecklist,
getCourseLaunchChecklist,
@@ -30,11 +29,9 @@ import {
setVideoSharingOption,
setCourseItemOrderList,
pasteBlock,
dismissNotification, createDiscussionsTopics, createCourseXblock,
dismissNotification, createDiscussionsTopics,
} from './api';
import {
addSection,
addSubsection,
fetchOutlineIndexSuccess,
updateOutlineIndexLoadingStatus,
updateReindexLoadingStatus,
@@ -516,81 +513,6 @@ export function duplicateUnitQuery(unitId: string, subsectionId: string, section
};
}
/**
* Generic function to add any course item. See wrapper functions below for specific implementations.
*/
function addNewCourseItemQuery(
parentLocator: string,
category: string,
displayName: string,
addItemFn: (data: any) => Promise<any>,
) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await createCourseXblock({
parentLocator,
type: category,
displayName,
}).then(async (result) => {
if (result) {
await addItemFn(result);
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
}
});
} catch {
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}
export function addNewSectionQuery(parentLocator: string) {
return async (dispatch) => {
dispatch(addNewCourseItemQuery(
parentLocator,
COURSE_BLOCK_NAMES.chapter.id,
COURSE_BLOCK_NAMES.chapter.name,
async (result) => {
const data = await getCourseItem(result.locator);
// Page should scroll to newly created section.
data.shouldScroll = true;
dispatch(addSection(data));
},
));
};
}
export function addNewSubsectionQuery(parentLocator: string) {
return async (dispatch) => {
dispatch(addNewCourseItemQuery(
parentLocator,
COURSE_BLOCK_NAMES.sequential.id,
COURSE_BLOCK_NAMES.sequential.name,
async (result) => {
const data = await getCourseItem(result.locator);
// Page should scroll to newly created subsection.
data.shouldScroll = true;
dispatch(addSubsection({ parentLocator, data }));
},
));
};
}
export function addNewUnitQuery(parentLocator: string, callback: { (locator: any): void }) {
return async (dispatch) => {
dispatch(addNewCourseItemQuery(
parentLocator,
COURSE_BLOCK_NAMES.vertical.id,
COURSE_BLOCK_NAMES.vertical.name,
async (result) => callback(result.locator),
));
};
}
function setBlockOrderListQuery(
parentId: string,
blockIds: string[],

View File

@@ -5,11 +5,12 @@ import {
import {
Add as IconAdd, FindInPage, ViewSidebar,
} from '@openedx/paragon/icons';
import { OUTLINE_SIDEBAR_PAGES } from '@src/course-outline/outline-sidebar/constants';
import { OutlinePageErrors, XBlockActions } from '@src/data/types';
import type { SidebarPage } from '@src/generic/sidebar';
import { useOutlineSidebarContext, OutlineSidebarPageKeys } from '../outline-sidebar/OutlineSidebarContext';
import { type OutlineSidebarPageKeys, useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
import messages from './messages';
@@ -29,7 +30,7 @@ const HeaderActions = ({
const intl = useIntl();
const { lmsLink } = actions;
const { setCurrentPageKey, sidebarPages } = useOutlineSidebarContext();
const { setCurrentPageKey } = useOutlineSidebarContext();
return (
<Stack direction="horizontal" gap={3}>
@@ -79,7 +80,7 @@ const HeaderActions = ({
<Icon src={ViewSidebar} />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
{Object.entries(sidebarPages).filter(([, page]) => !page.hideFromActionMenu)
{Object.entries(OUTLINE_SIDEBAR_PAGES).filter(([, page]) => !page.hideFromActionMenu)
.map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => (
<Dropdown.Item
key={key}
@@ -87,7 +88,7 @@ const HeaderActions = ({
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
{page.title}
{intl.formatMessage(page.title)}
</Stack>
</Dropdown.Item>
))}

View File

@@ -10,6 +10,7 @@ import { RequestStatus } from '@src/data/constants';
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { ContainerType } from '@src/generic/key-utils';
import { COURSE_BLOCK_NAMES } from './constants';
import {
setCurrentItem,
@@ -62,7 +63,7 @@ import { containerComparisonQueryKeys } from '../container-comparison/data/apiHo
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
const queryClient = useQueryClient();
const { handleNewSectionSubmit } = useCourseAuthoringContext();
const { handleAddSection } = useCourseAuthoringContext();
const {
reindexLink,
@@ -99,11 +100,6 @@ const useCourseOutline = ({ courseId }) => {
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
const [
isAddLibrarySectionModalOpen,
openAddLibrarySectionModal,
closeAddLibrarySectionModal,
] = useToggle(false);
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
@@ -116,7 +112,13 @@ const useCourseOutline = ({ courseId }) => {
};
const headerNavigationsActions = {
handleNewSection: handleNewSectionSubmit,
handleNewSection: () => {
handleAddSection.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseStructure?.id,
displayName: COURSE_BLOCK_NAMES.chapter.name,
});
},
handleReIndex: () => {
setDisableReindexButton(true);
setShowSuccessAlert(false);
@@ -316,9 +318,6 @@ const useCourseOutline = ({ courseId }) => {
closePublishModal,
isConfigureModalOpen,
openConfigureModal,
isAddLibrarySectionModalOpen,
openAddLibrarySectionModal,
closeAddLibrarySectionModal,
handleConfigureModalClose,
headerNavigationsActions,
handleEnableHighlightsSubmit,

View File

@@ -76,6 +76,31 @@ const messages = defineMessages({
defaultMessage: 'Select section',
description: 'Section modal picker title text in outline',
},
unitPickerModalTitle: {
id: 'course-authoring.course-outline.subsection.unit.modal.single-title.text',
defaultMessage: 'Select unit',
description: 'Library unit picker modal title.',
},
subsectionPickerModalTitle: {
id: 'course-authoring.course-outline.section.subsection-modal.title',
defaultMessage: 'Select subsection',
description: 'Subsection modal picker title text in outline',
},
placeholderSectionText: {
id: 'course-authoring.course-outline.placeholder.section.title',
defaultMessage: 'Adding Library Section',
description: 'Placeholder section text while adding library section',
},
placeholderSubsectionText: {
id: 'course-authoring.course-outline.placeholder.subsection.title',
defaultMessage: 'Adding Library Subsection',
description: 'Placeholder subsection text while adding library subsection',
},
placeholderUnitText: {
id: 'course-authoring.course-outline.placeholder.unit.title',
defaultMessage: 'Adding Library Unit',
description: 'Placeholder unit text while adding library unit',
},
});
export default messages;

View File

@@ -1,8 +1,10 @@
import { courseOutlineIndexMock } from '@src/course-outline/__mocks__';
import { initializeMocks, render, screen } from '@src/testUtils';
import {
initializeMocks, render, screen, waitFor,
} from '@src/testUtils';
import { userEvent } from '@testing-library/user-event';
import mockResult from '@src/library-authoring/__mocks__/library-search.json';
import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock';
import { mockContentSearchConfig } from '@src/search-manager/data/api.mock';
import {
mockContentLibrary,
mockGetCollectionMetadata,
@@ -10,14 +12,17 @@ import {
mockGetContentLibraryV2List,
mockLibraryBlockMetadata,
} from '@src/library-authoring/data/api.mocks';
import {
type OutlineFlow,
type OutlineFlowType,
OutlineSidebarProvider,
} from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import fetchMock from 'fetch-mock-jest';
import { AddSidebar } from './AddSidebar';
const handleNewSectionSubmit = jest.fn();
const handleNewSubsectionSubmit = jest.fn();
const handleNewUnitSubmit = jest.fn();
const handleAddSectionFromLibrary = { mutateAsync: jest.fn() };
const handleAddSubsectionFromLibrary = { mutateAsync: jest.fn() };
const handleAddUnitFromLibrary = { mutateAsync: jest.fn() };
const handleAddSection = { mutateAsync: jest.fn() };
const handleAddSubsection = { mutateAsync: jest.fn() };
const handleAddUnit = { mutateAsync: jest.fn() };
mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetCollectionMetadata.applyMock();
@@ -25,17 +30,15 @@ mockGetContentLibraryV2List.applyMock();
mockLibraryBlockMetadata.applyMock();
mockGetContainerMetadata.applyMock();
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
courseUsageKey: 'course-usage-key',
courseDetails: { name: 'Test course' },
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleNewSectionSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSection,
handleAddSubsection,
handleAddUnit,
}),
}));
@@ -52,7 +55,16 @@ jest.mock('@src/studio-home/hooks', () => ({
}),
}));
const renderComponent = () => render(<AddSidebar />);
let currentFlow: OutlineFlow | null = null;
jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({
...jest.requireActual('../outline-sidebar/OutlineSidebarContext'),
useOutlineSidebarContext: () => ({
...jest.requireActual('../outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(),
currentFlow,
}),
}));
const renderComponent = () => render(<AddSidebar />, { extraWrapper: OutlineSidebarProvider });
const searchResult = {
...mockResult,
results: [
@@ -71,8 +83,20 @@ const searchResult = {
describe('AddSidebar component', () => {
beforeEach(() => {
initializeMocks();
mockSearchResult({
...searchResult,
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.mockReset();
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse((req.body ?? '') as string);
const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise Instantsearch will update the UI and change the query,
// leading to unexpected results in the test cases.
const newMockResult = { ...searchResult };
newMockResult.results[0].query = query;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResult;
});
});
@@ -113,6 +137,9 @@ describe('AddSidebar component', () => {
it('calls appropriate handlers on new button click', async () => {
const user = userEvent.setup();
const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children;
const lastSection = sectionList[3];
const lastSubsection = lastSection.childInfo.children[0];
renderComponent();
// Validate handler for adding section, subsection and unit
@@ -120,11 +147,23 @@ describe('AddSidebar component', () => {
const subsection = await screen.findByRole('button', { name: 'Subsection' });
const unit = await screen.findByRole('button', { name: 'Unit' });
await user.click(section);
expect(handleNewSectionSubmit).toHaveBeenCalled();
expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
type: 'chapter',
parentLocator: 'course-usage-key',
displayName: 'Section',
});
await user.click(subsection);
expect(handleNewSubsectionSubmit).toHaveBeenCalled();
expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
type: 'sequential',
parentLocator: lastSection.id,
displayName: 'Subsection',
});
await user.click(unit);
expect(handleNewUnitSubmit).toHaveBeenCalled();
expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({
type: 'vertical',
parentLocator: lastSubsection.id,
displayName: 'Unit',
});
});
it('calls appropriate handlers on existing button click', async () => {
@@ -140,7 +179,7 @@ describe('AddSidebar component', () => {
const addBtns = await screen.findAllByRole('button', { name: 'Add' });
// first one is unit as per mock
await user.click(addBtns[0]);
expect(handleAddUnitFromLibrary.mutateAsync).toHaveBeenCalledWith({
expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({
type: 'library_v2',
category: 'vertical',
parentLocator: lastSubsection.id,
@@ -148,7 +187,7 @@ describe('AddSidebar component', () => {
});
// second one is subsection as per mock
await user.click(addBtns[1]);
expect(handleAddSubsectionFromLibrary.mutateAsync).toHaveBeenCalledWith({
expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
type: 'library_v2',
category: 'sequential',
parentLocator: lastSection.id,
@@ -156,11 +195,56 @@ describe('AddSidebar component', () => {
});
// third one is section as per mock
await user.click(addBtns[2]);
expect(handleAddSectionFromLibrary.mutateAsync).toHaveBeenCalledWith({
expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
type: 'library_v2',
category: 'chapter',
parentLocator: 'course-usage-key',
libraryContentKey: searchResult.results[0].hits[2].usage_key,
});
});
['section', 'subsection', 'unit'].forEach((category) => {
it(`shows appropriate existing and new content based on ${category} use button click`, async () => {
const user = userEvent.setup();
const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children;
const firstSection = sectionList[0];
const firstSubsection = firstSection.childInfo.children[0];
currentFlow = {
flowType: `use-${category}` as OutlineFlowType,
parentLocator: category === 'subsection' ? firstSection.id : firstSubsection.id,
parentTitle: category === 'subsection' ? firstSection.displayName : firstSubsection.displayName!,
};
renderComponent();
// Check existing tab content is rendered by default
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse((req.body ?? '') as string);
const requestedFilter = requestData?.queries[0].filter;
return requestedFilter?.[2] === `block_type IN ["${category}"]`;
});
await user.click(await screen.findByRole('tab', { name: 'Add New' }));
// Only category button should be visible
const section = screen.queryByRole('button', { name: 'Section' });
const subsection = screen.queryByRole('button', { name: 'Subsection' });
const unit = screen.queryByRole('button', { name: 'Unit' });
switch (category) {
case 'section':
expect(section).toBeInTheDocument();
expect(subsection).not.toBeInTheDocument();
expect(unit).not.toBeInTheDocument();
break;
case 'subsection':
expect(section).not.toBeInTheDocument();
expect(subsection).toBeInTheDocument();
expect(unit).not.toBeInTheDocument();
break;
default:
expect(section).not.toBeInTheDocument();
expect(subsection).not.toBeInTheDocument();
expect(unit).toBeInTheDocument();
break;
}
});
});
});

View File

@@ -12,7 +12,9 @@ import {
import { getIconBorderStyleColor, getItemIcon } from '@src/generic/block-type-utils';
import { useSelector } from 'react-redux';
import { getSectionsList } from '@src/course-outline/data/selectors';
import { useCallback, useMemo } from 'react';
import {
useCallback, useEffect, useMemo, useState,
} from 'react';
import { ComponentSelectedEvent } from '@src/library-authoring/common/context/ComponentPickerContext';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { ContainerType } from '@src/generic/key-utils';
@@ -20,7 +22,9 @@ import type { XBlock } from '@src/data/types';
import { ContentType } from '@src/library-authoring/routes';
import { ComponentPicker } from '@src/library-authoring';
import { MultiLibraryProvider } from '@src/library-authoring/common/context/MultiLibraryContext';
import { COURSE_BLOCK_NAMES } from '@src/constants';
import messages from './messages';
import { useOutlineSidebarContext } from './OutlineSidebarContext';
type ContainerTypes = 'unit' | 'subsection' | 'section';
@@ -47,36 +51,55 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
const sectionsList = useSelector(getSectionsList);
const lastSection = getLastEditableParent(sectionsList);
const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []);
const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
const sectionParentId = currentFlow?.parentLocator || lastSection?.id;
const subsectionParentId = currentFlow?.parentLocator || lastSubsection?.id;
const {
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddUnit,
} = useCourseAuthoringContext();
const onCreateContent = useCallback(() => {
const onCreateContent = useCallback(async () => {
switch (blockType) {
case 'section':
handleNewSectionSubmit();
await handleAddSection.mutateAsync({
type: ContainerType.Chapter,
parentLocator: courseUsageKey,
displayName: COURSE_BLOCK_NAMES.chapter.name,
});
break;
case 'subsection':
if (lastSection) {
handleNewSubsectionSubmit(lastSection.id);
if (sectionParentId) {
await handleAddSubsection.mutateAsync({
type: ContainerType.Sequential,
parentLocator: sectionParentId,
displayName: COURSE_BLOCK_NAMES.sequential.name,
});
}
break;
case 'unit':
if (lastSubsection) {
handleNewUnitSubmit(lastSubsection.id);
if (subsectionParentId) {
await handleAddUnit.mutateAsync({
type: ContainerType.Vertical,
parentLocator: subsectionParentId,
displayName: COURSE_BLOCK_NAMES.vertical.name,
});
}
break;
default:
// istanbul ignore next: unreachable
throw new Error(`Unrecognized block type ${blockType}`);
}
stopCurrentFlow();
}, [
blockType,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
courseUsageKey,
handleAddSection,
handleAddSubsection,
handleAddUnit,
currentFlow,
lastSection,
lastSubsection,
]);
@@ -101,40 +124,77 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
/** Add New Content Tab Section */
const AddNewContent = () => {
const intl = useIntl();
const { currentFlow } = useOutlineSidebarContext();
const btns = useCallback(() => {
switch (currentFlow?.flowType) {
case 'use-section':
return (
<AddContentButton
name={intl.formatMessage(contentMessages.sectionButton)}
blockType="section"
/>
);
case 'use-subsection':
return (
<AddContentButton
name={intl.formatMessage(contentMessages.subsectionButton)}
blockType="subsection"
/>
);
case 'use-unit':
return (
<AddContentButton
name={intl.formatMessage(contentMessages.unitButton)}
blockType="unit"
/>
);
default:
return (
<>
<AddContentButton
name={intl.formatMessage(contentMessages.sectionButton)}
blockType="section"
/>
<AddContentButton
name={intl.formatMessage(contentMessages.subsectionButton)}
blockType="subsection"
/>
<AddContentButton
name={intl.formatMessage(contentMessages.unitButton)}
blockType="unit"
/>
</>
);
}
}, [currentFlow, intl]);
return (
<Stack gap={2}>
<AddContentButton
name={intl.formatMessage(contentMessages.sectionButton)}
blockType="section"
/>
<AddContentButton
name={intl.formatMessage(contentMessages.subsectionButton)}
blockType="subsection"
/>
<AddContentButton
name={intl.formatMessage(contentMessages.unitButton)}
blockType="unit"
/>
{btns()}
</Stack>
);
};
/** Add Existing Content Tab Section */
const ShowLibraryContent = () => {
const sectionsList: Array<XBlock> = useSelector(getSectionsList);
const lastSection = getLastEditableParent(sectionsList);
const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []);
const {
courseUsageKey,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSection,
handleAddSubsection,
handleAddUnit,
} = useCourseAuthoringContext();
const sectionsList: Array<XBlock> = useSelector(getSectionsList);
const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
const onComponentSelected: ComponentSelectedEvent = useCallback(({ usageKey, blockType }) => {
const lastSection = getLastEditableParent(sectionsList);
const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []);
const sectionParentId = currentFlow?.parentLocator || lastSection?.id;
const subsectionParentId = currentFlow?.parentLocator || lastSubsection?.id;
const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => {
switch (blockType) {
case 'section':
handleAddSectionFromLibrary.mutateAsync({
await handleAddSection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Chapter,
parentLocator: courseUsageKey,
@@ -142,50 +202,62 @@ const ShowLibraryContent = () => {
});
break;
case 'subsection':
if (lastSection) {
handleAddSubsectionFromLibrary.mutateAsync({
if (sectionParentId) {
await handleAddSubsection.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Sequential,
parentLocator: lastSection.id,
parentLocator: sectionParentId,
libraryContentKey: usageKey,
});
}
break;
case 'unit':
if (lastSubsection) {
handleAddUnitFromLibrary.mutateAsync({
if (subsectionParentId) {
await handleAddUnit.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Vertical,
parentLocator: lastSubsection.id,
parentLocator: subsectionParentId,
libraryContentKey: usageKey,
});
}
break;
default:
// istanbul ignore next: unreachable
// istanbul ignore next: should not happen
throw new Error(`Unrecognized block type ${blockType}`);
}
stopCurrentFlow();
}, [
courseUsageKey,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSection,
handleAddSubsection,
handleAddUnit,
lastSection,
lastSubsection,
currentFlow,
stopCurrentFlow,
]);
const allowedBlocks = useMemo(() => {
const blocks: ContainerTypes[] = ['section'];
if (lastSection) { blocks.push('subsection'); }
if (lastSubsection) { blocks.push('unit'); }
return blocks;
}, [lastSection, lastSubsection, sectionsList]);
switch (currentFlow?.flowType) {
case 'use-section':
return ['section'];
case 'use-subsection':
return ['subsection'];
case 'use-unit':
return ['unit'];
default:
if (lastSection) { blocks.push('subsection'); }
if (lastSubsection) { blocks.push('unit'); }
return blocks;
}
}, [lastSection, lastSubsection, sectionsList, currentFlow]);
return (
<MultiLibraryProvider>
<ComponentPicker
showOnlyPublished
extraFilter={[`block_type IN [${allowedBlocks.join(',')}]`]}
extraFilter={[`block_type IN ["${allowedBlocks.join('","')}"]`]}
visibleTabs={[ContentType.home]}
FiltersComponent={SidebarFilters}
onComponentSelected={onComponentSelected}
@@ -197,13 +269,22 @@ const ShowLibraryContent = () => {
/** Tabs Component */
const AddTabs = () => {
const intl = useIntl();
const { currentFlow } = useOutlineSidebarContext();
const [key, setKey] = useState('addNew');
useEffect(() => {
if (currentFlow) {
setKey('addExisting');
}
}, [currentFlow, setKey]);
return (
<Tabs
variant="tabs"
defaultActiveKey="addNew"
className="my-2 d-flex justify-content-around"
id="add-content-tabs"
activeKey={key}
onSelect={setKey}
mountOnEnter
>
<Tab eventKey="addNew" title={intl.formatMessage(messages.sidebarTabsAddNew)}>
<AddNewContent />
@@ -217,13 +298,25 @@ const AddTabs = () => {
/** Main Sidebar Component */
export const AddSidebar = () => {
const intl = useIntl();
const { courseDetails } = useCourseAuthoringContext();
const { currentFlow } = useOutlineSidebarContext();
const titleAndIcon = useMemo(() => {
switch (currentFlow?.flowType) {
case 'use-subsection':
return { title: currentFlow.parentTitle, icon: getItemIcon('section') };
case 'use-unit':
return { title: currentFlow.parentTitle, icon: getItemIcon('subsection') };
default:
return { title: courseDetails?.name || '', icon: SchoolOutline };
}
}, [currentFlow, intl, getItemIcon]);
return (
<div>
<SidebarTitle
title={courseDetails?.name || ''}
icon={SchoolOutline}
title={titleAndIcon.title}
icon={titleAndIcon.icon}
/>
<SidebarContent>
<SidebarSection>

View File

@@ -3,6 +3,7 @@ import { useMediaQuery } from 'react-responsive';
import { Sidebar } from '@src/generic/sidebar';
import { OUTLINE_SIDEBAR_PAGES } from '@src/course-outline/outline-sidebar/constants';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { useOutlineSidebarContext } from './OutlineSidebarContext';
import { isOutlineNewDesignEnabled } from '../utils';
@@ -15,7 +16,6 @@ const OutlineSideBar = () => {
setCurrentPageKey,
isOpen,
toggle,
sidebarPages,
} = useOutlineSidebarContext();
// Returns the previous help sidebar component if the waffle flag is disabled
@@ -31,7 +31,7 @@ const OutlineSideBar = () => {
return (
<Sidebar
pages={sidebarPages}
pages={OUTLINE_SIDEBAR_PAGES}
currentPageKey={currentPageKey}
setCurrentPageKey={setCurrentPageKey}
isOpen={isOpen}

View File

@@ -2,31 +2,36 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { HelpOutline, Info, Plus } from '@openedx/paragon/icons';
import type { SidebarPage } from '@src/generic/sidebar';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { OutlineInfoSidebar } from './OutlineInfoSidebar';
import messages from './messages';
import { AddSidebar } from './AddSidebar';
import { useStateWithUrlSearchParam } from '@src/hooks';
import { isOutlineNewDesignEnabled } from '../utils';
export type OutlineSidebarPageKeys = 'help' | 'info' | 'add';
export type OutlineSidebarPages = Record<OutlineSidebarPageKeys, SidebarPage>;
export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null;
export type OutlineFlow = {
flowType: 'use-section';
parentLocator?: string;
parentTitle?: string;
} | {
flowType: OutlineFlowType;
parentLocator: string;
parentTitle: string;
};
interface OutlineSidebarContextData {
currentPageKey: OutlineSidebarPageKeys;
setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void;
currentFlow: OutlineFlow | null;
startCurrentFlow: (flow: OutlineFlow) => void;
stopCurrentFlow: () => void;
isOpen: boolean;
open: () => void;
toggle: () => void;
sidebarPages: OutlineSidebarPages;
selectedContainerId?: string;
openContainerInfoSidebar: (containerId: string) => void;
}
@@ -34,9 +39,13 @@ interface OutlineSidebarContextData {
const OutlineSidebarContext = createContext<OutlineSidebarContextData | undefined>(undefined);
export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => {
const intl = useIntl();
const [currentPageKey, setCurrentPageKeyState] = useState<OutlineSidebarPageKeys>('info');
const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam<OutlineSidebarPageKeys>(
'info',
'sidebar',
(value: string) => value as OutlineSidebarPageKeys,
(value: OutlineSidebarPageKeys) => value,
);
const [currentFlow, setCurrentFlow] = useState<OutlineFlow | null>(null);
const [isOpen, open, , toggle] = useToggle(true);
const [selectedContainerId, setSelectedContainerId] = useState<string | undefined>();
@@ -47,35 +56,50 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
}
}, [setSelectedContainerId]);
/**
* Stops current add content flow.
* This will cause the sidebar to switch back to its normal state and clear out any placeholder containers.
*/
const stopCurrentFlow = useCallback(() => {
setCurrentFlow(null);
}, [setCurrentFlow]);
const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => {
setCurrentPageKeyState(pageKey);
setCurrentFlow(null);
open();
}, [open]);
}, [open, setCurrentFlow]);
const sidebarPages = {
info: {
component: OutlineInfoSidebar,
icon: Info,
title: intl.formatMessage(messages.sidebarButtonInfo),
},
help: {
component: OutlineHelpSidebar,
icon: HelpOutline,
title: intl.formatMessage(messages.sidebarButtonHelp),
},
add: {
component: AddSidebar,
icon: Plus,
title: intl.formatMessage(messages.sidebarButtonAdd),
hideFromActionMenu: true,
},
} satisfies OutlineSidebarPages;
/**
* Starts add content flow.
* The sidebar enters an add content flow which allows user to add content in a specific container.
* A placeholder container is added in the location when the flow is started.
*/
const startCurrentFlow = useCallback((flow: OutlineFlow) => {
setCurrentPageKey('add');
setCurrentFlow(flow);
}, [setCurrentFlow, setCurrentPageKey]);
useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
stopCurrentFlow();
}
};
window.addEventListener('keydown', handleEsc);
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, []);
const context = useMemo<OutlineSidebarContextData>(
() => ({
currentPageKey,
setCurrentPageKey,
sidebarPages,
currentFlow,
startCurrentFlow,
stopCurrentFlow,
isOpen,
open,
toggle,
@@ -85,7 +109,9 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
[
currentPageKey,
setCurrentPageKey,
sidebarPages,
currentFlow,
startCurrentFlow,
stopCurrentFlow,
isOpen,
open,
toggle,

View File

@@ -0,0 +1,28 @@
import { HelpOutline, Info, Plus } from '@openedx/paragon/icons';
import type { SidebarPage } from '@src/generic/sidebar';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { OutlineInfoSidebar } from './OutlineInfoSidebar';
import messages from './messages';
import { AddSidebar } from './AddSidebar';
import type { OutlineSidebarPageKeys } from './OutlineSidebarContext';
export type OutlineSidebarPages = Record<OutlineSidebarPageKeys, SidebarPage>;
export const OUTLINE_SIDEBAR_PAGES: OutlineSidebarPages = {
info: {
component: OutlineInfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
help: {
component: OutlineHelpSidebar,
icon: HelpOutline,
title: messages.sidebarButtonHelp,
},
add: {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
hideFromActionMenu: true,
},
};

View File

@@ -105,6 +105,21 @@ const messages = defineMessages({
defaultMessage: 'Add Existing',
description: 'Tab title for adding existing library components in outline using sidebar',
},
sidebarTabsAddExisitingSectionToParent: {
id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-existing-tab',
defaultMessage: 'Adding section to course',
description: 'Tab title for adding existing library section to a specific parent in outline using sidebar',
},
sidebarTabsAddExisitingSubsectionToParent: {
id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-existing-tab',
defaultMessage: 'Adding subsection to {name}',
description: 'Tab title for adding existing library subsection to a specific parent in outline using sidebar',
},
sidebarTabsAddExisitingUnitToParent: {
id: 'course-authoring.course-outline.sidebar.sidebar-section-add.add-existing-tab',
defaultMessage: 'Adding unit to {name}',
description: 'Tab title for adding existing library unit to a specific parent in outline using sidebar',
},
});
export default messages;

View File

@@ -2,9 +2,8 @@ import {
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
} from 'react';
import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Bubble, Button, StandardModal, useToggle,
Bubble, Button, useToggle,
} from '@openedx/paragon';
import { useSearchParams } from 'react-router-dom';
import classNames from 'classnames';
@@ -21,16 +20,13 @@ import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
import { ContainerType } from '@src/generic/key-utils';
import { LibraryAndComponentPicker, SelectedComponent } from '@src/library-authoring';
import { ContentType } from '@src/library-authoring/routes';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
import type { XBlock } from '@src/data/types';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import messages from './messages';
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
interface SectionCardProps {
section: XBlock,
@@ -72,23 +68,13 @@ const SectionCard = ({
resetScrollState,
}: SectionCardProps) => {
const currentRef = useRef(null);
const intl = useIntl();
const dispatch = useDispatch();
const { activeId, overId } = useContext(DragContext);
const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext();
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
const isScrolledToElement = locatorId === section.id;
const [
isAddLibrarySubsectionModalOpen,
openAddLibrarySubsectionModal,
closeAddLibrarySubsectionModal,
] = useToggle(false);
const {
courseId,
handleAddSubsectionFromLibrary,
handleNewSubsectionSubmit,
} = useCourseAuthoringContext();
const { courseId } = useCourseAuthoringContext();
const queryClient = useQueryClient();
// Expand the section if a search result should be shown/scrolled to
@@ -229,21 +215,6 @@ const SectionCard = ({
onOrderChange(index, index + 1);
};
/**
* Callback to handle the selection of a library subsection to be imported to course.
* @param {Object} selectedSubection - The selected subsection details.
* @returns {void}
*/
const handleSelectLibrarySubsection = useCallback((selectedSubection: SelectedComponent) => {
handleAddSubsectionFromLibrary.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Sequential,
parentLocator: id,
libraryContentKey: selectedSubection.usageKey,
});
closeAddLibrarySubsectionModal();
}, [id, handleAddSubsectionFromLibrary, closeAddLibrarySubsectionModal]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
closeForm();
@@ -382,10 +353,10 @@ const SectionCard = ({
{children}
{actions.childAddable && (
<OutlineAddChildButtons
handleNewButtonClick={() => handleNewSubsectionSubmit(id)}
handleUseFromLibraryClick={openAddLibrarySubsectionModal}
onClickCard={(e) => onClickCard(e, true)}
childType={ContainerType.Subsection}
parentLocator={section.id}
parentTitle={section.displayName}
/>
)}
</div>
@@ -393,21 +364,6 @@ const SectionCard = ({
</div>
</div>
</SortableItem>
<StandardModal
title={intl.formatMessage(messages.subsectionPickerModalTitle)}
isOpen={isAddLibrarySubsectionModalOpen}
onClose={closeAddLibrarySubsectionModal}
isOverflowVisible={false}
size="xl"
>
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['block_type = "subsection"']}
componentPickerMode="single"
onComponentSelected={handleSelectLibrarySubsection}
visibleTabs={[ContentType.subsections]}
/>
</StandardModal>
{blockSyncData && (
<PreviewLibraryXBlockChanges
blockData={blockSyncData}

View File

@@ -5,11 +5,6 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.section.badge.section-highlights',
defaultMessage: 'Section highlights',
},
subsectionPickerModalTitle: {
id: 'course-authoring.course-outline.section.subsection-modal.title',
defaultMessage: 'Select subsection',
description: 'Subsection modal picker title text in outline',
},
});
export default messages;

View File

@@ -4,13 +4,14 @@ import {
act, fireEvent, initializeMocks, render, screen, waitFor, within,
} from '@src/testUtils';
import { XBlock } from '@src/data/types';
import userEvent from '@testing-library/user-event';
import cardHeaderMessages from '../card-header/messages';
import SubsectionCard from './SubsectionCard';
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
let store;
const containerKey = 'lct:org:lib:unit:1';
const handleOnAddUnitFromLibrary = { mutateAsync: jest.fn() };
const handleOnAddUnitFromLibrary = { mutateAsync: jest.fn(), isPending: false };
const mockUseAcceptLibraryBlockChanges = jest.fn();
const mockUseIgnoreLibraryBlockChanges = jest.fn();
@@ -27,8 +28,9 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => ({
courseId: 5,
handleNewUnitSubmit: jest.fn(),
handleAddUnitFromLibrary: handleOnAddUnitFromLibrary,
handleAddUnit: handleOnAddUnitFromLibrary,
handleAddSubsection: {},
handleAddSection: {},
}),
}));
@@ -46,7 +48,7 @@ jest.mock('@src/library-authoring/component-picker', () => ({
// eslint-disable-next-line react/prop-types
props.onComponentSelected({
usageKey: containerKey,
blockType: 'unti',
blockType: 'unit',
});
};
return (
@@ -340,6 +342,11 @@ describe('<SubsectionCard />', () => {
});
it('should add unit from library', async () => {
setConfig({
...getConfig(),
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'false',
});
const user = userEvent.setup();
renderComponent();
const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn');
@@ -349,15 +356,14 @@ describe('<SubsectionCard />', () => {
name: /use unit from library/i,
});
expect(useUnitFromLibraryButton).toBeInTheDocument();
fireEvent.click(useUnitFromLibraryButton);
await user.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);
await user.click(dummyBtn);
expect(handleOnAddUnitFromLibrary.mutateAsync).toHaveBeenCalled();
expect(handleOnAddUnitFromLibrary.mutateAsync).toHaveBeenCalledWith({
type: COMPONENT_TYPES.libraryV2,
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',

View File

@@ -1,10 +1,10 @@
import React, {
import {
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
} from 'react';
import { useDispatch } from 'react-redux';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StandardModal, useToggle } from '@openedx/paragon';
import { useToggle } from '@openedx/paragon';
import { useQueryClient } from '@tanstack/react-query';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
@@ -20,18 +20,15 @@ import TitleButton from '@src/course-outline/card-header/TitleButton';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
import { LibraryAndComponentPicker, SelectedComponent } from '@src/library-authoring';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { ContainerType } from '@src/generic/key-utils';
import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
import { ContentType } from '@src/library-authoring/routes';
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
import type { XBlock } from '@src/data/types';
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
import messages from './messages';
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
interface SubsectionCardProps {
section: XBlock,
@@ -86,12 +83,7 @@ const SubsectionCard = ({
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
const namePrefix = 'subsection';
const { sharedClipboardData, showPasteUnit } = useClipboard();
const [
isAddLibraryUnitModalOpen,
openAddLibraryUnitModal,
closeAddLibraryUnitModal,
] = useToggle(false);
const { courseId, handleNewUnitSubmit, handleAddUnitFromLibrary } = useCourseAuthoringContext();
const { courseId } = useCourseAuthoringContext();
const queryClient = useQueryClient();
const {
@@ -187,7 +179,6 @@ const SubsectionCard = ({
onOrderChange(section, moveDownDetails);
};
const handleNewButtonClick = () => handleNewUnitSubmit(id);
const handlePasteButtonClick = () => onPasteClick(id, section.id);
const titleComponent = (
@@ -250,16 +241,6 @@ const SubsectionCard = ({
&& !section.upstreamInfo?.upstreamRef
);
const handleSelectLibraryUnit = useCallback((selectedUnit: SelectedComponent) => {
handleAddUnitFromLibrary.mutateAsync({
type: COMPONENT_TYPES.libraryV2,
category: ContainerType.Vertical,
parentLocator: id,
libraryContentKey: selectedUnit.usageKey,
});
closeAddLibraryUnitModal();
}, [id, handleAddUnitFromLibrary, closeAddLibraryUnitModal]);
const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => {
if (!preventNodeEvents || e.target === e.currentTarget) {
openContainerInfoSidebar(subsection.id);
@@ -357,10 +338,10 @@ const SubsectionCard = ({
{actions.childAddable && (
<>
<OutlineAddChildButtons
handleNewButtonClick={handleNewButtonClick}
handleUseFromLibraryClick={openAddLibraryUnitModal}
onClickCard={(e) => onClickCard(e, true)}
childType={ContainerType.Unit}
parentLocator={subsection.id}
parentTitle={subsection.displayName}
/>
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
<PasteComponent
@@ -376,21 +357,6 @@ const SubsectionCard = ({
)}
</div>
</SortableItem>
<StandardModal
title={intl.formatMessage(messages.unitPickerModalTitle)}
isOpen={isAddLibraryUnitModalOpen}
onClose={closeAddLibraryUnitModal}
isOverflowVisible={false}
size="xl"
>
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['block_type = "unit"']}
componentPickerMode="single"
onComponentSelected={handleSelectLibraryUnit}
visibleTabs={[ContentType.units]}
/>
</StandardModal>
{blockSyncData && (
<PreviewLibraryXBlockChanges
blockData={blockSyncData}

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Paste unit',
description: 'Message of the button to paste a new unit in a subsection.',
},
unitPickerModalTitle: {
id: 'course-authoring.course-outline.subsection.unit.modal.single-title.text',
defaultMessage: 'Select unit',
description: 'Library unit picker modal title.',
},
});
export default messages;

View File

@@ -14,12 +14,18 @@ const Icon1 = () => <div>Icon 1</div>;
const Icon2 = () => <div>Icon 2</div>;
const pages = {
page1: {
title: 'Page 1',
title: {
id: 'page-1',
defaultMessage: 'Page 1',
},
component: Component1,
icon: Icon1,
},
page2: {
title: 'Page 2',
title: {
id: 'page-2',
defaultMessage: 'Page 2',
},
component: Component2,
icon: Icon2,
},

View File

@@ -11,13 +11,14 @@ import {
FormatIndentDecrease,
FormatIndentIncrease,
} from '@openedx/paragon/icons';
import type { MessageDescriptor } from 'react-intl';
import messages from './messages';
export interface SidebarPage {
component: React.ComponentType;
icon: React.ComponentType;
title: string;
title: MessageDescriptor;
hideFromActionMenu?: boolean;
}
@@ -55,12 +56,12 @@ interface SidebarProps<T extends SidebarPages> {
* help: {
* component: OutlineHelpSidebar,
* icon: HelpOutline,
* title: intl.formatMessage(messages.sidebarButtonHelp),
* title: messages.sidebarButtonHelp,
* },
* info: {
* component: OutlineInfoSidebar,
* icon: Info,
* title: intl.formatMessage(messages.sidebarButtonInfo),
* title: messages.sidebarButtonInfo,
* },
* } satisfies SidebarPages;
*
@@ -99,7 +100,7 @@ export function Sidebar<T extends SidebarPages>({
variant="tertiary"
className="x-small text-primary font-weight-bold pl-0"
>
{pages[currentPageKey].title}
{intl.formatMessage(pages[currentPageKey].title)}
<Icon src={pages[currentPageKey].icon} size="xs" className="ml-2" />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
@@ -110,7 +111,7 @@ export function Sidebar<T extends SidebarPages>({
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
{page.title}
{intl.formatMessage(page.title)}
</Stack>
</Dropdown.Item>
))}
@@ -138,7 +139,7 @@ export function Sidebar<T extends SidebarPages>({
// @ts-ignore
value={key}
src={page.icon}
alt={page.title}
alt={intl.formatMessage(page.title)}
className="rounded-iconbutton"
/>
))}

View File

@@ -11,7 +11,7 @@ export interface SelectedComponent {
blockType: string;
}
export type ComponentSelectedEvent = (selectedComponent: SelectedComponent) => void;
export type ComponentSelectedEvent = (selectedComponent: SelectedComponent) => void | Promise<void>;
export type ComponentSelectionChangedEvent = (selectedComponents: SelectedComponent[]) => void;
type NoComponentPickerType = {
@@ -25,11 +25,15 @@ type NoComponentPickerType = {
removeComponentFromSelectedComponents?: never;
restrictToLibrary?: never;
extraFilter?: never;
isLoading?: never;
setIsLoading?: never;
};
type BasePickerType = {
restrictToLibrary: boolean;
extraFilter: string[],
isLoading?: boolean;
setIsLoading?: React.Dispatch<React.SetStateAction<boolean>>;
};
type ComponentPickerSingleType = BasePickerType & {
@@ -94,6 +98,7 @@ export const ComponentPickerProvider = ({
extraFilter,
}: ComponentPickerProviderProps) => {
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const addComponentToSelectedComponents = useCallback<ComponentSelectedEvent>((
selectedComponent: SelectedComponent,
@@ -133,6 +138,8 @@ export const ComponentPickerProvider = ({
restrictToLibrary,
onComponentSelected,
extraFilter: extraFilter || [],
isLoading,
setIsLoading,
};
case 'multiple':
return {
@@ -142,6 +149,8 @@ export const ComponentPickerProvider = ({
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
extraFilter: extraFilter || [],
isLoading,
setIsLoading,
};
default:
// istanbul ignore next: this should never happen
@@ -156,6 +165,8 @@ export const ComponentPickerProvider = ({
selectedComponents,
onChangeComponentSelection,
extraFilter,
isLoading,
setIsLoading,
]);
return (

View File

@@ -23,6 +23,8 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) =>
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
isLoading,
setIsLoading,
} = useComponentPickerContext();
// istanbul ignore if: this should never happen
@@ -35,14 +37,23 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) =>
return null;
}
/** disables button while the onComponentSelected operation is pending */
const onClick = async () => {
setIsLoading?.(true);
try {
await onComponentSelected?.({ usageKey, blockType });
} finally {
setIsLoading?.(false);
}
};
if (componentPickerMode === 'single') {
return (
<Button
variant="outline-primary"
iconBefore={AddCircleOutline}
onClick={() => {
onComponentSelected({ usageKey, blockType });
}}
disabled={isLoading}
onClick={onClick}
>
<FormattedMessage {...messages.componentPickerSingleSelectTitle} />
</Button>