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
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ INVITE_STUDENTS_EMAIL_TO=''
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
Expand Down
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ INVITE_STUDENTS_EMAIL_TO="[email protected]"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
Expand Down
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ INVITE_STUDENTS_EMAIL_TO="[email protected]"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='[email protected]'
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jest.mock('react-router-dom', () => ({
// Mock the useGetBlockTypes hook
jest.mock('@src/search-manager', () => ({
useGetBlockTypes: jest.fn().mockReturnValue({ isPending: true, data: null }),
useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));

const renderComponent = (studioHomeState: Partial<StudioHomeState> = {}) => {
Expand Down Expand Up @@ -159,7 +160,7 @@ describe('<ImportStepperModal />', () => {
expect(courseCard).toBeChecked();
});

it('should import select course on button click', async () => {
it('should import selected course on button click', async () => {
(useGetBlockTypes as jest.Mock).mockReturnValue({
isPending: false,
data: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { useGetBlockTypes } from '@src/search-manager';
import { useGetBlockTypes, useGetContentHits } from '@src/search-manager';
import { render as baseRender, screen, initializeMocks } from '@src/testUtils';
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
Expand All @@ -25,6 +25,7 @@ jest.mock('@src/library-authoring/data/apiHooks', () => ({
// Mock the useGetBlockTypes hook
jest.mock('@src/search-manager', () => ({
useGetBlockTypes: jest.fn().mockReturnValue({ isPending: true, data: null }),
useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));

const render = (element: React.ReactElement) => {
Expand Down Expand Up @@ -80,7 +81,7 @@ describe('ReviewImportDetails', () => {
}],
},
});
(useGetBlockTypes as jest.Mock).mockReturnValue({
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: { html: 1 },
});
Expand All @@ -103,7 +104,7 @@ describe('ReviewImportDetails', () => {
isPending: false,
data: null,
});
(useGetBlockTypes as jest.Mock).mockReturnValue({
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: {
chapter: 1,
Expand Down Expand Up @@ -134,13 +135,63 @@ describe('ReviewImportDetails', () => {
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
});

it('considers children blocks of unsupportedBlocks', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: null,
});
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [{ usage_key: 'some-usage-key' }],
estimatedTotalHits: 1,
},
});
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: {
chapter: 1,
sequential: 2,
vertical: 3,
library_content: 1,
html: 1,
problem: 4,
},
}).mockReturnValueOnce({
isPending: false,
data: {
problem: 2,
},
});

render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);

expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(await screen.findByText(/Import Analysis Complete/i)).toBeInTheDocument();
expect(await screen.findByText(
/25.00% of content cannot be imported. For details see below./i,
)).toBeInTheDocument();
expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument();
expect(await screen.findByText('9/12')).toBeInTheDocument();
expect(await screen.findByText('Sections')).toBeInTheDocument();
expect(await screen.findByText('1')).toBeInTheDocument();
expect(await screen.findByText('Subsections')).toBeInTheDocument();
expect(await screen.findByText('2')).toBeInTheDocument();
expect(await screen.findByText('Units')).toBeInTheDocument();
expect(await screen.findByText('3')).toBeInTheDocument();
expect(await screen.findByText('Components')).toBeInTheDocument();
expect(await screen.findByText('3/6')).toBeInTheDocument();
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
});

it('renders success alert when no unsupported blocks', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: null,
});
(useGetBlockTypes as jest.Mock).mockReturnValue({
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: {
chapter: 1,
Expand Down
79 changes: 61 additions & 18 deletions src/library-authoring/import-course/stepper/ReviewImportDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useEffect, useMemo } from 'react';
import { CheckCircle, Warning } from '@openedx/paragon/icons';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { useGetBlockTypes } from '@src/search-manager';
import { useGetBlockTypes, useGetContentHits } from '@src/search-manager';
import { SummaryCard } from './SummaryCard';
import messages from '../messages';

Expand Down Expand Up @@ -120,30 +120,72 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) =
]);

useEffect(() => {
// Mark complete to inform parent component of analysis completion.
markAnalysisComplete(!isBlockDataPending);
}, [isBlockDataPending]);

const totalUnsupportedBlocks = useMemo(() => {
/** Filter unsupported blocks by checking if the block type is in the library's list of unsupported blocks. */
const unsupportedBlockTypes = useMemo(() => {
if (!blockTypes) {
return undefined;
}
return Object.entries(blockTypes).filter(([blockType]) => (
getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType)
));
}, [blockTypes]);

/** Calculate the total number of unsupported blocks by summing up the count for each block type. */
const totalUnsupportedBlocks = useMemo(() => {
if (!unsupportedBlockTypes) {
return 0;
}
const unsupportedBlocks = Object.entries(blockTypes).reduce((total, [blockType, count]) => {
const isUnsupportedBlock = getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType);
if (isUnsupportedBlock) {
return total + count;
}
return total;
}, 0);
const unsupportedBlocks = unsupportedBlockTypes.reduce((total, [, count]) => total + count, 0);
return unsupportedBlocks;
}, [blockTypes]);
}, [unsupportedBlockTypes]);

// Fetch unsupported blocks usage_key information from meilisearch index.
const { data: unsupportedBlocksData } = useGetContentHits(
[
`context_key = "${courseId}"`,
`block_type IN [${unsupportedBlockTypes?.flatMap(([value]) => `"${value}"`).join(',')}]`,
],
totalUnsupportedBlocks > 0,
['usage_key'],
totalUnsupportedBlocks,
'always',
);

// Fetch children blocks for each block in the unsupportedBlocks array.
const { data: unsupportedBlocksChildren } = useGetBlockTypes([
`context_key = "${courseId}"`,
`breadcrumbs.usage_key IN [${unsupportedBlocksData?.hits.map((value) => `"${value.usage_key}"`).join(',')}]`,
], (unsupportedBlocksData?.estimatedTotalHits || 0) > 0);

/** Calculate the total number of unsupported children blocks by summing up the count for each block. */
const totalUnsupportedBlockChildren = useMemo(() => {
if (!unsupportedBlocksChildren) {
return 0;
}
const unsupportedBlocks = Object.values(unsupportedBlocksChildren).reduce((total, count) => total + count, 0);
return unsupportedBlocks;
}, [unsupportedBlocksChildren]);

/** Finally calculate the final number of unsupported blocks by adding parent unsupported and children
unsupported blocks. */
const finalUnssupportedBlocks = useMemo(
() => totalUnsupportedBlocks + totalUnsupportedBlockChildren,
[totalUnsupportedBlocks, totalUnsupportedBlockChildren],
);

/** Calculate total supported blocks by subtracting final unsupported blocks from the total number of blocks */
const totalBlocks = useMemo(() => {
if (!blockTypes) {
return undefined;
}
return Object.values(blockTypes).reduce((total, block) => total + block, 0) - totalUnsupportedBlocks;
}, [blockTypes]);
return Object.values(blockTypes).reduce((total, block) => total + block, 0) - finalUnssupportedBlocks;
}, [blockTypes, finalUnssupportedBlocks]);

/** Calculate total components by excluding those that are chapters, sequential, or vertical. */
const totalComponents = useMemo(() => {
if (!blockTypes) {
return undefined;
Expand All @@ -157,15 +199,16 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) =
return total;
},
0,
) - totalUnsupportedBlocks;
}, [blockTypes]);
) - finalUnssupportedBlocks;
}, [blockTypes, finalUnssupportedBlocks]);

