Skip to content
Merged
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
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);
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/openedx-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.

Copy link
Contributor

Choose a reason for hiding this comment

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

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

I think it might be a component under Problem Bank which is only supported legacy studio, so it doesn't redirect to MFE.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right! I may have done something wrong with my testing.
It is respecting the waffle flag as expected.

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