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 8278b3727c..88f22de53a 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -1,21 +1,28 @@ +import { getConfig } from '@edx/frontend-platform'; import { initializeMocks, render as baseRender, screen, + fireEvent, } from '../../testUtils'; +import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock'; import { mockContentLibrary, mockLibraryBlockMetadata, mockXBlockAssets, mockXBlockOLX, + 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(); +mockComponentDownstreamLinks.applyMock(); +mockFetchIndexDocuments.applyMock(); const render = (usageKey: string) => baseRender(, { extraWrapper: ({ children }) => ( @@ -46,10 +53,34 @@ describe('', () => { }); it('should render the component usage', async () => { - render(mockLibraryBlockMetadata.usageKeyNeverPublished); + render(mockComponentDownstreamLinks.usageKey); expect(await screen.findByText('Component Usage')).toBeInTheDocument(); - // TODO: replace with actual data when implement course list - expect(screen.queryByText(/This will show the courses that use this component./)).toBeInTheDocument(); + const course1 = await screen.findByText('Course 1'); + expect(course1).toBeInTheDocument(); + fireEvent.click(screen.getByText('Course 1')); + + const course2 = screen.getByText('Course 2'); + expect(course2).toBeInTheDocument(); + fireEvent.click(screen.getByText('Course 2')); + + const links = screen.getAllByRole('link'); + // There are 2 instances in the Unit 1, but only one is shown + expect(links).toHaveLength(3); + expect(links[0]).toHaveTextContent('Unit 1'); + expect(links[0]).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/container/block-v1:org+course1+run+type@vertical+block@verticalId1`, + ); + expect(links[1]).toHaveTextContent('Unit 2'); + expect(links[1]).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/container/block-v1:org+course1+run+type@vertical+block@verticalId2`, + ); + expect(links[2]).toHaveTextContent('Problem Bank 3'); + expect(links[2]).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/container/block-v1:org+course2+run+type@itembank+block@itembankId3`, + ); }); it('should render the component history', async () => { diff --git a/src/library-authoring/component-info/ComponentDetails.tsx b/src/library-authoring/component-info/ComponentDetails.tsx index e41f3ad50c..ceb122126c 100644 --- a/src/library-authoring/component-info/ComponentDetails.tsx +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -7,6 +7,7 @@ import { useSidebarContext } from '../common/context/SidebarContext'; import { useLibraryBlockMetadata } from '../data/apiHooks'; import HistoryWidget from '../generic/history-widget'; import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; +import { ComponentUsage } from './ComponentUsage'; import messages from './messages'; const ComponentDetails = () => { @@ -36,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..a12710e67f --- /dev/null +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -0,0 +1,116 @@ +import { getConfig } from '@edx/frontend-platform'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Collapsible, Hyperlink, Stack } from '@openedx/paragon'; + +import AlertError from '../../generic/alert-error'; +import Loading from '../../generic/Loading'; +import { useFetchIndexDocuments } from '../../search-manager'; +import { useComponentDownstreamLinks } from '../data/apiHooks'; +import messages from './messages'; + +interface ComponentUsageProps { + usageKey: string; +} + +type ComponentUsageTree = Record; + +const getContainerUrl = (usageKey: string) => ( + `${getConfig().STUDIO_BASE_URL}/container/${usageKey}` +); + +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 && !!downstreamKeys.length)) { + return ; + } + + if (!downstreamKeys.length || !downstreamHits) { + 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; + } + + const linkData = { + displayName: link.displayName, + url: getContainerUrl(link.usageKey), + }; + + if (hit.contextKey in acc) { + if (!(link.usageKey in acc[hit.contextKey].links)) { + acc[hit.contextKey].links[link.usageKey] = linkData; + return acc; + } + } else { + acc[hit.contextKey] = { + key: hit.contextKey, + contextName: hit.breadcrumbs[0].displayName, + links: { + [link.usageKey]: linkData, + }, + }; + } + return acc; + }, {}); + + const componentUsageList = Object.values(componentUsage); + + return ( + <> + { + componentUsageList.map((context) => ( + + + {Object.keys(context.links).map((downstreamUsageKey: string) => ( + + {context.links[downstreamUsageKey].displayName} + + ))} + + + )) + } + + ); +}; diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 1c02d867f4..7e4068d53e 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -116,10 +116,10 @@ const messages = defineMessages({ defaultMessage: 'Component Usage', description: 'Title for the Component Usage container in the details tab', }, - detailsTabUsagePlaceholder: { - id: 'course-authoring.library-authoring.component.details-tab.usage-placeholder', - defaultMessage: 'This will show the courses that use this component. Feature coming soon.', - description: 'Explanation/placeholder for the future "Component Usage" feature', + detailsTabUsageEmpty: { + id: 'course-authoring.library-authoring.component.details-tab.usage-empty', + defaultMessage: 'This component is not used in any course.', + description: 'Message to display in usage section when component is not used in any course', }, detailsTabHistoryTitle: { id: 'course-authoring.library-authoring.component.details-tab.history-title', diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index ce4be29168..490f021f8e 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -526,3 +526,27 @@ 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 mockComponentDownstreamLinks( + usageKey: string, +): ReturnType { + const thisMock = mockComponentDownstreamLinks; + switch (usageKey) { + case thisMock.usageKey: return thisMock.componentUsage; + default: return []; + } +} +mockComponentDownstreamLinks.usageKey = mockXBlockFields.usageKeyHtml; +mockComponentDownstreamLinks.componentUsage = [ + 'block-v1:org+course1+run+type@html+block@blockid1', + 'block-v1:org+course1+run+type@html+block@blockid2', + 'block-v1:org+course1+run+type@html+block@blockid3', + 'block-v1:org+course2+run+type@html+block@blockid1', +] satisfies Awaited>; +mockComponentDownstreamLinks.emptyUsageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; +mockComponentDownstreamLinks.emptyComponentUsage = [] as string[]; + +mockComponentDownstreamLinks.applyMock = () => jest.spyOn( + api, + 'getComponentDownstreamLinks', +).mockImplementation(mockComponentDownstreamLinks); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 6432aaafd3..2f4251d283 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -94,6 +94,14 @@ export const getLibraryCollectionRestoreApiUrl = (libraryId: string, collectionI * Get the URL for the xblock api. */ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; +/** + * Get the URL for the content store api. + */ +export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/`; +/** + * Get the URL for the component downstream contexts API. + */ +export const getComponentDownstreamContextsApiUrl = (usageKey: string) => `${getContentStoreApiUrl()}upstream/${usageKey}/downstream-links`; export interface ContentLibrary { id: string; @@ -533,3 +541,11 @@ export async function updateComponentCollections(usageKey: string, collectionKey collection_keys: collectionKeys, }); } + +/** + * Fetch downstream links for a component. + */ +export async function getComponentDownstreamLinks(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getComponentDownstreamContextsApiUrl(usageKey)); + return data; +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 46cd148925..a707e4681b 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -45,6 +45,7 @@ import { publishXBlock, deleteXBlockAsset, restoreLibraryBlock, + getComponentDownstreamLinks, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -99,6 +100,7 @@ export const xblockQueryKeys = { /** assets (static files) */ xblockAssets: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'assets'], componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'], + componentDownstreamLinks: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamLinks'], }; /** @@ -542,3 +544,14 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin }, }); }; + +/** + * Get the downstream links of a component in a library + */ +export const useComponentDownstreamLinks = (usageKey: string) => ( + useQuery({ + queryKey: xblockQueryKeys.componentDownstreamLinks(usageKey), + queryFn: () => getComponentDownstreamLinks(usageKey), + enabled: !!usageKey, + }) +); diff --git a/src/search-manager/data/__mocks__/downstream-links.json b/src/search-manager/data/__mocks__/downstream-links.json new file mode 100644 index 0000000000..9261941331 --- /dev/null +++ b/src/search-manager/data/__mocks__/downstream-links.json @@ -0,0 +1,98 @@ +{ + "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" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId1", + "display_name": "Unit 1" + } + ] + }, + { + "usage_key": "block-v1:org+course1+run+type@html+block@blockid2", + "contextKey": "course-v1:org+course1+run", + "breadcrumbs": [ + { + "display_name": "Course 1" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2", + "display_name": "Unit 2" + } + ] + }, + { + "usage_key": "block-v1:org+course1+run+type@html+block@blockid3", + "contextKey": "course-v1:org+course1+run", + "breadcrumbs": [ + { + "display_name": "Course 1" + }, + { + "usage_key": "unit-v1:org+course1+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course1+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2", + "display_name": "Unit 2" + } + ] + }, + { + "usage_key": "block-v1:org+course2+run+type@html+block@blockid1", + "contextKey": "course-v1:org+course2+run", + "breadcrumbs": [ + { + "display_name": "Course 2" + }, + { + "usage_key": "unit-v1:org+course2+run+section1", + "display_name": "Section 1" + }, + { + "usage_key": "unit-v1:org+course2+run+subsection1", + "display_name": "Sub Section 1" + }, + { + "usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId3", + "display_name": "Unit 3" + }, + { + "usage_key": "block-v1:org+course2+run+type@itembank+block@itembankId3", + "display_name": "Problem Bank 3" + } + ] + } + ] +} diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts index e4673dd01b..54f4671851 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) ); @@ -26,7 +28,7 @@ mockContentSearchConfig.applyMock = () => ( * a different mock response, or you call `fetchMock.mockReset()` */ export function mockSearchResult(mockResponse: MultiSearchResponse) { - fetchMock.post(mockContentSearchConfig.searchEndpointUrl, (_url, req) => { + fetchMock.post(mockContentSearchConfig.multisearchEndpointUrl, (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; // We have to replace the query (search keywords) in the mock results with the actual query, @@ -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..4a7fe02d01 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 }, + ...Array<{ displayName: string, usageKey: 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';