Skip to content

Commit

Permalink
refactor: use meilisearch to get downstream links data
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Feb 20, 2025
1 parent c8bee25 commit c0a6fda
Show file tree
Hide file tree
Showing 14 changed files with 260 additions and 154 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
16 changes: 9 additions & 7 deletions src/library-authoring/component-info/ComponentDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,24 @@ import {
screen,
fireEvent,
} from '../../testUtils';
import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock';
import {
mockContentLibrary,
mockLibraryBlockMetadata,
mockXBlockAssets,
mockXBlockOLX,
mockComponentDownstreamContexts,
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();
mockComponentDownstreamContexts.applyMock();
mockComponentDownstreamLinks.applyMock();
mockFetchIndexDocuments.applyMock();

const render = (usageKey: string) => baseRender(<ComponentDetails />, {
extraWrapper: ({ children }) => (
Expand Down Expand Up @@ -49,10 +52,9 @@ describe('<ComponentDetails />', () => {
});

it('should render the component usage', async () => {
render(mockComponentDownstreamContexts.usageKey);
render(mockComponentDownstreamLinks.usageKey);
expect(await screen.findByText('Component Usage')).toBeInTheDocument();
screen.logTestingPlaygroundURL();
const course1 = screen.getByText('Course 1');
const course1 = await screen.findByText('Course 1');
expect(course1).toBeInTheDocument();
fireEvent.click(screen.getByText('Course 1'));

Expand All @@ -65,12 +67,12 @@ describe('<ComponentDetails />', () => {
expect(links[0]).toHaveTextContent('Unit 1');
expect(links[0]).toHaveAttribute(
'href',
'/course/course-v1:org+course+run/container/block-v1:org+course1+run+type@vertical+block@verticalId1',
'/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+course+run/container/block-v1:org+course1+run+type@vertical+block@verticalId2',
'/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(
Expand Down
61 changes: 7 additions & 54 deletions src/library-authoring/component-info/ComponentDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,15 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Collapsible, Stack } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { Stack } from '@openedx/paragon';

import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useComponentDownstreamLinks, useLibraryBlockMetadata } from '../data/apiHooks';
import { useLibraryBlockMetadata } from '../data/apiHooks';
import HistoryWidget from '../generic/history-widget';
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
import { ComponentUsage } from './ComponentUsage';
import messages from './messages';

interface ComponentUsageProps {
usageKey: string;
}

const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
const {
data,
isError,
error,
isLoading,
} = useComponentDownstreamLinks(usageKey);

if (isError) {
return <AlertError error={error} />;
}

if (isLoading) {
return <Loading />;
}

return (
<>
<div>Component Usage: {usageKey}</div>
{data.length > 0 ? (
<ul>
{data.map((item) => (
<li key={item}>
{item}
</li>
))}
</ul>
) : (
<FormattedMessage {...messages.detailsTabUsageEmpty} />
)}
</>
);
};

/*
<Collapsible key={course.id} title={course.displayName} styling="basic">
{course.containers.map((container) => (
<Link key={container.id} to={course.url + container.url}>{container.displayName}</Link>
))}
</Collapsible>
*/

const ComponentDetails = () => {
const { sidebarComponentInfo } = useSidebarContext();

Expand Down Expand Up @@ -84,19 +37,19 @@ const ComponentDetails = () => {

return (
<Stack gap={3}>
<div>
<>
<h3 className="h5">
<FormattedMessage {...messages.detailsTabUsageTitle} />
</h3>
<ComponentUsage usageKey={usageKey} />
</div>
</>
<hr className="w-100" />
<div>
<>
<h3 className="h5">
<FormattedMessage {...messages.detailsTabHistoryTitle} />
</h3>
<HistoryWidget {...componentMetadata} />
</div>
</>
<ComponentAdvancedInfo />
</Stack>
);
Expand Down
93 changes: 93 additions & 0 deletions src/library-authoring/component-info/ComponentUsage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Collapsible } from '@openedx/paragon';
import { Link } from 'react-router-dom';

import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading';
import { useFetchIndexDocuments, type ContentHit } 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: ContentHit['breadcrumbs'][1][],
}>;

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) {
return <Loading />;
}

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;
}

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

console.error(componentUsage);

Check warning on line 70 in src/library-authoring/component-info/ComponentUsage.tsx

View workflow job for this annotation

GitHub Actions / tests

Unexpected console statement

const componentUsageList = Object.values(componentUsage);

return (
componentUsageList.length ? (
<>
{
componentUsageList.map((context) => (
<Collapsible key={context.key} title={context.contextName} styling="basic">
{context.links.map(({ usageKey: downstreamUsageKey, url, displayName }) => (
url ? (
<Link key={downstreamUsageKey} to={url}>{displayName}</Link>
) : (
<span key={downstreamUsageKey}>{displayName}</span>
)
))}
</Collapsible>
))
}
</>
) : <FormattedMessage {...messages.detailsTabUsageEmpty} />
);
};
65 changes: 13 additions & 52 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,62 +527,23 @@ 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<api.ComponentDownstreamContext[]> {
const thisMock = mockComponentDownstreamContexts;
export async function mockComponentDownstreamLinks(
usageKey: string,
): ReturnType<typeof api.getComponentDownstreamLinks> {
const thisMock = mockComponentDownstreamLinks;
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',
containers: [
{
id: 'unit-v1:org+course1+run+unit1',
displayName: 'Unit 1',
url: '/container/block-v1:org+course1+run+type@vertical+block@verticalId1',
links: [
{
id: 'block-v1:org+course1+run+type@html+block@blockid1',
},
],
},
{
id: 'unit-v1:org+course1+run+unit2',
displayName: 'Unit 2',
url: '/container/block-v1:org+course1+run+type@vertical+block@verticalId2',
links: [
{
id: 'block-v1:org+course1+run+type@html+block@blockid2',
},
],
},
],
},
{
id: 'course-v1:org+course2+run',
displayName: 'Course 2',
url: '/course/course-v1:org+course2+run',
containers: [
{
id: 'unit-v1:org+course2+run+unit1',
displayName: 'Problem Bank 3',
url: '/container/block-v1:org+course2+run+type@itembank+block@itembankId3',
links: [
{
id: 'block-v1:org+course2+run+type@html+block@blockid1',
},
],
},
],
},
] satisfies api.ComponentDownstreamContext[];
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>>;

mockComponentDownstreamContexts.applyMock = () => jest.spyOn(
mockComponentDownstreamLinks.applyMock = () => jest.spyOn(
api,
'getComponentDownstreamContexts',
).mockImplementation(mockComponentDownstreamContexts);
'getComponentDownstreamLinks',
).mockImplementation(mockComponentDownstreamLinks);
4 changes: 2 additions & 2 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/
/**
* Get the URL for the component downstream contexts API.
*/
export const getComponentDownstreamContextsApiUrl = (usageKey: string) => `${getContentStoreApiUrl()}upstream/${usageKey}/downstream-contexts`;
export const getComponentDownstreamContextsApiUrl = (usageKey: string) => `${getContentStoreApiUrl()}upstream/${usageKey}/downstream-links`;

export interface ContentLibrary {
id: string;
Expand Down Expand Up @@ -547,5 +547,5 @@ export async function updateComponentCollections(usageKey: string, collectionKey
*/
export async function getComponentDownstreamLinks(usageKey: string): Promise<string[]> {
const { data } = await getAuthenticatedHttpClient().get(getComponentDownstreamContextsApiUrl(usageKey));
return camelCaseObject(data);
return data;
}
19 changes: 1 addition & 18 deletions src/library-authoring/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
Expand All @@ -10,7 +10,6 @@ import {
getLibraryCollectionComponentApiUrl,
getLibraryCollectionsApiUrl,
getLibraryCollectionApiUrl,
getComponentDownstreamContextsApiUrl,
} from './api';
import {
useCommitLibraryChanges,
Expand All @@ -19,9 +18,7 @@ import {
useRevertLibraryChanges,
useAddComponentsToCollection,
useCollection,
useComponentDownstreamContexts,
} from './apiHooks';
import { mockComponentDownstreamContexts } from './api.mocks';

let axiosMock;

Expand Down Expand Up @@ -124,18 +121,4 @@ describe('library api hooks', () => {
expect(result.current.data).toEqual({ testData: 'test-value' });
expect(axiosMock.history.get[0].url).toEqual(url);
});

it('should get component usage', async () => {
const usageKey = 'usage-key';
const url = getComponentDownstreamContextsApiUrl(usageKey);

// Use snake case to match the API response
axiosMock.onGet(url).reply(200, snakeCaseObject(mockComponentDownstreamContexts.componentUsage));
const { result } = renderHook(() => useComponentDownstreamContexts(usageKey), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(axiosMock.history.get[0].url).toEqual(url);
expect(result.current.data).toEqual(mockComponentDownstreamContexts.componentUsage);
});
});
Loading

0 comments on commit c0a6fda

Please sign in to comment.