Files
frontend-app-authoring/src/course-unit/hooks.jsx
Ihor Romaniuk 9a2dac6d4b fix: load sequences in unit page (#1867)
This handles loading errors when opening the course unit page via direct link as an unauthorized user.
2025-06-05 09:39:43 -03:00

340 lines
11 KiB
JavaScript

import {
useCallback, useEffect, useMemo, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { RequestStatus } from '../data/constants';
import { useClipboard } from '../generic/clipboard';
import { useEventListener } from '../generic/hooks';
import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '../constants';
import { messageTypes, PUBLISH_TYPES } from './constants';
import {
createNewCourseXBlock,
deleteUnitItemQuery,
duplicateUnitItemQuery,
editCourseItemQuery,
editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
} from './data/thunk';
import {
getCanEdit,
getCourseOutlineInfo,
getCourseSectionVertical,
getCourseUnitData,
getCourseVerticalChildren,
getErrorMessage,
getIsLoading,
getMovedXBlockParams,
getSavingStatus,
getSequenceStatus,
getStaticFileNotices,
} from './data/selectors';
import {
changeEditTitleFormOpen,
updateMovedXBlockParams,
updateQueryPendingStatus,
} from './data/slice';
import { useIframe } from '../generic/hooks/context/hooks';
export const useCourseUnit = ({ courseId, blockId }) => {
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const { sendMessageToIframe } = useIframe();
const [addComponentTemplateData, setAddComponentTemplateData] = useState({});
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
const courseUnit = useSelector(getCourseUnitData);
const savingStatus = useSelector(getSavingStatus);
const isLoading = useSelector(getIsLoading);
const errorMessage = useSelector(getErrorMessage);
const sequenceStatus = useSelector(getSequenceStatus);
const { draftPreviewLink, publishedPreviewLink, xblockInfo = {} } = useSelector(getCourseSectionVertical);
const courseVerticalChildren = useSelector(getCourseVerticalChildren);
const staticFileNotices = useSelector(getStaticFileNotices);
const navigate = useNavigate();
const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen);
const canEdit = useSelector(getCanEdit);
const courseOutlineInfo = useSelector(getCourseOutlineInfo);
const movedXBlockParams = useSelector(getMovedXBlockParams);
const { currentlyVisibleToStudents } = courseUnit;
const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit);
const { canPasteComponent } = courseVerticalChildren;
const { displayName: unitTitle, category: unitCategory } = xblockInfo;
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
const headerNavigationsActions = {
handleViewLive: () => {
window.open(publishedPreviewLink, '_blank');
},
handlePreview: () => {
window.open(draftPreviewLink, '_blank');
},
handleEdit: () => {
sendMessageToIframe(messageTypes.editXBlock, { id: courseUnit.id }, window);
},
};
const handleTitleEdit = () => {
dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen));
};
const handleConfigureSubmit = (id, isVisible, groupAccess, isDiscussionEnabled, closeModalFn) => {
dispatch(editCourseUnitVisibilityAndData(
id,
PUBLISH_TYPES.republish,
isVisible,
groupAccess,
isDiscussionEnabled,
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: id }),
blockId,
));
if (typeof closeModalFn === 'function') {
closeModalFn();
}
};
const handleTitleEditSubmit = (displayName) => {
if (unitTitle !== displayName) {
dispatch(editCourseItemQuery(blockId, displayName, sequenceId));
}
handleTitleEdit();
};
const handleNavigate = (id) => {
if (sequenceId) {
const path = `/course/${courseId}/container/${blockId}/${id}`;
const options = { replace: true };
if (searchParams.size) {
navigate({
pathname: path,
search: `?${searchParams}`,
}, options);
} else {
navigate(path, options);
}
}
};
const handleCreateNewCourseXBlock = (body, callback) => (
dispatch(createNewCourseXBlock(body, callback, blockId, sendMessageToIframe))
);
const unitXBlockActions = {
handleDelete: (XBlockId) => {
dispatch(deleteUnitItemQuery(blockId, XBlockId, sendMessageToIframe));
},
handleDuplicate: (XBlockId) => {
dispatch(duplicateUnitItemQuery(
blockId,
XBlockId,
(courseKey, locator) => sendMessageToIframe(messageTypes.completeXBlockDuplicating, { courseKey, locator }),
));
},
};
const handleRollbackMovedXBlock = () => {
const {
sourceLocator, targetParentLocator, title, currentParentLocator,
} = movedXBlockParams;
dispatch(patchUnitItemQuery({
sourceLocator,
targetParentLocator,
title,
currentParentLocator,
isMoving: false,
callbackFn: () => {
sendMessageToIframe(messageTypes.rollbackMovedXBlock, { locator: sourceLocator });
window.scrollTo({ top: 0, behavior: 'smooth' });
},
}));
};
const handleCloseXBlockMovedAlert = () => {
dispatch(updateMovedXBlockParams({ isSuccess: false }));
};
const handleNavigateToTargetUnit = () => {
navigate(`/course/${courseId}/container/${movedXBlockParams.targetParentLocator}`);
};
const receiveMessage = useCallback(({ data }) => {
const { payload, type } = data;
if (type === messageTypes.handleViewXBlockContent) {
const { usageId } = payload;
navigate(`/course/${courseId}/container/${usageId}/${sequenceId}`);
}
if (type === messageTypes.handleViewGroupConfigurations) {
const { usageId } = payload;
const groupId = usageId.split('#').pop();
navigate(`/course/${courseId}/group_configurations#${groupId}`);
}
if (type === messageTypes.showComponentTemplates) {
setAddComponentTemplateData(camelCaseObject(payload));
}
}, [courseId, sequenceId]);
useEventListener('message', receiveMessage);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateQueryPendingStatus(true));
}
}, [savingStatus]);
useEffect(() => {
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
handleNavigate(sequenceId);
dispatch(updateMovedXBlockParams({ isSuccess: false }));
}, [courseId, blockId, sequenceId]);
useEffect(() => {
if (isSplitTestType) {
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
}
}, [isSplitTestType, blockId]);
useEffect(() => {
if (isMoveModalOpen && !Object.keys(courseOutlineInfo).length) {
dispatch(getCourseOutlineInfoQuery(courseId));
}
}, [isMoveModalOpen]);
return {
sequenceId,
courseUnit,
unitTitle,
unitCategory,
errorMessage,
sequenceStatus,
savingStatus,
staticFileNotices,
currentlyVisibleToStudents,
isLoading,
isTitleEditFormOpen,
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
sharedClipboardData,
showPasteXBlock,
showPasteUnit,
unitXBlockActions,
headerNavigationsActions,
handleTitleEdit,
handleTitleEditSubmit,
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
canPasteComponent,
isMoveModalOpen,
openMoveModal,
closeMoveModal,
handleRollbackMovedXBlock,
handleCloseXBlockMovedAlert,
movedXBlockParams,
handleNavigateToTargetUnit,
addComponentTemplateData,
setAddComponentTemplateData,
};
};
/**
* Custom hook to determine the layout grid configuration based on unit category and type.
*
* @param {string} unitCategory - The category of the unit. This may influence future layout logic.
* @param {boolean} isUnitLibraryType - A flag indicating whether the unit is of library content type.
* @returns {Object} - An object representing the layout configuration for different screen sizes.
* The configuration includes keys like 'lg', 'md', 'sm', 'xs', and 'xl',
* each specifying an array of layout spans.
*/
export const useLayoutGrid = (unitCategory, isUnitLibraryType) => (
useMemo(() => {
const layouts = {
fullWidth: {
lg: [{ span: 12 }, { span: 0 }],
md: [{ span: 12 }, { span: 0 }],
sm: [{ span: 12 }, { span: 0 }],
xs: [{ span: 12 }, { span: 0 }],
xl: [{ span: 12 }, { span: 0 }],
},
default: {
lg: [{ span: 8 }, { span: 4 }],
md: [{ span: 8 }, { span: 4 }],
sm: [{ span: 8 }, { span: 3 }],
xs: [{ span: 9 }, { span: 3 }],
xl: [{ span: 9 }, { span: 3 }],
},
};
return isUnitLibraryType ? layouts.fullWidth : layouts.default;
}, [unitCategory])
);
/**
* Custom hook that restores the scroll position from `localStorage` after a page reload.
* It listens for a `plugin.resize` message event and scrolls the window to the saved position
* after a 1-second delay, provided no new resize messages are received during that time.
*
* @param {string} [storageKey='createXBlockLastYPosition'] -
* The key used to store the last scroll position in `localStorage`.
*/
export const useScrollToLastPosition = (storageKey = 'createXBlockLastYPosition') => {
const timeoutRef = useRef(null);
const [hasLastPosition, setHasLastPosition] = useState(() => !!localStorage.getItem(storageKey));
const scrollToLastPosition = useCallback(() => {
const lastYPosition = localStorage.getItem(storageKey);
if (!lastYPosition) {
setHasLastPosition(false);
return;
}
const yPosition = parseInt(lastYPosition, 10);
if (!Number.isNaN(yPosition)) {
window.scrollTo({ top: yPosition, behavior: 'smooth' });
localStorage.removeItem(storageKey);
setHasLastPosition(false);
}
}, [storageKey]);
const handleMessage = useCallback((event) => {
if (event.data?.type === iframeMessageTypes.resize) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(scrollToLastPosition, 1000);
}
}, [scrollToLastPosition]);
useEffect(() => {
if (!hasLastPosition) {
return undefined;
}
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [hasLastPosition, handleMessage]);
return null;
};