Skip to content

Commit 3c22e4b

Browse files
authored
feat: Add sidebar and library dropdown filter [FC-0114] (#2778)
* Add flow in course outline sidebar. Allows author to add new section/subsection/unit or any container from existing libraries via sidebar. * Adds library dropdown filter and collections dropdown filter in add sidebar. Allows authors to filter containers by selected libraries and collections.
1 parent a7cbfea commit 3c22e4b

107 files changed

Lines changed: 2218 additions & 659 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

plugins/course-apps/proctoring/Settings.test.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,9 @@ describe('ProctoredExamSettings', () => {
460460
screen.getByDisplayValue('mockproc');
461461
});
462462
// (1) for studio settings
463-
// (2) for course details
464-
expect(axiosMock.history.get.length).toBe(2);
463+
// (2) waffle flags
464+
// (3) for course details
465+
expect(axiosMock.history.get.length).toBe(3);
465466
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
466467
});
467468

src/CourseAuthoringContext.tsx

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
1+
import { getConfig } from '@edx/frontend-platform';
12
import { createContext, useContext, useMemo } from 'react';
23
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
4+
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
5+
import { getCourseItem } from '@src/course-outline/data/api';
6+
import { useDispatch, useSelector } from 'react-redux';
7+
import { addSection, addSubsection, updateSavingStatus } from '@src/course-outline/data/slice';
8+
import { addNewSectionQuery, addNewSubsectionQuery, addNewUnitQuery } from '@src/course-outline/data/thunk';
9+
import { useNavigate } from 'react-router';
10+
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
11+
import { RequestStatus, RequestStatusType } from './data/constants';
12+
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
313
import { CourseDetailsData } from './data/api';
4-
import { useCourseDetails } from './data/apiHooks';
5-
import { RequestStatusType } from './data/constants';
614

715
export type CourseAuthoringContextData = {
816
/** The ID of the current course */
917
courseId: string;
18+
courseUsageKey: string;
1019
courseDetails?: CourseDetailsData;
1120
courseDetailStatus: RequestStatusType;
1221
canChangeProviders: boolean;
22+
handleAddSectionFromLibrary: ReturnType<typeof useCreateCourseBlock>;
23+
handleAddSubsectionFromLibrary: ReturnType<typeof useCreateCourseBlock>;
24+
handleAddUnitFromLibrary: ReturnType<typeof useCreateCourseBlock>;
25+
handleNewSectionSubmit: () => void;
26+
handleNewSubsectionSubmit: (sectionId: string) => void;
27+
handleNewUnitSubmit: (subsectionId: string) => void;
28+
openUnitPage: (locator: string) => void;
29+
getUnitUrl: (locator: string) => string;
1330
};
1431