/** Calculate the unsupported block percentage based on the final total blocks and unsupported blocks. */
const unsupportedBlockPercentage = useMemo(() => {
if (!blockTypes || !totalBlocks) {
return 0;
}
return (totalUnsupportedBlocks / (totalBlocks + totalUnsupportedBlocks)) * 100;
}, [blockTypes]);
return (finalUnssupportedBlocks / (totalBlocks + finalUnssupportedBlocks)) * 100;
}, [blockTypes, finalUnssupportedBlocks]);

return (
<Stack gap={4}>
Expand All @@ -181,10 +224,10 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) =
sections={blockTypes?.chapter}
subsections={blockTypes?.sequential}
units={blockTypes?.vertical}
unsupportedBlocks={totalUnsupportedBlocks}
unsupportedBlocks={finalUnssupportedBlocks}
isPending={isBlockDataPending}
/>
{!isBlockDataPending && totalUnsupportedBlocks > 0
{!isBlockDataPending && finalUnssupportedBlocks > 0
&& (
<>
<h4><FormattedMessage {...messages.importCourseAnalysisDetails} /></h4>
Expand Down
22 changes: 22 additions & 0 deletions src/search-manager/data/api.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export async function mockContentSearchConfig(): ReturnType<typeof api.getConten
};
}
mockContentSearchConfig.multisearchEndpointUrl = 'http://mock.meilisearch.local/multi-search';
mockContentSearchConfig.searchEndpointUrl = 'http://mock.meilisearch.local/indexes/studio/search';
mockContentSearchConfig.applyMock = () => (
jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig)
);
Expand Down Expand Up @@ -102,3 +103,24 @@ mockFetchIndexDocuments.applyMock = () => {
{ overwriteRoutes: true },
);
};

