From a39678bf54f1caa468d17432c96d477f43bc2a33 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 10 Mar 2026 10:40:34 -0500 Subject: [PATCH 01/10] feat: Initial work for menus in sidebard --- src/CourseAuthoringContext.tsx | 103 +++++++++++- src/course-outline/CourseOutline.tsx | 25 +-- src/course-outline/drag-helper/utils.ts | 3 + src/course-outline/hooks.jsx | 51 +----- .../outline-sidebar/OutlineSidebarContext.tsx | 5 +- .../info-sidebar/InfoSidebar.tsx | 21 ++- .../info-sidebar/SectionInfoSidebar.tsx | 34 +++- .../info-sidebar/SubsectionInfoSidebar.tsx | 18 +- .../info-sidebar/UnitInfoSidebar.tsx | 22 ++- .../section-card/SectionCard.tsx | 5 +- .../subsection-card/SubsectionCard.tsx | 7 + src/course-outline/unit-card/UnitCard.tsx | 3 + .../unit-info/ComponentInfoSidebar.tsx | 9 + .../unit-info/UnitInfoSidebar.tsx | 9 + src/data/types.ts | 2 + src/generic/sidebar/InfoSidebarMenu.tsx | 158 ++++++++++++++++++ src/generic/sidebar/SidebarTitle.tsx | 30 ++-- src/generic/sidebar/messages.ts | 57 ++++++- 18 files changed, 470 insertions(+), 92 deletions(-) create mode 100644 src/generic/sidebar/InfoSidebarMenu.tsx diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index a31f887155..5dee0146c8 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -3,15 +3,17 @@ import { createContext, useContext, useMemo, useState, } from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; -import { useSelector } from 'react-redux'; +import { useCreateCourseBlock, useDuplicateItem } from '@src/course-outline/data/apiHooks'; +import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router'; -import { getOutlineIndexData } from '@src/course-outline/data/selectors'; +import { getOutlineIndexData, getSectionsList } from '@src/course-outline/data/selectors'; import { useToggleWithValue } from '@src/hooks'; import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types'; import { CourseDetailsData } from './data/api'; import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; import { RequestStatusType } from './data/constants'; +import { arrayMove } from '@dnd-kit/sortable'; +import { setSectionOrderListQuery } from './course-outline/data/thunk'; type ModalState = { value?: XBlock | UnitXBlock; @@ -40,6 +42,15 @@ export type CourseAuthoringContextData = { closePublishModal: () => void; currentSelection?: SelectionState; setCurrentSelection: React.Dispatch>; + sections: XBlock[]; + restoreSectionList: () => void; + setSections: React.Dispatch>; + isDuplicatingItem: boolean; + handleDuplicateSectionSubmit: () => void; + handleDuplicateSubsectionSubmit: () => void; + handleDuplicateUnitSubmit: () => void; + handleSectionDragAndDrop: (sectionListIds: string[]) => void; + updateSectionOrderByIndex: (currentIndex: number, newIndex: number) => void; }; /** @@ -61,6 +72,7 @@ export const CourseAuthoringProvider = ({ courseId, }: CourseAuthoringProviderProps) => { const navigate = useNavigate(); + const dispatch = useDispatch(); const waffleFlags = useWaffleFlags(); const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId); const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date(); @@ -78,6 +90,13 @@ export const CourseAuthoringProvider = ({ openPublishModal, closePublishModal, ] = useToggleWithValue(); + const sectionsList = useSelector(getSectionsList); + const [sections, setSections] = useState(sectionsList); + + const restoreSectionList = () => { + setSections(() => [...sectionsList]); + }; + /** * This will hold the state of current item that is being operated on, * For example: @@ -113,6 +132,67 @@ export const CourseAuthoringProvider = ({ const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage); const handleAddBlock = useCreateCourseBlock(courseId); + const { + mutate: duplicateItem, + isPending: isDuplicatingItem, + } = useDuplicateItem(courseId); + const handleDuplicateSectionSubmit = () => { + if (currentSelection && currentSelection.currentId) { + duplicateItem({ + itemId: currentSelection.currentId, + parentId: courseStructure.id, + sectionId: currentSelection.sectionId, + subsectionId: currentSelection.subsectionId, + }); + } + }; + + const handleDuplicateSubsectionSubmit = () => { + if (currentSelection && currentSelection.currentId && currentSelection.sectionId) { + duplicateItem({ + itemId: currentSelection.currentId, + parentId: currentSelection.sectionId, + sectionId: currentSelection.sectionId, + subsectionId: currentSelection.subsectionId, + }); + } + }; + + const handleDuplicateUnitSubmit = () => { + if (currentSelection && currentSelection.currentId && currentSelection.subsectionId) { + duplicateItem({ + itemId: currentSelection?.currentId, + parentId: currentSelection?.subsectionId, + sectionId: currentSelection?.sectionId, + subsectionId: currentSelection?.subsectionId, + }); + } + }; + + const handleSectionDragAndDrop = ( + sectionListIds: string[], + ) => { + dispatch(setSectionOrderListQuery( + courseId, + sectionListIds, + restoreSectionList, + )); + }; + + /** + * Move section to new index + */ + const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => { + if (currentIndex === newIndex) { + return; + } + setSections((prevSections) => { + const newSections = arrayMove(prevSections, currentIndex, newIndex); + handleSectionDragAndDrop(newSections.map(section => section.id)); + return newSections; + }); + }; + const context = useMemo(() => ({ courseId, courseUsageKey, @@ -133,6 +213,15 @@ export const CourseAuthoringProvider = ({ closePublishModal, currentSelection, setCurrentSelection, + sections, + restoreSectionList, + setSections, + isDuplicatingItem, + handleDuplicateSectionSubmit, + handleDuplicateSubsectionSubmit, + handleDuplicateUnitSubmit, + handleSectionDragAndDrop, + updateSectionOrderByIndex, }), [ courseId, courseUsageKey, @@ -153,6 +242,14 @@ export const CourseAuthoringProvider = ({ closePublishModal, currentSelection, setCurrentSelection, + sections, + restoreSectionList, + setSections, + isDuplicatingItem, + handleDuplicateSectionSubmit, + handleDuplicateSubsectionSubmit, + handleSectionDragAndDrop, + updateSectionOrderByIndex, ]); return ( diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 95d75627c9..989b1eba9c 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -12,7 +12,6 @@ import { Helmet } from 'react-helmet'; import { CheckCircle as CheckCircleIcon, CloseFullscreen, OpenInFull } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; import { - arrayMove, SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable'; @@ -76,6 +75,10 @@ const CourseOutline = () => { isUnlinkModalOpen, closeUnlinkModal, currentSelection, + sections, + restoreSectionList, + setSections, + updateSectionOrderByIndex, } = useCourseAuthoringContext(); const { @@ -155,11 +158,6 @@ const CourseOutline = () => { } }, [location, courseId, courseName]); - const [sections, setSections] = useState(sectionsList); - - const restoreSectionList = () => { - setSections(() => [...sectionsList]); - }; const { isShow: isShowProcessingNotification, @@ -174,20 +172,6 @@ const CourseOutline = () => { const enableProctoredExams = useSelector(getProctoredExamsFlag); const enableTimedExams = useSelector(getTimedExamsFlag); - /** - * Move section to new index - */ - const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => { - if (currentIndex === newIndex) { - return; - } - setSections((prevSections) => { - const newSections = arrayMove(prevSections, currentIndex, newIndex); - handleSectionDragAndDrop(newSections.map(section => section.id)); - return newSections; - }); - }; - /** * Uses details from move information and moves subsection */ @@ -399,6 +383,7 @@ const CourseOutline = () => { section={section} subsection={subsection} index={subsectionIndex} + sectionIndex={sectionIndex} getPossibleMoves={possibleSubsectionMoves( [...sections], sectionIndex, diff --git a/src/course-outline/drag-helper/utils.ts b/src/course-outline/drag-helper/utils.ts index 40a41f1e9d..952674ccb6 100644 --- a/src/course-outline/drag-helper/utils.ts +++ b/src/course-outline/drag-helper/utils.ts @@ -152,6 +152,9 @@ export const moveUnit = ( * This helps us avoid moving the item to a position of unmovable item. */ export const canMoveSection = (sections: XBlock[]) => (id: number, step: number) => { + if (id === -1) { + return false; + } const newId = id + step; const indexCheck = newId >= 0 && newId < sections.length; if (!indexCheck) { diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 6d8197de52..58d5cee574 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -34,7 +34,6 @@ import { getOutlineIndexData, getSavingStatus, getStatusBarData, - getSectionsList, getCourseActions, getCustomRelativeDatesActiveFlag, getErrors, @@ -62,6 +61,11 @@ const useCourseOutline = ({ courseId }) => { currentSelection, currentUnlinkModalData, closeUnlinkModal, + isDuplicatingItem, + handleDuplicateSectionSubmit, + handleDuplicateSubsectionSubmit, + handleDuplicateUnitSubmit, + handleSectionDragAndDrop, } = useCourseAuthoringContext(); const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); @@ -83,7 +87,7 @@ const useCourseOutline = ({ courseId }) => { const statusBarData = useSelector(getStatusBarData); const savingStatus = useSelector(getSavingStatus); const courseActions = useSelector(getCourseActions); - const sectionsList = useSelector(getSectionsList); + const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag); const genericSavingStatus = useSelector(getGenericSavingStatus); const errors = useSelector(getErrors); @@ -296,37 +300,6 @@ const useCourseOutline = ({ courseId }) => { deleteSubsection, ]); - const { - mutate: duplicateItem, - isPending: isDuplicatingItem, - } = useDuplicateItem(courseId); - const handleDuplicateSectionSubmit = () => { - duplicateItem({ - itemId: currentSelection?.currentId, - parentId: courseStructure.id, - sectionId: currentSelection?.sectionId, - subsectionId: currentSelection?.subsectionId, - }); - }; - - const handleDuplicateSubsectionSubmit = () => { - duplicateItem({ - itemId: currentSelection?.currentId, - parentId: currentSelection?.sectionId, - sectionId: currentSelection?.sectionId, - subsectionId: currentSelection?.subsectionId, - }); - }; - - const handleDuplicateUnitSubmit = () => { - duplicateItem({ - itemId: currentSelection?.currentId, - parentId: currentSelection?.subsectionId, - sectionId: currentSelection?.sectionId, - subsectionId: currentSelection?.subsectionId, - }); - }; - const handleVideoSharingOptionChange = (value) => { dispatch(setVideoSharingOptionQuery(courseId, value)); }; @@ -335,17 +308,6 @@ const useCourseOutline = ({ courseId }) => { dispatch(dismissNotificationQuery(`${getConfig().STUDIO_BASE_URL}${notificationDismissUrl}`)); }; - const handleSectionDragAndDrop = ( - sectionListIds, - restoreSectionList, - ) => { - dispatch(setSectionOrderListQuery( - courseId, - sectionListIds, - restoreSectionList, - )); - }; - const handleSubsectionDragAndDrop = ( sectionId, prevSectionId, @@ -396,7 +358,6 @@ const useCourseOutline = ({ courseId }) => { courseUsageKey: courseStructure?.id, courseActions, savingStatus, - sectionsList, isCustomRelativeDatesActive, isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS, isLoadingDenied: outlineIndexLoadingStatus === RequestStatus.DENIED, diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx index c93cdfa896..a274739c6d 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -37,7 +37,7 @@ interface OutlineSidebarContextData { toggle: () => void; selectedContainerState?: SelectionState; setSelectedContainerState: (selectedContainerState?: SelectionState) => void; - openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void; + openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string, index?: number) => void; clearSelection: () => void; /** Stores last section that allows adding subsections inside it. */ lastEditableSection?: XBlock; @@ -123,9 +123,10 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod containerId: string, subsectionId?: string, sectionId?: string, + index?: number, ) => { if (isOutlineNewDesignEnabled()) { - setSelectedContainerState({ currentId: containerId, subsectionId, sectionId }); + setSelectedContainerState({ currentId: containerId, subsectionId, sectionId, index }); setCurrentPageKey('info'); } }, [setSelectedContainerState, setCurrentPageKey]); diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx index faf8f1193a..833ce8a4ba 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.tsx @@ -17,13 +17,28 @@ export const InfoSidebar = () => { switch (itemType) { case ContainerType.Chapter: case ContainerType.Section: - return ; + return ( + + ); case ContainerType.Sequential: case ContainerType.Subsection: - return ; + return ( + + ); case ContainerType.Vertical: case ContainerType.Unit: - return ; + return ( + + ); default: return ; } diff --git a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx index 82f684c5da..d2f87edf24 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx @@ -13,16 +13,23 @@ import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/Ou import { InfoSection } from './InfoSection'; import messages from '../messages'; import { PublishButon } from './PublishButon'; +import { canMoveSection } from '@src/course-outline/drag-helper/utils'; interface Props { sectionId: string; + index?: number; } -export const SectionSidebar = ({ sectionId }: Props) => { +export const SectionSidebar = ({ sectionId, index }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); const { data: sectionData, isLoading } = useCourseItemData(sectionId); - const { openPublishModal } = useCourseAuthoringContext(); + const { + openPublishModal, + handleDuplicateSectionSubmit, + sections, + updateSectionOrderByIndex, + } = useCourseAuthoringContext(); const { clearSelection } = useOutlineSidebarContext(); const handlePublish = () => { @@ -38,12 +45,35 @@ export const SectionSidebar = ({ sectionId }: Props) => { return ; } + const handleMoveUp = () => { + if (index) { + updateSectionOrderByIndex(index, index - 1); + } + } + + const handleMoveDown = () => { + if (index) { + updateSectionOrderByIndex(index, index + 1); + } + } + return ( <> {}, + onClickDelete: () => {}, + onClickViewLibrary: () => {}, + }} /> {sectionData?.hasChanges && } { +export const SubsectionSidebar = ({ subsectionId, index }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'info' | 'settings'>('info'); const { data: subsectionData, isLoading } = useCourseItemData(subsectionId); const { selectedContainerState } = useOutlineSidebarContext(); - const { openPublishModal } = useCourseAuthoringContext(); + const { + openPublishModal, + handleDuplicateSubsectionSubmit, + } = useCourseAuthoringContext(); const { clearSelection } = useOutlineSidebarContext(); const handlePublish = () => { @@ -45,6 +49,16 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => { title={subsectionData?.displayName || ''} icon={getItemIcon(subsectionData?.category || '')} onBackBtnClick={clearSelection} + menuProps={{ + itemId: subsectionId, + index: index ?? -1, + onClickDuplicate: handleDuplicateSubsectionSubmit, + onClickMoveUp: () => {}, + onClickMoveDown: () => {}, + onClickUnlink: () => {}, + onClickDelete: () => {}, + onClickViewLibrary: () => {}, + }} /> {subsectionData?.hasChanges && } { +export const UnitSidebar = ({ unitId, index }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); const { data: unitData, isLoading } = useCourseItemData(unitId); const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); - const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); + const { + openPublishModal, + getUnitUrl, + courseId, + handleDuplicateUnitSubmit, + } = useCourseAuthoringContext(); const handlePublish = () => { if (unitData?.hasChanges) { @@ -53,6 +59,18 @@ export const UnitSidebar = ({ unitId }: Props) => { title={unitData?.displayName || ''} icon={getItemIcon(unitData?.category || '')} onBackBtnClick={clearSelection} + menuProps={{ + itemId: unitId, + index: index ?? -1, + onClickDuplicate: handleDuplicateUnitSubmit, + onClickMoveUp: () => {}, + onClickMoveDown: () => {}, + onClickUnlink: () => {}, + onClickDelete: () => {}, + onClickViewLibrary: () => {}, + onClickCopy: () => {}, + onClickCopyLocation: () => {}, + }} />