Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add component usage data in the ComponentDetails component [FC-0076] #1656

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
35 changes: 32 additions & 3 deletions src/library-authoring/component-info/ComponentDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@ 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 +52,33 @@ 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');
expect(links).toHaveLength(3);
expect(links[0]).toHaveTextContent('Unit 1');
expect(links[0]).toHaveAttribute(
'href',
'/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+course1+run/container/block-v1:org+course1+run+type@vertical+block@verticalId2',
);
expect(links[2]).toHaveTextContent('Problem Bank 3');
expect(links[2]).toHaveAttribute(
'href',
'/course/course-v1:org+course2+run/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
102 changes: 102 additions & 0 deletions src/library-authoring/component-info/ComponentUsage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { getConfig, getPath } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Collapsible, Hyperlink } 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 = (contextKey: string, usageKey: string) => (
`${getPath(getConfig().PUBLIC_PATH)}course/${contextKey}/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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll need to build url to link to the units, like here. Currently, I just see the unit names and links don't work.

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch this, I missed to reindex and test. Will come back to this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think building the url here is simple enough to avoid adding the link data to index. See openedx/edx-platform#36253 (comment)

Copy link
Contributor Author

@rpenido rpenido Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @navinkarkera! Fixed!

I made it a bit different because the link on the Library page redirects me to Legacy Studio, despite the waffle flag.

// 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 = {
...link,
url: getContainerUrl(hit.contextKey, link.usageKey),
};

if (hit.contextKey in acc) {
acc[hit.contextKey].links.push(linkData);
} else {
acc[hit.contextKey] = {
key: hit.contextKey,
contextName: hit.breadcrumbs[0].displayName,
links: [linkData],
};
}
return acc;
}, {});

const componentUsageList = Object.values(componentUsage);

return (
<>
{
componentUsageList.map((context) => (
<Collapsible key={context.key} title={context.contextName} styling="basic">
{context.links.map(({ usageKey: downstreamUsageKey, displayName, url }) => (
<Hyperlink key={downstreamUsageKey} destination={url} target="_blank">{displayName}</Hyperlink>
))}
</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
23 changes: 23 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,26 @@ 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+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 @@
* 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 @@
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;

Check warning on line 550 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L550

Added line #L550 was not covered by tests
}
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,
})
);
77 changes: 77 additions & 0 deletions src/search-manager/data/__mocks__/downstream-links.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"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+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"
}
]
}
]
}
Loading