refactor(course-outline): improve query cache handling and remove redux thunks (#2884)
- Centralizes and reuses query cache handling logic: Introduces a `ParentIds` type (src/generic/types.ts) and standardizes its use across data/API hooks for updating or invalidating parent/child query caches. - Ensures cache coherence using `cancelQueries` before updating query data: Before calling `setQueryData` for any block, any inflight queries are cancelled to prevent race conditions and stale UI. - Simplifies post-sync/invalidation flows: Removes Redux thunk usages in favor of direct query invalidations using React Query APIs within course outline cards, sidebars, publish modal, and `unlinkmodal`. - Refactors data types for clarity: Splits XBlock into `XBlockBase` and derived interfaces so the presence of `childInfo` is explicit. - Cleans up redundant code and props: Removes unnecessary `memoization`, `useDispatch` imports, and duplicate logic in React components.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks';
|
||||
import type { XBlock } from '@src/data/types';
|
||||
import type { XBlockBase, XblockChildInfo } from '@src/data/types';
|
||||
import { 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,
|
||||
@@ -41,22 +42,22 @@ export const courseOutlineQueryKeys = {
|
||||
],
|
||||
};
|
||||
|
||||
type ParentIds = {
|
||||
/** This id will be used to invalidate data of parent subsection */
|
||||
subsectionId?: string;
|
||||
/** This id will be used to invalidate data of parent section */
|
||||
sectionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidate parent Subsection and Section data.
|
||||
*
|
||||
* This function ensures that cached data for parent subsection and section is invalidated
|
||||
* when child items are created, updated, or deleted.
|
||||
*
|
||||
* Priority:
|
||||
* 1. If sectionId exists, invalidate section data which also updates all children block data
|
||||
* 2. Else If subsectionId exists, invalidate subsection data
|
||||
*/
|
||||
const invalidateParentQueries = async (queryClient: QueryClient, variables: ParentIds) => {
|
||||
if (variables.subsectionId) {
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) });
|
||||
}
|
||||
if (variables.sectionId) {
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.sectionId) });
|
||||
} else if (variables.subsectionId) {
|
||||
// istanbul ignore next
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,6 +67,9 @@ type CreateCourseXBlockMutationProps = CreateCourseXBlockType & ParentIds;
|
||||
* Hook to create an XBLOCK in a course .
|
||||
* The `locator` is the ID of the parent block where this new XBLOCK should be created.
|
||||
* Can also be used to import block from library by passing `libraryContentKey` in request body
|
||||
*
|
||||
* @param callback - Optional function called after successful creation to handle additional logic
|
||||
* @returns Mutation object for creating course blocks
|
||||
*/
|
||||
export const useCreateCourseBlock = (
|
||||
callback?: ((locator: string, parentLocator: string) => Promise<void>),
|
||||
@@ -75,7 +79,6 @@ export const useCreateCourseBlock = (
|
||||
mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables),
|
||||
onSettled: async (data: { locator: string; }, _err, variables) => {
|
||||
await callback?.(data.locator, variables.parentLocator);
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)),
|
||||
});
|
||||
@@ -84,13 +87,34 @@ export const useCreateCourseBlock = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useCourseItemData = <T = XBlock>(itemId?: string, initialData?: T, enabled: boolean = true) => (
|
||||
useQuery({
|
||||
export const useCourseItemData = <T extends XBlockBase>(itemId?: string, initialData?: T, enabled: boolean = true) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery<T>({
|
||||
initialData,
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
|
||||
queryFn: enabled && itemId ? () => getCourseItem<T>(itemId!) : skipToken,
|
||||
})
|
||||
);
|
||||
queryFn: enabled && itemId ? async () => {
|
||||
const data = await getCourseItem<T>(itemId!);
|
||||
// If the container has children blocks, update children react-query cache
|
||||
// data without hitting the API as each xblock call returns its children information as well.
|
||||
if ('childInfo' in data) {
|
||||
// This could mean that data is of a section or subsection
|
||||
(data.childInfo as XblockChildInfo).children.forEach(async (child) => {
|
||||
await queryClient.cancelQueries({ queryKey: courseOutlineQueryKeys.courseItemId(child.id) });
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(child.id), child);
|
||||
if ('childInfo' in child) {
|
||||
// This means that the data is of section and so its children subsections also
|
||||
// have children i.e. units
|
||||
(child.childInfo as XblockChildInfo).children.forEach(async (grandChild) => {
|
||||
await queryClient.cancelQueries({ queryKey: courseOutlineQueryKeys.courseItemId(grandChild.id) });
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(grandChild.id), grandChild);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return data;
|
||||
} : skipToken,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCourseDetails = (courseId?: string, enabled: boolean = true) => (
|
||||
useQuery({
|
||||
@@ -99,6 +123,15 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) =>
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to update the display name of a course block.
|
||||
*
|
||||
* This mutation updates the display name of a course item and invalidates relevant cache queries
|
||||
* to ensure the UI reflects the changes.
|
||||
*
|
||||
* @param courseId - The ID of the course containing the item
|
||||
* @returns Mutation object for updating course block names
|
||||
*/
|
||||
export const useUpdateCourseBlockName = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -107,10 +140,9 @@ export const useUpdateCourseBlockName = (courseId: string) => {
|
||||
displayName: string;
|
||||
} & ParentIds) => editItemDisplayName({ itemId: variables.itemId, displayName: variables.displayName }),
|
||||
onSuccess: async (_data, variables) => {
|
||||
await queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) });
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -122,9 +154,8 @@ export const usePublishCourseItem = () => {
|
||||
itemId: string;
|
||||
} & ParentIds) => publishCourseItem(variables.itemId),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -169,24 +169,13 @@ const useCourseOutline = ({ courseId }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await unlinkDownstream(currentUnlinkModalData.value.id, {
|
||||
await unlinkDownstream({
|
||||
downstreamBlockId: currentUnlinkModalData.value.id,
|
||||
sectionId: currentUnlinkModalData.sectionId,
|
||||
subsectionId: currentUnlinkModalData.subsectionId,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
closeUnlinkModal();
|
||||
// istanbul ignore next
|
||||
// refresh child block data
|
||||
currentUnlinkModalData.value.childInfo?.children.forEach((block) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(block.id) });
|
||||
block.childInfo?.children.forEach(({ id: blockId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
|
||||
});
|
||||
});
|
||||
// refresh parent blocks data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.sectionId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.subsectionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal]);
|
||||
|
||||
@@ -144,7 +144,7 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
|
||||
setCurrentFlow(flow);
|
||||
}, [setCurrentFlow, setCurrentPageKey]);
|
||||
|
||||
const { data: currentItemData } = useCourseItemData(selectedContainerState?.currentId);
|
||||
const { data: currentItemData } = useCourseItemData<XBlock>(selectedContainerState?.currentId);
|
||||
const sectionsList = useSelector(getSectionsList);
|
||||
|
||||
/** Stores last section that allows adding subsections inside it. */
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SchoolOutline, Tag } from '@openedx/paragon/icons';
|
||||
import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils';
|
||||
@@ -44,14 +43,14 @@ export const InfoSection = ({ itemId }: Props) => {
|
||||
*/
|
||||
// istanbul ignore next
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
// invalidating section data will update all children blocks as well.
|
||||
if (selectedContainerState?.sectionId) {
|
||||
dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId]));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(selectedContainerState?.sectionId),
|
||||
});
|
||||
}
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.course(courseId),
|
||||
});
|
||||
}
|
||||
}, [dispatch, selectedContainerState, queryClient, courseId]);
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
/* eslint-disable import/named */
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
ActionRow,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { courseOutlineQueryKeys, usePublishCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import { usePublishCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import type { UnitXBlock, XBlock } from '@src/data/types';
|
||||
import LoadingButton from '@src/generic/loading-button';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import messages from './messages';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
|
||||
@@ -26,22 +25,6 @@ const PublishModal = () => {
|
||||
: undefined;
|
||||
const children: Array<XBlock | UnitXBlock> | undefined = childInfo?.children;
|
||||
const publishMutation = usePublishCourseItem();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const childrenIds = useMemo(() => children?.reduce((
|
||||
result: string[],
|
||||
current: XBlock | UnitXBlock,
|
||||
): string[] => {
|
||||
let temp = [...result];
|
||||
if ('childInfo' in current) {
|
||||
const grandChildren = current.childInfo.children.filter((child) => child.hasChanges);
|
||||
temp = [...temp, ...grandChildren.map((child) => child.id)];
|
||||
}
|
||||
if (current.hasChanges) {
|
||||
temp.push(current.id);
|
||||
}
|
||||
return temp;
|
||||
}, []), [children]);
|
||||
|
||||
const onPublishSubmit = async () => {
|
||||
if (id) {
|
||||
@@ -52,10 +35,6 @@ const PublishModal = () => {
|
||||
}, {
|
||||
onSettled: () => {
|
||||
closePublishModal();
|
||||
// Update query client to refresh the data of all children blocks
|
||||
childrenIds?.forEach((blockId) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
Bubble, Button, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
@@ -14,7 +13,6 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
import TitleButton from '@src/course-outline/card-header/TitleButton';
|
||||
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
|
||||
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
@@ -60,7 +58,6 @@ const SectionCard = ({
|
||||
resetScrollState,
|
||||
}: SectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -111,6 +108,10 @@ const SectionCard = ({
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(section.editedOnRaw))) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
|
||||
// eslint-disable-next-line no-console
|
||||
}).catch((error) => console.error('Error cancelling query:', error));
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, section]);
|
||||
@@ -167,11 +168,13 @@ const SectionCard = ({
|
||||
}, [locatorId, setIsExpanded]);
|
||||
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
dispatch(fetchCourseSectionQuery([section.id]));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
|
||||
});
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
}
|
||||
}, [dispatch, section, courseId, queryClient]);
|
||||
}, [section, courseId, queryClient]);
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...sectionActions };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
@@ -15,7 +14,6 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
import { useClipboard, PasteComponent } from '@src/generic/clipboard';
|
||||
import TitleButton from '@src/course-outline/card-header/TitleButton';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
@@ -65,7 +63,6 @@ const SubsectionCard = ({
|
||||
}: SubsectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -146,6 +143,10 @@ const SubsectionCard = ({
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(subsection.editedOnRaw))) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
|
||||
// eslint-disable-next-line no-console
|
||||
}).catch((error) => console.error('Error cancelling query:', error));
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, subsection]);
|
||||
@@ -171,11 +172,13 @@ const SubsectionCard = ({
|
||||
};
|
||||
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
dispatch(fetchCourseSectionQuery([section.id]));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
|
||||
});
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
}
|
||||
}, [dispatch, section, queryClient, courseId]);
|
||||
}, [section, queryClient, courseId]);
|
||||
|
||||
const handleSubsectionMoveUp = () => {
|
||||
onOrderChange(section, moveUpDetails);
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import TitleLink from '@src/course-outline/card-header/TitleLink';
|
||||
@@ -61,7 +59,6 @@ const UnitCard = ({
|
||||
discussionsSettings,
|
||||
}: UnitCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { selectedContainerState, openContainerInfoSidebar, setSelectedContainerState } = useOutlineSidebarContext();
|
||||
const locatorId = searchParams.get('show');
|
||||
@@ -162,11 +159,13 @@ const UnitCard = ({
|
||||
};
|
||||
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
dispatch(fetchCourseSectionQuery([section.id]));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(section.id),
|
||||
});
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
}
|
||||
}, [dispatch, section, queryClient, courseId]);
|
||||
}, [section, queryClient, courseId]);
|
||||
|
||||
const onClickCard = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
@@ -202,6 +201,10 @@ const UnitCard = ({
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(unit.editedOnRaw))) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(initialData.id),
|
||||
// eslint-disable-next-line no-console
|
||||
}).catch((error) => console.error('Error cancelling query:', error));
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, unit]);
|
||||
|
||||
@@ -14,7 +14,7 @@ const expectedCourseItemDataWithUnit = {
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
id: 'unitId',
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -64,7 +64,12 @@ describe('SubsectionUnitRedirect', () => {
|
||||
// Confirm redirection by checking the final URL
|
||||
const mockNavigate = screen.getByTestId('mock-navigate');
|
||||
expect(mockNavigate).toBeInTheDocument();
|
||||
expect(mockNavigate).toHaveAttribute('data-to', `/course/${courseId}/container/unitId`);
|
||||
expect(mockNavigate).toHaveAttribute(
|
||||
'data-to',
|
||||
`/course/${courseId}/container/${encodeURIComponent(
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1',
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import { useCourseItemData } from '../course-outline/data/apiHooks';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { XBlock } from '@src/data/types';
|
||||
|
||||
const SubsectionUnitRedirect = () => {
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
let { subsectionId } = useParams();
|
||||
// if the call is made via the click on breadcrumbs the re won't be courseId available
|
||||
// in such cases the page should redirect to the 1st unit of he subsection
|
||||
const { data: courseItemData, isLoading } = useCourseItemData(subsectionId);
|
||||
const { data: courseItemData, isLoading } = useCourseItemData<XBlock>(subsectionId);
|
||||
let firstUnitId = courseItemData?.childInfo?.children?.[0]?.id;
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -73,7 +73,8 @@ export const useCourseUnit = ({
|
||||
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
|
||||
const { canPasteComponent } = courseVerticalChildren;
|
||||
const { displayName: unitTitle, category: unitCategory } = xblockInfo;
|
||||
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
|
||||
const sequenceId = courseUnit.ancestorInfo?.ancestors[0]?.id;
|
||||
const sectionId = courseUnit.ancestorInfo?.ancestors[1]?.id;
|
||||
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
|
||||
const isUnitLegacyLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
|
||||
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
|
||||
@@ -139,19 +140,26 @@ export const useCourseUnit = ({
|
||||
const { mutateAsync: unlinkDownstream } = useUnlinkDownstream();
|
||||
|
||||
const unitXBlockActions = {
|
||||
handleDelete: async (XBlockId) => {
|
||||
handleDelete: async (XBlockId: string) => {
|
||||
// oxlint-disable-next-line typescript-eslint(await-thenable)
|
||||
await dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
|
||||
},
|
||||
handleDuplicate: (XBlockId) => {
|
||||
handleDuplicate: (XBlockId: string) => {
|
||||
dispatch(duplicateUnitItemQuery(
|
||||
blockId,
|
||||
XBlockId,
|
||||
(courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }),
|
||||
(courseKey: string, locator: string) => sendMessageToIframe(
|
||||
messageTypes.completeXBlockDuplicating,
|
||||
{ courseKey, locator },
|
||||
),
|
||||
));
|
||||
},
|
||||
handleUnlink: async (XBlockId) => {
|
||||
await unlinkDownstream(XBlockId);
|
||||
handleUnlink: async (XBlockId: string) => {
|
||||
await unlinkDownstream({
|
||||
downstreamBlockId: XBlockId,
|
||||
subsectionId: sequenceId,
|
||||
sectionId,
|
||||
});
|
||||
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ComponentInfoSidebar = () => {
|
||||
const handlePostChange = () => {
|
||||
sendMessageToIframe(messageTypes.refreshXBlock, null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(selectedComponentId),
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(sectionId),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface UpstreamInfo {
|
||||
isReadyToSyncIndividually?: boolean,
|
||||
}
|
||||
|
||||
export interface XBlock {
|
||||
export interface XBlockBase {
|
||||
id: string;
|
||||
locator: string;
|
||||
usageKey: string;
|
||||
@@ -102,7 +102,6 @@ export interface XBlock {
|
||||
highlightsEnabled: boolean;
|
||||
highlightsPreviewOnly: boolean;
|
||||
highlightsDocUrl: string;
|
||||
childInfo: XblockChildInfo;
|
||||
ancestorHasStaffLock: boolean;
|
||||
staffOnlyMessage: boolean;
|
||||
hasPartitionGroupComponents: boolean;
|
||||
@@ -127,7 +126,11 @@ export interface XBlock {
|
||||
upstreamInfo?: UpstreamInfo;
|
||||
}
|
||||
|
||||
export type UnitXBlock = Omit<XBlock, 'childInfo'>;
|
||||
export interface XBlock extends XBlockBase {
|
||||
childInfo: XblockChildInfo;
|
||||
}
|
||||
|
||||
export interface UnitXBlock extends XBlockBase {}
|
||||
|
||||
interface OutlineError {
|
||||
data?: string;
|
||||
|
||||
@@ -6,16 +6,16 @@ import { Cached, LinkOff, Newsstand } from '@openedx/paragon/icons';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import type { XBlock, XBlockBase } from '@src/data/types';
|
||||
import { ContainerType, getBlockType, normalizeContainerType } from '@src/generic/key-utils';
|
||||
import { useToggleWithValue } from '@src/hooks';
|
||||
import { useMemo } from 'react';
|
||||
import messages from './messages';
|
||||
|
||||
interface SubProps {
|
||||
blockData: XBlock;
|
||||
blockData: XBlockBase;
|
||||
displayName: string;
|
||||
openSyncModal: (val: XBlock) => void;
|
||||
openSyncModal: (val: XBlockBase) => void;
|
||||
sectionId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,10 @@ export interface UseIFrameBehaviorReturnTypes {
|
||||
showError: boolean;
|
||||
hasLoaded: boolean;
|
||||
}
|
||||
|
||||
export type ParentIds = {
|
||||
/** This id will be used to invalidate data of parent subsection */
|
||||
subsectionId?: string;
|
||||
/** This id will be used to invalidate data of parent section */
|
||||
sectionId?: string;
|
||||
};
|
||||
|
||||
@@ -3,23 +3,28 @@ import { courseLibrariesQueryKeys } from '@src/course-libraries';
|
||||
import { getCourseKey } from '@src/generic/key-utils';
|
||||
|
||||
import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks';
|
||||
import { ParentIds } from '@src/generic/types';
|
||||
import { unlinkDownstream } from './api';
|
||||
|
||||
export const useUnlinkDownstream = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: unlinkDownstream,
|
||||
onSuccess: (_, contentId: string) => {
|
||||
const courseKey = getCourseKey(contentId);
|
||||
mutationFn: (variables: {
|
||||
downstreamBlockId: string;
|
||||
} & ParentIds) => unlinkDownstream(variables.downstreamBlockId),
|
||||
onSuccess: (_, variables) => {
|
||||
const courseKey = getCourseKey(variables.downstreamBlockId);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(contentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(courseKey),
|
||||
});
|
||||
if (variables.sectionId) {
|
||||
// This will handle updating all children block cache data as section data contains
|
||||
// xblock data of all of its children
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.sectionId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user