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:
@@ -1,6 +1,7 @@
|
||||
.library-item-card {
|
||||
.pgn__card {
|
||||
height: 100%
|
||||
height: 100%;
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.library-item-header {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()`
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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!),
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user