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
25 changes: 25 additions & 0 deletions src/generic/block-type-utils/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
54 changes: 52 additions & 2 deletions src/library-authoring/LibraryContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) => ({
Expand All @@ -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 });

Expand Down Expand Up @@ -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();
});
});
42 changes: 37 additions & 5 deletions src/library-authoring/LibraryContent.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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>
);
};
Expand Down
15 changes: 11 additions & 4 deletions src/library-authoring/components/BaseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -25,6 +26,7 @@ type BaseCardProps = {
hasUnpublishedChanges?: boolean;
onSelect: (e?: React.MouseEvent) => void;
selected?: boolean;
isPlaceholder?: boolean;
};

const BaseCard = ({
Expand All @@ -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">
Expand All @@ -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
Expand All @@ -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 && (
Expand Down
39 changes: 38 additions & 1 deletion src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
21 changes: 21 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 changes: 28 additions & 0 deletions src/library-authoring/import-course/PlaceholderCard.tsx
Original file line number Diff line number Diff line change
@@ -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;