feat: unit cards in library [FC-0083] (#1742)
Unit cards in library and rename BaseComponentCard -> BaseCard
This commit is contained in:
@@ -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,
|
||||
@@ -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',
|
||||
|
||||
14
src/generic/component-count/ComponentCount.test.tsx
Normal file
14
src/generic/component-count/ComponentCount.test.tsx
Normal file
@@ -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
src/generic/component-count/index.tsx
Normal file
18
src/generic/component-count/index.tsx
Normal file
@@ -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;
|
||||
@@ -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 (
|
||||
@@ -26,13 +32,4 @@ const TagCount = ({ count, onClick }) => {
|
||||
);
|
||||
};
|
||||
|
||||
TagCount.defaultProps = {
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
TagCount.propTypes = {
|
||||
count: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default TagCount;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,4 +21,8 @@
|
||||
margin: .25rem 0 .25rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
min-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,14 @@ 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;
|
||||
@@ -23,16 +24,16 @@ type BaseComponentCardProps = {
|
||||
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;
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -15,23 +15,29 @@ import { useComponentPickerContext } from '../common/context/ComponentPickerCont
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import BaseComponentCard from './BaseComponentCard';
|
||||
import BaseCard from './BaseCard';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useDeleteCollection, useRestoreCollection } from '../data/apiHooks';
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
import messages from './messages';
|
||||
|
||||
type CollectionMenuProps = {
|
||||
collectionHit: CollectionHit,
|
||||
hit: CollectionHit,
|
||||
};
|
||||
|
||||
const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
|
||||
const CollectionMenu = ({ hit } : CollectionMenuProps) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const { closeLibrarySidebar, sidebarComponentInfo } = useSidebarContext();
|
||||
const {
|
||||
contextKey,
|
||||
blockId,
|
||||
type,
|
||||
displayName,
|
||||
} = hit;
|
||||
|
||||
const restoreCollectionMutation = useRestoreCollection(collectionHit.contextKey, collectionHit.blockId);
|
||||
const restoreCollectionMutation = useRestoreCollection(contextKey, blockId);
|
||||
const restoreCollection = useCallback(() => {
|
||||
restoreCollectionMutation.mutateAsync()
|
||||
.then(() => {
|
||||
@@ -41,9 +47,9 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteCollectionMutation = useDeleteCollection(collectionHit.contextKey, collectionHit.blockId);
|
||||
const deleteCollectionMutation = useDeleteCollection(contextKey, blockId);
|
||||
const deleteCollection = useCallback(async () => {
|
||||
if (sidebarComponentInfo?.id === collectionHit.blockId) {
|
||||
if (sidebarComponentInfo?.id === blockId) {
|
||||
// Close sidebar if current collection is open to avoid displaying
|
||||
// deleted collection in sidebar
|
||||
closeLibrarySidebar();
|
||||
@@ -79,7 +85,7 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
as={Link}
|
||||
to={`/library/${collectionHit.contextKey}/collection/${collectionHit.blockId}`}
|
||||
to={`/library/${contextKey}/collection/${blockId}`}
|
||||
>
|
||||
<FormattedMessage {...messages.menuOpen} />
|
||||
</Dropdown.Item>
|
||||
@@ -92,9 +98,9 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
variant="warning"
|
||||
category={collectionHit.type}
|
||||
category={type}
|
||||
description={intl.formatMessage(messages.deleteCollectionConfirm, {
|
||||
collectionTitle: collectionHit.displayName,
|
||||
collectionTitle: displayName,
|
||||
})}
|
||||
onDeleteSubmit={deleteCollection}
|
||||
/>
|
||||
@@ -103,22 +109,22 @@ const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => {
|
||||
};
|
||||
|
||||
type CollectionCardProps = {
|
||||
collectionHit: CollectionHit,
|
||||
hit: CollectionHit,
|
||||
};
|
||||
|
||||
const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
|
||||
const CollectionCard = ({ hit } : CollectionCardProps) => {
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const { openCollectionInfoSidebar } = useSidebarContext();
|
||||
|
||||
const {
|
||||
type: componentType,
|
||||
type: itemType,
|
||||
blockId: collectionId,
|
||||
formatted,
|
||||
tags,
|
||||
numChildren,
|
||||
published,
|
||||
} = collectionHit;
|
||||
} = hit;
|
||||
|
||||
const numChildrenCount = showOnlyPublished ? (
|
||||
published?.numChildren || 0
|
||||
@@ -136,15 +142,15 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
|
||||
}, [collectionId, navigateTo, openCollectionInfoSidebar]);
|
||||
|
||||
return (
|
||||
<BaseComponentCard
|
||||
componentType={componentType}
|
||||
<BaseCard
|
||||
itemType={itemType}
|
||||
displayName={displayName}
|
||||
description={description}
|
||||
tags={tags}
|
||||
numChildren={numChildrenCount}
|
||||
actions={!componentPickerMode && (
|
||||
<ActionRow>
|
||||
<CollectionMenu collectionHit={collectionHit} />
|
||||
<CollectionMenu hit={hit} />
|
||||
</ActionRow>
|
||||
)}
|
||||
onSelect={openCollection}
|
||||
|
||||
@@ -47,7 +47,7 @@ const clipboardBroadcastChannelMock = {
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const libraryId = 'lib:org1:Demo_Course';
|
||||
const render = () => baseRender(<ComponentCard contentHit={contentHit} />, {
|
||||
const render = () => baseRender(<ComponentCard hit={contentHit} />, {
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider libraryId={libraryId}>
|
||||
{ children }
|
||||
|
||||
@@ -17,21 +17,20 @@ import {
|
||||
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContentHit } from '../../search-manager';
|
||||
import { type ContentHit, PublishStatus } from '../../search-manager';
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRemoveComponentsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
|
||||
import BaseComponentCard from './BaseComponentCard';
|
||||
import BaseCard from './BaseCard';
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import messages from './messages';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
import { PublishStatus } from '../../search-manager/data/api';
|
||||
|
||||
type ComponentCardProps = {
|
||||
contentHit: ContentHit,
|
||||
hit: ContentHit,
|
||||
};
|
||||
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
@@ -181,7 +180,7 @@ const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) =>
|
||||
return null;
|
||||
};
|
||||
|
||||
const ComponentCard = ({ contentHit }: ComponentCardProps) => {
|
||||
const ComponentCard = ({ hit }: ComponentCardProps) => {
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const { openComponentInfoSidebar } = useSidebarContext();
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
@@ -192,7 +191,7 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
|
||||
tags,
|
||||
usageKey,
|
||||
publishStatus,
|
||||
} = contentHit;
|
||||
} = hit;
|
||||
const componentDescription: string = (
|
||||
showOnlyPublished ? formatted.published?.description : formatted.description
|
||||
) ?? '';
|
||||
@@ -210,8 +209,8 @@ const ComponentCard = ({ contentHit }: ComponentCardProps) => {
|
||||
}, [usageKey, navigateTo, openComponentInfoSidebar]);
|
||||
|
||||
return (
|
||||
<BaseComponentCard
|
||||
componentType={blockType}
|
||||
<BaseCard
|
||||
itemType={blockType}
|
||||
displayName={displayName}
|
||||
description={componentDescription}
|
||||
tags={tags}
|
||||
|
||||
83
src/library-authoring/components/ContainerCard.test.tsx
Normal file
83
src/library-authoring/components/ContainerCard.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
initializeMocks, render as baseRender, screen,
|
||||
} from '../../testUtils';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { type ContainerHit, PublishStatus } from '../../search-manager';
|
||||
import ContainerCard from './ContainerCard';
|
||||
|
||||
const containerHitSample: ContainerHit = {
|
||||
id: 'lctorg1democourse-unit-display-name-123',
|
||||
type: 'library_container',
|
||||
contextKey: 'lb:org1:Demo_Course',
|
||||
usageKey: 'lct:org1:Demo_Course:unit:unit-display-name-123',
|
||||
org: 'org1',
|
||||
blockId: 'unit-display-name-123',
|
||||
blockType: 'unit',
|
||||
breadcrumbs: [{ displayName: 'Demo Lib' }],
|
||||
displayName: 'Unit Display Name',
|
||||
formatted: {
|
||||
displayName: 'Unit Display Formated Name',
|
||||
published: {
|
||||
displayName: 'Published Unit Display Name',
|
||||
},
|
||||
},
|
||||
created: 1722434322294,
|
||||
modified: 1722434322294,
|
||||
numChildren: 2,
|
||||
published: {
|
||||
numChildren: 1,
|
||||
},
|
||||
tags: {},
|
||||
publishStatus: PublishStatus.Published,
|
||||
};
|
||||
|
||||
const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, {
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider
|
||||
libraryId="lib:Axim:TEST"
|
||||
showOnlyPublished={showOnlyPublished}
|
||||
>
|
||||
{children}
|
||||
</LibraryProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe('<ContainerCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('should render the card with title', () => {
|
||||
render(<ContainerCard hit={containerHitSample} />);
|
||||
|
||||
expect(screen.queryByText('Unit Display Formated Name')).toBeInTheDocument();
|
||||
expect(screen.queryByText('2')).toBeInTheDocument(); // Component count
|
||||
});
|
||||
|
||||
it('should render published content', () => {
|
||||
render(<ContainerCard hit={containerHitSample} />, true);
|
||||
|
||||
expect(screen.queryByText('Published Unit Display Name')).toBeInTheDocument();
|
||||
expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count
|
||||
});
|
||||
|
||||
it('should navigate to the container if the open menu clicked', async () => {
|
||||
render(<ContainerCard hit={containerHitSample} />);
|
||||
|
||||
// Open menu
|
||||
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
|
||||
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
|
||||
|
||||
// Open menu item
|
||||
const openMenuItem = screen.getByRole('link', { name: 'Open' });
|
||||
expect(openMenuItem).toBeInTheDocument();
|
||||
|
||||
// TODO: To be implemented
|
||||
// expect(openMenuItem).toHaveAttribute(
|
||||
// 'href',
|
||||
// '/library/lb:org1:Demo_Course/container/container-display-name-123',
|
||||
// );
|
||||
});
|
||||
});
|
||||
93
src/library-authoring/components/ContainerCard.tsx
Normal file
93
src/library-authoring/components/ContainerCard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Dropdown,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { type ContainerHit, PublishStatus } from '../../search-manager';
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import BaseCard from './BaseCard';
|
||||
import messages from './messages';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
hit: ContainerHit,
|
||||
};
|
||||
|
||||
const ContainerMenu = ({ hit } : ContainerMenuProps) => {
|
||||
const intl = useIntl();
|
||||
const { contextKey, blockId } = hit;
|
||||
|
||||
return (
|
||||
<Dropdown id="container-card-dropdown">
|
||||
<Dropdown.Toggle
|
||||
id="container-card-menu-toggle"
|
||||
as={IconButton}
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
|
||||
data-testid="container-card-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
as={Link}
|
||||
to={`/library/${contextKey}/container/${blockId}`}
|
||||
disabled
|
||||
>
|
||||
<FormattedMessage {...messages.menuOpen} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
type ContainerCardProps = {
|
||||
hit: ContainerHit,
|
||||
};
|
||||
|
||||
const ContainerCard = ({ hit } : ContainerCardProps) => {
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
|
||||
const {
|
||||
blockType: itemType,
|
||||
formatted,
|
||||
tags,
|
||||
numChildren,
|
||||
published,
|
||||
publishStatus,
|
||||
} = hit;
|
||||
|
||||
const numChildrenCount = showOnlyPublished ? (
|
||||
published?.numChildren || 0
|
||||
) : numChildren;
|
||||
|
||||
const displayName: string = (
|
||||
showOnlyPublished ? formatted.published?.displayName : formatted.displayName
|
||||
) ?? '';
|
||||
|
||||
const openContainer = () => {};
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
itemType={itemType}
|
||||
displayName={displayName}
|
||||
tags={tags}
|
||||
numChildren={numChildrenCount}
|
||||
actions={!componentPickerMode && (
|
||||
<ActionRow>
|
||||
<ContainerMenu hit={hit} />
|
||||
</ActionRow>
|
||||
)}
|
||||
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}
|
||||
onSelect={openContainer}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerCard;
|
||||
@@ -11,10 +11,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Collection actions menu',
|
||||
description: 'Alt/title text for the collection card menu button.',
|
||||
},
|
||||
containerCardMenuAlt: {
|
||||
id: 'course-authoring.library-authoring.container.menu',
|
||||
defaultMessage: 'Container actions menu',
|
||||
description: 'Alt/title text for the container card menu button.',
|
||||
},
|
||||
menuOpen: {
|
||||
id: 'course-authoring.library-authoring.collection.menu.open',
|
||||
id: 'course-authoring.library-authoring.menu.open',
|
||||
defaultMessage: 'Open',
|
||||
description: 'Menu item for open a collection.',
|
||||
description: 'Menu item for open a collection/container.',
|
||||
},
|
||||
menuEdit: {
|
||||
id: 'course-authoring.library-authoring.component.menu.edit',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "./component-info/ComponentPreview";
|
||||
@import "./components/ComponentCard";
|
||||
@import "./components/BaseCard";
|
||||
@import "./generic";
|
||||
@import "./LibraryAuthoringPage";
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import { MeiliSearch, type Filter } from 'meilisearch';
|
||||
import { union } from 'lodash';
|
||||
|
||||
import {
|
||||
CollectionHit,
|
||||
ContentHit,
|
||||
type HitType,
|
||||
SearchSortOption,
|
||||
forceArray, PublishStatus,
|
||||
} from './data/api';
|
||||
@@ -39,7 +38,7 @@ export interface SearchContextData {
|
||||
searchSortOrder: SearchSortOption;
|
||||
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
|
||||
defaultSearchSortOrder: SearchSortOption;
|
||||
hits: (ContentHit | CollectionHit)[];
|
||||
hits: HitType[];
|
||||
totalHits: number;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean | undefined;
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface ContentHitTags {
|
||||
*/
|
||||
interface BaseContentHit {
|
||||
id: string;
|
||||
type: 'course_block' | 'library_block' | 'collection';
|
||||
type: 'course_block' | 'library_block' | 'collection' | 'library_container';
|
||||
displayName: string;
|
||||
usageKey: string;
|
||||
blockId: string;
|
||||
@@ -167,11 +167,26 @@ export interface CollectionHit extends BaseContentHit {
|
||||
published?: ContentPublishedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a single container returned in the search results
|
||||
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
|
||||
*/
|
||||
export interface ContainerHit extends BaseContentHit {
|
||||
type: 'library_container';
|
||||
blockType: 'unit'; // This should be expanded to include other container types
|
||||
numChildren?: number;
|
||||
published?: ContentPublishedData;
|
||||
publishStatus: PublishStatus;
|
||||
formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, };
|
||||
}
|
||||
|
||||
export type HitType = ContentHit | CollectionHit | ContainerHit;
|
||||
|
||||
/**
|
||||
* Convert search hits to camelCase
|
||||
* @param hit A search result directly from Meilisearch
|
||||
*/
|
||||
export function formatSearchHit(hit: Record<string, any>): ContentHit | CollectionHit {
|
||||
export function formatSearchHit(hit: Record<string, any>): HitType {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { _formatted, ...newHit } = hit;
|
||||
newHit.formatted = {
|
||||
@@ -214,7 +229,7 @@ export async function fetchSearchResults({
|
||||
skipBlockTypeFetch = false,
|
||||
limit = 20,
|
||||
}: FetchSearchParams): Promise<{
|
||||
hits: (ContentHit | CollectionHit)[],
|
||||
hits: HitType[],
|
||||
nextOffset: number | undefined,
|
||||
totalHits: number,
|
||||
blockTypes: Record<string, number>,
|
||||
|
||||
@@ -8,8 +8,13 @@ export { default as Highlight } from './Highlight';
|
||||
export { default as SearchKeywordsField } from './SearchKeywordsField';
|
||||
export { default as SearchSortWidget } from './SearchSortWidget';
|
||||
export { default as Stats } from './Stats';
|
||||
export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api';
|
||||
export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG, PublishStatus } from './data/api';
|
||||
export { useContentSearchConnection, useContentSearchResults, useGetBlockTypes } from './data/apiHooks';
|
||||
export { TypesFilterData } from './hooks';
|
||||
|
||||
export type { CollectionHit, ContentHit, ContentHitTags } from './data/api';
|
||||
export type {
|
||||
CollectionHit,
|
||||
ContainerHit,
|
||||
ContentHit,
|
||||
ContentHitTags,
|
||||
} from './data/api';
|
||||
|
||||
Reference in New Issue
Block a user