feat: show sync button on section/subsections [FC-0097] (#2324)

- Adds the sync button on section/subsection cards
This commit is contained in:
Chris Chávez
2025-08-21 16:38:16 -05:00
committed by GitHub
parent 8e680dc8d4
commit 0c88fd6da9
9 changed files with 228 additions and 30 deletions

View File

@@ -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) => {

View File

@@ -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<T> {

View File

@@ -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),
});
};

View File

@@ -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(
<SectionCard
section={section}
index={1}
@@ -98,7 +101,8 @@ const renderComponent = (props?: object, entry = '/') => render(
<span>children</span>
</SectionCard>,
{
path: '/',
path: '/course/:courseId',
params: { courseId: '5' },
routerProps: {
initialEntries: [entry],
},
@@ -182,7 +186,7 @@ describe('<SectionCard />', () => {
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('<SectionCard />', () => {
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('<SectionCard />', () => {
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());
});
});

View File

@@ -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}
/>
)}
<div className="section-card__content" data-testid="section-card__content">
@@ -330,6 +359,14 @@ const SectionCard = ({
visibleTabs={[ContentType.subsections]}
/>
</StandardModal>
{blockSyncData && (
<PreviewLibraryXBlockChanges
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={handleOnPostChangeSync}
/>
)}
</>
);
};

View File

@@ -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(
<SubsectionCard
section={section}
subsection={subsection}
@@ -122,7 +126,8 @@ const renderComponent = (props?: object, entry = '/') => render(
<span>children</span>
</SubsectionCard>,
{
path: '/',
path: '/course/:courseId',
params: { courseId: '5' },
routerProps: {
initialEntries: [entry],
},
@@ -277,7 +282,7 @@ describe('<SubsectionCard />', () => {
});
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('<SubsectionCard />', () => {
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('<SubsectionCard />', () => {
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());
});
});

View File

@@ -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}
/>
<div className="subsection-card__content item-children" data-testid="subsection-card__content">
<XBlockStatus
@@ -332,6 +361,14 @@ const SubsectionCard = ({
visibleTabs={[ContentType.units]}
/>
</StandardModal>
{blockSyncData && (
<PreviewLibraryXBlockChanges
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={handleOnPostChangeSync}
/>
)}
</>
);
};

View File

@@ -92,6 +92,10 @@ const renderComponent = (props?: object) => render(
}}
{...props}
/>,
{
path: '/course/:courseId',
params: { courseId: '5' },
},
);
describe('<UnitCard />', () => {

View File

@@ -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 = (
<TitleLink