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:
@@ -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 = {
|
||||
|
||||
37
src/hooks.js
37
src/hooks.js
@@ -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
68
src/hooks.ts
Normal 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]);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +206,7 @@ const LibraryAuthoringPage = () => {
|
||||
/>
|
||||
<Route
|
||||
path={TabList.collections}
|
||||
element={<LibraryCollections />}
|
||||
element={<LibraryCollections variant="full" />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
80
src/library-authoring/components/BaseComponentCard.tsx
Normal file
80
src/library-authoring/components/BaseComponentCard.tsx
Normal 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;
|
||||
38
src/library-authoring/components/CollectionCard.test.tsx
Normal file
38
src/library-authoring/components/CollectionCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
49
src/library-authoring/components/CollectionCard.tsx
Normal file
49
src/library-authoring/components/CollectionCard.tsx
Normal 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;
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
"block_type": {}
|
||||
},
|
||||
"facetStats": {}
|
||||
},
|
||||
{
|
||||
"indexUid": "studio",
|
||||
"hits": [],
|
||||
"query": "noresult",
|
||||
"processingTimeMs": 0,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -365,6 +365,15 @@
|
||||
}
|
||||
},
|
||||
"facetStats": {}
|
||||
},
|
||||
{
|
||||
"indexUid": "studio",
|
||||
"hits": [],
|
||||
"query": "learn",
|
||||
"processingTimeMs": 1,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"estimatedTotalHits": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user