feat: placeholder blocks for failed import blocks (#2703)

Adds placeholder blocks in home page and respective collections tab in library for failed blocks during import from course.
This commit is contained in:
Navin Karkera
2025-11-27 23:12:21 +05:30
committed by GitHub
parent 294fe42942
commit 0c1554b337
7 changed files with 212 additions and 12 deletions

View File

@@ -198,3 +198,28 @@
}
}
}
.component-style-import-placeholder {
background-color: #AB0E01;
.pgn__icon:not(.btn-icon-before) {
color: white;
}
.btn-icon {
&:hover, &:active, &:focus {
background-color: darken(#AB0E01, 15%);
}
}
.btn {
background-color: lighten(#AB0E01, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#AB0E01, 20%);
border: 1px solid var(--pgn-color-primary-base);
margin: -1px;
}
}
}

View File

@@ -8,11 +8,14 @@ import {
initializeMocks,
} from '@src/testUtils';
import MockAdapter from 'axios-mock-adapter/types';
import { useGetContentHits } from '@src/search-manager';
import { mockContentLibrary } from './data/api.mocks';
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
import { LibraryProvider } from './common/context/LibraryContext';
import LibraryContent from './LibraryContent';
import { libraryComponentsMock } from './__mocks__';
import { getModulestoreMigratedBlocksInfoUrl } from './data/api';
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
@@ -43,9 +46,10 @@ const returnEmptyResult = (_url: string, req) => {
return mockEmptyResult;
};
jest.mock('../search-manager', () => ({
jest.mock('@src/search-manager', () => ({
...jest.requireActual('../search-manager'),
useSearchContext: () => mockUseSearchContext(),
useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));
const withLibraryId = (libraryId: string) => ({
@@ -55,10 +59,12 @@ const withLibraryId = (libraryId: string) => ({
</LibraryProvider>
),
});
let axiosMock: MockAdapter;
describe('<LibraryHome />', () => {
beforeEach(() => {
const { axiosMock } = initializeMocks();
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
@@ -108,4 +114,48 @@ describe('<LibraryHome />', () => {
fireEvent.scroll(window, { target: { scrollY: 1000 } });
expect(mockFetchNextPage).toHaveBeenCalled();
});
it('should show placeholderBlocks', async () => {
axiosMock.onGet(getModulestoreMigratedBlocksInfoUrl()).reply(200, [
{
sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@library_content+block@test_lib_content',
targetKey: null,
unsupportedReason: 'The "library_content" XBlock (ID: "test_lib_content") has children, so it not supported in content libraries. It has 2 children blocks.',
},
{
sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@conditional+block@test_conditional',
targetKey: null,
unsupportedReason: 'The "conditional" XBlock (ID: "test_conditional") has children, so it not supported in content libraries. It has 2 children blocks.',
},
]);
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [
{
display_name: 'Randomized Content Block',
usage_key: 'block-v1:UNIX+UX2+2025_T2+type@library_content+block@test_lib_content',
block_type: 'library_content',
},
{
display_name: 'Conditional',
usage_key: 'block-v1:UNIX+UX2+2025_T2+type@conditional+block@test_conditional',
block_type: 'conditional',
},
],
query: '',
processingTimeMs: 0,
limit: 2,
offset: 0,
estimatedTotalHits: 2,
},
});
mockUseSearchContext.mockReturnValue({
...data,
hits: libraryComponentsMock,
});
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
expect(await screen.findByText('Randomized Content Block')).toBeInTheDocument();
expect(await screen.findByText('Conditional')).toBeInTheDocument();
});
});

View File

@@ -1,15 +1,17 @@
import { useEffect } from 'react';
import { LoadingSpinner } from '../generic/Loading';
import { useSearchContext } from '../search-manager';
import { LoadingSpinner } from '@src/generic/Loading';
import { useGetContentHits, useSearchContext } from '@src/search-manager';
import { useLoadOnScroll } from '@src/hooks';
import { NoComponents, NoSearchResults } from './EmptyStates';
import { useLibraryContext } from './common/context/LibraryContext';
import { useSidebarContext } from './common/context/SidebarContext';
import CollectionCard from './components/CollectionCard';
import ComponentCard from './components/ComponentCard';
import { ContentType } from './routes';
import { useLoadOnScroll } from '../hooks';
import { ContentType, useLibraryRoutes } from './routes';
import messages from './collections/messages';
import ContainerCard from './containers/ContainerCard';
import { useMigrationBlocksInfo } from './data/apiHooks';
import PlaceholderCard from './import-course/PlaceholderCard';
/**
* Library Content to show content grid
@@ -40,8 +42,32 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
isFiltered,
usageKey,
} = useSearchContext();
const { openCreateCollectionModal } = useLibraryContext();
const { libraryId, openCreateCollectionModal, collectionId } = useLibraryContext();
const { openAddContentSidebar, openComponentInfoSidebar } = useSidebarContext();
const { insideCollection } = useLibraryRoutes();
/**
* Placeholder blocks represent fake blocks for failed imports from other sources, such as courses.
* They should only be displayed when viewing all components in the home tab of the library and the
collection representing the course.
* Blocks should be hidden when the user is searching or filtering them.
*/
const showPlaceholderBlocks = ([ContentType.home].includes(contentType) || insideCollection) && !isFiltered;
const { data: placeholderBlocks } = useMigrationBlocksInfo(
libraryId,
collectionId,
true,
showPlaceholderBlocks,
);
// Fetch unsupported blocks usage_key information from meilisearch index.
const { data: placeholderData } = useGetContentHits(
[
`usage_key IN [${placeholderBlocks?.map((block) => `"${block.sourceKey}"`).join(',')}]`,
],
(placeholderBlocks?.length || 0) > 0,
['usage_key', 'block_type', 'display_name'],
placeholderBlocks?.length,
true,
);
useEffect(() => {
if (usageKey) {
@@ -81,6 +107,12 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
return <CardComponent key={contentHit.id} hit={contentHit} />;
})}
{showPlaceholderBlocks && placeholderData?.hits?.map((item) => (
<PlaceholderCard
displayName={item.display_name}
blockType={item.block_type}
/>
))}
</div>
);
};

View File

@@ -12,6 +12,7 @@ import ComponentCount from '@src/generic/component-count';
import TagCount from '@src/generic/tag-count';
import { BlockTypeLabel, type ContentHitTags, Highlight } from '@src/search-manager';
import { skipIfUnwantedTarget } from '@src/utils';
import { Report } from '@openedx/paragon/icons';
import messages from './messages';
type BaseCardProps = {
@@ -25,6 +26,7 @@ type BaseCardProps = {
hasUnpublishedChanges?: boolean;
onSelect: (e?: React.MouseEvent) => void;
selected?: boolean;
isPlaceholder?: boolean;
};
const BaseCard = ({
@@ -48,6 +50,7 @@ const BaseCard = ({
const itemIcon = getItemIcon(itemType);
const intl = useIntl();
const itemComponentStyle = !props.isPlaceholder ? getComponentStyleColor(itemType) : 'component-style-import-placeholder';
return (
<Container className="library-item-card selected">
@@ -62,9 +65,9 @@ const BaseCard = ({
className={selected ? 'selected' : undefined}
>
<Card.Header
className={`library-item-header ${getComponentStyleColor(itemType)}`}
className={`library-item-header ${itemComponentStyle}`}
title={
<Icon src={itemIcon} className="library-item-header-icon my-2" />
<Icon src={props.isPlaceholder ? Report : itemIcon} className="library-item-header-icon my-2" />
}
actions={(
<div
@@ -91,8 +94,12 @@ const BaseCard = ({
<BlockTypeLabel blockType={itemType} />
</small>
</Stack>
<ComponentCount count={numChildren} />
<TagCount size="sm" count={tagCount} />
{!props.isPlaceholder && (
<>
<ComponentCount count={numChildren} />
<TagCount size="sm" count={tagCount} />
</>
)}
</Stack>
<div className="badge-container d-flex align-items-center justify-content-center">
{props.hasUnpublishedChanges && (

View File

@@ -157,10 +157,18 @@ export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUr
* Get the URL for the API endpoint to copy a single container.
*/
export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`;
/**
* Base url for modulestore_migrator
*/
export const getBaseModuleStoreMigrationUrl = () => `${getApiBaseUrl()}/api/modulestore_migrator/v1/`;
/**
* Get the url for the API endpoint to list library course imports.
*/
export const getCourseImportsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`;
export const getCourseImportsApiUrl = (libraryId: string) => `${getBaseModuleStoreMigrationUrl()}library/${libraryId}/migrations/courses/`;
/**
* Get the url for the API endpoint to get migration blocks info.
*/
export const getModulestoreMigratedBlocksInfoUrl = () => `${getBaseModuleStoreMigrationUrl()}migration_blocks/`;
export interface ContentLibrary {
id: string;
@@ -830,3 +838,32 @@ export async function getMigrationInfo(sourceKeys: string[]): Promise<Record<str
const { data } = await client.get(`${getApiBaseUrl()}/api/modulestore_migrator/v1/migration_info/`, { params });
return camelCaseObject(data);
}
export interface BlockMigrationInfo {
sourceKey: string;
targetKey: string | null;
unsupportedReason?: string;
}
/**
* Get the migration blocks info data for a library
*/
export async function getModulestoreMigrationBlocksInfo(
libraryId: string,
collectionId?: string,
isFailed?: boolean,
): Promise<BlockMigrationInfo[]> {
const client = getAuthenticatedHttpClient();
const params = new URLSearchParams();
params.append('target_key', libraryId);
if (collectionId) {
params.append('target_collection_key', collectionId);
}
if (isFailed !== undefined) {
params.append('is_failed', JSON.stringify(isFailed));
}
const { data } = await client.get(getModulestoreMigratedBlocksInfoUrl(), { params });
return camelCaseObject(data);
}

View File

@@ -99,6 +99,12 @@ export const libraryAuthoringQueryKeys = {
...libraryAuthoringQueryKeys.allMigrationInfo(),
...sourceKeys,
],
migrationBlocksInfo: (libraryId: string, collectionId?: string, isFailed?: boolean) => [
...libraryAuthoringQueryKeys.allMigrationInfo(),
libraryId,
collectionId,
isFailed,
],
};
export const xblockQueryKeys = {
@@ -981,3 +987,18 @@ export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true)
queryFn: enabled ? () => api.getMigrationInfo(sourcesKeys) : skipToken,
})
);
/**
* Returns the migration blocks info of a given library
*/
export const useMigrationBlocksInfo = (
libraryId: string,
collectionId?: string,
isFailed?: boolean,
enabled = true,
) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.migrationBlocksInfo(libraryId, collectionId, isFailed),
queryFn: enabled ? () => api.getModulestoreMigrationBlocksInfo(libraryId, collectionId, isFailed) : skipToken,
})
);

View File

@@ -0,0 +1,28 @@
import BaseCard from '../components/BaseCard';
interface PlaceHolderCardProps {
blockType: string;
displayName: string;
description?: string;
}
const PlaceholderCard = ({ blockType, displayName, description }: PlaceHolderCardProps) => {
const truncatedDescription = description ? `${description.substring(0, 40) }...` : undefined;
/* istanbul ignore next */
return (
<BaseCard
itemType={blockType}
displayName={displayName}
description={truncatedDescription}
tags={{}}
numChildren={0}
actions={null}
hasUnpublishedChanges={false}
onSelect={() => null}
selected={false}
isPlaceholder
/>
);
};
export default PlaceholderCard;