From f1389e32ac64b46bffedf072d780a1f26ee75e03 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Fri, 26 Jul 2024 05:09:29 +0930 Subject: [PATCH] feat: Add "Recently Modified" library section * feat: Add Recently Modified library section * feat: Add "View All" to library sections The "View All" action appears on sections that pass in a view all action and contain content that exceeds the defined preview limit, which defaults to 4. * feat: Use intl library section titles * test: Update tests --- .../LibraryAuthoringPage.test.tsx | 113 ++++++++++++++++-- .../LibraryAuthoringPage.tsx | 8 +- src/library-authoring/LibraryHome.tsx | 62 +++++----- .../LibraryRecentlyModified.tsx | 35 ++++++ .../components/LibraryComponents.tsx | 3 +- .../components/LibrarySection.tsx | 39 ++++++ src/library-authoring/messages.ts | 5 - src/search-manager/SearchManager.ts | 10 +- 8 files changed, 226 insertions(+), 49 deletions(-) create mode 100644 src/library-authoring/LibraryRecentlyModified.tsx create mode 100644 src/library-authoring/components/LibrarySection.tsx diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index cee647311a..1117740a11 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -50,6 +50,21 @@ const returnEmptyResult = (_url, req) => { return mockEmptyResult; }; +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 +169,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 +185,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 +219,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 +247,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(); @@ -267,6 +289,77 @@ 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(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (6)')).toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).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(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (2)')).toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).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); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 2cdb67d48b..02e7d93260 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -136,7 +136,13 @@ 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, @@ -35,21 +25,37 @@ const LibraryHome = ({ libraryId } : LibraryHomeProps) => { const collectionCount = 0; - if (componentCount === 0) { - return searchKeywords === '' ? : ; - } + const renderEmptyState = () => { + if (componentCount === 0) { + return searchKeywords === '' ? : ; + } + 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.tsx b/src/library-authoring/components/LibraryComponents.tsx index a4742194c0..691a5285e4 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, @@ -31,7 +32,7 @@ const LibraryComponents = ({ searchKeywords, } = 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); 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/SearchManager.ts b/src/search-manager/SearchManager.ts index 20e8a9804d..29e45d5c09 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -84,9 +84,10 @@ function useStateWithUrlSearchParam( export const SearchContextProvider: React.FC<{ extraFilter?: Filter; + overrideSearchSortOrder?: SearchSortOption children: React.ReactNode, closeSearchModal?: () => void, -}> = ({ ...props }) => { +}> = ({ overrideSearchSortOrder, ...props }) => { const [searchKeywords, setSearchKeywords] = React.useState(''); const [blockTypesFilter, setBlockTypesFilter] = React.useState([]); const [tagsFilter, setTagsFilter] = React.useState([]); @@ -103,16 +104,17 @@ export const SearchContextProvider: React.FC<{ ); // SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we // send it to useContentSearchResults as an empty array. - const sort: SearchSortOption[] = (searchSortOrder === defaultSortOption ? [] : [searchSortOrder]); + const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder; + const sort: SearchSortOption[] = (searchSortOrderToUse === defaultSortOption ? [] : [searchSortOrderToUse]); // Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components. - if (searchSortOrder === SearchSortOption.RECENTLY_PUBLISHED) { + if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) { extraFilter.push('last_published IS NOT EMPTY'); } const canClearFilters = ( blockTypesFilter.length > 0 || tagsFilter.length > 0 - || searchSortOrder !== defaultSortOption + || searchSortOrderToUse !== defaultSortOption ); const clearFilters = React.useCallback(() => { setBlockTypesFilter([]);