diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index 009c42eef9..d15a433f9f 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -111,13 +111,13 @@ const LibraryCard: React.FC = ({ courseId, title, 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 { data: downstreamInfo } = useFetchIndexDocuments({ + filter: [`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys.join('","')}"]`], + limit: downstreamKeys.length, + attributesToRetrieve: ['usage_key', 'display_name', 'breadcrumbs', 'description', 'block_type'], + attributesToCrop: ['description:30'], + sort: [SearchSortOption.TITLE_AZ], + }) as unknown as { data: ComponentInfo[] }; const renderBlockCards = (info: ComponentInfo) => { // eslint-disable-next-line no-param-reassign diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 390beb50f0..6fa1c1333a 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -4,21 +4,24 @@ import { screen, fireEvent, } from '../../testUtils'; +import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock'; import { mockContentLibrary, mockLibraryBlockMetadata, mockXBlockAssets, mockXBlockOLX, - mockComponentDownstreamContexts, + mockComponentDownstreamLinks, } from '../data/api.mocks'; import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; import ComponentDetails from './ComponentDetails'; +mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); mockXBlockAssets.applyMock(); mockXBlockOLX.applyMock(); -mockComponentDownstreamContexts.applyMock(); +mockComponentDownstreamLinks.applyMock(); +mockFetchIndexDocuments.applyMock(); const render = (usageKey: string) => baseRender(, { extraWrapper: ({ children }) => ( @@ -49,10 +52,9 @@ describe('', () => { }); it('should render the component usage', async () => { - render(mockComponentDownstreamContexts.usageKey); + render(mockComponentDownstreamLinks.usageKey); expect(await screen.findByText('Component Usage')).toBeInTheDocument(); - screen.logTestingPlaygroundURL(); - const course1 = screen.getByText('Course 1'); + const course1 = await screen.findByText('Course 1'); expect(course1).toBeInTheDocument(); fireEvent.click(screen.getByText('Course 1')); @@ -65,12 +67,12 @@ describe('', () => { expect(links[0]).toHaveTextContent('Unit 1'); expect(links[0]).toHaveAttribute( 'href', - '/course/course-v1:org+course+run/container/block-v1:org+course1+run+type@vertical+block@verticalId1', + '/course/course-v1:org+course1+run/container/block-v1:org+course1+run+type@vertical+block@verticalId1', ); expect(links[1]).toHaveTextContent('Unit 2'); expect(links[1]).toHaveAttribute( 'href', - '/course/course-v1:org+course+run/container/block-v1:org+course1+run+type@vertical+block@verticalId2', + '/course/course-v1:org+course1+run/container/block-v1:org+course1+run+type@vertical+block@verticalId2', ); expect(links[2]).toHaveTextContent('Problem Bank 3'); expect(links[2]).toHaveAttribute( diff --git a/src/library-authoring/component-info/ComponentDetails.tsx b/src/library-authoring/component-info/ComponentDetails.tsx index e0bb4c8f35..ceb122126c 100644 --- a/src/library-authoring/component-info/ComponentDetails.tsx +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -1,62 +1,15 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Collapsible, Stack } from '@openedx/paragon'; -import { Link } from 'react-router-dom'; +import { Stack } from '@openedx/paragon'; import AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useComponentDownstreamLinks, useLibraryBlockMetadata } from '../data/apiHooks'; +import { useLibraryBlockMetadata } from '../data/apiHooks'; import HistoryWidget from '../generic/history-widget'; import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; +import { ComponentUsage } from './ComponentUsage'; import messages from './messages'; -interface ComponentUsageProps { - usageKey: string; -} - -const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { - const { - data, - isError, - error, - isLoading, - } = useComponentDownstreamLinks(usageKey); - - if (isError) { - return ; - } - - if (isLoading) { - return ; - } - - return ( - <> -
Component Usage: {usageKey}
- {data.length > 0 ? ( -
    - {data.map((item) => ( -
  • - {item} -
  • - ))} -
- ) : ( - - )} - - ); -}; - -/* - - - {course.containers.map((container) => ( - {container.displayName} - ))} - -*/ - const ComponentDetails = () => { const { sidebarComponentInfo } = useSidebarContext(); @@ -84,19 +37,19 @@ const ComponentDetails = () => { return ( -
+ <>

-
+
-
+ <>

-
+
); diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx new file mode 100644 index 0000000000..5829be34ae --- /dev/null +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -0,0 +1,93 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Collapsible } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; + +import AlertError from '../../generic/alert-error'; +import Loading from '../../generic/Loading'; +import { useFetchIndexDocuments, type ContentHit } from '../../search-manager'; +import { useComponentDownstreamLinks } from '../data/apiHooks'; +import messages from './messages'; + +interface ComponentUsageProps { + usageKey: string; +} + +type ComponentUsageTree = Record; + +export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { + const { + data: dataDownstreamLinks, + isError: isErrorDownstreamLinks, + error: errorDownstreamLinks, + isLoading: isLoadingDownstreamLinks, + } = useComponentDownstreamLinks(usageKey); + + const downstreamKeys = dataDownstreamLinks || []; + + const { + data: downstreamHits, + isError: isErrorIndexDocuments, + error: errorIndexDocuments, + isLoading: isLoadingIndexDocuments, + } = useFetchIndexDocuments({ + filter: [`usage_key IN ["${downstreamKeys.join('","')}"]`], + limit: downstreamKeys.length, + attributesToRetrieve: ['usage_key', 'breadcrumbs', 'context_key'], + enabled: !!downstreamKeys.length, + }); + + if (isErrorDownstreamLinks || isErrorIndexDocuments) { + return ; + } + + if (isLoadingDownstreamLinks || isLoadingIndexDocuments) { + return ; + } + + const componentUsage = downstreamHits.reduce((acc, hit) => { + const link = hit.breadcrumbs.at(-1); + // istanbul ignore if: this should never happen. it is a type guard for the breadcrumb last item + if (!(link && ('usageKey' in link))) { + return acc; + } + + if (hit.contextKey in acc) { + acc[hit.contextKey].links.push(link); + } else { + acc[hit.contextKey] = { + key: hit.contextKey, + contextName: hit.breadcrumbs[0].displayName, + links: [link], + }; + } + return acc; + }, {}); + + console.error(componentUsage); + + const componentUsageList = Object.values(componentUsage); + + return ( + componentUsageList.length ? ( + <> + { + componentUsageList.map((context) => ( + + {context.links.map(({ usageKey: downstreamUsageKey, url, displayName }) => ( + url ? ( + {displayName} + ) : ( + {displayName} + ) + ))} + + )) + } + + ) : + ); +}; diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index dae1c78716..8c7d416102 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -527,62 +527,23 @@ mockGetLibraryTeam.notMember = { /** Apply this mock. Returns a spy object that can tell you if it's been called. */ mockGetLibraryTeam.applyMock = () => jest.spyOn(api, 'getLibraryTeam').mockImplementation(mockGetLibraryTeam); -export async function mockComponentDownstreamContexts(usageKey: string): Promise { - const thisMock = mockComponentDownstreamContexts; +export async function mockComponentDownstreamLinks( + usageKey: string, +): ReturnType { + const thisMock = mockComponentDownstreamLinks; switch (usageKey) { case thisMock.usageKey: return thisMock.componentUsage; default: return []; } } -mockComponentDownstreamContexts.usageKey = mockXBlockFields.usageKeyHtml; -mockComponentDownstreamContexts.componentUsage = [ - { - id: 'course-v1:org+course1+run', - displayName: 'Course 1', - url: '/course/course-v1:org+course+run', - containers: [ - { - id: 'unit-v1:org+course1+run+unit1', - displayName: 'Unit 1', - url: '/container/block-v1:org+course1+run+type@vertical+block@verticalId1', - links: [ - { - id: 'block-v1:org+course1+run+type@html+block@blockid1', - }, - ], - }, - { - id: 'unit-v1:org+course1+run+unit2', - displayName: 'Unit 2', - url: '/container/block-v1:org+course1+run+type@vertical+block@verticalId2', - links: [ - { - id: 'block-v1:org+course1+run+type@html+block@blockid2', - }, - ], - }, - ], - }, - { - id: 'course-v1:org+course2+run', - displayName: 'Course 2', - url: '/course/course-v1:org+course2+run', - containers: [ - { - id: 'unit-v1:org+course2+run+unit1', - displayName: 'Problem Bank 3', - url: '/container/block-v1:org+course2+run+type@itembank+block@itembankId3', - links: [ - { - id: 'block-v1:org+course2+run+type@html+block@blockid1', - }, - ], - }, - ], - }, -] satisfies api.ComponentDownstreamContext[]; +mockComponentDownstreamLinks.usageKey = mockXBlockFields.usageKeyHtml; +mockComponentDownstreamLinks.componentUsage = [ + 'block-v1:org+course1+run+type@html+block@blockid1', + 'block-v1:org+course1+run+type@html+block@blockid2', + 'block-v1:org+course2+run+type@html+block@blockid1', +]satisfies Awaited>; -mockComponentDownstreamContexts.applyMock = () => jest.spyOn( +mockComponentDownstreamLinks.applyMock = () => jest.spyOn( api, - 'getComponentDownstreamContexts', -).mockImplementation(mockComponentDownstreamContexts); + 'getComponentDownstreamLinks', +).mockImplementation(mockComponentDownstreamLinks); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index d2a0903eba..2f4251d283 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -101,7 +101,7 @@ export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/ /** * Get the URL for the component downstream contexts API. */ -export const getComponentDownstreamContextsApiUrl = (usageKey: string) => `${getContentStoreApiUrl()}upstream/${usageKey}/downstream-contexts`; +export const getComponentDownstreamContextsApiUrl = (usageKey: string) => `${getContentStoreApiUrl()}upstream/${usageKey}/downstream-links`; export interface ContentLibrary { id: string; @@ -547,5 +547,5 @@ export async function updateComponentCollections(usageKey: string, collectionKey */ export async function getComponentDownstreamLinks(usageKey: string): Promise { const { data } = await getAuthenticatedHttpClient().get(getComponentDownstreamContextsApiUrl(usageKey)); - return camelCaseObject(data); + return data; } diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 59ebc9e470..1cc416ad89 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -1,4 +1,4 @@ -import { initializeMockApp, snakeCaseObject } from '@edx/frontend-platform'; +import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; @@ -10,7 +10,6 @@ import { getLibraryCollectionComponentApiUrl, getLibraryCollectionsApiUrl, getLibraryCollectionApiUrl, - getComponentDownstreamContextsApiUrl, } from './api'; import { useCommitLibraryChanges, @@ -19,9 +18,7 @@ import { useRevertLibraryChanges, useAddComponentsToCollection, useCollection, - useComponentDownstreamContexts, } from './apiHooks'; -import { mockComponentDownstreamContexts } from './api.mocks'; let axiosMock; @@ -124,18 +121,4 @@ describe('library api hooks', () => { expect(result.current.data).toEqual({ testData: 'test-value' }); expect(axiosMock.history.get[0].url).toEqual(url); }); - - it('should get component usage', async () => { - const usageKey = 'usage-key'; - const url = getComponentDownstreamContextsApiUrl(usageKey); - - // Use snake case to match the API response - axiosMock.onGet(url).reply(200, snakeCaseObject(mockComponentDownstreamContexts.componentUsage)); - const { result } = renderHook(() => useComponentDownstreamContexts(usageKey), { wrapper }); - await waitFor(() => { - expect(result.current.isLoading).toBeFalsy(); - }); - expect(axiosMock.history.get[0].url).toEqual(url); - expect(result.current.data).toEqual(mockComponentDownstreamContexts.componentUsage); - }); }); diff --git a/src/search-manager/data/__mocks__/downstream-links.json b/src/search-manager/data/__mocks__/downstream-links.json new file mode 100644 index 0000000000..39d6834661 --- /dev/null +++ b/src/search-manager/data/__mocks__/downstream-links.json @@ -0,0 +1,84 @@ +{ + "comment": "This is a mock of the response from Meilisearch for downstream links", + "estimatedTotalHits": 3, + "query": "", + "limit": 3, + "offset": 0, + "processingTimeMs": 1, + "hits": [ + { + "usageKey": "block-v1:org+course1+run+type@html+block@blockid1", + "contextKey": "course-v1:org+course1+run", + "breadcrumbs": [ + { + "display_name": "Course 1", + "url": "/course/course-v1:org+course1+run" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId3", + "display_name": "Unit 1", + "url": "/course/course-v1:org+course1+run/container/block-v1:org+course1+run+type@vertical+block@verticalId1" + } + ] + }, + { + "usage_key": "block-v1:org+course1+run+type@html+block@blockid2", + "contextKey": "course-v1:org+course1+run", + "breadcrumbs": [ + { + "display_name": "Course 1", + "url": "/course/course-v1:org+course1+run" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2", + "display_name": "Unit 2", + "url": "/course/course-v1:org+course1+run/container/block-v1:org+course1+run+type@vertical+block@verticalId2" + } + ] + }, + { + "usage_key": "block-v1:org+course2+run+type@html+block@blockid1", + "contextKey": "course-v1:org+course2+run", + "breadcrumbs": [ + { + "display_name": "Course 2", + "url": "/course/course-v1:org+course2+run" + }, + { + "usage_key": "unit-v1:org+course2+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course2+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId3", + "display_name": "Unit 3", + "url": "/course/course-v1:org+course2+run/container/block-v1:org+course1+run+type@vertical+block@verticalId2" + }, + { + "usage_key": "block-v1:org+course2+run+type@itembank+block@itembankId3", + "display_name": "Problem Bank 3", + "url": "/course/course-v1:org+course2+run/container/block-v1:org+course2+run+type@itembank+block@itembankId3" + } + ] + } + ] +} diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts index e4673dd01b..4dbea8b6a5 100644 --- a/src/search-manager/data/api.mock.ts +++ b/src/search-manager/data/api.mock.ts @@ -2,6 +2,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import fetchMock from 'fetch-mock-jest'; import type { MultiSearchResponse } from 'meilisearch'; +import mockLinksResult from './__mocks__/downstream-links.json'; import * as api from './api'; /** @@ -14,7 +15,8 @@ export async function mockContentSearchConfig(): ReturnType ( jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig) ); @@ -64,3 +66,15 @@ export async function mockGetBlockTypes( jest.spyOn(api, 'fetchBlockTypes').mockResolvedValue(mockResponseMap[mockResponse]); } mockGetBlockTypes.applyMock = () => jest.spyOn(api, 'fetchBlockTypes').mockResolvedValue({}); + +export async function mockFetchIndexDocuments() { + return mockLinksResult; +} + +mockFetchIndexDocuments.applyMock = () => { + fetchMock.post( + mockContentSearchConfig.searchEndpointUrl, + mockFetchIndexDocuments, + { overwriteRoutes: true }, + ); +}; diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 6d112eefc8..c3b181a4c2 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -133,7 +133,10 @@ export interface ContentHit extends BaseContentHit { * - After that is the name and usage key of any parent Section/Subsection/Unit/etc. */ type: 'course_block' | 'library_block'; - breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>]; + breadcrumbs: [ + { displayName: string, url?: string }, + ...Array<{ displayName: string, usageKey: string, url?: string }>, + ]; description?: string; content?: ContentDetails; lastPublished: number | null; diff --git a/src/search-manager/data/apiHooks.test.tsx b/src/search-manager/data/apiHooks.test.tsx index b6fc63f49a..e21c581bf6 100644 --- a/src/search-manager/data/apiHooks.test.tsx +++ b/src/search-manager/data/apiHooks.test.tsx @@ -27,7 +27,7 @@ const wrapper = ({ children }) => ( const fetchMockResponse = () => { fetchMock.post( - mockContentSearchConfig.searchEndpointUrl, + mockContentSearchConfig.multisearchEndpointUrl, () => mockResult, { overwriteRoutes: true }, ); diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index 3a13971b88..857d59f41f 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -260,16 +260,29 @@ export const useGetBlockTypes = (extraFilters: Filter) => { }); }; -export const useFetchIndexDocuments = ( - filter: Filter, - limit: number, - attributesToRetrieve?: string[], - attributesToCrop?: string[], - sort?: SearchSortOption[], -) => { +interface UseFetchIndexDocumentsParams { + filter: Filter; + limit: number; + attributesToRetrieve?: string[]; + attributesToCrop?: string[]; + sort?: SearchSortOption[]; + enabled?: boolean; +} + +/** + * Fetch documents from the index. + */ +export const useFetchIndexDocuments = ({ + filter, + limit, + attributesToRetrieve, + attributesToCrop, + sort, + enabled = true, +} : UseFetchIndexDocumentsParams) => { const { client, indexName } = useContentSearchConnection(); return useQuery({ - enabled: client !== undefined && indexName !== undefined, + enabled: enabled && client !== undefined && indexName !== undefined, queryKey: [ 'content_search', client?.config.apiKey, @@ -278,7 +291,7 @@ export const useFetchIndexDocuments = ( filter, 'generic-one-off', ], - queryFn: () => fetchIndexDocuments( + queryFn: enabled ? () => fetchIndexDocuments( client!, indexName!, filter, @@ -286,6 +299,6 @@ export const useFetchIndexDocuments = ( attributesToRetrieve, attributesToCrop, sort, - ), + ) : undefined, }); }; diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts index 65e515d847..7cf40620d3 100644 --- a/src/search-manager/index.ts +++ b/src/search-manager/index.ts @@ -9,7 +9,7 @@ export { default as SearchKeywordsField } from './SearchKeywordsField'; export { default as SearchSortWidget } from './SearchSortWidget'; export { default as Stats } from './Stats'; export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api'; -export { useGetBlockTypes } from './data/apiHooks'; +export { useFetchIndexDocuments, useGetBlockTypes } from './data/apiHooks'; export { TypesFilterData } from './hooks'; export type { CollectionHit, ContentHit, ContentHitTags } from './data/api'; diff --git a/src/search-modal/__mocks__/downstream-links.json b/src/search-modal/__mocks__/downstream-links.json new file mode 100644 index 0000000000..e69de29bb2