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);