Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
cdd52b7
feat: add sidebar in course outline
navinkarkera Dec 22, 2025
b214470
feat: sidebar filter
navinkarkera Dec 25, 2025
a1af0d0
feat: add new tab section in sidebar
navinkarkera Dec 26, 2025
eec0813
refactor: to typescript
navinkarkera Dec 26, 2025
a988c53
refactor: move functions into context from hooks
navinkarkera Dec 26, 2025
ba7fd3c
feat: add item to last parent block
navinkarkera Dec 26, 2025
667902e
feat: adjust things as per design
navinkarkera Dec 29, 2025
e41e765
fix: lint and typing issues
navinkarkera Dec 29, 2025
465c1dd
test: fix tests
navinkarkera Dec 29, 2025
db15cdb
test: add tests
navinkarkera Dec 29, 2025
2c13a5a
refactor: make components run without library id if not required
navinkarkera Dec 30, 2025
a9247b6
fix: maintain query positions
navinkarkera Dec 30, 2025
5a42e7d
chore: apply review suggestions
navinkarkera Dec 30, 2025
b4df4ae
chore: update imports
navinkarkera Jan 6, 2026
b5f95f8
feat: library filter in add sidebar
navinkarkera Dec 31, 2025
7bb0f2f
fixup! feat: library filter in add sidebar
navinkarkera Dec 31, 2025
4a2bf91
feat: libray items
navinkarkera Jan 1, 2026
b30d9d7
fix: lint and type issues
navinkarkera Jan 1, 2026
d1c7986
test: add tests
navinkarkera Jan 1, 2026
8ce03d3
refactor: library context
navinkarkera Jan 7, 2026
d3a0056
refactor: rename component picker
navinkarkera Jan 7, 2026
94cff6a
refactor: split component picker
navinkarkera Jan 7, 2026
0736eec
refactor: move multiple library context into its own context
navinkarkera Jan 7, 2026
eebf2c4
refactor: create separate context for showOnlyPublished filter
navinkarkera Jan 7, 2026
e2c4049
fix: component picker use in library
navinkarkera Jan 7, 2026
13180a9
chore: remove unused type
navinkarkera Jan 8, 2026
322b853
fix: tests
navinkarkera Jan 8, 2026
3dce41a
refactor: add sidebar style
navinkarkera Jan 8, 2026
a129442
feat: persist library selection
navinkarkera Jan 8, 2026
ce9c286
feat: collection dropdown filter
navinkarkera Jan 8, 2026
fa71d87
test: collections filter
navinkarkera Jan 8, 2026
49a7533
fix: collections filter overlay
navinkarkera Jan 8, 2026
04d8673
fix: tooltip
navinkarkera Jan 8, 2026
f46bd96
refactor: apply review suggestions
navinkarkera Jan 9, 2026
71a2195
fix: failing tests
navinkarkera Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
// (1) for studio settings
// (2) for course details
expect(axiosMock.history.get.length).toBe(2);
// (2) waffle flags
// (3) for course details
expect(axiosMock.history.get.length).toBe(3);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});

Expand Down
119 changes: 108 additions & 11 deletions src/CourseAuthoringContext.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import { getConfig } from '@edx/frontend-platform';
import { createContext, useContext, useMemo } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
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';
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
import { CourseDetailsData } from './data/api';
import { useCourseDetails } from './data/apiHooks';
import { RequestStatusType } from './data/constants';

export type CourseAuthoringContextData = {
/** The ID of the current course */
courseId: string;
courseUsageKey: string;
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;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
};

/**
Expand All @@ -30,23 +47,103 @@ export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
const { courseStructure } = useSelector(getOutlineIndexData);
const { id: courseUsageKey } = courseStructure || {};

const context = useMemo<CourseAuthoringContextData>(() => {
const contextValue = {
courseId,
courseDetails,
courseDetailStatus,
canChangeProviders,
};
const getUnitUrl = (locator: string) => {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
return `/course/${courseId}/container/${locator}`;
}
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
};

return contextValue;
}, [
/**
* Open the unit page for a given locator.
*/
const openUnitPage = (locator: string) => {
const url = getUnitUrl(locator);
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
navigate(url);
} else {
window.location.assign(url);
}
};

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) => {
try {
const data = await getCourseItem(locator);
// instanbul ignore next
// Page should scroll to newly added section.
data.shouldScroll = true;
dispatch(addSection(data));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});