1532
/**
@@ -30,23 +47,103 @@ export const CourseAuthoringProvider = ({
3047
children,
3148
courseId,
3249
}: CourseAuthoringProviderProps) => {
50+
const dispatch = useDispatch();
51+
const navigate = useNavigate();
52+
const waffleFlags = useWaffleFlags();
3353
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
3454
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
55+
const { courseStructure } = useSelector(getOutlineIndexData);
56+
const { id: courseUsageKey } = courseStructure || {};
3557

36-
const context = useMemo<CourseAuthoringContextData>(() => {
37-
const contextValue = {
38-
courseId,
39-
courseDetails,
40-
courseDetailStatus,
41-
canChangeProviders,
42-
};
58+
const getUnitUrl = (locator: string) => {
59+
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
60+
// instanbul ignore next
61+
return `/course/${courseId}/container/${locator}`;
62+
}
63+
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
64+
};
4365

44-
return contextValue;
45-
}, [
66+
/**
67+
* Open the unit page for a given locator.
68+
*/
69+
const openUnitPage = (locator: string) => {
70+
const url = getUnitUrl(locator);
71+
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
72+
// instanbul ignore next
73+
navigate(url);
74+
} else {
75+
window.location.assign(url);
76+
}
77+
};
78+
79+
const handleNewSectionSubmit = () => {
80+
dispatch(addNewSectionQuery(courseUsageKey));
81+
};
82+
83+
const handleNewSubsectionSubmit = (sectionId: string) => {
84+
dispatch(addNewSubsectionQuery(sectionId));
85+
};
86+
87+
const handleNewUnitSubmit = (subsectionId: string) => {
88+
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
89+
};
90+
91+
const handleAddSectionFromLibrary = useCreateCourseBlock(async (locator) => {
92+
try {
93+
const data = await getCourseItem(locator);
94+
// instanbul ignore next
95+
// Page should scroll to newly added section.
96+
data.shouldScroll = true;
97+
dispatch(addSection(data));
98+
} catch {
99+
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
100+
}
101+
});
102+
103+
const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => {
104+
try {
105+
const data = await getCourseItem(locator);
106+
data.shouldScroll = true;
107+
// Page should scroll to newly added subsection.
108+
dispatch(addSubsection({ parentLocator, data }));
109+
} catch {
110+
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
111+
}
112+
});
113+
114+
/**
115+
* import a unit block from library and redirect user to this unit page.
116+
*/
117+
const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage);
118+
119+
const context = useMemo<CourseAuthoringContextData>(() => ({
120+
courseId,
121+
courseUsageKey,
122+
courseDetails,
123+
courseDetailStatus,
124+
canChangeProviders,
125+
handleNewSectionSubmit,
126+
handleNewSubsectionSubmit,
127+
handleNewUnitSubmit,
128+
handleAddSectionFromLibrary,
129+
handleAddSubsectionFromLibrary,
130+
handleAddUnitFromLibrary,
131+
getUnitUrl,
132+
openUnitPage,
133+
}), [
46134
courseId,
135+
courseUsageKey,
47136
courseDetails,
48137
courseDetailStatus,
49138
canChangeProviders,
139+
handleNewSectionSubmit,
140+
handleNewSubsectionSubmit,
141+
handleNewUnitSubmit,
142+
handleAddSectionFromLibrary,
143+
handleAddSubsectionFromLibrary,
144+
handleAddUnitFromLibrary,
145+
getUnitUrl,
146+
openUnitPage,
50147
]);
51148

52149
return (

src/CourseAuthoringPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ const CourseAuthoringPage = ({ children }: Props) => {
5757
org={courseOrg}
5858
title={courseTitle}
5959
contextId={courseId}
60+
containerProps={{
61+
size: 'fluid',
62+
}}
6063
/>
6164
)
6265
)}

src/authz/data/apiHooks.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useQuery } from '@tanstack/react-query';
1+
import { skipToken, useQuery } from '@tanstack/react-query';
22
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
33
import { validateUserPermissions } from './api';
44

