Skip to content
5 changes: 4 additions & 1 deletion plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ describe('ProctoredExamSettings', () => {
provider: null,
});

axiosMock.onGet(/course_index/).reply(200, { sections: [] });

axiosMock.onGet(
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
).reply(200, {
Expand Down Expand Up @@ -466,7 +468,8 @@ describe('ProctoredExamSettings', () => {
// (1) for studio settings
// (2) waffle flags
// (3) for course details
expect(axiosMock.history.get.length).toBe(3);
// (4) for course outline index (from CourseAuthoringProvider)
expect(axiosMock.history.get.length).toBe(4);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});

Expand Down
272 changes: 268 additions & 4 deletions src/CourseAuthoringContext.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { getConfig } from '@edx/frontend-platform';
import {
createContext, useContext, useMemo, useState,
createContext, useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
import { useSelector } from 'react-redux';
import { useCreateCourseBlock, useDeleteCourseItem, useDuplicateItem } from '@src/course-outline/data/apiHooks';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router';
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
import { getOutlineIndexData, getSectionsList } from '@src/course-outline/data/selectors';
import { useToggleWithValue } from '@src/hooks';
import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types';
import { CourseDetailsData } from './data/api';
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
import { RequestStatusType } from './data/constants';
import { arrayMove } from '@dnd-kit/sortable';
import { fetchCourseOutlineIndexQuery, setSectionOrderListQuery, setSubsectionOrderListQuery, setUnitOrderListQuery } from './course-outline/data/thunk';
import { useToggle } from '@openedx/paragon';
import { getBlockType } from './generic/key-utils';
import { COURSE_BLOCK_NAMES } from './constants';
import { deleteSection, deleteSubsection, deleteUnit } from './course-outline/data/slice';

type ModalState = {
value?: XBlock | UnitXBlock;
Expand Down Expand Up @@ -40,6 +46,23 @@ export type CourseAuthoringContextData = {
closePublishModal: () => void;
currentSelection?: SelectionState;
setCurrentSelection: React.Dispatch<React.SetStateAction<SelectionState | undefined>>;
sections: XBlock[];
restoreSectionList: () => void;
setSections: React.Dispatch<React.SetStateAction<XBlock[]>>;
isDuplicatingItem: boolean;
isDeleteModalOpen: boolean;
openDeleteModal: () => void;
closeDeleteModal: () => void;
getHandleDeleteItemSubmit: (callback: () => void) => () => Promise<void>;
handleDuplicateSectionSubmit: () => void;
handleDuplicateSubsectionSubmit: () => void;
handleDuplicateUnitSubmit: () => void;
handleSectionDragAndDrop: (sectionListIds: string[]) => void;
handleSubsectionDragAndDrop: (sectionId: string, prevSectionId: string, subsectionListIds: string[]) => void;
handleUnitDragAndDrop: (sectionId: string, prevSectionId: string, subsectionId: string, unitListIds: string[]) => void;
updateSectionOrderByIndex: (currentIndex: number, newIndex: number) => void;
updateSubsectionOrderByIndex: (section: XBlock, moveDetails: any) => void;
updateUnitOrderByIndex: (section: XBlock, moveDetails: any) => void;
};

/**
Expand All @@ -61,6 +84,7 @@ export const CourseAuthoringProvider = ({
courseId,
}: CourseAuthoringProviderProps) => {
const navigate = useNavigate();
const dispatch = useDispatch();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
Expand All @@ -78,6 +102,23 @@ export const CourseAuthoringProvider = ({
openPublishModal,
closePublishModal,
] = useToggleWithValue<ModalState>();
const sectionsList = useSelector(getSectionsList);
const [sections, setSections] = useState<XBlock[]>(sectionsList);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);


const restoreSectionList = () => {
setSections(() => [...sectionsList]);
};

useEffect(() => {
dispatch(fetchCourseOutlineIndexQuery(courseId));
}, [courseId]);

useEffect(() => {
setSections(sectionsList);
}, [sectionsList]);

/**
* This will hold the state of current item that is being operated on,
* For example:
Expand Down Expand Up @@ -113,6 +154,196 @@ export const CourseAuthoringProvider = ({
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
const handleAddBlock = useCreateCourseBlock(courseId);

const {
mutate: duplicateItem,
isPending: isDuplicatingItem,
} = useDuplicateItem(courseId);
const handleDuplicateSectionSubmit = () => {
if (currentSelection && currentSelection.currentId) {
duplicateItem({
itemId: currentSelection.currentId,
parentId: courseStructure.id,
sectionId: currentSelection.sectionId,
subsectionId: currentSelection.subsectionId,
});
}
};

const handleDuplicateSubsectionSubmit = () => {
if (currentSelection && currentSelection.currentId && currentSelection.sectionId) {
duplicateItem({
itemId: currentSelection.currentId,
parentId: currentSelection.sectionId,
sectionId: currentSelection.sectionId,
subsectionId: currentSelection.subsectionId,
});
}
};

const handleDuplicateUnitSubmit = () => {
if (currentSelection && currentSelection.currentId && currentSelection.subsectionId) {
duplicateItem({
itemId: currentSelection?.currentId,
parentId: currentSelection?.subsectionId,
sectionId: currentSelection?.sectionId,
subsectionId: currentSelection?.subsectionId,
});
}
};

const handleSectionDragAndDrop = (
sectionListIds: string[],
) => {
dispatch(setSectionOrderListQuery(
courseId,
sectionListIds,
restoreSectionList,
));
};

const handleSubsectionDragAndDrop = (
sectionId: string,
prevSectionId: string,
subsectionListIds: string[],
) => {
dispatch(setSubsectionOrderListQuery(
sectionId,
prevSectionId,
subsectionListIds,
restoreSectionList,
));
};

const handleUnitDragAndDrop = (
sectionId: string,
prevSectionId: string,
subsectionId: string,
unitListIds: string[],
) => {
dispatch(setUnitOrderListQuery(
sectionId,
subsectionId,
prevSectionId,
unitListIds,
restoreSectionList,
));
};

/**
* Uses details from move information and moves unit
*/
const updateUnitOrderByIndex = (section: XBlock, moveDetails) => {
const { fn, args, sectionId, subsectionId } = moveDetails;
if (!args) {
return;
}
const [sectionsCopy, newUnits] = fn(...args);
if (newUnits && subsectionId) {
setSections(sectionsCopy);
handleUnitDragAndDrop(
sectionId,
section.id,
subsectionId,
newUnits.map((unit) => unit.id),
);
}
};

/**
* Move section to new index
*/
const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => {
if (currentIndex === newIndex) {
return;
}
setSections((prevSections) => {
const newSections = arrayMove(prevSections, currentIndex, newIndex);
handleSectionDragAndDrop(newSections.map(section => section.id));
return newSections;
});
};

/**
* Uses details from move information and moves subsection
*/
const updateSubsectionOrderByIndex = (section: XBlock, moveDetails) => {
const { fn, args, sectionId } = moveDetails;
if (!args) {
return;
}
const [sectionsCopy, newSubsections] = fn(...args);
if (newSubsections && sectionId) {
setSections(sectionsCopy);
handleSubsectionDragAndDrop(
sectionId,
section.id,
newSubsections.map(subsection => subsection.id),
);
}
};

const deleteMutation = useDeleteCourseItem();

const getHandleDeleteItemSubmit = useCallback((callback: () => void) => {
return async () => {
// istanbul ignore if
if (!currentSelection) {
return;
}
const category = getBlockType(currentSelection.currentId);
switch (category) {
case COURSE_BLOCK_NAMES.chapter.id:
await deleteMutation.mutateAsync(
{ itemId: currentSelection.currentId },
{
onSettled: () => dispatch(deleteSection({ itemId: currentSelection.currentId })),
},
);
break;
case COURSE_BLOCK_NAMES.sequential.id:
await deleteMutation.mutateAsync(
{ itemId: currentSelection.currentId, sectionId: currentSelection.sectionId },
{
onSettled: () => dispatch(deleteSubsection({
itemId: currentSelection.currentId,
sectionId: currentSelection.sectionId,
})),
},
);
break;
case COURSE_BLOCK_NAMES.vertical.id:
await deleteMutation.mutateAsync(
{
itemId: currentSelection.currentId,
subsectionId: currentSelection.subsectionId,
sectionId: currentSelection.sectionId,
},
{
onSettled: () => dispatch(deleteUnit({
itemId: currentSelection.currentId,
subsectionId: currentSelection.subsectionId,
sectionId: currentSelection.sectionId,
})),
},
);
break;
default:
// istanbul ignore next
throw new Error(`Unrecognized category ${category}`);
}
closeDeleteModal();
callback();
};
}, [
deleteMutation,
closeDeleteModal,
currentSelection,
dispatch,
deleteSection,
deleteUnit,
deleteSubsection,
]);

const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
courseUsageKey,
Expand All @@ -133,6 +364,23 @@ export const CourseAuthoringProvider = ({
closePublishModal,
currentSelection,
setCurrentSelection,
sections,
restoreSectionList,
setSections,
isDuplicatingItem,
isDeleteModalOpen,
openDeleteModal,
closeDeleteModal,
getHandleDeleteItemSubmit,
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
handleDuplicateUnitSubmit,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
updateSectionOrderByIndex,
updateSubsectionOrderByIndex,
updateUnitOrderByIndex,
}), [
courseId,
courseUsageKey,
Expand All @@ -153,6 +401,22 @@ export const CourseAuthoringProvider = ({
closePublishModal,
currentSelection,
setCurrentSelection,
sections,
restoreSectionList,
setSections,
isDuplicatingItem,
isDeleteModalOpen,
openDeleteModal,
closeDeleteModal,
getHandleDeleteItemSubmit,
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
handleSectionDragAndDrop,
handleSubsectionDragAndDrop,
handleUnitDragAndDrop,
updateSectionOrderByIndex,
updateSubsectionOrderByIndex,
updateUnitOrderByIndex,
]);

return (
Expand Down
2 changes: 1 addition & 1 deletion src/content-tags-drawer/data/apiHooks.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('useTaxonomyTagsData', () => {
// Assert that useQueries was called with the correct arguments
expect(useQueries).toHaveBeenCalledWith({
queries: [
{ queryKey: ['taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity },
{ queryKey: ['contentTags', 'taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity },
],
});

Expand Down
Loading
Loading