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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
28
src/library-authoring/import-course/PlaceholderCard.tsx
Normal file
28
src/library-authoring/import-course/PlaceholderCard.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user