From c2ce1e2f2c067076be93562556e30caa94febb7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 13 Feb 2025 20:58:46 -0300 Subject: [PATCH] feat: add component usage data in the ComponentDetails component --- .../component-info/ComponentDetails.test.tsx | 12 ++++++-- .../component-info/ComponentDetails.tsx | 20 ++++++++++--- .../component-info/messages.ts | 8 +++--- src/library-authoring/data/api.mocks.ts | 28 +++++++++++++++++++ src/library-authoring/data/api.ts | 20 +++++++++++++ src/library-authoring/data/apiHooks.test.tsx | 1 + src/library-authoring/data/apiHooks.ts | 10 +++++++ 7 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 8278b3727c..c202840353 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -8,6 +8,7 @@ import { mockLibraryBlockMetadata, mockXBlockAssets, mockXBlockOLX, + mockComponentDownstreamContexts, } from '../data/api.mocks'; import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; import ComponentDetails from './ComponentDetails'; @@ -16,6 +17,7 @@ mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); mockXBlockAssets.applyMock(); mockXBlockOLX.applyMock(); +mockComponentDownstreamContexts.applyMock(); const render = (usageKey: string) => baseRender(, { extraWrapper: ({ children }) => ( @@ -46,10 +48,14 @@ describe('', () => { }); it('should render the component usage', async () => { - render(mockLibraryBlockMetadata.usageKeyNeverPublished); + render(mockComponentDownstreamContexts.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 links = screen.getAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveTextContent('Course 1'); + expect(links[0]).toHaveAttribute('href', '/course/course-v1:org+course+run'); + expect(links[1]).toHaveTextContent('Course 2'); + expect(links[1]).toHaveAttribute('href', '/course/course-v1:org+course2+run'); }); 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..6e424cb698 100644 --- a/src/library-authoring/component-info/ComponentDetails.tsx +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -1,10 +1,11 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Stack } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; import AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useLibraryBlockMetadata } from '../data/apiHooks'; +import { useComponentDownstreamContexts, useLibraryBlockMetadata } from '../data/apiHooks'; import HistoryWidget from '../generic/history-widget'; import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; import messages from './messages'; @@ -23,14 +24,16 @@ const ComponentDetails = () => { data: componentMetadata, isError, error, - isLoading, + isLoading: isLoadingComponentMetadata, } = useLibraryBlockMetadata(usageKey); + const { data: componentUsage, isLoading: isLoadingComponentUsage } = useComponentDownstreamContexts(usageKey); + if (isError) { return ; } - if (isLoading) { + if (isLoadingComponentMetadata || isLoadingComponentUsage) { return ; } @@ -40,7 +43,16 @@ const ComponentDetails = () => {

- + { + componentUsage?.length ? ( + componentUsage.map(({ id, displayName, url }) => ( + {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..0b260bd195 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -526,3 +526,31 @@ 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; + 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', + count: 2, + }, + { + id: 'course-v1:org+course2+run', + displayName: 'Course 2', + url: '/course/course-v1:org+course2+run', + count: 1, + }, +] satisfies api.ComponentDownstreamContext[]; + +mockComponentDownstreamContexts.applyMock = () => jest.spyOn( + api, + 'getComponentDownstreamContexts', +).mockImplementation(mockComponentDownstreamContexts); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 6432aaafd3..44f2eaf6f7 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-contexts`; export interface ContentLibrary { id: string; @@ -533,3 +541,15 @@ export async function updateComponentCollections(usageKey: string, collectionKey collection_keys: collectionKeys, }); } + +export interface ComponentDownstreamContext { + id: string; + displayName: string; + count: number; + url: string; +} + +export async function getComponentDownstreamContexts(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getComponentDownstreamContextsApiUrl(usageKey)); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 62fadf31ec..5d4b404b68 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -12,6 +12,7 @@ import { getLibraryCollectionComponentApiUrl, getLibraryCollectionsApiUrl, getLibraryCollectionApiUrl, + getComponentDownstreamContextsApiUrl, } from './api'; import { useCommitLibraryChanges, diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 46cd148925..1967dc4e5c 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -45,6 +45,7 @@ import { publishXBlock, deleteXBlockAsset, restoreLibraryBlock, + getComponentDownstreamContexts, } 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'], + componentDownstreamContexts: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamContexts'], }; /** @@ -542,3 +544,11 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin }, }); }; + +export const useComponentDownstreamContexts = (usageKey: string) => ( + useQuery({ + queryKey: xblockQueryKeys.componentDownstreamContexts(usageKey), + queryFn: () => getComponentDownstreamContexts(usageKey), + enabled: !!usageKey, + }) +);