refactor(course-outline): replace thunks with react query, add typed APIs, and improve type usages (#2900)
* Replaces configure xblock and section highlights redux functions with react-query. * Replaces section highlights thunks with react query * Replaces duplicate block thunks * Removes add subsection, unit redux functions * Replaces scrollTo redux state to react-query based state. * Replaces paste unit block redux functions * Removes a lot of redux related functions as a result. * Reduces API calls without compromising data integrity.
This commit is contained in:
@@ -4,18 +4,14 @@ import {
|
||||
} 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, addUnit, updateSavingStatus,
|
||||
} from '@src/course-outline/data/slice';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getOutlineIndexData } 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 { RequestStatus, RequestStatusType } from './data/constants';
|
||||
import { RequestStatusType } from './data/constants';
|
||||
|
||||
type ModalState = {
|
||||
value?: XBlock | UnitXBlock;
|
||||
@@ -30,10 +26,8 @@ export type CourseAuthoringContextData = {
|
||||
courseDetails?: CourseDetailsData;
|
||||
courseDetailStatus: RequestStatusType;
|
||||
canChangeProviders: boolean;
|
||||
handleAddSection: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddSubsection: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddUnit: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddBlock: ReturnType<typeof useCreateCourseBlock>;
|
||||
openUnitPage: (locator: string) => void;
|
||||
getUnitUrl: (locator: string) => string;
|
||||
isUnlinkModalOpen: boolean;
|
||||
@@ -66,7 +60,6 @@ export const CourseAuthoringProvider = ({
|
||||
children,
|
||||
courseId,
|
||||
}: CourseAuthoringProviderProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const waffleFlags = useWaffleFlags();
|
||||
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
|
||||
@@ -114,47 +107,11 @@ export const CourseAuthoringProvider = ({
|
||||
window.location.assign(url);
|
||||
}
|
||||
};
|
||||
|
||||
const addSectionToCourse = /* istanbul ignore next */ async (locator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const addSubsectionToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const addUnitToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addUnit({ parentLocator, data }));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSection = useCreateCourseBlock(addSectionToCourse);
|
||||
const handleAddSubsection = useCreateCourseBlock(addSubsectionToCourse);
|
||||
/**
|
||||
* import a unit block from library and redirect user to this unit page.
|
||||
*/
|
||||
const handleAddAndOpenUnit = useCreateCourseBlock(openUnitPage);
|
||||
const handleAddUnit = useCreateCourseBlock(addUnitToCourse);
|
||||
const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage);
|
||||
const handleAddBlock = useCreateCourseBlock(courseId);
|
||||
|
||||
const context = useMemo<CourseAuthoringContextData>(() => ({
|
||||
courseId,
|
||||
@@ -162,9 +119,7 @@ export const CourseAuthoringProvider = ({
|
||||
courseDetails,
|
||||
courseDetailStatus,
|
||||
canChangeProviders,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
getUnitUrl,
|
||||
openUnitPage,
|
||||
@@ -184,9 +139,7 @@ export const CourseAuthoringProvider = ({
|
||||
courseDetails,
|
||||
courseDetailStatus,
|
||||
canChangeProviders,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
getUnitUrl,
|
||||
openUnitPage,
|
||||
|
||||
@@ -36,10 +36,9 @@ import {
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery, syncDiscussionsTopics,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
} from './data/thunk';
|
||||
import {
|
||||
courseOutlineIndexMock,
|
||||
courseOutlineIndexMock as originalCourseOutlineIndexMock,
|
||||
courseOutlineIndexWithoutSections,
|
||||
courseBestPracticesMock,
|
||||
courseLaunchMock,
|
||||
@@ -71,6 +70,7 @@ const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1');
|
||||
const getContainerType = jest.fn().mockReturnValue('unit');
|
||||
const clearSelection = jest.fn();
|
||||
let selectedContainerId: string | undefined;
|
||||
let courseOutlineIndexMock = cloneDeep(originalCourseOutlineIndexMock);
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
@@ -96,13 +96,6 @@ jest.mock('@src/help-urls/hooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./data/api', () => ({
|
||||
...jest.requireActual('./data/api'),
|
||||
getTagsCount: () => jest.fn().mockResolvedValue({}),
|
||||
@@ -163,6 +156,8 @@ describe('<CourseOutline />', () => {
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
selectedContainerId = undefined;
|
||||
// restore index mock
|
||||
courseOutlineIndexMock = cloneDeep(originalCourseOutlineIndexMock);
|
||||
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: mockPathname,
|
||||
@@ -300,7 +295,7 @@ describe('<CourseOutline />', () => {
|
||||
expect(alertElements.find(
|
||||
(el) => el.classList.contains('alert-content'),
|
||||
)).toHaveTextContent(
|
||||
pageAlertMessages.alertFailedGeneric.defaultMessage,
|
||||
'Unable to save changes. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -404,6 +399,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds new subsection correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [section] = await findAllByTestId('section-card');
|
||||
let subsections = await within(section).findAllByTestId('subsection-card');
|
||||
@@ -428,10 +424,14 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
|
||||
.reply(200, courseSubsectionMock);
|
||||
const firstSectionData = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
// @ts-ignore
|
||||
firstSectionData.childInfo.children.push(courseSubsectionMock);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(firstSectionData.id))
|
||||
.reply(200, firstSectionData);
|
||||
const newSubsectionButton = await within(section).findByRole('button', { name: 'New subsection' });
|
||||
await act(async () => {
|
||||
fireEvent.click(newSubsectionButton);
|
||||
});
|
||||
await user.click(newSubsectionButton);
|
||||
|
||||
subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(3);
|
||||
@@ -540,6 +540,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds a section from library correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
getContainerKey.mockReturnValue('lct:org:lib:section:1');
|
||||
getContainerKey.mockReturnValue('section');
|
||||
renderComponent();
|
||||
@@ -552,11 +553,14 @@ describe('<CourseOutline />', () => {
|
||||
locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd',
|
||||
courseKey: 'course-v1:UNIX+UX1+2025_T3',
|
||||
});
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl('block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd'))
|
||||
.reply(200, courseSectionMock);
|
||||
|
||||
const addSectionFromLibraryButton = await screen.findByRole('button', {
|
||||
name: /use section from library/i,
|
||||
});
|
||||
fireEvent.click(addSectionFromLibraryButton);
|
||||
await user.click(addSectionFromLibraryButton);
|
||||
|
||||
// click dummy button to execute onComponentSelected prop.
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
@@ -705,26 +709,54 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check edit title works for section, subsection and unit', async () => {
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const checkEditTitle = async (element, item, newName, elementName) => {
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
if (item.id === section.id) {
|
||||
// return normal section data the first time to keep original name first
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
// @ts-ignore
|
||||
.replyOnce(section);
|
||||
}
|
||||
|
||||
// mock section, subsection and unit name and check within the elements.
|
||||
// this is done to avoid adding conditions to this mock.
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(item.id))
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...item,
|
||||
...section,
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
display_name: newName,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
|
||||
fireEvent.click(editButton);
|
||||
const editField = await within(element).findByTestId(`${elementName}-edit-field`);
|
||||
fireEvent.change(editField, { target: { value: newName } });
|
||||
await act(async () => fireEvent.blur(editField));
|
||||
await user.keyboard('{enter}');
|
||||
expect(
|
||||
axiosMock.history.post[axiosMock.history.post.length - 1].data,
|
||||
).toBe(JSON.stringify({
|
||||
@@ -737,8 +769,7 @@ describe('<CourseOutline />', () => {
|
||||
};
|
||||
|
||||
// check section
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
await checkEditTitle(sectionElement, section, 'New section name', 'section');
|
||||
|
||||
// check subsection
|
||||
@@ -1627,15 +1658,14 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check update highlights when update highlights query is successfully', async () => {
|
||||
const { getByRole } = renderComponent();
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const highlights = [
|
||||
'New Highlight 1',
|
||||
'New Highlight 2',
|
||||
'New Highlight 3',
|
||||
'New Highlight 4',
|
||||
'New Highlight 5',
|
||||
];
|
||||
|
||||
axiosMock
|
||||
@@ -1653,12 +1683,21 @@ describe('<CourseOutline />', () => {
|
||||
...section,
|
||||
highlights,
|
||||
});
|
||||
|
||||
await executeThunk(updateCourseSectionHighlightsQuery(section.id, highlights), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
|
||||
const highlightBtn = await screen.findAllByRole('button', { name: '0 Section highlights' });
|
||||
await user.click(highlightBtn[0]);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 1' }), {
|
||||
target: { value: 'New Highlight 1' },
|
||||
});
|
||||
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 2' }), {
|
||||
target: { value: 'New Highlight 2' },
|
||||
});
|
||||
fireEvent.change(await within(dialog).findByRole('textbox', { name: 'Highlight 3' }), {
|
||||
target: { value: 'New Highlight 3' },
|
||||
});
|
||||
await user.click(await within(dialog).findByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: '3 Section highlights' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check whether section move up and down options work correctly', async () => {
|
||||
@@ -2270,6 +2309,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
@@ -2328,13 +2368,6 @@ describe('<CourseOutline />', () => {
|
||||
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
|
||||
expect(lastUnitElement).toHaveTextContent(unit.displayName);
|
||||
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({
|
||||
newFiles: ['some.css'],
|
||||
conflictingFiles: ['con.css'],
|
||||
errorFiles: ['error.css'],
|
||||
});
|
||||
|
||||
let alerts = await screen.findAllByRole('alert');
|
||||
// Exclude processing notification toast
|
||||
alerts = alerts.filter((el) => !el.classList.contains('toast-container'));
|
||||
@@ -2343,18 +2376,18 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// check alerts for errorFiles
|
||||
let dismissBtn = await within(alerts[0]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
await user.click(dismissBtn);
|
||||
|
||||
// check alerts for conflictingFiles
|
||||
dismissBtn = await within(alerts[1]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
await user.click(dismissBtn);
|
||||
|
||||
// check alerts for newFiles
|
||||
dismissBtn = await within(alerts[2]).findByText('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
await user.click(dismissBtn);
|
||||
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
|
||||
// check that all alerts are gone
|
||||
expect((screen.queryAllByRole('alert')).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should show toats on export tags', async () => {
|
||||
|
||||
@@ -71,10 +71,8 @@ const CourseOutline = () => {
|
||||
const {
|
||||
courseId,
|
||||
courseUsageKey,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
handleAddSection,
|
||||
isUnlinkModalOpen,
|
||||
closeUnlinkModal,
|
||||
currentSelection,
|
||||
@@ -97,6 +95,7 @@ const CourseOutline = () => {
|
||||
isDisabledReindexButton,
|
||||
isHighlightsModalOpen,
|
||||
isConfigureModalOpen,
|
||||
isConfigureOpPending,
|
||||
isDeleteModalOpen,
|
||||
closeHighlightsModal,
|
||||
handleConfigureModalClose,
|
||||
@@ -109,14 +108,17 @@ const CourseOutline = () => {
|
||||
handleEnableHighlightsSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
isSectionHighlightsUpdatePending,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureItemSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
isDuplicatingItem,
|
||||
handleVideoSharingOptionChange,
|
||||
handlePasteClipboardClick,
|
||||
isPasting,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
@@ -129,7 +131,6 @@ const CourseOutline = () => {
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
handleUnlinkItemSubmit,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
@@ -386,7 +387,6 @@ const CourseOutline = () => {
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={section.id}
|
||||
@@ -413,7 +413,6 @@ const CourseOutline = () => {
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOrderChange={updateSubsectionOrderByIndex}
|
||||
onPasteClick={handlePasteClipboardClick}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={subsection.id}
|
||||
@@ -523,10 +522,12 @@ const CourseOutline = () => {
|
||||
// Show processing toast if any mutation is running
|
||||
isShow={
|
||||
isShowProcessingNotification
|
||||
|| handleAddUnit.isPending
|
||||
|| handleAddBlock.isPending
|
||||
|| handleAddAndOpenUnit.isPending
|
||||
|| handleAddSubsection.isPending
|
||||
|| handleAddSection.isPending
|
||||
|| isConfigureOpPending
|
||||
|| isSectionHighlightsUpdatePending
|
||||
|| isDuplicatingItem
|
||||
|| isPasting
|
||||
}
|
||||
// HACK: Use saving as default title till we have a need for better messages
|
||||
title={processingNotificationTitle || NOTIFICATION_MESSAGES.saving}
|
||||
|
||||
@@ -14,10 +14,8 @@ jest.mock('@src/studio-home/data/selectors', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const handleAddSection = { mutateAsync: jest.fn() };
|
||||
const handleAddSubsection = { mutateAsync: jest.fn() };
|
||||
const handleAddAndOpenUnit = { mutateAsync: jest.fn() };
|
||||
const handleAddUnit = { mutateAsync: jest.fn() };
|
||||
const handleAddBlock = { mutateAsync: jest.fn() };
|
||||
const courseUsageKey = 'some/usage/key';
|
||||
const setCurrentSelection = jest.fn();
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
@@ -25,10 +23,8 @@ jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
courseId: 5,
|
||||
courseUsageKey,
|
||||
getUnitUrl: (id: string) => `/some/${id}`,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddAndOpenUnit,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
@@ -81,9 +77,11 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
|
||||
it('calls appropriate new handlers', async () => {
|
||||
const parentLocator = `parent-of-${containerType}`;
|
||||
const grandParentLocator = `grandparent-of-${containerType}`;
|
||||
render(<OutlineAddChildButtons
|
||||
childType={containerType}
|
||||
parentLocator={parentLocator}
|
||||
grandParentLocator={grandParentLocator}
|
||||
/>, { extraWrapper: OutlineSidebarProvider });
|
||||
|
||||
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
|
||||
@@ -91,17 +89,18 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
await userEvent.click(newBtn);
|
||||
switch (containerType) {
|
||||
case ContainerType.Section:
|
||||
await waitFor(() => expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
|
||||
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: 'Section',
|
||||
}));
|
||||
break;
|
||||
case ContainerType.Subsection:
|
||||
await waitFor(() => expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
|
||||
await waitFor(() => expect(handleAddBlock.mutateAsync).toHaveBeenCalledWith({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
displayName: 'Subsection',
|
||||
sectionId: parentLocator,
|
||||
}));
|
||||
break;
|
||||
case ContainerType.Unit:
|
||||
@@ -109,6 +108,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: 'Unit',
|
||||
sectionId: grandParentLocator,
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -28,9 +28,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
|
||||
const intl = useIntl();
|
||||
const { isCurrentFlowOn, currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
|
||||
const {
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
|
||||
@@ -58,10 +56,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
|
||||
>
|
||||
<Col className="py-3">
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
{(handleAddSection.isPending
|
||||
|| handleAddSubsection.isPending
|
||||
|| handleAddAndOpenUnit.isPending
|
||||
|| handleAddUnit.isPending) && (
|
||||
{(handleAddAndOpenUnit.isPending || handleAddBlock.isPending) && (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
<h3 className="mb-0">{getTitle()}</h3>
|
||||
@@ -86,11 +81,11 @@ interface BaseProps {
|
||||
btnClasses?: string;
|
||||
btnSize?: 'sm' | 'md' | 'lg' | 'inline';
|
||||
parentLocator: string;
|
||||
grandParentLocator?: string;
|
||||
}
|
||||
|
||||
interface NewChildButtonsProps extends BaseProps {
|
||||
handleUseFromLibraryClick?: () => void;
|
||||
grandParentLocator?: string;
|
||||
}
|
||||
|
||||
const NewOutlineAddChildButtons = ({
|
||||
@@ -113,8 +108,7 @@ const NewOutlineAddChildButtons = ({
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const { startCurrentFlow } = useOutlineSidebarContext();
|
||||
@@ -132,7 +126,7 @@ const NewOutlineAddChildButtons = ({
|
||||
newButton: messages.newSectionButton,
|
||||
importButton: messages.useSectionFromLibraryButton,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
@@ -144,10 +138,11 @@ const NewOutlineAddChildButtons = ({
|
||||
newButton: messages.newSubsectionButton,
|
||||
importButton: messages.useSubsectionFromLibraryButton,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSubsection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
sectionId: parentLocator,
|
||||
});
|
||||
flowType = ContainerType.Subsection;
|
||||
break;
|
||||
@@ -160,6 +155,7 @@ const NewOutlineAddChildButtons = ({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
sectionId: grandParentLocator,
|
||||
});
|
||||
flowType = ContainerType.Unit;
|
||||
break;
|
||||
@@ -226,6 +222,7 @@ const LegacyOutlineAddChildButtons = ({
|
||||
btnClasses = 'mt-4 border-gray-500 rounded-0',
|
||||
btnSize,
|
||||
parentLocator,
|
||||
grandParentLocator,
|
||||
onClickCard,
|
||||
}: BaseProps) => {
|
||||
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
|
||||
@@ -237,8 +234,7 @@ const LegacyOutlineAddChildButtons = ({
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const [
|
||||
@@ -263,12 +259,12 @@ const LegacyOutlineAddChildButtons = ({
|
||||
importButton: messages.useSectionFromLibraryButton,
|
||||
modalTitle: messages.sectionPickerModalTitle,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
});
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddSection.mutateAsync({
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
@@ -283,16 +279,18 @@ const LegacyOutlineAddChildButtons = ({
|
||||
importButton: messages.useSubsectionFromLibraryButton,
|
||||
modalTitle: messages.subsectionPickerModalTitle,
|
||||
};
|
||||
onNewCreateContent = () => handleAddSubsection.mutateAsync({
|
||||
onNewCreateContent = () => handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
sectionId: parentLocator,
|
||||
});
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddSubsection.mutateAsync({
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Sequential,
|
||||
parentLocator,
|
||||
libraryContentKey: selected.usageKey,
|
||||
sectionId: parentLocator,
|
||||
});
|
||||
visibleTabs = [ContentType.subsections];
|
||||
query = ['block_type = "subsection"'];
|
||||
@@ -307,12 +305,14 @@ const LegacyOutlineAddChildButtons = ({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
sectionId: grandParentLocator,
|
||||
});
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddAndOpenUnit.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
libraryContentKey: selected.usageKey,
|
||||
sectionId: grandParentLocator,
|
||||
});
|
||||
visibleTabs = [ContentType.units];
|
||||
query = ['block_type = "unit"'];
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline, CourseDetails, CourseItemUpdateResult } from './types';
|
||||
import {
|
||||
CourseOutline,
|
||||
CourseDetails,
|
||||
CourseItemUpdateResult,
|
||||
ConfigureSectionData,
|
||||
ConfigureSubsectionData,
|
||||
ConfigureUnitData,
|
||||
StaticFileNotices,
|
||||
} from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
@@ -212,23 +220,15 @@ export async function publishCourseItem(itemId: string): Promise<CourseItemUpdat
|
||||
|
||||
/**
|
||||
* Configure course section
|
||||
* @param {string} sectionId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {string} startDatetime
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSection(
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
startDatetime: string,
|
||||
): Promise<object> {
|
||||
export async function configureCourseSection(variables: ConfigureSectionData): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
.post(getCourseItemApiUrl(variables.sectionId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
start: startDatetime,
|
||||
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
|
||||
start: variables.startDatetime,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -236,66 +236,30 @@ export async function configureCourseSection(
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure course section
|
||||
* @param {string} itemId
|
||||
* @param {string} isVisibleToStaffOnly
|
||||
* @param {string} releaseDate
|
||||
* @param {string} graderType
|
||||
* @param {string} dueDate
|
||||
* @param {boolean} isProctoredExam,
|
||||
* @param {boolean} isOnboardingExam,
|
||||
* @param {boolean} isPracticeExam,
|
||||
* @param {string} examReviewRules,
|
||||
* @param {boolean} isTimeLimited
|
||||
* @param {number} defaultTimeLimitMin
|
||||
* @param {string} hideAfterDue
|
||||
* @param {string} showCorrectness
|
||||
* @param {boolean} isPrereq,
|
||||
* @param {string} prereqUsageKey,
|
||||
* @param {number} prereqMinScore,
|
||||
* @param {number} prereqMinCompletion,
|
||||
* @returns {Promise<Object>}
|
||||
* Configure course subsection
|
||||
*/
|
||||
export async function configureCourseSubsection(
|
||||
itemId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
): Promise<object> {
|
||||
export async function configureCourseSubsection(variables: ConfigureSubsectionData): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
.post(getCourseItemApiUrl(variables.itemId), {
|
||||
publish: 'republish',
|
||||
graderType,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
graderType: variables.graderType,
|
||||
isPrereq: variables.isPrereq,
|
||||
prereqUsageKey: variables.prereqUsageKey,
|
||||
prereqMinScore: variables.prereqMinScore,
|
||||
prereqMinCompletion: variables.prereqMinCompletion,
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
due: dueDate,
|
||||
hide_after_due: hideAfterDue,
|
||||
show_correctness: showCorrectness,
|
||||
is_practice_exam: isPracticeExam,
|
||||
is_time_limited: isTimeLimited,
|
||||
is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam,
|
||||
exam_review_rules: examReviewRules,
|
||||
default_time_limit_minutes: defaultTimeLimitMin,
|
||||
is_onboarding_exam: isOnboardingExam,
|
||||
start: releaseDate,
|
||||
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
|
||||
due: variables.dueDate,
|
||||
hide_after_due: variables.hideAfterDue,
|
||||
show_correctness: variables.showCorrectness,
|
||||
is_practice_exam: variables.isPracticeExam,
|
||||
is_time_limited: variables.isTimeLimited,
|
||||
is_proctored_enabled: variables.isProctoredExam || variables.isPracticeExam || variables.isOnboardingExam,
|
||||
exam_review_rules: variables.examReviewRules,
|
||||
default_time_limit_minutes: variables.defaultTimeLimitMin,
|
||||
is_onboarding_exam: variables.isOnboardingExam,
|
||||
start: variables.releaseDate,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
@@ -303,26 +267,16 @@ export async function configureCourseSubsection(
|
||||
|
||||
/**
|
||||
* Configure course unit
|
||||
* @param {string} unitId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {object} groupAccess
|
||||
* @param {boolean} discussionEnabled
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseUnit(
|
||||
unitId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
): Promise<object> {
|
||||
export async function configureCourseUnit(variables: ConfigureUnitData): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(unitId), {
|
||||
.post(getCourseItemApiUrl(variables.unitId), {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
group_access: groupAccess,
|
||||
discussion_enabled: discussionEnabled,
|
||||
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
|
||||
group_access: variables.groupAccess,
|
||||
discussion_enabled: variables.discussionEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -361,7 +315,10 @@ export async function deleteCourseItem(itemId: string): Promise<object> {
|
||||
/**
|
||||
* Duplicate course section
|
||||
*/
|
||||
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<XBlock> {
|
||||
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<{
|
||||
courseKey: string;
|
||||
locator: string;
|
||||
}> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
duplicate_source_locator: itemId,
|
||||
@@ -467,7 +424,12 @@ export async function setVideoSharingOption(
|
||||
* @param {string} parentLocator
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function pasteBlock(parentLocator: string): Promise<object> {
|
||||
export async function pasteBlock(parentLocator: string): Promise<{
|
||||
locator: string;
|
||||
courseKey: string;
|
||||
staticFileNotices: StaticFileNotices;
|
||||
upstreamRef: string;
|
||||
}> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks';
|
||||
import { addSection, duplicateSection, updateSectionList } from '@src/course-outline/data/slice';
|
||||
import {
|
||||
ConfigureSectionData,
|
||||
ConfigureSubsectionData,
|
||||
ConfigureUnitData,
|
||||
StaticFileNotices,
|
||||
} from '@src/course-outline/data/types';
|
||||
import { createGlobalState } from '@src/data/apiHooks';
|
||||
import type { XBlockBase, XblockChildInfo } from '@src/data/types';
|
||||
import { getCourseKey } from '@src/generic/key-utils';
|
||||
import { getBlockType, getCourseKey } from '@src/generic/key-utils';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import { ParentIds } from '@src/generic/types';
|
||||
import {
|
||||
QueryClient,
|
||||
skipToken, useMutation, useQuery, useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
createCourseXblock,
|
||||
type CreateCourseXBlockType,
|
||||
@@ -15,6 +24,12 @@ import {
|
||||
getCourseDetails,
|
||||
getCourseItem,
|
||||
publishCourseItem,
|
||||
configureCourseSection,
|
||||
configureCourseSubsection,
|
||||
configureCourseUnit,
|
||||
updateCourseSectionHighlights,
|
||||
duplicateCourseItem,
|
||||
pasteBlock,
|
||||
} from './api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
@@ -27,6 +42,14 @@ export const courseOutlineQueryKeys = {
|
||||
...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId) : undefined),
|
||||
itemId,
|
||||
],
|
||||
scrollToCourseItemId: (courseId?: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'scroll',
|
||||
],
|
||||
pasteFileNotices: (courseId?: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'pasteFileNotices',
|
||||
],
|
||||
courseDetails: (courseId?: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'details',
|
||||
@@ -42,6 +65,14 @@ export const courseOutlineQueryKeys = {
|
||||
],
|
||||
};
|
||||
|
||||
type ScrollState = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const useScrollState = createGlobalState<ScrollState>(courseOutlineQueryKeys.scrollToCourseItemId, {
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* Invalidate parent Subsection and Section data.
|
||||
*
|
||||
@@ -72,23 +103,35 @@ type CreateCourseXBlockMutationProps = CreateCourseXBlockType & ParentIds;
|
||||
* @returns Mutation object for creating course blocks
|
||||
*/
|
||||
export const useCreateCourseBlock = (
|
||||
courseKey: string,
|
||||
callback?: ((locator: string, parentLocator: string) => Promise<void>),
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { setData } = useScrollState(courseKey);
|
||||
const dispatch = useDispatch();
|
||||
return useMutation({
|
||||
mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables),
|
||||
onSettled: async (data: { locator: string; }, _err, variables) => {
|
||||
onSuccess: async (data: { locator: string; }, variables) => {
|
||||
await callback?.(data.locator, variables.parentLocator);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)),
|
||||
});
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
// scroll to newly added block
|
||||
setData({ id: data.locator });
|
||||
// if newly created block is chapter or section, fetch and add it to store
|
||||
// all other types are handled by invalidateParentQueries and useCourseItemData
|
||||
if (getBlockType(data.locator) === 'chapter') {
|
||||
const newBlock = await getCourseItem(data.locator);
|
||||
dispatch(addSection(newBlock));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCourseItemData = <T extends XBlockBase>(itemId?: string, initialData?: T, enabled: boolean = true) => {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
return useQuery<T>({
|
||||
initialData,
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
|
||||
@@ -111,6 +154,14 @@ export const useCourseItemData = <T extends XBlockBase>(itemId?: string, initial
|
||||
}
|
||||
});
|
||||
}
|
||||
// We update redux store section list to update children list in outline.
|
||||
// Even though each block has its own hook to fetch data, new child blocks or deleted blocks
|
||||
// won't be detected as the child blocks are rendered in the outline from the top level
|
||||
// sectionList from redux store.
|
||||
if (['chapter', 'section'].includes(data.category)) {
|
||||
const payload = { [data.id]: data };
|
||||
dispatch(updateSectionList(payload));
|
||||
}
|
||||
return data;
|
||||
} : skipToken,
|
||||
});
|
||||
@@ -172,3 +223,103 @@ export const useDeleteCourseItem = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfigureSection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: ConfigureSectionData & ParentIds) => configureCourseSection(variables),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)),
|
||||
});
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfigureSubsection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: ConfigureSubsectionData & ParentIds) => configureCourseSubsection(variables),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfigureUnit = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: ConfigureUnitData & ParentIds) => configureCourseUnit(variables),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.unitId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCourseSectionHighlights = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
sectionId: string;
|
||||
highlights: string[];
|
||||
} & ParentIds) => updateCourseSectionHighlights(variables.sectionId, variables.highlights),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)),
|
||||
});
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDuplicateItem = (courseKey: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { setData } = useScrollState(courseKey);
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
itemId: string;
|
||||
parentId: string;
|
||||
} & ParentIds) => duplicateCourseItem(variables.itemId, variables.parentId),
|
||||
onSuccess: async (data, variables) => {
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
// add duplicated section to store, subsection and unit are handled by invalidateParentQueries
|
||||
if (getBlockType(variables.itemId) === 'chapter') {
|
||||
const duplicatedItem = await getCourseItem(data.locator);
|
||||
dispatch(duplicateSection({ id: variables.itemId, duplicatedItem }));
|
||||
}
|
||||
// scroll to newly added block
|
||||
setData({ id: data.locator });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePasteFileNotices = createGlobalState<StaticFileNotices>(
|
||||
courseOutlineQueryKeys.pasteFileNotices,
|
||||
{
|
||||
newFiles: [],
|
||||
conflictingFiles: [],
|
||||
errorFiles: [],
|
||||
},
|
||||
);
|
||||
|
||||
export const usePasteItem = (courseId?: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { setData: setScrollState } = useScrollState(courseId);
|
||||
const { setData } = usePasteFileNotices(courseId);
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
parentLocator: string;
|
||||
} & ParentIds) => pasteBlock(variables.parentLocator),
|
||||
onSuccess: async (data, variables) => {
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
// set pasteFileNotices
|
||||
setData(data.staticFileNotices);
|
||||
// scroll to pasted block
|
||||
setScrollState({ id: data.locator });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,6 +7,5 @@ export const getCourseActions = (state) => state.courseOutline.actions;
|
||||
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
|
||||
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
|
||||
export const getTimedExamsFlag = (state) => state.courseOutline.enableTimedExams;
|
||||
export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices;
|
||||
export const getErrors = (state) => state.courseOutline.errors;
|
||||
export const getCreatedOn = (state) => state.courseOutline.createdOn;
|
||||
|
||||
@@ -47,9 +47,8 @@ const initialState = {
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
enableTimedExams: false,
|
||||
pasteFileNotices: {},
|
||||
createdOn: null,
|
||||
} satisfies CourseOutlineState as unknown as CourseOutlineState;
|
||||
} satisfies CourseOutlineState;
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseOutline',
|
||||
@@ -133,27 +132,6 @@ const slice = createSlice({
|
||||
payload,
|
||||
];
|
||||
},
|
||||
resetScrollField: (state) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
section.shouldScroll = false;
|
||||
section.childInfo.children.map((subsection) => {
|
||||
subsection.shouldScroll = false;
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
addSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id === payload.parentLocator) {
|
||||
section.childInfo.children = [
|
||||
...section.childInfo.children.filter(child => child.id !== payload.data.id), // Filter to avoid duplicates
|
||||
payload.data,
|
||||
];
|
||||
}
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
@@ -170,25 +148,6 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
// FIXME: This is a temporary measure to add unit using redux even while we are
|
||||
// actively trying to get rid of it.
|
||||
// To remove this and other add functions, we need to migrate course outline data
|
||||
// to a react-query and perform optimistic updates to add/remove content.
|
||||
addUnit: /* istanbul ignore next */ (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
section.childInfo.children = section.childInfo.children.map((subsection) => {
|
||||
if (subsection.id !== payload.parentLocator) {
|
||||
return subsection;
|
||||
}
|
||||
subsection.childInfo.children = [
|
||||
...subsection.childInfo.children.filter(({ id }) => id !== payload.data.id),
|
||||
payload.data,
|
||||
];
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
@@ -214,20 +173,11 @@ const slice = createSlice({
|
||||
return [...result, currentValue];
|
||||
}, []);
|
||||
},
|
||||
setPasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
state.pasteFileNotices = payload;
|
||||
},
|
||||
removePasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
const pasteFileNotices = { ...state.pasteFileNotices };
|
||||
payload.forEach((key: string | number) => delete pasteFileNotices[key]);
|
||||
state.pasteFileNotices = pasteFileNotices;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addSection,
|
||||
addSubsection,
|
||||
fetchOutlineIndexSuccess,
|
||||
updateOutlineIndexLoadingStatus,
|
||||
updateReindexLoadingStatus,
|
||||
@@ -242,13 +192,9 @@ export const {
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
addUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
setPasteFileNotices,
|
||||
removePasteFileNotices,
|
||||
dismissError,
|
||||
resetScrollField,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -11,21 +11,15 @@ import {
|
||||
} from '../utils/getChecklistForStatusBar';
|
||||
import { getErrorDetails } from '../utils/getErrorDetails';
|
||||
import {
|
||||
duplicateCourseItem,
|
||||
enableCourseHighlightsEmails,
|
||||
getCourseBestPractices,
|
||||
getCourseLaunch,
|
||||
getCourseOutlineIndex,
|
||||
getCourseItem,
|
||||
configureCourseSection,
|
||||
configureCourseSubsection,
|
||||
configureCourseUnit,
|
||||
restartIndexingOnCourse,
|
||||
updateCourseSectionHighlights,
|
||||
setSectionOrderList,
|
||||
setVideoSharingOption,
|
||||
setCourseItemOrderList,
|
||||
pasteBlock,
|
||||
dismissNotification, createDiscussionsTopics,
|
||||
} from './api';
|
||||
import {
|
||||
@@ -39,9 +33,7 @@ import {
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
updateFetchSectionLoadingStatus,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
setPasteFileNotices,
|
||||
updateCourseLaunchQueryStatus,
|
||||
} from './slice';
|
||||
|
||||
@@ -201,32 +193,13 @@ export function fetchCourseReindexQuery(reindexLink: string) {
|
||||
/**
|
||||
* Fetches course sections and optionally scrolls to a specific subsection/unit.
|
||||
*/
|
||||
export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
|
||||
subsectionId: string,
|
||||
unitId?: string,
|
||||
}) {
|
||||
export function fetchCourseSectionQuery(sectionIds: string[]) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const sections = {};
|
||||
const results = await Promise.all(sectionIds.map((sectionId) => getCourseItem(sectionId)));
|
||||
results.forEach(section => {
|
||||
if (scrollToId) {
|
||||
const targetSubsection = section?.childInfo?.children?.find(
|
||||
subsection => subsection.id === scrollToId.subsectionId,
|
||||
);
|
||||
|
||||
if (targetSubsection) {
|
||||
if (scrollToId.unitId) {
|
||||
const targetUnit = targetSubsection?.childInfo?.children?.find(unit => unit.id === scrollToId.unitId);
|
||||
if (targetUnit) {
|
||||
targetUnit.shouldScroll = true;
|
||||
}
|
||||
} else {
|
||||
targetSubsection.shouldScroll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
sections[section.id] = section;
|
||||
});
|
||||
dispatch(updateSectionList(sections));
|
||||
@@ -240,186 +213,6 @@ export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseSectionHighlightsQuery(sectionId: string, highlights: string[]) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await updateCourseSectionHighlights(sectionId, highlights).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise<any>) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await configureFn().then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSectionQuery(sectionId: string, isVisibleToStaffOnly: boolean, startDatetime: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSubsectionQuery(
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseUnitQuery(
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess, discussionEnabled),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to duplicate any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {string} parentLocator
|
||||
* @param {(locator) => Promise<any>} duplicateFn
|
||||
*/
|
||||
function duplicateCourseItemQuery(
|
||||
itemId: string,
|
||||
parentLocator: string,
|
||||
duplicateFn: (locator: string) => Promise<any>,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
|
||||
|
||||
try {
|
||||
await duplicateCourseItem(itemId, parentLocator).then(async (result) => {
|
||||
if (result) {
|
||||
await duplicateFn(result.locator);
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSectionQuery(sectionId: string, courseBlockId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
sectionId,
|
||||
courseBlockId,
|
||||
async (locator) => {
|
||||
const duplicatedItem = await getCourseItem(locator);
|
||||
// Page should scroll to newly duplicated item.
|
||||
duplicatedItem.shouldScroll = true;
|
||||
dispatch(duplicateSection({ id: sectionId, duplicatedItem }));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSubsectionQuery(subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId: itemId, // To scroll to the newly duplicated subsection
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
unitId,
|
||||
subsectionId,
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId,
|
||||
unitId: itemId, // To scroll to the newly duplicated unit
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
function setBlockOrderListQuery(
|
||||
parentId: string,
|
||||
blockIds: string[],
|
||||
@@ -515,27 +308,6 @@ export function setUnitOrderListQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteClipboardContent(parentLocator: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
||||
|
||||
try {
|
||||
await pasteBlock(parentLocator).then(async (result: any) => {
|
||||
if (result) {
|
||||
dispatch(fetchCourseSectionQuery([sectionId], { subsectionId: parentLocator, unitId: result.locator }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(setPasteFileNotices(result?.staticFileNotices));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissNotificationQuery(url: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
@@ -74,7 +74,6 @@ export interface CourseOutlineState {
|
||||
actions: XBlockActions;
|
||||
enableProctoredExams: boolean;
|
||||
enableTimedExams: boolean;
|
||||
pasteFileNotices: object;
|
||||
createdOn: null | Date;
|
||||
}
|
||||
|
||||
@@ -90,3 +89,42 @@ export interface CourseItemUpdateResult {
|
||||
displayName?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigureSectionData {
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
startDatetime: string,
|
||||
}
|
||||
|
||||
export interface ConfigureSubsectionData {
|
||||
itemId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
}
|
||||
|
||||
export interface ConfigureUnitData {
|
||||
unitId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
}
|
||||
|
||||
export type StaticFileNotices = {
|
||||
conflictingFiles: string[],
|
||||
errorFiles: string[],
|
||||
newFiles: string[],
|
||||
};
|
||||
|
||||
@@ -12,13 +12,21 @@ import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { courseOutlineQueryKeys, useDeleteCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import {
|
||||
courseOutlineQueryKeys,
|
||||
useConfigureSection,
|
||||
useConfigureSubsection,
|
||||
useConfigureUnit,
|
||||
useDeleteCourseItem,
|
||||
useDuplicateItem,
|
||||
usePasteItem,
|
||||
useUpdateCourseSectionHighlights,
|
||||
} from '@src/course-outline/data/apiHooks';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import {
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
resetScrollField,
|
||||
updateSavingStatus,
|
||||
} from './data/slice';
|
||||
import {
|
||||
@@ -33,23 +41,15 @@ import {
|
||||
getCreatedOn,
|
||||
} from './data/selectors';
|
||||
import {
|
||||
duplicateSectionQuery,
|
||||
duplicateSubsectionQuery,
|
||||
duplicateUnitQuery,
|
||||
enableCourseHighlightsEmailsQuery,
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery,
|
||||
fetchCourseReindexQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
configureCourseSectionQuery,
|
||||
configureCourseSubsectionQuery,
|
||||
configureCourseUnitQuery,
|
||||
setSectionOrderListQuery,
|
||||
setVideoSharingOptionQuery,
|
||||
setSubsectionOrderListQuery,
|
||||
setUnitOrderListQuery,
|
||||
pasteClipboardContent,
|
||||
dismissNotificationQuery,
|
||||
syncDiscussionsTopics,
|
||||
} from './data/thunk';
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
handleAddSection,
|
||||
handleAddBlock,
|
||||
setCurrentSelection,
|
||||
currentSelection,
|
||||
currentUnlinkModalData,
|
||||
@@ -99,18 +99,19 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
const handlePasteClipboardClick = (parentLocator, sectionId) => {
|
||||
dispatch(pasteClipboardContent(parentLocator, sectionId));
|
||||
};
|
||||
|
||||
const resetScrollState = () => {
|
||||
dispatch(resetScrollField());
|
||||
const { mutate: pasteClipboardContent, isPending: isPasting } = usePasteItem(courseId);
|
||||
const handlePasteClipboardClick = (parentLocator, subsectionId, sectionId) => {
|
||||
pasteClipboardContent({
|
||||
parentLocator,
|
||||
subsectionId,
|
||||
sectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleNewSection: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleAddSection.mutateAsync({
|
||||
handleAddBlock.mutateAsync({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseStructure?.id,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
@@ -147,9 +148,16 @@ const useCourseOutline = ({ courseId }) => {
|
||||
openHighlightsModal();
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: updateCourseSectionHighlights,
|
||||
isPending: isSectionHighlightsUpdatePending,
|
||||
} = useUpdateCourseSectionHighlights();
|
||||
const handleHighlightsFormSubmit = (highlights) => {
|
||||
const dataToSend = Object.values(highlights).filter(Boolean);
|
||||
dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId, dataToSend));
|
||||
updateCourseSectionHighlights({
|
||||
sectionId: currentSelection?.currentId,
|
||||
highlights: dataToSend,
|
||||
});
|
||||
|
||||
closeHighlightsModal();
|
||||
};
|
||||
@@ -180,17 +188,41 @@ const useCourseOutline = ({ courseId }) => {
|
||||
});
|
||||
}, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal]);
|
||||
|
||||
const handleConfigureItemSubmit = (...arg) => {
|
||||
const {
|
||||
mutate: configureCourseSection,
|
||||
isPending: isSectionConfigurePending,
|
||||
} = useConfigureSection();
|
||||
const {
|
||||
mutate: configureCourseSubsection,
|
||||
isPending: isSubsectionConfigurePending,
|
||||
} = useConfigureSubsection();
|
||||
const {
|
||||
mutate: configureCourseUnit,
|
||||
isPending: isUnitConfigurePending,
|
||||
} = useConfigureUnit();
|
||||
const isConfigureOpPending = isSectionConfigurePending || isSubsectionConfigurePending || isUnitConfigurePending;
|
||||
const handleConfigureItemSubmit = (variables) => {
|
||||
const category = getBlockType(currentSelection.currentId);
|
||||
switch (category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(configureCourseSectionQuery(currentSelection?.sectionId, ...arg));
|
||||
configureCourseSection({
|
||||
sectionId: currentSelection?.sectionId,
|
||||
...variables,
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(configureCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
|
||||
configureCourseSubsection({
|
||||
itemId: currentSelection?.currentId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
...variables,
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(configureCourseUnitQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
|
||||
configureCourseUnit({
|
||||
unitId: currentSelection?.currentId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
...variables,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// istanbul ignore next
|
||||
@@ -264,20 +296,35 @@ const useCourseOutline = ({ courseId }) => {
|
||||
deleteSubsection,
|
||||
]);
|
||||
|
||||
const {
|
||||
mutate: duplicateItem,
|
||||
isPending: isDuplicatingItem,
|
||||
} = useDuplicateItem(courseId);
|
||||
const handleDuplicateSectionSubmit = () => {
|
||||
dispatch(duplicateSectionQuery(currentSelection?.sectionId, courseStructure.id));
|
||||
duplicateItem({
|
||||
itemId: currentSelection?.currentId,
|
||||
parentId: courseStructure.id,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
subsectionId: currentSelection?.subsectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateSubsectionSubmit = () => {
|
||||
dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId, currentSelection?.sectionId));
|
||||
duplicateItem({
|
||||
itemId: currentSelection?.currentId,
|
||||
parentId: currentSelection?.sectionId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
subsectionId: currentSelection?.subsectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateUnitSubmit = () => {
|
||||
dispatch(duplicateUnitQuery(
|
||||
currentSelection?.currentId,
|
||||
currentSelection?.subsectionId,
|
||||
currentSelection?.sectionId,
|
||||
));
|
||||
duplicateItem({
|
||||
itemId: currentSelection?.currentId,
|
||||
parentId: currentSelection?.subsectionId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
subsectionId: currentSelection?.subsectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoSharingOptionChange = (value) => {
|
||||
@@ -360,12 +407,14 @@ const useCourseOutline = ({ courseId }) => {
|
||||
isConfigureModalOpen,
|
||||
openConfigureModal,
|
||||
handleConfigureModalClose,
|
||||
isConfigureOpPending,
|
||||
headerNavigationsActions,
|
||||
handleEnableHighlightsSubmit,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureItemSubmit,
|
||||
statusBarData,
|
||||
isEnableHighlightsModalOpen,
|
||||
isSectionHighlightsUpdatePending,
|
||||
openEnableHighlightsModal,
|
||||
closeEnableHighlightsModal,
|
||||
isInternetConnectionAlertFailed: isSavingStatusFailed,
|
||||
@@ -380,9 +429,11 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
isDuplicatingItem,
|
||||
handleDuplicateUnitSubmit,
|
||||
handleVideoSharingOptionChange,
|
||||
handlePasteClipboardClick,
|
||||
isPasting,
|
||||
notificationDismissUrl,
|
||||
discussionsSettings,
|
||||
discussionsIncontextLearnmoreUrl,
|
||||
@@ -396,7 +447,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
handleUnlinkItemSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ContainerType } from '@src/generic/key-utils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { snakeCaseKeys } from '@src/editors/utils';
|
||||
import { getXBlockBaseApiUrl } from '@src/course-outline/data/api';
|
||||
import { getXBlockApiUrl, getXBlockBaseApiUrl } from '@src/course-outline/data/api';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
import { AddSidebar } from './AddSidebar';
|
||||
|
||||
@@ -199,10 +199,13 @@ describe('AddSidebar', () => {
|
||||
const sectionId = 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chapter123';
|
||||
axiosMock.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, { locator: sectionId });
|
||||
axiosMock.onGet(getXBlockApiUrl(sectionId))
|
||||
.reply(200, {});
|
||||
renderComponent();
|
||||
|
||||
const subsection = await screen.findByRole('button', { name: 'Subsection' });
|
||||
await user.click(subsection);
|
||||
await waitFor(() => expect(axiosMock.history.post.length).toBeGreaterThan(1));
|
||||
// should add a section first
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'chapter',
|
||||
@@ -250,6 +253,8 @@ describe('AddSidebar', () => {
|
||||
.reply(200, { locator: subsectionId });
|
||||
axiosMock.onPost(getXBlockBaseApiUrl(), unitBody)
|
||||
.reply(200, { locator: unitId });
|
||||
axiosMock.onGet(getXBlockApiUrl(sectionId))
|
||||
.reply(200, {});
|
||||
renderComponent();
|
||||
|
||||
const unit = await screen.findByRole('button', { name: 'Unit' });
|
||||
|
||||
@@ -50,8 +50,7 @@ type AddContentButtonProps = {
|
||||
const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const {
|
||||
@@ -65,7 +64,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
let subsectionParentId = lastEditableSubsection?.data?.id;
|
||||
|
||||
const addSection = (onSuccess?: (data: { locator: string; }) => void) => {
|
||||
handleAddSection.mutate({
|
||||
handleAddBlock.mutate({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
@@ -82,10 +81,11 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
};
|
||||
|
||||
const addSubsection = (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => {
|
||||
handleAddSubsection.mutate({
|
||||
handleAddBlock.mutate({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator: sectionId,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
sectionId,
|
||||
}, {
|
||||
onSuccess: (data: { locator: string; }) => {
|
||||
// istanbul ignore next
|
||||
@@ -146,8 +146,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
}, [
|
||||
blockType,
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddBlock,
|
||||
handleAddAndOpenUnit,
|
||||
currentFlow,
|
||||
sectionParentId,
|
||||
@@ -155,7 +154,7 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
lastEditableSubsection,
|
||||
]);
|
||||
|
||||
const disabled = handleAddSection.isPending || handleAddSubsection.isPending || handleAddAndOpenUnit.isPending;
|
||||
const disabled = handleAddBlock.isPending || handleAddAndOpenUnit.isPending;
|
||||
|
||||
return (
|
||||
<BlockCardButton
|
||||
@@ -213,9 +212,7 @@ const AddNewContent = () => {
|
||||
const ShowLibraryContent = () => {
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
} = useCourseAuthoringContext();
|
||||
const {
|
||||
isCurrentFlowOn,
|
||||
@@ -233,7 +230,7 @@ const ShowLibraryContent = () => {
|
||||
const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => {
|
||||
switch (blockType) {
|
||||
case 'section':
|
||||
await handleAddSection.mutateAsync({
|
||||
await handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
@@ -243,11 +240,12 @@ const ShowLibraryContent = () => {
|
||||
case 'subsection':
|
||||
sectionParentId = currentFlow?.parentLocator || sectionParentId;
|
||||
if (sectionParentId) {
|
||||
await handleAddSubsection.mutateAsync({
|
||||
await handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Sequential,
|
||||
parentLocator: sectionParentId,
|
||||
libraryContentKey: usageKey,
|
||||
sectionId: sectionParentId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -257,7 +255,7 @@ const ShowLibraryContent = () => {
|
||||
);
|
||||
subsectionParentId = currentFlow?.parentLocator || subsectionParentId;
|
||||
if (subsectionParentId) {
|
||||
await handleAddUnit.mutateAsync({
|
||||
await handleAddBlock.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Vertical,
|
||||
parentLocator: subsectionParentId,
|
||||
@@ -273,9 +271,7 @@ const ShowLibraryContent = () => {
|
||||
stopCurrentFlow();
|
||||
}, [
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddBlock,
|
||||
lastEditableSection,
|
||||
lastEditableSubsection,
|
||||
currentFlow,
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
} from '@openedx/paragon/icons';
|
||||
import { uniqBy } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { usePasteFileNotices } from '@src/course-outline/data/apiHooks';
|
||||
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
|
||||
import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
@@ -23,8 +24,7 @@ import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
import AlertMessage from '../../generic/alert-message';
|
||||
import AlertProctoringError from '../../generic/AlertProctoringError';
|
||||
import { API_ERROR_TYPES } from '../constants';
|
||||
import { getPasteFileNotices } from '../data/selectors';
|
||||
import { dismissError, removePasteFileNotices } from '../data/slice';
|
||||
import { dismissError } from '../data/slice';
|
||||
import messages from './messages';
|
||||
|
||||
const PageAlerts = ({
|
||||
@@ -48,7 +48,7 @@ const PageAlerts = ({
|
||||
const [showDiscussionAlert, setShowDiscussionAlert] = useState(
|
||||
localStorage.getItem(discussionAlertDismissKey) === null,
|
||||
);
|
||||
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
|
||||
const { data: pasteFileNotices, setData: setPasteFileNotices } = usePasteFileNotices(courseId);
|
||||
const [showOutOfSyncAlert, setShowOutOfSyncAlert] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -247,16 +247,16 @@ const PageAlerts = ({
|
||||
|
||||
const newFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['newFiles']));
|
||||
setPasteFileNotices({ ...pasteFileNotices, newFiles: [] });
|
||||
};
|
||||
|
||||
if (newFiles?.length) {
|
||||
if (pasteFileNotices?.newFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: newFiles.length })}
|
||||
title={intl.formatMessage(messages.newFileAlertTitle, { newFilesLen: pasteFileNotices.newFiles.length })}
|
||||
description={intl.formatMessage(
|
||||
messages.newFileAlertDesc,
|
||||
{ newFilesLen: newFiles.length, newFilesStr: newFiles.join(', ') },
|
||||
{ newFilesLen: pasteFileNotices.newFiles.length, newFilesStr: pasteFileNotices.newFiles.join(', ') },
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
@@ -279,16 +279,16 @@ const PageAlerts = ({
|
||||
|
||||
const errorFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['errorFiles']));
|
||||
setPasteFileNotices({ ...pasteFileNotices, errorFiles: [] });
|
||||
};
|
||||
|
||||
if (errorFiles?.length) {
|
||||
if (pasteFileNotices?.errorFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(messages.errorFileAlertTitle)}
|
||||
description={intl.formatMessage(
|
||||
messages.errorFileAlertDesc,
|
||||
{ errorFilesLen: errorFiles.length, errorFilesStr: errorFiles.join(', ') },
|
||||
{ errorFilesLen: pasteFileNotices.errorFiles.length, errorFilesStr: pasteFileNotices.errorFiles.join(', ') },
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
@@ -303,19 +303,22 @@ const PageAlerts = ({
|
||||
|
||||
const conflictingFilesPasteAlert = () => {
|
||||
const onDismiss = () => {
|
||||
dispatch(removePasteFileNotices(['conflictingFiles']));
|
||||
setPasteFileNotices({ ...pasteFileNotices, conflictingFiles: [] });
|
||||
};
|
||||
|
||||
if (conflictingFiles?.length) {
|
||||
if (pasteFileNotices?.conflictingFiles?.length) {
|
||||
return (
|
||||
<AlertMessage
|
||||
title={intl.formatMessage(
|
||||
messages.conflictingFileAlertTitle,
|
||||
{ conflictingFilesLen: conflictingFiles.length },
|
||||
{ conflictingFilesLen: pasteFileNotices.conflictingFiles.length },
|
||||
)}
|
||||
description={intl.formatMessage(
|
||||
messages.conflictingFileAlertDesc,
|
||||
{ conflictingFilesLen: conflictingFiles.length, conflictingFilesStr: conflictingFiles.join(', ') },
|
||||
{
|
||||
conflictingFilesLen: pasteFileNotices.conflictingFiles.length,
|
||||
conflictingFilesStr: pasteFileNotices.conflictingFiles.join(', '),
|
||||
},
|
||||
)}
|
||||
dismissible
|
||||
show
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
@@ -22,9 +21,10 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
let mockNotices = {};
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
...jest.requireActual('@src/course-outline/data/apiHooks'),
|
||||
usePasteFileNotices: () => ({ data: mockNotices }),
|
||||
}));
|
||||
|
||||
jest.mock('../../course-libraries/data/apiHooks', () => ({
|
||||
@@ -72,7 +72,7 @@ describe('<PageAlerts />', () => {
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
useSelector.mockReturnValue({});
|
||||
mockNotices = {};
|
||||
});
|
||||
|
||||
it('renders null when no alerts are present', async () => {
|
||||
@@ -174,11 +174,11 @@ describe('<PageAlerts />', () => {
|
||||
});
|
||||
|
||||
it('renders new & error files alert', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
mockNotices = {
|
||||
newFiles: ['periodic-table.css'],
|
||||
conflictingFiles: [],
|
||||
errorFiles: ['error.css'],
|
||||
});
|
||||
};
|
||||
renderComponent();
|
||||
expect(screen.queryByText(messages.newFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.errorFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
@@ -189,11 +189,11 @@ describe('<PageAlerts />', () => {
|
||||
});
|
||||
|
||||
it('renders conflicting files alert', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
mockNotices = {
|
||||
newFiles: [],
|
||||
conflictingFiles: ['some.css', 'some.js'],
|
||||
errorFiles: [],
|
||||
});
|
||||
};
|
||||
renderComponent();
|
||||
expect(screen.queryByText(messages.conflictingFileAlertTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.newFileAlertAction.defaultMessage)).toHaveAttribute(
|
||||
|
||||
@@ -26,8 +26,6 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
handleAddSubsectionFromLibrary: jest.fn(),
|
||||
handleNewSubsectionSubmit: jest.fn(),
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
@@ -99,7 +97,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
isSectionsExpanded
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
resetScrollState={jest.fn()}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
|
||||
@@ -22,8 +22,9 @@ import type { XBlock } from '@src/data/types';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import messages from './messages';
|
||||
|
||||
interface SectionCardProps {
|
||||
@@ -39,7 +40,6 @@ interface SectionCardProps {
|
||||
index: number,
|
||||
canMoveItem: (oldIndex: number, newIndex: number) => boolean,
|
||||
onOrderChange: (oldIndex: number, newIndex: number) => void,
|
||||
resetScrollState: () => void,
|
||||
}
|
||||
|
||||
const SectionCard = ({
|
||||
@@ -55,7 +55,6 @@ const SectionCard = ({
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onOrderChange,
|
||||
resetScrollState,
|
||||
}: SectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
@@ -68,6 +67,7 @@ const SectionCard = ({
|
||||
const queryClient = useQueryClient();
|
||||
// Set initialData state from course outline and subsequently depend on its own state
|
||||
const { data: section = initialData } = useCourseItemData(initialData.id, initialData);
|
||||
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
|
||||
const isScrolledToElement = locatorId === section?.id;
|
||||
|
||||
// Expand the section if a search result should be shown/scrolled to
|
||||
@@ -153,13 +153,13 @@ const SectionCard = ({
|
||||
}, [activeId, overId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (scrollState?.id === section.id || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState();
|
||||
resetScrollState().catch((error) => handleResponseErrors(error));
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
}, [isScrolledToElement, scrollState, resetScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the locatorId is set/changed, we need to make sure that the section is expanded
|
||||
|
||||
@@ -31,8 +31,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
handleAddAndOpenUnit: handleOnAddUnitFromLibrary,
|
||||
handleAddSubsection: {},
|
||||
handleAddSection: {},
|
||||
handleAddBlock: {},
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
@@ -127,7 +126,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
resetScrollState={jest.fn()}
|
||||
isSectionsExpanded={false}
|
||||
{...props}
|
||||
>
|
||||
@@ -344,6 +342,7 @@ describe('<SubsectionCard />', () => {
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
category: 'vertical',
|
||||
sectionId: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
libraryContentKey: containerKey,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,8 +24,9 @@ import type { XBlock } from '@src/data/types';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import messages from './messages';
|
||||
|
||||
interface SubsectionCardProps {
|
||||
@@ -41,8 +42,11 @@ interface SubsectionCardProps {
|
||||
getPossibleMoves: (index: number, step: number) => void,
|
||||
onOrderChange: (section: XBlock, moveDetails: any) => void,
|
||||
onOpenConfigureModal: () => void,
|
||||
onPasteClick: (parentLocator: string, sectionId: string) => void,
|
||||
resetScrollState: () => void,
|
||||
onPasteClick: (
|
||||
parentLocator: string,
|
||||
subsectionId: string,
|
||||
sectionId: string
|
||||
) => void,
|
||||
}
|
||||
|
||||
const SubsectionCard = ({
|
||||
@@ -59,7 +63,6 @@ const SubsectionCard = ({
|
||||
onOrderChange,
|
||||
onOpenConfigureModal,
|
||||
onPasteClick,
|
||||
resetScrollState,
|
||||
}: SubsectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
@@ -77,6 +80,7 @@ const SubsectionCard = ({
|
||||
// Set initialData state from course outline and subsequently depend on its own state
|
||||
const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData);
|
||||
const { data: subsection = initialData } = useCourseItemData(initialData.id, initialData);
|
||||
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
|
||||
const {
|
||||
@@ -188,7 +192,7 @@ const SubsectionCard = ({
|
||||
onOrderChange(section, moveDownDetails);
|
||||
};
|
||||
|
||||
const handlePasteButtonClick = () => onPasteClick(id, section.id);
|
||||
const handlePasteButtonClick = () => onPasteClick(id, id, section.id);
|
||||
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
@@ -223,13 +227,13 @@ const SubsectionCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
if (currentRef.current && (subsection.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (scrollState?.id === subsection.id || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState();
|
||||
resetScrollState().catch((error) => handleResponseErrors(error));
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
}, [isScrolledToElement, scrollState, resetScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the locatorId is set/changed, we need to make sure that the subsection is expanded
|
||||
|
||||
@@ -22,8 +22,9 @@ import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import type { UnitXBlock, XBlock } from '@src/data/types';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData, useScrollState } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
|
||||
|
||||
interface UnitCardProps {
|
||||
@@ -76,6 +77,7 @@ const UnitCard = ({
|
||||
initialSubsectionData,
|
||||
);
|
||||
const { data: unit = initialData } = useCourseItemData<UnitXBlock>(initialData.id, initialData);
|
||||
const { data: scrollState, resetData: resetScrollState } = useScrollState(courseId);
|
||||
const isScrolledToElement = locatorId === unit.id;
|
||||
|
||||
const {
|
||||
@@ -211,12 +213,13 @@ const UnitCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (scrollState?.id === unit.id || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState().catch((error) => handleResponseErrors(error));
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
}, [isScrolledToElement, scrollState, resetScrollState]);
|
||||
|
||||
if (!isHeaderVisible) {
|
||||
return null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
|
||||
import { COURSE_BLOCK_NAMES } from '@src/constants';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { getCourseUnitData } from '../data/selectors';
|
||||
import { updateQueryPendingStatus } from '../data/slice';
|
||||
import messages from './messages';
|
||||
@@ -21,13 +22,7 @@ type HeaderTitleProps = {
|
||||
isTitleEditFormOpen: boolean;
|
||||
handleTitleEdit: () => void;
|
||||
handleTitleEditSubmit: (title: string) => void;
|
||||
handleConfigureSubmit: (
|
||||
id: string,
|
||||
isVisible: boolean,
|
||||
groupAccess: boolean,
|
||||
isDiscussionEnabled: boolean,
|
||||
closeModalFn: (value: boolean) => void
|
||||
) => void;
|
||||
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,14 +51,12 @@ const HeaderTitle = ({
|
||||
COURSE_BLOCK_NAMES.component.id,
|
||||
].includes(currentItemData.category);
|
||||
|
||||
const onConfigureSubmit = (...arg) => {
|
||||
handleConfigureSubmit(
|
||||
currentItemData.id,
|
||||
arg[0],
|
||||
arg[1],
|
||||
arg[2],
|
||||
closeConfigureModal,
|
||||
);
|
||||
const onConfigureSubmit = (variables: Omit<ConfigureUnitData, 'unitId'>) => {
|
||||
handleConfigureSubmit({
|
||||
...variables,
|
||||
unitId: currentItemData.id,
|
||||
closeModalFn: closeConfigureModal,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEventListener } from '@src/generic/hooks';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '@src/constants';
|
||||
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { messageTypes, PUBLISH_TYPES } from './constants';
|
||||
import {
|
||||
createNewCourseXBlock,
|
||||
@@ -99,19 +100,17 @@ export const useCourseUnit = ({
|
||||
dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen));
|
||||
};
|
||||
|
||||
const handleConfigureSubmit = (id, isVisible, groupAccess, isDiscussionEnabled, closeModalFn) => {
|
||||
const handleConfigureSubmit = (variables: ConfigureUnitData & { closeModalFn?: () => void }) => {
|
||||
dispatch(editCourseUnitVisibilityAndData(
|
||||
id,
|
||||
variables.unitId,
|
||||
PUBLISH_TYPES.republish,
|
||||
isVisible,
|
||||
groupAccess,
|
||||
isDiscussionEnabled,
|
||||
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: id }),
|
||||
variables.isVisibleToStaffOnly,
|
||||
variables.groupAccess,
|
||||
variables.discussionEnabled,
|
||||
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: variables.unitId }),
|
||||
blockId,
|
||||
));
|
||||
if (typeof closeModalFn === 'function') {
|
||||
closeModalFn();
|
||||
}
|
||||
variables.closeModalFn?.();
|
||||
};
|
||||
|
||||
const handleTitleEditSubmit = (displayName) => {
|
||||
|
||||
@@ -25,6 +25,8 @@ import VideoSelectorPage from '@src/editors/VideoSelectorPage';
|
||||
import EditorPage from '@src/editors/EditorPage';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { AccessManagedXBlockDataTypes } from '@src/data/types';
|
||||
import { messageTypes } from '../constants';
|
||||
import {
|
||||
fetchCourseSectionVerticalData,
|
||||
@@ -37,7 +39,6 @@ import {
|
||||
import messages from './messages';
|
||||
import {
|
||||
XBlockContainerIframeProps,
|
||||
AccessManagedXBlockDataTypes,
|
||||
} from './types';
|
||||
import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils';
|
||||
import { useUnitSidebarContext } from '../unit-sidebar/UnitSidebarContext';
|
||||
@@ -69,7 +70,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
const [blockType, setBlockType] = useState<string>('');
|
||||
const { useVideoGalleryFlow } = useWaffleFlags(courseId);
|
||||
const [newBlockId, setNewBlockId] = useState<string>('');
|
||||
const [accessManagedXBlockData, setAccessManagedXBlockData] = useState<AccessManagedXBlockDataTypes | {}>({});
|
||||
const [
|
||||
accessManagedXBlockData,
|
||||
setAccessManagedXBlockData,
|
||||
] = useState<AccessManagedXBlockDataTypes | undefined>(undefined);
|
||||
const [iframeOffset, setIframeOffset] = useState(0);
|
||||
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
|
||||
const [unlinkXBlockId, setUnlinkXBlockId] = useState<string | null>(null);
|
||||
@@ -146,10 +150,14 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onManageXBlockAccessSubmit = (...args: any[]) => {
|
||||
const onManageXBlockAccessSubmit = (variables: Omit<ConfigureUnitData, 'unitId'>) => {
|
||||
if (configureXBlockId) {
|
||||
handleConfigureSubmit(configureXBlockId, ...args, closeConfigureModal);
|
||||
setAccessManagedXBlockData({});
|
||||
handleConfigureSubmit({
|
||||
unitId: configureXBlockId,
|
||||
...variables,
|
||||
closeModalFn: closeConfigureModal,
|
||||
});
|
||||
setAccessManagedXBlockData(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,19 +296,17 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(accessManagedXBlockData).length ? (
|
||||
<ConfigureModal
|
||||
isXBlockComponent
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => {
|
||||
closeConfigureModal();
|
||||
setAccessManagedXBlockData({});
|
||||
}}
|
||||
onConfigureSubmit={onManageXBlockAccessSubmit}
|
||||
currentItemData={accessManagedXBlockData as AccessManagedXBlockDataTypes}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
) : null}
|
||||
<ConfigureModal
|
||||
isXBlockComponent
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => {
|
||||
closeConfigureModal();
|
||||
setAccessManagedXBlockData(undefined);
|
||||
}}
|
||||
onConfigureSubmit={onManageXBlockAccessSubmit}
|
||||
currentItemData={accessManagedXBlockData}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
ref={iframeRef}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserPartitionInfoTypes, UserPartitionTypes, XBlockPrereqs } from '@src/data/types';
|
||||
import { ConfigureUnitData } from '@src/course-outline/data/types';
|
||||
import { UserPartitionTypes } from '@src/data/types';
|
||||
|
||||
export interface XBlockActionsTypes {
|
||||
canCopy: boolean;
|
||||
@@ -36,41 +37,6 @@ export interface XBlockContainerIframeProps {
|
||||
handleUnlink: (XBlockId: string | null) => void;
|
||||
};
|
||||
courseVerticalChildren: Array<XBlockTypes>;
|
||||
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
|
||||
handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export type AccessManagedXBlockDataTypes = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
start?: string;
|
||||
visibilityState?: string | boolean;
|
||||
blockType: string;
|
||||
due?: string;
|
||||
isTimeLimited?: boolean;
|
||||
defaultTimeLimitMinutes?: number;
|
||||
hideAfterDue?: boolean;
|
||||
showCorrectness?: string | boolean;
|
||||
courseGraders?: string[];
|
||||
category?: string;
|
||||
format?: string;
|
||||
userPartitionInfo?: UserPartitionInfoTypes;
|
||||
ancestorHasStaffLock?: boolean;
|
||||
isPrereq?: boolean;
|
||||
prereqs?: XBlockPrereqs[];
|
||||
prereq?: string;
|
||||
prereqMinScore?: number;
|
||||
prereqMinCompletion?: number;
|
||||
releasedToStudents?: boolean;
|
||||
wasExamEverLinkedWithExternal?: boolean;
|
||||
isProctoredExam?: boolean;
|
||||
isOnboardingExam?: boolean;
|
||||
isPracticeExam?: boolean;
|
||||
examReviewRules?: string;
|
||||
supportsOnboarding?: boolean;
|
||||
showReviewRules?: boolean;
|
||||
onlineProctoringRules?: string;
|
||||
discussionEnabled: boolean;
|
||||
};
|
||||
|
||||
export type FormattedAccessManagedXBlockDataTypes = Omit<AccessManagedXBlockDataTypes, 'discussionEnabled'>;
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AccessManagedXBlockDataTypes } from '@src/data/types';
|
||||
|
||||
import { COURSE_BLOCK_NAMES } from '../../constants';
|
||||
import { FormattedAccessManagedXBlockDataTypes, XBlockTypes } from './types';
|
||||
import { XBlockTypes } from './types';
|
||||
|
||||
/**
|
||||
* Formats the XBlock data into a standardized structure for access management.
|
||||
*
|
||||
* @param {XBlockTypes} xblock - The XBlock object containing the original data.
|
||||
* @param {string} usageId - The unique identifier for the XBlock.
|
||||
*
|
||||
* @returns {FormattedAccessManagedXBlockDataTypes} - The formatted XBlock data, ready for access management operations.
|
||||
*/
|
||||
export const formatAccessManagedXBlockData = (
|
||||
xblock: XBlockTypes,
|
||||
usageId: string,
|
||||
): FormattedAccessManagedXBlockDataTypes => ({
|
||||
): AccessManagedXBlockDataTypes => ({
|
||||
category: COURSE_BLOCK_NAMES.component.id,
|
||||
displayName: xblock.name,
|
||||
userPartitionInfo: xblock.userPartitionInfo,
|
||||
showCorrectness: 'always',
|
||||
blockType: xblock.blockType,
|
||||
id: usageId,
|
||||
});
|
||||
|
||||
|
||||
@@ -130,3 +130,38 @@ export const useCourseDetails = (courseId: string) => {
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a global state function for a query.
|
||||
*/
|
||||
export function createGlobalState<T>(
|
||||
queryKeyFn: (queryKeyArgs?: any) => unknown[],
|
||||
initialData: T | null = null,
|
||||
) {
|
||||
return (queryKeyArgs?: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = queryKeyFn(queryKeyArgs);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => Promise.resolve(initialData),
|
||||
refetchInterval: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
function setData(x: Partial<T>) {
|
||||
queryClient.setQueryData(queryKey, x);
|
||||
}
|
||||
|
||||
async function resetData() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
});
|
||||
}
|
||||
|
||||
return { data, setData, resetData };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface XBlockBase {
|
||||
due?: string;
|
||||
relativeWeeksDue?: number;
|
||||
format?: string;
|
||||
courseGraders: string[];
|
||||
courseGraders?: string[];
|
||||
hasChanges: boolean;
|
||||
actions: XBlockActions;
|
||||
explanatoryMessage?: string;
|
||||
@@ -107,7 +107,6 @@ export interface XBlockBase {
|
||||
hasPartitionGroupComponents: boolean;
|
||||
userPartitionInfo?: UserPartitionInfoTypes;
|
||||
enableCopyPasteUnits: boolean;
|
||||
shouldScroll: boolean;
|
||||
isHeaderVisible: boolean;
|
||||
proctoringExamConfigurationLink?: string;
|
||||
isTimeLimited?: boolean;
|
||||
@@ -124,6 +123,11 @@ export interface XBlockBase {
|
||||
prereqMinCompletion?: number;
|
||||
discussionEnabled?: boolean;
|
||||
upstreamInfo?: UpstreamInfo;
|
||||
wasExamEverLinkedWithExternal?: boolean;
|
||||
supportsOnboarding?: boolean;
|
||||
showReviewRules?: boolean;
|
||||
onlineProctoringRules?: string;
|
||||
groupAccess?: object;
|
||||
}
|
||||
|
||||
export interface XBlock extends XBlockBase {
|
||||
@@ -165,3 +169,35 @@ export type SelectionState = {
|
||||
sectionId?: string;
|
||||
subsectionId?: string;
|
||||
};
|
||||
|
||||
export type AccessManagedXBlockDataTypes = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
start?: string;
|
||||
visibilityState?: string | boolean;
|
||||
due?: string;
|
||||
isTimeLimited?: boolean;
|
||||
defaultTimeLimitMinutes?: number;
|
||||
hideAfterDue?: boolean;
|
||||
showCorrectness?: string | boolean;
|
||||
courseGraders?: string[];
|
||||
category?: string;
|
||||
format?: string;
|
||||
userPartitionInfo?: UserPartitionInfoTypes;
|
||||
ancestorHasStaffLock?: boolean;
|
||||
isPrereq?: boolean;
|
||||
prereqs?: XBlockPrereqs[];
|
||||
prereq?: string;
|
||||
prereqMinScore?: number;
|
||||
prereqMinCompletion?: number;
|
||||
releasedToStudents?: boolean;
|
||||
wasExamEverLinkedWithExternal?: boolean;
|
||||
isProctoredExam?: boolean;
|
||||
isOnboardingExam?: boolean;
|
||||
isPracticeExam?: boolean;
|
||||
examReviewRules?: string;
|
||||
supportsOnboarding?: boolean;
|
||||
showReviewRules?: boolean;
|
||||
onlineProctoringRules?: string;
|
||||
discussionEnabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ interface PrereqItem {
|
||||
interface AdvancedTabProps {
|
||||
values: ValuesProps;
|
||||
setFieldValue: (field: string, value: any) => void;
|
||||
releasedToStudents: boolean;
|
||||
releasedToStudents?: boolean;
|
||||
prereqs?: PrereqItem[];
|
||||
wasExamEverLinkedWithExternal?: boolean;
|
||||
enableProctoredExams?: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { AccessManagedXBlockDataTypes } from '@src/data/types';
|
||||
import initializeStore from '../../store';
|
||||
import ConfigureModal from './ConfigureModal';
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ const renderComponent = () => render(
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentSectionMock}
|
||||
currentItemData={currentSectionMock as unknown as AccessManagedXBlockDataTypes}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
@@ -82,14 +83,14 @@ describe('<ConfigureModal /> for Section', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderSubsectionComponent = (props) => render(
|
||||
const renderSubsectionComponent = (props?: object) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentSubsectionMock}
|
||||
currentItemData={currentSubsectionMock as unknown as AccessManagedXBlockDataTypes}
|
||||
isSelfPaced={false}
|
||||
{...props}
|
||||
/>
|
||||
@@ -172,14 +173,14 @@ describe('<ConfigureModal /> for Subsection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderUnitComponent = (props) => render(
|
||||
const renderUnitComponent = (props?: object) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentUnitMock}
|
||||
currentItemData={currentUnitMock as unknown as AccessManagedXBlockDataTypes}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
@@ -238,7 +239,7 @@ describe('<ConfigureModal /> for Unit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderXBlockComponent = (props) => render(
|
||||
const renderXBlockComponent = (props?: object) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
@@ -246,7 +247,7 @@ const renderXBlockComponent = (props) => render(
|
||||
isXBlockComponent
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentXBlockMock}
|
||||
currentItemData={currentXBlockMock as unknown as AccessManagedXBlockDataTypes}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
@@ -325,7 +326,7 @@ describe('<ConfigureModal /> with enableTimedExams prop', () => {
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentSubsectionMock}
|
||||
currentItemData={currentSubsectionMock as unknown as AccessManagedXBlockDataTypes}
|
||||
enableTimedExams={enableTimedExams}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
@@ -393,8 +394,8 @@ describe('<ConfigureModal /> with enableTimedExams prop', () => {
|
||||
|
||||
const buttons = getByRole('tab', {
|
||||
name: messages.advancedTabTitle.defaultMessage,
|
||||
}).parentElement.querySelectorAll('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
}).parentElement?.querySelectorAll('button');
|
||||
expect(buttons?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('defaults enableTimedExams to false when not provided', async () => {
|
||||
@@ -407,7 +408,7 @@ describe('<ConfigureModal /> with enableTimedExams prop', () => {
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentSubsectionMock}
|
||||
currentItemData={currentSubsectionMock as unknown as AccessManagedXBlockDataTypes}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
</IntlProvider>
|
||||
@@ -1,7 +1,4 @@
|
||||
/* eslint-disable import/named */
|
||||
import React from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
@@ -13,14 +10,26 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { Formik } from 'formik';
|
||||
|
||||
import { VisibilityTypes } from '../../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../../constants';
|
||||
import { VisibilityTypes } from '@src/data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from '@src/constants';
|
||||
import { AccessManagedXBlockDataTypes } from '@src/data/types';
|
||||
import messages from './messages';
|
||||
import BasicTab from './BasicTab';
|
||||
import VisibilityTab from './VisibilityTab';
|
||||
import AdvancedTab from './AdvancedTab';
|
||||
import { UnitTab } from './UnitTab';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean,
|
||||
onClose: () => void;
|
||||
onConfigureSubmit: (args: object) => void,
|
||||
enableProctoredExams?: boolean,
|
||||
enableTimedExams?: boolean,
|
||||
currentItemData?: AccessManagedXBlockDataTypes,
|
||||
isXBlockComponent?: boolean,
|
||||
isSelfPaced?: boolean,
|
||||
}
|
||||
|
||||
const ConfigureModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -30,8 +39,13 @@ const ConfigureModal = ({
|
||||
enableTimedExams = false,
|
||||
isXBlockComponent = false,
|
||||
isSelfPaced,
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (!currentItemData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
displayName,
|
||||
start: sectionStartDate,
|
||||
@@ -61,10 +75,10 @@ const ConfigureModal = ({
|
||||
showReviewRules,
|
||||
onlineProctoringRules,
|
||||
discussionEnabled,
|
||||
} = currentItemData || {};
|
||||
} = currentItemData;
|
||||
|
||||
const getSelectedGroups = () => {
|
||||
if (userPartitionInfo?.selectedPartitionIndex >= 0) {
|
||||
if ((userPartitionInfo?.selectedPartitionIndex || 0) >= 0) {
|
||||
return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex]
|
||||
?.groups
|
||||
.filter(({ selected }) => selected)
|
||||
@@ -147,27 +161,30 @@ const ConfigureModal = ({
|
||||
const groupAccess = {};
|
||||
switch (category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
onConfigureSubmit(data.isVisibleToStaffOnly, releaseDate);
|
||||
onConfigureSubmit({
|
||||
isVisibleToStaffOnly: data.isVisibleToStaffOnly,
|
||||
startDatetime: releaseDate,
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
onConfigureSubmit(
|
||||
data.isVisibleToStaffOnly,
|
||||
onConfigureSubmit({
|
||||
isVisibleToStaffOnly: data.isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
data.graderType,
|
||||
data.dueDate,
|
||||
data.isTimeLimited,
|
||||
data.isProctoredExam,
|
||||
data.isOnboardingExam,
|
||||
data.isPracticeExam,
|
||||
data.examReviewRules,
|
||||
data.isTimeLimited ? data.defaultTimeLimitMinutes : 0,
|
||||
data.hideAfterDue,
|
||||
data.showCorrectness,
|
||||
data.isPrereq,
|
||||
data.prereqUsageKey,
|
||||
data.prereqMinScore,
|
||||
data.prereqMinCompletion,
|
||||
);
|
||||
graderType: data.graderType,
|
||||
dueDate: data.dueDate,
|
||||
isTimeLimited: data.isTimeLimited,
|
||||
isProctoredExam: data.isProctoredExam,
|
||||
isOnboardingExam: data.isOnboardingExam,
|
||||
isPracticeExam: data.isPracticeExam,
|
||||
examReviewRules: data.examReviewRules,
|
||||
defaultTimeLimitMin: data.isTimeLimited ? data.defaultTimeLimitMinutes : 0,
|
||||
hideAfterDue: data.hideAfterDue,
|
||||
showCorrectness: data.showCorrectness,
|
||||
isPrereq: data.isPrereq,
|
||||
prereqUsageKey: data.prereqUsageKey,
|
||||
prereqMinScore: data.prereqMinScore,
|
||||
prereqMinCompletion: data.prereqMinCompletion,
|
||||
});
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
case COURSE_BLOCK_NAMES.libraryContent.id:
|
||||
@@ -175,10 +192,14 @@ const ConfigureModal = ({
|
||||
case COURSE_BLOCK_NAMES.component.id:
|
||||
// groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1
|
||||
if (data.selectedPartitionIndex >= 0) {
|
||||
const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id;
|
||||
const partitionId = userPartitionInfo!.selectablePartitions[data.selectedPartitionIndex].id;
|
||||
groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10));
|
||||
}
|
||||
onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess, data.discussionEnabled);
|
||||
onConfigureSubmit({
|
||||
isVisibleToStaffOnly: data.isVisibleToStaffOnly,
|
||||
groupAccess,
|
||||
discussionEnabled: data.discussionEnabled,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -195,8 +216,8 @@ const ConfigureModal = ({
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
isSubsection={isSubsection}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
isSelfPaced={isSelfPaced}
|
||||
courseGraders={courseGraders || []}
|
||||
isSelfPaced={!!isSelfPaced}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
@@ -218,8 +239,8 @@ const ConfigureModal = ({
|
||||
values={values}
|
||||
setFieldValue={setFieldValue}
|
||||
isSubsection={isSubsection}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
isSelfPaced={isSelfPaced}
|
||||
courseGraders={courseGraders || []}
|
||||
isSelfPaced={!!isSelfPaced}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
@@ -295,7 +316,7 @@ const ConfigureModal = ({
|
||||
{({
|
||||
values, handleSubmit, setFieldValue,
|
||||
}) => (
|
||||
<>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<ModalDialog.Body className="configure-modal__body">
|
||||
<Form.Group size="sm" className="form-field">
|
||||
{renderModalBody(values, setFieldValue)}
|
||||
@@ -308,13 +329,13 @@ const ConfigureModal = ({
|
||||
</ModalDialog.CloseButton>
|
||||
<Button
|
||||
data-testid="configure-save-button"
|
||||
onClick={handleSubmit}
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage(messages.saveButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
@@ -322,61 +343,4 @@ const ConfigureModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
ConfigureModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onConfigureSubmit: PropTypes.func.isRequired,
|
||||
enableProctoredExams: PropTypes.bool,
|
||||
enableTimedExams: PropTypes.bool,
|
||||
currentItemData: PropTypes.shape({
|
||||
displayName: PropTypes.string,
|
||||
start: PropTypes.string,
|
||||
visibilityState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
due: PropTypes.string,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
defaultTimeLimitMinutes: PropTypes.number,
|
||||
hideAfterDue: PropTypes.bool,
|
||||
showCorrectness: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
courseGraders: PropTypes.arrayOf(PropTypes.string),
|
||||
category: PropTypes.string,
|
||||
format: PropTypes.string,
|
||||
userPartitionInfo: PropTypes.shape({
|
||||
selectablePartitions: PropTypes.arrayOf(PropTypes.shape({
|
||||
groups: PropTypes.arrayOf(PropTypes.shape({
|
||||
deleted: PropTypes.bool,
|
||||
id: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
selected: PropTypes.bool,
|
||||
})),
|
||||
id: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
scheme: PropTypes.string,
|
||||
})),
|
||||
selectedPartitionIndex: PropTypes.number,
|
||||
selectedGroupsLabel: PropTypes.string,
|
||||
}),
|
||||
ancestorHasStaffLock: PropTypes.bool,
|
||||
isPrereq: PropTypes.bool,
|
||||
prereqs: PropTypes.arrayOf({
|
||||
blockDisplayName: PropTypes.string,
|
||||
blockUsageKey: PropTypes.string,
|
||||
}),
|
||||
prereq: PropTypes.string,
|
||||
prereqMinScore: PropTypes.number,
|
||||
prereqMinCompletion: PropTypes.number,
|
||||
releasedToStudents: PropTypes.bool,
|
||||
wasExamEverLinkedWithExternal: PropTypes.bool,
|
||||
isProctoredExam: PropTypes.bool,
|
||||
isOnboardingExam: PropTypes.bool,
|
||||
isPracticeExam: PropTypes.bool,
|
||||
examReviewRules: PropTypes.string,
|
||||
supportsOnboarding: PropTypes.bool,
|
||||
showReviewRules: PropTypes.bool,
|
||||
onlineProctoringRules: PropTypes.string,
|
||||
discussionEnabled: PropTypes.bool,
|
||||
}),
|
||||
isXBlockComponent: PropTypes.bool,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default ConfigureModal;
|
||||
@@ -35,7 +35,7 @@ export interface UnitTabProps {
|
||||
},
|
||||
setFieldValue: (key: string, value: any) => void,
|
||||
showWarning: boolean,
|
||||
userPartitionInfo: UserPartitionInfo,
|
||||
userPartitionInfo?: UserPartitionInfo,
|
||||
}
|
||||
|
||||
export const DiscussionEditComponent = ({
|
||||
@@ -56,7 +56,7 @@ export const DiscussionEditComponent = ({
|
||||
export interface AccessEditComponentProps {
|
||||
selectedPartitionIndex: number,
|
||||
setFieldValue: (key: string, value: any) => void,
|
||||
userPartitionInfo: UserPartitionInfo,
|
||||
userPartitionInfo?: UserPartitionInfo,
|
||||
selectedGroups: string[],
|
||||
}
|
||||
|
||||
@@ -91,11 +91,11 @@ export const AccessEditComponent = ({
|
||||
data-testid="group-type-select"
|
||||
>
|
||||
<option value="-1" key="-1">
|
||||
{userPartitionInfo.selectedPartitionIndex === -1
|
||||
{userPartitionInfo?.selectedPartitionIndex === -1
|
||||
? intl.formatMessage(messages.unitSelectGroupType)
|
||||
: intl.formatMessage(messages.unitAllLearnersAndStaff)}
|
||||
</option>
|
||||
{userPartitionInfo.selectablePartitions.map((partition, index) => (
|
||||
{userPartitionInfo?.selectablePartitions.map((partition, index) => (
|
||||
<option
|
||||
key={partition.id}
|
||||
value={index}
|
||||
@@ -105,7 +105,7 @@ export const AccessEditComponent = ({
|
||||
))}
|
||||
</Form.Control>
|
||||
|
||||
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
|
||||
{selectedPartitionIndex >= 0 && userPartitionInfo?.selectablePartitions.length && (
|
||||
<Form.Group controlId="select-groups-checkboxes">
|
||||
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
|
||||
<div
|
||||
@@ -199,7 +199,7 @@ export const UnitTab = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{userPartitionInfo.selectablePartitions.length > 0 && (
|
||||
{(userPartitionInfo?.selectablePartitions.length || 0) > 0 && (
|
||||
<Form.Group controlId="groupSelect">
|
||||
<h4 className="mt-3">
|
||||
<FormattedMessage {...getAccessBlockTitle()} />
|
||||
|
||||
@@ -27,7 +27,6 @@ CourseOutlineSubsectionCardExtraActionsSlot.propTypes = {
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
enableCopyPasteUnits: PropTypes.bool,
|
||||
proctoringExamConfigurationLink: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
|
||||
@@ -19,7 +19,6 @@ CourseOutlineUnitCardExtraActionsSlot.propTypes = {
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
actions: PropTypes.shape({
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
@@ -36,7 +35,6 @@ CourseOutlineUnitCardExtraActionsSlot.propTypes = {
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}).isRequired,
|
||||
@@ -46,7 +44,6 @@ CourseOutlineUnitCardExtraActionsSlot.propTypes = {
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user