/**
* Mock the useGetContentHits
*/
export async function mockGetContentHits(
mockResponse: 'noHits' | 'someHits',
) {
fetchMock.post(mockContentSearchConfig.searchEndpointUrl, () => {
const mockResponseMap = {
noHits: {
hits: [],
estimatedTotalHits: 0,
},
someHits: {
hits: [{ usage_key: 'some-key' }, { usage_key: 'other-key' }],
estimatedTotalHits: 2,
},
};
return mockResponseMap[mockResponse];
});
}
24 changes: 23 additions & 1 deletion src/search-manager/data/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type {
Filter, MeiliSearch, MultiSearchQuery,
Filter, MeiliSearch, MultiSearchQuery, SearchResponse,
} from 'meilisearch';
import { ContainerType } from '../../generic/key-utils';

Expand Down Expand Up @@ -552,3 +552,25 @@ export async function fetchTagsThatMatchKeyword({

return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit };
}

/**
* Fetch the blocks that match query
*/
export const fetchContentHits = async (
client: MeiliSearch,
indexName: string,
extraFilter?: Filter,
limit?: number,
attributesToRetrieve?: string[],
): Promise<SearchResponse<Record<string, any>>> => {
// Convert 'extraFilter' into an array
const extraFilterFormatted = forceArray(extraFilter);

const results = await client.index(indexName).search('', {
filter: extraFilterFormatted,
limit,
attributesToRetrieve,
});

return results;
};
17 changes: 15 additions & 2 deletions src/search-manager/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';

import mockResult from './__mocks__/block-types.json';
import { mockContentSearchConfig } from './api.mock';
import { mockContentSearchConfig, mockGetContentHits } from './api.mock';
import {
useGetBlockTypes,
useGetBlockTypes, useGetContentHits,
} from './apiHooks';

mockContentSearchConfig.applyMock();
Expand Down Expand Up @@ -53,4 +53,17 @@ describe('search manager api hooks', () => {
expect(result.current.data).toEqual(expectedData);
expect(fetchMock.calls().length).toEqual(1);
});

it('useGetContentHits should return hits', async () => {
mockGetContentHits('someHits');
const { result } = renderHook(() => useGetContentHits('filter'), { wrapper });
await waitFor(() => {
expect(result.current.isPending).toBeFalsy();
});
const expectedData = {
hits: [{ usage_key: 'some-key' }, { usage_key: 'other-key' }],
estimatedTotalHits: 2,
};
expect(result.current.data).toEqual(expectedData);
});
});
Loading