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:
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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)}
|
||||
/>,
|
||||
]}
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
@import "./publish-modal/PublishModal";
|
||||
@import "./xblock-status/XBlockStatus";
|
||||
@import "./drag-helper/SortableItem";
|
||||
|
||||
.border-dashed {
|
||||
border: dashed;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
src/course-outline/outline-sidebar/constants.ts
Normal file
28
src/course-outline/outline-sidebar/constants.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user