From 515f9b124bc77a1084770b94fde698a69216ff47 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 23 Jul 2025 16:07:31 -0500 Subject: [PATCH] feat: show sync button on section/subsections --- .../section-card/SectionCard.test.tsx | 60 +++++++++++++++++- .../section-card/SectionCard.tsx | 35 ++++++++++- .../subsection-card/SubsectionCard.test.tsx | 61 ++++++++++++++++++- .../subsection-card/SubsectionCard.tsx | 35 ++++++++++- 4 files changed, 185 insertions(+), 6 deletions(-) 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 && ( + + )} ); };