diff --git a/src/course-outline/section-card/SectionCard.test.tsx b/src/course-outline/section-card/SectionCard.test.tsx
index ca5973b540..ebcf3977ec 100644
--- a/src/course-outline/section-card/SectionCard.test.tsx
+++ b/src/course-outline/section-card/SectionCard.test.tsx
@@ -1,10 +1,21 @@
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('@src/course-unit/data/apiHooks', () => ({
+ useAcceptLibraryBlockChanges: () => ({
+ mutateAsync: mockUseAcceptLibraryBlockChanges,
+ }),
+ useIgnoreLibraryBlockChanges: () => ({
+ mutateAsync: mockUseIgnoreLibraryBlockChanges,
+ }),
+}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -213,4 +224,51 @@ describe('', () => {
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 7d0abade12..3a6a4c089a 100644
--- a/src/course-outline/section-card/SectionCard.tsx
+++ b/src/course-outline/section-card/SectionCard.tsx
@@ -1,5 +1,5 @@
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';
@@ -17,6 +17,7 @@ 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';
@@ -24,6 +25,7 @@ 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 { XBlock } from '@src/data/types';
+import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
import messages from './messages';
interface SectionCardProps {
@@ -107,6 +109,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(() => {
@@ -123,8 +126,22 @@ const SectionCard = ({
highlights,
actions: sectionActions,
isHeaderVisible = true,
+ upstreamInfo,
} = section;
+ const blockSyncData = useMemo(() => {
+ if (!upstreamInfo?.readyToSync) {
+ return undefined;
+ }
+ return {
+ displayName,
+ downstreamBlockId: id,
+ upstreamBlockId: upstreamInfo.upstreamRef,
+ upstreamBlockVersionSynced: upstreamInfo.versionSynced,
+ isVertical: true,
+ };
+ }, [upstreamInfo]);
+
useEffect(() => {
if (activeId === id && isExpanded) {
setIsExpanded(false);
@@ -148,6 +165,10 @@ const SectionCard = ({
setIsExpanded((prevState) => containsSearchResult() || prevState);
}, [locatorId, setIsExpanded]);
+ const handleOnPostChangeSync = useCallback(() => {
+ dispatch(fetchCourseSectionQuery([section.id]));
+ }, [dispatch, section]);
+
// re-create actions object for customizations
const actions = { ...sectionActions };
// add actions to control display of move up & down menu buton.
@@ -225,7 +246,7 @@ const SectionCard = ({
isExpanded={isExpanded}
onTitleClick={handleExpandContent}
namePrefix={namePrefix}
- prefixIcon={!!section.upstreamInfo?.upstreamRef && (
+ prefixIcon={!!upstreamInfo?.upstreamRef && (
)}
/>
@@ -264,6 +285,7 @@ const SectionCard = ({
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleSectionMoveUp}
onClickMoveDown={handleSectionMoveDown}
+ onClickSync={openSyncModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
@@ -272,6 +294,7 @@ const SectionCard = ({
titleComponent={titleComponent}
namePrefix={namePrefix}
actions={actions}
+ readyToSync={upstreamInfo?.readyToSync}
/>
)}
@@ -327,6 +350,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 efa429faf4..dd64ca1a55 100644
--- a/src/course-outline/subsection-card/SubsectionCard.test.tsx
+++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx
@@ -1,6 +1,6 @@
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';
@@ -11,6 +11,18 @@ const mockPathname = '/foo-bar';
const containerKey = 'lct:org:lib:unit:1';
const handleOnAddUnitFromLibrary = jest.fn();
+const mockUseAcceptLibraryBlockChanges = jest.fn();
+const mockUseIgnoreLibraryBlockChanges = jest.fn();
+
+jest.mock('@src/course-unit/data/apiHooks', () => ({
+ useAcceptLibraryBlockChanges: () => ({
+ mutateAsync: mockUseAcceptLibraryBlockChanges,
+ }),
+ useIgnoreLibraryBlockChanges: () => ({
+ mutateAsync: mockUseIgnoreLibraryBlockChanges,
+ }),
+}));
+
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
@@ -290,4 +302,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 5d6e04b948..881206e63b 100644
--- a/src/course-outline/subsection-card/SubsectionCard.tsx
+++ b/src/course-outline/subsection-card/SubsectionCard.tsx
@@ -1,5 +1,5 @@
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';
@@ -17,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';
@@ -25,6 +26,7 @@ import { ContainerType } from '@src/generic/key-utils';
import { ContentType } from '@src/library-authoring/routes';
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
import { XBlock } from '@src/data/types';
+import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
import messages from './messages';
interface SubsectionCardProps {
@@ -86,6 +88,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 [
@@ -105,8 +108,22 @@ const SubsectionCard = ({
isHeaderVisible = true,
enableCopyPasteUnits = false,
proctoringExamConfigurationLink,
+ upstreamInfo,
} = subsection;
+ const blockSyncData = useMemo(() => {
+ if (!upstreamInfo?.readyToSync) {
+ return undefined;
+ }
+ return {
+ displayName,
+ downstreamBlockId: id,
+ upstreamBlockId: upstreamInfo.upstreamRef,
+ upstreamBlockVersionSynced: upstreamInfo.versionSynced,
+ isVertical: true,
+ };
+ }, [upstreamInfo]);
+
// re-create actions object for customizations
const actions = { ...subsectionActions };
// add actions to control display of move up & down menu button.
@@ -145,6 +162,10 @@ const SubsectionCard = ({
dispatch(setCurrentItem(subsection));
};
+ const handleOnPostChangeSync = useCallback(() => {
+ dispatch(fetchCourseSectionQuery([section.id]));
+ }, [dispatch, section]);
+
const handleEditSubmit = (titleValue: string) => {
if (displayName !== titleValue) {
onEditSubmit(id, section.id, titleValue);
@@ -171,7 +192,7 @@ const SubsectionCard = ({
isExpanded={isExpanded}
onTitleClick={handleExpandContent}
namePrefix={namePrefix}
- prefixIcon={!!subsection.upstreamInfo?.upstreamRef && (
+ prefixIcon={!!upstreamInfo?.upstreamRef && (
)}
/>
@@ -262,6 +283,7 @@ const SubsectionCard = ({
onClickMoveUp={handleSubsectionMoveUp}
onClickMoveDown={handleSubsectionMoveDown}
onClickConfigure={onOpenConfigureModal}
+ onClickSync={openSyncModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
@@ -273,6 +295,7 @@ const SubsectionCard = ({
proctoringExamConfigurationLink={proctoringExamConfigurationLink}
isSequential
extraActionsComponent={extraActionsComponent}
+ readyToSync={upstreamInfo?.readyToSync}
/>
+ {blockSyncData && (
+
+ )}
>
);
};