feat: Unit card previews [FC-0083] (#1774)

Adds block tiles to the Unit card to indicate type and quantity of children in the container.
This commit is contained in:
Jillian
2025-04-09 08:17:04 +09:30
committed by GitHub
parent 451b821c3b
commit 7ceeb32820
9 changed files with 266 additions and 7 deletions

View File

@@ -1,6 +1,7 @@
.library-item-card {
.pgn__card {
height: 100%
height: 100%;
min-width: 15rem;
}
.library-item-header {

View File

@@ -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 = ({
/>
<Card.Body className="w-100">
<Card.Section>
<div className="text-truncate h3 mt-2">
<div className="text-truncate h3 mt-1">
<Highlight text={displayName} />
</div>
<Highlight text={description} />
{props.preview || <Highlight text={description} />}
</Card.Section>
</Card.Body>
<Card.Footer className="mt-auto">

View File

@@ -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 }) => (
<LibraryProvider
@@ -80,4 +84,37 @@ describe('<ContainerCard />', () => {
// '/library/lb:org1:Demo_Course/container/container-display-name-123',
// );
});
it('should render no child blocks in card preview', async () => {
render(<ContainerCard hit={containerHitSample} />);
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(<ContainerCard hit={containerWith5Children} />);
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(<ContainerCard hit={containerWith6Children} />);
await waitFor(() => {
expect(screen.getAllByTitle('text block').length).toBe(4);
});
expect(screen.queryByText('+2')).toBeInTheDocument();
});
});

View File

@@ -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 (
<Stack direction="horizontal" gap={2}>
{
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 = (
<Icon
src={getItemIcon(blockType)}
screenReaderText={blockType}
title={displayName}
/>
);
} else {
// Container has more blocks than can fit in the preview, so show "+N"
blockPreview = (
<FormattedMessage
{...messages.containerPreviewMoreBlocks}
values={{ count: hiddenChildren + 1 }}
/>
);
}
return (
<div
key={`container-card-preview-block-${id}`}
className={classNames}
>
{blockPreview}
</div>
);
})
}
</Stack>
);
};
type ContainerCardProps = {
hit: ContainerHit,
};
@@ -90,6 +146,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
<BaseCard
itemType={itemType}
displayName={displayName}
preview={<ContainerCardPreview containerId={unitId} />}
tags={tags}
numChildren={numChildrenCount}
actions={!componentPickerMode && (

View File

@@ -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;

View File

@@ -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<api.LibraryBlockMetadata[]> {
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()`
*

View File

@@ -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<LibraryBlockMetadata[]> {
const client = getAuthenticatedHttpClient();
const { data } = await client.get(getLibraryContainerChildrenApiUrl(containerId));
return camelCaseObject(data);
}

View File

@@ -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);
});
});

View File

@@ -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!),
})
);