diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 7f39be499..dd64c6402 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -25,7 +25,7 @@ import CourseImportPage from './import-page/CourseImportPage'; import { DECODED_ROUTES } from './constants'; import CourseChecklist from './course-checklist'; import GroupConfigurations from './group-configurations'; -import CourseLibraries from './course-libraries'; +import { CourseLibraries } from './course-libraries'; /** * As of this writing, these routes are mounted at a path prefixed with the following: diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx index 659f73eba..286e48a79 100644 --- a/src/course-libraries/CourseLibraries.test.tsx +++ b/src/course-libraries/CourseLibraries.test.tsx @@ -1,22 +1,35 @@ import fetchMock from 'fetch-mock-jest'; -import { cloneDeep } from 'lodash'; import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter/types'; +import { QueryClient } from '@tanstack/react-query'; import { initializeMocks, render, screen, + waitFor, within, } from '../testUtils'; import { mockContentSearchConfig } from '../search-manager/data/api.mock'; -import mockInfoResult from './__mocks__/courseBlocksInfo.json'; -import CourseLibraries from './CourseLibraries'; -import { mockGetEntityLinksByDownstreamContext } from './data/api.mocks'; +import { CourseLibraries } from './CourseLibraries'; +import { + mockGetEntityLinks, + mockGetEntityLinksSummaryByDownstreamContext, + mockFetchIndexDocuments, + mockUseLibBlockMetadata, +} from './data/api.mocks'; +import { libraryBlockChangesUrl } from '../course-unit/data/api'; +import { type ToastActionData } from '../generic/toast-context'; mockContentSearchConfig.applyMock(); -mockGetEntityLinksByDownstreamContext.applyMock(); +mockGetEntityLinks.applyMock(); +mockGetEntityLinksSummaryByDownstreamContext.applyMock(); +mockUseLibBlockMetadata.applyMock(); -const searchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search'; +const searchParamsGetMock = jest.fn(); +let axiosMock: MockAdapter; +let mockShowToast: (message: string, action?: ToastActionData | undefined) => void; +let queryClient: QueryClient; jest.mock('../studio-home/hooks', () => ({ useStudioHome: () => ({ @@ -26,54 +39,46 @@ jest.mock('../studio-home/hooks', () => ({ }), })); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useSearchParams: () => [{ + get: searchParamsGetMock, + getAll: () => [], + }], +})); + describe('', () => { beforeEach(() => { initializeMocks(); fetchMock.mockReset(); - - // The Meilisearch client-side API uses fetch, not Axios. - fetchMock.post(searchEndpoint, (_url, req) => { - const requestData = JSON.parse(req.body?.toString() ?? ''); - const filter = requestData?.filter[1]; - const mockInfoResultCopy = cloneDeep(mockInfoResult); - const resp = mockInfoResultCopy.filter((o: { filter: string }) => o.filter === filter)[0] || { - result: { - hits: [], - query: '', - processingTimeMs: 0, - limit: 4, - offset: 0, - estimatedTotalHits: 0, - }, - }; - const { result } = resp; - return result; - }); + mockFetchIndexDocuments.applyMock(); + localStorage.clear(); + searchParamsGetMock.mockReturnValue('all'); }); const renderCourseLibrariesPage = async (courseKey?: string) => { - const courseId = courseKey || mockGetEntityLinksByDownstreamContext.courseKey; + const courseId = courseKey || mockGetEntityLinks.courseKey; render(); }; it('shows the spinner before the query is complete', async () => { // This mock will never return data (it loads forever): - await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKeyLoading); + await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading); const spinner = await screen.findByRole('status'); expect(spinner.textContent).toEqual('Loading...'); }); - it('shows empty state wheen no links are present', async () => { - await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKeyEmpty); + it('shows empty state when no links are present', async () => { + await renderCourseLibrariesPage(mockGetEntityLinks.courseKeyEmpty); const emptyMsg = await screen.findByText('This course does not use any content from libraries.'); expect(emptyMsg).toBeInTheDocument(); }); it('shows alert when out of sync components are present', async () => { - await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey); + await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); const alert = await screen.findByRole('alert'); expect(await within(alert).findByText( - '1 library components are out of sync. Review updates to accept or ignore changes', + '5 library components are out of sync. Review updates to accept or ignore changes', )).toBeInTheDocument(); const allTab = await screen.findByRole('tab', { name: 'Libraries' }); expect(allTab).toHaveAttribute('aria-selected', 'true'); @@ -82,7 +87,7 @@ describe('', () => { userEvent.click(reviewBtn); expect(allTab).toHaveAttribute('aria-selected', 'false'); - expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true'); + expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true'); expect(alert).not.toBeInTheDocument(); // go back to all tab @@ -94,14 +99,14 @@ describe('', () => { // review updates button const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' }); userEvent.click(reviewActionBtn); - expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true'); + expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true'); }); it('hide alert on dismiss', async () => { - await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey); + await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); const alert = await screen.findByRole('alert'); expect(await within(alert).findByText( - '1 library components are out of sync. Review updates to accept or ignore changes', + '5 library components are out of sync. Review updates to accept or ignore changes', )).toBeInTheDocument(); const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' }); userEvent.click(dismissBtn); @@ -110,39 +115,127 @@ describe('', () => { expect(alert).not.toBeInTheDocument(); }); +}); - it('shows links split by library', async () => { - await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey); - const msg = await screen.findByText('This course contains content from these libraries.'); - expect(msg).toBeInTheDocument(); - const allButtons = await screen.findAllByRole('button'); - // total 3 components used from lib 1 - const expectedLib1Blocks = 3; - // total 4 components used from lib 1 - const expectedLib2Blocks = 4; - // 1 component has updates. - const expectedLib2ToUpdate = 1; +describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; + fetchMock.mockReset(); + mockFetchIndexDocuments.applyMock(); + localStorage.clear(); + searchParamsGetMock.mockReturnValue('review'); + queryClient = mocks.queryClient; + }); - const libraryCards = allButtons.filter((el) => el.classList.contains('collapsible-trigger')); - expect(libraryCards.length).toEqual(2); - expect(await within(libraryCards[0]).findByText('CS problems 2')).toBeInTheDocument(); - expect(await within(libraryCards[0]).findByText(`${expectedLib1Blocks} components applied`)).toBeInTheDocument(); - expect(await within(libraryCards[0]).findByText('All components up to date')).toBeInTheDocument(); + const renderCourseLibrariesReviewPage = async (courseKey?: string) => { + const courseId = courseKey || mockGetEntityLinks.courseKey; + render(); + }; - const libParent1 = libraryCards[0].parentElement; - expect(libParent1).not.toBeNull(); - userEvent.click(libraryCards[0]); - const xblockCards1 = libParent1!.querySelectorAll('div.card'); - expect(xblockCards1.length).toEqual(expectedLib1Blocks); + it('shows the spinner before the query is complete', async () => { + // This mock will never return data (it loads forever): + await renderCourseLibrariesReviewPage(mockGetEntityLinks.courseKeyLoading); + const spinner = await screen.findByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + }); - expect(await within(libraryCards[1]).findByText('CS problems 3')).toBeInTheDocument(); - expect(await within(libraryCards[1]).findByText(`${expectedLib2Blocks} components applied`)).toBeInTheDocument(); - expect(await within(libraryCards[1]).findByText(`${expectedLib2ToUpdate} component out of sync`)).toBeInTheDocument(); + it('shows empty state when no readyToSync links are present', async () => { + await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate); + const emptyMsg = await screen.findByText('All components are up to date'); + expect(emptyMsg).toBeInTheDocument(); + }); - const libParent2 = libraryCards[1].parentElement; - expect(libParent2).not.toBeNull(); - userEvent.click(libraryCards[1]); - const xblockCards2 = libParent2!.querySelectorAll('div.card'); - expect(xblockCards2.length).toEqual(expectedLib2Blocks); + it('shows all readyToSync links', async () => { + await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); + const updateBtns = await screen.findAllByRole('button', { name: 'Update' }); + expect(updateBtns.length).toEqual(5); + const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' }); + expect(ignoreBtns.length).toEqual(5); + }); + + it('update changes works', async () => { + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); + await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); + const updateBtns = await screen.findAllByRole('button', { name: 'Update' }); + expect(updateBtns.length).toEqual(5); + userEvent.click(updateBtns[0]); + await waitFor(() => { + expect(axiosMock.history.post.length).toEqual(1); + }); + expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); + expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated'); + expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']); + }); + + it('update changes works in preview modal', async () => { + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); + await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); + const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); + expect(previewBtns.length).toEqual(5); + userEvent.click(previewBtns[0]); + const dialog = await screen.findByRole('dialog'); + const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' }); + userEvent.click(confirmBtn); + await waitFor(() => { + expect(axiosMock.history.post.length).toEqual(1); + }); + expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); + expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated'); + expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']); + }); + + it('ignore change works', async () => { + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); + await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); + const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' }); + expect(ignoreBtns.length).toEqual(5); + // Show confirmation modal on clicking ignore. + userEvent.click(ignoreBtns[0]); + const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' }); + expect(dialog).toBeInTheDocument(); + const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' }); + userEvent.click(confirmBtn); + await waitFor(() => { + expect(axiosMock.history.delete.length).toEqual(1); + }); + expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey)); + expect(mockShowToast).toHaveBeenCalledWith( + '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.', + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']); + }); + + it('ignore change works in preview', async () => { + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); + await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); + const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); + expect(previewBtns.length).toEqual(5); + userEvent.click(previewBtns[0]); + const previewDialog = await screen.findByRole('dialog'); + const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' }); + userEvent.click(ignoreBtn); + // Show confirmation modal on clicking ignore. + const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' }); + expect(dialog).toBeInTheDocument(); + const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' }); + userEvent.click(confirmBtn); + await waitFor(() => { + expect(axiosMock.history.delete.length).toEqual(1); + }); + expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey)); + expect(mockShowToast).toHaveBeenCalledWith( + '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.', + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']); }); }); diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index d15a433f9..d449f6942 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -6,236 +6,110 @@ import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Alert, - Breadcrumb, Button, Card, Collapsible, Container, Dropdown, Hyperlink, Icon, IconButton, Layout, Stack, Tab, Tabs, + ActionRow, + Button, + Card, + Container, + Hyperlink, + Icon, + Stack, + Tab, + Tabs, } from '@openedx/paragon'; import { - Cached, CheckCircle, KeyboardArrowDown, KeyboardArrowRight, Loop, MoreVert, + Cached, CheckCircle, Launch, Loop, } from '@openedx/paragon/icons'; -import { - countBy, groupBy, keyBy, tail, uniq, -} from 'lodash'; -import classNames from 'classnames'; +import _ from 'lodash'; +import { useSearchParams } from 'react-router-dom'; import getPageHeadTitle from '../generic/utils'; import { useModel } from '../generic/model-store'; import messages from './messages'; import SubHeader from '../generic/sub-header/SubHeader'; -import { useEntityLinksByDownstreamContext } from './data/apiHooks'; -import type { PublishableEntityLink } from './data/api'; -import { useFetchIndexDocuments } from '../search-manager/data/apiHooks'; -import { getItemIcon } from '../generic/block-type-utils'; -import { BlockTypeLabel } from '../search-manager'; -import AlertMessage from '../generic/alert-message'; -import type { ContentHit } from '../search-manager/data/api'; -import { SearchSortOption } from '../search-manager/data/api'; +import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks'; +import type { PublishableEntityLinkSummary } from './data/api'; import Loading from '../generic/Loading'; import { useStudioHome } from '../studio-home/hooks'; +import NewsstandIcon from '../generic/NewsstandIcon'; +import ReviewTabContent from './ReviewTabContent'; +import { OutOfSyncAlert } from './OutOfSyncAlert'; interface Props { courseId: string; } interface LibraryCardProps { - courseId: string; - title: string; - links: PublishableEntityLink[]; -} - -interface ComponentInfo extends ContentHit { - readyToSync: boolean; -} - -interface BlockCardProps { - info: ComponentInfo; + linkSummary: PublishableEntityLinkSummary; } export enum CourseLibraryTabs { - home = '', + all = 'all', review = 'review', } -const BlockCard: React.FC = ({ info }) => { +const LibraryCard = ({ linkSummary }: LibraryCardProps) => { const intl = useIntl(); - const componentIcon = getItemIcon(info.blockType); - const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>; - - const getBlockLink = useCallback(() => { - let key = info.usageKey; - if (breadcrumbs?.length > 1) { - key = breadcrumbs[breadcrumbs.length - 1].usageKey || key; - } - return `${getConfig().STUDIO_BASE_URL}/container/${key}`; - }, [info]); return ( - - - - - - - - {' '} - + + + + {linkSummary.upstreamContextTitle} - - {info.readyToSync && } - {info.formatted?.displayName} - -
{info.formatted?.description}
- ({ label: breadcrumb.displayName }))} - spacer={/} - linkAs="span" - /> + )} + actions={( + + + + )} + size="sm" + /> + + + + {intl.formatMessage(messages.totalComponentLabel, { totalComponents: linkSummary.totalCount })} + + {linkSummary.readyToSyncCount > 0 && ( + + + + {intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount: linkSummary.readyToSyncCount })} + + + )}
); }; -const LibraryCard: React.FC = ({ courseId, title, links }) => { - const intl = useIntl(); - const linksInfo = useMemo(() => keyBy(links, 'downstreamUsageKey'), [links]); - const totalComponents = links.length; - const outOfSyncCount = useMemo(() => countBy(links, 'readyToSync').true, [links]); - const downstreamKeys = useMemo(() => uniq(Object.keys(linksInfo)), [links]); - const { data: downstreamInfo } = useFetchIndexDocuments({ - filter: [`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys.join('","')}"]`], - limit: downstreamKeys.length, - attributesToRetrieve: ['usage_key', 'display_name', 'breadcrumbs', 'description', 'block_type'], - attributesToCrop: ['description:30'], - sort: [SearchSortOption.TITLE_AZ], - }) as unknown as { data: ComponentInfo[] }; - - const renderBlockCards = (info: ComponentInfo) => { - // eslint-disable-next-line no-param-reassign - info.readyToSync = linksInfo[info.usageKey].readyToSync; - return ; - }; - - return ( - - - - - - - - - -

{title}

- - - {intl.formatMessage(messages.totalComponentLabel, { totalComponents })} - - / - {outOfSyncCount ? ( - <> - - - {intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount })} - - - ) : ( - <> - - - {intl.formatMessage(messages.allUptodateLabel)} - - - )} - -
- void; }) => e.stopPropagation()}> - - - TODO 1 - - -
- - - {downstreamInfo?.map(info => renderBlockCards(info))} - -
- ); -}; - -interface ReviewAlertProps { - show: boolean; - outOfSyncCount: number; - onDismiss: () => void; - onReview: () => void; -} - -const ReviewAlert: React.FC = ({ - show, outOfSyncCount, onDismiss, onReview, -}) => { - const intl = useIntl(); - return ( - - {intl.formatMessage(messages.outOfSyncCountAlertReviewBtn)} - , - ]} - /> - ); -}; - -const TabContent = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - Help panel - - -); - -const CourseLibraries: React.FC = ({ courseId }) => { +export const CourseLibraries: React.FC = ({ courseId }) => { const intl = useIntl(); const courseDetails = useModel('courseDetails', courseId); - const [tabKey, setTabKey] = useState(CourseLibraryTabs.home); - const [showReviewAlert, setShowReviewAlert] = useState(true); - const { data: links, isLoading } = useEntityLinksByDownstreamContext(courseId); - const linksByLib = useMemo(() => groupBy(links, 'upstreamContextKey'), [links]); - const outOfSyncCount = useMemo(() => countBy(links, 'readyToSync').true, [links]); + const [searchParams] = useSearchParams(); + const [tabKey, setTabKey] = useState( + () => searchParams.get('tab') as CourseLibraryTabs || CourseLibraryTabs.all, + ); + const [showReviewAlert, setShowReviewAlert] = useState(false); + const { data: libraries, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId); + const outOfSyncCount = useMemo(() => _.sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]); const { isLoadingPage: isLoadingStudioHome, isFailedLoadingPage: isFailedLoadingStudioHome, @@ -244,33 +118,51 @@ const CourseLibraries: React.FC = ({ courseId }) => { const onAlertReview = () => { setTabKey(CourseLibraryTabs.review); - setShowReviewAlert(false); - }; - const onAlertDismiss = () => { - setShowReviewAlert(false); }; + const tabChange = useCallback((selectedTab: CourseLibraryTabs) => { + setTabKey(selectedTab); + }, []); + const renderLibrariesTabContent = useCallback(() => { if (isLoading) { return ; } - if (links?.length === 0) { + if (libraries?.length === 0) { return ; } return ( <> - {Object.entries(linksByLib).map(([libKey, libLinks]) => ( + {libraries?.map((library) => ( ))} ); - }, [links, isLoading, linksByLib]); + }, [libraries, isLoading]); + + const renderReviewTabContent = useCallback(() => { + if (isLoading) { + return ; + } + if (tabKey !== CourseLibraryTabs.review) { + return null; + } + if (!outOfSyncCount || outOfSyncCount === 0) { + return ( + + + + + + + ); + } + return ; + }, [outOfSyncCount, isLoading, tabKey]); if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) { return ( @@ -288,16 +180,16 @@ const CourseLibraries: React.FC = ({ courseId }) => { - 0 && tabKey === CourseLibraryTabs.home && showReviewAlert} - outOfSyncCount={outOfSyncCount} - onDismiss={onAlertDismiss} + 0 && tabKey === CourseLibraryTabs.all && ( , + ]} + /> + ); +}; diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx new file mode 100644 index 000000000..80a9c12c3 --- /dev/null +++ b/src/course-libraries/ReviewTabContent.tsx @@ -0,0 +1,391 @@ +import React, { + useCallback, useContext, useEffect, useMemo, useState, +} from 'react'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Breadcrumb, + Button, + Card, + Hyperlink, + Icon, + Stack, + useToggle, +} from '@openedx/paragon'; + +import _ from 'lodash'; +import { useQueryClient } from '@tanstack/react-query'; +import { Loop, Warning } from '@openedx/paragon/icons'; +import messages from './messages'; +import previewChangesMessages from '../course-unit/preview-changes/messages'; +import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks'; +import { + SearchContextProvider, SearchKeywordsField, useSearchContext, BlockTypeLabel, Highlight, SearchSortWidget, +} from '../search-manager'; +import { getItemIcon } from '../generic/block-type-utils'; +import type { ContentHit } from '../search-manager/data/api'; +import { SearchSortOption } from '../search-manager/data/api'; +import Loading from '../generic/Loading'; +import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../course-unit/data/apiHooks'; +import { PreviewLibraryXBlockChanges, LibraryChangesMessageData } from '../course-unit/preview-changes'; +import LoadingButton from '../generic/loading-button'; +import { ToastContext } from '../generic/toast-context'; +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; +} + +interface BlockCardProps { + info: ContentHit; + actions?: React.ReactNode; +} + +const BlockCard: React.FC = ({ info, actions }) => { + const intl = useIntl(); + const componentIcon = getItemIcon(info.blockType); + const breadcrumbs = _.tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>; + + const getBlockLink = useCallback(() => { + let key = info.usageKey; + if (breadcrumbs?.length > 1) { + key = breadcrumbs[breadcrumbs.length - 1].usageKey || key; + } + return `${getConfig().STUDIO_BASE_URL}/container/${key}`; + }, [info]); + + return ( + + + + + + + + + + + + + + + {intl.formatMessage(messages.breadcrumbLabel)} + + ({ label: breadcrumb.displayName }))} + spacer={/} + linkAs="span" + /> + + + + {actions} + + + + ); +}; + +const ComponentReviewList = ({ + outOfSyncComponents, + onSearchUpdate, +}: { + outOfSyncComponents: PublishableEntityLink[]; + onSearchUpdate: () => void; +}) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + const [blockData, setBlockData] = useState(undefined); + // ignore changes confirmation modal toggle. + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + const { + hits: downstreamInfo, + isLoading: isIndexDataLoading, + searchKeywords, + searchSortOrder, + hasError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useSearchContext() as { + hits: ContentHit[]; + isLoading: boolean; + searchKeywords: string; + searchSortOrder: SearchSortOption; + hasError: boolean; + hasNextPage: boolean | undefined, + isFetchingNextPage: boolean; + fetchNextPage: () => void; + }; + + useLoadOnScroll( + hasNextPage, + isFetchingNextPage, + fetchNextPage, + true, + ); + + const outOfSyncComponentsByKey = useMemo( + () => _.keyBy(outOfSyncComponents, 'downstreamUsageKey'), + [outOfSyncComponents], + ); + const downstreamInfoByKey = useMemo( + () => _.keyBy(downstreamInfo, 'usageKey'), + [downstreamInfo], + ); + 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) => { + setBlockData({ + displayName: info.displayName, + downstreamBlockId: info.usageKey, + upstreamBlockId: outOfSyncComponentsByKey[info.usageKey].upstreamUsageKey, + upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced, + isVertical: info.blockType === 'vertical', + }); + }; + // Show preview changes on review + const onReview = useCallback((info: ContentHit) => { + setSeletecdBlockData(info); + openModal(); + }, [setSeletecdBlockData, openModal]); + + const onIgnoreClick = useCallback((info: ContentHit) => { + setSeletecdBlockData(info); + openConfirmModal(); + }, [setSeletecdBlockData, openConfirmModal]); + + const reloadLinks = useCallback((usageKey: string) => { + const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey; + queryClient.invalidateQueries(courseLibrariesQueryKeys.courseLibraries(courseKey)); + }, [outOfSyncComponentsByKey]); + + const postChange = (accept: boolean) => { + // istanbul ignore if: this should never happen + if (!blockData) { + return; + } + reloadLinks(blockData.downstreamBlockId); + if (accept) { + showToast(intl.formatMessage( + messages.updateSingleBlockSuccess, + { name: blockData.displayName }, + )); + } else { + showToast(intl.formatMessage( + messages.ignoreSingleBlockSuccess, + { name: blockData.displayName }, + )); + } + }; + + const updateBlock = useCallback(async (info: ContentHit) => { + try { + await acceptChangesMutation.mutateAsync(info.usageKey); + reloadLinks(info.usageKey); + showToast(intl.formatMessage( + messages.updateSingleBlockSuccess, + { name: info.displayName }, + )); + } catch (e) { + showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure)); + } + }, []); + + const ignoreBlock = useCallback(async () => { + // istanbul ignore if: this should never happen + if (!blockData) { + return; + } + try { + await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId); + reloadLinks(blockData.downstreamBlockId); + showToast(intl.formatMessage( + messages.ignoreSingleBlockSuccess, + { name: blockData.displayName }, + )); + } catch (e) { + showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure)); + } finally { + closeConfirmModal(); + } + }, [blockData]); + + const orderInfo = useMemo(() => { + if (searchSortOrder !== SearchSortOption.RECENTLY_MODIFIED) { + return downstreamInfo; + } + if (isIndexDataLoading) { + return []; + } + let merged = _.merge(downstreamInfoByKey, outOfSyncComponentsByKey); + merged = _.omitBy(merged, (o) => !o.displayName); + const ordered = _.orderBy(Object.values(merged), 'updated', 'desc'); + return ordered; + }, [downstreamInfoByKey, outOfSyncComponentsByKey]); + + if (isIndexDataLoading) { + return ; + } + + if (hasError) { + return ; + } + + return ( + <> + {orderInfo?.map((info) => ( + + + + + updateBlock(info)} + className="rounded-0" + /> + + )} + /> + ))} + + )} + /> + + + ); +}; + +const ReviewTabContent = ({ courseId }: Props) => { + const intl = useIntl(); + const { + data: linkPages, + 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, + SearchSortOption.NEWEST, + SearchSortOption.RECENTLY_PUBLISHED, + ]; + + if (isSyncComponentsLoading) { + return ; + } + + if (isError) { + return ; + } + + return ( + + + + + + + + + ); +}; + +export default ReviewTabContent; diff --git a/src/course-libraries/__mocks__/libBlockMetadata.json b/src/course-libraries/__mocks__/libBlockMetadata.json new file mode 100644 index 000000000..ca03b2c60 --- /dev/null +++ b/src/course-libraries/__mocks__/libBlockMetadata.json @@ -0,0 +1,23 @@ +{ + "id": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "def_key": null, + "block_type": "problem", + "display_name": "Dropdown 123", + "last_published": "2025-02-19T13:58:49Z", + "published_by": "edx", + "last_draft_created": "2025-02-19T13:58:48Z", + "last_draft_created_by": null, + "has_unpublished_changes": false, + "created": "2024-10-30T10:48:35Z", + "modified": "2025-02-19T13:58:48Z", + "collections": [ + { + "key": "second-collection", + "title": "Second collection" + }, + { + "key": "test-collection-2", + "title": "Test collection 2" + } + ] +} diff --git a/src/course-libraries/__mocks__/linkCourseSummary.json b/src/course-libraries/__mocks__/linkCourseSummary.json new file mode 100644 index 000000000..6a28ee46a --- /dev/null +++ b/src/course-libraries/__mocks__/linkCourseSummary.json @@ -0,0 +1,20 @@ +[ + { + "upstreamContextTitle": "CS problems 3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "readyToSyncCount": 5, + "totalCount": 14 + }, + { + "upstreamContextTitle": "CS problems 2", + "upstreamContextKey": "lib:OpenedX:CSPROB2", + "readyToSyncCount": 0, + "totalCount": 21 + }, + { + "upstreamContextTitle": "CS problems", + "upstreamContextKey": "lib:OpenedX:CSPROB", + "readyToSyncCount": 0, + "totalCount": 3 + } +] diff --git a/src/course-libraries/__mocks__/linkDetailsFromIndex.json b/src/course-libraries/__mocks__/linkDetailsFromIndex.json new file mode 100644 index 000000000..76e137955 --- /dev/null +++ b/src/course-libraries/__mocks__/linkDetailsFromIndex.json @@ -0,0 +1,376 @@ +{ + "results": [ + { + "indexUid": "tutor_studio_content", + "hits": [ + { + "display_name": "Dropdown", + "block_id": "problem3", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": 4, + "_formatted": { + "display_name": "Dropdown", + "block_id": "problem3", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": "4" + } + }, + { + "display_name": "Dropdown", + "block_id": "problem6", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": 4, + "_formatted": { + "display_name": "Dropdown", + "block_id": "problem6", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": "4" + } + }, + { + "display_name": "Dropdown", + "block_id": "210e356cfa304b0aac591af53f6a6ae0", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": 4, + "_formatted": { + "display_name": "Dropdown", + "block_id": "210e356cfa304b0aac591af53f6a6ae0", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": "4" + } + }, + { + "display_name": "HTML 1", + "block_id": "257e68e3386d4a8f8739d45b67e76a9b", + "content": { + "html_content": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1" + }, + "description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", + "block_type": "html", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": 4, + "_formatted": { + "display_name": "HTML 1", + "block_id": "257e68e3386d4a8f8739d45b67e76a9b", + "content": { + "html_content": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1" + }, + "description": "A step beyond the simplicity of the WYSIWYG editor is…", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", + "block_type": "html", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": "4" + } + }, + { + "display_name": "Dropdown", + "block_id": "a4455860b03647219ff8b01cde49cf37", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": 4, + "_formatted": { + "display_name": "Dropdown", + "block_id": "a4455860b03647219ff8b01cde49cf37", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "asfd sdaf afd" + }, + "description": "asfd sdaf afd", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + }, + { + "display_name": "Module 1: Dive into the Open edX® platform!", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", + "block_type": "problem", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": "4" + } + } + ], + "query": "", + "processingTimeMs": 8, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 5 + } + ] +} diff --git a/src/course-libraries/__mocks__/publishableEntityLinks.json b/src/course-libraries/__mocks__/publishableEntityLinks.json index 641e4366a..9988dee71 100644 --- a/src/course-libraries/__mocks__/publishableEntityLinks.json +++ b/src/course-libraries/__mocks__/publishableEntityLinks.json @@ -1,100 +1,79 @@ -[ - { - "id": 970, - "upstreamContextTitle": "CS problems 2", - "upstreamVersion": 15, - "readyToSync": false, - "upstreamUsageKey": "lb:OpenedX:CSPROB2:html:c0c1ca28-ff25-4757-83bc-3a2c2a0fe9c8", - "upstreamContextKey": "lib:OpenedX:CSPROB2", - "downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5", - "downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1", - "versionSynced": 15, - "versionDeclined": 13, - "created": "2025-02-08T14:11:23.650589Z", - "updated": "2025-02-08T14:11:23.650589Z" - }, - { - "id": 971, - "upstreamContextTitle": "CS problems 2", - "upstreamVersion": 3, - "readyToSync": false, - "upstreamUsageKey": "lb:OpenedX:CSPROB2:html:fd2d3827-e633-4217-bca9-c6661086b4b2", - "upstreamContextKey": "lib:OpenedX:CSPROB2", - "downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d", - "downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1", - "versionSynced": 3, - "versionDeclined": null, - "created": "2025-02-08T14:11:23.650589Z", - "updated": "2025-02-08T14:11:23.650589Z" - }, - { - "id": 972, - "upstreamContextTitle": "CS problems 2", - "upstreamVersion": 3, - "readyToSync": false, - "upstreamUsageKey": "lb:OpenedX:CSPROB2:video:ba2023d4-b4e4-44a5-bfc8-322203e8737f", - "upstreamContextKey": "lib:OpenedX:CSPROB2", - "downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83", - "downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1", - "versionSynced": 3, - "versionDeclined": null, - "created": "2025-02-08T14:11:23.650589Z", - "updated": "2025-02-08T14:11:23.650589Z" - }, - { - "id": 974, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 18, - "readyToSync": false, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62", - "downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1", - "versionSynced": 17, - "versionDeclined": 18, - "created": "2025-02-12T05:38:53.967738Z", - "updated": "2025-02-12T05:41:01.225542Z" - }, - { - "id": 975, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 1, - "readyToSync": false, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:4abdfa10-dd1a-4ebb-bad3-489000671acb", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07", - "downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1", - "versionSynced": 1, - "versionDeclined": null, - "created": "2025-02-12T05:38:55.899821Z", - "updated": "2025-02-12T05:38:55.899821Z" - }, - { - "id": 976, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 1, - "readyToSync": false, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:6aff1b41-e406-41ff-9d31-70d02ef42deb", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d", - "downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1", - "versionSynced": 1, - "versionDeclined": null, - "created": "2025-02-12T05:38:57.228152Z", - "updated": "2025-02-12T05:38:57.228152Z" - }, - { - "id": 977, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 3, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef", - "downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-12T05:38:58.538280Z", - "updated": "2025-02-12T05:38:58.538280Z" - } -] \ No newline at end of file +{ + "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" + } + ] +} diff --git a/src/course-libraries/data/api.mocks.ts b/src/course-libraries/data/api.mocks.ts index 96cfee6e2..1f4d0e5ba 100644 --- a/src/course-libraries/data/api.mocks.ts +++ b/src/course-libraries/data/api.mocks.ts @@ -1,36 +1,121 @@ +/* istanbul ignore file */ +// eslint-disable-next-line import/no-extraneous-dependencies +import fetchMock from 'fetch-mock-jest'; import mockLinksResult from '../__mocks__/publishableEntityLinks.json'; +import mockSummaryResult from '../__mocks__/linkCourseSummary.json'; +import mockLinkDetailsFromIndex from '../__mocks__/linkDetailsFromIndex.json'; +import mockLibBlockMetadata from '../__mocks__/libBlockMetadata.json'; import { createAxiosError } from '../../testUtils'; import * as api from './api'; +import * as libApi from '../../library-authoring/data/api'; /** - * Mock for `getEntityLinksByDownstreamContext()` + * Mock for `getEntityLinks()` * * This mock returns a fixed response for the downstreamContextKey. */ -export async function mockGetEntityLinksByDownstreamContext( - downstreamContextKey: string, -): Promise { +export async function mockGetEntityLinks( + downstreamContextKey?: string, + readyToSync?: boolean, +): ReturnType { switch (downstreamContextKey) { - case mockGetEntityLinksByDownstreamContext.invalidCourseKey: + case mockGetEntityLinks.invalidCourseKey: throw createAxiosError({ code: 404, message: 'Not found.', - path: api.getEntityLinksByDownstreamContextUrl(downstreamContextKey), + path: api.getEntityLinksByDownstreamContextUrl(), }); - case mockGetEntityLinksByDownstreamContext.courseKeyLoading: + case mockGetEntityLinks.courseKeyLoading: return new Promise(() => {}); - case mockGetEntityLinksByDownstreamContext.courseKeyEmpty: - return Promise.resolve([]); - default: - return Promise.resolve(mockGetEntityLinksByDownstreamContext.response); + case mockGetEntityLinks.courseKeyEmpty: + return Promise.resolve({ + next: null, + previous: null, + nextPageNum: null, + previousPageNum: null, + count: 0, + numPages: 0, + currentPage: 0, + results: [], + }); + default: { + const { response } = mockGetEntityLinks; + if (readyToSync !== undefined) { + response.results = response.results.filter((o) => o.readyToSync === readyToSync); + response.count = response.results.length; + } + return Promise.resolve(response); + } } } -mockGetEntityLinksByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey; -mockGetEntityLinksByDownstreamContext.invalidCourseKey = 'course_key_error'; -mockGetEntityLinksByDownstreamContext.courseKeyLoading = 'courseKeyLoading'; -mockGetEntityLinksByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty'; -mockGetEntityLinksByDownstreamContext.response = mockLinksResult; +mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey; +mockGetEntityLinks.invalidCourseKey = 'course_key_error'; +mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading'; +mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty'; +mockGetEntityLinks.response = mockLinksResult; /** Apply this mock. Returns a spy object that can tell you if it's been called. */ -mockGetEntityLinksByDownstreamContext.applyMock = () => { - jest.spyOn(api, 'getEntityLinksByDownstreamContext').mockImplementation(mockGetEntityLinksByDownstreamContext); +mockGetEntityLinks.applyMock = () => { + jest.spyOn(api, 'getEntityLinks').mockImplementation(mockGetEntityLinks); +}; + +/** + * Mock for `getEntityLinksSummaryByDownstreamContext()` + * + * This mock returns a fixed response for the downstreamContextKey. + */ +export async function mockGetEntityLinksSummaryByDownstreamContext( + courseId?: string, +): ReturnType { + switch (courseId) { + case mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey: + throw createAxiosError({ + code: 404, + message: 'Not found.', + path: api.getEntityLinksByDownstreamContextUrl(), + }); + case mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading: + return new Promise(() => {}); + case mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty: + return Promise.resolve([]); + case mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate: + return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response.filter( + (o: { readyToSyncCount: number }) => o.readyToSyncCount === 0, + )); + default: + return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response); + } +} +mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey; +mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error'; +mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading'; +mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty'; +mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate = 'courseKeyUpToDate'; +mockGetEntityLinksSummaryByDownstreamContext.response = mockSummaryResult; +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockGetEntityLinksSummaryByDownstreamContext.applyMock = () => { + jest.spyOn(api, 'getEntityLinksSummaryByDownstreamContext').mockImplementation(mockGetEntityLinksSummaryByDownstreamContext); +}; + +/** + * Mock for multi-search from meilisearch index for link details. + */ +export async function mockFetchIndexDocuments() { + return mockLinkDetailsFromIndex; +} +mockFetchIndexDocuments.applyMock = () => { + fetchMock.post( + 'http://mock.meilisearch.local/multi-search', + mockFetchIndexDocuments, + { overwriteRoutes: true }, + ); +}; + +/** + * Mock for library block metadata + */ +export async function mockUseLibBlockMetadata() { + return mockLibBlockMetadata; +} +mockUseLibBlockMetadata.applyMock = () => { + jest.spyOn(libApi, 'getLibraryBlockMetadata').mockImplementation(mockUseLibBlockMetadata); }; diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index e5ecc76e2..4dd04c9bd 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -3,27 +3,84 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getEntityLinksByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/upstreams/${downstreamContextKey}`; +export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`; + +export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`; + +export interface PaginatedData { + next: string | null; + previous: string | null; + nextPageNum: number | null; + previousPageNum: number | null; + count: number; + numPages: number; + currentPage: number; + results: T, +} export interface PublishableEntityLink { + id: number; upstreamUsageKey: string; upstreamContextKey: string; upstreamContextTitle: string; - upstreamVersion: string; + upstreamVersion: number; downstreamUsageKey: string; - downstreamContextTitle: string; downstreamContextKey: string; - versionSynced: string; - versionDeclined: string; + versionSynced: number; + versionDeclined: number | null; created: string; updated: string; readyToSync: boolean; } -export const getEntityLinksByDownstreamContext = async ( - downstreamContextKey: string, -): Promise => { +export interface PublishableEntityLinkSummary { + upstreamContextKey: string; + upstreamContextTitle: string; + readyToSyncCount: number; + totalCount: number; +} + +export const getEntityLinks = async ( + downstreamContextKey?: string, + readyToSync?: boolean, + upstreamUsageKey?: string, + pageParam?: number, + pageSize?: number, +): Promise> => { const { data } = await getAuthenticatedHttpClient() - .get(getEntityLinksByDownstreamContextUrl(downstreamContextKey)); + .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(), { + params: { + course_id: downstreamContextKey, + ready_to_sync: readyToSync, + upstream_usage_key: upstreamUsageKey, + no_page: true, + }, + }); + return camelCaseObject(data); +}; + +export const getEntityLinksSummaryByDownstreamContext = async ( + downstreamContextKey: string, +): Promise => { + const { data } = await getAuthenticatedHttpClient() + .get(getEntityLinksSummaryByDownstreamContextUrl(downstreamContextKey)); return camelCaseObject(data); }; diff --git a/src/course-libraries/data/apiHooks.test.tsx b/src/course-libraries/data/apiHooks.test.tsx index 7d7867baa..d106ed68a 100644 --- a/src/course-libraries/data/apiHooks.test.tsx +++ b/src/course-libraries/data/apiHooks.test.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import { waitFor } from '@testing-library/react'; import { getEntityLinksByDownstreamContextUrl } from './api'; -import { useEntityLinksByDownstreamContext } from './apiHooks'; +import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks'; let axiosMock: MockAdapter; @@ -36,15 +36,39 @@ describe('course libraries api hooks', () => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); - it('should create library block', async () => { - const courseKey = 'course-v1:some+key'; - const url = getEntityLinksByDownstreamContextUrl(courseKey); - axiosMock.onGet(url).reply(200, []); - const { result } = renderHook(() => useEntityLinksByDownstreamContext(courseKey), { wrapper }); + afterEach(() => { + 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).toEqual([]); + 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 }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + }); + expect(axiosMock.history.get[0].url).toEqual(url); + expect(axiosMock.history.get[0].params).toEqual({ + course_id: courseId, + ready_to_sync: undefined, + upstream_usage_key: undefined, + no_page: true, + }); + }); }); diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index 84820ffaa..2d6b0c044 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,20 +1,95 @@ import { + useInfiniteQuery, useQuery, } from '@tanstack/react-query'; -import { getEntityLinksByDownstreamContext } from './api'; +import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api'; export const courseLibrariesQueryKeys = { all: ['courseLibraries'], - courseLibraries: (courseKey?: string) => [...courseLibrariesQueryKeys.all, courseKey], + courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId], + courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey }: { + courseId?: string, + readyToSync?: boolean, + upstreamUsageKey?: string, + pageSize?: number, + }) => { + const key: Array = [...courseLibrariesQueryKeys.all]; + if (courseId !== undefined) { + key.push(courseId); + } + if (readyToSync !== undefined) { + key.push(readyToSync); + } + if (upstreamUsageKey !== undefined) { + key.push(upstreamUsageKey); + } + return key; + }, + courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'], }; /** - * Hook to fetch a content library by its ID. + * Hook to fetch publishable entity links by course key. + * (That is, get a list of the library components used in the given course.) */ -export const useEntityLinksByDownstreamContext = (courseKey: string | undefined) => ( - useQuery({ - queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey), - queryFn: () => getEntityLinksByDownstreamContext(courseKey!), - enabled: courseKey !== undefined, +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, + readyToSync?: boolean, + upstreamUsageKey?: string, +}) => ( + useQuery({ + queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({ + courseId, + readyToSync, + upstreamUsageKey, + }), + queryFn: () => getUnpaginatedEntityLinks( + courseId, + readyToSync, + upstreamUsageKey, + ), + enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined, + }) +); + +/** + * Hook to fetch publishable entity links summary by course key. + */ +export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => ( + useQuery({ + queryKey: courseLibrariesQueryKeys.courseLibrariesSummary(courseId), + queryFn: () => getEntityLinksSummaryByDownstreamContext(courseId!), + enabled: courseId !== undefined, }) ); diff --git a/src/course-libraries/index.tsx b/src/course-libraries/index.tsx index b9bd26691..c5fd05834 100644 --- a/src/course-libraries/index.tsx +++ b/src/course-libraries/index.tsx @@ -1 +1 @@ -export { default } from './CourseLibraries'; +export { CourseLibraries } from './CourseLibraries'; diff --git a/src/course-libraries/messages.ts b/src/course-libraries/messages.ts index f72779652..803084f16 100644 --- a/src/course-libraries/messages.ts +++ b/src/course-libraries/messages.ts @@ -18,7 +18,7 @@ const messages = defineMessages({ }, homeTabDescription: { id: 'course-authoring.course-libraries.tab.home.description', - defaultMessage: 'This course contains content from these libraries.', + defaultMessage: 'Your course contains content from these libraries.', description: 'Description text for home tab', }, homeTabDescriptionEmpty: { @@ -28,18 +28,18 @@ const messages = defineMessages({ }, reviewTabTitle: { id: 'course-authoring.course-libraries.tab.review.title', - defaultMessage: 'Review Content Updates ({count})', + defaultMessage: 'Review Content Updates', description: 'Tab title for review tab', }, - reviewTabTitleEmpty: { - id: 'course-authoring.course-libraries.tab.review.title-no-updates', - defaultMessage: 'Review Content Updates', - description: 'Tab title for review tab when no updates are available', + reviewTabDescriptionEmpty: { + id: 'course-authoring.course-libraries.tab.home.description-no-links', + defaultMessage: 'All components are up to date', + description: 'Description text for home tab', }, - breadcrumbAriaLabel: { - id: 'course-authoring.course-libraries.downstream-block.breadcrumb.aria-label', - defaultMessage: 'Component breadcrumb', - description: 'Aria label for breadcrumb in component cards in course libraries page.', + breadcrumbLabel: { + id: 'course-authoring.course-libraries.downstream-block.breadcrumb.label', + defaultMessage: 'Location:', + description: 'label for breadcrumb in component cards in course libraries page.', }, totalComponentLabel: { id: 'course-authoring.course-libraries.libcard.total-component.label', @@ -58,7 +58,7 @@ const messages = defineMessages({ }, outOfSyncCountAlertTitle: { id: 'course-authoring.course-libraries.libcard.out-of-sync.alert.title', - defaultMessage: '{outOfSyncCount} library components are out of sync. Review updates to accept or ignore changes', + defaultMessage: '{outOfSyncCount, plural, one {# library component is} other {# library components are}} out of sync. Review updates to accept or ignore changes', description: 'Alert message shown when library components are out of sync', }, reviewUpdatesBtn: { @@ -76,6 +76,51 @@ const messages = defineMessages({ defaultMessage: 'This page cannot be shown: Libraries v2 are disabled.', description: 'Error message shown to users when trying to load a libraries V2 page while libraries v2 are disabled.', }, + cardReviewContentBtn: { + id: 'course-authoring.course-libraries.review-tab.libcard.review-btn-text', + defaultMessage: 'Review Updates', + description: 'Card review button for component in review tab', + }, + cardUpdateContentBtn: { + id: 'course-authoring.course-libraries.review-tab.libcard.update-btn-text', + defaultMessage: 'Update', + description: 'Card update button for component in review tab', + }, + cardIgnoreContentBtn: { + id: 'course-authoring.course-libraries.review-tab.libcard.ignore-btn-text', + defaultMessage: 'Ignore', + description: 'Card ignore button for component in review tab', + }, + updateSingleBlockSuccess: { + id: 'course-authoring.course-libraries.review-tab.libcard.update-success-toast', + defaultMessage: 'Success! "{name}" is updated', + description: 'Success toast message when a component is updated.', + }, + ignoreSingleBlockSuccess: { + id: 'course-authoring.course-libraries.review-tab.libcard.ignore-success-toast', + defaultMessage: '"{name}" will remain out of sync with library content. You will be notified when this component is updated again.', + description: 'Success toast message when a component update is ignored.', + }, + searchPlaceholder: { + id: 'course-authoring.course-libraries.review-tab.search.placeholder', + defaultMessage: 'Search', + description: 'Search text box in review tab placeholder text', + }, + brokenLinkTooltip: { + id: 'course-authoring.course-libraries.home-tab.broken-link.tooltip', + defaultMessage: 'Sourced from a library - but the upstream link is broken/invalid.', + description: 'Tooltip text describing broken link in component listing.', + }, + genericErrorMessage: { + id: 'course-authoring.course-libraries.home-tab.error.message', + 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/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index 9d6eefb21..a687bbadb 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -13,7 +13,7 @@ import { import { Alert, Button, Hyperlink, Truncate, } from '@openedx/paragon'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert'; import { RequestStatus } from '../../data/constants'; @@ -24,6 +24,7 @@ import advancedSettingsMessages from '../../advanced-settings/messages'; import { getPasteFileNotices } from '../data/selectors'; import { dismissError, removePasteFileNotices } from '../data/slice'; import { API_ERROR_TYPES } from '../constants'; +import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert'; const PageAlerts = ({ courseId, @@ -48,6 +49,8 @@ const PageAlerts = ({ localStorage.getItem(discussionAlertDismissKey) === null, ); const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices); + const [showOutOfSyncAlert, setShowOutOfSyncAlert] = useState(false); + const navigate = useNavigate(); const getAssetsUrl = () => { if (getConfig().ENABLE_ASSETS_PAGE === 'true') { @@ -419,6 +422,15 @@ const PageAlerts = ({ ); }; + const renderOutOfSyncAlert = () => ( + navigate(`/course/${courseId}/libraries?tab=review`)} + showAlert={showOutOfSyncAlert} + setShowAlert={setShowOutOfSyncAlert} + /> + ); + return ( <> {configurationErrors()} @@ -432,6 +444,7 @@ const PageAlerts = ({ {errorFilesPasteAlert()} {conflictingFilesPasteAlert()} {newFilesPasteAlert()} + {renderOutOfSyncAlert()} ); }; diff --git a/src/course-outline/page-alerts/PageAlerts.test.jsx b/src/course-outline/page-alerts/PageAlerts.test.jsx index 487a7e7ac..b17d3c2e5 100644 --- a/src/course-outline/page-alerts/PageAlerts.test.jsx +++ b/src/course-outline/page-alerts/PageAlerts.test.jsx @@ -28,6 +28,13 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +jest.mock('../../course-libraries/data/apiHooks', () => ({ + useEntityLinksSummaryByDownstreamContext: () => ({ + data: [], + isLoading: false, + }), +})); + let store; const handleDismissNotification = jest.fn(); @@ -70,9 +77,9 @@ describe('', () => { useSelector.mockReturnValue({}); }); - it('renders null when no alerts are present', () => { + it('renders null when no alerts are present', async () => { renderComponent(); - expect(screen.queryByTestId('browser-router')).toBeEmptyDOMElement(); + expect(await screen.findByTestId('browser-router')).toBeEmptyDOMElement(); }); it('renders configuration alerts', async () => { diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 61a9b5c3c..b4932e8e1 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -36,7 +36,7 @@ import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls'; import { PasteNotificationAlert } from './clipboard'; import XBlockContainerIframe from './xblock-container-iframe'; import MoveModal from './move-modal'; -import PreviewLibraryXBlockChanges from './preview-changes'; +import IframePreviewLibraryXBlockChanges from './preview-changes'; const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); @@ -213,7 +213,7 @@ const CourseUnit = ({ courseId }) => { closeModal={closeMoveModal} courseId={courseId} /> - + diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 5212d1cd6..38f279a0d 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -352,10 +352,10 @@ describe('', () => { it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { const { - getByTitle, getByText, queryByRole, getAllByRole, getByRole, + getByTitle, getByText, queryByRole, getByRole, } = render(); - await waitFor(() => { + await waitFor(async () => { const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', @@ -370,13 +370,12 @@ describe('', () => { expect(getByText(/Delete this component?/i)).toBeInTheDocument(); expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); - expect(getByRole('dialog')).toBeInTheDocument(); + const dialog = getByRole('dialog'); + expect(dialog).toBeInTheDocument(); // Find the Cancel and Delete buttons within the iframe by their specific classes - const cancelButton = getAllByRole('button', { name: /Cancel/i }) - .find(({ classList }) => classList.contains('btn-tertiary')); - const deleteButton = getAllByRole('button', { name: /Delete/i }) - .find(({ classList }) => classList.contains('btn-primary')); + const cancelButton = await within(dialog).findByRole('button', { name: /Cancel/i }); + const deleteButton = await within(dialog).findByRole('button', { name: /Delete/i }); expect(cancelButton).toBeInTheDocument(); diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index 235560820..ccdb13c7f 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -8,7 +8,7 @@ import { waitFor, } from '../../testUtils'; -import PreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.'; +import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.'; import { messageTypes } from '../constants'; import { IframeProvider } from '../context/iFrameContext'; import { libraryBlockChangesUrl } from '../data/api'; @@ -31,7 +31,7 @@ jest.mock('../context/hooks', () => ({ }), })); const render = (eventData?: LibraryChangesMessageData) => { - baseRender(, { + baseRender(, { extraWrapper: ({ children }) => { children }, }); const message = { @@ -49,7 +49,7 @@ const render = (eventData?: LibraryChangesMessageData) => { let axiosMock: MockAdapter; let mockShowToast: (message: string, action?: ToastActionData | undefined) => void; -describe('', () => { +describe('', () => { beforeEach(() => { const mocks = initializeMocks(); axiosMock = mocks.axiosMock; diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index 87acd2265..606910ad0 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import { ActionRow, Button, ModalDialog, useToggle, } from '@openedx/paragon'; @@ -24,36 +24,34 @@ export interface LibraryChangesMessageData { isVertical: boolean, } -const PreviewLibraryXBlockChanges = () => { +export interface PreviewLibraryXBlockChangesProps { + blockData?: LibraryChangesMessageData, + isModalOpen: boolean, + closeModal: () => void, + postChange: (accept: boolean) => void, + alertNode?: React.ReactNode, +} + +/** + * Component to preview two xblock versions in a modal that depends on params + * to display blocks, open-close modal, accept-ignore changes and post change triggers + */ +export const PreviewLibraryXBlockChanges = ({ + blockData, + isModalOpen, + closeModal, + postChange, + alertNode, +}: PreviewLibraryXBlockChangesProps) => { const { showToast } = useContext(ToastContext); const intl = useIntl(); - const [blockData, setBlockData] = useState(undefined); - - // Main preview library modal toggle. - const [isModalOpen, openModal, closeModal] = useToggle(false); // ignore changes confirmation modal toggle. const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId); const acceptChangesMutation = useAcceptLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); - const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId); - - const { sendMessageToIframe } = useIframe(); - - const receiveMessage = useCallback(({ data }: { data: { - payload: LibraryChangesMessageData; - type: string; - } }) => { - const { payload, type } = data; - - if (type === messageTypes.showXBlockLibraryChangesPreview) { - setBlockData(payload); - openModal(); - } - }, [openModal]); - - useEventListener('message', receiveMessage); const getTitle = useCallback(() => { const oldName = blockData?.displayName; @@ -95,7 +93,7 @@ const PreviewLibraryXBlockChanges = () => { try { await mutation.mutateAsync(blockData.downstreamBlockId); - sendMessageToIframe(messageTypes.refreshXBlock, null); + postChange(accept); } catch (e) { showToast(intl.formatMessage(failureMsg)); } finally { @@ -112,6 +110,7 @@ const PreviewLibraryXBlockChanges = () => { className="lib-preview-xblock-changes-modal" hasCloseButton isFullscreenOnMobile + isOverflowVisible={false} > @@ -119,6 +118,7 @@ const PreviewLibraryXBlockChanges = () => { + {alertNode} {getBody()} @@ -151,4 +151,42 @@ const PreviewLibraryXBlockChanges = () => { ); }; -export default PreviewLibraryXBlockChanges; +/** + * Wrapper over PreviewLibraryXBlockChanges to preview two xblock versions in a modal + * that depends on iframe message events to setBlockData and display modal. + */ +const IframePreviewLibraryXBlockChanges = () => { + const [blockData, setBlockData] = useState(undefined); + + // Main preview library modal toggle. + const [isModalOpen, openModal, closeModal] = useToggle(false); + + const { sendMessageToIframe } = useIframe(); + + const receiveMessage = useCallback(({ data }: { + data: { + payload: LibraryChangesMessageData; + type: string; + } + }) => { + const { payload, type } = data; + + if (type === messageTypes.showXBlockLibraryChangesPreview) { + setBlockData(payload); + openModal(); + } + }, [openModal]); + + useEventListener('message', receiveMessage); + + return ( + sendMessageToIframe(messageTypes.refreshXBlock, null)} + /> + ); +}; + +export default IframePreviewLibraryXBlockChanges; diff --git a/src/generic/loading-button/index.jsx b/src/generic/loading-button/index.jsx index bda47739b..f41bdea39 100644 --- a/src/generic/loading-button/index.jsx +++ b/src/generic/loading-button/index.jsx @@ -16,12 +16,18 @@ import PropTypes from 'prop-types'; * @param {string} props.label * @param {function=} props.onClick * @param {boolean=} props.disabled + * @param {string=} props.size + * @param {string=} props.variant + * @param {string=} props.className * @returns {JSX.Element} */ const LoadingButton = ({ label, onClick, disabled, + size, + variant, + className, }) => { const [state, setState] = useState(''); // This is used to prevent setting the isLoading state after the component has been unmounted. @@ -54,6 +60,9 @@ const LoadingButton = ({ onClick={loadingOnClick} labels={{ default: label }} state={state} + size={size} + variant={variant} + className={className} /> ); }; @@ -62,11 +71,17 @@ LoadingButton.propTypes = { label: PropTypes.string.isRequired, onClick: PropTypes.func, disabled: PropTypes.bool, + size: PropTypes.string, + variant: PropTypes.string, + className: PropTypes.string, }; LoadingButton.defaultProps = { onClick: undefined, disabled: undefined, + size: undefined, + variant: '', + className: '', }; export default LoadingButton; diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 88f22de53..53671cd7e 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -8,10 +8,10 @@ import { import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock'; import { mockContentLibrary, + mockGetUnpaginatedEntityLinks, mockLibraryBlockMetadata, mockXBlockAssets, mockXBlockOLX, - mockComponentDownstreamLinks, } from '../data/api.mocks'; import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; import ComponentDetails from './ComponentDetails'; @@ -21,7 +21,7 @@ mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); mockXBlockAssets.applyMock(); mockXBlockOLX.applyMock(); -mockComponentDownstreamLinks.applyMock(); +mockGetUnpaginatedEntityLinks.applyMock(); mockFetchIndexDocuments.applyMock(); const render = (usageKey: string) => baseRender(, { @@ -53,7 +53,7 @@ describe('', () => { }); it('should render the component usage', async () => { - render(mockComponentDownstreamLinks.usageKey); + render(mockLibraryBlockMetadata.usageKeyPublished); expect(await screen.findByText('Component Usage')).toBeInTheDocument(); const course1 = await screen.findByText('Course 1'); expect(course1).toBeInTheDocument(); diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx index 8dfb02327..c5a107b64 100644 --- a/src/library-authoring/component-info/ComponentInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentInfo.test.tsx @@ -7,19 +7,20 @@ import { import { mockContentLibrary, mockLibraryBlockMetadata, - mockComponentDownstreamLinks, + mockGetUnpaginatedEntityLinks, } from '../data/api.mocks'; -import { mockFetchIndexDocuments } from '../../search-manager/data/api.mock'; +import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock'; import { mockBroadcastChannel } from '../../generic/data/api.mock'; import { LibraryProvider } from '../common/context/LibraryContext'; import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; import ComponentInfo from './ComponentInfo'; import { getXBlockPublishApiUrl } from '../data/api'; +mockContentSearchConfig.applyMock(); mockBroadcastChannel(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); -mockComponentDownstreamLinks.applyMock(); +mockGetUnpaginatedEntityLinks.applyMock(); mockFetchIndexDocuments.applyMock(); jest.mock('./ComponentPreview', () => ({ __esModule: true, // Required when mocking 'default' export @@ -99,6 +100,7 @@ describe(' Sidebar', () => { }); it('should show publish confirmation on first publish', async () => { + initializeMocks(); render( , withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished), @@ -114,6 +116,7 @@ describe(' Sidebar', () => { }); it('should show publish confirmation on already published', async () => { + initializeMocks(); render( , withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChanges), @@ -130,6 +133,7 @@ describe(' Sidebar', () => { }); it('should show publish confirmation on already published empty downstreams', async () => { + initializeMocks(); render( , withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2), diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx index a12710e67..48c97fba8 100644 --- a/src/library-authoring/component-info/ComponentUsage.tsx +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -1,11 +1,12 @@ 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 AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; -import { useFetchIndexDocuments } from '../../search-manager'; -import { useComponentDownstreamLinks } from '../data/apiHooks'; +import { useContentSearchConnection, useContentSearchResults } from '../../search-manager'; import messages from './messages'; interface ComponentUsageProps { @@ -33,20 +34,27 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { isError: isErrorDownstreamLinks, error: errorDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useComponentDownstreamLinks(usageKey); + } = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey }); - const downstreamKeys = dataDownstreamLinks || []; + const downstreamKeys = useMemo( + () => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [], + [dataDownstreamLinks], + ); + const { client, indexName } = useContentSearchConnection(); const { - data: downstreamHits, + hits: downstreamHits, isError: isErrorIndexDocuments, error: errorIndexDocuments, isLoading: isLoadingIndexDocuments, - } = useFetchIndexDocuments({ - filter: [`usage_key IN ["${downstreamKeys.join('","')}"]`], + } = useContentSearchResults({ + client, + indexName, + searchKeywords: '', + extraFilter: [`usage_key IN ["${downstreamKeys.join('","')}"]`], limit: downstreamKeys.length, - attributesToRetrieve: ['usage_key', 'breadcrumbs', 'context_key'], enabled: !!downstreamKeys.length, + skipBlockTypeFetch: true, }); if (isErrorDownstreamLinks || isErrorIndexDocuments) { @@ -62,9 +70,9 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { } const componentUsage = downstreamHits.reduce((acc, hit) => { - const link = hit.breadcrumbs.at(-1); + const link = hit.breadcrumbs.at(-1) as { displayName: string, usageKey: string }; // istanbul ignore if: this should never happen. it is a type guard for the breadcrumb last item - if (!(link && ('usageKey' in link))) { + if (!link?.usageKey) { return acc; } diff --git a/src/library-authoring/components/PublishConfirmationModal.tsx b/src/library-authoring/components/PublishConfirmationModal.tsx index bb9e40f5b..8c98e234f 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 { useComponentDownstreamLinks } from '../data/apiHooks'; +import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks'; interface PublishConfirmationModalProps { isOpen: boolean, @@ -29,7 +29,7 @@ const PublishConfirmationModal = ({ const { data: dataDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useComponentDownstreamLinks(usageKey); + } = useUnpaginatedEntityLinks({ 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 d93f25b5b..21f697058 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -4,7 +4,9 @@ import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api. import { getBlockType } from '../../generic/key-utils'; import { createAxiosError } from '../../testUtils'; import contentLibrariesListV2 from '../__mocks__/contentLibrariesListV2'; +import downstreamLinkInfo from '../../search-manager/data/__mocks__/downstream-links.json'; import * as api from './api'; +import * as courseLibApi from '../../course-libraries/data/api'; /** * Mock for `getContentLibraryV2List()` @@ -569,28 +571,38 @@ 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 mockComponentDownstreamLinks( - usageKey: string, -): ReturnType { - const thisMock = mockComponentDownstreamLinks; - switch (usageKey) { - case thisMock.usageKey: return thisMock.componentUsage; - case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.componentUsage; - case mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2: return thisMock.emptyComponentUsage; +export async function mockGetUnpaginatedEntityLinks( + _downstreamContextKey?: string, + _readyToSync?: boolean, + upstreamUsageKey?: string, +): ReturnType { + const thisMock = mockGetUnpaginatedEntityLinks; + switch (upstreamUsageKey) { + case thisMock.upstreamUsageKey: return thisMock.response; + case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response; + case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage; default: return []; } } -mockComponentDownstreamLinks.usageKey = mockXBlockFields.usageKeyHtml; -mockComponentDownstreamLinks.componentUsage = [ - 'block-v1:org+course1+run+type@html+block@blockid1', - 'block-v1:org+course1+run+type@html+block@blockid2', - 'block-v1:org+course1+run+type@html+block@blockid3', - 'block-v1:org+course2+run+type@html+block@blockid1', -] satisfies Awaited>; -mockComponentDownstreamLinks.emptyUsageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; -mockComponentDownstreamLinks.emptyComponentUsage = [] as string[]; +mockGetUnpaginatedEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished; +mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ + id: 875, + upstreamContextTitle: 'CS problems 3', + upstreamVersion: 10, + readyToSync: true, + upstreamUsageKey: mockLibraryBlockMetadata.usageKeyPublished, + upstreamContextKey: 'lib:Axim:TEST2', + downstreamUsageKey: obj.usageKey, + downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', + versionSynced: 2, + versionDeclined: null, + 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[]; -mockComponentDownstreamLinks.applyMock = () => jest.spyOn( - api, - 'getComponentDownstreamLinks', -).mockImplementation(mockComponentDownstreamLinks); +mockGetUnpaginatedEntityLinks.applyMock = () => jest.spyOn( + courseLibApi, + 'getUnpaginatedEntityLinks', +).mockImplementation(mockGetUnpaginatedEntityLinks); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index c9d9917a4..49009f972 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -103,10 +103,6 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; * Get the URL for the content store api. */ export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/`; -/** - * Get the URL for the component downstream contexts API. - */ -export const getComponentDownstreamContextsApiUrl = (usageKey: string) => `${getContentStoreApiUrl()}upstream/${usageKey}/downstream-links`; export interface ContentLibrary { id: string; @@ -561,11 +557,3 @@ export async function updateComponentCollections(usageKey: string, collectionKey collection_keys: collectionKeys, }); } - -/** - * Fetch downstream links for a component. - */ -export async function getComponentDownstreamLinks(usageKey: string): Promise { - const { data } = await getAuthenticatedHttpClient().get(getComponentDownstreamContextsApiUrl(usageKey)); - return data; -} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 274a89e02..2b16615a3 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -46,7 +46,6 @@ import { deleteXBlockAsset, restoreLibraryBlock, getBlockTypes, - getComponentDownstreamLinks, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -561,14 +560,3 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin }, }); }; - -/** - * Get the downstream links of a component in a library - */ -export const useComponentDownstreamLinks = (usageKey: string) => ( - useQuery({ - queryKey: xblockQueryKeys.componentDownstreamLinks(usageKey), - queryFn: () => getComponentDownstreamLinks(usageKey), - enabled: !!usageKey, - }) -); diff --git a/src/search-manager/SearchSortWidget.tsx b/src/search-manager/SearchSortWidget.tsx index 6f2d78272..ce35e5d12 100644 --- a/src/search-manager/SearchSortWidget.tsx +++ b/src/search-manager/SearchSortWidget.tsx @@ -8,7 +8,13 @@ import messages from './messages'; import { SearchSortOption } from './data/api'; import { useSearchContext } from './SearchManager'; -export const SearchSortWidget = ({ iconOnly = false }: { iconOnly?: boolean }) => { +export const SearchSortWidget = ({ + iconOnly = false, + disableOptions, +}: { + iconOnly?: boolean; + disableOptions?: SearchSortOption[]; +}) => { const intl = useIntl(); const { searchSortOrder, @@ -22,43 +28,46 @@ export const SearchSortWidget = ({ iconOnly = false }: { iconOnly?: boolean }) = id: 'search-sort-option-most-relevant', name: intl.formatMessage(messages.searchSortMostRelevant), value: SearchSortOption.RELEVANCE, - show: (defaultSearchSortOrder === SearchSortOption.RELEVANCE), + show: ( + !disableOptions?.includes(SearchSortOption.RELEVANCE) + && defaultSearchSortOrder === SearchSortOption.RELEVANCE + ), }, { id: 'search-sort-option-recently-modified', name: intl.formatMessage(messages.searchSortRecentlyModified), value: SearchSortOption.RECENTLY_MODIFIED, - show: true, + show: !disableOptions?.includes(SearchSortOption.RECENTLY_MODIFIED), }, { id: 'search-sort-option-recently-published', name: intl.formatMessage(messages.searchSortRecentlyPublished), value: SearchSortOption.RECENTLY_PUBLISHED, - show: true, + show: !disableOptions?.includes(SearchSortOption.RECENTLY_PUBLISHED), }, { id: 'search-sort-option-title-az', name: intl.formatMessage(messages.searchSortTitleAZ), value: SearchSortOption.TITLE_AZ, - show: true, + show: !disableOptions?.includes(SearchSortOption.TITLE_AZ), }, { id: 'search-sort-option-title-za', name: intl.formatMessage(messages.searchSortTitleZA), value: SearchSortOption.TITLE_ZA, - show: true, + show: !disableOptions?.includes(SearchSortOption.TITLE_ZA), }, { id: 'search-sort-option-newest', name: intl.formatMessage(messages.searchSortNewest), value: SearchSortOption.NEWEST, - show: true, + show: !disableOptions?.includes(SearchSortOption.NEWEST), }, { id: 'search-sort-option-oldest', name: intl.formatMessage(messages.searchSortOldest), value: SearchSortOption.OLDEST, - show: true, + show: !disableOptions?.includes(SearchSortOption.OLDEST), }, ], [intl, defaultSearchSortOrder], diff --git a/src/search-manager/data/__mocks__/downstream-links.json b/src/search-manager/data/__mocks__/downstream-links.json index 926194133..3745a3e55 100644 --- a/src/search-manager/data/__mocks__/downstream-links.json +++ b/src/search-manager/data/__mocks__/downstream-links.json @@ -1,96 +1,100 @@ { "comment": "This is a mock of the response from Meilisearch for downstream links", - "estimatedTotalHits": 3, - "query": "", - "limit": 3, - "offset": 0, - "processingTimeMs": 1, - "hits": [ + "results": [ { - "usageKey": "block-v1:org+course1+run+type@html+block@blockid1", - "contextKey": "course-v1:org+course1+run", - "breadcrumbs": [ + "estimatedTotalHits": 3, + "query": "", + "limit": 3, + "offset": 0, + "processingTimeMs": 1, + "hits": [ { - "display_name": "Course 1" + "usageKey": "block-v1:org+course1+run+type@html+block@blockid1", + "contextKey": "course-v1:org+course1+run", + "breadcrumbs": [ + { + "display_name": "Course 1" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId1", + "display_name": "Unit 1" + } + ] }, { - "usage_key": "unit-v1:org+course1+run+section1", - "display_name": "Section 1" + "usage_key": "block-v1:org+course1+run+type@html+block@blockid2", + "contextKey": "course-v1:org+course1+run", + "breadcrumbs": [ + { + "display_name": "Course 1" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2", + "display_name": "Unit 2" + } + ] }, { - "usage_key": "unit-v1:org+course1+run+subsection1", - "display_name": "Sub Section 1" + "usage_key": "block-v1:org+course1+run+type@html+block@blockid3", + "contextKey": "course-v1:org+course1+run", + "breadcrumbs": [ + { + "display_name": "Course 1" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2", + "display_name": "Unit 2" + } + ] }, { - "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId1", - "display_name": "Unit 1" - } - ] - }, - { - "usage_key": "block-v1:org+course1+run+type@html+block@blockid2", - "contextKey": "course-v1:org+course1+run", - "breadcrumbs": [ - { - "display_name": "Course 1" - }, - { - "usage_key": "unit-v1:org+course1+run+section1", - "display_name": "Section 1" - }, - { - "usage_key": "unit-v1:org+course1+run+subsection1", - "display_name": "Sub Section 1" - }, - { - "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2", - "display_name": "Unit 2" - } - ] - }, - { - "usage_key": "block-v1:org+course1+run+type@html+block@blockid3", - "contextKey": "course-v1:org+course1+run", - "breadcrumbs": [ - { - "display_name": "Course 1" - }, - { - "usage_key": "unit-v1:org+course1+run+section1", - "display_name": "Section 1" - }, - { - "usage_key": "unit-v1:org+course1+run+subsection1", - "display_name": "Sub Section 1" - }, - { - "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2", - "display_name": "Unit 2" - } - ] - }, - { - "usage_key": "block-v1:org+course2+run+type@html+block@blockid1", - "contextKey": "course-v1:org+course2+run", - "breadcrumbs": [ - { - "display_name": "Course 2" - }, - { - "usage_key": "unit-v1:org+course2+run+section1", - "display_name": "Section 1" - }, - { - "usage_key": "unit-v1:org+course2+run+subsection1", - "display_name": "Sub Section 1" - }, - { - "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId3", - "display_name": "Unit 3" - }, - { - "usage_key": "block-v1:org+course2+run+type@itembank+block@itembankId3", - "display_name": "Problem Bank 3" + "usage_key": "block-v1:org+course2+run+type@html+block@blockid4", + "contextKey": "course-v1:org+course2+run", + "breadcrumbs": [ + { + "display_name": "Course 2" + }, + { + "usage_key": "unit-v1:org+course2+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course2+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId3", + "display_name": "Unit 3" + }, + { + "usage_key": "block-v1:org+course2+run+type@itembank+block@itembankId3", + "display_name": "Problem Bank 3" + } + ] } ] } diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts index 54f467185..24fa9ae4d 100644 --- a/src/search-manager/data/api.mock.ts +++ b/src/search-manager/data/api.mock.ts @@ -16,7 +16,6 @@ export async function mockContentSearchConfig(): ReturnType ( jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig) ); @@ -73,7 +72,7 @@ export async function mockFetchIndexDocuments() { mockFetchIndexDocuments.applyMock = () => { fetchMock.post( - mockContentSearchConfig.searchEndpointUrl, + mockContentSearchConfig.multisearchEndpointUrl, mockFetchIndexDocuments, { overwriteRoutes: true }, ); diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 4a7fe02d0..99dbcfa59 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -197,6 +197,7 @@ interface FetchSearchParams { /** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */ offset?: number, skipBlockTypeFetch?: boolean, + limit?: number, } export async function fetchSearchResults({ @@ -211,6 +212,7 @@ export async function fetchSearchResults({ sort, offset = 0, skipBlockTypeFetch = false, + limit = 20, }: FetchSearchParams): Promise<{ hits: (ContentHit | CollectionHit)[], nextOffset: number | undefined, @@ -232,8 +234,6 @@ export async function fetchSearchResults({ const tagsFilterFormatted = formatTagsFilter(tagsFilter); - const limit = 20; // How many results to retrieve per page. - // To filter normal block types and problem types as 'OR' query const typeFilters = [[ ...blockTypesFilterFormatted, @@ -521,29 +521,3 @@ export async function fetchTagsThatMatchKeyword({ return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit }; } - -/** - * Generic one-off fetch from meilisearch index. - */ -export const fetchIndexDocuments = async ( - client: MeiliSearch, - indexName: string, - filter?: Filter, - limit?: number, - attributesToRetrieve?: string[], - attributesToCrop?: string[], - sort?: SearchSortOption[], -): Promise => { - // Convert 'extraFilter' into an array - const filterFormatted = forceArray(filter); - - const { hits } = await client.index(indexName).search('', { - filter: filterFormatted, - limit, - attributesToRetrieve, - attributesToCrop, - sort, - }); - - return hits.map(formatSearchHit) as ContentHit[]; -}; diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index 857d59f41..a5ead78dc 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -11,7 +11,6 @@ import { getContentSearchConfig, fetchBlockTypes, type PublishStatus, - fetchIndexDocuments, } from './api'; /** @@ -59,6 +58,8 @@ export const useContentSearchResults = ({ tagsFilter = [], sort = [], skipBlockTypeFetch = false, + limit, + enabled = true, }: { /** The Meilisearch API client */ client?: MeiliSearch; @@ -79,9 +80,13 @@ export const useContentSearchResults = ({ sort?: SearchSortOption[]; /** If true, don't fetch the block types from the server */ skipBlockTypeFetch?: boolean; + /** Limit results */ + limit?: number; + /** Enable or disable api */ + enabled?: boolean; }) => { const query = useInfiniteQuery({ - enabled: client !== undefined && indexName !== undefined, + enabled: enabled && client !== undefined && indexName !== undefined, queryKey: [ 'content_search', 'results', @@ -115,6 +120,7 @@ export const useContentSearchResults = ({ // Note that if there are 20 results per page, the "second page" has offset=20, not 2. offset: pageParam, skipBlockTypeFetch, + limit, }); }, getNextPageParam: (lastPage) => lastPage.nextOffset, @@ -138,6 +144,7 @@ export const useContentSearchResults = ({ status: query.status, isLoading: query.isLoading, isError: query.isError, + error: query.error, isFetchingNextPage: query.isFetchingNextPage, // Call this to load more pages. We include some "safety" features recommended by the docs: this should never be // called while already fetching a page, and parameters (like 'event') should not be passed into fetchNextPage(). @@ -259,46 +266,3 @@ export const useGetBlockTypes = (extraFilters: Filter) => { queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters), }); }; - -interface UseFetchIndexDocumentsParams { - filter: Filter; - limit: number; - attributesToRetrieve?: string[]; - attributesToCrop?: string[]; - sort?: SearchSortOption[]; - enabled?: boolean; -} - -/** - * Fetch documents from the index. - */ -export const useFetchIndexDocuments = ({ - filter, - limit, - attributesToRetrieve, - attributesToCrop, - sort, - enabled = true, -} : UseFetchIndexDocumentsParams) => { - const { client, indexName } = useContentSearchConnection(); - return useQuery({ - enabled: enabled && client !== undefined && indexName !== undefined, - queryKey: [ - 'content_search', - client?.config.apiKey, - client?.config.host, - indexName, - filter, - 'generic-one-off', - ], - queryFn: enabled ? () => fetchIndexDocuments( - client!, - indexName!, - filter, - limit, - attributesToRetrieve, - attributesToCrop, - sort, - ) : undefined, - }); -}; diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts index 7cf40620d..cd73551f0 100644 --- a/src/search-manager/index.ts +++ b/src/search-manager/index.ts @@ -9,7 +9,7 @@ export { default as SearchKeywordsField } from './SearchKeywordsField'; export { default as SearchSortWidget } from './SearchSortWidget'; export { default as Stats } from './Stats'; export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api'; -export { useFetchIndexDocuments, useGetBlockTypes } from './data/apiHooks'; +export { useContentSearchConnection, useContentSearchResults, useGetBlockTypes } from './data/apiHooks'; export { TypesFilterData } from './hooks'; export type { CollectionHit, ContentHit, ContentHitTags } from './data/api'; diff --git a/src/testUtils.tsx b/src/testUtils.tsx index b15cd75a6..7419c7e39 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -191,6 +191,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined } axiosMock, mockShowToast: mockToastContext.showToast, mockToastAction: mockToastContext.toastAction, + queryClient, }; }