diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 4b10848e8b..793d983cd3 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, { diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index a31f887155..89fc017e99 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -1,19 +1,18 @@ import { getConfig } from '@edx/frontend-platform'; import { - createContext, useContext, useMemo, useState, + createContext, useContext, useMemo, } from 'react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks'; 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 { 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 { getOutlineIndexData } from './course-outline/data/selectors'; -type ModalState = { +export type ModalState = { value?: XBlock | UnitXBlock; subsectionId?: string; sectionId?: string; @@ -26,20 +25,12 @@ export type CourseAuthoringContextData = { courseDetails?: CourseDetailsData; courseDetailStatus: RequestStatusType; canChangeProviders: boolean; - handleAddAndOpenUnit: ReturnType; - handleAddBlock: ReturnType; - openUnitPage: (locator: string) => void; + openUnitPage: (locator: string) => Promise; getUnitUrl: (locator: string) => string; isUnlinkModalOpen: boolean; currentUnlinkModalData?: ModalState; openUnlinkModal: (value: ModalState) => void; closeUnlinkModal: () => void; - isPublishModalOpen: boolean; - currentPublishModalData?: ModalState; - openPublishModal: (value: ModalState) => void; - closePublishModal: () => void; - currentSelection?: SelectionState; - setCurrentSelection: React.Dispatch>; }; /** @@ -72,20 +63,6 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, ] = useToggleWithValue(); - const [ - isPublishModalOpen, - currentPublishModalData, - openPublishModal, - closePublishModal, - ] = useToggleWithValue(); - /** - * This will hold the state of current item that is being operated on, - * For example: - * - the details of container that is being edited. - * - the details of container of which see more dropdown is open. - * It is mostly used in modals which should be soon be replaced with its equivalent in sidebar. - */ - const [currentSelection, setCurrentSelection] = useState(); const getUnitUrl = (locator: string) => { if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) { @@ -107,11 +84,6 @@ export const CourseAuthoringProvider = ({ window.location.assign(url); } }; - /** - * import a unit block from library and redirect user to this unit page. - */ - const handleAddAndOpenUnit = useCreateCourseBlock(courseId, openUnitPage); - const handleAddBlock = useCreateCourseBlock(courseId); const context = useMemo(() => ({ courseId, @@ -119,40 +91,24 @@ export const CourseAuthoringProvider = ({ courseDetails, courseDetailStatus, canChangeProviders, - handleAddBlock, - handleAddAndOpenUnit, getUnitUrl, openUnitPage, isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, - isPublishModalOpen, - currentPublishModalData, - openPublishModal, - closePublishModal, - currentSelection, - setCurrentSelection, }), [ courseId, courseUsageKey, courseDetails, courseDetailStatus, canChangeProviders, - handleAddBlock, - handleAddAndOpenUnit, getUnitUrl, openUnitPage, isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, - isPublishModalOpen, - currentPublishModalData, - openPublishModal, - closePublishModal, - currentSelection, - setCurrentSelection, ]); return ( diff --git a/src/CourseAuthoringRoutes.tsx b/src/CourseAuthoringRoutes.tsx index 4e6f0cdac6..6ca81f930d 100644 --- a/src/CourseAuthoringRoutes.tsx +++ b/src/CourseAuthoringRoutes.tsx @@ -16,6 +16,7 @@ import { OutlineSidebarProvider, OutlineSidebarPagesProvider, } from './course-outline'; +import { CourseOutlineProvider } from './course-outline/CourseOutlineContext'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; @@ -66,11 +67,13 @@ const CourseAuthoringRoutes = () => { path="/" element={( - - - - - + + + + + + + )} /> 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.test.tsx b/src/course-outline/CourseOutline.test.tsx index 638df9c774..0d7364dcd9 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -18,6 +18,7 @@ import { } from '@src/testUtils'; import { XBlock } from '@src/data/types'; import { userEvent } from '@testing-library/user-event'; +import { CourseOutlineProvider } from './CourseOutlineContext'; import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext'; import { OutlineSidebarPagesProvider } from './outline-sidebar/OutlineSidebarPagesContext'; import { @@ -144,11 +145,13 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const renderComponent = () => render( - - - - - + + + + + + + , ); diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 0df9fcb46e..b9fb53a692 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'; @@ -31,6 +30,7 @@ import getPageHeadTitle from '@src/generic/utils'; import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot'; import { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useCourseOutlineContext } from './CourseOutlineContext'; import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert'; import { ContainerType } from '@src/generic/key-utils'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; @@ -70,15 +70,24 @@ const CourseOutline = () => { courseUsageKey, isUnlinkModalOpen, closeUnlinkModal, - currentSelection, } = useCourseAuthoringContext(); + const { + handleAddBlock, + handleAddAndOpenUnit, + currentSelection, + sections, + restoreSectionList, + setSections, + updateSectionOrderByIndex, + updateSubsectionOrderByIndex, + updateUnitOrderByIndex, + } = useCourseOutlineContext(); const { courseName, savingStatus, statusBarData, courseActions, - sectionsList, isCustomRelativeDatesActive, isLoading, isLoadingDenied, @@ -146,11 +155,6 @@ const CourseOutline = () => { } }, [location, courseId, courseName]); - const [sections, setSections] = useState(sectionsList); - - const restoreSectionList = () => { - setSections(() => [...sectionsList]); - }; const { data: currentItemData } = useCourseItemData(currentSelection?.currentId); @@ -160,67 +164,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 ( @@ -296,7 +239,7 @@ const CourseOutline = () => { isSectionsExpanded={isSectionsExpanded} headerNavigationsActions={headerNavigationsActions} isDisabledReindexButton={isDisabledReindexButton} - hasSections={Boolean(sectionsList.length)} + hasSections={Boolean(sections.length)} courseActions={courseActions} errors={errors} sections={sections} @@ -326,7 +269,7 @@ const CourseOutline = () => {
{showNewActionsBar && ( - {Boolean(sectionsList.length) && ( + {Boolean(sections.length) && ( })); jest.mock('./InfoSection', () => ({ InfoSection: ({ itemId }: any) =>
InfoSection:{itemId}
})); jest.mock('@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings', () => ({ GenericUnitInfoSettings: () =>
GenericUnitInfoSettings
})); @@ -28,22 +32,40 @@ jest.mock('@src/generic/hooks/context/iFrameContext', () => ({ IframeProvider: ( const apiHooks = jest.requireMock('@src/course-outline/data/apiHooks') as any; const outlineContext = jest.requireMock('../OutlineSidebarContext') as any; const authoring = jest.requireMock('@src/CourseAuthoringContext') as any; +const outlineCtx = jest.requireMock('@src/course-outline/CourseOutlineContext') as any; describe('UnitSidebar', () => { beforeEach(() => { initializeMocks(); - outlineContext.useOutlineSidebarContext.mockReturnValue({ selectedContainerState: { sectionId: 's1', subsectionId: 'ss1' }, clearSelection: jest.fn() }); - authoring.useCourseAuthoringContext.mockReturnValue({ openPublishModal: jest.fn(), getUnitUrl: (id: string) => `/unit/${id}`, courseId: '5' }); + outlineContext.useOutlineSidebarContext.mockReturnValue({ + selectedContainerState: { currentId: 'unit-1', sectionId: 's1', subsectionId: 'ss1' }, + clearSelection: jest.fn(), + setSelectedContainerState: jest.fn(), + }); + authoring.useCourseAuthoringContext.mockReturnValue({ + openPublishModal: jest.fn(), + getUnitUrl: (id: string) => `/unit/${id}`, + courseId: '5', + openUnlinkModal: jest.fn(), + }); + outlineCtx.useCourseOutlineContext.mockReturnValue({ + openPublishModal: jest.fn(), + handleDuplicateUnitSubmit: jest.fn(), + sections: [], + updateUnitOrderByIndex: jest.fn(), + openDeleteModal: jest.fn(), + }); }); it('renders title and info tab by default', () => { apiHooks.useCourseItemData.mockReturnValue({ data: { displayName: 'Unit 1', hasChanges: false, category: 'vertical', id: 'unit-1', + actions: { deletable: true, duplicable: true }, }, isPending: false, }); - render(); + render(); expect(screen.getByText('Unit 1')).toBeInTheDocument(); expect(screen.getByText('InfoSection:unit-1')).toBeInTheDocument(); }); @@ -51,15 +73,27 @@ describe('UnitSidebar', () => { it('shows publish button and triggers openPublishModal when unit has changes', async () => { const user = userEvent.setup(); const openPublishModal = jest.fn(); - authoring.useCourseAuthoringContext.mockReturnValue({ openPublishModal, getUnitUrl: (id: string) => `/unit/${id}`, courseId: '5' }); + outlineCtx.useCourseOutlineContext.mockReturnValue({ + openPublishModal, + handleDuplicateUnitSubmit: jest.fn(), + sections: [], + updateUnitOrderByIndex: jest.fn(), + openDeleteModal: jest.fn(), + }); + outlineContext.useOutlineSidebarContext.mockReturnValue({ + selectedContainerState: { currentId: 'unit-2', sectionId: 's1', subsectionId: 'ss1' }, + clearSelection: jest.fn(), + setSelectedContainerState: jest.fn(), + }); apiHooks.useCourseItemData.mockReturnValue({ data: { displayName: 'Unit 2', hasChanges: true, category: 'vertical', id: 'unit-2', + actions: { deletable: true, duplicable: true }, }, isPending: false, }); - render(); + render(); expect(screen.getByText('Publish')).toBeInTheDocument(); await user.click(screen.getByText('Publish')); expect(openPublishModal).toHaveBeenCalledWith({ value: expect.any(Object), sectionId: 's1', subsectionId: 'ss1' }); @@ -67,26 +101,39 @@ describe('UnitSidebar', () => { it('switches to preview tab and shows iframe', async () => { const user = userEvent.setup(); + outlineContext.useOutlineSidebarContext.mockReturnValue({ + selectedContainerState: { currentId: 'unit-3', sectionId: 's1', subsectionId: 'ss1' }, + clearSelection: jest.fn(), + setSelectedContainerState: jest.fn(), + }); apiHooks.useCourseItemData.mockReturnValue({ data: { displayName: 'Unit 3', hasChanges: false, category: 'vertical', id: 'unit-3', + actions: { deletable: true, duplicable: true }, }, isPending: false, }); - render(); + render(); await user.click(screen.getByRole('tab', { name: /Preview/i })); expect(screen.getByText('XBlockIframe')).toBeInTheDocument(); }); it('shows settings tab content when selected', async () => { const user = userEvent.setup(); + outlineContext.useOutlineSidebarContext.mockReturnValue({ + selectedContainerState: { currentId: 'unit-4', sectionId: 's1', subsectionId: 'ss1' }, + clearSelection: jest.fn(), + setSelectedContainerState: jest.fn(), + }); apiHooks.useCourseItemData.mockReturnValue({ data: { - displayName: 'Unit 4', hasChanges: false, category: 'vertical', id: 'unit-4', visibilityState: undefined, discussionEnabled: false, userPartitionInfo: null, + displayName: 'Unit 4', hasChanges: false, category: 'vertical', id: 'unit-4', + visibilityState: undefined, discussionEnabled: false, userPartitionInfo: null, + actions: { deletable: true, duplicable: true }, }, isPending: false, }); - render(); + render(); await user.click(screen.getByRole('tab', { name: /Settings/i })); expect(screen.getByText('GenericUnitInfoSettings')).toBeInTheDocument(); }); diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx index 50b9e9ee37..1a6f324c0e 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react'; +import { useContext, useState } from 'react'; +import { isEmpty } from 'lodash'; + import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Stack, Tab, Tabs, @@ -14,16 +16,22 @@ import { SidebarTitle } from '@src/generic/sidebar'; import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useCourseOutlineContext } from '@src/course-outline/CourseOutlineContext'; import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; +import { getLibraryId } from '@src/generic/key-utils'; +import { extractCourseUnitId } from '@src/course-unit/legacy-sidebar/utils'; +import { possibleUnitMoves } from '@src/course-outline/drag-helper/utils'; import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; import { useQueryClient } from '@tanstack/react-query'; import { useOutlineSidebarContext } from '../OutlineSidebarContext'; import { PublishButon } from './PublishButon'; import messages from '../messages'; import { InfoSection } from './InfoSection'; - +import { useClipboard } from '@src/generic/clipboard'; +import { ToastContext } from '@src/generic/toast-context'; +import { XBlock } from '@src/data/types'; interface Props { unitId: string; } @@ -55,12 +63,32 @@ const UnitSettingsTab = ({ unitId }: Props) => { ); }; -export const UnitSidebar = ({ unitId }: Props) => { +export const UnitSidebar = () => { const intl = useIntl(); + const navigate = useNavigate(); const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); + const { selectedContainerState, clearSelection, setSelectedContainerState } = useOutlineSidebarContext(); + const { + currentId: unitId = /* istanbul ignore next */ '', + index, + } = selectedContainerState ?? {}; const { data: unitData, isPending } = useCourseItemData(unitId); - const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); - const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); + const { data: section } = useCourseItemData(selectedContainerState?.sectionId); + const { data: subsection } = useCourseItemData(selectedContainerState?.subsectionId); + const { getUnitUrl, courseId, openUnlinkModal } = useCourseAuthoringContext(); + const { + openPublishModal, + handleDuplicateUnitSubmit, + sections, + updateUnitOrderByIndex, + openDeleteModal, + } = useCourseOutlineContext(); + const sectionIndex = sections.findIndex((s) => s.id === selectedContainerState?.sectionId); + const subsectionIndex = section?.childInfo?.children?.findIndex( + (s) => s.id === selectedContainerState?.subsectionId, + ) ?? -1; + const { copyToClipboard } = useClipboard(); + const { showToast } = useContext(ToastContext); const handlePublish = () => { if (unitData?.hasChanges) { @@ -72,9 +100,88 @@ export const UnitSidebar = ({ unitId }: Props) => { } }; - if (isPending) { + if (isPending || !unitData) { return ; } + // re-create actions object for customizations + const actions = { ...unitData.actions }; + actions.deletable = actions.deletable && !subsection?.upstreamInfo?.upstreamRef; + actions.duplicable = actions.duplicable && !subsection?.upstreamInfo?.upstreamRef; + + // Build move calculator only when all ancestor context is available + const getPossibleMoves = (section && subsection && subsectionIndex !== -1) + ? possibleUnitMoves( + [...sections], + sectionIndex ?? -1, + subsectionIndex, + section, + subsection, + subsection.childInfo.children, + ) + : undefined; + + const canMoveUnit = (oldIndex: number, step: number) => { + if (getPossibleMoves) { + const moveDetails = getPossibleMoves(oldIndex, step); + return !isEmpty(moveDetails) && !subsection?.upstreamInfo?.upstreamRef; + } + /* istanbul ignore next */ + return false; + }; + + const handleMove = (step: number) => { + if (section && subsection && getPossibleMoves && index !== undefined && sectionIndex !== undefined) { + const moveDetails = getPossibleMoves(index, step); + // section is the current parent section (used as prevSection in cross-section moves) + updateUnitOrderByIndex(section, moveDetails); + if (!isEmpty(moveDetails)) { + const newSectionId = moveDetails.sectionId; + const newSubsectionId = moveDetails.subsectionId; + // Cross-subsection move: unit goes to end of previous or start of next subsection + const isCrossSubsection = newSubsectionId !== subsection.id; + /* istanbul ignore next */ + const newSectionIndex = newSectionId !== section.id + ? sections.findIndex((s) => s.id === newSectionId) + : sectionIndex; + /* istanbul ignore next */ + const newIndex = isCrossSubsection + ? (step === -1 + ? sections[newSectionIndex].childInfo.children.find((s) => s.id === newSubsectionId)?.childInfo.children.length ?? 0 + : 0) + : index + step; + /* istanbul ignore next */ + setSelectedContainerState(selectedContainerState ? { + ...selectedContainerState, + sectionId: newSectionId, + subsectionId: newSubsectionId, + index: newIndex, + } : undefined); + } + } + }; + + const handleCopyLocation = () => { + const locationId = extractCourseUnitId(unitId); + if (!locationId) { + /* istanbul ignore next */ + return; + } + + if (navigator.clipboard) { + // Modern approach: requires HTTPS (secure context) + void navigator.clipboard.writeText(locationId); + } else /* istanbul ignore next */ { + // Fallback for HTTP (non-secure) dev environments + // Note: execCommand is deprecated but still widely supported as fallback + const textarea = document.createElement('textarea'); + textarea.value = locationId; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); // eslint-disable-line deprecation/deprecation + document.body.removeChild(textarea); + } + showToast(intl.formatMessage(messages.locationCopiedText)); + }; return ( <> @@ -82,6 +189,30 @@ export const UnitSidebar = ({ unitId }: Props) => { title={unitData?.displayName || ''} icon={getItemIcon(unitData?.category || '')} onBackBtnClick={clearSelection} + menuProps={{ + itemId: unitId, + index: index ?? -1, + actions, + canMoveItem: canMoveUnit, + onClickDuplicate: unitData?.actions?.duplicable ? handleDuplicateUnitSubmit : undefined, + onClickMoveUp: () => handleMove(-1), + onClickMoveDown: () => handleMove(1), + onClickUnlink: () => openUnlinkModal({ + value: unitData, + sectionId: selectedContainerState?.sectionId, + subsectionId: selectedContainerState?.subsectionId, + }), + onClickDelete: openDeleteModal, + onClickViewLibrary: () => { + const upstreamRef = unitData?.upstreamInfo?.upstreamRef; + if (upstreamRef) { + const libId = getLibraryId(upstreamRef); + navigate(`/library/${libId}/unit/${upstreamRef}`); + } + }, + onClickCopy: /* istanbul ignore next */ () => copyToClipboard(unitId), + onClickCopyLocation: handleCopyLocation, + }} />
+ + ); }; 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 281d79414d..df59e6fb83 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, @@ -41,6 +42,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, @@ -51,6 +54,7 @@ const XBlockContainerIframe: FC = ({ readonly, }) => { const intl = useIntl(); + const queryClient = useQueryClient(); const dispatch = useDispatch(); const { setCurrentPageKey, @@ -95,11 +99,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); @@ -145,10 +159,11 @@ const XBlockContainerIframe: FC = ({ } }; - const onUnlinkSubmit = () => { + const onUnlinkSubmit = async () => { if (unlinkXBlockId) { - unitXBlockActions.handleUnlink(unlinkXBlockId); + await unitXBlockActions.handleUnlink(unlinkXBlockId); closeUnlinkModal(); + refreshComponent(unlinkXBlockId); } }; @@ -187,6 +202,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 f117f66cd0..4aa7f50484 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -33,7 +33,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; readonly?: boolean; diff --git a/src/data/types.ts b/src/data/types.ts index 1dd30420c2..8a9af2223a 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -168,6 +168,7 @@ export type SelectionState = { currentId: string; sectionId?: string; subsectionId?: string; + index?: 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..5275bfd0c6 --- /dev/null +++ b/src/generic/sidebar/InfoSidebarMenu.tsx @@ -0,0 +1,162 @@ +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'; +import { XBlockActions } from '@src/data/types'; + +export interface InfoSidebarMenuProps { + itemId: string; + index: number; + actions: XBlockActions; + 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 = (props: InfoSidebarMenuProps) => { + const intl = useIntl(); + const { + itemId, + index, + actions, + onClickDuplicate, + onClickMoveUp, + onClickMoveDown, + canMoveItem, + onClickUnlink, + onClickDelete, + onClickViewLibrary, + onClickCopy, + onClickCopyLocation, + onClickMove, + } = props; + const { data: item } = useCourseItemData(itemId); + + if (item === undefined) { + return null; + } + + const { 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 + + ) +};