Skip to content
5 changes: 3 additions & 2 deletions src/course-libraries/ReviewTabContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { useEntityLinks } from './data/apiHooks';
import {
SearchContextProvider, SearchKeywordsField, useSearchContext, BlockTypeLabel, Highlight, SearchSortWidget,
} from '../search-manager';
Expand All @@ -36,6 +36,7 @@ import DeleteModal from '../generic/delete-modal/DeleteModal';
import { PublishableEntityLink } from './data/api';
import AlertError from '../generic/alert-error';
import NewsstandIcon from '../generic/NewsstandIcon';
import { invalidateLinksQuery } from './utils';

interface Props {
courseId: string;
Expand Down Expand Up @@ -189,7 +190,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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/course-libraries/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
11 changes: 11 additions & 0 deletions src/course-libraries/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { type QueryClient } from '@tanstack/react-query';
import { courseLibrariesQueryKeys } from './data/apiHooks';

/**
* Ivalidates the downstream links query for a course
*/
export const invalidateLinksQuery = (queryClient: QueryClient, courseId: string) => {
queryClient.invalidateQueries({
queryKey: courseLibrariesQueryKeys.courseLibraries(courseId),
});
};
63 changes: 62 additions & 1 deletion src/course-outline/section-card/SectionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
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'),
useLocation: () => ({
pathname: mockPathname,
}),
useParams: () => ({
courseId: '5',
}),
}));

const unit = {
Expand Down Expand Up @@ -213,4 +227,51 @@ describe('<SectionCard />', () => {
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());
});
});
41 changes: 39 additions & 2 deletions src/course-outline/section-card/SectionCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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/utils';
import messages from './messages';

interface SectionCardProps {
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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);
Expand All @@ -149,6 +169,13 @@ const SectionCard = ({
setIsExpanded((prevState) => containsSearchResult() || prevState);
}, [locatorId, setIsExpanded]);

const handleOnPostChangeSync = useCallback(() => {
dispatch(fetchCourseSectionQuery([section.id]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to invalidate course libraries query after syncing changes. Same for Subsection and unit cards.

Vice versa, we need to refetch course section when the sync is performed from course libraries page.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to invalidate course libraries query after syncing changes. Same for Subsection and unit cards.

Updated here: 6ebf564
That updates the counter in the warning.

Vice versa, we need to refetch course section when the sync is performed from course libraries page.

I'm not sure this is necessary. Does the course libraries page display any course information?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the course libraries page display any course information?

No, just wanted to make sure that the outline is updated and the sync button from the outline is removed. But it seems like the outline is refresh every time, so not needed.

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.
Expand Down Expand Up @@ -267,6 +294,7 @@ const SectionCard = ({
onClickDelete={onOpenDeleteModal}
onClickMoveUp={handleSectionMoveUp}
onClickMoveDown={handleSectionMoveDown}
onClickSync={openSyncModal}
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
Expand All @@ -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">
Expand Down Expand Up @@ -330,6 +359,14 @@ const SectionCard = ({
visibleTabs={[ContentType.subsections]}
/>
</StandardModal>
{blockSyncData && (
<PreviewLibraryXBlockChanges
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={handleOnPostChangeSync}
/>
)}
</>
);
};
Expand Down
64 changes: 63 additions & 1 deletion src/course-outline/subsection-card/SubsectionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,11 +11,26 @@ 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: () => ({
pathname: mockPathname,
}),
useParams: () => ({
courseId: '5',
}),
}));

jest.mock('react-redux', () => ({
Expand Down Expand Up @@ -320,4 +335,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());
});
});
Loading