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
2 changes: 2 additions & 0 deletions src/generic/block-type-utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const COMPONENT_TYPE_ICON_MAP: Record<string, React.ComponentType> = {

export const STRUCTURAL_TYPE_ICONS: Record<string, React.ComponentType> = {
vertical: UNIT_TYPE_ICONS_MAP.vertical,
unit: UNIT_TYPE_ICONS_MAP.vertical,
sequential: Folder,
chapter: Folder,
collection: Folder,
Expand All @@ -73,6 +74,7 @@ export const COMPONENT_TYPE_STYLE_COLOR_MAP = {
[COMPONENT_TYPES.video]: 'component-style-video',
[COMPONENT_TYPES.dragAndDrop]: 'component-style-default',
vertical: 'component-style-vertical',
unit: 'component-style-vertical',
sequential: 'component-style-default',
chapter: 'component-style-default',
collection: 'component-style-collection',
Expand Down
14 changes: 14 additions & 0 deletions src/generic/component-count/ComponentCount.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { render, screen } from '@testing-library/react';
import ComponentCount from '.';

describe('<ComponentCount>', () => {
it('should render the component', () => {
render(<ComponentCount count={17} />);
expect(screen.getByText('17')).toBeInTheDocument();
});

it('should render the component with zero', () => {
render(<ComponentCount count={0} />);
expect(screen.getByText('0')).toBeInTheDocument();
});
});
18 changes: 18 additions & 0 deletions src/generic/component-count/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import { Icon, Stack } from '@openedx/paragon';
import { Widgets } from '@openedx/paragon/icons';

type ComponentCountProps = {
count?: number;
};

const ComponentCount: React.FC<ComponentCountProps> = ({ count }) => (
count !== undefined ? (
<Stack direction="horizontal" gap={1}>
<Icon size="sm" src={Widgets} />
<small>{count}</small>
</Stack>
) : null
);

export default ComponentCount;
27 changes: 12 additions & 15 deletions src/generic/tag-count/index.jsx → src/generic/tag-count/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import PropTypes from 'prop-types';
import { Icon, Button } from '@openedx/paragon';
import { Button, Icon, Stack } from '@openedx/paragon';
import { Tag } from '@openedx/paragon/icons';
import classNames from 'classnames';

const TagCount = ({ count, onClick }) => {
type TagCountProps = {
count: number;
onClick?: () => void;
size?: Parameters<typeof Icon>[0]['size'];
};

// eslint-disable-next-line react/prop-types
const TagCount: React.FC<TagCountProps> = ({ count, onClick, size }) => {
const renderContent = () => (
<>
<Icon className="mr-1 pt-1" src={Tag} />
<Stack direction="horizontal" gap={1}>
<Icon size={size} src={Tag} />
{count}
</>
</Stack>
);

return (
Expand All @@ -26,13 +32,4 @@ const TagCount = ({ count, onClick }) => {
);
};

TagCount.defaultProps = {
onClick: undefined,
};

TagCount.propTypes = {
count: PropTypes.number.isRequired,
onClick: PropTypes.func,
};

export default TagCount;
2 changes: 1 addition & 1 deletion src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
}

const activeTypeFilters = {
components: 'NOT type = "collection"',
components: 'type = "library_block"',
collections: 'type = "collection"',
};
if (activeKey !== ContentType.home) {
Expand Down
25 changes: 12 additions & 13 deletions src/library-authoring/LibraryContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useLibraryContext } from './common/context/LibraryContext';
import { useSidebarContext } from './common/context/SidebarContext';
import CollectionCard from './components/CollectionCard';
import ComponentCard from './components/ComponentCard';
import ContainerCard from './components/ContainerCard';
import { ContentType } from './routes';
import { useLoadOnScroll } from '../hooks';
import messages from './collections/messages';
Expand All @@ -22,6 +23,12 @@ type LibraryContentProps = {
contentType?: ContentType;
};

const LibraryItemCard = {
collection: CollectionCard,
library_block: ComponentCard,
library_container: ContainerCard,
};

const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) => {
const {
hits,
Expand Down Expand Up @@ -69,19 +76,11 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)

return (
<div className="library-cards-grid">
{hits.map((contentHit) => (
contentHit.type === 'collection' ? (
<CollectionCard
key={contentHit.id}
collectionHit={contentHit}
/>
) : (
<ComponentCard
key={contentHit.id}
contentHit={contentHit}
/>
)
))}
{hits.map((contentHit) => {
const CardComponent = LibraryItemCard[contentHit.type] || ComponentCard;

return <CardComponent key={contentHit.id} hit={contentHit} />;
})}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
.library-component-card {
.library-item-card {
.pgn__card {
height: 100%
}

.library-component-header {
.library-item-header {
border-top-left-radius: .375rem;
border-top-right-radius: .375rem;
padding: 0 .5rem 0 1.25rem;

.library-component-header-icon {
.library-item-header-icon {
width: 2.3rem;
height: 2.3rem;
}
Expand All @@ -21,4 +21,8 @@
margin: .25rem 0 .25rem 1rem;
}
}

.badge-container {
min-height: 20px;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,31 @@ import {
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import ComponentCount from '../../generic/component-count';
import TagCount from '../../generic/tag-count';
import { BlockTypeLabel, type ContentHitTags, Highlight } from '../../search-manager';

type BaseComponentCardProps = {
componentType: string;
type BaseCardProps = {
itemType: string;
displayName: string;
description: string;
description?: string;
numChildren?: number;
tags: ContentHitTags;
actions: React.ReactNode;
hasUnpublishedChanges?: boolean;
onSelect: () => void
};

const BaseComponentCard = ({
componentType,
const BaseCard = ({
itemType,
displayName,
description,
description = '',
numChildren,
tags,
actions,
onSelect,
...props
} : BaseComponentCardProps) => {
} : BaseCardProps) => {
const tagCount = useMemo(() => {
if (!tags) {
return 0;
Expand All @@ -41,11 +42,11 @@ const BaseComponentCard = ({
+ (tags.level2?.length || 0) + (tags.level3?.length || 0);
}, [tags]);

const componentIcon = getItemIcon(componentType);
const itemIcon = getItemIcon(itemType);
const intl = useIntl();

return (
<Container className="library-component-card">
<Container className="library-item-card">
<Card
isClickable
onClick={onSelect}
Expand All @@ -56,9 +57,9 @@ const BaseComponentCard = ({
}}
>
<Card.Header
className={`library-component-header ${getComponentStyleColor(componentType)}`}
className={`library-item-header ${getComponentStyleColor(itemType)}`}
title={
<Icon src={componentIcon} className="library-component-header-icon" />
<Icon src={itemIcon} className="library-item-header-icon" />
}
actions={
// Wrap the actions in a div to prevent the card from being clicked when the actions are clicked
Expand All @@ -67,27 +68,36 @@ const BaseComponentCard = ({
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
}
/>
<Card.Body>
<Card.Body className="w-100">
<Card.Section>
<Stack direction="horizontal" className="d-flex justify-content-between">
<Stack direction="horizontal" gap={1}>
<Icon src={componentIcon} size="sm" />
<span className="small">
<BlockTypeLabel blockType={componentType} count={numChildren} />
</span>
</Stack>
<TagCount count={tagCount} />
</Stack>
<div className="text-truncate h3 mt-2">
<Highlight text={displayName} />
</div>
<Highlight text={description} /><br />
{props.hasUnpublishedChanges ? <Badge variant="warning">{intl.formatMessage(messages.unpublishedChanges)}</Badge> : null}
<Highlight text={description} />
</Card.Section>
</Card.Body>
<Card.Footer className="mt-auto">
<Stack gap={2}>
<Stack direction="horizontal" gap={1}>
<Stack direction="horizontal" gap={1} className="mr-auto">
<Icon src={itemIcon} size="sm" />
<small>
<BlockTypeLabel blockType={itemType} />
</small>
</Stack>
<ComponentCount count={numChildren} />
<TagCount size="sm" count={tagCount} />
</Stack>
<div className="badge-container d-flex align-items-center justify-content-center">
{props.hasUnpublishedChanges && (
<Badge variant="warning">{intl.formatMessage(messages.unpublishedChanges)}</Badge>
)}
</div>
</Stack>
</Card.Footer>
</Card>
</Container>
);
};

export default BaseComponentCard;
export default BaseCard;
22 changes: 11 additions & 11 deletions src/library-authoring/components/CollectionCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import CollectionCard from './CollectionCard';
import messages from './messages';
import { getLibraryCollectionApiUrl, getLibraryCollectionRestoreApiUrl } from '../data/api';

const CollectionHitSample: CollectionHit = {
const collectionHitSample: CollectionHit = {
id: 'lib-collectionorg1democourse-collection-display-name',
type: 'collection',
contextKey: 'lb:org1:Demo_Course',
Expand Down Expand Up @@ -55,23 +55,23 @@ describe('<CollectionCard />', () => {
});

it('should render the card with title and description', () => {
render(<CollectionCard collectionHit={CollectionHitSample} />);
render(<CollectionCard hit={collectionHitSample} />);

expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument();
expect(screen.queryByText('Collection description')).toBeInTheDocument();
expect(screen.queryByText('Collection (2)')).toBeInTheDocument();
expect(screen.queryByText('2')).toBeInTheDocument(); // Component count
});

it('should render published content', () => {
render(<CollectionCard collectionHit={CollectionHitSample} />, true);
render(<CollectionCard hit={collectionHitSample} />, true);

expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument();
expect(screen.queryByText('Collection description')).toBeInTheDocument();
expect(screen.queryByText('Collection (1)')).toBeInTheDocument();
expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count
});

it('should navigate to the collection if the open menu clicked', async () => {
render(<CollectionCard collectionHit={CollectionHitSample} />);
render(<CollectionCard hit={collectionHitSample} />);

// Open menu
expect(screen.getByTestId('collection-card-menu-toggle')).toBeInTheDocument();
Expand All @@ -85,9 +85,9 @@ describe('<CollectionCard />', () => {
});

it('should show confirmation box, delete collection and show toast to undo deletion', async () => {
const url = getLibraryCollectionApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId);
const url = getLibraryCollectionApiUrl(collectionHitSample.contextKey, collectionHitSample.blockId);
axiosMock.onDelete(url).reply(204);
render(<CollectionCard collectionHit={CollectionHitSample} />);
render(<CollectionCard hit={collectionHitSample} />);

expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument();
// Open menu
Expand Down Expand Up @@ -123,7 +123,7 @@ describe('<CollectionCard />', () => {
// Get restore / undo func from the toast
const restoreFn = mockShowToast.mock.calls[0][1].onClick;

const restoreUrl = getLibraryCollectionRestoreApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId);
const restoreUrl = getLibraryCollectionRestoreApiUrl(collectionHitSample.contextKey, collectionHitSample.blockId);
axiosMock.onPost(restoreUrl).reply(200);
// restore collection
restoreFn();
Expand All @@ -134,9 +134,9 @@ describe('<CollectionCard />', () => {
});

it('should show failed toast on delete collection failure', async () => {
const url = getLibraryCollectionApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId);
const url = getLibraryCollectionApiUrl(collectionHitSample.contextKey, collectionHitSample.blockId);
axiosMock.onDelete(url).reply(404);
render(<CollectionCard collectionHit={CollectionHitSample} />);
render(<CollectionCard hit={collectionHitSample} />);

expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument();
// Open menu
Expand Down
Loading