diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx index 4e8440e4d..9bafb8f75 100644 --- a/src/course-libraries/ReviewTabContent.tsx +++ b/src/course-libraries/ReviewTabContent.tsx @@ -19,7 +19,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { Loop } from '@openedx/paragon/icons'; import messages from './messages'; import previewChangesMessages from '../course-unit/preview-changes/messages'; -import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks'; +import { invalidateLinksQuery, useEntityLinks } from './data/apiHooks'; import { SearchContextProvider, SearchKeywordsField, useSearchContext, BlockTypeLabel, Highlight, SearchSortWidget, } from '../search-manager'; @@ -189,7 +189,7 @@ const ItemReviewList = ({ const reloadLinks = useCallback((usageKey: string) => { const courseKey = outOfSyncItemsByKey[usageKey].downstreamContextKey; - queryClient.invalidateQueries({ queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey) }); + invalidateLinksQuery(queryClient, courseKey); }, [outOfSyncItemsByKey]); const postChange = (accept: boolean) => { diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index 1db39ec29..048083874 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -3,7 +3,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams-all/`; +export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`; export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`; export interface PaginatedData { diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index 5ec4bdc08..78920339c 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,4 +1,5 @@ import { + type QueryClient, useQuery, } from '@tanstack/react-query'; import { getEntityLinksSummaryByDownstreamContext, getEntityLinks } from './api'; @@ -70,3 +71,12 @@ export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => ( enabled: courseId !== undefined, }) ); + +/** + * Ivalidates the downstream links query for a course + */ +export const invalidateLinksQuery = (queryClient: QueryClient, courseId: string) => { + queryClient.invalidateQueries({ + queryKey: courseLibrariesQueryKeys.courseLibraries(courseId), + }); +}; diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx index 3acac9f91..bbc4aea3f 100644 --- a/src/course-outline/section-card/SectionCard.test.tsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -1,15 +1,18 @@ import { - act, fireEvent, initializeMocks, render, screen, within, + act, fireEvent, initializeMocks, render, screen, waitFor, within, } from '@src/testUtils'; import { XBlock } from '@src/data/types'; import SectionCard from './SectionCard'; -const mockPathname = '/foo-bar'; +const mockUseAcceptLibraryBlockChanges = jest.fn(); +const mockUseIgnoreLibraryBlockChanges = jest.fn(); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: () => ({ - pathname: mockPathname, +jest.mock('@src/course-unit/data/apiHooks', () => ({ + useAcceptLibraryBlockChanges: () => ({ + mutateAsync: mockUseAcceptLibraryBlockChanges, + }), + useIgnoreLibraryBlockChanges: () => ({ + mutateAsync: mockUseIgnoreLibraryBlockChanges, }), })); @@ -74,7 +77,7 @@ const section = { const onEditSectionSubmit = jest.fn(); -const renderComponent = (props?: object, entry = '/') => render( +const renderComponent = (props?: object, entry = '/course/:courseId') => render( render( children , { - path: '/', + path: '/course/:courseId', + params: { courseId: '5' }, routerProps: { initialEntries: [entry], }, @@ -182,7 +186,7 @@ describe('', () => { const collapsedSections = { ...section }; // @ts-ignore-next-line collapsedSections.isSectionsExpanded = false; - renderComponent(collapsedSections, `?show=${subsection.id}`); + renderComponent(collapsedSections, `/course/:courseId?show=${subsection.id}`); const cardSubsections = await screen.findByTestId('section-card__subsections'); const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' }); @@ -194,7 +198,7 @@ describe('', () => { const collapsedSections = { ...section }; // @ts-ignore-next-line collapsedSections.isSectionsExpanded = false; - renderComponent(collapsedSections, `?show=${unit.id}`); + renderComponent(collapsedSections, `/course/:courseId?show=${unit.id}`); const cardSubsections = await screen.findByTestId('section-card__subsections'); const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' }); @@ -207,11 +211,58 @@ describe('', () => { const collapsedSections = { ...section }; // @ts-ignore-next-line collapsedSections.isSectionsExpanded = false; - renderComponent(collapsedSections, `?show=${randomId}`); + renderComponent(collapsedSections, `/course/:courseId?show=${randomId}`); const cardSubsections = screen.queryByTestId('section-card__subsections'); const newSubsectionButton = screen.queryByRole('button', { name: 'New subsection' }); expect(cardSubsections).toBeNull(); expect(newSubsectionButton).toBeNull(); }); + + it('should sync section changes from upstream', async () => { + renderComponent(); + + expect(await screen.findByTestId('section-card-header')).toBeInTheDocument(); + + // Click on sync button + const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); + fireEvent.click(syncButton); + + // Should open compare preview modal + expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument(); + expect(screen.getByText('Preview not available')).toBeInTheDocument(); + + // Click on accept changes + const acceptChangesButton = screen.getByText(/accept changes/i); + fireEvent.click(acceptChangesButton); + + await waitFor(() => expect(mockUseAcceptLibraryBlockChanges).toHaveBeenCalled()); + }); + + it('should decline sync section changes from upstream', async () => { + renderComponent(); + + expect(await screen.findByTestId('section-card-header')).toBeInTheDocument(); + + // Click on sync button + const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); + fireEvent.click(syncButton); + + // Should open compare preview modal + expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument(); + expect(screen.getByText('Preview not available')).toBeInTheDocument(); + + // Click on ignore changes + const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i }); + fireEvent.click(ignoreChangesButton); + + // Should open the confirmation modal + expect(screen.getByRole('heading', { name: /ignore these changes\?/i })).toBeInTheDocument(); + + // Click on ignore button + const ignoreButton = screen.getByRole('button', { name: /ignore/i }); + fireEvent.click(ignoreButton); + + await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled()); + }); }); diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 227219c45..475957acf 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -1,13 +1,14 @@ import { - useContext, useEffect, useState, useRef, useCallback, ReactNode, + useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo, } from 'react'; import { useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Bubble, Button, StandardModal, useToggle, } from '@openedx/paragon'; -import { useSearchParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import classNames from 'classnames'; +import { useQueryClient } from '@tanstack/react-query'; import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice'; import { RequestStatus } from '@src/data/constants'; @@ -16,14 +17,17 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; import TitleButton from '@src/course-outline/card-header/TitleButton'; import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus'; +import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils'; import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons'; import { ContainerType } from '@src/generic/key-utils'; import { ComponentPicker, SelectedComponent } from '@src/library-authoring'; import { ContentType } from '@src/library-authoring/routes'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import type { XBlock } from '@src/data/types'; +import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import messages from './messages'; interface SectionCardProps { @@ -79,6 +83,8 @@ const SectionCard = ({ openAddLibrarySubsectionModal, closeAddLibrarySubsectionModal, ] = useToggle(false); + const { courseId } = useParams(); + const queryClient = useQueryClient(); // Expand the section if a search result should be shown/scrolled to const containsSearchResult = () => { @@ -107,6 +113,7 @@ const SectionCard = ({ }; const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded); const [isFormOpen, openForm, closeForm] = useToggle(false); + const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'section'; useEffect(() => { @@ -126,6 +133,19 @@ const SectionCard = ({ upstreamInfo, } = section; + const blockSyncData = useMemo(() => { + if (!upstreamInfo?.readyToSync) { + return undefined; + } + return { + displayName, + downstreamBlockId: id, + upstreamBlockId: upstreamInfo.upstreamRef, + upstreamBlockVersionSynced: upstreamInfo.versionSynced, + isContainer: true, + }; + }, [upstreamInfo]); + useEffect(() => { if (activeId === id && isExpanded) { setIsExpanded(false); @@ -149,6 +169,13 @@ const SectionCard = ({ setIsExpanded((prevState) => containsSearchResult() || prevState); }, [locatorId, setIsExpanded]); + const handleOnPostChangeSync = useCallback(() => { + dispatch(fetchCourseSectionQuery([section.id])); + if (courseId) { + invalidateLinksQuery(queryClient, courseId); + } + }, [dispatch, section, courseId, queryClient]); + // re-create actions object for customizations const actions = { ...sectionActions }; // add actions to control display of move up & down menu buton. @@ -267,6 +294,7 @@ const SectionCard = ({ onClickDelete={onOpenDeleteModal} onClickMoveUp={handleSectionMoveUp} onClickMoveDown={handleSectionMoveDown} + onClickSync={openSyncModal} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} @@ -275,6 +303,7 @@ const SectionCard = ({ titleComponent={titleComponent} namePrefix={namePrefix} actions={actions} + readyToSync={upstreamInfo?.readyToSync} /> )}
@@ -330,6 +359,14 @@ const SectionCard = ({ visibleTabs={[ContentType.subsections]} /> + {blockSyncData && ( + + )} ); }; diff --git a/src/course-outline/subsection-card/SubsectionCard.test.tsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx index 30dc5f7ac..54bee7fa6 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -1,20 +1,24 @@ import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { - act, fireEvent, initializeMocks, render, screen, within, + act, fireEvent, initializeMocks, render, screen, waitFor, within, } from '@src/testUtils'; import { XBlock } from '@src/data/types'; import cardHeaderMessages from '../card-header/messages'; import SubsectionCard from './SubsectionCard'; let store; -const mockPathname = '/foo-bar'; const containerKey = 'lct:org:lib:unit:1'; const handleOnAddUnitFromLibrary = jest.fn(); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: () => ({ - pathname: mockPathname, +const mockUseAcceptLibraryBlockChanges = jest.fn(); +const mockUseIgnoreLibraryBlockChanges = jest.fn(); + +jest.mock('@src/course-unit/data/apiHooks', () => ({ + useAcceptLibraryBlockChanges: () => ({ + mutateAsync: mockUseAcceptLibraryBlockChanges, + }), + useIgnoreLibraryBlockChanges: () => ({ + mutateAsync: mockUseIgnoreLibraryBlockChanges, }), })); @@ -97,7 +101,7 @@ const section: XBlock = { const onEditSubectionSubmit = jest.fn(); -const renderComponent = (props?: object, entry = '/') => render( +const renderComponent = (props?: object, entry = '/course/:courseId') => render( render( children , { - path: '/', + path: '/course/:courseId', + params: { courseId: '5' }, routerProps: { initialEntries: [entry], }, @@ -277,7 +282,7 @@ describe('', () => { }); it('check extended subsection when URL "show" param in subsection', async () => { - renderComponent(undefined, `?show=${unit.id}`); + renderComponent(undefined, `/course/:courseId?show=${unit.id}`); const cardUnits = await screen.findByTestId('subsection-card__units'); const newUnitButton = await screen.findByRole('button', { name: 'New unit' }); @@ -287,7 +292,7 @@ describe('', () => { it('check not extended subsection when URL "show" param not in subsection', async () => { const randomId = 'random-id'; - renderComponent(undefined, `?show=${randomId}`); + renderComponent(undefined, `/course/:courseId?show=${randomId}`); const cardUnits = screen.queryByTestId('subsection-card__units'); const newUnitButton = screen.queryByRole('button', { name: 'New unit' }); @@ -321,4 +326,51 @@ describe('', () => { libraryContentKey: containerKey, }); }); + + it('should sync subsection changes from upstream', async () => { + renderComponent(); + + expect(await screen.findByTestId('subsection-card-header')).toBeInTheDocument(); + + // Click on sync button + const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); + fireEvent.click(syncButton); + + // Should open compare preview modal + expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument(); + expect(screen.getByText('Preview not available')).toBeInTheDocument(); + + // Click on accept changes + const acceptChangesButton = screen.getByText(/accept changes/i); + fireEvent.click(acceptChangesButton); + + await waitFor(() => expect(mockUseAcceptLibraryBlockChanges).toHaveBeenCalled()); + }); + + it('should decline sync subsection changes from upstream', async () => { + renderComponent(); + + expect(await screen.findByTestId('subsection-card-header')).toBeInTheDocument(); + + // Click on sync button + const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); + fireEvent.click(syncButton); + + // Should open compare preview modal + expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument(); + expect(screen.getByText('Preview not available')).toBeInTheDocument(); + + // Click on ignore changes + const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i }); + fireEvent.click(ignoreChangesButton); + + // Should open the confirmation modal + expect(screen.getByRole('heading', { name: /ignore these changes\?/i })).toBeInTheDocument(); + + // Click on ignore button + const ignoreButton = screen.getByRole('button', { name: /ignore/i }); + fireEvent.click(ignoreButton); + + await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled()); + }); }); diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index c70952f63..5ceb79d83 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -1,10 +1,11 @@ import React, { - useContext, useEffect, useState, useRef, useCallback, ReactNode, + useContext, useEffect, useState, useRef, useCallback, ReactNode, useMemo, } from 'react'; import { useDispatch } from 'react-redux'; -import { useSearchParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { StandardModal, useToggle } from '@openedx/paragon'; +import { useQueryClient } from '@tanstack/react-query'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; @@ -16,6 +17,7 @@ import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; import { useClipboard, PasteComponent } from '@src/generic/clipboard'; import TitleButton from '@src/course-outline/card-header/TitleButton'; +import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils'; import { ComponentPicker, SelectedComponent } from '@src/library-authoring'; @@ -24,7 +26,9 @@ import { ContainerType } from '@src/generic/key-utils'; import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import { ContentType } from '@src/library-authoring/routes'; import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons'; +import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import type { XBlock } from '@src/data/types'; +import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import messages from './messages'; interface SubsectionCardProps { @@ -86,6 +90,7 @@ const SubsectionCard = ({ const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === subsection.id; const [isFormOpen, openForm, closeForm] = useToggle(false); + const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); const [ @@ -93,6 +98,8 @@ const SubsectionCard = ({ openAddLibraryUnitModal, closeAddLibraryUnitModal, ] = useToggle(false); + const { courseId } = useParams(); + const queryClient = useQueryClient(); const { id, @@ -108,6 +115,19 @@ const SubsectionCard = ({ upstreamInfo, } = subsection; + const blockSyncData = useMemo(() => { + if (!upstreamInfo?.readyToSync) { + return undefined; + } + return { + displayName, + downstreamBlockId: id, + upstreamBlockId: upstreamInfo.upstreamRef, + upstreamBlockVersionSynced: upstreamInfo.versionSynced, + isContainer: true, + }; + }, [upstreamInfo]); + // re-create actions object for customizations const actions = { ...subsectionActions }; // add actions to control display of move up & down menu button. @@ -148,6 +168,13 @@ const SubsectionCard = ({ dispatch(setCurrentItem(subsection)); }; + const handleOnPostChangeSync = useCallback(() => { + dispatch(fetchCourseSectionQuery([section.id])); + if (courseId) { + invalidateLinksQuery(queryClient, courseId); + } + }, [dispatch, section, queryClient, courseId]); + const handleEditSubmit = (titleValue: string) => { if (displayName !== titleValue) { onEditSubmit(id, section.id, titleValue); @@ -269,6 +296,7 @@ const SubsectionCard = ({ onClickMoveUp={handleSubsectionMoveUp} onClickMoveDown={handleSubsectionMoveDown} onClickConfigure={onOpenConfigureModal} + onClickSync={openSyncModal} isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} @@ -280,6 +308,7 @@ const SubsectionCard = ({ proctoringExamConfigurationLink={proctoringExamConfigurationLink} isSequential extraActionsComponent={extraActionsComponent} + readyToSync={upstreamInfo?.readyToSync} />
+ {blockSyncData && ( + + )} ); }; diff --git a/src/course-outline/unit-card/UnitCard.test.tsx b/src/course-outline/unit-card/UnitCard.test.tsx index e102c2b9d..490100855 100644 --- a/src/course-outline/unit-card/UnitCard.test.tsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -92,6 +92,10 @@ const renderComponent = (props?: object) => render( }} {...props} />, + { + path: '/course/:courseId', + params: { courseId: '5' }, + }, ); describe('', () => { diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 975844fdb..6119c180b 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -7,7 +7,8 @@ import { import { useDispatch } from 'react-redux'; import { useToggle } from '@openedx/paragon'; import { isEmpty } from 'lodash'; -import { useSearchParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; @@ -22,6 +23,7 @@ import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course import { useClipboard } from '@src/generic/clipboard'; import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; +import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; interface UnitCardProps { @@ -74,6 +76,8 @@ const UnitCard = ({ const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); + const { courseId } = useParams(); + const queryClient = useQueryClient(); const { id, @@ -155,7 +159,10 @@ const UnitCard = ({ const handleOnPostChangeSync = useCallback(() => { dispatch(fetchCourseSectionQuery([section.id])); - }, [dispatch, section]); + if (courseId) { + invalidateLinksQuery(queryClient, courseId); + } + }, [dispatch, section, queryClient, courseId]); const titleComponent = (