diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 4bef34268..7f39be499 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -25,6 +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'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -56,6 +57,10 @@ const CourseAuthoringRoutes = () => { path="course_info" element={} /> + } + /> } diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx new file mode 100644 index 000000000..659f73eba --- /dev/null +++ b/src/course-libraries/CourseLibraries.test.tsx @@ -0,0 +1,148 @@ +import fetchMock from 'fetch-mock-jest'; +import { cloneDeep } from 'lodash'; + +import userEvent from '@testing-library/user-event'; +import { + initializeMocks, + render, + screen, + 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'; + +mockContentSearchConfig.applyMock(); +mockGetEntityLinksByDownstreamContext.applyMock(); + +const searchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search'; + +jest.mock('../studio-home/hooks', () => ({ + useStudioHome: () => ({ + isLoadingPage: false, + isFailedLoadingPage: false, + librariesV2Enabled: true, + }), +})); + +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; + }); + }); + + const renderCourseLibrariesPage = async (courseKey?: string) => { + const courseId = courseKey || mockGetEntityLinksByDownstreamContext.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); + 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); + 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); + 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', + )).toBeInTheDocument(); + const allTab = await screen.findByRole('tab', { name: 'Libraries' }); + expect(allTab).toHaveAttribute('aria-selected', 'true'); + + const reviewBtn = await screen.findByRole('button', { name: 'Review' }); + userEvent.click(reviewBtn); + + expect(allTab).toHaveAttribute('aria-selected', 'false'); + expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true'); + expect(alert).not.toBeInTheDocument(); + + // go back to all tab + userEvent.click(allTab); + // alert should not be back + expect(alert).not.toBeInTheDocument(); + expect(allTab).toHaveAttribute('aria-selected', 'true'); + + // 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'); + }); + + it('hide alert on dismiss', async () => { + await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.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', + )).toBeInTheDocument(); + const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' }); + userEvent.click(dismissBtn); + const allTab = await screen.findByRole('tab', { name: 'Libraries' }); + expect(allTab).toHaveAttribute('aria-selected', 'true'); + + 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; + + 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 libParent1 = libraryCards[0].parentElement; + expect(libParent1).not.toBeNull(); + userEvent.click(libraryCards[0]); + const xblockCards1 = libParent1!.querySelectorAll('div.card'); + expect(xblockCards1.length).toEqual(expectedLib1Blocks); + + 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(); + + const libParent2 = libraryCards[1].parentElement; + expect(libParent2).not.toBeNull(); + userEvent.click(libraryCards[1]); + const xblockCards2 = libParent2!.querySelectorAll('div.card'); + expect(xblockCards2.length).toEqual(expectedLib2Blocks); + }); +}); diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx new file mode 100644 index 000000000..009c42eef --- /dev/null +++ b/src/course-libraries/CourseLibraries.tsx @@ -0,0 +1,342 @@ +import React, { + useCallback, useMemo, useState, +} from 'react'; +import { Helmet } from 'react-helmet'; +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, +} from '@openedx/paragon'; +import { + Cached, CheckCircle, KeyboardArrowDown, KeyboardArrowRight, Loop, MoreVert, +} from '@openedx/paragon/icons'; + +import { + countBy, groupBy, keyBy, tail, uniq, +} from 'lodash'; +import classNames from 'classnames'; +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 Loading from '../generic/Loading'; +import { useStudioHome } from '../studio-home/hooks'; + +interface Props { + courseId: string; +} + +interface LibraryCardProps { + courseId: string; + title: string; + links: PublishableEntityLink[]; +} + +interface ComponentInfo extends ContentHit { + readyToSync: boolean; +} + +interface BlockCardProps { + info: ComponentInfo; +} + +export enum CourseLibraryTabs { + home = '', + review = 'review', +} + +const BlockCard: React.FC = ({ info }) => { + 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 ( + + + + + + + + {' '} + + + + {info.readyToSync && } + {info.formatted?.displayName} + +
{info.formatted?.description}
+ ({ label: breadcrumb.displayName }))} + spacer={/} + linkAs="span" + /> +
+
+
+ ); +}; + +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( + [`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys.join('","')}"]`], + downstreamKeys.length, + ['usage_key', 'display_name', 'breadcrumbs', 'description', 'block_type'], + ['description:30'], + [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 }) => { + 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 { + isLoadingPage: isLoadingStudioHome, + isFailedLoadingPage: isFailedLoadingStudioHome, + librariesV2Enabled, + } = useStudioHome(); + + const onAlertReview = () => { + setTabKey(CourseLibraryTabs.review); + setShowReviewAlert(false); + }; + const onAlertDismiss = () => { + setShowReviewAlert(false); + }; + + const renderLibrariesTabContent = useCallback(() => { + if (isLoading) { + return ; + } + if (links?.length === 0) { + return ; + } + return ( + <> + + {Object.entries(linksByLib).map(([libKey, libLinks]) => ( + + ))} + + ); + }, [links, isLoading, linksByLib]); + + if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) { + return ( + + {intl.formatMessage(messages.librariesV2DisabledError)} + + ); + } + + return ( + <> + + + {getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))} + + + + 0 && tabKey === CourseLibraryTabs.home && showReviewAlert} + outOfSyncCount={outOfSyncCount} + onDismiss={onAlertDismiss} + onReview={onAlertReview} + /> + + {intl.formatMessage(messages.reviewUpdatesBtn)} + + )} + hideBorder + /> +
+ setTabKey(k)} + > + + + {renderLibrariesTabContent()} + + + 0 ? messages.reviewTabTitle : messages.reviewTabTitleEmpty, + { count: outOfSyncCount }, + )} + > + Help + + +
+
+ + ); +}; + +export default CourseLibraries; diff --git a/src/course-libraries/__mocks__/courseBlocksInfo.json b/src/course-libraries/__mocks__/courseBlocksInfo.json new file mode 100644 index 000000000..8f016a57a --- /dev/null +++ b/src/course-libraries/__mocks__/courseBlocksInfo.json @@ -0,0 +1,374 @@ +[ + { + "filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef\"]", + "result": { + "hits": [ + { + "display_name": "Dropdown", + "description": "asfd sdaf afd", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef", + "block_type": "problem", + "_formatted": { + "display_name": "Dropdown", + "description": "asfd sdaf afd", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef", + "block_type": "problem" + } + }, + { + "display_name": "HTML 12", + "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", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62", + "block_type": "html", + "_formatted": { + "display_name": "HTML 12", + "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…", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62", + "block_type": "html" + } + }, + { + "display_name": "Text", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07", + "block_type": "html", + "_formatted": { + "display_name": "Text", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07", + "block_type": "html" + } + }, + { + "display_name": "Text", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d", + "block_type": "html", + "_formatted": { + "display_name": "Text", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734" + }, + { + "display_name": "Problem Bank", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d", + "block_type": "html" + } + } + ], + "query": "", + "processingTimeMs": 0, + "limit": 4, + "offset": 0, + "estimatedTotalHits": 4 + } + }, + { + "filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83\"]", + "result": { + "hits": [ + { + "display_name": "Edited title", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83", + "block_type": "video", + "_formatted": { + "display_name": "Edited title", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83", + "block_type": "video" + } + }, + { + "display_name": "Text 1", + "description": " 8¹⁺² 3² Accept change now!d", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5", + "block_type": "html", + "_formatted": { + "display_name": "Text 1", + "description": " 8¹⁺² 3² Accept change now!d", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5", + "block_type": "html" + } + }, + { + "display_name": "Text 23", + "description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d", + "block_type": "html", + "_formatted": { + "display_name": "Text 23", + "description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ", + "breadcrumbs": [ + { + "display_name": "Learn UNIXY" + }, + { + "display_name": "Section", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887" + }, + { + "display_name": "Subsection", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208" + }, + { + "display_name": "Unit", + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35" + } + ], + "usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d", + "block_type": "html" + } + } + ], + "query": "", + "processingTimeMs": 0, + "limit": 3, + "offset": 0, + "estimatedTotalHits": 3 + } + } +] diff --git a/src/course-libraries/__mocks__/publishableEntityLinks.json b/src/course-libraries/__mocks__/publishableEntityLinks.json new file mode 100644 index 000000000..641e4366a --- /dev/null +++ b/src/course-libraries/__mocks__/publishableEntityLinks.json @@ -0,0 +1,100 @@ +[ + { + "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 diff --git a/src/course-libraries/data/api.mocks.ts b/src/course-libraries/data/api.mocks.ts new file mode 100644 index 000000000..96cfee6e2 --- /dev/null +++ b/src/course-libraries/data/api.mocks.ts @@ -0,0 +1,36 @@ +import mockLinksResult from '../__mocks__/publishableEntityLinks.json'; +import { createAxiosError } from '../../testUtils'; +import * as api from './api'; + +/** + * Mock for `getEntityLinksByDownstreamContext()` + * + * This mock returns a fixed response for the downstreamContextKey. + */ +export async function mockGetEntityLinksByDownstreamContext( + downstreamContextKey: string, +): Promise { + switch (downstreamContextKey) { + case mockGetEntityLinksByDownstreamContext.invalidCourseKey: + throw createAxiosError({ + code: 404, + message: 'Not found.', + path: api.getEntityLinksByDownstreamContextUrl(downstreamContextKey), + }); + case mockGetEntityLinksByDownstreamContext.courseKeyLoading: + return new Promise(() => {}); + case mockGetEntityLinksByDownstreamContext.courseKeyEmpty: + return Promise.resolve([]); + default: + return Promise.resolve(mockGetEntityLinksByDownstreamContext.response); + } +} +mockGetEntityLinksByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey; +mockGetEntityLinksByDownstreamContext.invalidCourseKey = 'course_key_error'; +mockGetEntityLinksByDownstreamContext.courseKeyLoading = 'courseKeyLoading'; +mockGetEntityLinksByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty'; +mockGetEntityLinksByDownstreamContext.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); +}; diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts new file mode 100644 index 000000000..e5ecc76e2 --- /dev/null +++ b/src/course-libraries/data/api.ts @@ -0,0 +1,29 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +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 interface PublishableEntityLink { + upstreamUsageKey: string; + upstreamContextKey: string; + upstreamContextTitle: string; + upstreamVersion: string; + downstreamUsageKey: string; + downstreamContextTitle: string; + downstreamContextKey: string; + versionSynced: string; + versionDeclined: string; + created: string; + updated: string; + readyToSync: boolean; +} + +export const getEntityLinksByDownstreamContext = async ( + downstreamContextKey: string, +): Promise => { + const { data } = await getAuthenticatedHttpClient() + .get(getEntityLinksByDownstreamContextUrl(downstreamContextKey)); + return camelCaseObject(data); +}; diff --git a/src/course-libraries/data/apiHooks.test.tsx b/src/course-libraries/data/apiHooks.test.tsx new file mode 100644 index 000000000..7d7867baa --- /dev/null +++ b/src/course-libraries/data/apiHooks.test.tsx @@ -0,0 +1,50 @@ +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { renderHook } from '@testing-library/react-hooks'; +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'; + +let axiosMock: MockAdapter; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('course libraries api hooks', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + 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 }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + }); + expect(result.current.data).toEqual([]); + expect(axiosMock.history.get[0].url).toEqual(url); + }); +}); diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts new file mode 100644 index 000000000..84820ffaa --- /dev/null +++ b/src/course-libraries/data/apiHooks.ts @@ -0,0 +1,20 @@ +import { + useQuery, +} from '@tanstack/react-query'; +import { getEntityLinksByDownstreamContext } from './api'; + +export const courseLibrariesQueryKeys = { + all: ['courseLibraries'], + courseLibraries: (courseKey?: string) => [...courseLibrariesQueryKeys.all, courseKey], +}; + +/** + * Hook to fetch a content library by its ID. + */ +export const useEntityLinksByDownstreamContext = (courseKey: string | undefined) => ( + useQuery({ + queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey), + queryFn: () => getEntityLinksByDownstreamContext(courseKey!), + enabled: courseKey !== undefined, + }) +); diff --git a/src/course-libraries/index.tsx b/src/course-libraries/index.tsx new file mode 100644 index 000000000..b9bd26691 --- /dev/null +++ b/src/course-libraries/index.tsx @@ -0,0 +1 @@ +export { default } from './CourseLibraries'; diff --git a/src/course-libraries/messages.ts b/src/course-libraries/messages.ts new file mode 100644 index 000000000..f72779652 --- /dev/null +++ b/src/course-libraries/messages.ts @@ -0,0 +1,81 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headingTitle: { + id: 'course-authoring.course-libraries.header.title', + defaultMessage: 'Libraries', + description: 'Title for page', + }, + headingSubtitle: { + id: 'course-authoring.course-libraries.header.subtitle', + defaultMessage: 'Content', + description: 'Subtitle for page', + }, + homeTabTitle: { + id: 'course-authoring.course-libraries.tab.home.title', + defaultMessage: 'Libraries', + description: 'Tab title for home tab', + }, + homeTabDescription: { + id: 'course-authoring.course-libraries.tab.home.description', + defaultMessage: 'This course contains content from these libraries.', + description: 'Description text for home tab', + }, + homeTabDescriptionEmpty: { + id: 'course-authoring.course-libraries.tab.home.description-no-links', + defaultMessage: 'This course does not use any content from libraries.', + description: 'Description text for home tab', + }, + reviewTabTitle: { + id: 'course-authoring.course-libraries.tab.review.title', + defaultMessage: 'Review Content Updates ({count})', + 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', + }, + 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.', + }, + totalComponentLabel: { + id: 'course-authoring.course-libraries.libcard.total-component.label', + defaultMessage: '{totalComponents, plural, one {# component} other {# components}} applied', + description: 'Prints total components applied from library', + }, + allUptodateLabel: { + id: 'course-authoring.course-libraries.libcard.up-to-date.label', + defaultMessage: 'All components up to date', + description: 'Shown if all components under a library are up to date', + }, + outOfSyncCountLabel: { + id: 'course-authoring.course-libraries.libcard.out-of-sync.label', + defaultMessage: '{outOfSyncCount, plural, one {# component} other {# components}} out of sync', + description: 'Prints number of components out of sync from library', + }, + 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', + description: 'Alert message shown when library components are out of sync', + }, + reviewUpdatesBtn: { + id: 'course-authoring.course-libraries.libcard.review-updates.btn.text', + defaultMessage: 'Review Updates', + description: 'Action button to review updates', + }, + outOfSyncCountAlertReviewBtn: { + id: 'course-authoring.course-libraries.libcard.out-of-sync.alert.review-btn-text', + defaultMessage: 'Review', + description: 'Alert review button text', + }, + librariesV2DisabledError: { + id: 'course-authoring.course-libraries.alert.error.libraries.v2.disabled', + 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.', + }, +}); + +export default messages; diff --git a/src/header/hooks.js b/src/header/hooks.js index d1e5e3a44..d318c05ae 100644 --- a/src/header/hooks.js +++ b/src/header/hooks.js @@ -11,6 +11,7 @@ export const useContentMenuItems = courseId => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const waffleFlags = useSelector(getWaffleFlags); + const { librariesV2Enabled } = useSelector(getStudioHomeData); const items = [ { @@ -37,6 +38,13 @@ export const useContentMenuItems = courseId => { }); } + if (librariesV2Enabled) { + items.splice(1, 0, { + href: `/course/${courseId}/libraries`, + title: intl.formatMessage(messages['header.links.libraries']), + }); + } + return items; }; diff --git a/src/header/hooks.test.js b/src/header/hooks.test.js index aa0829b33..acbe4e7b2 100644 --- a/src/header/hooks.test.js +++ b/src/header/hooks.test.js @@ -19,6 +19,9 @@ jest.mock('react-redux', () => ({ describe('header utils', () => { describe('getContentMenuItems', () => { it('when video upload page enabled should include Video Uploads option', () => { + useSelector.mockReturnValue({ + librariesV2Enabled: false, + }); setConfig({ ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true', @@ -27,6 +30,9 @@ describe('header utils', () => { expect(actualItems).toHaveLength(5); }); it('when video upload page disabled should not include Video Uploads option', () => { + useSelector.mockReturnValue({ + librariesV2Enabled: false, + }); setConfig({ ...getConfig(), ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false', @@ -34,10 +40,21 @@ describe('header utils', () => { const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; expect(actualItems).toHaveLength(4); }); + it('adds course libraries link to content menu when libraries v2 is enabled', () => { + useSelector.mockReturnValue({ + librariesV2Enabled: true, + }); + const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; + expect(actualItems[1]).toEqual({ href: '/course/course-123/libraries', title: 'Libraries' }); + }); }); describe('getSettingsMenuitems', () => { - useSelector.mockReturnValue({ canAccessAdvancedSettings: true }); + beforeEach(() => { + useSelector.mockReturnValue({ + canAccessAdvancedSettings: true, + }); + }); it('when certificate page enabled should include certificates option', () => { setConfig({ @@ -67,6 +84,11 @@ describe('header utils', () => { }); describe('getToolsMenuItems', () => { + beforeEach(() => { + useSelector.mockReturnValue({ + waffleFlags: jest.fn(), + }); + }); it('when tags enabled should include export tags option', () => { setConfig({ ...getConfig(), diff --git a/src/header/messages.js b/src/header/messages.js index 6c578790f..e465be1c3 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -21,6 +21,11 @@ const messages = defineMessages({ defaultMessage: 'Outline', description: 'Link to Studio Outline page', }, + 'header.links.libraries': { + id: 'header.links.libraries', + defaultMessage: 'Libraries', + description: 'Link to Linked Libraries page in a course', + }, 'header.links.updates': { id: 'header.links.updates', defaultMessage: 'Updates', diff --git a/src/index.scss b/src/index.scss index 31ffa2de8..c5d9bcb76 100644 --- a/src/index.scss +++ b/src/index.scss @@ -66,3 +66,7 @@ body { mark { padding: 0; } + +.border-left-purple { + border-left: 3px solid #5E35B1 !important; +} diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index eb5340d54..6d112eefc 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -518,3 +518,29 @@ 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 c2fe73bf7..3a13971b8 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -11,6 +11,7 @@ import { getContentSearchConfig, fetchBlockTypes, type PublishStatus, + fetchIndexDocuments, } from './api'; /** @@ -258,3 +259,33 @@ export const useGetBlockTypes = (extraFilters: Filter) => { queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters), }); }; + +export const useFetchIndexDocuments = ( + filter: Filter, + limit: number, + attributesToRetrieve?: string[], + attributesToCrop?: string[], + sort?: SearchSortOption[], +) => { + const { client, indexName } = useContentSearchConnection(); + return useQuery({ + enabled: client !== undefined && indexName !== undefined, + queryKey: [ + 'content_search', + client?.config.apiKey, + client?.config.host, + indexName, + filter, + 'generic-one-off', + ], + queryFn: () => fetchIndexDocuments( + client!, + indexName!, + filter, + limit, + attributesToRetrieve, + attributesToCrop, + sort, + ), + }); +};