Skip to content

Commit

Permalink
feat: add component usage data in the ComponentDetails component [FC-…
Browse files Browse the repository at this point in the history
…0076] (#1656)

Adds the list of Courses and Units/Containers using a component to the "Details" tab on the sidebar.
  • Loading branch information
rpenido authored Feb 25, 2025
1 parent 6b2ba6e commit 56b7a7b
Show file tree
Hide file tree
Showing 14 changed files with 363 additions and 34 deletions.
14 changes: 7 additions & 7 deletions src/course-libraries/CourseLibraries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,13 @@ const LibraryCard: React.FC<LibraryCardProps> = ({ 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
Expand Down
37 changes: 34 additions & 3 deletions src/library-authoring/component-info/ComponentDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ComponentDetails />, {
extraWrapper: ({ children }) => (
Expand Down Expand Up @@ -46,10 +53,34 @@ describe('<ComponentDetails />', () => {
});

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 () => {
Expand Down
11 changes: 6 additions & 5 deletions src/library-authoring/component-info/ComponentDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -36,19 +37,19 @@ const ComponentDetails = () => {

return (
<Stack gap={3}>
<div>
<>
<h3 className="h5">
<FormattedMessage {...messages.detailsTabUsageTitle} />
</h3>
<small><FormattedMessage {...messages.detailsTabUsagePlaceholder} /></small>
</div>
<ComponentUsage usageKey={usageKey} />
</>
<hr className="w-100" />
<div>
<>
<h3 className="h5">
<FormattedMessage {...messages.detailsTabHistoryTitle} />
</h3>
<HistoryWidget {...componentMetadata} />
</div>
</>
<ComponentAdvancedInfo />
</Stack>
);
Expand Down
116 changes: 116 additions & 0 deletions src/library-authoring/component-info/ComponentUsage.tsx
Original file line number Diff line number Diff line change
@@ -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<string, {
key: string,
contextName: string,
links: {
[usageKey: string]: {
displayName: string,
url: string,
},
},
}>;

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 <AlertError error={errorDownstreamLinks || errorIndexDocuments} />;
}

if (isLoadingDownstreamLinks || (isLoadingIndexDocuments && !!downstreamKeys.length)) {
return <Loading />;
}

if (!downstreamKeys.length || !downstreamHits) {
return <FormattedMessage {...messages.detailsTabUsageEmpty} />;
}

const componentUsage = downstreamHits.reduce<ComponentUsageTree>((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) => (
<Collapsible key={context.key} title={context.contextName} styling="basic">
<Stack>
{Object.keys(context.links).map((downstreamUsageKey: string) => (
<Hyperlink
key={downstreamUsageKey}
destination={context.links[downstreamUsageKey].url}
target="_blank"
>
{context.links[downstreamUsageKey].displayName}
</Hyperlink>
))}
</Stack>
</Collapsible>
))
}
</>
);
};
8 changes: 4 additions & 4 deletions src/library-authoring/component-info/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof api.getComponentDownstreamLinks> {
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<ReturnType<typeof api.getComponentDownstreamLinks>>;
mockComponentDownstreamLinks.emptyUsageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
mockComponentDownstreamLinks.emptyComponentUsage = [] as string[];

mockComponentDownstreamLinks.applyMock = () => jest.spyOn(
api,
'getComponentDownstreamLinks',
).mockImplementation(mockComponentDownstreamLinks);
16 changes: 16 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string[]> {
const { data } = await getAuthenticatedHttpClient().get(getComponentDownstreamContextsApiUrl(usageKey));
return data;
}
13 changes: 13 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
publishXBlock,
deleteXBlockAsset,
restoreLibraryBlock,
getComponentDownstreamLinks,
} from './api';
import { VersionSpec } from '../LibraryBlock';

Expand Down Expand Up @@ -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'],
};

/**
Expand Down Expand Up @@ -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,
})
);
Loading

0 comments on commit 56b7a7b

Please sign in to comment.