feat: unit cards in library [FC-0083] (#1742)

Unit cards in library and rename BaseComponentCard -> BaseCard
This commit is contained in:
Rômulo Penido
2025-03-31 12:54:07 -03:00
committed by GitHub
parent df7405ec39
commit 98ae74e78c
20 changed files with 352 additions and 103 deletions

View File

@@ -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',

View 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();
});
});

View 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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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}

View File

@@ -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 }

View File

@@ -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}

View 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',
// );
});
});

View 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;

View File

@@ -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',

View File

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

View File

@@ -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;

View File

@@ -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>,

View File

@@ -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';