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
3 changes: 2 additions & 1 deletion src/library-authoring/components/BaseCard.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.library-item-card {
.pgn__card {
height: 100%
height: 100%;
min-width: 15rem;
}

.library-item-header {
Expand Down
5 changes: 3 additions & 2 deletions src/library-authoring/components/BaseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type BaseCardProps = {
itemType: string;
displayName: string;
description?: string;
preview?: React.ReactNode;
numChildren?: number;
tags: ContentHitTags;
actions: React.ReactNode;
Expand Down Expand Up @@ -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">
Expand Down
39 changes: 38 additions & 1 deletion src/library-authoring/components/ContainerCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
});
61 changes: 59 additions & 2 deletions src/library-authoring/components/ContainerCard.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -90,6 +146,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
<BaseCard
itemType={itemType}
displayName={displayName}
preview={<ContainerCardPreview containerId={unitId} />}
tags={tags}
numChildren={numChildrenCount}
actions={!componentPickerMode && (
Expand Down
5 changes: 5 additions & 0 deletions src/library-authoring/components/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
50 changes: 50 additions & 0 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
*
Expand Down
13 changes: 13 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
77 changes: 77 additions & 0 deletions src/library-authoring/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getLibraryCollectionApiUrl,
getBlockTypesMetaDataUrl,
getLibraryContainerApiUrl,
getLibraryContainerChildrenApiUrl,
} from './api';
import {
useCommitLibraryChanges,
Expand All @@ -23,6 +24,7 @@ import {
useCollection,
useBlockTypesMetadata,
useContainer,
useContainerChildren,
} from './apiHooks';

let axiosMock;
Expand Down Expand Up @@ -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);
});
});
Loading