const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => {
try {
const data = await getCourseItem(locator);
data.shouldScroll = true;
// Page should scroll to newly added subsection.
dispatch(addSubsection({ parentLocator, data }));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});

/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage);

const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
getUnitUrl,
openUnitPage,
}), [
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
getUnitUrl,
openUnitPage,
]);

return (
Expand Down
3 changes: 3 additions & 0 deletions src/CourseAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ const CourseAuthoringPage = ({ children }: Props) => {
org={courseOrg}
title={courseTitle}
contextId={courseId}
containerProps={{
size: 'fluid',
}}
/>
)
)}
Expand Down
5 changes: 3 additions & 2 deletions src/authz/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { skipToken, useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api';

Expand Down Expand Up @@ -29,8 +29,9 @@ const adminConsoleQueryKeys = {
*/
export const useUserPermissions = (
permissions: PermissionValidationQuery,
enabled: boolean = true,
) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: () => validateUserPermissions(permissions),
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken,
retry: false,
});
9 changes: 5 additions & 4 deletions src/course-outline/CourseOutline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ jest.mock('./data/api', () => ({
getTagsCount: () => jest.fn().mockResolvedValue({}),
}));

// Mock ComponentPicker to call onComponentSelected on click
// Mock LibraryAndComponentPicker to call onComponentSelected on click
jest.mock('@src/library-authoring/component-picker', () => ({
ComponentPicker: (props) => {
LibraryAndComponentPicker: (props) => {
const onClick = () => {
// eslint-disable-next-line react/prop-types
props.onComponentSelected({
Expand Down Expand Up @@ -438,8 +438,9 @@ describe('<CourseOutline />', () => {
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [subsection] = section.childInfo.children;
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
parent_locator: subsection.id,
type: COURSE_BLOCK_NAMES.vertical.id,
category: COURSE_BLOCK_NAMES.vertical.id,
parent_locator: subsection.id,
display_name: COURSE_BLOCK_NAMES.vertical.name,
}));
});
Expand Down Expand Up @@ -2495,7 +2496,7 @@ describe('<CourseOutline />', () => {
const btn = await screen.findByRole('button', { name: 'Collapse all' });
expect(btn).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'View live' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Add' })).toBeInTheDocument();
expect((await screen.findAllByRole('button', { name: 'Add' })).length).toEqual(2);
expect(await screen.findByRole('button', { name: 'More actions' })).toBeInTheDocument();
const user = userEvent.setup();
await user.click(btn);
Expand Down
28 changes: 11 additions & 17 deletions src/course-outline/CourseOutline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ 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 { ComponentPicker, SelectedComponent } from '@src/library-authoring';
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';
Expand Down Expand Up @@ -73,7 +73,13 @@ import { LegacyStatusBar } from './status-bar/LegacyStatusBar';
const CourseOutline = () => {
const intl = useIntl();
const location = useLocation();
const { courseId } = useCourseAuthoringContext();
const {
courseId,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSectionFromLibrary,
handleNewSectionSubmit,
} = useCourseAuthoringContext();

const {
courseUsageKey,
Expand Down Expand Up @@ -123,13 +129,6 @@ const CourseOutline = () => {
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
handleDuplicateUnitSubmit,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddUnitFromLibrary,
handleAddSubsectionFromLibrary,
handleAddSectionFromLibrary,
getUnitUrl,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
notificationDismissUrl,
Expand Down Expand Up @@ -269,7 +268,7 @@ const CourseOutline = () => {

if (isLoadingDenied) {
return (
<Container size="xl" className="px-4 mt-4">
<Container fluid className="px-3 mt-4">
<PageAlerts
courseId={courseId}
notificationDismissUrl={notificationDismissUrl}
Expand All @@ -292,7 +291,7 @@ const CourseOutline = () => {
<Helmet>
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
</Helmet>
<Container size="xl" className="px-4">
<Container fluid className="px-3">
<section className="course-outline-container mb-4 mt-5">
<PageAlerts
courseId={courseId}
Expand Down Expand Up @@ -413,9 +412,7 @@ const CourseOutline = () => {
onEditSectionSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
onNewSubsectionSubmit={handleNewSubsectionSubmit}
onOrderChange={updateSectionOrderByIndex}
onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync}
resetScrollState={resetScrollState}
>
<SortableContext
Expand Down Expand Up @@ -445,8 +442,6 @@ const CourseOutline = () => {
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onOpenConfigureModal={openConfigureModal}
onNewUnitSubmit={handleNewUnitSubmit}
onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync}
onOrderChange={updateSubsectionOrderByIndex}
onPasteClick={handlePasteClipboardClick}
resetScrollState={resetScrollState}
Expand Down Expand Up @@ -480,7 +475,6 @@ const CourseOutline = () => {
onOpenUnlinkModal={openUnlinkModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
onOrderChange={updateUnitOrderByIndex}
discussionsSettings={discussionsSettings}
/>
Expand Down Expand Up @@ -571,7 +565,7 @@ const CourseOutline = () => {
isOverflowVisible={false}
size="xl"
>
<ComponentPicker
<LibraryAndComponentPicker
showOnlyPublished
extraFilter={['block_type = "section"']}
componentPickerMode="single"
Expand Down
43 changes: 32 additions & 11 deletions src/course-outline/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,19 +382,40 @@ export async function duplicateCourseItem(itemId: string, parentId: string): Pro
}

/**
* Add new course item like section, subsection or unit.
* @param {string} parentLocator
* @param {string} category
* @param {string} displayName
* @returns {Promise<Object>}
* Creates a new course XBlock. Can be used to create any type of block
* and also import a content from library.
*/
export async function addNewCourseItem(parentLocator: string, category: string, displayName: string): Promise<object> {
export async function createCourseXblock({
type,
category,
parentLocator,
displayName,
boilerplate,
stagedContent,
libraryContentKey,
}: {
type: string,
/** The category of the XBlock. Defaults to the type if not provided. */
category?: string,
parentLocator: string,
displayName?: string,
boilerplate?: string,
stagedContent?: string,
/** component key from library if being imported. */
libraryContentKey?: string,
}) {
const body = {
type,
boilerplate,
category: category || type,
parent_locator: parentLocator,
display_name: displayName,
staged_content: stagedContent,
library_content_key: libraryContentKey,
};

const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,
category,
display_name: displayName,
});
.post(getXBlockBaseApiUrl(), body);

return data;
}
Expand Down
16 changes: 5 additions & 11 deletions src/course-outline/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import {
skipToken, useMutation, useQuery,
} from '@tanstack/react-query';
import { createCourseXblock } from '@src/course-unit/data/api';
import {
getCourseDetails,
getCourseItem,
} from './api';
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
import { createCourseXblock, getCourseDetails, getCourseItem } from './api';

export const courseOutlineQueryKeys = {
all: ['courseOutline'],
Expand All @@ -29,11 +23,11 @@ export const courseOutlineQueryKeys = {
* Can also be used to import block from library by passing `libraryContentKey` in request body
*/
export const useCreateCourseBlock = (
callback?: ((locator?: string, parentLocator?: string) => void),
callback?: ((locator: string, parentLocator: string) => void),
) => useMutation({
mutationFn: createCourseXblock,
onSettled: async (data) => {
callback?.(data?.locator, data.parent_locator);
onSettled: async (data: { locator: string, parent_locator: string }) => {
callback?.(data.locator, data.parent_locator);
},
});

Expand Down
Loading