feat: Section/Subsection Card Preview [FC-0090] (#2057)

Section/Subsection card previews
This commit is contained in:
Chris Chávez
2025-06-09 11:10:18 -05:00
committed by GitHub
parent 019eede7c2
commit be13c18e5d
7 changed files with 216 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
.container-card-preview-text {
display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
};

View File

@@ -68,17 +68,51 @@ describe('<ContainerCard />', () => {
({ axiosMock, mockShowToast } = initializeMocks());
});
it('should render the card with title', () => {
render(<ContainerCard hit={getContainerHitSample()} />);
test.each([
{
label: 'should render the unit card with title',
containerType: ContainerType.Unit,
displayName: 'unit Display Formated Name',
},
{
label: 'should render the subsection card with title',
containerType: ContainerType.Subsection,
displayName: 'subsection Display Formated Name',
},
{
label: 'should render the section card with title',
containerType: ContainerType.Section,
displayName: 'section Display Formated Name',
},
])('$label', ({ containerType, displayName }) => {
const container = getContainerHitSample(containerType);
render(<ContainerCard hit={container} />);
expect(screen.getByText('unit Display Formated Name')).toBeInTheDocument();
expect(screen.getByText(displayName)).toBeInTheDocument();
expect(screen.queryByText('2')).toBeInTheDocument(); // Component count
});
it('should render published content', () => {
render(<ContainerCard hit={getContainerHitSample()} />, true);
test.each([
{
label: 'sould render published content of unit card',
containerType: ContainerType.Unit,
displayName: 'Published unit Display Name',
},
{
label: 'sould render published content of subsection card',
containerType: ContainerType.Subsection,
displayName: 'Published subsection Display Name',
},
{
label: 'sould render published content of section card',
containerType: ContainerType.Section,
displayName: 'Published section Display Name',
},
])('$label', ({ containerType, displayName }) => {
const container = getContainerHitSample(containerType);
render(<ContainerCard hit={container} />, true);
expect(screen.getByText('Published unit Display Name')).toBeInTheDocument();
expect(screen.getByText(displayName)).toBeInTheDocument();
expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count
});
@@ -153,7 +187,7 @@ describe('<ContainerCard />', () => {
fireEvent.click(deleteMenuItem);
// Confirm delete Modal is open
expect(screen.getByText('Delete Unit'));
expect(await screen.findByText('Delete Unit')).toBeInTheDocument();
const deleteButton = screen.getByRole('button', { name: /delete/i });
fireEvent.click(deleteButton);
@@ -200,14 +234,14 @@ describe('<ContainerCard />', () => {
expect(mockShowToast).toHaveBeenCalledWith('Failed to delete unit');
});
it('should render no child blocks in card preview', async () => {
it('should render no child blocks in unit card preview', async () => {
render(<ContainerCard hit={getContainerHitSample()} />);
expect(screen.queryByTitle('lb:org1:Demo_course:html:text-0')).not.toBeInTheDocument();
expect(screen.queryByText('+0')).not.toBeInTheDocument();
});
it('should render <=5 child blocks in card preview', async () => {
it('should render <=5 child blocks in unit card preview', async () => {
const containerWith5Children = {
...getContainerHitSample(),
content: {
@@ -220,7 +254,7 @@ describe('<ContainerCard />', () => {
expect(screen.queryByText('+0')).not.toBeInTheDocument();
});
it('should render >5 child blocks with +N in card preview', async () => {
it('should render >5 child blocks with +N in unit card preview', async () => {
const containerWith6Children = {
...getContainerHitSample(),
content: {
@@ -233,7 +267,7 @@ describe('<ContainerCard />', () => {
expect(screen.queryByText('+2')).toBeInTheDocument();
});
it('should render published child blocks when rendering a published card preview', async () => {
it('should render published child blocks when rendering a published unit card preview', async () => {
const containerWithPublishedChildren = {
...getContainerHitSample(),
content: {
@@ -253,4 +287,104 @@ describe('<ContainerCard />', () => {
expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(2);
expect(screen.queryByText('+2')).not.toBeInTheDocument();
});
test.each([
{
label: 'should render published child in subsection card preview',
containerType: ContainerType.Subsection,
childrenType: 'unit',
displayName: 'Published subsection Display Name',
expected: /contains unit 0, unit 1\./i,
},
{
label: 'should render published child in section card preview',
containerType: ContainerType.Section,
childrenType: 'subsection',
displayName: 'Published section Display Name',
expected: /contains subsection 0, subsection 1\./i,
},
])('$label', ({
containerType,
childrenType,
displayName,
expected,
}) => {
const containerWithChildren = {
...getContainerHitSample(containerType),
content: {
childUsageKeys: Array(6).fill('').map(
(_child, idx) => `lct:org1:Demo_Course:${childrenType}:${childrenType}-${idx}`,
),
childDisplayNames: Array(6).fill('').map((_child, idx) => `${childrenType} ${idx}`),
},
published: {
content: {
childUsageKeys: Array(2).fill('').map(
(_child, idx) => `lct:org1:Demo_Course:${childrenType}:${childrenType}-${idx}`,
),
childDisplayNames: Array(2).fill('').map((_child, idx) => `${childrenType} ${idx}`),
},
},
} satisfies ContainerHit;
render(<ContainerCard hit={containerWithChildren} />, true);
expect(screen.getByText(displayName)).toBeInTheDocument();
expect(screen.getByText(expected)).toBeInTheDocument();
});
test.each([
{
label: 'should render subsection card preview with children',
containerType: ContainerType.Subsection,
childrenType: 'unit',
displayName: 'subsection Display Formated Name',
expected: /contains unit 0, unit 1\./i,
},
{
label: 'should render section card preview with children',
containerType: ContainerType.Section,
childrenType: 'subsection',
displayName: 'section Display Formated Name',
expected: /contains subsection 0, subsection 1\./i,
},
])('$label', ({
containerType,
childrenType,
displayName,
expected,
}) => {
const containerWithChildren = {
...getContainerHitSample(containerType),
content: {
childUsageKeys: Array(2).fill('').map(
(_child, idx) => `lct:org1:Demo_Course:${childrenType}:${childrenType}-${idx}`,
),
childDisplayNames: Array(2).fill('').map((_child, idx) => `${childrenType} ${idx}`),
},
} satisfies ContainerHit;
render(<ContainerCard hit={containerWithChildren} />);
expect(screen.getByText(displayName)).toBeInTheDocument();
expect(screen.getByText(expected)).toBeInTheDocument();
});
test.each([
{
label: 'should render subsection card preview without children',
containerType: ContainerType.Subsection,
displayName: 'subsection Display Formated Name',
},
{
label: 'should render section card preview without children',
containerType: ContainerType.Section,
displayName: 'section Display Formated Name',
},
])('$label', ({ containerType, displayName }) => {
const container = getContainerHitSample(containerType);
render(<ContainerCard hit={container} />);
expect(screen.getByText(displayName)).toBeInTheDocument();
expect(screen.queryByText(/contains/i)).not.toBeInTheDocument();
});
});

View File

@@ -13,7 +13,7 @@ import { MoreVert } from '@openedx/paragon/icons';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import { ContainerType, getBlockType } from '../../generic/key-utils';
import { ToastContext } from '../../generic/toast-context';
import { type ContainerHit, PublishStatus } from '../../search-manager';
import { type ContainerHit, Highlight, PublishStatus } from '../../search-manager';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
@@ -88,7 +88,7 @@ export const ContainerMenu = ({ containerKey, containerType, displayName } : Con
<Dropdown.Item onClick={openContainer}>
<FormattedMessage {...messages.menuOpen} />
</Dropdown.Item>
<Dropdown.Item onClick={confirmDelete}>
<Dropdown.Item onClick={confirmDelete} disabled={containerType !== 'unit'}>
<FormattedMessage {...messages.menuDeleteContainer} />
</Dropdown.Item>
{insideCollection && (
@@ -96,7 +96,7 @@ export const ContainerMenu = ({ containerKey, containerType, displayName } : Con
<FormattedMessage {...messages.menuRemoveFromCollection} />
</Dropdown.Item>
)}
<Dropdown.Item onClick={showManageCollections}>
<Dropdown.Item onClick={showManageCollections} disabled={containerType !== 'unit'}>
<FormattedMessage {...messages.menuAddToCollection} />
</Dropdown.Item>
</Dropdown.Menu>
@@ -111,17 +111,17 @@ export const ContainerMenu = ({ containerKey, containerType, displayName } : Con
);
};
type ContainerCardPreviewProps = {
childUsageKeys: Array<string>;
type UnitCardPreviewProps = {
childKeys: Array<string>;
showMaxChildren?: number;
};
const ContainerCardPreview = ({ childUsageKeys, showMaxChildren = 5 }: ContainerCardPreviewProps) => {
const hiddenChildren = childUsageKeys.length - showMaxChildren;
const UnitcardPreview = ({ childKeys, showMaxChildren = 5 }: UnitCardPreviewProps) => {
const hiddenChildren = childKeys.length - showMaxChildren;
return (
<Stack direction="horizontal" gap={2}>
{
childUsageKeys.slice(0, showMaxChildren).map((usageKey, idx) => {
childKeys.slice(0, showMaxChildren).map((usageKey, idx) => {
const blockType = getBlockType(usageKey);
let blockPreview: ReactNode;
let classNames;
@@ -162,6 +162,51 @@ const ContainerCardPreview = ({ childUsageKeys, showMaxChildren = 5 }: Container
);
};
type ContainerCardPreviewProps = {
hit: ContainerHit,
};
const ContainerCardPreview = ({ hit }: ContainerCardPreviewProps) => {
const intl = useIntl();
const { showOnlyPublished } = useLibraryContext();
const {
blockType: itemType,
published,
content,
} = hit;
if (itemType === 'unit') {
const childKeys: Array<string> = (
showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys
) ?? [];
return <UnitcardPreview childKeys={childKeys} />;
}
// TODO Section highlights
const childNames: Array<string> = (
showOnlyPublished ? published?.content?.childDisplayNames : content?.childDisplayNames
) ?? [];
if (childNames.length > 0) {
// Preview with a truncated text with all children display names
const childrenText = intl.formatMessage(
messages.containerPreviewText,
{
children: childNames.join(', '),
},
);
return (
<div className="container-card-preview-text">
<Highlight text={childrenText} />
</div>
);
}
// Empty preview
return null;
};
type ContainerCardProps = {
hit: ContainerHit,
};
@@ -179,7 +224,6 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
published,
publishStatus,
usageKey: containerKey,
content,
} = hit;
const numChildrenCount = showOnlyPublished ? (
@@ -190,10 +234,6 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
showOnlyPublished ? formatted.published?.displayName : formatted.displayName
) ?? '';
const childUsageKeys: Array<string> = (
showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys
) ?? [];
const selected = sidebarComponentInfo?.id === containerKey;
const { navigateTo } = useLibraryRoutes();
@@ -237,7 +277,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
<BaseCard
itemType={itemType}
displayName={displayName}
preview={<ContainerCardPreview childUsageKeys={childUsageKeys} />}
preview={<ContainerCardPreview hit={hit} />}
tags={tags}
numChildren={numChildrenCount}
actions={(

View File

@@ -0,0 +1,2 @@
@import "./BaseCard.scss";
@import "./ContainerCard.scss";

View File

@@ -276,5 +276,10 @@ const messages = defineMessages({
defaultMessage: 'Failed to undo remove component operation',
description: 'Message to display on failure to undo delete component',
},
containerPreviewText: {
id: 'course-authoring.library-authoring.container.preview.text',
defaultMessage: 'Contains {children}.',
description: 'Preview message for section/subsections with the names of children separated by commas',
},
});
export default messages;

View File

@@ -1,5 +1,5 @@
@import "./component-info/ComponentPreview";
@import "./components/BaseCard";
@import "./components";
@import "./generic";
@import "./LibraryAuthoringPage";
@import "./units";

View File

@@ -54,6 +54,7 @@ export interface ContentDetails {
htmlContent?: string;
capaContent?: string;
childUsageKeys?: Array<string>;
childDisplayNames?: Array<string>;
[k: string]: any;
}
@@ -179,6 +180,7 @@ export interface CollectionHit extends BaseContentHit {
*/
interface ContainerHitContent {
childUsageKeys?: string[],
childDisplayNames?: string[],
}
export interface ContainerHit extends BaseContentHit {
type: 'library_container';