diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss index 70e4882148..f0636a8ce9 100644 --- a/src/generic/block-type-utils/index.scss +++ b/src/generic/block-type-utils/index.scss @@ -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; + } + } +} diff --git a/src/library-authoring/LibraryContent.test.tsx b/src/library-authoring/LibraryContent.test.tsx index 8e12757ac0..fab12aa144 100644 --- a/src/library-authoring/LibraryContent.test.tsx +++ b/src/library-authoring/LibraryContent.test.tsx @@ -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) => ({ ), }); +let axiosMock: MockAdapter; describe('', () => { beforeEach(() => { - const { axiosMock } = initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true }); @@ -108,4 +114,48 @@ describe('', () => { 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(, withLibraryId(mockContentLibrary.libraryId)); + expect(await screen.findByText('Randomized Content Block')).toBeInTheDocument(); + expect(await screen.findByText('Conditional')).toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/LibraryContent.tsx b/src/library-authoring/LibraryContent.tsx index cb381f5970..74fe31f3cf 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -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 ; })} + {showPlaceholderBlocks && placeholderData?.hits?.map((item) => ( + + ))} ); }; diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index 62dcb947e0..f60a3a1cde 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -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 ( @@ -62,9 +65,9 @@ const BaseCard = ({ className={selected ? 'selected' : undefined} > + } actions={(
- - + {!props.isPlaceholder && ( + <> + + + + )}
{props.hasUnpublishedChanges && ( diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 0945bac2fa..3ebf174038 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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 { + 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); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 697f882dab..9d4f5e711d 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -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, + }) +); diff --git a/src/library-authoring/import-course/PlaceholderCard.tsx b/src/library-authoring/import-course/PlaceholderCard.tsx new file mode 100644 index 0000000000..ec174c79f0 --- /dev/null +++ b/src/library-authoring/import-course/PlaceholderCard.tsx @@ -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 ( + null} + selected={false} + isPlaceholder + /> + ); +}; + +export default PlaceholderCard;