diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 4b10848e8b..8e8a8be2e1 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -74,6 +74,8 @@ describe('ProctoredExamSettings', () => { provider: null, }); + axiosMock.onGet(/course_index/).reply(200, { sections: [] }); + axiosMock.onGet( StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), ).reply(200, { @@ -466,7 +468,8 @@ describe('ProctoredExamSettings', () => { // (1) for studio settings // (2) waffle flags // (3) for course details - expect(axiosMock.history.get.length).toBe(3); + // (4) for course outline index (from CourseAuthoringProvider) + expect(axiosMock.history.get.length).toBe(4); expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); }); diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index a31f887155..91c3556fbe 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,17 +1,23 @@ import { getConfig } from '@edx/frontend-platform'; import { - createContext, useContext, useMemo, useState, + createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; -import { useSelector } from 'react-redux'; +import { useCreateCourseBlock, useDeleteCourseItem, useDuplicateItem } from '@src/course-outline/data/apiHooks'; +import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router'; -import { getOutlineIndexData } from '@src/course-outline/data/selectors'; +import { getOutlineIndexData, getSectionsList } from '@src/course-outline/data/selectors'; import { useToggleWithValue } from '@src/hooks'; import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types'; import { CourseDetailsData } from './data/api'; import { useCourseDetails, useWaffleFlags } from './data/apiHooks'; import { RequestStatusType } from './data/constants'; +import { arrayMove } from '@dnd-kit/sortable'; +import { fetchCourseOutlineIndexQuery, setSectionOrderListQuery, setSubsectionOrderListQuery, setUnitOrderListQuery } from './course-outline/data/thunk'; +import { useToggle } from '@openedx/paragon'; +import { getBlockType } from './generic/key-utils'; +import { COURSE_BLOCK_NAMES } from './constants'; +import { deleteSection, deleteSubsection, deleteUnit } from './course-outline/data/slice'; type ModalState = { value?: XBlock | UnitXBlock; @@ -40,6 +46,23 @@ export type CourseAuthoringContextData = { closePublishModal: () => void; currentSelection?: SelectionState; setCurrentSelection: React.Dispatch>; + sections: XBlock[]; + restoreSectionList: () => void; + setSections: React.Dispatch>; + isDuplicatingItem: boolean; + isDeleteModalOpen: boolean; + openDeleteModal: () => void; + closeDeleteModal: () => void; + getHandleDeleteItemSubmit: (callback: () => void) => () => Promise; + handleDuplicateSectionSubmit: () => void; + handleDuplicateSubsectionSubmit: () => void; + handleDuplicateUnitSubmit: () => void; + handleSectionDragAndDrop: (sectionListIds: string[]) => void; + handleSubsectionDragAndDrop: (sectionId: string, prevSectionId: string, subsectionListIds: string[]) => void; + handleUnitDragAndDrop: (sectionId: string, prevSectionId: string, subsectionId: string, unitListIds: string[]) => void; + updateSectionOrderByIndex: (currentIndex: number, newIndex: number) => void; + updateSubsectionOrderByIndex: (section: XBlock, moveDetails: any) => void; + updateUnitOrderByIndex: (section: XBlock, moveDetails: any) => void; }; /** @@ -61,6 +84,7 @@ export const CourseAuthoringProvider = ({ courseId, }: CourseAuthoringProviderProps) => { const navigate = useNavigate(); + const dispatch = useDispatch(); const waffleFlags = useWaffleFlags(); const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId); const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date(); @@ -78,6 +102,23 @@ export const CourseAuthoringProvider = ({ openPublishModal, closePublishModal, ] = useToggleWithValue(); + const sectionsList = useSelector(getSectionsList); + const [sections, setSections] = useState(sectionsList); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + + + const restoreSectionList = () => { + setSections(() => [...sectionsList]); + }; + + useEffect(() => { + dispatch(fetchCourseOutlineIndexQuery(courseId)); + }, [courseId]); + + useEffect(() => { + setSections(sectionsList); + }, [sectionsList]); + /** * This will hold the state of current item that is being operated on, * For example: @@ -113,6 +154,196 @@ export const CourseAuthoringProvider = ({ const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage); const handleAddBlock = useCreateCourseBlock(courseId); + const { + mutate: duplicateItem, + isPending: isDuplicatingItem, + } = useDuplicateItem(courseId); + const handleDuplicateSectionSubmit = () => { + if (currentSelection && currentSelection.currentId) { + duplicateItem({ + itemId: currentSelection.currentId, + parentId: courseStructure.id, + sectionId: currentSelection.sectionId, + subsectionId: currentSelection.subsectionId, + }); + } + }; + + const handleDuplicateSubsectionSubmit = () => { + if (currentSelection && currentSelection.currentId && currentSelection.sectionId) { + duplicateItem({ + itemId: currentSelection.currentId, + parentId: currentSelection.sectionId, + sectionId: currentSelection.sectionId, + subsectionId: currentSelection.subsectionId, + }); + } + }; + + const handleDuplicateUnitSubmit = () => { + if (currentSelection && currentSelection.currentId && currentSelection.subsectionId) { + duplicateItem({ + itemId: currentSelection?.currentId, + parentId: currentSelection?.subsectionId, + sectionId: currentSelection?.sectionId, + subsectionId: currentSelection?.subsectionId, + }); + } + }; + + const handleSectionDragAndDrop = ( + sectionListIds: string[], + ) => { + dispatch(setSectionOrderListQuery( + courseId, + sectionListIds, + restoreSectionList, + )); + }; + + const handleSubsectionDragAndDrop = ( + sectionId: string, + prevSectionId: string, + subsectionListIds: string[], + ) => { + dispatch(setSubsectionOrderListQuery( + sectionId, + prevSectionId, + subsectionListIds, + restoreSectionList, + )); + }; + + const handleUnitDragAndDrop = ( + sectionId: string, + prevSectionId: string, + subsectionId: string, + unitListIds: string[], + ) => { + dispatch(setUnitOrderListQuery( + sectionId, + subsectionId, + prevSectionId, + unitListIds, + restoreSectionList, + )); + }; + + /** + * Uses details from move information and moves unit + */ + const updateUnitOrderByIndex = (section: XBlock, moveDetails) => { + const { fn, args, sectionId, subsectionId } = moveDetails; + if (!args) { + return; + } + const [sectionsCopy, newUnits] = fn(...args); + if (newUnits && subsectionId) { + setSections(sectionsCopy); + handleUnitDragAndDrop( + sectionId, + section.id, + subsectionId, + newUnits.map((unit) => unit.id), + ); + } + }; + + /** + * Move section to new index + */ + const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => { + if (currentIndex === newIndex) { + return; + } + setSections((prevSections) => { + const newSections = arrayMove(prevSections, currentIndex, newIndex); + handleSectionDragAndDrop(newSections.map(section => section.id)); + return newSections; + }); + }; + + /** + * Uses details from move information and moves subsection + */ + const updateSubsectionOrderByIndex = (section: XBlock, moveDetails) => { + const { fn, args, sectionId } = moveDetails; + if (!args) { + return; + } + const [sectionsCopy, newSubsections] = fn(...args); + if (newSubsections && sectionId) { + setSections(sectionsCopy); + handleSubsectionDragAndDrop( + sectionId, + section.id, + newSubsections.map(subsection => subsection.id), + ); + } + }; + + const deleteMutation = useDeleteCourseItem(); + + const getHandleDeleteItemSubmit = useCallback((callback: () => void) => { + return async () => { + // istanbul ignore if + if (!currentSelection) { + return; + } + const category = getBlockType(currentSelection.currentId); + switch (category) { + case COURSE_BLOCK_NAMES.chapter.id: + await deleteMutation.mutateAsync( + { itemId: currentSelection.currentId }, + { + onSettled: () => dispatch(deleteSection({ itemId: currentSelection.currentId })), + }, + ); + break; + case COURSE_BLOCK_NAMES.sequential.id: + await deleteMutation.mutateAsync( + { itemId: currentSelection.currentId, sectionId: currentSelection.sectionId }, + { + onSettled: () => dispatch(deleteSubsection({ + itemId: currentSelection.currentId, + sectionId: currentSelection.sectionId, + })), + }, + ); + break; + case COURSE_BLOCK_NAMES.vertical.id: + await deleteMutation.mutateAsync( + { + itemId: currentSelection.currentId, + subsectionId: currentSelection.subsectionId, + sectionId: currentSelection.sectionId, + }, + { + onSettled: () => dispatch(deleteUnit({ + itemId: currentSelection.currentId, + subsectionId: currentSelection.subsectionId, + sectionId: currentSelection.sectionId, + })), + }, + ); + break; + default: + // istanbul ignore next + throw new Error(`Unrecognized category ${category}`); + } + closeDeleteModal(); + callback(); + }; + }, [ + deleteMutation, + closeDeleteModal, + currentSelection, + dispatch, + deleteSection, + deleteUnit, + deleteSubsection, + ]); + const context = useMemo(() => ({ courseId, courseUsageKey, @@ -133,6 +364,23 @@ export const CourseAuthoringProvider = ({ closePublishModal, currentSelection, setCurrentSelection, + sections, + restoreSectionList, + setSections, + isDuplicatingItem, + isDeleteModalOpen, + openDeleteModal, + closeDeleteModal, + getHandleDeleteItemSubmit, + handleDuplicateSectionSubmit, + handleDuplicateSubsectionSubmit, + handleDuplicateUnitSubmit, + handleSectionDragAndDrop, + handleSubsectionDragAndDrop, + handleUnitDragAndDrop, + updateSectionOrderByIndex, + updateSubsectionOrderByIndex, + updateUnitOrderByIndex, }), [ courseId, courseUsageKey, @@ -153,6 +401,22 @@ export const CourseAuthoringProvider = ({ closePublishModal, currentSelection, setCurrentSelection, + sections, + restoreSectionList, + setSections, + isDuplicatingItem, + isDeleteModalOpen, + openDeleteModal, + closeDeleteModal, + getHandleDeleteItemSubmit, + handleDuplicateSectionSubmit, + handleDuplicateSubsectionSubmit, + handleSectionDragAndDrop, + handleSubsectionDragAndDrop, + handleUnitDragAndDrop, + updateSectionOrderByIndex, + updateSubsectionOrderByIndex, + updateUnitOrderByIndex, ]); return ( diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index ebfaf9e779..7906f5c266 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -78,7 +78,7 @@ describe('useTaxonomyTagsData', () => { // Assert that useQueries was called with the correct arguments expect(useQueries).toHaveBeenCalledWith({ queries: [ - { queryKey: ['taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity }, + { queryKey: ['contentTags', 'taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity }, ], }); diff --git a/src/content-tags-drawer/data/apiHooks.ts b/src/content-tags-drawer/data/apiHooks.ts index 832154d4ce..577d32bb9f 100644 --- a/src/content-tags-drawer/data/apiHooks.ts +++ b/src/content-tags-drawer/data/apiHooks.ts @@ -20,6 +20,33 @@ import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } fro import { getLibraryId } from '../../generic/key-utils'; import type { UpdateTagsData } from './types'; +export const contentTagsQueryKeys = { + all: ['contentTags'], + taxonomyTags: (taxonomyId: number, parentTag: string | null, page: number, searchTerm: string) => [ + ...contentTagsQueryKeys.all, + 'taxonomyTags', + taxonomyId, + parentTag, + page, + searchTerm, + ], + contentTaxonomyTags: (contentId: string) => [ + ...contentTagsQueryKeys.all, + 'contentTaxonomyTags', + contentId, + ], + contentData: (contentId?: string) => [ + ...contentTagsQueryKeys.all, + 'contentData', + contentId, + ], + contentTagsCount: (contentPattern: string) => [ + ...contentTagsQueryKeys.all, + 'contentTagsCount', + contentPattern, + ], +}; + /** * Builds the query to get the taxonomy tags */ @@ -43,7 +70,7 @@ export const useTaxonomyTagsData = ( const queries: { queryKey: any[]; queryFn: typeof queryFn; staleTime: number }[] = []; for (let page = 1; page <= numPages; page++) { queries.push( - { queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity }, + { queryKey: contentTagsQueryKeys.taxonomyTags(taxonomyId, parentTag, page, searchTerm), queryFn, staleTime: Infinity }, ); } @@ -74,7 +101,7 @@ export const useTaxonomyTagsData = ( // Store the pre-loaded descendants into the query cache: preLoadedData.forEach((tags, parentValue) => { - const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm]; + const queryKey = contentTagsQueryKeys.taxonomyTags(taxonomyId, parentValue, 1, searchTerm); const cachedData: TagListData = { next: '', previous: '', @@ -106,7 +133,7 @@ export const useTaxonomyTagsData = ( */ export const useContentTaxonomyTagsData = (contentId: string) => ( useQuery({ - queryKey: ['contentTaxonomyTags', contentId], + queryKey: contentTagsQueryKeys.contentTaxonomyTags(contentId), queryFn: () => getContentTaxonomyTagsData(contentId), }) ); @@ -118,7 +145,7 @@ export const useContentTaxonomyTagsData = (contentId: string) => ( */ export const useContentData = (contentId?: string, enabled: boolean = true) => ( useQuery({ - queryKey: ['contentData', contentId], + queryKey: contentTagsQueryKeys.contentData(contentId), queryFn: (enabled && contentId) ? () => getContentData(contentId) : skipToken, }) ); @@ -137,7 +164,7 @@ export const useContentTaxonomyTagsUpdater = (contentId: string) => { updateContentTaxonomyTags(contentId, tagsData) ), onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] }); + queryClient.invalidateQueries({ queryKey: contentTagsQueryKeys.contentTaxonomyTags(contentId) }); /// Invalidate query with pattern on course outline let contentPattern; if (contentId.includes('course-v1')) { @@ -145,7 +172,7 @@ export const useContentTaxonomyTagsUpdater = (contentId: string) => { } else { contentPattern = contentId.replace(/\+type@.*$/, '*'); } - queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] }); + queryClient.invalidateQueries({ queryKey: contentTagsQueryKeys.contentTagsCount(contentPattern) }); if (contentId.startsWith('lb:') || contentId.startsWith('lib-collection:') || contentId.startsWith('lct:')) { // Obtain library id from contentId const libraryId = getLibraryId(contentId); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 95d75627c9..d50986fff0 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,12 @@ const CourseOutline = () => { isUnlinkModalOpen, closeUnlinkModal, currentSelection, + sections, + restoreSectionList, + setSections, + updateSectionOrderByIndex, + updateSubsectionOrderByIndex, + updateUnitOrderByIndex, } = useCourseAuthoringContext(); const { @@ -83,7 +88,6 @@ const CourseOutline = () => { savingStatus, statusBarData, courseActions, - sectionsList, isCustomRelativeDatesActive, isLoading, isLoadingDenied, @@ -155,11 +159,6 @@ const CourseOutline = () => { } }, [location, courseId, courseName]); - const [sections, setSections] = useState(sectionsList); - - const restoreSectionList = () => { - setSections(() => [...sectionsList]); - }; const { isShow: isShowProcessingNotification, @@ -174,67 +173,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 - */ - const updateSubsectionOrderByIndex = (section: XBlock, moveDetails) => { - const { fn, args, sectionId } = moveDetails; - if (!args) { - return; - } - const [sectionsCopy, newSubsections] = fn(...args); - if (newSubsections && sectionId) { - setSections(sectionsCopy); - handleSubsectionDragAndDrop( - sectionId, - section.id, - newSubsections.map(subsection => subsection.id), - restoreSectionList, - ); - } - }; - - /** - * Uses details from move information and moves unit - */ - const updateUnitOrderByIndex = (section: XBlock, moveDetails) => { - const { - fn, args, sectionId, subsectionId, - } = moveDetails; - if (!args) { - return; - } - const [sectionsCopy, newUnits] = fn(...args); - if (newUnits && sectionId && subsectionId) { - setSections(sectionsCopy); - handleUnitDragAndDrop( - sectionId, - section.id, - subsectionId, - newUnits.map(unit => unit.id), - restoreSectionList, - ); - } - }; - - useEffect(() => { - setSections(sectionsList); - }, [sectionsList]); - if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment return ( @@ -310,7 +248,7 @@ const CourseOutline = () => { isSectionsExpanded={isSectionsExpanded} headerNavigationsActions={headerNavigationsActions} isDisabledReindexButton={isDisabledReindexButton} - hasSections={Boolean(sectionsList.length)} + hasSections={Boolean(sections.length)} courseActions={courseActions} errors={errors} sections={sections} @@ -340,7 +278,7 @@ const CourseOutline = () => {
{showNewActionsBar && ( - {Boolean(sectionsList.length) && ( + {Boolean(sections.length) && (
+ + ); }; diff --git a/src/course-unit/unit-sidebar/unit-info/messages.ts b/src/course-unit/unit-sidebar/unit-info/messages.ts index 9201916f8b..3a7db84ba4 100644 --- a/src/course-unit/unit-sidebar/unit-info/messages.ts +++ b/src/course-unit/unit-sidebar/unit-info/messages.ts @@ -134,6 +134,11 @@ const messages = defineMessages({ defaultMessage: 'Settings', description: 'Label for the settings tab of the unit info sidebar', }, + locationCopiedText: { + id: 'course-authoring.unit-page.sidebar.info.copied-location', + defaultMessage: 'Location ID saved in the Clipboard', + description: 'Toast messages when the user copied an unit location ID', + }, }); export default messages; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 6ee3335a42..3e88b3c6c0 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query'; import { getConfig } from '@edx/frontend-platform'; import { FC, useEffect, useState, useMemo, useCallback, @@ -43,6 +44,8 @@ import { import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils'; import { useUnitSidebarContext } from '../unit-sidebar/UnitSidebarContext'; import { isUnitPageNewDesignEnabled } from '../utils'; +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; +import { contentTagsQueryKeys } from '@src/content-tags-drawer/data/apiHooks'; const XBlockContainerIframe: FC = ({ courseId, @@ -54,6 +57,7 @@ const XBlockContainerIframe: FC = ({ readonly, }) => { const intl = useIntl(); + const queryClient = useQueryClient(); const dispatch = useDispatch(); const { setCurrentPageKey, @@ -93,11 +97,21 @@ const XBlockContainerIframe: FC = ({ setIframeRef(iframeRef); }, [setIframeRef]); + const refreshComponent = (id: string) => { + queryClient.invalidateQueries({ + queryKey: courseOutlineQueryKeys.courseItemId(id), + }); + queryClient.invalidateQueries({ + queryKey: contentTagsQueryKeys.contentData(id), + }); + }; + const onXBlockSave = useCallback(/* istanbul ignore next */ () => { closeXBlockEditorModal(); closeVideoSelectorModal(); sendMessageToIframe(messageTypes.refreshXBlock, null); - }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]); + refreshComponent(newBlockId); + }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe, newBlockId]); const handleEditXBlock = useCallback((type: string, id: string) => { setBlockType(type); @@ -143,10 +157,11 @@ const XBlockContainerIframe: FC = ({ } }; - const onUnlinkSubmit = () => { + const onUnlinkSubmit = async () => { if (unlinkXBlockId) { - unitXBlockActions.handleUnlink(unlinkXBlockId); + await unitXBlockActions.handleUnlink(unlinkXBlockId); closeUnlinkModal(); + refreshComponent(unlinkXBlockId); } }; @@ -181,6 +196,9 @@ const XBlockContainerIframe: FC = ({ const handleSaveEditedXBlockData = () => { sendMessageToIframe(messageTypes.completeXBlockEditing, { locator: configureXBlockId }); dispatch(updateCourseUnitSidebar(blockId)); + if (configureXBlockId) { + refreshComponent(configureXBlockId); + } if (!isUnitVerticalType) { dispatch(fetchCourseSectionVerticalData(blockId)); } diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index 2c1b0757a4..e89fd98df3 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -34,7 +34,7 @@ export interface XBlockContainerIframeProps { unitXBlockActions: { handleDelete: (XBlockId: string | null) => Promise | void; handleDuplicate: (XBlockId: string | null) => void; - handleUnlink: (XBlockId: string | null) => void; + handleUnlink: (XBlockId: string | null) => Promise | void; }; courseVerticalChildren: Array; handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void; diff --git a/src/data/types.ts b/src/data/types.ts index c13205a6a0..d7d8ecefde 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -168,6 +168,8 @@ export type SelectionState = { currentId: string; sectionId?: string; subsectionId?: string; + index?: number; + sectionIndex?: number; }; export type AccessManagedXBlockDataTypes = { diff --git a/src/generic/library-reference-card/LibraryReferenceCard.tsx b/src/generic/library-reference-card/LibraryReferenceCard.tsx index b428458e86..c42a142ce8 100644 --- a/src/generic/library-reference-card/LibraryReferenceCard.tsx +++ b/src/generic/library-reference-card/LibraryReferenceCard.tsx @@ -178,7 +178,7 @@ const TopLevelTextAndButton = ({ ); } - if ((upstreamInfo?.downstreamCustomized.length || 0) > 0) { + if ((upstreamInfo?.downstreamCustomized?.length || 0) > 0) { return ( ); diff --git a/src/generic/sidebar/InfoSidebarMenu.tsx b/src/generic/sidebar/InfoSidebarMenu.tsx new file mode 100644 index 0000000000..9152327e2d --- /dev/null +++ b/src/generic/sidebar/InfoSidebarMenu.tsx @@ -0,0 +1,158 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Dropdown, Icon, IconButton, Stack, +} from '@openedx/paragon'; +import { + ArrowDownward, + ArrowOutward, + ArrowUpward, + ContentCopy, + Delete, + LinkOff, + MoreVert, + Newsstand, +} from '@openedx/paragon/icons'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import messages from './messages'; + +export interface InfoSidebarMenuProps { + itemId: string; + index: number; + onClickUnlink: () => void; + onClickDelete: () => void; + onClickViewLibrary: () => void; + onClickDuplicate?: () => void; + onClickMoveUp?: () => void; + onClickMoveDown?: () => void; + canMoveItem?: (oldIndex: number, step: number) => boolean; + onClickCopy?: () => void; + onClickCopyLocation?: () => void; + onClickMove?: () => void; +} + +export const InfoSidebarMenu = ({ + itemId, + index, + onClickDuplicate, + onClickMoveUp, + onClickMoveDown, + canMoveItem, + onClickUnlink, + onClickDelete, + onClickViewLibrary, + onClickCopy, + onClickCopyLocation, + onClickMove, +}: InfoSidebarMenuProps) => { + const intl = useIntl(); + const { data: item } = useCourseItemData(itemId); + + if (item === undefined) { + return null; + } + + const { actions, upstreamInfo } = item; + + return ( + + + + {actions?.duplicable && onClickDuplicate && ( + + + + {intl.formatMessage(messages.menuDuplicate)} + + + )} + {onClickCopy && ( + + + + {intl.formatMessage(messages.menuCopy)} + + + )} + {onClickCopyLocation && ( + + + + {intl.formatMessage(messages.menuCopyLocation)} + + + )} + {onClickMove && ( + + + + {intl.formatMessage(messages.menuMove)} + + + )} + {actions?.draggable && onClickMoveUp && onClickMoveDown && canMoveItem && ( + <> + + + + {intl.formatMessage(messages.menuMoveUp)} + + + + + + {intl.formatMessage(messages.menuMoveDown)} + + + + )} + {upstreamInfo?.upstreamRef && ( + + + + {intl.formatMessage(messages.menuViewLibrary)} + + + )} + {((actions?.unlinkable ?? null) !== null || actions?.deletable) && } + {(actions?.unlinkable ?? null) !== null && ( + + + + {intl.formatMessage(messages.menuUnlink)} + + + )} + {actions?.deletable && ( + + + + {intl.formatMessage(messages.menuDelete)} + + + )} + + + ); +}; diff --git a/src/generic/sidebar/SidebarTitle.tsx b/src/generic/sidebar/SidebarTitle.tsx index 52a3e9e0c2..25629785f7 100644 --- a/src/generic/sidebar/SidebarTitle.tsx +++ b/src/generic/sidebar/SidebarTitle.tsx @@ -2,6 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, IconButton, Stack } from '@openedx/paragon'; import { ArrowBack } from '@openedx/paragon/icons'; import messages from './messages'; +import { InfoSidebarMenu, InfoSidebarMenuProps } from './InfoSidebarMenu'; interface SidebarTitleProps { /** Title of the section */ @@ -9,6 +10,7 @@ interface SidebarTitleProps { /** Icon to be displayed in the section title */ icon?: React.ComponentType; onBackBtnClick?: () => void; + menuProps?: InfoSidebarMenuProps; } /** @@ -24,22 +26,28 @@ export const SidebarTitle = ({ title, icon, onBackBtnClick, + menuProps, }: SidebarTitleProps) => { const intl = useIntl(); return ( <> - - {onBackBtnClick && ( - +
+ + {onBackBtnClick && ( + + )} + +

{title}

+
+ {menuProps && ( + )} - -

{title}

- +

); diff --git a/src/generic/sidebar/messages.ts b/src/generic/sidebar/messages.ts index 869e12d509..9846fdf874 100644 --- a/src/generic/sidebar/messages.ts +++ b/src/generic/sidebar/messages.ts @@ -9,8 +9,63 @@ const messages = defineMessages({ backBtnText: { id: 'course-authoring.sidebar.back.btn.alt-text', defaultMessage: 'Back', - description: 'Alternate text of Back button in sidebar title', + description: 'Alternate text for Back button in sidebar title', }, + itemMenuAlt: { + id: 'course-authoring.sidebar.item-menu.button.alt', + defaultMessage: 'Item Menu', + description: 'Alternate text for Item Menu in the sidebar', + }, + menuDuplicate: { + id: 'course-authoring.sidebar.item-menu.duplicate', + defaultMessage: 'Duplicate', + description: 'Text for the Duplicate button in the sidebar menu', + }, + menuCopy: { + id: 'course-authoring.sidebar.item-menu.copy', + defaultMessage: 'Copy to Clipboard', + description: 'Text for the Copy button in the sidebar menu', + }, + menuMoveUp: { + id: 'course-authoring.sidebar.item-menu.move-up', + defaultMessage: 'Move Up', + description: 'Text for the Move Up button in the sidebar menu', + }, + menuMoveDown: { + id: 'course-authoring.sidebar.item-menu.move-down', + defaultMessage: 'Move Down', + description: 'Text for the Move Down button in the sidebar menu', + }, + menuUnlink: { + id: 'course-authoring.sidebar.item-menu.unlink', + defaultMessage: 'Unlink from Library', + description: 'Text for the Unlink button in the sidebar menu', + }, + menuDelete: { + id: 'course-authoring.sidebar.item-menu.delete', + defaultMessage: 'Delete', + description: 'Text for the Delete button in the sidebar menu', + }, + menuUnlinkDisabledTooltip: { + id: 'course-authoring.sidebar.item-menu.unlink.tooltip', + defaultMessage: 'Only the highest level library reference can be unlinked.', + description: 'Tooltip for disabled unlink option', + }, + menuViewLibrary: { + id: 'course-authoring.sidebar.item-menu.view-library', + defaultMessage: 'View in Library', + description: 'Text for the View in Library button in the sidebar menu', + }, + menuCopyLocation: { + id: 'course-authoring.sidebar.item-menu.copy-location', + defaultMessage: 'Copy Location ID', + description: 'Text for the Copy location button in the sidebar menu', + }, + menuMove: { + id: 'course-authoring.sidebar.item-menu.move', + defaultMessage: 'Move', + description: 'Text for the Move button in the sidebar menu', + }, }); export default messages; diff --git a/src/library-authoring/history-log/HistoryLog.tsx b/src/library-authoring/history-log/HistoryLog.tsx new file mode 100644 index 0000000000..54260dc71c --- /dev/null +++ b/src/library-authoring/history-log/HistoryLog.tsx @@ -0,0 +1,9 @@ +import { Stack } from "@openedx/paragon"; + +const HistoryLog = () => { + return ( + + History + + ) +};