feat: collections tab [FC-0062] (#1257)

* feat: add collections query to search results

* feat: collections tab with basic cards

* feat: add collection card also fix inifinite scroll for collections

* feat: collection empty states

* test: add test for collections card
This commit is contained in:
Navin Karkera
2024-09-13 05:25:34 +05:30
committed by GitHub
parent 4035931cbb
commit 9b61037311
24 changed files with 1069 additions and 445 deletions

View File

@@ -51,6 +51,7 @@ export const STRUCTURAL_TYPE_ICONS: Record<string, React.ComponentType> = {
vertical: UNIT_TYPE_ICONS_MAP.vertical,
sequential: Folder,
chapter: Folder,
collection: Folder,
};
export const COMPONENT_TYPE_STYLE_COLOR_MAP = {

View File

@@ -1,37 +0,0 @@
import { useEffect, useState } from 'react';
import { history } from '@edx/frontend-platform';
export const useScrollToHashElement = ({ isLoading }) => {
const [elementWithHash, setElementWithHash] = useState(null);
useEffect(() => {
const currentHash = window.location.hash.substring(1);
if (currentHash) {
const element = document.getElementById(currentHash);
if (element) {
element.scrollIntoView();
history.replace({ hash: '' });
}
setElementWithHash(currentHash);
}
}, [isLoading]);
return { elementWithHash };
};
export const useEscapeClick = ({ onEscape, dependency }) => {
useEffect(() => {
const handleEscapeClick = (event) => {
if (event.key === 'Escape') {
onEscape();
}
};
window.addEventListener('keydown', handleEscapeClick);
return () => {
window.removeEventListener('keydown', handleEscapeClick);
};
}, [dependency]);
};

68
src/hooks.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { history } from '@edx/frontend-platform';
export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => {
const [elementWithHash, setElementWithHash] = useState<string | null>(null);
useEffect(() => {
const currentHash = window.location.hash.substring(1);
if (currentHash) {
const element = document.getElementById(currentHash);
if (element) {
element.scrollIntoView();
history.replace({ hash: '' });
}
setElementWithHash(currentHash);
}
}, [isLoading]);
return { elementWithHash };
};
export const useEscapeClick = ({ onEscape, dependency }: { onEscape: () => void, dependency: any }) => {
useEffect(() => {
const handleEscapeClick = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onEscape();
}
};
window.addEventListener('keydown', handleEscapeClick);
return () => {
window.removeEventListener('keydown', handleEscapeClick);
};
}, [dependency]);
};
/**
* Hook which loads next page of items on scroll
*/
export const useLoadOnScroll = (
hasNextPage: boolean | undefined,
isFetchingNextPage: boolean,
fetchNextPage: () => void,
enabled: boolean,
) => {
useEffect(() => {
if (enabled) {
const onscroll = () => {
// Verify the position of the scroll to implement an infinite scroll.
// Used `loadLimit` to fetch next page before reach the end of the screen.
const loadLimit = 300;
const scrolledTo = window.scrollY + window.innerHeight;
const scrollDiff = document.body.scrollHeight - scrolledTo;
const isNearToBottom = scrollDiff <= loadLimit;
if (isNearToBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
window.addEventListener('scroll', onscroll);
return () => {
window.removeEventListener('scroll', onscroll);
};
}
return () => { };
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
};

View File

@@ -10,7 +10,11 @@ import messages from './messages';
import { LibraryContext } from './common/context';
import { useContentLibrary } from './data/apiHooks';
export const NoComponents = () => {
type NoSearchResultsProps = {
searchType?: 'collection' | 'component',
};
export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => {
const { openAddContentSidebar } = useContext(LibraryContext);
const { libraryId } = useParams();
const { data: libraryData } = useContentLibrary(libraryId);
@@ -18,19 +22,25 @@ export const NoComponents = () => {
return (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
<FormattedMessage {...messages.noComponents} />
{searchType === 'collection'
? <FormattedMessage {...messages.noCollections} />
: <FormattedMessage {...messages.noComponents} />}
{canEditLibrary && (
<Button iconBefore={Add} onClick={() => openAddContentSidebar()}>
<FormattedMessage {...messages.addComponent} />
{searchType === 'collection'
? <FormattedMessage {...messages.addCollection} />
: <FormattedMessage {...messages.addComponent} />}
</Button>
)}
</Stack>
);
};
export const NoSearchResults = () => (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
<FormattedMessage {...messages.noSearchResults} />
export const NoSearchResults = ({ searchType = 'component' }: NoSearchResultsProps) => (
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
{searchType === 'collection'
? <FormattedMessage {...messages.noSearchResultsCollections} />
: <FormattedMessage {...messages.noSearchResults} />}
<ClearFiltersButton variant="primary" size="md" />
</Stack>
);

View File

@@ -28,9 +28,12 @@ const returnEmptyResult = (_url, req) => {
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
mockEmptyResult.results[0].query = query;
mockEmptyResult.results[2].query = query;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockEmptyResult;
};
@@ -48,10 +51,14 @@ const returnLowNumberResults = (_url, req) => {
newMockResult.results[0].query = query;
// Limit number of results to just 2
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2);
newMockResult.results[0].estimatedTotalHits = 2;
newMockResult.results[2].estimatedTotalHits = 2;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResult;
};
@@ -129,7 +136,7 @@ describe('<LibraryAuthoringPage />', () => {
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();
@@ -137,34 +144,38 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
// Navigate to the collections tab
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument();
expect(screen.getByText('Coming soon!')).toBeInTheDocument();
expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument();
// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
});
it('shows a library without components', async () => {
it('shows a library without components and collections', async () => {
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
await renderLibraryPage();
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
expect(screen.getByText('You have not added any collection to this library yet.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
});
@@ -211,6 +222,14 @@ describe('<LibraryAuthoringPage />', () => {
// Navigate to the components tab
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument();
// Navigate to the collections tab
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
expect(screen.getByText('No matching collections found in this library.')).toBeInTheDocument();
// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
});
it('should open and close new content sidebar', async () => {
@@ -282,20 +301,29 @@ describe('<LibraryAuthoringPage />', () => {
// "Recently Modified" header + sort shown
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
// There should only be one "View All" button, since the Components count
// There should be two "View All" button, since the Components and Collections count
// are above the preview limit (4)
expect(screen.getByText('View All')).toBeInTheDocument();
expect(screen.getAllByText('View All').length).toEqual(2);
// Clicking on "View All" button should navigate to the Components tab
fireEvent.click(screen.getByText('View All'));
// Clicking on first "View All" button should navigate to the Collections tab
fireEvent.click(screen.getAllByText('View All')[0]);
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.getByText('Collection 1')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// Clicking on second "View All" button should navigate to the Components tab
fireEvent.click(screen.getAllByText('View All')[1]);
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
@@ -304,7 +332,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
});
@@ -317,7 +345,7 @@ describe('<LibraryAuthoringPage />', () => {
// "Recently Modified" header + sort shown
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (2)')).toBeInTheDocument();
expect(screen.getByText('Components (2)')).toBeInTheDocument();
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
@@ -405,8 +433,8 @@ describe('<LibraryAuthoringPage />', () => {
await renderLibraryPage();
// Click on the first component
waitFor(() => expect(screen.queryByText(displayName)).toBeInTheDocument());
fireEvent.click(screen.getAllByText(displayName)[0]);
expect((await screen.findAllByText(displayName))[0]).toBeInTheDocument();
fireEvent.click((await screen.findAllByText(displayName))[0]);
const sidebar = screen.getByTestId('library-sidebar');
@@ -518,4 +546,20 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});
it('shows both components and collections in recently modified section', async () => {
await renderLibraryPage();
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement;
expect(recentModifiedContainer).toBeTruthy();
const container = within(recentModifiedContainer!);
expect(container.queryAllByText('Text').length).toBeGreaterThan(0);
expect(container.queryAllByText('Collection').length).toBeGreaterThan(0);
});
});

View File

@@ -206,7 +206,7 @@ const LibraryAuthoringPage = () => {
/>
<Route
path={TabList.collections}
element={<LibraryCollections />}
element={<LibraryCollections variant="full" />}
/>
<Route
path="*"

View File

@@ -1,14 +1,63 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { CardGrid } from '@openedx/paragon';
import messages from './messages';
import { useLoadOnScroll } from '../hooks';
import { useSearchContext } from '../search-manager';
import { NoComponents, NoSearchResults } from './EmptyStates';
import CollectionCard from './components/CollectionCard';
import { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection';
const LibraryCollections = () => (
<div className="d-flex my-6 justify-content-center">
<FormattedMessage
{...messages.collectionsTempPlaceholder}
/>
</div>
);
type LibraryCollectionsProps = {
variant: 'full' | 'preview',
};
/**
* Library Collections to show collections grid
*
* Use style to:
* - 'full': Show all collections with Infinite scroll pagination.
* - 'preview': Show first 4 collections without pagination.
*/
const LibraryCollections = ({ variant }: LibraryCollectionsProps) => {
const {
collectionHits,
totalCollectionHits,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isFiltered,
} = useSearchContext();
const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits;
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
variant === 'full',
);
if (totalCollectionHits === 0) {
return isFiltered ? <NoSearchResults searchType="collection" /> : <NoComponents searchType="collection" />;
}
return (
<CardGrid
columnSizes={{
sm: 12,
md: 6,
lg: 4,
xl: 3,
}}
hasEqualColumnHeights
>
{ collectionList.map((collectionHit) => (
<CollectionCard
key={collectionHit.id}
collectionHit={collectionHit}
/>
)) }
</CardGrid>
);
};
export default LibraryCollections;

View File

@@ -20,13 +20,12 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps)
const intl = useIntl();
const {
totalHits: componentCount,
totalCollectionHits: collectionCount,
isFiltered,
} = useSearchContext();
const collectionCount = 0;
const renderEmptyState = () => {
if (componentCount === 0) {
if (componentCount === 0 && collectionCount === 0) {
return isFiltered ? <NoSearchResults /> : <NoComponents />;
}
return null;
@@ -42,9 +41,9 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps)
<LibrarySection
title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}
contentCount={collectionCount}
// TODO: add viewAllAction here once collections implemented
viewAllAction={() => handleTabChange(tabList.collections)}
>
<LibraryCollections />
<LibraryCollections variant="preview" />
</LibrarySection>
<LibrarySection
title={intl.formatMessage(messages.componentsTitle, { componentCount })}

View File

@@ -1,15 +1,46 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { orderBy } from 'lodash';
import { CardGrid } from '@openedx/paragon';
import { SearchContextProvider, useSearchContext } from '../search-manager';
import { SearchSortOption } from '../search-manager/data/api';
import LibraryComponents from './components/LibraryComponents';
import LibrarySection from './components/LibrarySection';
import { type CollectionHit, type ContentHit, SearchSortOption } from '../search-manager/data/api';
import LibrarySection, { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection';
import messages from './messages';
import ComponentCard from './components/ComponentCard';
import { useLibraryBlockTypes } from './data/apiHooks';
import CollectionCard from './components/CollectionCard';
const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => {
const intl = useIntl();
const { totalHits: componentCount } = useSearchContext();
const {
hits,
collectionHits,
totalHits,
totalCollectionHits,
} = useSearchContext();
const componentCount = totalHits + totalCollectionHits;
// Since we only display a fixed number of items in preview,
// only these number of items are use in sort step below
const componentList = hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
const collectionList = collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
// Sort them by `modified` field in reverse and display them
const recentItems = orderBy([
...componentList,
...collectionList,
], ['modified'], ['desc']).slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
const { data: blockTypesData } = useLibraryBlockTypes(libraryId);
const blockTypes = useMemo(() => {
const result = {};
if (blockTypesData) {
blockTypesData.forEach(blockType => {
result[blockType.blockType] = blockType;
});
}
return result;
}, [blockTypesData]);
return componentCount > 0
? (
@@ -17,7 +48,30 @@ const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => {
title={intl.formatMessage(messages.recentlyModifiedTitle)}
contentCount={componentCount}
>
<LibraryComponents libraryId={libraryId} variant="preview" />
<CardGrid
columnSizes={{
sm: 12,
md: 6,
lg: 4,
xl: 3,
}}
hasEqualColumnHeights
>
{recentItems.map((contentHit) => (
contentHit.type === 'collection' ? (
<CollectionCard
key={contentHit.id}
collectionHit={contentHit as CollectionHit}
/>
) : (
<ComponentCard
key={contentHit.id}
contentHit={contentHit as ContentHit}
blockTypeDisplayName={blockTypes[(contentHit as ContentHit).blockType]?.displayName ?? ''}
/>
)
))}
</CardGrid>
</LibrarySection>
)
: null;

View File

@@ -1,273 +1,485 @@
{
"comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts",
"note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.",
"results": [
"comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts",
"note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.",
"results": [
{
"indexUid": "studio_content",
"hits": [
{
"indexUid": "studio_content",
"hits": [
{
"id": "lbaximtesthtml571fe018-f3ce-45c9-8f53-5dafcb422fdd-273ebd90",
"display_name": "Introduction to Testing",
"block_id": "571fe018-f3ce-45c9-8f53-5dafcb422fdd",
"content": {
"html_content": "This is a text component which uses HTML."
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1721857069.042984,
"modified": 1725398676.078056,
"last_published": 1725035862.450613,
"usage_key": "lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
"display_name": "Second Text Component",
"block_id": "73a22298-bcd9-4f4c-ae34-0bc2b0612480",
"content": {
"html_content": "Preview of the second text component here"
},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1724879593.066427,
"modified": 1725034981.663482,
"last_published": 1725035862.450613,
"usage_key": "lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95",
"display_name": "Third Text component",
"block_id": "be5b5db9-26ba-4fac-86af-654538c70b5e",
"content": {
"html_content": "This is a text component that I've edited within the library. "
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1721857034.455737,
"modified": 1722551300.377488,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:html:be5b5db9-26ba-4fac-86af-654538c70b5e",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2",
"display_name": "Text 4",
"block_id": "e59e8c73-4056-4894-bca4-062781fb3f68",
"content": {
"html_content": ""
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774228.49832,
"modified": 1720774228.49832,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:html:e59e8c73-4056-4894-bca4-062781fb3f68",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115",
"display_name": "Blank Problem",
"block_id": "f16116c9-516e-4bb9-b99e-103599f62417",
"content": {
"problem_types": [],
"capa_content": " "
},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1724725821.973896,
"modified": 1724725821.973896,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:f16116c9-516e-4bb9-b99e-103599f62417",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblem2ace6b9b-6620-413c-a66f-19c797527f34-3a7973b7",
"display_name": "Multiple Choice Problem",
"block_id": "2ace6b9b-6620-413c-a66f-19c797527f34",
"content": {
"problem_types": ["multiplechoiceresponse"],
"capa_content": "What is the gradient of an inverted hyperspace manifold?cos (x) ey ln(z) i + sin(x)ey ln(z)j + sin(x) ey(1/z)k "
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:2ace6b9b-6620-413c-a66f-19c797527f34",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblem7d7e98ba-3ac9-4aa8-8946-159129b39a28-3a7973b7",
"display_name": "Single Choice Problem",
"block_id": "7d7e98ba-3ac9-4aa8-8946-159129b39a28",
"content": {
"problem_types": ["choiceresponse"],
"capa_content": "Blah blah?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:7d7e98ba-3ac9-4aa8-8946-159129b39a28",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblem4e1a72f9-ac93-42aa-a61c-ab5f9698c398-3a7973b7",
"display_name": "Numerical Response Problem",
"block_id": "4e1a72f9-ac93-42aa-a61c-ab5f9698c398",
"content": {
"problem_types": ["numericalresponse"],
"capa_content": "What is 1 + 1?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:4e1a72f9-ac93-42aa-a61c-ab5f9698c398",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblemad483625-ade2-4712-88d8-c9743abbd291-3a7973b7",
"display_name": "Option Response Problem",
"block_id": "ad483625-ade2-4712-88d8-c9743abbd291",
"content": {
"problem_types": ["optionresponse"],
"capa_content": "What is foobar?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:ad483625-ade2-4712-88d8-c9743abbd291",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblemb4c859cb-de70-421a-917b-e6e01ce44bd8-3a7973b7",
"display_name": "String Response Problem",
"block_id": "b4c859cb-de70-421a-917b-e6e01ce44bd8",
"content": {
"problem_types": ["stringresponse"],
"capa_content": "What is your name?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:b4c859cb-de70-421a-917b-e6e01ce44bd8",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
}
],
"query": "",
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 10
"id": "lbaximtesthtml571fe018-f3ce-45c9-8f53-5dafcb422fdd-273ebd90",
"display_name": "Introduction to Testing",
"block_id": "571fe018-f3ce-45c9-8f53-5dafcb422fdd",
"content": {
"html_content": "This is a text component which uses HTML."
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1721857069.042984,
"modified": 1725878053.420395,
"last_published": 1725035862.450613,
"usage_key": "lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"indexUid": "studio_content",
"hits": [],
"query": "",
"processingTimeMs": 0,
"limit": 0,
"offset": 0,
"estimatedTotalHits": 10,
"facetDistribution": {
"block_type": {
"html": 4,
"problem": 6
},
"content.problem_types": {
"multiplechoiceresponse": 1,
"choiceresponse": 1,
"numericalresponse": 1,
"optionresponse": 1,
"stringresponse": 1
}
},
"facetStats": {}
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
"display_name": "Second Text Component",
"block_id": "73a22298-bcd9-4f4c-ae34-0bc2b0612480",
"content": {
"html_content": "Preview of the second text component here"
},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1724879593.066427,
"modified": 1725034981.663482,
"last_published": 1725035862.450613,
"usage_key": "lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95",
"display_name": "Third Text component",
"block_id": "be5b5db9-26ba-4fac-86af-654538c70b5e",
"content": {
"html_content": "This is a text component that I've edited within the library. "
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1721857034.455737,
"modified": 1722551300.377488,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:html:be5b5db9-26ba-4fac-86af-654538c70b5e",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2",
"display_name": "Text 4",
"block_id": "e59e8c73-4056-4894-bca4-062781fb3f68",
"content": {
"html_content": ""
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774228.49832,
"modified": 1720774228.49832,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:html:e59e8c73-4056-4894-bca4-062781fb3f68",
"block_type": "html",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115",
"display_name": "Blank Problem",
"block_id": "f16116c9-516e-4bb9-b99e-103599f62417",
"content": {
"problem_types": [],
"capa_content": " "
},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1724725821.973896,
"modified": 1724725821.973896,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:f16116c9-516e-4bb9-b99e-103599f62417",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblem2ace6b9b-6620-413c-a66f-19c797527f34-3a7973b7",
"display_name": "Multiple Choice Problem",
"block_id": "2ace6b9b-6620-413c-a66f-19c797527f34",
"content": {
"problem_types": [
"multiplechoiceresponse"
],
"capa_content": "What is the gradient of an inverted hyperspace manifold?cos (x) ey ln(z) i + sin(x)ey ln(z)j + sin(x) ey(1/z)k "
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:2ace6b9b-6620-413c-a66f-19c797527f34",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblem7d7e98ba-3ac9-4aa8-8946-159129b39a28-3a7973b7",
"display_name": "Single Choice Problem",
"block_id": "7d7e98ba-3ac9-4aa8-8946-159129b39a28",
"content": {
"problem_types": [
"choiceresponse"
],
"capa_content": "Blah blah?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:7d7e98ba-3ac9-4aa8-8946-159129b39a28",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblem4e1a72f9-ac93-42aa-a61c-ab5f9698c398-3a7973b7",
"display_name": "Numerical Response Problem",
"block_id": "4e1a72f9-ac93-42aa-a61c-ab5f9698c398",
"content": {
"problem_types": [
"numericalresponse"
],
"capa_content": "What is 1 + 1?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:4e1a72f9-ac93-42aa-a61c-ab5f9698c398",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblemad483625-ade2-4712-88d8-c9743abbd291-3a7973b7",
"display_name": "Option Response Problem",
"block_id": "ad483625-ade2-4712-88d8-c9743abbd291",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "What is foobar?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:ad483625-ade2-4712-88d8-c9743abbd291",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
},
{
"id": "lbaximtestproblemb4c859cb-de70-421a-917b-e6e01ce44bd8-3a7973b7",
"display_name": "String Response Problem",
"block_id": "b4c859cb-de70-421a-917b-e6e01ce44bd8",
"content": {
"problem_types": [
"stringresponse"
],
"capa_content": "What is your name?"
},
"tags": {},
"type": "library_block",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1720774232.76135,
"modified": 1720774232.76135,
"last_published": 1724879092.002222,
"usage_key": "lb:Axim:TEST:problem:b4c859cb-de70-421a-917b-e6e01ce44bd8",
"block_type": "problem",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15
}
]
}
],
"query": "",
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 10
},
{
"indexUid": "studio_content",
"hits": [],
"query": "",
"processingTimeMs": 0,
"limit": 0,
"offset": 0,
"estimatedTotalHits": 10,
"facetDistribution": {
"block_type": {
"html": 4,
"problem": 6
},
"content.problem_types": {
"multiplechoiceresponse": 1,
"choiceresponse": 1,
"numericalresponse": 1,
"optionresponse": 1,
"stringresponse": 1
}
},
"facetStats": {}
},
{
"indexUid": "studio",
"hits": [
{
"display_name": "Collection 1",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.",
"id": 1,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.628254,
"modified": 1725878053.420395,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 1",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "1",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.628254",
"modified": "1725534795.628266",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 2",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
"id": 2,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.619101,
"modified": 1725534795.619113,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 2",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "2",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.619101",
"modified": "1725534795.619113",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 3",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
"id": 3,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.609781,
"modified": 1725534795.609794,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 3",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "3",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.609781",
"modified": "1725534795.609794",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 4",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
"id": 4,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.596287,
"modified": 1725534795.5963,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 4",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "4",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.596287",
"modified": "1725534795.5963",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 5",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
"id": 5,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.583068,
"modified": 1725534795.583082,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 5",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "5",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.583068",
"modified": "1725534795.583082",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
},
{
"display_name": "Collection 6",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
"id": 6,
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": 1725534795.573794,
"modified": 1725534795.573808,
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"_formatted": {
"display_name": "Collection 6",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
"id": "6",
"type": "collection",
"breadcrumbs": [
{
"display_name": "CS problems 2"
}
],
"created": "1725534795.573794",
"modified": "1725534795.573808",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": "16"
}
}
],
"query": "learn",
"processingTimeMs": 1,
"limit": 6,
"offset": 0,
"estimatedTotalHits": 6
}
]
}

View File

@@ -0,0 +1,80 @@
import React, { useMemo } from 'react';
import {
Card,
Container,
Icon,
Stack,
} from '@openedx/paragon';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import TagCount from '../../generic/tag-count';
import { ContentHitTags, Highlight } from '../../search-manager';
type BaseComponentCardProps = {
type: string,
displayName: string,
description: string,
tags: ContentHitTags,
actions: React.ReactNode,
blockTypeDisplayName: string,
openInfoSidebar: () => void
};
const BaseComponentCard = ({
type,
displayName,
description,
tags,
actions,
blockTypeDisplayName,
openInfoSidebar,
} : BaseComponentCardProps) => {
const tagCount = useMemo(() => {
if (!tags) {
return 0;
}
return (tags.level0?.length || 0) + (tags.level1?.length || 0)
+ (tags.level2?.length || 0) + (tags.level3?.length || 0);
}, [tags]);
const componentIcon = getItemIcon(type);
return (
<Container className="library-component-card">
<Card
isClickable
onClick={openInfoSidebar}
onKeyDown={(e: React.KeyboardEvent) => {
if (['Enter', ' '].includes(e.key)) {
openInfoSidebar();
}
}}
>
<Card.Header
className={`library-component-header ${getComponentStyleColor(type)}`}
title={
<Icon src={componentIcon} className="library-component-header-icon" />
}
actions={actions}
/>
<Card.Body>
<Card.Section>
<Stack direction="horizontal" className="d-flex justify-content-between">
<Stack direction="horizontal" gap={1}>
<Icon src={componentIcon} size="sm" />
<span className="small">{blockTypeDisplayName}</span>
</Stack>
<TagCount count={tagCount} />
</Stack>
<div className="text-truncate h3 mt-2">
<Highlight text={displayName} />
</div>
<Highlight text={description} />
</Card.Section>
</Card.Body>
</Card>
</Container>
);
};
export default BaseComponentCard;

View File

@@ -0,0 +1,38 @@
import { initializeMocks, render, screen } from '../../testUtils';
import { type CollectionHit } from '../../search-manager';
import CollectionCard from './CollectionCard';
const CollectionHitSample: CollectionHit = {
id: '1',
type: 'collection',
contextKey: 'lb:org1:Demo_Course',
org: 'org1',
breadcrumbs: [{ displayName: 'Demo Lib' }],
displayName: 'Collection Display Name',
description: 'Collection description',
formatted: {
displayName: 'Collection Display Formated Name',
description: 'Collection description',
},
created: 1722434322294,
modified: 1722434322294,
tags: {},
};
describe('<CollectionCard />', () => {
beforeEach(() => {
initializeMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render the card with title and description', () => {
render(<CollectionCard collectionHit={CollectionHitSample} />);
expect(screen.getByText('Collection Display Formated Name')).toBeInTheDocument();
expect(screen.getByText('Collection description')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,49 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Icon,
IconButton,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { type CollectionHit } from '../../search-manager';
import messages from './messages';
import BaseComponentCard from './BaseComponentCard';
type CollectionCardProps = {
collectionHit: CollectionHit,
};
const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
const intl = useIntl();
const {
type,
formatted,
tags,
} = collectionHit;
const { displayName = '', description = '' } = formatted;
return (
<BaseComponentCard
type={type}
displayName={displayName}
description={description}
tags={tags}
actions={(
<ActionRow>
<IconButton
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
/>
</ActionRow>
)}
blockTypeDisplayName={intl.formatMessage(messages.collectionType)}
openInfoSidebar={() => {}}
/>
);
};
export default CollectionCard;

View File

@@ -1,24 +1,20 @@
import React, { useContext, useMemo, useState } from 'react';
import React, { useContext, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Card,
Container,
Icon,
IconButton,
Dropdown,
Stack,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import { updateClipboard } from '../../generic/data/api';
import TagCount from '../../generic/tag-count';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit, Highlight } from '../../search-manager';
import { type ContentHit } from '../../search-manager';
import { LibraryContext } from '../common/context';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import BaseComponentCard from './BaseComponentCard';
type ComponentCardProps = {
contentHit: ContentHit,
@@ -77,55 +73,21 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
} = contentHit;
const description = formatted?.content?.htmlContent ?? '';
const displayName = formatted?.displayName ?? '';
const tagCount = useMemo(() => {
if (!tags) {
return 0;
}
return (tags.level0?.length || 0) + (tags.level1?.length || 0)
+ (tags.level2?.length || 0) + (tags.level3?.length || 0);
}, [tags]);
const componentIcon = getItemIcon(blockType);
return (
<Container className="library-component-card">
<Card
isClickable
onClick={() => openComponentInfoSidebar(usageKey)}
onKeyDown={(e: React.KeyboardEvent) => {
if (['Enter', ' '].includes(e.key)) {
openComponentInfoSidebar(usageKey);
}
}}
>
<Card.Header
className={`library-component-header ${getComponentStyleColor(blockType)}`}
title={
<Icon src={componentIcon} className="library-component-header-icon" />
}
actions={(
<ActionRow>
<ComponentMenu usageKey={usageKey} />
</ActionRow>
)}
/>
<Card.Body>
<Card.Section>
<Stack direction="horizontal" className="d-flex justify-content-between">
<Stack direction="horizontal" gap={1}>
<Icon src={componentIcon} size="sm" />
<span className="small">{blockTypeDisplayName}</span>
</Stack>
<TagCount count={tagCount} />
</Stack>
<div className="text-truncate h3 mt-2">
<Highlight text={displayName} />
</div>
<Highlight text={description} />
</Card.Section>
</Card.Body>
</Card>
</Container>
<BaseComponentCard
type={blockType}
displayName={displayName}
description={description}
tags={tags}
actions={(
<ActionRow>
<ComponentMenu usageKey={usageKey} />
</ActionRow>
)}
blockTypeDisplayName={blockTypeDisplayName}
openInfoSidebar={() => openComponentInfoSidebar(usageKey)}
/>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import { CardGrid } from '@openedx/paragon';
import { useLoadOnScroll } from '../../hooks';
import { useSearchContext } from '../../search-manager';
import { NoComponents, NoSearchResults } from '../EmptyStates';
import { useLibraryBlockTypes } from '../data/apiHooks';
@@ -43,26 +44,12 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
return result;
}, [blockTypesData]);
useEffect(() => {
if (variant === 'full') {
const onscroll = () => {
// Verify the position of the scroll to implementa a infinite scroll.
// Used `loadLimit` to fetch next page before reach the end of the screen.
const loadLimit = 300;
const scrolledTo = window.scrollY + window.innerHeight;
const scrollDiff = document.body.scrollHeight - scrolledTo;
const isNearToBottom = scrollDiff <= loadLimit;
if (isNearToBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
window.addEventListener('scroll', onscroll);
return () => {
window.removeEventListener('scroll', onscroll);
};
}
return () => {};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
variant === 'full',
);
if (componentCount === 0) {
return isFiltered ? <NoSearchResults /> : <NoComponents />;

View File

@@ -6,6 +6,16 @@ const messages = defineMessages({
defaultMessage: 'Component actions menu',
description: 'Alt/title text for the component card menu button.',
},
collectionCardMenuAlt: {
id: 'course-authoring.library-authoring.collection.menu',
defaultMessage: 'Collection actions menu',
description: 'Alt/title text for the collection card menu button.',
},
collectionType: {
id: 'course-authoring.library-authoring.collection.type',
defaultMessage: 'Collection',
description: 'Collection type text',
},
menuEdit: {
id: 'course-authoring.library-authoring.component.menu.edit',
defaultMessage: 'Edit',

View File

@@ -21,16 +21,31 @@ const messages = defineMessages({
defaultMessage: 'No matching components found in this library.',
description: 'Message displayed when no search results are found',
},
noSearchResultsCollections: {
id: 'course-authoring.library-authoring.no-search-results-collections',
defaultMessage: 'No matching collections found in this library.',
description: 'Message displayed when no matching collections are found',
},
noComponents: {
id: 'course-authoring.library-authoring.no-components',
defaultMessage: 'You have not added any content to this library yet.',
description: 'Message displayed when the library is empty',
},
noCollections: {
id: 'course-authoring.library-authoring.no-collections',
defaultMessage: 'You have not added any collection to this library yet.',
description: 'Message displayed when the library has no collections',
},
addComponent: {
id: 'course-authoring.library-authoring.add-component',
defaultMessage: 'Add component',
description: 'Button text to add a new component',
},
addCollection: {
id: 'course-authoring.library-authoring.add-collection',
defaultMessage: 'Add collection',
description: 'Button text to add a new collection',
},
homeTab: {
id: 'course-authoring.library-authoring.home-tab',
defaultMessage: 'Home',

View File

@@ -8,7 +8,9 @@ import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { MeiliSearch, type Filter } from 'meilisearch';
import { ContentHit, SearchSortOption, forceArray } from './data/api';
import {
CollectionHit, ContentHit, SearchSortOption, forceArray,
} from './data/api';
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
export interface SearchContextData {
@@ -39,6 +41,8 @@ export interface SearchContextData {
fetchNextPage: () => void;
closeSearchModal: () => void;
hasError: boolean;
collectionHits: CollectionHit[];
totalCollectionHits: number;
}
const SearchContext = React.createContext<SearchContextData | undefined>(undefined);

View File

@@ -83,7 +83,7 @@ function formatTagsFilter(tagsFilter?: string[]): string[] {
/**
* The tags that are associated with a search result, at various levels of the tag hierarchy.
*/
interface ContentHitTags {
export interface ContentHitTags {
taxonomy?: string[];
level0?: string[];
level1?: string[];
@@ -95,42 +95,60 @@ interface ContentHitTags {
* Information about a single XBlock returned in the search results
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
*/
export interface ContentHit {
interface BaseContentHit {
id: string;
usageKey: string;
type: 'course_block' | 'library_block';
blockId: string;
type: 'course_block' | 'library_block' | 'collection';
displayName: string;
/** The block_type part of the usage key. What type of XBlock this is. */
blockType: string;
/** The course or library ID */
contextKey: string;
org: string;
breadcrumbs: Array<{ displayName: string }>;
tags: ContentHitTags;
/** Same fields with <mark>...</mark> highlights */
formatted: { displayName: string, content?: ContentDetails, description?: string };
created: number;
modified: number;
}
/**
* Information about a single XBlock returned in the search results
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
*/
export interface ContentHit extends BaseContentHit {
usageKey: string;
blockId: string;
/** The block_type part of the usage key. What type of XBlock this is. */
blockType: string;
/**
* Breadcrumbs:
* - First one is the name of the course/library itself.
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
*/
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
tags: ContentHitTags;
content?: ContentDetails;
/** Same fields with <mark>...</mark> highlights */
formatted: { displayName: string, content?: ContentDetails };
created: number;
modified: number;
lastPublished: number | null;
}
/**
* Information about a single collection returned in the search results
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
*/
export interface CollectionHit extends BaseContentHit {
description: string;
componentCount?: number;
}
/**
* Convert search hits to camelCase
* @param hit A search result directly from Meilisearch
*/
function formatSearchHit(hit: Record<string, any>): ContentHit {
function formatSearchHit(hit: Record<string, any>): ContentHit | CollectionHit {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { _formatted, ...newHit } = hit;
newHit.formatted = {
displayName: _formatted.display_name,
content: _formatted.content ?? {},
description: _formatted.description,
};
return camelCaseObject(newHit);
}
@@ -165,6 +183,8 @@ export async function fetchSearchResults({
totalHits: number,
blockTypes: Record<string, number>,
problemTypes: Record<string, number>,
collectionHits: CollectionHit[],
totalCollectionHits: number,
}> {
const queries: MultiSearchQuery[] = [];
@@ -185,6 +205,8 @@ export async function fetchSearchResults({
...problemTypesFilterFormatted,
].flat()];
const collectionsFilter = 'type = "collection"';
// First query is always to get the hits, with all the filters applied.
queries.push({
indexUid: indexName,
@@ -192,6 +214,7 @@ export async function fetchSearchResults({
filter: [
// top-level entries in the array are AND conditions and must all match
// Inner arrays are OR conditions, where only one needs to match.
`NOT ${collectionsFilter}`, // exclude collections
...typeFilters,
...extraFilterFormatted,
...tagsFilterFormatted,
@@ -219,13 +242,37 @@ export async function fetchSearchResults({
limit: 0, // We don't need any "hits" for this - just the facetDistribution
});
// Third query is to get the hits for collections, with all the filters applied.
queries.push({
indexUid: indexName,
q: searchKeywords,
filter: [
// top-level entries in the array are AND conditions and must all match
// Inner arrays are OR conditions, where only one needs to match.
collectionsFilter, // include only collections
...extraFilterFormatted,
// We exclude the block type filter as collections are only of 1 type i.e. collection.
...tagsFilterFormatted,
],
attributesToHighlight: ['display_name', 'description'],
highlightPreTag: HIGHLIGHT_PRE_TAG,
highlightPostTag: HIGHLIGHT_POST_TAG,
attributesToCrop: ['description'],
cropLength: 15,
sort,
offset,
limit,
});
const { results } = await client.multiSearch(({ queries }));
return {
hits: results[0].hits.map(formatSearchHit),
hits: results[0].hits.map(formatSearchHit) as ContentHit[],
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length,
blockTypes: results[1].facetDistribution?.block_type ?? {},
problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {},
nextOffset: results[0].hits.length === limit ? offset + limit : undefined,
nextOffset: results[0].hits.length === limit || results[2].hits.length === limit ? offset + limit : undefined,
collectionHits: results[2].hits.map(formatSearchHit) as CollectionHit[],
totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? results[2].hits.length,
};
}

View File

@@ -103,8 +103,14 @@ export const useContentSearchResults = ({
[pages],
);
const collectionHits = React.useMemo(
() => pages?.reduce((allHits, page) => [...allHits, ...page.collectionHits], []) ?? [],
[pages],
);
return {
hits,
collectionHits,
// The distribution of block type filter options
blockTypes: pages?.[0]?.blockTypes ?? {},
problemTypes: pages?.[0]?.problemTypes ?? {},
@@ -119,6 +125,7 @@ export const useContentSearchResults = ({
hasNextPage: query.hasNextPage,
// The last page has the most accurate count of total hits
totalHits: pages?.[pages.length - 1]?.totalHits ?? 0,
totalCollectionHits: pages?.[pages.length - 1]?.totalCollectionHits ?? 0,
};
};

View File

@@ -8,4 +8,4 @@ export { default as SearchSortWidget } from './SearchSortWidget';
export { default as Stats } from './Stats';
export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api';
export type { ContentHit } from './data/api';
export type { CollectionHit, ContentHit, ContentHitTags } from './data/api';

View File

@@ -100,9 +100,12 @@ describe('<SearchUI />', () => {
// because otherwise Instantsearch will update the UI and change the query,
// leading to unexpected results in the test cases.
mockResult.results[0].query = query;
mockResult.results[2].query = query;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockResult;
});
fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult);
@@ -174,8 +177,8 @@ describe('<SearchUI />', () => {
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return requestedFilter?.[1] === 'type = "course_block"'
&& requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"';
return requestedFilter?.[2] === 'type = "course_block"'
&& requestedFilter?.[3] === 'context_key = "course-v1:org+test+123"';
});
// Now we should see the results:
expect(queryByText('Enter a keyword')).toBeNull();
@@ -398,8 +401,9 @@ describe('<SearchUI />', () => {
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
// the filter is: ['type = "course_block"', 'context_key = "course-v1:org+test+123"']
return (requestedFilter?.length === 3);
// the filter is:
// ['NOT type == "collection"', '', 'type = "course_block"', 'context_key = "course-v1:org+test+123"']
return (requestedFilter?.length === 4);
});
// Now we should see the results:
expect(getByText('6 results found')).toBeInTheDocument();
@@ -425,6 +429,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
'NOT type = "collection"',
[
'block_type = problem',
'content.problem_types = choiceresponse',
@@ -458,6 +463,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
'NOT type = "collection"',
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
@@ -493,6 +499,7 @@ describe('<SearchUI />', () => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
'NOT type = "collection"',
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',

View File

@@ -22,6 +22,15 @@
"block_type": {}
},
"facetStats": {}
},
{
"indexUid": "studio",
"hits": [],
"query": "noresult",
"processingTimeMs": 0,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 0
}
]
}

View File

@@ -365,6 +365,15 @@
}
},
"facetStats": {}
},
{
"indexUid": "studio",
"hits": [],
"query": "learn",
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 0
}
]
}