diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx
index 5df2fe313..4c4414424 100644
--- a/src/course-libraries/CourseLibraries.test.tsx
+++ b/src/course-libraries/CourseLibraries.test.tsx
@@ -118,6 +118,46 @@ describe('', () => {
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('', () => {
@@ -160,7 +200,7 @@ describe('', () => {
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('', () => {
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('', () => {
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('', () => {
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' });
diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx
index 3c1a0730f..2d0cfadf0 100644
--- a/src/course-libraries/CourseLibraries.tsx
+++ b/src/course-libraries/CourseLibraries.tsx
@@ -164,7 +164,7 @@ export const CourseLibraries: React.FC = ({ courseId }) => {
if (tabKey !== CourseLibraryTabs.review) {
return null;
}
- if (!outOfSyncCount || outOfSyncCount === 0) {
+ if (!outOfSyncCount) {
return (
diff --git a/src/course-libraries/OutOfSyncAlert.tsx b/src/course-libraries/OutOfSyncAlert.tsx
index 27b88f2c8..da36c4086 100644
--- a/src/course-libraries/OutOfSyncAlert.tsx
+++ b/src/course-libraries/OutOfSyncAlert.tsx
@@ -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} = .
-* * 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} = .
+* * 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 = ({
showAlert,
@@ -35,6 +34,8 @@ export const OutOfSyncAlert: React.FC = ({
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 = ({
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?.();
};
diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx
index 0c22815a3..36634335b 100644
--- a/src/course-libraries/ReviewTabContent.tsx
+++ b/src/course-libraries/ReviewTabContent.tsx
@@ -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 = ({ 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 = ({
)}
/>
))}
-
- )}
- />
+ {blockData && (
+
+ )}
{
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) => {
);
diff --git a/src/course-libraries/__mocks__/linkCourseSummary.json b/src/course-libraries/__mocks__/linkCourseSummary.json
index 32e98e898..05039086d 100644
--- a/src/course-libraries/__mocks__/linkCourseSummary.json
+++ b/src/course-libraries/__mocks__/linkCourseSummary.json
@@ -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"
}
]
diff --git a/src/course-libraries/__mocks__/publishableEntityLinks.json b/src/course-libraries/__mocks__/publishableEntityLinks.json
index 9988dee71..1dac4b2db 100644
--- a/src/course-libraries/__mocks__/publishableEntityLinks.json
+++ b/src/course-libraries/__mocks__/publishableEntityLinks.json
@@ -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"
+ }
+]
diff --git a/src/course-libraries/data/api.mocks.ts b/src/course-libraries/data/api.mocks.ts
index 1f4d0e5ba..3614a5151 100644
--- a/src/course-libraries/data/api.mocks.ts
+++ b/src/course-libraries/data/api.mocks.ts
@@ -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';
diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts
index 4dd04c9bd..af8108c53 100644
--- a/src/course-libraries/data/api.ts
+++ b/src/course-libraries/data/api.ts
@@ -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> => {
- 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 => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {
diff --git a/src/course-libraries/data/apiHooks.test.tsx b/src/course-libraries/data/apiHooks.test.tsx
index b46f87c3f..f1063ce80 100644
--- a/src/course-libraries/data/apiHooks.test.tsx
+++ b/src/course-libraries/data/apiHooks.test.tsx
@@ -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();
});
diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts
index 2d6b0c044..093a63121 100644
--- a/src/course-libraries/data/apiHooks.ts
+++ b/src/course-libraries/data/apiHooks.ts
@@ -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,
diff --git a/src/course-libraries/messages.ts b/src/course-libraries/messages.ts
index 803084f16..8dc7ab098 100644
--- a/src/course-libraries/messages.ts
+++ b/src/course-libraries/messages.ts
@@ -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;
diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx
index 82df0d28f..25c9bfd5b 100644
--- a/src/course-outline/unit-card/UnitCard.jsx
+++ b/src/course-outline/unit-card/UnitCard.jsx
@@ -229,12 +229,14 @@ const UnitCard = ({
-
+ {blockSyncData && (
+
+ )}
>
);
};
diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx
index d28f02575..f88b436c7 100644
--- a/src/course-unit/preview-changes/index.test.tsx
+++ b/src/course-unit/preview-changes/index.test.tsx
@@ -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('', () => {
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('', () => {
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();
diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx
index 157aaeab0..c038adf08 100644
--- a/src/course-unit/preview-changes/index.tsx
+++ b/src/course-unit/preview-changes/index.tsx
@@ -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 ;
@@ -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 (
- {getTitle()}
+ {title}
- {alertNode}
+
{getBody()}
@@ -186,6 +176,10 @@ const IframePreviewLibraryXBlockChanges = () => {
useEventListener('message', receiveMessage);
+ if (!blockData) {
+ return null;
+ }
+
return (
{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;
diff --git a/src/generic/alert-message/index.scss b/src/generic/alert-message/index.scss
new file mode 100644
index 000000000..394e6d259
--- /dev/null
+++ b/src/generic/alert-message/index.scss
@@ -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;
+ }
+}
diff --git a/src/generic/styles.scss b/src/generic/styles.scss
index 00ef45922..22756b9ad 100644
--- a/src/generic/styles.scss
+++ b/src/generic/styles.scss
@@ -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";
diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx
index 53671cd7e..7f7b1e3bb 100644
--- a/src/library-authoring/component-info/ComponentDetails.test.tsx
+++ b/src/library-authoring/component-info/ComponentDetails.test.tsx
@@ -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(, {
diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx
index 2206be3e5..0427e1b34 100644
--- a/src/library-authoring/component-info/ComponentInfo.test.tsx
+++ b/src/library-authoring/component-info/ComponentInfo.test.tsx
@@ -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
diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx
index 48c97fba8..13e59565f 100644
--- a/src/library-authoring/component-info/ComponentUsage.tsx
+++ b/src/library-authoring/component-info/ComponentUsage.tsx
@@ -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) || [],
diff --git a/src/library-authoring/components/PublishConfirmationModal.tsx b/src/library-authoring/components/PublishConfirmationModal.tsx
index 8c98e234f..eeadc84e9 100644
--- a/src/library-authoring/components/PublishConfirmationModal.tsx
+++ b/src/library-authoring/components/PublishConfirmationModal.tsx
@@ -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;
diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index eb5f60188..e267b9483 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -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 {
- const thisMock = mockGetUnpaginatedEntityLinks;
+): ReturnType {
+ 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);