Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3484ffd
feat: add sidebar in course outline
navinkarkera Dec 22, 2025
cd1a299
feat: sidebar filter
navinkarkera Dec 25, 2025
c93b39d
feat: add new tab section in sidebar
navinkarkera Dec 26, 2025
c82b96d
refactor: to typescript
navinkarkera Dec 26, 2025
329fc31
refactor: move functions into context from hooks
navinkarkera Dec 26, 2025
59e1d41
feat: add item to last parent block
navinkarkera Dec 26, 2025
5c76bea
feat: adjust things as per design
navinkarkera Dec 29, 2025
9337b3e
fix: lint and typing issues
navinkarkera Dec 29, 2025
a72af6c
test: fix tests
navinkarkera Dec 29, 2025
cfe6521
test: add tests
navinkarkera Dec 29, 2025
736759b
refactor: make components run without library id if not required
navinkarkera Dec 30, 2025
9cbec02
fix: maintain query positions
navinkarkera Dec 30, 2025
5b770d1
chore: apply review suggestions
navinkarkera Dec 30, 2025
7075d2c
chore: update imports
navinkarkera Jan 6, 2026
b8957ef
feat: library filter in add sidebar
navinkarkera Dec 31, 2025
35e956d
fixup! feat: library filter in add sidebar
navinkarkera Dec 31, 2025
3419b3b
feat: libray items
navinkarkera Jan 1, 2026
8cded53
fix: lint and type issues
navinkarkera Jan 1, 2026
f949745
test: add tests
navinkarkera Jan 1, 2026
06c81a6
refactor: library context
navinkarkera Jan 7, 2026
e561b92
refactor: rename component picker
navinkarkera Jan 7, 2026
bc033e1
refactor: split component picker
navinkarkera Jan 7, 2026
2de58f6
refactor: move multiple library context into its own context
navinkarkera Jan 7, 2026
6bd58eb
refactor: create separate context for showOnlyPublished filter
navinkarkera Jan 7, 2026
8ae3431
fix: component picker use in library
navinkarkera Jan 7, 2026
07773d1
chore: remove unused type
navinkarkera Jan 8, 2026
ccba44a
fix: tests
navinkarkera Jan 8, 2026
cddba2e
refactor: add sidebar style
navinkarkera Jan 8, 2026
fc600b4
feat: persist library selection
navinkarkera Jan 8, 2026
e70b2c1
feat: collection dropdown filter
navinkarkera Jan 8, 2026
46547bc
test: collections filter
navinkarkera Jan 8, 2026
36bf0c3
fix: collections filter overlay
navinkarkera Jan 8, 2026
f710585
fix: tooltip
navinkarkera Jan 8, 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
Loading