diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx
index 343b5b947d..a968bcaa09 100644
--- a/src/library-authoring/EmptyStates.tsx
+++ b/src/library-authoring/EmptyStates.tsx
@@ -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 = () => (
-
+
-
+
+
);
diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index f08555b023..07afaff667 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -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 ... 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('', () => {
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const {
- getByRole, getByText, queryByText, findByText,
+ getByRole, getByText, getAllByText, queryByText,
} = render();
- // 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('', () => {
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('', () => {
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('', () => {
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('', () => {
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();
+
+ // 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();
+
+ // 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();
+
+ 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('', '');
+ });
});
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index f3afb5555f..02e7d93260 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -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 ;
}
@@ -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 = () => {
+
{
}
+ element={(
+
+ )}
/>
(
-
-
-
- {children}
-
-
-);
-
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 === '' ? : ;
- }
+ const renderEmptyState = () => {
+ if (componentCount === 0) {
+ return isFiltered ? : ;
+ }
+ return null;
+ };
return (
-
- { intl.formatMessage(messages.recentComponentsTempPlaceholder) }
-
-
-
+
+ {
+ renderEmptyState()
+ || (
+ <>
+
+
+
+ handleTabChange(tabList.components)}
+ >
+
+
+ >
+ )
+ }
);
};
diff --git a/src/library-authoring/LibraryRecentlyModified.tsx b/src/library-authoring/LibraryRecentlyModified.tsx
new file mode 100644
index 0000000000..7708f47ac4
--- /dev/null
+++ b/src/library-authoring/LibraryRecentlyModified.tsx
@@ -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
+ ? (
+
+
+
+ )
+ : null;
+};
+
+const LibraryRecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => (
+
+
+
+);
+
+export default LibraryRecentlyModified;
diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx
index 716f07ec83..a68309812d 100644
--- a/src/library-authoring/components/LibraryComponents.test.tsx
+++ b/src/library-authoring/components/LibraryComponents.test.tsx
@@ -30,6 +30,7 @@ const data = {
hasNextPage: false,
fetchNextPage: mockFetchNextPage,
searchKeywords: '',
+ isFiltered: false,
};
let store: Store;
diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx
index a4742194c0..0e4d978722 100644
--- a/src/library-authoring/components/LibraryComponents.tsx
+++ b/src/library-authoring/components/LibraryComponents.tsx
@@ -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 === '' ? : ;
+ return isFiltered ? : ;
}
return (
diff --git a/src/library-authoring/components/LibrarySection.tsx b/src/library-authoring/components/LibrarySection.tsx
new file mode 100644
index 0000000000..66fe604ac6
--- /dev/null
+++ b/src/library-authoring/components/LibrarySection.tsx
@@ -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,
+}) => (
+
+ previewLimit
+ && (
+
+
+
+ )
+ }
+ />
+
+ {children}
+
+
+);
+
+export default LibrarySection;
diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts
index 88116c620b..eb9a9f21fc 100644
--- a/src/library-authoring/messages.ts
+++ b/src/library-authoring/messages.ts
@@ -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',
diff --git a/src/search-manager/ClearFiltersButton.tsx b/src/search-manager/ClearFiltersButton.tsx
index eeae127381..0aca013741 100644
--- a/src/search-manager/ClearFiltersButton.tsx
+++ b/src/search-manager/ClearFiltersButton.tsx
@@ -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> = () => {
+const ClearFiltersButton = ({
+ variant = 'link',
+ size = 'sm',
+}: ClearFiltersButtonProps) => {
const { canClearFilters, clearFilters } = useSearchContext();
if (canClearFilters) {
return (
-