diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx index 06cf098e45..4753d0d52f 100644 --- a/src/course-libraries/CourseLibraries.test.tsx +++ b/src/course-libraries/CourseLibraries.test.tsx @@ -78,14 +78,14 @@ describe('', () => { const user = userEvent.setup(); await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); const allTab = await screen.findByRole('tab', { name: 'Libraries' }); - const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' }); // review tab should be open by default as outOfSyncCount is greater than 0 expect(reviewTab).toHaveAttribute('aria-selected', 'true'); await user.click(allTab); const alert = await screen.findByRole('alert'); expect(await within(alert).findByText( - '5 library components are out of sync. Review updates to accept or ignore changes', + '7 library components are out of sync. Review updates to accept or ignore changes', )).toBeInTheDocument(); expect(allTab).toHaveAttribute('aria-selected', 'true'); @@ -93,14 +93,14 @@ describe('', () => { await user.click(reviewBtn); expect(allTab).toHaveAttribute('aria-selected', 'false'); - expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true'); + expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true'); expect(alert).not.toBeInTheDocument(); }); it('hide alert on dismiss', async () => { const user = userEvent.setup(); await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); - const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' }); // review tab should be open by default as outOfSyncCount is greater than 0 expect(reviewTab).toHaveAttribute('aria-selected', 'true'); const allTab = await screen.findByRole('tab', { name: 'Libraries' }); @@ -109,7 +109,7 @@ describe('', () => { const alert = await screen.findByRole('alert'); expect(await within(alert).findByText( - '5 library components are out of sync. Review updates to accept or ignore changes', + '7 library components are out of sync. Review updates to accept or ignore changes', )).toBeInTheDocument(); const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' }); await user.click(dismissBtn); @@ -118,7 +118,7 @@ describe('', () => { // review updates button const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' }); await user.click(reviewActionBtn); - expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true'); + expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true'); }); it('show alert if max lastPublishedDate is greated than the local storage value', async () => { @@ -131,14 +131,14 @@ describe('', () => { await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); const allTab = await screen.findByRole('tab', { name: 'Libraries' }); - const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' }); // review tab should be open by default as outOfSyncCount is greater than 0 expect(reviewTab).toHaveAttribute('aria-selected', 'true'); await user.click(allTab); const alert = await screen.findByRole('alert'); expect(await within(alert).findByText( - '5 library components are out of sync. Review updates to accept or ignore changes', + '7 library components are out of sync. Review updates to accept or ignore changes', )).toBeInTheDocument(); }); @@ -152,14 +152,12 @@ describe('', () => { await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); const allTab = await screen.findByRole('tab', { name: 'Libraries' }); - const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' }); // review tab should be open by default as outOfSyncCount is greater than 0 expect(reviewTab).toHaveAttribute('aria-selected', 'true'); await user.click(allTab); expect(allTab).toHaveAttribute('aria-selected', 'true'); - screen.logTestingPlaygroundURL(); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); }); @@ -195,39 +193,61 @@ describe('', () => { }); it('shows all readyToSync links', async () => { - await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); + await renderCourseLibrariesReviewPage(); const updateBtns = await screen.findAllByRole('button', { name: 'Update' }); - expect(updateBtns.length).toEqual(5); + expect(updateBtns.length).toEqual(7); const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' }); - expect(ignoreBtns.length).toEqual(5); + expect(ignoreBtns.length).toEqual(7); }); - it('update changes works', async () => { + test.each([ + { + label: 'update changes works with components', + itemIndex: 0, + expectedToastMsg: 'Success! "Dropdown" is updated', + }, + { + label: 'update changes works with containers', + itemIndex: 5, + expectedToastMsg: 'Success! "Unit 1" is updated', + }, + ])('$label', async ({ itemIndex, expectedToastMsg }) => { const user = userEvent.setup(); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey; axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const updateBtns = await screen.findAllByRole('button', { name: 'Update' }); - expect(updateBtns.length).toEqual(5); - await user.click(updateBtns[0]); + expect(updateBtns.length).toEqual(7); + await user.click(updateBtns[itemIndex]); 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']); + expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] }); }); - it('update changes works in preview modal', async () => { + test.each([ + { + label: 'update changes works in preview modal with components', + itemIndex: 0, + expectedToastMsg: 'Success! "Dropdown" is updated', + }, + { + label: 'update changes works in preview modal with containers', + itemIndex: 5, + expectedToastMsg: 'Success! "Unit 1" is updated', + }, + ])('$label', async ({ itemIndex, expectedToastMsg }) => { const user = userEvent.setup(); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[itemIndex].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); - await user.click(previewBtns[0]); + expect(previewBtns.length).toEqual(7); + await user.click(previewBtns[itemIndex]); const dialog = await screen.findByRole('dialog'); const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' }); await user.click(confirmBtn); @@ -235,20 +255,31 @@ describe('', () => { 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']); + expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] }); }); - it('ignore change works', async () => { + test.each([ + { + label: 'ignore change works with components', + itemIndex: 0, + expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.', + }, + { + label: 'ignore change works with containers', + itemIndex: 5, + expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.', + }, + ])('$label', async ({ itemIndex, expectedToastMsg }) => { const user = userEvent.setup(); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey; axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' }); - expect(ignoreBtns.length).toEqual(5); + expect(ignoreBtns.length).toEqual(7); // Show confirmation modal on clicking ignore. - await user.click(ignoreBtns[0]); + await user.click(ignoreBtns[itemIndex]); const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' }); expect(dialog).toBeInTheDocument(); const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' }); @@ -257,21 +288,30 @@ describe('', () => { 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']); + expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] }); }); - it('ignore change works in preview', async () => { + test.each([ + { + label: 'ignore change works with components', + itemIndex: 0, + expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.', + }, + { + label: 'ignore change works with containers', + itemIndex: 5, + expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.', + }, + ])('$label', async ({ itemIndex, expectedToastMsg }) => { const user = userEvent.setup(); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[itemIndex].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); - await user.click(previewBtns[0]); + expect(previewBtns.length).toEqual(7); + await user.click(previewBtns[itemIndex]); const previewDialog = await screen.findByRole('dialog'); const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' }); await user.click(ignoreBtn); @@ -284,9 +324,7 @@ describe('', () => { 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']); + expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] }); }); }); diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx index ba3fa2c0e8..4e8440e4d7 100644 --- a/src/course-libraries/ReviewTabContent.tsx +++ b/src/course-libraries/ReviewTabContent.tsx @@ -41,23 +41,39 @@ interface Props { courseId: string; } -interface BlockCardProps { +interface ItemCardProps { info: ContentHit; + itemType: 'component' | 'container'; actions?: React.ReactNode; libraryName?: string; } -const BlockCard: React.FC = ({ info, actions, libraryName }) => { +const ItemCard: React.FC = ({ + info, + itemType, + actions, + libraryName, +}) => { const intl = useIntl(); - const componentIcon = getItemIcon(info.blockType); + const itemIcon = getItemIcon(info.blockType); const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>; - const getBlockLink = useCallback(() => { + const getItemLink = useCallback(() => { let key = info.usageKey; if (breadcrumbs?.length > 1) { key = breadcrumbs[breadcrumbs.length - 1].usageKey || key; } - return `${getConfig().STUDIO_BASE_URL}/container/${key}`; + + if (itemType === 'component') { + return `${getConfig().STUDIO_BASE_URL}/container/${key}`; + } + if (itemType === 'container') { + const encodedKey = encodeURIComponent(key); + return `${getConfig().STUDIO_BASE_URL}/course/${info.contextKey}?show=${encodedKey}`; + } + + // istanbul ignore next + return ''; }, [info]); return ( @@ -71,7 +87,7 @@ const BlockCard: React.FC = ({ info, actions, libraryName }) => - + @@ -87,14 +103,20 @@ const BlockCard: React.FC = ({ info, actions, libraryName }) => )} {intl.formatMessage(messages.breadcrumbLabel)} - - ({ label: breadcrumb.displayName }))} - spacer={/} - linkAs="span" - /> + + {info.blockType === 'chapter' ? ( + + {intl.formatMessage(messages.viewSectionInCourseLabel)} + + ) : ( + ({ label: breadcrumb.displayName }))} + spacer={/} + linkAs="span" + /> + )} @@ -105,16 +127,21 @@ const BlockCard: React.FC = ({ info, actions, libraryName }) => ); }; -const ComponentReviewList = ({ - outOfSyncComponents, +const ItemReviewList = ({ + outOfSyncItems, }: { - outOfSyncComponents: PublishableEntityLink[]; + outOfSyncItems: PublishableEntityLink[]; }) => { const intl = useIntl(); const { showToast } = useContext(ToastContext); const [blockData, setBlockData] = useState(undefined); // ignore changes confirmation modal toggle. const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + // Toggle preview changes modal + const [isPreviewModalOpen, openPreviewModal, closePreviewModal] = useToggle(false); + const acceptChangesMutation = useAcceptLibraryBlockChanges(); + const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); + const { hits, isLoading: isIndexDataLoading, @@ -133,32 +160,27 @@ const ComponentReviewList = ({ true, ); - const outOfSyncComponentsByKey = useMemo( - () => keyBy(outOfSyncComponents, 'downstreamUsageKey'), - [outOfSyncComponents], + const outOfSyncItemsByKey = useMemo( + () => keyBy(outOfSyncItems, 'downstreamUsageKey'), + [outOfSyncItems], ); const queryClient = useQueryClient(); - // Toggle preview changes modal - const [isModalOpen, openModal, closeModal] = useToggle(false); - const acceptChangesMutation = useAcceptLibraryBlockChanges(); - const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); - const setSelectedBlockData = useCallback((info: ContentHit) => { setBlockData({ displayName: info.displayName, downstreamBlockId: info.usageKey, - upstreamBlockId: outOfSyncComponentsByKey[info.usageKey].upstreamUsageKey, - upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced, - isVertical: info.blockType === 'vertical', + upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey, + upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced, + isContainer: info.blockType === 'vertical' || info.blockType === 'sequencial' || info.blockType === 'chapter', }); - }, [outOfSyncComponentsByKey]); + }, [outOfSyncItemsByKey]); // Show preview changes on review const onReview = useCallback((info: ContentHit) => { setSelectedBlockData(info); - openModal(); - }, [setSelectedBlockData, openModal]); + openPreviewModal(); + }, [setSelectedBlockData, openPreviewModal]); const onIgnoreClick = useCallback((info: ContentHit) => { setSelectedBlockData(info); @@ -166,9 +188,9 @@ const ComponentReviewList = ({ }, [setSelectedBlockData, openConfirmModal]); const reloadLinks = useCallback((usageKey: string) => { - const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey; - queryClient.invalidateQueries(courseLibrariesQueryKeys.courseLibraries(courseKey)); - }, [outOfSyncComponentsByKey]); + const courseKey = outOfSyncItemsByKey[usageKey].downstreamContextKey; + queryClient.invalidateQueries({ queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey) }); + }, [outOfSyncItemsByKey]); const postChange = (accept: boolean) => { // istanbul ignore if: this should never happen @@ -232,10 +254,11 @@ const ComponentReviewList = ({ return ( <> {downstreamInfo?.map((info) => ( - )} @@ -290,15 +313,19 @@ const ComponentReviewList = ({ const ReviewTabContent = ({ courseId }: Props) => { const intl = useIntl(); const { - data: outOfSyncComponents, - isLoading: isSyncComponentsLoading, + data: outOfSyncItems, + isLoading: isSyncItemsLoading, isError, error, - } = useEntityLinks({ courseId, readyToSync: true }); + } = useEntityLinks({ + courseId, + readyToSync: true, + useTopLevelParents: true, + }); const downstreamKeys = useMemo( - () => outOfSyncComponents?.map(link => link.downstreamUsageKey), - [outOfSyncComponents], + () => outOfSyncItems?.map(link => link.downstreamUsageKey), + [outOfSyncItems], ); const disableSortOptions = [ @@ -308,7 +335,7 @@ const ReviewTabContent = ({ courseId }: Props) => { SearchSortOption.RECENTLY_PUBLISHED, ]; - if (isSyncComponentsLoading) { + if (isSyncItemsLoading) { return ; } @@ -329,8 +356,8 @@ const ReviewTabContent = ({ courseId }: Props) => { - ); diff --git a/src/course-libraries/__mocks__/linkCourseSummary.json b/src/course-libraries/__mocks__/linkCourseSummary.json index 05039086d2..3b050665d9 100644 --- a/src/course-libraries/__mocks__/linkCourseSummary.json +++ b/src/course-libraries/__mocks__/linkCourseSummary.json @@ -2,7 +2,7 @@ { "upstreamContextTitle": "CS problems 3", "upstreamContextKey": "lib:OpenedX:CSPROB3", - "readyToSyncCount": 5, + "readyToSyncCount": 7, "totalCount": 14, "lastPublishedAt": "2025-05-01T20:20:44.989042Z" }, diff --git a/src/course-libraries/__mocks__/linkDetailsFromIndex.json b/src/course-libraries/__mocks__/linkDetailsFromIndex.json index 76e137955a..4a1f2c6d2c 100644 --- a/src/course-libraries/__mocks__/linkDetailsFromIndex.json +++ b/src/course-libraries/__mocks__/linkDetailsFromIndex.json @@ -364,13 +364,125 @@ "org": "OpenEdx", "access_id": "4" } + }, + { + "display_name": "Unit 1", + "block_id": "20f2c12b8b7f4b4bab7a900576c78ad8", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "This is a super unit" + }, + "description": "This is a super unit", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeverticalblocka20f2c12b8b7f4b4bab7a900576c78ad8-efa48aff", + "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" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8", + "block_type": "vertical", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": 4, + "_formatted": { + "display_name": "Unit 1", + "block_id": "20f2c12b8b7f4b4bab7a900576c78ad8", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "This is a super unit" + }, + "description": "This is a super unit", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypeverticalblocka20f2c12b8b7f4b4bab7a900576c78ad8-efa48aff", + "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" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8", + "block_type": "vertical", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": "4" + } + }, + { + "display_name": "Section 1", + "block_id": "20f2c12b8b7e4b4cab7a900576c78cv5", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "This is a super section" + }, + "description": "This is a super section", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypechapterblocka20f2c12b8b7e4b4cab7a900576c78cv5", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5", + "block_type": "chapter", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": 4, + "_formatted": { + "display_name": "Section 1", + "block_id": "20f2c12b8b7e4b4cab7a900576c78cv5", + "content": { + "problem_types": [ + "optionresponse" + ], + "capa_content": "This is a super section" + }, + "description": "This is a super section", + "tags": {}, + "id": "block-v1itcracydemoxcoursextypechapterblocka20f2c12b8b7e4b4cab7a900576c78cv5", + "type": "course_block", + "breadcrumbs": [ + { + "display_name": "OpenedX Demo Course" + } + ], + "usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5", + "block_type": "chapter", + "context_key": "course-v1:OpenEdx+DemoX+CourseX", + "org": "OpenEdx", + "access_id": "4" + } } ], "query": "", "processingTimeMs": 8, "limit": 20, "offset": 0, - "estimatedTotalHits": 5 + "estimatedTotalHits": 6 } ] } diff --git a/src/course-libraries/__mocks__/publishableEntityLinks.json b/src/course-libraries/__mocks__/publishableEntityLinks.json index 1dac4b2dbd..daf938525f 100644 --- a/src/course-libraries/__mocks__/publishableEntityLinks.json +++ b/src/course-libraries/__mocks__/publishableEntityLinks.json @@ -4,7 +4,8 @@ "upstreamContextTitle": "CS problems 3", "upstreamVersion": 10, "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamType": "component", "upstreamContextKey": "lib:OpenedX:CSPROB3", "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", @@ -18,7 +19,8 @@ "upstreamContextTitle": "CS problems 3", "upstreamVersion": 10, "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamType": "component", "upstreamContextKey": "lib:OpenedX:CSPROB3", "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", @@ -32,7 +34,8 @@ "upstreamContextTitle": "CS problems 3", "upstreamVersion": 26, "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", + "upstreamKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", + "upstreamType": "component", "upstreamContextKey": "lib:OpenedX:CSPROB3", "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", @@ -46,7 +49,8 @@ "upstreamContextTitle": "CS problems 3", "upstreamVersion": 10, "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamType": "component", "upstreamContextKey": "lib:OpenedX:CSPROB3", "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", @@ -60,7 +64,8 @@ "upstreamContextTitle": "CS problems 3", "upstreamVersion": 10, "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamType": "component", "upstreamContextKey": "lib:OpenedX:CSPROB3", "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", @@ -68,5 +73,35 @@ "versionDeclined": null, "created": "2025-02-08T14:07:05.588484Z", "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 891, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 17, + "readyToSync": true, + "upstreamKey": "lct:OpenedX:CSPROB3:unit:cb367f92-bf7d-4d08-86cd-aae9efa48aff", + "upstreamType": "container", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8", + "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": 892, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 3, + "readyToSync": true, + "upstreamKey": "lct:OpenedX:CSPROB3:section:9a90e4e4-cdf9-48ce-89b5-a8f9f07ccbfc", + "upstreamType": "container", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5", + "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 3614a51517..b4d2fc9a1b 100644 --- a/src/course-libraries/data/api.mocks.ts +++ b/src/course-libraries/data/api.mocks.ts @@ -1,19 +1,15 @@ /* istanbul ignore file */ // eslint-disable-next-line import/no-extraneous-dependencies import fetchMock from 'fetch-mock-jest'; +import * as libApi from '@src/library-authoring/data/api'; +import { createAxiosError } from '@src/testUtils'; + 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 `getEntityLinks()` - * - * This mock returns a fixed response for the downstreamContextKey. - */ export async function mockGetEntityLinks( downstreamContextKey?: string, readyToSync?: boolean, @@ -61,7 +57,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext( throw createAxiosError({ code: 404, message: 'Not found.', - path: api.getEntityLinksByDownstreamContextUrl(), + path: api.getEntityLinksSummaryByDownstreamContextUrl(courseId), }); case mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading: return new Promise(() => {}); diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index b503731409..1db39ec29c 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -3,9 +3,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`; -export const getContainerEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstream-containers/`; - +export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams-all/`; export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`; export interface PaginatedData { @@ -33,7 +31,7 @@ export interface BasePublishableEntityLink { readyToSync: boolean; } -export interface PublishableEntityLink extends BasePublishableEntityLink { +export interface ComponentPublishableEntityLink extends BasePublishableEntityLink { upstreamUsageKey: string; } @@ -41,6 +39,11 @@ export interface ContainerPublishableEntityLink extends BasePublishableEntityLin upstreamContainerKey: string; } +export interface PublishableEntityLink extends BasePublishableEntityLink { + upstreamKey: string; + upstreamType: 'component' | 'container'; +} + export interface PublishableEntityLinkSummary { upstreamContextKey: string; upstreamContextTitle: string; @@ -52,31 +55,18 @@ export interface PublishableEntityLinkSummary { export const getEntityLinks = async ( downstreamContextKey?: string, readyToSync?: boolean, - upstreamUsageKey?: string, + useTopLevelParents?: boolean, + upstreamKey?: string, + contentType?: 'all' | 'components' | 'containers', ): 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 getContainerEntityLinks = async ( - downstreamContextKey?: string, - readyToSync?: boolean, - upstreamContainerKey?: string, -): Promise => { - const { data } = await getAuthenticatedHttpClient() - .get(getContainerEntityLinksByDownstreamContextUrl(), { - params: { - course_id: downstreamContextKey, - ready_to_sync: readyToSync, - upstream_container_key: upstreamContainerKey, + upstream_key: upstreamKey, + use_top_level_parents: useTopLevelParents, + item_type: contentType, no_page: true, }, }); diff --git a/src/course-libraries/data/apiHooks.test.tsx b/src/course-libraries/data/apiHooks.test.tsx index f1063ce803..4f8c5f9a9b 100644 --- a/src/course-libraries/data/apiHooks.test.tsx +++ b/src/course-libraries/data/apiHooks.test.tsx @@ -39,6 +39,24 @@ describe('course libraries api hooks', () => { axiosMock.reset(); }); + it('should return component links for course', async () => { + const courseId = 'course-v1:some+key'; + const url = getEntityLinksByDownstreamContextUrl(); + axiosMock.onGet(url).reply(200, []); + const { result } = renderHook(() => useEntityLinks({ courseId, contentType: 'components' }), { 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_key: undefined, + no_page: true, + item_type: 'components', + }); + }); + it('should return links for course', async () => { const courseId = 'course-v1:some+key'; const url = getEntityLinksByDownstreamContextUrl(); @@ -53,6 +71,7 @@ describe('course libraries api hooks', () => { ready_to_sync: undefined, upstream_usage_key: undefined, no_page: true, + content_type: undefined, }); }); }); diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index 506687b15d..5ec4bdc08c 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,61 +1,62 @@ import { useQuery, } from '@tanstack/react-query'; -import { getContainerEntityLinks, getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api'; +import { getEntityLinksSummaryByDownstreamContext, getEntityLinks } from './api'; export const courseLibrariesQueryKeys = { all: ['courseLibraries'], courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId], courseReadyToSyncLibraries: ({ - courseId, readyToSync, upstreamUsageKey, upstreamContainerKey, + contentType, courseId, readyToSync, upstreamKey, }: { + contentType?: 'all' | 'components' | 'containers', courseId?: string, readyToSync?: boolean, - upstreamUsageKey?: string, - upstreamContainerKey?: string, + upstreamKey?: string, pageSize?: number, }) => { const key: Array = [...courseLibrariesQueryKeys.all]; if (courseId !== undefined) { key.push(courseId); } + if (contentType !== undefined) { + key.push(contentType); + } if (readyToSync !== undefined) { key.push(readyToSync); } - if (upstreamUsageKey !== undefined) { - key.push(upstreamUsageKey); - } - if (upstreamContainerKey !== undefined) { - key.push(upstreamContainerKey); + if (upstreamKey !== undefined) { + key.push(upstreamKey); } return key; }, courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'], }; -/** - * Hook to fetch list of publishable entity links by course key. - * (That is, get a list of the library components used in the given course.) - */ export const useEntityLinks = ({ - courseId, readyToSync, upstreamUsageKey, + courseId, readyToSync, useTopLevelParents, upstreamKey, contentType, }: { courseId?: string, readyToSync?: boolean, - upstreamUsageKey?: string, + useTopLevelParents?: boolean, + upstreamKey?: string, + contentType?: 'all' | 'components' | 'containers', }) => ( useQuery({ queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({ + contentType: contentType ?? 'all', courseId, readyToSync, - upstreamUsageKey, + upstreamKey, }), queryFn: () => getEntityLinks( courseId, readyToSync, - upstreamUsageKey, + useTopLevelParents, + upstreamKey, + contentType, ), - enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined, + enabled: courseId !== undefined || upstreamKey !== undefined || readyToSync !== undefined, }) ); @@ -69,29 +70,3 @@ export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => ( enabled: courseId !== undefined, }) ); - -/** - * Hook to fetch list of publishable entity links for containers by course key. - * (That is, get a list of the library containers used in the given course.) - */ -export const useContainerEntityLinks = ({ - courseId, readyToSync, upstreamContainerKey, -}: { - courseId?: string, - readyToSync?: boolean, - upstreamContainerKey?: string, -}) => ( - useQuery({ - queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({ - courseId, - readyToSync, - upstreamContainerKey, - }), - queryFn: () => getContainerEntityLinks( - courseId, - readyToSync, - upstreamContainerKey, - ), - enabled: courseId !== undefined || upstreamContainerKey !== undefined || readyToSync !== undefined, - }) -); diff --git a/src/course-libraries/messages.ts b/src/course-libraries/messages.ts index 8dc7ab0980..8c817e3b23 100644 --- a/src/course-libraries/messages.ts +++ b/src/course-libraries/messages.ts @@ -39,7 +39,7 @@ const messages = defineMessages({ breadcrumbLabel: { id: 'course-authoring.course-libraries.downstream-block.breadcrumb.label', defaultMessage: 'Location:', - description: 'label for breadcrumb in component cards in course libraries page.', + description: 'Label for breadcrumb in component cards in course libraries page.', }, totalComponentLabel: { id: 'course-authoring.course-libraries.libcard.total-component.label', @@ -79,17 +79,17 @@ const messages = defineMessages({ cardReviewContentBtn: { id: 'course-authoring.course-libraries.review-tab.libcard.review-btn-text', defaultMessage: 'Review Updates', - description: 'Card review button for component in review tab', + description: 'Card review button for component/container 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', + description: 'Card update button for component/container 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', + description: 'Card ignore button for component/container in review tab', }, updateSingleBlockSuccess: { id: 'course-authoring.course-libraries.review-tab.libcard.update-success-toast', @@ -116,6 +116,11 @@ const messages = defineMessages({ defaultMessage: 'Something went wrong! Could not fetch results.', description: 'Generic error message displayed when fetching link data fails.', }, + viewSectionInCourseLabel: { + id: 'course-authoring.course-libraries.review-tab.libcard.view-section.label', + defaultMessage: 'View Section in Course', + description: 'Label of the button to see the section in the course', + }, }); export default messages; diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index d602ca4f44..d1666e2e8e 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -98,7 +98,7 @@ const UnitCard = ({ downstreamBlockId: id, upstreamBlockId: upstreamInfo.upstreamRef, upstreamBlockVersionSynced: upstreamInfo.versionSynced, - isVertical: true, + isContainer: true, }; }, [upstreamInfo]); diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index c6e7e63195..c475fed839 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -19,7 +19,7 @@ const defaultEventData: LibraryChangesMessageData = { downstreamBlockId: usageKey, upstreamBlockId: 'lct:org:lib1:unit:1', upstreamBlockVersionSynced: 1, - isVertical: false, + isContainer: false, }; const mockSendMessageToIframe = jest.fn(); @@ -66,9 +66,9 @@ describe('', () => { }); it('renders default displayName for units with no displayName', async () => { - render({ ...defaultEventData, isVertical: true, displayName: '' }); + render({ ...defaultEventData, isContainer: true, displayName: '' }); - expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument(); + expect(await screen.findByText('Preview changes: Container')).toBeInTheDocument(); }); it('renders default displayName for components with no displayName', async () => { diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index ea3ba6dac6..dceedc701d 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -22,7 +22,7 @@ export interface LibraryChangesMessageData { downstreamBlockId: string, upstreamBlockId: string, upstreamBlockVersionSynced: number, - isVertical: boolean, + isContainer: boolean, } export interface PreviewLibraryXBlockChangesProps { @@ -60,7 +60,7 @@ export const PreviewLibraryXBlockChanges = ({ usageKey={blockData.upstreamBlockId} oldVersion={blockData.upstreamBlockVersionSynced || 'published'} newVersion="published" - isContainer={blockData.isVertical} + isContainer={blockData.isContainer} /> ); }, [blockData]); @@ -85,8 +85,8 @@ export const PreviewLibraryXBlockChanges = ({ }, [blockData]); const defaultTitle = intl.formatMessage( - blockData.isVertical - ? messages.defaultUnitTitle + blockData.isContainer + ? messages.defaultContainerTitle : messages.defaultComponentTitle, ); const title = blockData.displayName diff --git a/src/course-unit/preview-changes/messages.ts b/src/course-unit/preview-changes/messages.ts index d4d8007338..c91deeb8b3 100644 --- a/src/course-unit/preview-changes/messages.ts +++ b/src/course-unit/preview-changes/messages.ts @@ -6,10 +6,10 @@ const messages = defineMessages({ defaultMessage: 'Preview changes: {blockTitle}', description: 'Preview changes modal title text', }, - defaultUnitTitle: { + defaultContainerTitle: { id: 'authoring.course-unit.preview-changes.modal-default-unit-title', - defaultMessage: 'Preview changes: Unit', - description: 'Preview changes modal default title text for units', + defaultMessage: 'Preview changes: Container', + description: 'Preview changes modal default title text for containers', }, defaultComponentTitle: { id: 'authoring.course-unit.preview-changes.modal-default-component-title', diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 92ffc2a993..c6a20d1ce2 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -4,8 +4,9 @@ import { render as baseRender, screen, fireEvent, -} from '../../testUtils'; -import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock'; +} from '@src/testUtils'; +import { mockFetchIndexDocuments, mockContentSearchConfig } from '@src/search-manager/data/api.mock'; + import { mockContentLibrary, mockGetEntityLinks, diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx index 9f4b94d628..e4a7418238 100644 --- a/src/library-authoring/component-info/ComponentInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentInfo.test.tsx @@ -3,13 +3,14 @@ import { render, screen, waitFor, -} from '../../testUtils'; +} from '@src/testUtils'; +import { mockContentSearchConfig, mockFetchIndexDocuments } from '@src/search-manager/data/api.mock'; + import { mockContentLibrary, mockLibraryBlockMetadata, mockGetEntityLinks, } from '../data/api.mocks'; -import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock'; import { LibraryProvider } from '../common/context/LibraryContext'; import { SidebarBodyItemId, SidebarProvider } from '../common/context/SidebarContext'; import ComponentInfo from './ComponentInfo'; diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx index 72c9d73a06..27803a7743 100644 --- a/src/library-authoring/component-info/ComponentUsage.tsx +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -1,11 +1,13 @@ +import { useMemo } from 'react'; + 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 { useEntityLinks } from '../../course-libraries/data/apiHooks'; -import AlertError from '../../generic/alert-error'; -import Loading from '../../generic/Loading'; +import { useEntityLinks } from '@src/course-libraries/data/apiHooks'; +import AlertError from '@src/generic/alert-error'; +import Loading from '@src/generic/Loading'; + import messages from './messages'; import { useContentFromSearchIndex } from '../data/apiHooks'; @@ -34,7 +36,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { isError: isErrorDownstreamLinks, error: errorDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useEntityLinks({ upstreamUsageKey: usageKey }); + } = useEntityLinks({ upstreamKey: usageKey, contentType: 'components' }); const downstreamKeys = useMemo( () => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [], diff --git a/src/library-authoring/components/PublishConfirmationModal.tsx b/src/library-authoring/components/PublishConfirmationModal.tsx index eeadc84e9a..f698b976b8 100644 --- a/src/library-authoring/components/PublishConfirmationModal.tsx +++ b/src/library-authoring/components/PublishConfirmationModal.tsx @@ -1,11 +1,12 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Alert, Button } from '@openedx/paragon'; -import BaseModal from '../../editors/sharedComponents/BaseModal'; +import BaseModal from '@src/editors/sharedComponents/BaseModal'; +import { useEntityLinks } from '@src/course-libraries/data/apiHooks'; + import messages from './messages'; import infoMessages from '../component-info/messages'; import { ComponentUsage } from '../component-info/ComponentUsage'; -import { useEntityLinks } from '../../course-libraries/data/apiHooks'; interface PublishConfirmationModalProps { isOpen: boolean, @@ -29,7 +30,7 @@ const PublishConfirmationModal = ({ const { data: dataDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useEntityLinks({ upstreamUsageKey: usageKey }); + } = useEntityLinks({ upstreamKey: usageKey, contentType: 'components' }); const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0; diff --git a/src/library-authoring/containers/ContainerDeleter.test.tsx b/src/library-authoring/containers/ContainerDeleter.test.tsx index e0a9f6869f..2909ba1d0c 100644 --- a/src/library-authoring/containers/ContainerDeleter.test.tsx +++ b/src/library-authoring/containers/ContainerDeleter.test.tsx @@ -1,11 +1,13 @@ -import type { ToastActionData } from '../../generic/toast-context'; +import type { ToastActionData } from '@src/generic/toast-context'; import { fireEvent, render, screen, initializeMocks, waitFor, -} from '../../testUtils'; +} from '@src/testUtils'; +import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock'; + import { LibraryProvider } from '../common/context/LibraryContext'; import { SidebarProvider } from '../common/context/SidebarContext'; import { @@ -13,14 +15,13 @@ import { mockGetContainerMetadata, mockDeleteContainer, mockRestoreContainer, - mockGetContainerEntityLinks, + mockGetEntityLinks, } from '../data/api.mocks'; -import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock'; import ContainerDeleter from './ContainerDeleter'; mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when loads data mockContentSearchConfig.applyMock(); -mockGetContainerEntityLinks.applyMock(); +mockGetEntityLinks.applyMock(); const mockDelete = mockDeleteContainer.applyMock(); const mockRestore = mockRestoreContainer.applyMock(); diff --git a/src/library-authoring/containers/ContainerDeleter.tsx b/src/library-authoring/containers/ContainerDeleter.tsx index 9dc9b9b4b9..c3497d5ce7 100644 --- a/src/library-authoring/containers/ContainerDeleter.tsx +++ b/src/library-authoring/containers/ContainerDeleter.tsx @@ -3,15 +3,16 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { Error, Warning, School } from '@openedx/paragon/icons'; -import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import DeleteModal from '@src/generic/delete-modal/DeleteModal'; +import { ToastContext } from '@src/generic/toast-context'; +import { ContainerType } from '@src/generic/key-utils'; +import { ContainerHit } from '@src/search-manager'; +import { useEntityLinks } from '@src/course-libraries/data/apiHooks'; +import { LoadingSpinner } from '@src/generic/Loading'; + import { useSidebarContext } from '../common/context/SidebarContext'; -import { ToastContext } from '../../generic/toast-context'; import { useContentFromSearchIndex, useDeleteContainer, useRestoreContainer } from '../data/apiHooks'; import messages from './messages'; -import { ContainerType } from '../../generic/key-utils'; -import { ContainerHit } from '../../search-manager'; -import { useContainerEntityLinks } from '../../course-libraries/data/apiHooks'; -import { LoadingSpinner } from '../../generic/Loading'; type ContainerDeleterProps = { isOpen: boolean, @@ -37,7 +38,7 @@ const ContainerDeleter = ({ const { data: dataDownstreamLinks, isLoading: linksIsLoading, - } = useContainerEntityLinks({ upstreamContainerKey: containerId }); + } = useEntityLinks({ upstreamKey: containerId, contentType: 'containers' }); const downstreamCount = dataDownstreamLinks?.length ?? 0; const messageMap = useMemo(() => { diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 2326959174..dd13fb11a4 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1,12 +1,14 @@ /* istanbul ignore file */ import { camelCaseObject } from '@edx/frontend-platform'; -import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks'; -import { ContainerType, getBlockType } from '../../generic/key-utils'; -import { createAxiosError } from '../../testUtils'; + +import { mockContentTaxonomyTagsData } from '@src/content-tags-drawer/data/api.mocks'; +import { ContainerType, getBlockType } from '@src/generic/key-utils'; +import { createAxiosError } from '@src/testUtils'; +import downstreamLinkInfo from '@src/search-manager/data/__mocks__/downstream-links.json'; +import * as courseLibApi from '@src/course-libraries/data/api'; + 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()` @@ -769,23 +771,36 @@ mockBlockTypesMetadata.applyMock = () => jest.spyOn(api, 'getBlockTypes').mockIm export async function mockGetEntityLinks( _downstreamContextKey?: string, _readyToSync?: boolean, - upstreamUsageKey?: string, + _useTopLevelParents?: boolean, + upstreamKey?: string, + contentType?: 'all' | 'components' | 'containers', ): ReturnType { const thisMock = mockGetEntityLinks; - switch (upstreamUsageKey) { - case thisMock.upstreamContainerKey: return thisMock.response; - case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response; - case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage; - default: return []; + if (contentType === 'components') { + switch (upstreamKey) { + case thisMock.upstreamContainerKey: return thisMock.componentResponse; + case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.componentResponse; + case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage; + default: return []; + } + } else if (contentType === 'containers') { + switch (upstreamKey) { + case thisMock.unitKey: return thisMock.unitResponse; + case thisMock.subsectionKey: return thisMock.subsectionResponse; + case thisMock.sectionKey: return thisMock.sectionResponse; + default: return []; + } } + return thisMock.allResponse; } + mockGetEntityLinks.upstreamContainerKey = mockLibraryBlockMetadata.usageKeyPublished; -mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ +mockGetEntityLinks.componentResponse = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ id: 875, upstreamContextTitle: 'CS problems 3', upstreamVersion: 10, readyToSync: true, - upstreamUsageKey: mockLibraryBlockMetadata.usageKeyPublished, + upstreamKey: mockLibraryBlockMetadata.usageKeyPublished, upstreamContextKey: 'lib:Axim:TEST2', downstreamUsageKey: obj.usageKey, downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', @@ -793,36 +808,18 @@ mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usa versionDeclined: null, created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', + upstreamType: 'component', })); mockGetEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty'; mockGetEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[]; - -mockGetEntityLinks.applyMock = () => jest.spyOn( - courseLibApi, - 'getEntityLinks', -).mockImplementation(mockGetEntityLinks); - -export async function mockGetContainerEntityLinks( - _downstreamContextKey?: string, - _readyToSync?: boolean, - upstreamContainerKey?: string, -): ReturnType { - const thisMock = mockGetContainerEntityLinks; - switch (upstreamContainerKey) { - case thisMock.unitKey: return thisMock.unitResponse; - case thisMock.subsectionKey: return thisMock.subsectionResponse; - case thisMock.sectionKey: return thisMock.sectionResponse; - default: return []; - } -} -mockGetContainerEntityLinks.unitKey = mockGetContainerMetadata.unitId; -mockGetContainerEntityLinks.unitResponse = [ +mockGetEntityLinks.unitKey = mockGetContainerMetadata.unitId; +mockGetEntityLinks.unitResponse = [ { id: 1, upstreamContextTitle: 'CS problems 3', upstreamVersion: 1, readyToSync: false, - upstreamContainerKey: mockGetContainerEntityLinks.unitKey, + upstreamKey: mockGetEntityLinks.unitKey, upstreamContextKey: 'lib:Axim:TEST2', downstreamUsageKey: 'some-key', downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', @@ -830,13 +827,14 @@ mockGetContainerEntityLinks.unitResponse = [ versionDeclined: null, created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', + upstreamType: 'container', }, { id: 1, upstreamContextTitle: 'CS problems 3', upstreamVersion: 1, readyToSync: false, - upstreamContainerKey: mockGetContainerEntityLinks.unitKey, + upstreamKey: mockGetEntityLinks.unitKey, upstreamContextKey: 'lib:Axim:TEST2', downstreamUsageKey: 'some-key-1', downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', @@ -844,16 +842,17 @@ mockGetContainerEntityLinks.unitResponse = [ versionDeclined: null, created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', + upstreamType: 'container', }, -]; -mockGetContainerEntityLinks.subsectionKey = mockGetContainerMetadata.subsectionId; -mockGetContainerEntityLinks.subsectionResponse = [ +] as courseLibApi.PublishableEntityLink[]; +mockGetEntityLinks.subsectionKey = mockGetContainerMetadata.subsectionId; +mockGetEntityLinks.subsectionResponse = [ { id: 1, upstreamContextTitle: 'CS problems 3', upstreamVersion: 1, readyToSync: false, - upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey, + upstreamKey: mockGetEntityLinks.subsectionKey, upstreamContextKey: 'lib:Axim:TEST2', downstreamUsageKey: 'some-subsection-key', downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', @@ -861,13 +860,14 @@ mockGetContainerEntityLinks.subsectionResponse = [ versionDeclined: null, created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', + upstreamType: 'container', }, { id: 1, upstreamContextTitle: 'CS problems 3', upstreamVersion: 1, readyToSync: false, - upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey, + upstreamKey: mockGetEntityLinks.subsectionKey, upstreamContextKey: 'lib:Axim:TEST2', downstreamUsageKey: 'some-subsection-key-1', downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', @@ -875,16 +875,17 @@ mockGetContainerEntityLinks.subsectionResponse = [ versionDeclined: null, created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', + upstreamType: 'container', }, -]; -mockGetContainerEntityLinks.sectionKey = mockGetContainerMetadata.sectionId; -mockGetContainerEntityLinks.sectionResponse = [ +] as courseLibApi.PublishableEntityLink[]; +mockGetEntityLinks.sectionKey = mockGetContainerMetadata.sectionId; +mockGetEntityLinks.sectionResponse = [ { id: 1, upstreamContextTitle: 'CS problems 3', upstreamVersion: 1, readyToSync: false, - upstreamContainerKey: mockGetContainerEntityLinks.sectionKey, + upstreamKey: mockGetEntityLinks.sectionKey, upstreamContextKey: 'lib:Axim:TEST2', downstreamUsageKey: 'some-section-key', downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', @@ -892,13 +893,14 @@ mockGetContainerEntityLinks.sectionResponse = [ versionDeclined: null, created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', + upstreamType: 'container', }, { id: 1, upstreamContextTitle: 'CS problems 3', upstreamVersion: 1, readyToSync: false, - upstreamContainerKey: mockGetContainerEntityLinks.sectionKey, + upstreamKey: mockGetEntityLinks.sectionKey, upstreamContextKey: 'lib:Axim:TEST2', downstreamUsageKey: 'some-section-key-1', downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', @@ -906,10 +908,16 @@ mockGetContainerEntityLinks.sectionResponse = [ versionDeclined: null, created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', + upstreamType: 'container', }, +] as courseLibApi.PublishableEntityLink[]; +mockGetEntityLinks.allResponse = [ + ...mockGetEntityLinks.componentResponse, + ...mockGetEntityLinks.unitResponse, + ...mockGetEntityLinks.subsectionResponse, + ...mockGetEntityLinks.sectionResponse, ]; - -mockGetContainerEntityLinks.applyMock = () => jest.spyOn( +mockGetEntityLinks.applyMock = () => jest.spyOn( courseLibApi, - 'getContainerEntityLinks', -).mockImplementation(mockGetContainerEntityLinks); + 'getEntityLinks', +).mockImplementation(mockGetEntityLinks);