From 2b0c3a41f03fb70e4f159a5455f57b808dc841da Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Nov 2025 20:58:14 +0530 Subject: [PATCH 1/8] fix: include children component while analyzing course for imports Currently, components with children are not supported by libraries v2. The analysis step before importing a course considers the parent block while counting unsupported blocks but does not include children in the unsupported count. We fetch usage_keys of all unsupported blocks and fetch the children blocks that contain these usage_keys in their breadcrumb field i.e., they are part of the unsupported blocks. --- .env | 2 +- .env.test | 2 +- .../stepper/ReviewImportDetails.tsx | 62 ++++++++++++++----- src/search-manager/data/api.ts | 22 ++++++- src/search-manager/data/apiHooks.ts | 23 ++++++- 5 files changed, 89 insertions(+), 22 deletions(-) diff --git a/.env b/.env index 23fa3de594..b2a38e955a 100644 --- a/.env +++ b/.env @@ -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" # Fallback in local style files PARAGON_THEME_URLS={} COURSE_TEAM_SUPPORT_EMAIL='' diff --git a/.env.test b/.env.test index e78d32b327..61a3ea47ae 100644 --- a/.env.test +++ b/.env.test @@ -40,6 +40,6 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com" 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" PARAGON_THEME_URLS= COURSE_TEAM_SUPPORT_EMAIL='support@example.com' diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx index ebd7cfe6cf..f18fbac976 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx @@ -11,6 +11,7 @@ import { useMigrationInfo } from '@src/library-authoring/data/apiHooks'; import { useGetBlockTypes } from '@src/search-manager'; import { SummaryCard } from './SummaryCard'; import messages from '../messages'; +import { useGetContentHits } from '../../../search-manager/data/apiHooks'; interface Props { courseId?: string; @@ -123,26 +124,55 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = markAnalysisComplete(!isBlockDataPending); }, [isBlockDataPending]); - const totalUnsupportedBlocks = useMemo(() => { + const unsupportedBlockTypes = useMemo(() => { if (!blockTypes) { + return undefined; + } + return Object.entries(blockTypes).filter(([blockType, ]) => ( + getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType) + )); + }, [blockTypes]); + + 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; + const unsupportedBlocks = unsupportedBlockTypes.reduce((total, [, count]) => { + return total + count; }, 0); return unsupportedBlocks; - }, [blockTypes]); + }, [unsupportedBlockTypes]); + + const { data: unsupportedBlocksData } = useGetContentHits([ + `context_key = "${courseId}"`, + `block_type IN [${unsupportedBlockTypes?.flatMap(([value]) => `"${value}"`).join(',')}]`, + ], totalUnsupportedBlocks > 0, totalUnsupportedBlocks, 'always') + + const { data: unsupportedBlocksChildren } = useGetBlockTypes([ + `context_key = "${courseId}"`, + `breadcrumbs.usage_key IN [${unsupportedBlocksData?.hits.map((value) => `"${value.usage_key}"`).join(',')}]`, + ], (unsupportedBlocksData?.estimatedTotalHits || 0) > 0); + + const totalUnsupportedBlockChildren = useMemo(() => { + if (!unsupportedBlocksChildren) { + return 0; + } + const unsupportedBlocks = Object.values(unsupportedBlocksChildren).reduce((total, count) => { + return total + count; + }, 0); + return unsupportedBlocks; + }, [unsupportedBlocksChildren]); + + const finalUnssupportedBlocks = useMemo(() => { + return totalUnsupportedBlocks + totalUnsupportedBlockChildren; + }, [totalUnsupportedBlocks, totalUnsupportedBlockChildren]); 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]); const totalComponents = useMemo(() => { if (!blockTypes) { @@ -157,15 +187,15 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = return total; }, 0, - ) - totalUnsupportedBlocks; - }, [blockTypes]); + ) - finalUnssupportedBlocks; + }, [blockTypes, finalUnssupportedBlocks]); const unsupportedBlockPercentage = useMemo(() => { if (!blockTypes || !totalBlocks) { return 0; } - return (totalUnsupportedBlocks / (totalBlocks + totalUnsupportedBlocks)) * 100; - }, [blockTypes]); + return (finalUnssupportedBlocks / (totalBlocks + finalUnssupportedBlocks)) * 100; + }, [blockTypes, finalUnssupportedBlocks]); return ( @@ -181,10 +211,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 && ( <>

diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index d6cf9db239..9d27508d99 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -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'; @@ -552,3 +552,23 @@ 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, +): Promise>> => { + // Convert 'extraFilter' into an array + const extraFilterFormatted = forceArray(extraFilter); + + const results = await client.index(indexName).search("", { + filter: extraFilterFormatted, + limit, + }); + + return results; +}; diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index 0c1aa947b9..cdadaeafa7 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { keepPreviousData, skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { type Filter, MeiliSearch } from 'meilisearch'; import { @@ -11,6 +11,7 @@ import { getContentSearchConfig, fetchBlockTypes, type PublishStatus, + fetchContentHits, } from './api'; /** @@ -286,7 +287,7 @@ export const useTagFilterOptions = (args: { return { ...mainQuery, data }; }; -export const useGetBlockTypes = (extraFilters: Filter) => { +export const useGetBlockTypes = (extraFilters: Filter, enabled: boolean = true) => { const { client, indexName } = useContentSearchConnection(); return useQuery({ enabled: client !== undefined && indexName !== undefined, @@ -298,7 +299,23 @@ export const useGetBlockTypes = (extraFilters: Filter) => { extraFilters, 'block_types', ], - queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters), + queryFn: enabled ? () => fetchBlockTypes(client!, indexName!, extraFilters): skipToken, refetchOnMount: 'always', }); }; + +export const useGetContentHits = (extraFilters: Filter, enabled: boolean = true, limit?: number, refetchOnMount?: boolean | 'always') => { + const { client, indexName } = useContentSearchConnection(); + return useQuery({ + enabled: client !== undefined && indexName !== undefined, + queryKey: [ + 'content_search', + client?.config.apiKey, + client?.config.host, + indexName, + extraFilters, + ], + queryFn: enabled ? () => fetchContentHits(client!, indexName!, extraFilters, limit): skipToken, + refetchOnMount, + }); +}; From 84311aeb1b1ff64bc4b317c0369aeb60f36e9dfd Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Nov 2025 21:02:47 +0530 Subject: [PATCH 2/8] chore: fix lint issues --- .../stepper/ReviewImportDetails.tsx | 19 ++++++++----------- src/search-manager/data/api.ts | 2 +- src/search-manager/data/apiHooks.ts | 15 +++++++++++---- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx index f18fbac976..7860281fbd 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx @@ -128,7 +128,7 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = if (!blockTypes) { return undefined; } - return Object.entries(blockTypes).filter(([blockType, ]) => ( + return Object.entries(blockTypes).filter(([blockType]) => ( getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType) )); }, [blockTypes]); @@ -137,16 +137,14 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = if (!unsupportedBlockTypes) { return 0; } - const unsupportedBlocks = unsupportedBlockTypes.reduce((total, [, count]) => { - return total + count; - }, 0); + const unsupportedBlocks = unsupportedBlockTypes.reduce((total, [, count]) => total + count, 0); return unsupportedBlocks; }, [unsupportedBlockTypes]); const { data: unsupportedBlocksData } = useGetContentHits([ `context_key = "${courseId}"`, `block_type IN [${unsupportedBlockTypes?.flatMap(([value]) => `"${value}"`).join(',')}]`, - ], totalUnsupportedBlocks > 0, totalUnsupportedBlocks, 'always') + ], totalUnsupportedBlocks > 0, totalUnsupportedBlocks, 'always'); const { data: unsupportedBlocksChildren } = useGetBlockTypes([ `context_key = "${courseId}"`, @@ -157,15 +155,14 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = if (!unsupportedBlocksChildren) { return 0; } - const unsupportedBlocks = Object.values(unsupportedBlocksChildren).reduce((total, count) => { - return total + count; - }, 0); + const unsupportedBlocks = Object.values(unsupportedBlocksChildren).reduce((total, count) => total + count, 0); return unsupportedBlocks; }, [unsupportedBlocksChildren]); - const finalUnssupportedBlocks = useMemo(() => { - return totalUnsupportedBlocks + totalUnsupportedBlockChildren; - }, [totalUnsupportedBlocks, totalUnsupportedBlockChildren]); + const finalUnssupportedBlocks = useMemo( + () => totalUnsupportedBlocks + totalUnsupportedBlockChildren, + [totalUnsupportedBlocks, totalUnsupportedBlockChildren], + ); const totalBlocks = useMemo(() => { if (!blockTypes) { diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 9d27508d99..dab3d67bed 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -565,7 +565,7 @@ export const fetchContentHits = async ( // Convert 'extraFilter' into an array const extraFilterFormatted = forceArray(extraFilter); - const results = await client.index(indexName).search("", { + const results = await client.index(indexName).search('', { filter: extraFilterFormatted, limit, }); diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index cdadaeafa7..8368bbfcc0 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -1,5 +1,7 @@ import React from 'react'; -import { keepPreviousData, skipToken, useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + keepPreviousData, skipToken, useInfiniteQuery, useQuery, +} from '@tanstack/react-query'; import { type Filter, MeiliSearch } from 'meilisearch'; import { @@ -299,12 +301,17 @@ export const useGetBlockTypes = (extraFilters: Filter, enabled: boolean = true) extraFilters, 'block_types', ], - queryFn: enabled ? () => fetchBlockTypes(client!, indexName!, extraFilters): skipToken, + queryFn: enabled ? () => fetchBlockTypes(client!, indexName!, extraFilters) : skipToken, refetchOnMount: 'always', }); }; -export const useGetContentHits = (extraFilters: Filter, enabled: boolean = true, limit?: number, refetchOnMount?: boolean | 'always') => { +export const useGetContentHits = ( + extraFilters: Filter, + enabled: boolean = true, + limit?: number, + refetchOnMount?: boolean | 'always', +) => { const { client, indexName } = useContentSearchConnection(); return useQuery({ enabled: client !== undefined && indexName !== undefined, @@ -315,7 +322,7 @@ export const useGetContentHits = (extraFilters: Filter, enabled: boolean = true, indexName, extraFilters, ], - queryFn: enabled ? () => fetchContentHits(client!, indexName!, extraFilters, limit): skipToken, + queryFn: enabled ? () => fetchContentHits(client!, indexName!, extraFilters, limit) : skipToken, refetchOnMount, }); }; From 54bc2f5f6fcbc73263bc41a9fc88856f2cc5cb40 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Nov 2025 21:46:53 +0530 Subject: [PATCH 3/8] test: add tests --- .../stepper/ImportStepperPage.test.tsx | 2 +- .../stepper/ReviewImportDetails.test.tsx | 59 +++++++++++++++++-- .../stepper/ReviewImportDetails.tsx | 17 ++++-- src/search-manager/data/api.ts | 2 + src/search-manager/data/apiHooks.ts | 9 ++- src/search-manager/index.ts | 1 + 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx index 1d309c14f3..6dd93d4f66 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx @@ -159,7 +159,7 @@ describe('', () => { 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: { diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx index ec4c96c699..edf1516c0b 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx @@ -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'; @@ -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) => { @@ -80,7 +81,7 @@ describe('ReviewImportDetails', () => { }], }, }); - (useGetBlockTypes as jest.Mock).mockReturnValue({ + (useGetBlockTypes as jest.Mock).mockReturnValueOnce({ isPending: false, data: { html: 1 }, }); @@ -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, @@ -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(); + + 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, diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx index 7860281fbd..ef93433e40 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx @@ -8,10 +8,9 @@ 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'; -import { useGetContentHits } from '../../../search-manager/data/apiHooks'; interface Props { courseId?: string; @@ -141,10 +140,16 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = return unsupportedBlocks; }, [unsupportedBlockTypes]); - const { data: unsupportedBlocksData } = useGetContentHits([ - `context_key = "${courseId}"`, - `block_type IN [${unsupportedBlockTypes?.flatMap(([value]) => `"${value}"`).join(',')}]`, - ], totalUnsupportedBlocks > 0, totalUnsupportedBlocks, 'always'); + const { data: unsupportedBlocksData } = useGetContentHits( + [ + `context_key = "${courseId}"`, + `block_type IN [${unsupportedBlockTypes?.flatMap(([ value ]) => `"${value}"`).join(',')}]`, + ], + totalUnsupportedBlocks > 0, + [ "usage_key" ], + totalUnsupportedBlocks, + 'always' + ); const { data: unsupportedBlocksChildren } = useGetBlockTypes([ `context_key = "${courseId}"`, diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index dab3d67bed..80e4fea0d3 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -561,6 +561,7 @@ export const fetchContentHits = async ( indexName: string, extraFilter?: Filter, limit?: number, + attributesToRetrieve?: string[], ): Promise>> => { // Convert 'extraFilter' into an array const extraFilterFormatted = forceArray(extraFilter); @@ -568,6 +569,7 @@ export const fetchContentHits = async ( const results = await client.index(indexName).search('', { filter: extraFilterFormatted, limit, + attributesToRetrieve, }); return results; diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index 8368bbfcc0..34a785393a 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -309,6 +309,7 @@ export const useGetBlockTypes = (extraFilters: Filter, enabled: boolean = true) export const useGetContentHits = ( extraFilters: Filter, enabled: boolean = true, + attributesToRetrieve?: string[], limit?: number, refetchOnMount?: boolean | 'always', ) => { @@ -322,7 +323,13 @@ export const useGetContentHits = ( indexName, extraFilters, ], - queryFn: enabled ? () => fetchContentHits(client!, indexName!, extraFilters, limit) : skipToken, + queryFn: enabled ? () => fetchContentHits( + client!, + indexName!, + extraFilters, + limit, + attributesToRetrieve, + ) : skipToken, refetchOnMount, }); }; diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts index 08c090f0a2..e3e2d1a42f 100644 --- a/src/search-manager/index.ts +++ b/src/search-manager/index.ts @@ -13,6 +13,7 @@ export { useContentSearchConnection, useContentSearchResults, useGetBlockTypes, + useGetContentHits, buildSearchQueryKey, } from './data/apiHooks'; export { TypesFilterData } from './hooks'; From a704f05962ccc7e5e9a23f5a5b2b6f97ad663a38 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Nov 2025 21:47:55 +0530 Subject: [PATCH 4/8] chore: fix lint issues --- .../import-course/stepper/ReviewImportDetails.test.tsx | 2 +- .../import-course/stepper/ReviewImportDetails.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx index edf1516c0b..b84af7b45a 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx @@ -144,7 +144,7 @@ describe('ReviewImportDetails', () => { (useGetContentHits as jest.Mock).mockReturnValue({ isPending: false, data: { - hits: [{usage_key: "some-usage-key"}], + hits: [{ usage_key: 'some-usage-key' }], estimatedTotalHits: 1, }, }); diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx index ef93433e40..71f680062f 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx @@ -143,12 +143,12 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = const { data: unsupportedBlocksData } = useGetContentHits( [ `context_key = "${courseId}"`, - `block_type IN [${unsupportedBlockTypes?.flatMap(([ value ]) => `"${value}"`).join(',')}]`, + `block_type IN [${unsupportedBlockTypes?.flatMap(([value]) => `"${value}"`).join(',')}]`, ], totalUnsupportedBlocks > 0, - [ "usage_key" ], + ['usage_key'], totalUnsupportedBlocks, - 'always' + 'always', ); const { data: unsupportedBlocksChildren } = useGetBlockTypes([ From 6c4d6bbe3953dcafcdfeea77dbb5fae9378391ab Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Nov 2025 22:04:13 +0530 Subject: [PATCH 5/8] docs: add comments --- .../import-course/stepper/ReviewImportDetails.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx index 71f680062f..75ddebbbb2 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx @@ -120,9 +120,11 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = ]); useEffect(() => { + // Mark complete to inform parent component of analysis completion. markAnalysisComplete(!isBlockDataPending); }, [isBlockDataPending]); + /** 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; @@ -132,6 +134,7 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = )); }, [blockTypes]); + /** Calculate the total number of unsupported blocks by summing up the count for each block type. */ const totalUnsupportedBlocks = useMemo(() => { if (!unsupportedBlockTypes) { return 0; @@ -140,6 +143,7 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = return unsupportedBlocks; }, [unsupportedBlockTypes]); + // Fetch unsupported blocks usage_key information from meilisearch index. const { data: unsupportedBlocksData } = useGetContentHits( [ `context_key = "${courseId}"`, @@ -151,11 +155,13 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = '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; @@ -164,11 +170,14 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = 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; @@ -176,6 +185,7 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = 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; @@ -192,6 +202,7 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) = ) - 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; From 4679ef32bf3962f36acc7b261ade9c4fe7111280 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Nov 2025 22:20:52 +0530 Subject: [PATCH 6/8] test: fix tests --- .../import-course/stepper/ImportStepperPage.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx index 6dd93d4f66..5f9d986a1a 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx @@ -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 = {}) => { From 8b8fb30a0b2c758549d64de1eb187e70e7671d32 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 20 Nov 2025 22:49:32 +0530 Subject: [PATCH 7/8] test: fix coverage --- src/search-manager/data/api.mock.ts | 22 ++++++++++++++++++++++ src/search-manager/data/apiHooks.test.tsx | 17 +++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts index 5153e79710..8ddd3738ea 100644 --- a/src/search-manager/data/api.mock.ts +++ b/src/search-manager/data/api.mock.ts @@ -16,6 +16,7 @@ export async function mockContentSearchConfig(): ReturnType ( jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig) ); @@ -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]; + }); +} diff --git a/src/search-manager/data/apiHooks.test.tsx b/src/search-manager/data/apiHooks.test.tsx index b347d2e95c..f4908ef624 100644 --- a/src/search-manager/data/apiHooks.test.tsx +++ b/src/search-manager/data/apiHooks.test.tsx @@ -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(); @@ -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); + }); }); From cba454be4e5cf8cb8a943708e5dceabb217c4dea Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 25 Nov 2025 15:33:55 -0500 Subject: [PATCH 8/8] chore: Add itembank to unsupported block in content library --- .env | 2 +- .env.development | 2 +- .env.test | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index b2a38e955a..3237da4c31 100644 --- a/.env +++ b/.env @@ -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_content" +LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank" # Fallback in local style files PARAGON_THEME_URLS={} COURSE_TEAM_SUPPORT_EMAIL='' diff --git a/.env.development b/.env.development index c243685943..970902cff7 100644 --- a/.env.development +++ b/.env.development @@ -48,7 +48,7 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com" 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='' diff --git a/.env.test b/.env.test index 61a3ea47ae..0e1e83d0cd 100644 --- a/.env.test +++ b/.env.test @@ -40,6 +40,6 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com" 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" PARAGON_THEME_URLS= COURSE_TEAM_SUPPORT_EMAIL='support@example.com'