diff --git a/src/library-authoring/components/BaseCard.scss b/src/library-authoring/components/BaseCard.scss index 9346618a3..3a13bd14c 100644 --- a/src/library-authoring/components/BaseCard.scss +++ b/src/library-authoring/components/BaseCard.scss @@ -1,6 +1,7 @@ .library-item-card { .pgn__card { - height: 100% + height: 100%; + min-width: 15rem; } .library-item-header { diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index f327c7801..2b1593789 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -17,6 +17,7 @@ type BaseCardProps = { itemType: string; displayName: string; description?: string; + preview?: React.ReactNode; numChildren?: number; tags: ContentHitTags; actions: React.ReactNode; @@ -70,10 +71,10 @@ const BaseCard = ({ /> -
+
- + {props.preview || } diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx index 657a2e112..dd5da2f20 100644 --- a/src/library-authoring/components/ContainerCard.test.tsx +++ b/src/library-authoring/components/ContainerCard.test.tsx @@ -1,9 +1,10 @@ import userEvent from '@testing-library/user-event'; import { - initializeMocks, render as baseRender, screen, + initializeMocks, render as baseRender, screen, waitFor, } from '../../testUtils'; import { LibraryProvider } from '../common/context/LibraryContext'; +import { mockContentLibrary, mockGetContainerChildren } from '../data/api.mocks'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import ContainerCard from './ContainerCard'; @@ -33,6 +34,9 @@ const containerHitSample: ContainerHit = { publishStatus: PublishStatus.Published, }; +mockContentLibrary.applyMock(); +mockGetContainerChildren.applyMock(); + const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, { extraWrapper: ({ children }) => ( ', () => { // '/library/lb:org1:Demo_Course/container/container-display-name-123', // ); }); + + it('should render no child blocks in card preview', async () => { + render(); + + expect(screen.queryByTitle('text block')).not.toBeInTheDocument(); + expect(screen.queryByText('+0')).not.toBeInTheDocument(); + }); + + it('should render <=5 child blocks in card preview', async () => { + const containerWith5Children = { + ...containerHitSample, + usageKey: mockGetContainerChildren.fiveChildren, + }; + render(); + + await waitFor(() => { + expect(screen.getAllByTitle('text block').length).toBe(5); + }); + expect(screen.queryByText('+0')).not.toBeInTheDocument(); + }); + + it('should render >5 child blocks with +N in card preview', async () => { + const containerWith6Children = { + ...containerHitSample, + usageKey: mockGetContainerChildren.sixChildren, + }; + render(); + + await waitFor(() => { + expect(screen.getAllByTitle('text block').length).toBe(4); + }); + expect(screen.queryByText('+2')).toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 42cd49dfc..d508c2082 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -1,20 +1,23 @@ -import { useCallback } from 'react'; +import { ReactNode, useCallback } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Dropdown, Icon, IconButton, + Stack, } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; +import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useLibraryRoutes } from '../routes'; import BaseCard from './BaseCard'; +import { useContainerChildren } from '../data/apiHooks'; +import { useLibraryRoutes } from '../routes'; import messages from './messages'; type ContainerMenuProps = { @@ -49,6 +52,59 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { ); }; +type ContainerCardPreviewProps = { + containerId: string; + showMaxChildren?: number; +}; + +const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => { + const { data, isLoading, isError } = useContainerChildren(containerId); + if (isLoading || isError) { + return null; + } + + const hiddenChildren = data.length - showMaxChildren; + return ( + + { + data.slice(0, showMaxChildren).map(({ id, blockType, displayName }, idx) => { + let blockPreview: ReactNode; + let classNames; + + if (idx < showMaxChildren - 1 || hiddenChildren <= 0) { + // Show the first N-1 blocks as item icons + // (or all N blocks if no hidden children) + classNames = `rounded p-1 ${getComponentStyleColor(blockType)}`; + blockPreview = ( + + ); + } else { + // Container has more blocks than can fit in the preview, so show "+N" + blockPreview = ( + + ); + } + return ( +
+ {blockPreview} +
+ ); + }) + } +
+ ); +}; + type ContainerCardProps = { hit: ContainerHit, }; @@ -90,6 +146,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { } tags={tags} numChildren={numChildrenCount} actions={!componentPickerMode && ( diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 40276ca7c..831855abc 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -176,5 +176,10 @@ const messages = defineMessages({ defaultMessage: 'This component can be synced in courses after publish.', description: 'Alert text of the modal to confirm publish a component in a library.', }, + containerPreviewMoreBlocks: { + id: 'course-authoring.library-authoring.component.container-card-preview.more-blocks', + defaultMessage: '+{count}', + description: 'Count shown when a container has more blocks than will fit on the card preview.', + }, }); export default messages; diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index d84ba79c6..73df11bfc 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -498,6 +498,56 @@ mockGetContainerMetadata.applyMock = () => { jest.spyOn(api, 'getContainerMetadata').mockImplementation(mockGetContainerMetadata); }; +/** + * Mock for `getContainerChildren()` + * + * This mock returns a fixed response for the given container ID. + */ +export async function mockGetContainerChildren(containerId: string): Promise { + let numChildren: number; + switch (containerId) { + case mockGetContainerChildren.fiveChildren: + numChildren = 5; + break; + case mockGetContainerChildren.sixChildren: + numChildren = 6; + break; + default: + numChildren = 0; + break; + } + return Promise.resolve( + Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => ( + { + ...child, + // Generate a unique ID for each child block to avoid "duplicate key" errors in tests + id: `lb:org1:Demo_course:html:text-${idx}`, + } + )), + ); +} +mockGetContainerChildren.fiveChildren = 'lct:org1:Demo_Course:unit:unit-5'; +mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6'; +mockGetContainerChildren.childTemplate = { + id: 'lb:org1:Demo_course:html:text', + blockType: 'html', + defKey: 'def_key', + displayName: 'text block', + lastPublished: null, + publishedBy: null, + lastDraftCreated: null, + lastDraftCreatedBy: null, + hasUnpublishedChanges: false, + created: null, + modified: null, + tagsCount: 0, + collections: [] as api.CollectionMetadata[], +} satisfies api.LibraryBlockMetadata; +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockGetContainerChildren.applyMock = () => { + jest.spyOn(api, 'getContainerChildren').mockImplementation(mockGetContainerChildren); +}; + /** * Mock for `getXBlockOLX()` * diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 352089a88..7348eedc9 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -111,6 +111,10 @@ export const getLibraryContainersApiUrl = (libraryId: string) => `${getApiBaseUr * Get the URL for the container detail api. */ export const getLibraryContainerApiUrl = (containerId: string) => `${getApiBaseUrl()}/api/libraries/v2/containers/${containerId}/`; +/** + * Get the URL for a single container children api. + */ +export const getLibraryContainerChildrenApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}children/`; export interface ContentLibrary { id: string; @@ -616,3 +620,12 @@ export async function updateContainerMetadata( const client = getAuthenticatedHttpClient(); await client.patch(getLibraryContainerApiUrl(containerId), snakeCaseObject(containerData)); } + +/** + * Fetch a library container's children's metadata. + */ +export async function getContainerChildren(containerId: string): Promise { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getLibraryContainerChildrenApiUrl(containerId)); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 2f920b544..ddc263f37 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -13,6 +13,7 @@ import { getLibraryCollectionApiUrl, getBlockTypesMetaDataUrl, getLibraryContainerApiUrl, + getLibraryContainerChildrenApiUrl, } from './api'; import { useCommitLibraryChanges, @@ -23,6 +24,7 @@ import { useCollection, useBlockTypesMetadata, useContainer, + useContainerChildren, } from './apiHooks'; let axiosMock; @@ -152,4 +154,79 @@ describe('library api hooks', () => { expect(result.current.data).toEqual({ testData: 'test-value' }); expect(axiosMock.history.get[0].url).toEqual(url); }); + + it('should get container children', async () => { + const containerId = 'lct:lib:org:unit:unit1'; + const url = getLibraryContainerChildrenApiUrl(containerId); + + axiosMock.onGet(url).reply(200, [ + { + id: 'lb:org1:Demo_course:html:text', + block_type: 'html', + def_key: 'def_key', + display_name: 'text block', + last_published: null, + published_by: null, + last_draft_created: null, + last_draft_created_by: null, + has_unpublished_changes: false, + created: null, + modified: null, + tags_count: 0, + collections: ['col1', 'col2'], + }, + { + id: 'lb:org1:Demo_course:video:video1', + block_type: 'video', + def_key: 'def_key', + display_name: 'video block', + last_published: null, + published_by: null, + last_draft_created: null, + last_draft_created_by: null, + has_unpublished_changes: false, + created: null, + modified: null, + tags_count: 0, + collections: ['col2'], + }, + ]); + const { result } = renderHook(() => useContainerChildren(containerId), { wrapper }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + }); + expect(result.current.data).toEqual([ + { + id: 'lb:org1:Demo_course:html:text', + blockType: 'html', + defKey: 'def_key', + displayName: 'text block', + lastPublished: null, + publishedBy: null, + lastDraftCreated: null, + lastDraftCreatedBy: null, + hasUnpublishedChanges: false, + created: null, + modified: null, + tagsCount: 0, + collections: ['col1', 'col2'], + }, + { + id: 'lb:org1:Demo_course:video:video1', + blockType: 'video', + defKey: 'def_key', + displayName: 'video block', + lastPublished: null, + publishedBy: null, + lastDraftCreated: null, + lastDraftCreatedBy: null, + hasUnpublishedChanges: false, + created: null, + modified: null, + tagsCount: 0, + collections: ['col2'], + }, + ]); + expect(axiosMock.history.get[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 31bbf1bbd..839492748 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -51,6 +51,7 @@ import { getContainerMetadata, updateContainerMetadata, type UpdateContainerDataRequest, + getContainerChildren, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -95,6 +96,11 @@ export const libraryAuthoringQueryKeys = { 'blockTypes', libraryId, ], + container: (libraryId?: string, containerId?: string) => [ + ...libraryAuthoringQueryKeys.all, + libraryId, + containerId, + ], }; export const xblockQueryKeys = { @@ -114,11 +120,12 @@ export const xblockQueryKeys = { }; export const containerQueryKeys = { - all: ['container'], + all: ['container', 'children'], /** * Base key for data specific to a container */ container: (usageKey?: string) => [...containerQueryKeys.all, usageKey], + children: (usageKey?: string) => [...containerQueryKeys.all, usageKey, 'children'], }; /** @@ -613,3 +620,14 @@ export const useUpdateContainer = (containerId: string) => { }, }); }; + +/** + * Get the metadata and children for a container in a library + */ +export const useContainerChildren = (containerId: string) => ( + useQuery({ + enabled: !!containerId, + queryKey: containerQueryKeys.children(containerId), + queryFn: () => getContainerChildren(containerId!), + }) +);