feat: adds sort widget to search manager and library component page (#1147)
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
Button, Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
import { ClearFiltersButton } from '../search-manager';
|
||||
import messages from './messages';
|
||||
import { LibraryContext } from './common/context';
|
||||
|
||||
@@ -21,7 +22,8 @@ export const NoComponents = () => {
|
||||
};
|
||||
|
||||
export const NoSearchResults = () => (
|
||||
<div className="d-flex mt-6 justify-content-center">
|
||||
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
|
||||
<FormattedMessage {...messages.noSearchResults} />
|
||||
</div>
|
||||
<ClearFiltersButton variant="primary" size="md" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,9 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns 0 components from the search query.
|
||||
*/
|
||||
const returnEmptyResult = (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
@@ -50,6 +53,26 @@ const returnEmptyResult = (_url, req) => {
|
||||
return mockEmptyResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns 2 components from the search query.
|
||||
* This lets us test that the StudioHome "View All" button is hidden when a
|
||||
* low number of search results are shown (<=4 by default).
|
||||
*/
|
||||
const returnLowNumberResults = (_url, req) => {
|
||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||
const query = requestData?.queries[0]?.q ?? '';
|
||||
// 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.
|
||||
mockResult.results[0].query = query;
|
||||
// Limit number of results to just 2
|
||||
mockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
|
||||
mockResult.results[0].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
|
||||
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return mockResult;
|
||||
};
|
||||
|
||||
const libraryData: ContentLibrary = {
|
||||
id: 'lib:org1:lib1',
|
||||
type: 'complex',
|
||||
@@ -154,11 +177,13 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
|
||||
const {
|
||||
getByRole, getByText, queryByText, findByText,
|
||||
getByRole, getByText, getAllByText, queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Ensure the search endpoint is called
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('Content library')).toBeInTheDocument();
|
||||
expect(getByText(libraryData.title)).toBeInTheDocument();
|
||||
@@ -168,7 +193,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
expect(await findByText('Test HTML Block')).toBeInTheDocument();
|
||||
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
|
||||
|
||||
// Navigate to the components tab
|
||||
fireEvent.click(getByRole('tab', { name: 'Components' }));
|
||||
@@ -202,8 +227,10 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(await findByText('Content library')).toBeInTheDocument();
|
||||
expect(await findByText(libraryData.title)).toBeInTheDocument();
|
||||
|
||||
// Ensure the search endpoint is called
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||
});
|
||||
@@ -228,13 +255,16 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
expect(await findByText('Content library')).toBeInTheDocument();
|
||||
expect(await findByText(libraryData.title)).toBeInTheDocument();
|
||||
|
||||
// Ensure the search endpoint is called
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } });
|
||||
|
||||
// Ensure the search endpoint is called again
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
// Ensure the search endpoint is called again, only once more since the recently modified call
|
||||
// should not be impacted by the search
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('No matching components found in this library.')).toBeInTheDocument();
|
||||
|
||||
@@ -266,4 +296,122 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
|
||||
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show the "View All" button when viewing library with many components', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
|
||||
const {
|
||||
getByRole, getByText, queryByText, getAllByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('Content library')).toBeInTheDocument();
|
||||
expect(getByText(libraryData.title)).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
|
||||
expect(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
|
||||
// are above the preview limit (4)
|
||||
expect(getByText('View All')).toBeInTheDocument();
|
||||
|
||||
// Clicking on "View All" button should navigate to the Components tab
|
||||
fireEvent.click(getByText('View All'));
|
||||
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
|
||||
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
|
||||
expect(queryByText('Components (6)')).not.toBeInTheDocument();
|
||||
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
|
||||
|
||||
// Go back to Home tab
|
||||
// This step is necessary to avoid the url change leak to other tests
|
||||
fireEvent.click(getByRole('tab', { name: 'Home' }));
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (6)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show the "View All" button when viewing library with low number of components', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true });
|
||||
|
||||
const {
|
||||
getByText, queryByText, getAllByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Ensure the search endpoint is called:
|
||||
// Call 1: To fetch searchable/filterable/sortable library data
|
||||
// Call 2: To fetch the recently modified components only
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||
|
||||
expect(getByText('Content library')).toBeInTheDocument();
|
||||
expect(getByText(libraryData.title)).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Recently Modified')).toBeInTheDocument();
|
||||
expect(getByText('Collections (0)')).toBeInTheDocument();
|
||||
expect(getByText('Components (2)')).toBeInTheDocument();
|
||||
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
|
||||
|
||||
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
||||
|
||||
// There should not be any "View All" button on page since Components count
|
||||
// is less than the preview limit (4)
|
||||
expect(queryByText('View All')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sort library components', async () => {
|
||||
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
|
||||
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
|
||||
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
|
||||
|
||||
const {
|
||||
findByTitle, getAllByText, getByText, getByTitle,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
expect(await findByTitle('Sort search results')).toBeInTheDocument();
|
||||
|
||||
const testSortOption = (async (optionText, sortBy) => {
|
||||
if (optionText) {
|
||||
fireEvent.click(getByTitle('Sort search results'));
|
||||
fireEvent.click(getByText(optionText));
|
||||
}
|
||||
const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]';
|
||||
const searchText = sortBy ? `?sort=${encodeURIComponent(sortBy)}` : '';
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining(bodyText),
|
||||
method: 'POST',
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
expect(window.location.search).toEqual(searchText);
|
||||
});
|
||||
|
||||
await testSortOption('Title, A-Z', 'display_name:asc');
|
||||
await testSortOption('Title, Z-A', 'display_name:desc');
|
||||
await testSortOption('Newest', 'created:desc');
|
||||
await testSortOption('Oldest', 'created:asc');
|
||||
|
||||
// Sorting by Recently Published also excludes unpublished components
|
||||
await testSortOption('Recently Published', 'last_published:desc');
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
|
||||
body: expect.stringContaining('last_published IS NOT NULL'),
|
||||
method: 'POST',
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
// Clearing filters clears the url search param and uses default sort
|
||||
fireEvent.click(getAllByText('Clear Filters')[0]);
|
||||
await testSortOption('', '');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { Add, InfoOutline } from '@openedx/paragon/icons';
|
||||
import {
|
||||
Routes, Route, useLocation, useNavigate, useParams,
|
||||
Routes, Route, useLocation, useNavigate, useParams, useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Loading from '../generic/Loading';
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
FilterByTags,
|
||||
SearchContextProvider,
|
||||
SearchKeywordsField,
|
||||
SearchSortWidget,
|
||||
} from '../search-manager';
|
||||
import LibraryComponents from './components/LibraryComponents';
|
||||
import LibraryCollections from './LibraryCollections';
|
||||
@@ -62,13 +63,14 @@ const LibraryAuthoringPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { libraryId } = useParams();
|
||||
|
||||
const { data: libraryData, isLoading } = useContentLibrary(libraryId);
|
||||
|
||||
const currentPath = location.pathname.split('/').pop();
|
||||
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
|
||||
const { sidebarBodyComponent, openAddContentSidebar } = useContext(LibraryContext);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -78,7 +80,10 @@ const LibraryAuthoringPage = () => {
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
navigate(key);
|
||||
navigate({
|
||||
pathname: key,
|
||||
search: searchParams.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -116,6 +121,7 @@ const LibraryAuthoringPage = () => {
|
||||
<FilterByBlockType />
|
||||
<ClearFiltersButton />
|
||||
<div className="flex-grow-1" />
|
||||
<SearchSortWidget />
|
||||
</div>
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
@@ -130,7 +136,13 @@ const LibraryAuthoringPage = () => {
|
||||
<Routes>
|
||||
<Route
|
||||
path={TabList.home}
|
||||
element={<LibraryHome libraryId={libraryId} />}
|
||||
element={(
|
||||
<LibraryHome
|
||||
libraryId={libraryId}
|
||||
tabList={TabList}
|
||||
handleTabChange={handleTabChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={TabList.components}
|
||||
|
||||
@@ -1,55 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Card, Stack,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { useSearchContext } from '../search-manager';
|
||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||
import LibraryCollections from './LibraryCollections';
|
||||
import { LibraryComponents } from './components';
|
||||
import LibrarySection from './components/LibrarySection';
|
||||
import LibraryRecentlyModified from './LibraryRecentlyModified';
|
||||
import messages from './messages';
|
||||
|
||||
const Section = ({ title, children } : { title: string, children: React.ReactNode }) => (
|
||||
<Card>
|
||||
<Card.Header
|
||||
title={title}
|
||||
/>
|
||||
<Card.Section>
|
||||
{children}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
|
||||
type LibraryHomeProps = {
|
||||
libraryId: string,
|
||||
tabList: { home: string, components: string, collections: string },
|
||||
handleTabChange: (key: string) => void,
|
||||
};
|
||||
|
||||
const LibraryHome = ({ libraryId } : LibraryHomeProps) => {
|
||||
const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
totalHits: componentCount,
|
||||
searchKeywords,
|
||||
isFiltered,
|
||||
} = useSearchContext();
|
||||
|
||||
const collectionCount = 0;
|
||||
|
||||
if (componentCount === 0) {
|
||||
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
|
||||
}
|
||||
const renderEmptyState = () => {
|
||||
if (componentCount === 0) {
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<Section title={intl.formatMessage(messages.recentlyModifiedTitle)}>
|
||||
{ intl.formatMessage(messages.recentComponentsTempPlaceholder) }
|
||||
</Section>
|
||||
<Section title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}>
|
||||
<LibraryCollections />
|
||||
</Section>
|
||||
<Section title={`Components (${componentCount})`}>
|
||||
<LibraryComponents libraryId={libraryId} variant="preview" />
|
||||
</Section>
|
||||
<LibraryRecentlyModified libraryId={libraryId} />
|
||||
{
|
||||
renderEmptyState()
|
||||
|| (
|
||||
<>
|
||||
<LibrarySection
|
||||
title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}
|
||||
contentCount={collectionCount}
|
||||
// TODO: add viewAllAction here once collections implemented
|
||||
>
|
||||
<LibraryCollections />
|
||||
</LibrarySection>
|
||||
<LibrarySection
|
||||
title={intl.formatMessage(messages.componentsTitle, { componentCount })}
|
||||
contentCount={componentCount}
|
||||
viewAllAction={() => handleTabChange(tabList.components)}
|
||||
>
|
||||
<LibraryComponents libraryId={libraryId} variant="preview" />
|
||||
</LibrarySection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
35
src/library-authoring/LibraryRecentlyModified.tsx
Normal file
35
src/library-authoring/LibraryRecentlyModified.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { SearchContextProvider, useSearchContext } from '../search-manager';
|
||||
import { SearchSortOption } from '../search-manager/data/api';
|
||||
import LibraryComponents from './components/LibraryComponents';
|
||||
import LibrarySection from './components/LibrarySection';
|
||||
import messages from './messages';
|
||||
|
||||
const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => {
|
||||
const intl = useIntl();
|
||||
const { totalHits: componentCount } = useSearchContext();
|
||||
|
||||
return componentCount > 0
|
||||
? (
|
||||
<LibrarySection
|
||||
title={intl.formatMessage(messages.recentlyModifiedTitle)}
|
||||
contentCount={componentCount}
|
||||
>
|
||||
<LibraryComponents libraryId={libraryId} variant="preview" />
|
||||
</LibrarySection>
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const LibraryRecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => (
|
||||
<SearchContextProvider
|
||||
extraFilter={`context_key = "${libraryId}"`}
|
||||
overrideSearchSortOrder={SearchSortOption.RECENTLY_MODIFIED}
|
||||
>
|
||||
<RecentlyModified libraryId={libraryId} />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
|
||||
export default LibraryRecentlyModified;
|
||||
@@ -30,6 +30,7 @@ const data = {
|
||||
hasNextPage: false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
searchKeywords: '',
|
||||
isFiltered: false,
|
||||
};
|
||||
|
||||
let store: Store;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchContext } from '../../search-manager';
|
||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
||||
import { useLibraryBlockTypes } from '../data/apiHooks';
|
||||
import ComponentCard from './ComponentCard';
|
||||
import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection';
|
||||
|
||||
type LibraryComponentsProps = {
|
||||
libraryId: string,
|
||||
@@ -28,10 +29,10 @@ const LibraryComponents = ({
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
searchKeywords,
|
||||
isFiltered,
|
||||
} = useSearchContext();
|
||||
|
||||
const componentList = variant === 'preview' ? hits.slice(0, 4) : hits;
|
||||
const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits;
|
||||
|
||||
// TODO add this to LibraryContext
|
||||
const { data: blockTypesData } = useLibraryBlockTypes(libraryId);
|
||||
@@ -67,7 +68,7 @@ const LibraryComponents = ({
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (componentCount === 0) {
|
||||
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
|
||||
return isFiltered ? <NoSearchResults /> : <NoComponents />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
39
src/library-authoring/components/LibrarySection.tsx
Normal file
39
src/library-authoring/components/LibrarySection.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
import React from 'react';
|
||||
import { Card, ActionRow, Button } from '@openedx/paragon';
|
||||
|
||||
export const LIBRARY_SECTION_PREVIEW_LIMIT = 4;
|
||||
|
||||
const LibrarySection: React.FC<{
|
||||
title: string,
|
||||
viewAllAction?: () => void,
|
||||
contentCount: number,
|
||||
previewLimit?: number,
|
||||
children: React.ReactNode,
|
||||
}> = ({
|
||||
title,
|
||||
viewAllAction,
|
||||
contentCount,
|
||||
previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT,
|
||||
children,
|
||||
}) => (
|
||||
<Card>
|
||||
<Card.Header
|
||||
title={title}
|
||||
actions={
|
||||
viewAllAction
|
||||
&& contentCount > previewLimit
|
||||
&& (
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={viewAllAction}>View All</Button>
|
||||
</ActionRow>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Card.Section>
|
||||
{children}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default LibrarySection;
|
||||
@@ -85,11 +85,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Components ({componentCount})',
|
||||
description: 'Title for the components container',
|
||||
},
|
||||
recentComponentsTempPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.recent-components-temp-placeholder',
|
||||
defaultMessage: 'Recently modified components and collections will be displayed here.',
|
||||
description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.',
|
||||
},
|
||||
addContentTitle: {
|
||||
id: 'course-authoring.library-authoring.drawer.title.add-content',
|
||||
defaultMessage: 'Add Content',
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import messages from './messages';
|
||||
import { useSearchContext } from './SearchManager';
|
||||
|
||||
type ClearFiltersButtonProps = {
|
||||
variant?: 'link' | 'primary',
|
||||
size?: 'sm' | 'md' | 'lg' | 'inline',
|
||||
};
|
||||
|
||||
/**
|
||||
* A button that appears when at least one filter is active, and will clear the filters when clicked.
|
||||
*/
|
||||
const ClearFiltersButton: React.FC<Record<never, never>> = () => {
|
||||
const ClearFiltersButton = ({
|
||||
variant = 'link',
|
||||
size = 'sm',
|
||||
}: ClearFiltersButtonProps) => {
|
||||
const { canClearFilters, clearFilters } = useSearchContext();
|
||||
if (canClearFilters) {
|
||||
return (
|
||||
<Button variant="link" size="sm" onClick={clearFilters}>
|
||||
<Button variant={variant} size={size} onClick={clearFilters}>
|
||||
<FormattedMessage {...messages.clearFilters} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* https://github.com/algolia/instantsearch/issues/1658
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { MeiliSearch, type Filter } from 'meilisearch';
|
||||
|
||||
import { ContentHit } from './data/api';
|
||||
import { ContentHit, SearchSortOption, forceArray } from './data/api';
|
||||
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
|
||||
|
||||
export interface SearchContextData {
|
||||
@@ -24,6 +25,9 @@ export interface SearchContextData {
|
||||
extraFilter?: Filter;
|
||||
canClearFilters: boolean;
|
||||
clearFilters: () => void;
|
||||
isFiltered: boolean;
|
||||
searchSortOrder: SearchSortOption;
|
||||
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
|
||||
hits: ContentHit[];
|
||||
totalHits: number;
|
||||
isFetching: boolean;
|
||||
@@ -36,19 +40,87 @@ export interface SearchContextData {
|
||||
|
||||
const SearchContext = React.createContext<SearchContextData | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Hook which lets you store state variables in the URL search parameters.
|
||||
*
|
||||
* It wraps useState with functions that get/set a query string
|
||||
* search parameter when returning/setting the state variable.
|
||||
*
|
||||
*/
|
||||
function useStateWithUrlSearchParam<Type>(
|
||||
defaultValue: Type,
|
||||
paramName: string,
|
||||
// Returns the Type equivalent of the given string value, or
|
||||
// undefined if value is invalid.
|
||||
fromString: (value: string | null) => Type | undefined,
|
||||
// Returns the string equivalent of the given Type value.
|
||||
// Returning empty string/undefined will clear the url search paramName.
|
||||
toString: (value: Type) => string | undefined,
|
||||
): [value: Type, setter: React.Dispatch<React.SetStateAction<Type>>] {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// The converted search parameter value takes precedence over the state value.
|
||||
const returnValue: Type = fromString(searchParams.get(paramName)) ?? defaultValue;
|
||||
// Function to update the url search parameter
|
||||
const returnSetter: React.Dispatch<React.SetStateAction<Type>> = React.useCallback((value: Type) => {
|
||||
setSearchParams((prevParams) => {
|
||||
const paramValue: string = toString(value) ?? '';
|
||||
const newSearchParams = new URLSearchParams(prevParams);
|
||||
if (paramValue) {
|
||||
newSearchParams.set(paramName, paramValue);
|
||||
} else {
|
||||
// If no paramValue, remove it from the search params, so
|
||||
// we don't get dangling parameter values like ?paramName=
|
||||
// Another way to decide this would be to check value === defaultValue,
|
||||
// and ensure that default values are never stored in the search string.
|
||||
newSearchParams.delete(paramName);
|
||||
}
|
||||
return newSearchParams;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
// Return the computed value and wrapped set state function
|
||||
return [returnValue, returnSetter];
|
||||
}
|
||||
|
||||
export const SearchContextProvider: React.FC<{
|
||||
extraFilter?: Filter;
|
||||
overrideSearchSortOrder?: SearchSortOption
|
||||
children: React.ReactNode,
|
||||
closeSearchModal?: () => void,
|
||||
}> = ({ extraFilter, ...props }) => {
|
||||
}> = ({ overrideSearchSortOrder, ...props }) => {
|
||||
const [searchKeywords, setSearchKeywords] = React.useState('');
|
||||
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
|
||||
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
|
||||
const extraFilter: string[] = forceArray(props.extraFilter);
|
||||
|
||||
const canClearFilters = blockTypesFilter.length > 0 || tagsFilter.length > 0;
|
||||
// The search sort order can be set via the query string
|
||||
// E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA.
|
||||
const defaultSortOption = SearchSortOption.RELEVANCE;
|
||||
const [searchSortOrder, setSearchSortOrder] = useStateWithUrlSearchParam<SearchSortOption>(
|
||||
defaultSortOption,
|
||||
'sort',
|
||||
(value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue),
|
||||
(value: SearchSortOption) => value.toString(),
|
||||
);
|
||||
// SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we
|
||||
// send it to useContentSearchResults as an empty array.
|
||||
const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder;
|
||||
const sort: SearchSortOption[] = (searchSortOrderToUse === defaultSortOption ? [] : [searchSortOrderToUse]);
|
||||
// Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components.
|
||||
if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) {
|
||||
extraFilter.push('last_published IS NOT NULL');
|
||||
}
|
||||
|
||||
const canClearFilters = (
|
||||
blockTypesFilter.length > 0
|
||||
|| tagsFilter.length > 0
|
||||
|| searchSortOrderToUse !== defaultSortOption
|
||||
);
|
||||
const isFiltered = canClearFilters || (searchKeywords !== '');
|
||||
const clearFilters = React.useCallback(() => {
|
||||
setBlockTypesFilter([]);
|
||||
setTagsFilter([]);
|
||||
setSearchSortOrder(defaultSortOption);
|
||||
}, []);
|
||||
|
||||
// Initialize a connection to Meilisearch:
|
||||
@@ -69,6 +141,7 @@ export const SearchContextProvider: React.FC<{
|
||||
searchKeywords,
|
||||
blockTypesFilter,
|
||||
tagsFilter,
|
||||
sort,
|
||||
});
|
||||
|
||||
return React.createElement(SearchContext.Provider, {
|
||||
@@ -82,8 +155,11 @@ export const SearchContextProvider: React.FC<{
|
||||
tagsFilter,
|
||||
setTagsFilter,
|
||||
extraFilter,
|
||||
isFiltered,
|
||||
canClearFilters,
|
||||
clearFilters,
|
||||
searchSortOrder,
|
||||
setSearchSortOrder,
|
||||
closeSearchModal: props.closeSearchModal ?? (() => {}),
|
||||
hasError: hasConnectionError || result.isError,
|
||||
...result,
|
||||
|
||||
82
src/search-manager/SearchSortWidget.tsx
Normal file
82
src/search-manager/SearchSortWidget.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Dropdown } from '@openedx/paragon';
|
||||
import { Check, SwapVert } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { SearchSortOption } from './data/api';
|
||||
import { useSearchContext } from './SearchManager';
|
||||
|
||||
export const SearchSortWidget: React.FC<Record<never, never>> = () => {
|
||||
const intl = useIntl();
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'search-sort-option-title-az',
|
||||
name: intl.formatMessage(messages.searchSortTitleAZ),
|
||||
value: SearchSortOption.TITLE_AZ,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-title-za',
|
||||
name: intl.formatMessage(messages.searchSortTitleZA),
|
||||
value: SearchSortOption.TITLE_ZA,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-newest',
|
||||
name: intl.formatMessage(messages.searchSortNewest),
|
||||
value: SearchSortOption.NEWEST,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-oldest',
|
||||
name: intl.formatMessage(messages.searchSortOldest),
|
||||
value: SearchSortOption.OLDEST,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-recently-published',
|
||||
name: intl.formatMessage(messages.searchSortRecentlyPublished),
|
||||
value: SearchSortOption.RECENTLY_PUBLISHED,
|
||||
},
|
||||
{
|
||||
id: 'search-sort-option-recently-modified',
|
||||
name: intl.formatMessage(messages.searchSortRecentlyModified),
|
||||
value: SearchSortOption.RECENTLY_MODIFIED,
|
||||
},
|
||||
],
|
||||
[intl],
|
||||
);
|
||||
|
||||
const { searchSortOrder, setSearchSortOrder } = useSearchContext();
|
||||
const selectedSortOption = menuItems.find((menuItem) => menuItem.value === searchSortOrder);
|
||||
const searchSortLabel = (
|
||||
selectedSortOption ? selectedSortOption.name : intl.formatMessage(messages.searchSortWidgetLabel)
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown id="search-sort-dropdown">
|
||||
<Dropdown.Toggle
|
||||
id="search-sort-toggle"
|
||||
title={intl.formatMessage(messages.searchSortWidgetAltTitle)}
|
||||
alt={intl.formatMessage(messages.searchSortWidgetAltTitle)}
|
||||
variant="outline-primary"
|
||||
className="dropdown-toggle-menu-items d-flex"
|
||||
size="sm"
|
||||
>
|
||||
<Icon src={SwapVert} className="d-inline" />
|
||||
{searchSortLabel}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{menuItems.map(({ id, name, value }) => (
|
||||
<Dropdown.Item
|
||||
key={id}
|
||||
onClick={() => setSearchSortOrder(value)}
|
||||
>
|
||||
{name}
|
||||
{(value === searchSortOrder) && <Icon src={Check} className="ml-2" />}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchSortWidget;
|
||||
@@ -13,6 +13,16 @@ export const HIGHLIGHT_POST_TAG = '__/meili-highlight__'; // Indicate the end of
|
||||
/** The separator used for hierarchical tags in the search index, e.g. tags.level1 = "Subject > Math > Calculus" */
|
||||
export const TAG_SEP = ' > ';
|
||||
|
||||
export enum SearchSortOption {
|
||||
RELEVANCE = '', // Default; sorts results by keyword search ranking
|
||||
TITLE_AZ = 'display_name:asc',
|
||||
TITLE_ZA = 'display_name:desc',
|
||||
NEWEST = 'created:desc',
|
||||
OLDEST = 'created:asc',
|
||||
RECENTLY_PUBLISHED = 'last_published:desc',
|
||||
RECENTLY_MODIFIED = 'modified:desc',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content search configuration from the CMS.
|
||||
*/
|
||||
@@ -40,14 +50,14 @@ export interface ContentDetails {
|
||||
* This helper method converts from any supported input format to an array, for consistency.
|
||||
* @param filter A filter expression, e.g. `'foo = bar'` or `[['a = b', 'a = c'], 'd = e']`
|
||||
*/
|
||||
function forceArray(filter?: Filter): (string | string[])[] {
|
||||
export function forceArray(filter?: Filter): string[] {
|
||||
if (typeof filter === 'string') {
|
||||
return [filter];
|
||||
}
|
||||
if (filter === undefined) {
|
||||
return [];
|
||||
if (Array.isArray(filter)) {
|
||||
return filter as string[];
|
||||
}
|
||||
return filter;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +105,9 @@ export interface ContentHit {
|
||||
content?: ContentDetails;
|
||||
/** Same fields with <mark>...</mark> highlights */
|
||||
formatted: { displayName: string, content?: ContentDetails };
|
||||
created: number;
|
||||
modified: number;
|
||||
last_published: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +132,7 @@ interface FetchSearchParams {
|
||||
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
|
||||
tagsFilter?: string[],
|
||||
extraFilter?: Filter,
|
||||
sort?: SearchSortOption[],
|
||||
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
|
||||
offset?: number,
|
||||
}
|
||||
@@ -130,6 +144,7 @@ export async function fetchSearchResults({
|
||||
blockTypesFilter,
|
||||
tagsFilter,
|
||||
extraFilter,
|
||||
sort,
|
||||
offset = 0,
|
||||
}: FetchSearchParams): Promise<{
|
||||
hits: ContentHit[],
|
||||
@@ -164,6 +179,7 @@ export async function fetchSearchResults({
|
||||
highlightPostTag: HIGHLIGHT_POST_TAG,
|
||||
attributesToCrop: ['content'],
|
||||
cropLength: 20,
|
||||
sort,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import type { Filter, MeiliSearch } from 'meilisearch';
|
||||
|
||||
import {
|
||||
SearchSortOption,
|
||||
TAG_SEP,
|
||||
fetchAvailableTagOptions,
|
||||
fetchSearchResults,
|
||||
@@ -37,6 +38,7 @@ export const useContentSearchResults = ({
|
||||
searchKeywords,
|
||||
blockTypesFilter = [],
|
||||
tagsFilter = [],
|
||||
sort = [],
|
||||
}: {
|
||||
/** The Meilisearch API client */
|
||||
client?: MeiliSearch;
|
||||
@@ -50,6 +52,8 @@ export const useContentSearchResults = ({
|
||||
blockTypesFilter?: string[];
|
||||
/** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */
|
||||
tagsFilter?: string[];
|
||||
/** Sort search results using these options */
|
||||
sort?: SearchSortOption[];
|
||||
}) => {
|
||||
const query = useInfiniteQuery({
|
||||
enabled: client !== undefined && indexName !== undefined,
|
||||
@@ -63,6 +67,7 @@ export const useContentSearchResults = ({
|
||||
searchKeywords,
|
||||
blockTypesFilter,
|
||||
tagsFilter,
|
||||
sort,
|
||||
],
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
if (client === undefined || indexName === undefined) {
|
||||
@@ -75,6 +80,7 @@ export const useContentSearchResults = ({
|
||||
searchKeywords,
|
||||
blockTypesFilter,
|
||||
tagsFilter,
|
||||
sort,
|
||||
// For infinite pagination of results, we can retrieve additional pages if requested.
|
||||
// Note that if there are 20 results per page, the "second page" has offset=20, not 2.
|
||||
offset: pageParam,
|
||||
|
||||
@@ -4,6 +4,7 @@ export { default as FilterByBlockType } from './FilterByBlockType';
|
||||
export { default as FilterByTags } from './FilterByTags';
|
||||
export { default as Highlight } from './Highlight';
|
||||
export { default as SearchKeywordsField } from './SearchKeywordsField';
|
||||
export { default as SearchSortWidget } from './SearchSortWidget';
|
||||
export { default as Stats } from './Stats';
|
||||
export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api';
|
||||
|
||||
|
||||
@@ -130,6 +130,46 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Clear Filter',
|
||||
description: 'Label for the button that removes applied search filters in a specific widget',
|
||||
},
|
||||
searchSortWidgetLabel: {
|
||||
id: 'course-authoring.course-search.searchSortWidget.label',
|
||||
defaultMessage: 'Sort',
|
||||
description: 'Label displayed to users when default sorting is used by the content search drop-down menu',
|
||||
},
|
||||
searchSortWidgetAltTitle: {
|
||||
id: 'course-authoring.course-search.searchSortWidget.title',
|
||||
defaultMessage: 'Sort search results',
|
||||
description: 'Alt/title text for the content search sort drop-down menu',
|
||||
},
|
||||
searchSortTitleAZ: {
|
||||
id: 'course-authoring.course-search.searchSort.titleAZ',
|
||||
defaultMessage: 'Title, A-Z',
|
||||
description: 'Label for the content search sort drop-down which sorts by content title, ascending',
|
||||
},
|
||||
searchSortTitleZA: {
|
||||
id: 'course-authoring.course-search.searchSort.titleZA',
|
||||
defaultMessage: 'Title, Z-A',
|
||||
description: 'Label for the content search sort drop-down which sorts by content title, descending',
|
||||
},
|
||||
searchSortNewest: {
|
||||
id: 'course-authoring.course-search.searchSort.newest',
|
||||
defaultMessage: 'Newest',
|
||||
description: 'Label for the content search sort drop-down which sorts by creation date, descending',
|
||||
},
|
||||
searchSortOldest: {
|
||||
id: 'course-authoring.course-search.searchSort.oldest',
|
||||
defaultMessage: 'Oldest',
|
||||
description: 'Label for the content search sort drop-down which sorts by creation date, ascending',
|
||||
},
|
||||
searchSortRecentlyPublished: {
|
||||
id: 'course-authoring.course-search.searchSort.recentlyPublished',
|
||||
defaultMessage: 'Recently Published',
|
||||
description: 'Label for the content search sort drop-down which sorts by published date, descending',
|
||||
},
|
||||
searchSortRecentlyModified: {
|
||||
id: 'course-authoring.course-search.searchSort.recentlyModified',
|
||||
defaultMessage: 'Recently Modified',
|
||||
description: 'Label for the content search sort drop-down which sorts by modified date, descending',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user