fix: review/sync bugs [FC-0083] (#1905) (#1941)

Fixes issues related to component libraries' review/sync flow

* Inconsistent sync pane title versions
* Library content shown in preview warning only appears in review changes modal when that modal is opened from the review tab
* Some new changes only appear within library review tab on scroll at top of list
* Vertically misaligned sync icon in review changes message on course outline
* Show available updates whenever content is updated, regardless of number of updates available
This commit is contained in:
Rômulo Penido
2025-05-12 16:42:34 -03:00
committed by GitHub
parent a162929fd7
commit fab786a6c6
22 changed files with 232 additions and 332 deletions

View File

@@ -118,6 +118,46 @@ describe('<CourseLibraries />', () => {
userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
});
it('show alert if max lastPublishedDate is greated than the local storage value', async () => {
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() - 1000),
);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
});
it('doesnt show alert if max lastPublishedDate is less than the local storage value', async () => {
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() + 1000),
);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
screen.logTestingPlaygroundURL();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
describe('<CourseLibraries ReviewTab />', () => {
@@ -160,7 +200,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('update changes works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
@@ -176,7 +216,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('update changes works in preview modal', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
@@ -195,7 +235,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('ignore change works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
@@ -218,7 +258,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('ignore change works in preview', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });

View File

@@ -164,7 +164,7 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
if (tabKey !== CourseLibraryTabs.review) {
return null;
}
if (!outOfSyncCount || outOfSyncCount === 0) {
if (!outOfSyncCount) {
return (
<Stack direction="horizontal" gap={2}>
<Icon src={CheckCircle} size="xs" />

View File

@@ -18,12 +18,11 @@ interface OutOfSyncAlertProps {
* in course can be updated. Following are the conditions for displaying the alert.
*
* * The alert is displayed if components are out of sync.
* * If the user clicks on dismiss button, the state is stored in localstorage of user
* in this format: outOfSyncCountAlert-${courseId} = <number of out of sync components>.
* * If the number of sync components don't change for the course and the user opens outline
* * If the user clicks on dismiss button, the state of dismiss is stored in localstorage of user
* in this format: outOfSyncCountAlert-${courseId} = <datetime value in milliseconds>.
* * If there are not new published components for the course and the user opens outline
* in the same browser, they don't see the alert again.
* * If the number changes, i.e., if a new component is out of sync or the user updates or ignores
* a component, the alert is displayed again.
* * If there is a new published component upstream, the alert is displayed again.
*/
export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
showAlert,
@@ -35,6 +34,8 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
const intl = useIntl();
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
.reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0);
const alertKey = `outOfSyncCountAlert-${courseId}`;
useEffect(() => {
@@ -46,13 +47,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
setShowAlert(false);
return;
}
const dismissedAlert = localStorage.getItem(alertKey);
setShowAlert(parseInt(dismissedAlert || '', 10) !== outOfSyncCount);
}, [outOfSyncCount, isLoading, data]);
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
const dismissAlert = () => {
setShowAlert(false);
localStorage.setItem(alertKey, String(outOfSyncCount));
localStorage.setItem(alertKey, Date.now().toString());
onDismiss?.();
};

View File

@@ -1,5 +1,5 @@
import React, {
useCallback, useContext, useEffect, useMemo, useState,
useCallback, useContext, useMemo, useState,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -16,7 +16,7 @@ import {
import { tail, keyBy } from 'lodash';
import { useQueryClient } from '@tanstack/react-query';
import { Loop, Warning } from '@openedx/paragon/icons';
import { Loop } from '@openedx/paragon/icons';
import messages from './messages';
import previewChangesMessages from '../course-unit/preview-changes/messages';
import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks';
@@ -35,7 +35,6 @@ import { useLoadOnScroll } from '../hooks';
import DeleteModal from '../generic/delete-modal/DeleteModal';
import { PublishableEntityLink } from './data/api';
import AlertError from '../generic/alert-error';
import AlertMessage from '../generic/alert-message';
interface Props {
courseId: string;
@@ -100,10 +99,8 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
const ComponentReviewList = ({
outOfSyncComponents,
onSearchUpdate,
}: {
outOfSyncComponents: PublishableEntityLink[];
onSearchUpdate: () => void;
}) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
@@ -111,23 +108,15 @@ const ComponentReviewList = ({
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const {
hits: downstreamInfo,
hits,
isLoading: isIndexDataLoading,
searchKeywords,
hasError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useSearchContext() as {
hits: ContentHit[];
isLoading: boolean;
searchKeywords: string;
searchSortOrder: SearchSortOption;
hasError: boolean;
hasNextPage: boolean | undefined,
isFetchingNextPage: boolean;
fetchNextPage: () => void;
};
} = useSearchContext();
const downstreamInfo = hits as ContentHit[];
useLoadOnScroll(
hasNextPage,
@@ -142,18 +131,12 @@ const ComponentReviewList = ({
);
const queryClient = useQueryClient();
useEffect(() => {
if (searchKeywords) {
onSearchUpdate();
}
}, [searchKeywords]);
// Toggle preview changes modal
const [isModalOpen, openModal, closeModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const setSeletecdBlockData = (info: ContentHit) => {
const setSelectedBlockData = useCallback((info: ContentHit) => {
setBlockData({
displayName: info.displayName,
downstreamBlockId: info.usageKey,
@@ -161,17 +144,18 @@ const ComponentReviewList = ({
upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced,
isVertical: info.blockType === 'vertical',
});
};
}, [outOfSyncComponentsByKey]);
// Show preview changes on review
const onReview = useCallback((info: ContentHit) => {
setSeletecdBlockData(info);
setSelectedBlockData(info);
openModal();
}, [setSeletecdBlockData, openModal]);
}, [setSelectedBlockData, openModal]);
const onIgnoreClick = useCallback((info: ContentHit) => {
setSeletecdBlockData(info);
setSelectedBlockData(info);
openConfirmModal();
}, [setSeletecdBlockData, openConfirmModal]);
}, [setSelectedBlockData, openConfirmModal]);
const reloadLinks = useCallback((usageKey: string) => {
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
@@ -273,20 +257,14 @@ const ComponentReviewList = ({
)}
/>
))}
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={postChange}
alertNode={(
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
)}
/>
{blockData && (
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={postChange}
/>
)}
<DeleteModal
isOpen={isConfirmModalOpen}
close={closeConfirmModal}
@@ -303,37 +281,17 @@ const ComponentReviewList = ({
const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl();
const {
data: linkPages,
data: outOfSyncComponents,
isLoading: isSyncComponentsLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isError,
error,
} = useEntityLinks({ courseId, readyToSync: true });
const outOfSyncComponents = useMemo(
() => linkPages?.pages?.reduce((links, page) => [...links, ...page.results], []) ?? [],
[linkPages],
);
const downstreamKeys = useMemo(
() => outOfSyncComponents?.map(link => link.downstreamUsageKey),
[outOfSyncComponents],
);
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
true,
);
const onSearchUpdate = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const disableSortOptions = [
SearchSortOption.RELEVANCE,
SearchSortOption.OLDEST,
@@ -364,7 +322,6 @@ const ReviewTabContent = ({ courseId }: Props) => {
</ActionRow>
<ComponentReviewList
outOfSyncComponents={outOfSyncComponents}
onSearchUpdate={onSearchUpdate}
/>
</SearchContextProvider>
);

View File

@@ -3,17 +3,20 @@
"upstreamContextTitle": "CS problems 3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"readyToSyncCount": 5,
"totalCount": 14
"totalCount": 14,
"lastPublishedAt": "2025-05-01T20:20:44.989042Z"
},
{
"upstreamContextTitle": "CS problems 2",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"readyToSyncCount": 0,
"totalCount": 21
"totalCount": 21,
"lastPublishedAt": "2025-05-01T21:20:44.989042Z"
},
{
"upstreamContextTitle": "CS problems",
"upstreamContextKey": "lib:OpenedX:CSPROB",
"totalCount": 3
"totalCount": 3,
"lastPublishedAt": "2025-05-01T22:20:44.989042Z"
}
]

View File

@@ -1,79 +1,72 @@
{
"count": 7,
"next": null,
"previous": null,
"num_pages": 1,
"current_page": 1,
"results": [
{
"id": 875,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 876,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 884,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 26,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 16,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 889,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 890,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
}
]
}
[
{
"id": 875,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 876,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 884,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 26,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 16,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 889,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 890,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
}
]

View File

@@ -28,27 +28,17 @@ export async function mockGetEntityLinks(
case mockGetEntityLinks.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinks.courseKeyEmpty:
return Promise.resolve({
next: null,
previous: null,
nextPageNum: null,
previousPageNum: null,
count: 0,
numPages: 0,
currentPage: 0,
results: [],
});
return Promise.resolve([]);
default: {
const { response } = mockGetEntityLinks;
let { response } = mockGetEntityLinks;
if (readyToSync !== undefined) {
response.results = response.results.filter((o) => o.readyToSync === readyToSync);
response.count = response.results.length;
response = response.filter((o) => o.readyToSync === readyToSync);
}
return Promise.resolve(response);
}
}
}
mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey;
mockGetEntityLinks.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinks.invalidCourseKey = 'course_key_error';
mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty';
@@ -85,7 +75,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext(
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response);
}
}
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey;
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';

View File

@@ -38,32 +38,13 @@ export interface PublishableEntityLinkSummary {
upstreamContextTitle: string;
readyToSyncCount: number;
totalCount: number;
lastPublishedAt: string;
}
export const getEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageParam?: number,
pageSize?: number,
): Promise<PaginatedData<PublishableEntityLink[]>> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {
params: {
course_id: downstreamContextKey,
ready_to_sync: readyToSync,
upstream_usage_key: upstreamUsageKey,
page_size: pageSize,
page: pageParam,
},
});
return camelCaseObject(data);
};
export const getUnpaginatedEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
): Promise<PublishableEntityLink[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {

View File

@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { renderHook, waitFor } from '@testing-library/react';
import { getEntityLinksByDownstreamContextUrl } from './api';
import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks';
import { useEntityLinks } from './apiHooks';
let axiosMock: MockAdapter;
@@ -39,26 +39,11 @@ describe('course libraries api hooks', () => {
axiosMock.reset();
});
it('should return paginated links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
const expectedResult = {
next: null, results: [], previous: null, total: 0,
};
axiosMock.onGet(url).reply(200, expectedResult);
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data?.pages).toEqual([expectedResult]);
expect(axiosMock.history.get[0].url).toEqual(url);
});
it('should return links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useUnpaginatedEntityLinks({ courseId }), { wrapper });
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});

View File

@@ -1,8 +1,7 @@
import {
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
export const courseLibrariesQueryKeys = {
all: ['courseLibraries'],
@@ -29,39 +28,10 @@ export const courseLibrariesQueryKeys = {
};
/**
* Hook to fetch publishable entity links by course key.
* Hook to fetch list of publishable entity links by course key.
* (That is, get a list of the library components used in the given course.)
*/
export const useEntityLinks = ({
courseId, readyToSync, upstreamUsageKey, pageSize,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageSize?: number
}) => (
useInfiniteQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
courseId,
readyToSync,
upstreamUsageKey,
}),
queryFn: ({ pageParam }) => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,
pageParam,
pageSize,
),
getNextPageParam: (lastPage) => lastPage.nextPageNum,
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
})
);
/**
* Hook to fetch unpaginated list of publishable entity links by course key.
*/
export const useUnpaginatedEntityLinks = ({
courseId, readyToSync, upstreamUsageKey,
}: {
courseId?: string,
@@ -74,7 +44,7 @@ export const useUnpaginatedEntityLinks = ({
readyToSync,
upstreamUsageKey,
}),
queryFn: () => getUnpaginatedEntityLinks(
queryFn: () => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,

View File

@@ -116,11 +116,6 @@ const messages = defineMessages({
defaultMessage: 'Something went wrong! Could not fetch results.',
description: 'Generic error message displayed when fetching link data fails.',
},
olderVersionPreviewAlert: {
id: 'course-authoring.course-libraries.reviw-tab.preview.old-version-alert',
defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that older version in preview is of library block',
},
});
export default messages;

View File

@@ -229,12 +229,14 @@ const UnitCard = ({
</div>
</div>
</SortableItem>
<PreviewLibraryXBlockChanges
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={handleOnPostChangeSync}
/>
{blockSyncData && (
<PreviewLibraryXBlockChanges
blockData={blockSyncData}
isModalOpen={isSyncModalOpen}
closeModal={closeSyncModal}
postChange={handleOnPostChangeSync}
/>
)}
</>
);
};

View File

@@ -12,7 +12,6 @@ import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.'
import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
import { getLibraryBlockMetadataUrl, getLibraryContainerApiUrl } from '../../library-authoring/data/api';
const usageKey = 'some-id';
const defaultEventData: LibraryChangesMessageData = {
@@ -66,7 +65,7 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
});
it('renders displayName for units', async () => {
it('renders default displayName for units with no displayName', async () => {
render({ ...defaultEventData, isVertical: true, displayName: '' });
expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument();
@@ -78,24 +77,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByText('Preview changes: Component')).toBeInTheDocument();
});
it('renders both new and old title if they are different', async () => {
axiosMock.onGet(getLibraryBlockMetadataUrl(defaultEventData.upstreamBlockId)).reply(200, {
displayName: 'New test block',
});
render();
expect(await screen.findByText('Preview changes: Test block -> New test block')).toBeInTheDocument();
});
it('renders both new and old title if they are different on units', async () => {
axiosMock.onGet(getLibraryContainerApiUrl(defaultEventData.upstreamBlockId)).reply(200, {
displayName: 'New test Unit',
});
render({ ...defaultEventData, isVertical: true, displayName: 'Test Unit' });
expect(await screen.findByText('Preview changes: Test Unit -> New test Unit')).toBeInTheDocument();
});
it('accept changes works', async () => {
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
render();

View File

@@ -1,20 +1,21 @@
import React, { useCallback, useContext, useState } from 'react';
import { useCallback, useContext, useState } from 'react';
import {
ActionRow, Button, ModalDialog, useToggle,
} from '@openedx/paragon';
import { Warning } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useEventListener } from '../../generic/hooks';
import { messageTypes } from '../constants';
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import AlertMessage from '../../generic/alert-message';
import { useIframe } from '../../generic/hooks/context/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import Loading from '../../generic/Loading';
import { useContainer, useLibraryBlockMetadata } from '../../library-authoring/data/apiHooks';
export interface LibraryChangesMessageData {
displayName: string,
@@ -25,11 +26,10 @@ export interface LibraryChangesMessageData {
}
export interface PreviewLibraryXBlockChangesProps {
blockData?: LibraryChangesMessageData,
blockData: LibraryChangesMessageData,
isModalOpen: boolean,
closeModal: () => void,
postChange: (accept: boolean) => void,
alertNode?: React.ReactNode,
}
/**
@@ -41,7 +41,6 @@ export const PreviewLibraryXBlockChanges = ({
isModalOpen,
closeModal,
postChange,
alertNode,
}: PreviewLibraryXBlockChangesProps) => {
const { showToast } = useContext(ToastContext);
const intl = useIntl();
@@ -49,32 +48,9 @@ export const PreviewLibraryXBlockChanges = ({
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
// TODO: Split into two different components to avoid making these two calls in which
// one will definitely fail
const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId);
const { data: unitMetadata } = useContainer(blockData?.upstreamBlockId);
const metadata = blockData?.isVertical ? unitMetadata : componentMetadata;
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const getTitle = useCallback(() => {
const oldName = blockData?.displayName;
const newName = metadata?.displayName;
if (!oldName) {
if (blockData?.isVertical) {
return intl.formatMessage(messages.defaultUnitTitle);
}
return intl.formatMessage(messages.defaultComponentTitle);
}
if (oldName === newName || !newName) {
return intl.formatMessage(messages.title, { blockTitle: oldName });
}
return intl.formatMessage(messages.diffTitle, { oldName, newName });
}, [blockData, metadata]);
const getBody = useCallback(() => {
if (!blockData) {
return <Loading />;
@@ -108,12 +84,21 @@ export const PreviewLibraryXBlockChanges = ({
}
}, [blockData]);
const defaultTitle = intl.formatMessage(
blockData.isVertical
? messages.defaultUnitTitle
: messages.defaultComponentTitle,
);
const title = blockData.displayName
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
: defaultTitle;
return (
<ModalDialog
isOpen={isModalOpen}
onClose={closeModal}
size="xl"
title={getTitle()}
title={title}
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
@@ -121,11 +106,16 @@ export const PreviewLibraryXBlockChanges = ({
>
<ModalDialog.Header>
<ModalDialog.Title>
{getTitle()}
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{alertNode}
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
@@ -186,6 +176,10 @@ const IframePreviewLibraryXBlockChanges = () => {
useEventListener('message', receiveMessage);
if (!blockData) {
return null;
}
return (
<PreviewLibraryXBlockChanges
blockData={blockData}

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Preview changes: {blockTitle}',
description: 'Preview changes modal title text',
},
diffTitle: {
id: 'authoring.course-unit.preview-changes.modal-diff-title',
defaultMessage: 'Preview changes: {oldName} -> {newName}',
description: 'Preview changes modal title text',
},
defaultUnitTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: Unit',
@@ -61,6 +56,11 @@ const messages = defineMessages({
defaultMessage: 'Ignore',
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
},
olderVersionPreviewAlert: {
id: 'course-authoring.review-tab.preview.old-version-alert',
defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that older version in preview is of library block',
},
});
export default messages;

View File

@@ -0,0 +1,6 @@
// TODO: remove this after upstream fix merging: https://github.com/openedx/paragon/pull/3562
.alert {
.alert-message-content {
align-self: baseline;
}
}

View File

@@ -12,4 +12,5 @@
@import "./modal-dropzone/ModalDropzone";
@import "./configure-modal/ConfigureModal";
@import "./block-type-utils";
@import "./modal-iframe"
@import "./modal-iframe";
@import "./alert-message";

View File

@@ -8,7 +8,7 @@ import {
import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock';
import {
mockContentLibrary,
mockGetUnpaginatedEntityLinks,
mockGetEntityLinks,
mockLibraryBlockMetadata,
mockXBlockAssets,
mockXBlockOLX,
@@ -21,7 +21,7 @@ mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockXBlockAssets.applyMock();
mockXBlockOLX.applyMock();
mockGetUnpaginatedEntityLinks.applyMock();
mockGetEntityLinks.applyMock();
mockFetchIndexDocuments.applyMock();
const render = (usageKey: string) => baseRender(<ComponentDetails />, {

View File

@@ -7,7 +7,7 @@ import {
import {
mockContentLibrary,
mockLibraryBlockMetadata,
mockGetUnpaginatedEntityLinks,
mockGetEntityLinks,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
@@ -18,7 +18,7 @@ import { getXBlockPublishApiUrl } from '../data/api';
mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockGetUnpaginatedEntityLinks.applyMock();
mockGetEntityLinks.applyMock();
mockFetchIndexDocuments.applyMock();
jest.mock('./ComponentPreview', () => ({
__esModule: true, // Required when mocking 'default' export

View File

@@ -2,7 +2,7 @@ import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Collapsible, Hyperlink, Stack } from '@openedx/paragon';
import { useMemo } from 'react';
import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks';
import { useEntityLinks } from '../../course-libraries/data/apiHooks';
import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading';
@@ -34,7 +34,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
isError: isErrorDownstreamLinks,
error: errorDownstreamLinks,
isLoading: isLoadingDownstreamLinks,
} = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey });
} = useEntityLinks({ upstreamUsageKey: usageKey });
const downstreamKeys = useMemo(
() => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [],

View File

@@ -5,7 +5,7 @@ import BaseModal from '../../editors/sharedComponents/BaseModal';
import messages from './messages';
import infoMessages from '../component-info/messages';
import { ComponentUsage } from '../component-info/ComponentUsage';
import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks';
import { useEntityLinks } from '../../course-libraries/data/apiHooks';
interface PublishConfirmationModalProps {
isOpen: boolean,
@@ -29,7 +29,7 @@ const PublishConfirmationModal = ({
const {
data: dataDownstreamLinks,
isLoading: isLoadingDownstreamLinks,
} = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey });
} = useEntityLinks({ upstreamUsageKey: usageKey });
const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0;

View File

@@ -675,12 +675,12 @@ mockBlockTypesMetadata.blockTypesMetadata = [
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockBlockTypesMetadata.applyMock = () => jest.spyOn(api, 'getBlockTypes').mockImplementation(mockBlockTypesMetadata);
export async function mockGetUnpaginatedEntityLinks(
export async function mockGetEntityLinks(
_downstreamContextKey?: string,
_readyToSync?: boolean,
upstreamUsageKey?: string,
): ReturnType<typeof courseLibApi.getUnpaginatedEntityLinks> {
const thisMock = mockGetUnpaginatedEntityLinks;
): ReturnType<typeof courseLibApi.getEntityLinks> {
const thisMock = mockGetEntityLinks;
switch (upstreamUsageKey) {
case thisMock.upstreamUsageKey: return thisMock.response;
case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response;
@@ -688,8 +688,8 @@ export async function mockGetUnpaginatedEntityLinks(
default: return [];
}
}
mockGetUnpaginatedEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished;
mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({
mockGetEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished;
mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({
id: 875,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 10,
@@ -703,10 +703,10 @@ mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map(
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
}));
mockGetUnpaginatedEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty';
mockGetUnpaginatedEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[];
mockGetEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty';
mockGetEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[];
mockGetUnpaginatedEntityLinks.applyMock = () => jest.spyOn(
mockGetEntityLinks.applyMock = () => jest.spyOn(
courseLibApi,
'getUnpaginatedEntityLinks',
).mockImplementation(mockGetUnpaginatedEntityLinks);
'getEntityLinks',
).mockImplementation(mockGetEntityLinks);