@@ -29,8 +29,9 @@ const adminConsoleQueryKeys = {
2929
*/
3030
export const useUserPermissions = (
3131
permissions: PermissionValidationQuery,
32+
enabled: boolean = true,
3233
) => useQuery<PermissionValidationAnswer, Error>({
3334
queryKey: adminConsoleQueryKeys.permissions(permissions),
34-
queryFn: () => validateUserPermissions(permissions),
35+
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken,
3536
retry: false,
3637
});

src/course-outline/CourseOutline.test.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ jest.mock('./data/api', () => ({
9696
getTagsCount: () => jest.fn().mockResolvedValue({}),
9797
}));
9898

99-
// Mock ComponentPicker to call onComponentSelected on click
99+
// Mock LibraryAndComponentPicker to call onComponentSelected on click
100100
jest.mock('@src/library-authoring/component-picker', () => ({
101-
ComponentPicker: (props) => {
101+
LibraryAndComponentPicker: (props) => {
102102
const onClick = () => {
103103
// eslint-disable-next-line react/prop-types
104104
props.onComponentSelected({
@@ -438,8 +438,9 @@ describe('<CourseOutline />', () => {
438438
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
439439
const [subsection] = section.childInfo.children;
440440
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
441-
parent_locator: subsection.id,
441+
type: COURSE_BLOCK_NAMES.vertical.id,
442442
category: COURSE_BLOCK_NAMES.vertical.id,
443+
parent_locator: subsection.id,
443444
display_name: COURSE_BLOCK_NAMES.vertical.name,
444445
}));
445446
});
@@ -2495,7 +2496,7 @@ describe('<CourseOutline />', () => {
24952496
const btn = await screen.findByRole('button', { name: 'Collapse all' });
24962497
expect(btn).toBeInTheDocument();
24972498
expect(await screen.findByRole('link', { name: 'View live' })).toBeInTheDocument();
2498-
expect(await screen.findByRole('button', { name: 'Add' })).toBeInTheDocument();
2499+
expect((await screen.findAllByRole('button', { name: 'Add' })).length).toEqual(2);
24992500
expect(await screen.findByRole('button', { name: 'More actions' })).toBeInTheDocument();
25002501
const user = userEvent.setup();
25012502
await user.click(btn);

src/course-outline/CourseOutline.tsx

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import AlertMessage from '@src/generic/alert-message';
3434
import getPageHeadTitle from '@src/generic/utils';
3535
import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot';
3636
import { ContainerType } from '@src/generic/key-utils';
37-
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
37+
import { LibraryAndComponentPicker, SelectedComponent } from '@src/library-authoring';
3838
import { ContentType } from '@src/library-authoring/routes';
3939
import { NOTIFICATION_MESSAGES } from '@src/constants';
4040
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
@@ -73,7 +73,13 @@ import { LegacyStatusBar } from './status-bar/LegacyStatusBar';
7373
const CourseOutline = () => {
7474
const intl = useIntl();
7575
const location = useLocation();
76-
const { courseId } = useCourseAuthoringContext();
76+
const {
77+
courseId,
78+
handleAddSubsectionFromLibrary,
79+
handleAddUnitFromLibrary,
80+
handleAddSectionFromLibrary,
81+
handleNewSectionSubmit,
82+
} = useCourseAuthoringContext();
7783

7884
const {
7985
courseUsageKey,
@@ -123,13 +129,6 @@ const CourseOutline = () => {
123129
handleDuplicateSectionSubmit,
124130
handleDuplicateSubsectionSubmit,
125131
handleDuplicateUnitSubmit,
126-
handleNewSectionSubmit,
127-
handleNewSubsectionSubmit,
128-
handleNewUnitSubmit,
129-
handleAddUnitFromLibrary,
130-
handleAddSubsectionFromLibrary,
131-
handleAddSectionFromLibrary,
132-
getUnitUrl,
133132
handleVideoSharingOptionChange,
134133
handlePasteClipboardClick,
135134
notificationDismissUrl,
@@ -269,7 +268,7 @@ const CourseOutline = () => {
269268

270269
if (isLoadingDenied) {
271270
return (
272-
<Container size="xl" className="px-4 mt-4">
271+
<Container fluid className="px-3 mt-4">
273272
<PageAlerts
274273
courseId={courseId}
275274
notificationDismissUrl={notificationDismissUrl}
@@ -292,7 +291,7 @@ const CourseOutline = () => {
292291
<Helmet>
293292
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
294293
</Helmet>
295-
<Container size="xl" className="px-4">
294+
<Container fluid className="px-3">
296295
<section className="course-outline-container mb-4 mt-5">
297296
<PageAlerts
298297
courseId={courseId}
@@ -413,9 +412,7 @@ const CourseOutline = () => {
413412
onEditSectionSubmit={handleEditSubmit}
414413
onDuplicateSubmit={handleDuplicateSectionSubmit}
415414
isSectionsExpanded={isSectionsExpanded}
416-
onNewSubsectionSubmit={handleNewSubsectionSubmit}
417415
onOrderChange={updateSectionOrderByIndex}
418-
onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync}
419416
resetScrollState={resetScrollState}
420417
>
421418
<SortableContext
@@ -445,8 +442,6 @@ const CourseOutline = () => {
445442
onEditSubmit={handleEditSubmit}
446443
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
447444
onOpenConfigureModal={openConfigureModal}
448-
onNewUnitSubmit={handleNewUnitSubmit}
449-
onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync}
450445
onOrderChange={updateSubsectionOrderByIndex}
451446
onPasteClick={handlePasteClipboardClick}
452447
resetScrollState={resetScrollState}
@@ -480,7 +475,6 @@ const CourseOutline = () => {
480475
onOpenUnlinkModal={openUnlinkModal}
481476
onEditSubmit={handleEditSubmit}
482477
onDuplicateSubmit={handleDuplicateUnitSubmit}
483-
getTitleLink={getUnitUrl}
484478
onOrderChange={updateUnitOrderByIndex}
485479
discussionsSettings={discussionsSettings}
486480
/>
@@ -571,7 +565,7 @@ const CourseOutline = () => {
571565
isOverflowVisible={false}
572566
size="xl"
573567
>
574-
<ComponentPicker
568+
<LibraryAndComponentPicker
575569
showOnlyPublished
576570
extraFilter={['block_type = "section"']}
577571
componentPickerMode="single"

src/course-outline/data/api.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -382,19 +382,40 @@ export async function duplicateCourseItem(itemId: string, parentId: string): Pro
382382
}
383383

384384
/**
385-
* Add new course item like section, subsection or unit.
386-
* @param {string} parentLocator
387-
* @param {string} category
388-
* @param {string} displayName
389-
* @returns {Promise<Object>}
385+
* Creates a new course XBlock. Can be used to create any type of block
386+
* and also import a content from library.
390387
*/
391-
export async function addNewCourseItem(parentLocator: string, category: string, displayName: string): Promise<object> {
388+
export async function createCourseXblock({
389+
type,
390+
category,
391+
parentLocator,
392+
displayName,
393+
boilerplate,
394+
stagedContent,
395+
libraryContentKey,
396+
}: {
397+
type: string,
398+
/** The category of the XBlock. Defaults to the type if not provided. */
399+
category?: string,
400+
parentLocator: string,
401+
displayName?: string,
402+
boilerplate?: string,
403+
stagedContent?: string,
404+
/** component key from library if being imported. */
405+
libraryContentKey?: string,
406+
}) {
407+
const body = {
408+
type,
409+
boilerplate,
410+
category: category || type,
411+
parent_locator: parentLocator,
412+
display_name: displayName,
413+
staged_content: stagedContent,
414+
library_content_key: libraryContentKey,
415+
};
416+
392417
const { data } = await getAuthenticatedHttpClient()
393-
.post(getXBlockBaseApiUrl(), {
394-
parent_locator: parentLocator,
395-
category,
396-
display_name: displayName,
397-
});
418+
.post(getXBlockBaseApiUrl(), body);
398419

399420
return data;
400421
}

src/course-outline/data/apiHooks.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
1-
import {
2-
skipToken, useMutation, useQuery,
3-
} from '@tanstack/react-query';
4-
import { createCourseXblock } from '@src/course-unit/data/api';
5-
import {
6-
getCourseDetails,
7-
getCourseItem,
8-
} from './api';
1+
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
2+
import { createCourseXblock, getCourseDetails, getCourseItem } from './api';
93

104
export const courseOutlineQueryKeys = {
115
all: ['courseOutline'],
@@ -29,11 +23,11 @@ export const courseOutlineQueryKeys = {
2923
* Can also be used to import block from library by passing `libraryContentKey` in request body
3024
*/
3125
export const useCreateCourseBlock = (
32-
callback?: ((locator?: string, parentLocator?: string) => void),
26+
callback?: ((locator: string, parentLocator: string) => void),
3327
) => useMutation({
3428
mutationFn: createCourseXblock,
35-
onSettled: async (data) => {
36-
callback?.(data?.locator, data.parent_locator);
29+
onSettled: async (data: { locator: string, parent_locator: string }) => {
30+
callback?.(data.locator, data.parent_locator);
3731
},
3832
});
3933

0 commit comments

Comments
 (0)