Skip to content

Commit 537b329

Browse files
authored
feat: library section subsection reuse in course (#2279)
Adds option for course author to import and use sections and subsections from library v2.
1 parent 46d5917 commit 537b329

36 files changed

+1904
-1598
lines changed

src/course-outline/CourseOutline.test.jsx renamed to src/course-outline/CourseOutline.test.tsx

Lines changed: 233 additions & 148 deletions
Large diffs are not rendered by default.

src/course-outline/CourseOutline.jsx renamed to src/course-outline/CourseOutline.tsx

Lines changed: 93 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,41 @@
1-
// @ts-check
2-
import React, { useState, useEffect } from 'react';
3-
import PropTypes from 'prop-types';
1+
import { useState, useEffect, useCallback } from 'react';
42
import { useIntl } from '@edx/frontend-platform/i18n';
53
import {
6-
Button,
74
Container,
85
Layout,
96
Row,
107
TransitionReplace,
118
Toast,
9+
StandardModal,
1210
} from '@openedx/paragon';
1311
import { Helmet } from 'react-helmet';
14-
import {
15-
Add as IconAdd,
16-
CheckCircle as CheckCircleIcon,
17-
} from '@openedx/paragon/icons';
12+
import { CheckCircle as CheckCircleIcon } from '@openedx/paragon/icons';
1813
import { useSelector } from 'react-redux';
1914
import {
2015
arrayMove,
2116
SortableContext,
2217
verticalListSortingStrategy,
2318
} from '@dnd-kit/sortable';
2419
import { useLocation } from 'react-router-dom';
25-
import { CourseAuthoringOutlineSidebarSlot } from '../plugin-slots/CourseAuthoringOutlineSidebarSlot';
20+
import { CourseAuthoringOutlineSidebarSlot } from '@src/plugin-slots/CourseAuthoringOutlineSidebarSlot';
2621

27-
import { LoadingSpinner } from '../generic/Loading';
28-
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
29-
import { RequestStatus } from '../data/constants';
30-
import SubHeader from '../generic/sub-header/SubHeader';
31-
import ProcessingNotification from '../generic/processing-notification';
32-
import InternetConnectionAlert from '../generic/internet-connection-alert';
33-
import DeleteModal from '../generic/delete-modal/DeleteModal';
34-
import ConfigureModal from '../generic/configure-modal/ConfigureModal';
35-
import AlertMessage from '../generic/alert-message';
36-
import getPageHeadTitle from '../generic/utils';
22+
import { LoadingSpinner } from '@src/generic/Loading';
23+
import { getProcessingNotification } from '@src/generic/processing-notification/data/selectors';
24+
import { RequestStatus } from '@src/data/constants';
25+
import SubHeader from '@src/generic/sub-header/SubHeader';
26+
import ProcessingNotification from '@src/generic/processing-notification';
27+
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
28+
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
29+
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
30+
import AlertMessage from '@src/generic/alert-message';
31+
import getPageHeadTitle from '@src/generic/utils';
32+
import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot';
33+
import { ContainerType } from '@src/generic/key-utils';
34+
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
35+
import { ContentType } from '@src/library-authoring/routes';
36+
import { NOTIFICATION_MESSAGES } from '@src/constants';
37+
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
38+
import { XBlock } from '@src/data/types';
3739
import { getCurrentItem, getProctoredExamsFlag } from './data/selectors';
3840
import { COURSE_BLOCK_NAMES } from './constants';
3941
import StatusBar from './status-bar/StatusBar';
@@ -54,13 +56,18 @@ import {
5456
import { useCourseOutline } from './hooks';
5557
import messages from './messages';
5658
import { getTagsExportFile } from './data/api';
57-
import CourseOutlineHeaderActionsSlot from '../plugin-slots/CourseOutlineHeaderActionsSlot';
59+
import OutlineAddChildButtons from './OutlineAddChildButtons';
60+
61+
interface CourseOutlineProps {
62+
courseId: string,
63+
}
5864

59-
const CourseOutline = ({ courseId }) => {
65+
const CourseOutline = ({ courseId }: CourseOutlineProps) => {
6066
const intl = useIntl();
6167
const location = useLocation();
6268

6369
const {
70+
courseUsageKey,
6471
courseName,
6572
savingStatus,
6673
statusBarData,
@@ -89,6 +96,9 @@ const CourseOutline = ({ courseId }) => {
8996
headerNavigationsActions,
9097
openEnableHighlightsModal,
9198
closeEnableHighlightsModal,
99+
isAddLibrarySectionModalOpen,
100+
openAddLibrarySectionModal,
101+
closeAddLibrarySectionModal,
92102
handleEnableHighlightsSubmit,
93103
handleInternetConnectionFailed,
94104
handleOpenHighlightsModal,
@@ -104,6 +114,8 @@ const CourseOutline = ({ courseId }) => {
104114
handleNewSubsectionSubmit,
105115
handleNewUnitSubmit,
106116
handleAddUnitFromLibrary,
117+
handleAddSubsectionFromLibrary,
118+
handleAddSectionFromLibrary,
107119
getUnitUrl,
108120
handleVideoSharingOptionChange,
109121
handlePasteClipboardClick,
@@ -119,10 +131,11 @@ const CourseOutline = ({ courseId }) => {
119131
handleSubsectionDragAndDrop,
120132
handleUnitDragAndDrop,
121133
errors,
134+
resetScrollState,
122135
} = useCourseOutline({ courseId });
123136

124137
// Use `setToastMessage` to show the toast.
125-
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
138+
const [toastMessage, setToastMessage] = useState<string | null>(null);
126139

127140
useEffect(() => {
128141
// Wait for the course data to load before exporting tags.
@@ -139,7 +152,7 @@ const CourseOutline = ({ courseId }) => {
139152
}
140153
}, [location, courseId, courseName]);
141154

142-
const [sections, setSections] = useState(sectionsList);
155+
const [sections, setSections] = useState<XBlock[]>(sectionsList);
143156

144157
const restoreSectionList = () => {
145158
setSections(() => [...sectionsList]);
@@ -157,10 +170,8 @@ const CourseOutline = ({ courseId }) => {
157170

158171
/**
159172
* Move section to new index
160-
* @param {any} currentIndex
161-
* @param {any} newIndex
162173
*/
163-
const updateSectionOrderByIndex = (currentIndex, newIndex) => {
174+
const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => {
164175
if (currentIndex === newIndex) {
165176
return;
166177
}
@@ -173,11 +184,8 @@ const CourseOutline = ({ courseId }) => {
173184

174185
/**
175186
* Uses details from move information and moves subsection
176-
* @param {any} section
177-
* @param {any} moveDetails
178-
* @returns {void}
179187
*/
180-
const updateSubsectionOrderByIndex = (section, moveDetails) => {
188+
const updateSubsectionOrderByIndex = (section: XBlock, moveDetails) => {
181189
const { fn, args, sectionId } = moveDetails;
182190
if (!args) {
183191
return;
@@ -196,11 +204,8 @@ const CourseOutline = ({ courseId }) => {
196204

197205
/**
198206
* Uses details from move information and moves unit
199-
* @param {any} section
200-
* @param {any} moveDetails
201-
* @returns {void}
202207
*/
203-
const updateUnitOrderByIndex = (section, moveDetails) => {
208+
const updateUnitOrderByIndex = (section: XBlock, moveDetails) => {
204209
const {
205210
fn, args, sectionId, subsectionId,
206211
} = moveDetails;
@@ -220,6 +225,16 @@ const CourseOutline = ({ courseId }) => {
220225
}
221226
};
222227

228+
const handleSelectLibrarySection = useCallback((selectedSection: SelectedComponent) => {
229+
handleAddSectionFromLibrary.mutateAsync({
230+
type: COMPONENT_TYPES.libraryV2,
231+
category: ContainerType.Chapter,
232+
parentLocator: courseUsageKey,
233+
libraryContentKey: selectedSection.usageKey,
234+
});
235+
closeAddLibrarySectionModal();
236+
}, [closeAddLibrarySectionModal, handleAddSectionFromLibrary.mutateAsync, courseId, courseUsageKey]);
237+
223238
useEffect(() => {
224239
setSections(sectionsList);
225240
}, [sectionsList]);
@@ -357,6 +372,8 @@ const CourseOutline = ({ courseId }) => {
357372
isSectionsExpanded={isSectionsExpanded}
358373
onNewSubsectionSubmit={handleNewSubsectionSubmit}
359374
onOrderChange={updateSectionOrderByIndex}
375+
onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync}
376+
resetScrollState={resetScrollState}
360377
>
361378
<SortableContext
362379
id={section.id}
@@ -385,9 +402,10 @@ const CourseOutline = ({ courseId }) => {
385402
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
386403
onOpenConfigureModal={openConfigureModal}
387404
onNewUnitSubmit={handleNewUnitSubmit}
388-
onAddUnitFromLibrary={handleAddUnitFromLibrary}
405+
onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync}
389406
onOrderChange={updateSubsectionOrderByIndex}
390407
onPasteClick={handlePasteClipboardClick}
408+
resetScrollState={resetScrollState}
391409
>
392410
<SortableContext
393411
id={subsection.id}
@@ -431,23 +449,25 @@ const CourseOutline = ({ courseId }) => {
431449
</SortableContext>
432450
</DraggableList>
433451
{courseActions.childAddable && (
434-
<Button
435-
data-testid="new-section-button"
436-
className="mt-4"
437-
variant="outline-primary"
438-
onClick={handleNewSectionSubmit}
439-
iconBefore={IconAdd}
440-
block
441-
>
442-
{intl.formatMessage(messages.newSectionButton)}
443-
</Button>
452+
<OutlineAddChildButtons
453+
handleNewButtonClick={handleNewSectionSubmit}
454+
handleUseFromLibraryClick={openAddLibrarySectionModal}
455+
childType={ContainerType.Section}
456+
/>
444457
)}
445458
</>
446459
) : (
447-
<EmptyPlaceholder
448-
onCreateNewSection={handleNewSectionSubmit}
449-
childAddable={courseActions.childAddable}
450-
/>
460+
<EmptyPlaceholder>
461+
{courseActions.childAddable && (
462+
<OutlineAddChildButtons
463+
handleNewButtonClick={handleNewSectionSubmit}
464+
handleUseFromLibraryClick={openAddLibrarySectionModal}
465+
childType={ContainerType.Section}
466+
btnVariant="primary"
467+
btnClasses="mt-1"
468+
/>
469+
)}
470+
</EmptyPlaceholder>
451471
)}
452472
</div>
453473
)}
@@ -493,11 +513,33 @@ const CourseOutline = ({ courseId }) => {
493513
close={closeDeleteModal}
494514
onDeleteSubmit={handleDeleteItemSubmit}
495515
/>
516+
<StandardModal
517+
title={intl.formatMessage(messages.sectionPickerModalTitle)}
518+
isOpen={isAddLibrarySectionModalOpen}
519+
onClose={closeAddLibrarySectionModal}
520+
isOverflowVisible={false}
521+
size="xl"
522+
>
523+
<ComponentPicker
524+
showOnlyPublished
525+
extraFilter={['block_type = "section"']}
526+
componentPickerMode="single"
527+
onComponentSelected={handleSelectLibrarySection}
528+
visibleTabs={[ContentType.sections]}
529+
/>
530+
</StandardModal>
496531
</Container>
497532
<div className="alert-toast">
498533
<ProcessingNotification
499-
isShow={isShowProcessingNotification}
500-
title={processingNotificationTitle}
534+
// Show processing toast if any mutation is running
535+
isShow={
536+
isShowProcessingNotification
537+
|| handleAddUnitFromLibrary.isPending
538+
|| handleAddSubsectionFromLibrary.isPending
539+
|| handleAddSectionFromLibrary.isPending
540+
}
541+
// HACK: Use saving as default title till we have a need for better messages
542+
title={processingNotificationTitle || NOTIFICATION_MESSAGES.saving}
501543
/>
502544
<InternetConnectionAlert
503545
isFailed={isInternetConnectionAlertFailed}
@@ -518,8 +560,4 @@ const CourseOutline = ({ courseId }) => {
518560
);
519561
};
520562

521-
CourseOutline.propTypes = {
522-
courseId: PropTypes.string.isRequired,
523-
};
524-
525563
export default CourseOutline;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import userEvent from '@testing-library/user-event';
2+
import { ContainerType } from '@src/generic/key-utils';
3+
import {
4+
initializeMocks, render, screen, waitFor,
5+
} from '@src/testUtils';
6+
import OutlineAddChildButtons from './OutlineAddChildButtons';
7+
8+
jest.mock('react-redux', () => ({
9+
...jest.requireActual('react-redux'),
10+
useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }),
11+
}));
12+
13+
[
14+
{ containerType: ContainerType.Section },
15+
{ containerType: ContainerType.Subsection },
16+
{ containerType: ContainerType.Unit },
17+
].forEach(({ containerType }) => {
18+
describe(`<OutlineAddChildButtons> for ${containerType}`, () => {
19+
beforeEach(() => {
20+
initializeMocks();
21+
});
22+
23+
it('renders and behaves correctly', async () => {
24+
const newClickHandler = jest.fn();
25+
const useFromLibClickHandler = jest.fn();
26+
render(<OutlineAddChildButtons
27+
handleNewButtonClick={newClickHandler}
28+
handleUseFromLibraryClick={useFromLibClickHandler}
29+
childType={containerType}
30+
/>);
31+
32+
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
33+
expect(newBtn).toBeInTheDocument();
34+
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
35+
expect(useBtn).toBeInTheDocument();
36+
userEvent.click(newBtn);
37+
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
38+
userEvent.click(useBtn);
39+
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
40+
});
41+
});
42+
});

0 commit comments

Comments
 (0)