Skip to content

Commit 0c1554b

Browse files
authored
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.
1 parent 294fe42 commit 0c1554b

File tree

7 files changed

+212
-12
lines changed

7 files changed

+212
-12
lines changed

src/generic/block-type-utils/index.scss

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,28 @@
198198
}
199199
}
200200
}
201+
202+
.component-style-import-placeholder {
203+
background-color: #AB0E01;
204+
205+
.pgn__icon:not(.btn-icon-before) {
206+
color: white;
207+
}
208+
209+
.btn-icon {
210+
&:hover, &:active, &:focus {
211+
background-color: darken(#AB0E01, 15%);
212+
}
213+
}
214+
215+
.btn {
216+
background-color: lighten(#AB0E01, 10%);
217+
border: 0;
218+
219+
&:hover, &:active, &:focus {
220+
background-color: lighten(#AB0E01, 20%);
221+
border: 1px solid var(--pgn-color-primary-base);
222+
margin: -1px;
223+
}
224+
}
225+
}

src/library-authoring/LibraryContent.test.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import {
88
initializeMocks,
99
} from '@src/testUtils';
1010

11+
import MockAdapter from 'axios-mock-adapter/types';
12+
import { useGetContentHits } from '@src/search-manager';
1113
import { mockContentLibrary } from './data/api.mocks';
1214
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
1315
import { LibraryProvider } from './common/context/LibraryContext';
1416
import LibraryContent from './LibraryContent';
1517
import { libraryComponentsMock } from './__mocks__';
18+
import { getModulestoreMigratedBlocksInfoUrl } from './data/api';
1619

1720
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
1821

@@ -43,9 +46,10 @@ const returnEmptyResult = (_url: string, req) => {
4346
return mockEmptyResult;
4447
};
4548

46-
jest.mock('../search-manager', () => ({
49+
jest.mock('@src/search-manager', () => ({
4750
...jest.requireActual('../search-manager'),
4851
useSearchContext: () => mockUseSearchContext(),
52+
useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }),
4953
}));
5054

5155
const withLibraryId = (libraryId: string) => ({
@@ -55,10 +59,12 @@ const withLibraryId = (libraryId: string) => ({
5559
</LibraryProvider>
5660
),
5761
});
62+
let axiosMock: MockAdapter;
5863

5964
describe('<LibraryHome />', () => {
6065
beforeEach(() => {
61-
const { axiosMock } = initializeMocks();
66+
const mocks = initializeMocks();
67+
axiosMock = mocks.axiosMock;
6268

6369
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
6470

@@ -108,4 +114,48 @@ describe('<LibraryHome />', () => {
108114
fireEvent.scroll(window, { target: { scrollY: 1000 } });
109115
expect(mockFetchNextPage).toHaveBeenCalled();
110116
});
117+
118+
it('should show placeholderBlocks', async () => {
119+
axiosMock.onGet(getModulestoreMigratedBlocksInfoUrl()).reply(200, [
120+
{
121+
sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@library_content+block@test_lib_content',
122+
targetKey: null,
123+
unsupportedReason: 'The "library_content" XBlock (ID: "test_lib_content") has children, so it not supported in content libraries. It has 2 children blocks.',
124+
},
125+
{
126+
sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@conditional+block@test_conditional',
127+
targetKey: null,
128+
unsupportedReason: 'The "conditional" XBlock (ID: "test_conditional") has children, so it not supported in content libraries. It has 2 children blocks.',
129+
},
130+
]);
131+
(useGetContentHits as jest.Mock).mockReturnValue({
132+
isPending: false,
133+
data: {
134+
hits: [
135+
{
136+
display_name: 'Randomized Content Block',
137+
usage_key: 'block-v1:UNIX+UX2+2025_T2+type@library_content+block@test_lib_content',
138+
block_type: 'library_content',
139+
},
140+
{
141+
display_name: 'Conditional',
142+
usage_key: 'block-v1:UNIX+UX2+2025_T2+type@conditional+block@test_conditional',
143+
block_type: 'conditional',
144+
},
145+
],
146+
query: '',
147+
processingTimeMs: 0,
148+
limit: 2,
149+
offset: 0,
150+
estimatedTotalHits: 2,
151+
},
152+
});
153+
mockUseSearchContext.mockReturnValue({
154+
...data,
155+
hits: libraryComponentsMock,
156+
});
157+
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
158+
expect(await screen.findByText('Randomized Content Block')).toBeInTheDocument();
159+
expect(await screen.findByText('Conditional')).toBeInTheDocument();
160+
});
111161
});

src/library-authoring/LibraryContent.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { useEffect } from 'react';
2-
import { LoadingSpinner } from '../generic/Loading';
3-
import { useSearchContext } from '../search-manager';
2+
import { LoadingSpinner } from '@src/generic/Loading';
3+
import { useGetContentHits, useSearchContext } from '@src/search-manager';
4+
import { useLoadOnScroll } from '@src/hooks';
45
import { NoComponents, NoSearchResults } from './EmptyStates';
56
import { useLibraryContext } from './common/context/LibraryContext';
67
import { useSidebarContext } from './common/context/SidebarContext';
78
import CollectionCard from './components/CollectionCard';
89
import ComponentCard from './components/ComponentCard';
9-
import { ContentType } from './routes';
10-
import { useLoadOnScroll } from '../hooks';
10+
import { ContentType, useLibraryRoutes } from './routes';
1111
import messages from './collections/messages';
1212
import ContainerCard from './containers/ContainerCard';
13+
import { useMigrationBlocksInfo } from './data/apiHooks';
14+
import PlaceholderCard from './import-course/PlaceholderCard';
1315

1416
/**
1517
* Library Content to show content grid
@@ -40,8 +42,32 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
4042
isFiltered,
4143
usageKey,
4244
} = useSearchContext();
43-
const { openCreateCollectionModal } = useLibraryContext();
45+
const { libraryId, openCreateCollectionModal, collectionId } = useLibraryContext();
4446
const { openAddContentSidebar, openComponentInfoSidebar } = useSidebarContext();
47+
const { insideCollection } = useLibraryRoutes();
48+
/**
49+
* Placeholder blocks represent fake blocks for failed imports from other sources, such as courses.
50+
* They should only be displayed when viewing all components in the home tab of the library and the
51+
collection representing the course.
52+
* Blocks should be hidden when the user is searching or filtering them.
53+
*/
54+
const showPlaceholderBlocks = ([ContentType.home].includes(contentType) || insideCollection) && !isFiltered;
55+
const { data: placeholderBlocks } = useMigrationBlocksInfo(
56+
libraryId,
57+
collectionId,
58+
true,
59+
showPlaceholderBlocks,
60+
);
61+
// Fetch unsupported blocks usage_key information from meilisearch index.
62+
const { data: placeholderData } = useGetContentHits(
63+
[
64+
`usage_key IN [${placeholderBlocks?.map((block) => `"${block.sourceKey}"`).join(',')}]`,
65+
],
66+
(placeholderBlocks?.length || 0) > 0,
67+
['usage_key', 'block_type', 'display_name'],
68+
placeholderBlocks?.length,
69+
true,
70+
);
4571

4672
useEffect(() => {
4773
if (usageKey) {
@@ -81,6 +107,12 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
81107

82108
return <CardComponent key={contentHit.id} hit={contentHit} />;
83109
})}
110+
{showPlaceholderBlocks && placeholderData?.hits?.map((item) => (
111+
<PlaceholderCard
112+
displayName={item.display_name}
113+
blockType={item.block_type}
114+
/>
115+
))}
84116
</div>
85117
);
86118
};

src/library-authoring/components/BaseCard.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import ComponentCount from '@src/generic/component-count';
1212
import TagCount from '@src/generic/tag-count';
1313
import { BlockTypeLabel, type ContentHitTags, Highlight } from '@src/search-manager';
1414
import { skipIfUnwantedTarget } from '@src/utils';
15+
import { Report } from '@openedx/paragon/icons';
1516
import messages from './messages';
1617

1718
type BaseCardProps = {
@@ -25,6 +26,7 @@ type BaseCardProps = {
2526
hasUnpublishedChanges?: boolean;
2627
onSelect: (e?: React.MouseEvent) => void;
2728
selected?: boolean;
29+
isPlaceholder?: boolean;
2830
};
2931

3032
const BaseCard = ({
@@ -48,6 +50,7 @@ const BaseCard = ({
4850

4951
const itemIcon = getItemIcon(itemType);
5052
const intl = useIntl();
53+
const itemComponentStyle = !props.isPlaceholder ? getComponentStyleColor(itemType) : 'component-style-import-placeholder';
5154

5255
return (
5356
<Container className="library-item-card selected">
@@ -62,9 +65,9 @@ const BaseCard = ({
6265
className={selected ? 'selected' : undefined}
6366
>
6467
<Card.Header
65-
className={`library-item-header ${getComponentStyleColor(itemType)}`}
68+
className={`library-item-header ${itemComponentStyle}`}
6669
title={
67-
<Icon src={itemIcon} className="library-item-header-icon my-2" />
70+
<Icon src={props.isPlaceholder ? Report : itemIcon} className="library-item-header-icon my-2" />
6871
}
6972
actions={(
7073
<div
@@ -91,8 +94,12 @@ const BaseCard = ({
9194
<BlockTypeLabel blockType={itemType} />
9295
</small>
9396
</Stack>
94-
<ComponentCount count={numChildren} />
95-
<TagCount size="sm" count={tagCount} />
97+
{!props.isPlaceholder && (
98+
<>
99+
<ComponentCount count={numChildren} />
100+
<TagCount size="sm" count={tagCount} />
101+
</>
102+
)}
96103
</Stack>
97104
<div className="badge-container d-flex align-items-center justify-content-center">
98105
{props.hasUnpublishedChanges && (

src/library-authoring/data/api.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,18 @@ export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUr
157157
* Get the URL for the API endpoint to copy a single container.
158158
*/
159159
export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`;
160+
/**
161+
* Base url for modulestore_migrator
162+
*/
163+
export const getBaseModuleStoreMigrationUrl = () => `${getApiBaseUrl()}/api/modulestore_migrator/v1/`;
160164
/**
161165
* Get the url for the API endpoint to list library course imports.
162166
*/
163-
export const getCourseImportsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`;
167+
export const getCourseImportsApiUrl = (libraryId: string) => `${getBaseModuleStoreMigrationUrl()}library/${libraryId}/migrations/courses/`;
168+
/**
169+
* Get the url for the API endpoint to get migration blocks info.
170+
*/
171+
export const getModulestoreMigratedBlocksInfoUrl = () => `${getBaseModuleStoreMigrationUrl()}migration_blocks/`;
164172

165173
export interface ContentLibrary {
166174
id: string;
@@ -830,3 +838,32 @@ export async function getMigrationInfo(sourceKeys: string[]): Promise<Record<str
830838
const { data } = await client.get(`${getApiBaseUrl()}/api/modulestore_migrator/v1/migration_info/`, { params });
831839
return camelCaseObject(data);
832840
}
841+
842+
export interface BlockMigrationInfo {
843+
sourceKey: string;
844+
targetKey: string | null;
845+
unsupportedReason?: string;
846+
}
847+
848+
/**
849+
* Get the migration blocks info data for a library
850+
*/
851+
export async function getModulestoreMigrationBlocksInfo(
852+
libraryId: string,
853+
collectionId?: string,
854+
isFailed?: boolean,
855+
): Promise<BlockMigrationInfo[]> {
856+
const client = getAuthenticatedHttpClient();
857+
858+
const params = new URLSearchParams();
859+
params.append('target_key', libraryId);
860+
if (collectionId) {
861+
params.append('target_collection_key', collectionId);
862+
}
863+
if (isFailed !== undefined) {
864+
params.append('is_failed', JSON.stringify(isFailed));
865+
}
866+
867+
const { data } = await client.get(getModulestoreMigratedBlocksInfoUrl(), { params });
868+
return camelCaseObject(data);
869+
}

src/library-authoring/data/apiHooks.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ export const libraryAuthoringQueryKeys = {
9999
...libraryAuthoringQueryKeys.allMigrationInfo(),
100100
...sourceKeys,
101101
],
102+
migrationBlocksInfo: (libraryId: string, collectionId?: string, isFailed?: boolean) => [
103+
...libraryAuthoringQueryKeys.allMigrationInfo(),
104+
libraryId,
105+
collectionId,
106+
isFailed,
107+
],
102108
};
103109

104110
export const xblockQueryKeys = {
@@ -981,3 +987,18 @@ export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true)
981987
queryFn: enabled ? () => api.getMigrationInfo(sourcesKeys) : skipToken,
982988
})
983989
);
990+
991+
/**
992+
* Returns the migration blocks info of a given library
993+
*/
994+
export const useMigrationBlocksInfo = (
995+
libraryId: string,
996+
collectionId?: string,
997+
isFailed?: boolean,
998+
enabled = true,
999+
) => (
1000+
useQuery({
1001+
queryKey: libraryAuthoringQueryKeys.migrationBlocksInfo(libraryId, collectionId, isFailed),
1002+
queryFn: enabled ? () => api.getModulestoreMigrationBlocksInfo(libraryId, collectionId, isFailed) : skipToken,
1003+
})
1004+
);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import BaseCard from '../components/BaseCard';
2+
3+
interface PlaceHolderCardProps {
4+
blockType: string;
5+
displayName: string;
6+
description?: string;
7+
}
8+
9+
const PlaceholderCard = ({ blockType, displayName, description }: PlaceHolderCardProps) => {
10+
const truncatedDescription = description ? `${description.substring(0, 40) }...` : undefined;
11+
/* istanbul ignore next */
12+
return (
13+
<BaseCard
14+
itemType={blockType}
15+
displayName={displayName}
16+
description={truncatedDescription}
17+
tags={{}}
18+
numChildren={0}
19+
actions={null}
20+
hasUnpublishedChanges={false}
21+
onSelect={() => null}
22+
selected={false}
23+
isPlaceholder
24+
/>
25+
);
26+
};
27+
28+
export default PlaceholderCard;

0 commit comments

Comments
 (0)