Skip to content

Commit

Permalink
Merge branch 'jill/FAL-3758-sort-components' into rpenido/bkp/fal-376…
Browse files Browse the repository at this point in the history
…4-before-squash
  • Loading branch information
rpenido authored Jul 24, 2024
2 parents f591fbe + 19c869a commit 88e5bae
Show file tree
Hide file tree
Showing 13 changed files with 485 additions and 60 deletions.
146 changes: 138 additions & 8 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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',
Expand Down Expand Up @@ -157,8 +172,10 @@ describe('<LibraryAuthoringPage />', () => {
getByRole, getByText, queryByText, findByText,
} = 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();
Expand Down Expand Up @@ -202,8 +219,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();
});
Expand All @@ -228,13 +247,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();

Expand All @@ -247,6 +269,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(getByRole('tab', { name: 'Home' }));
});


Check failure on line 272 in src/library-authoring/LibraryAuthoringPage.test.tsx

View workflow job for this annotation

GitHub Actions / tests

More than 1 blank line not allowed
it('should open and close new content sidebar', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
Expand All @@ -265,5 +288,112 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(closeButton);

expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
});

Check failure on line 291 in src/library-authoring/LibraryAuthoringPage.test.tsx

View workflow job for this annotation

GitHub Actions / tests

Trailing spaces not allowed

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(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(<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(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);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });

const { findByText, getByText, getByTitle } = render(<RootWrapper />);

// Default sorts by relevance
expect(await findByText('Most Relevant')).toBeInTheDocument();

const testSortOption = (async (optionText, sortBy) => {
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');
await testSortOption('Recently Published', 'last_published:desc');
await testSortOption('Recently Modified', 'modified:desc');

// Selecting the default sort option clears the url search param
await testSortOption('Most Relevant', '');
});
});
11 changes: 8 additions & 3 deletions src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +26,7 @@ import {
FilterByTags,
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
} from '../search-manager';
import LibraryComponents from './components/LibraryComponents';
import LibraryCollections from './LibraryCollections';
Expand Down Expand Up @@ -62,12 +63,12 @@ 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 />;
Expand All @@ -78,7 +79,10 @@ const LibraryAuthoringPage = () => {
}

const handleTabChange = (key: string) => {
navigate(key);
navigate({
pathname: key,
search: searchParams.toString(),
});
};

return (
Expand Down Expand Up @@ -116,6 +120,7 @@ const LibraryAuthoringPage = () => {
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />

Check failure on line 123 in src/library-authoring/LibraryAuthoringPage.tsx

View workflow job for this annotation

GitHub Actions / tests

Trailing spaces not allowed
</div>
<Tabs
variant="tabs"
Expand Down
63 changes: 35 additions & 28 deletions src/library-authoring/LibraryHome.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,62 @@
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 intl = useIntl();

Check failure on line 19 in src/library-authoring/LibraryHome.tsx

View workflow job for this annotation

GitHub Actions / tests

More than 1 blank line not allowed
const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) => {
const intl = useIntl();
const {
totalHits: componentCount,
searchKeywords,
} = useSearchContext();

const collectionCount = 0;

if (componentCount === 0) {
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
}
const renderEmptyState = () => {
if (componentCount === 0) {
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
}
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.collectionsSectionTitle, { collectionCount })}
contentCount={collectionCount}
// TODO: add viewAllAction here once collections implemented
>
<LibraryCollections />
</LibrarySection>
<LibrarySection
title={intl.formatMessage(messages.componentsSectionTitle, { componentCount })}
contentCount={componentCount}
viewAllAction={() => handleTabChange(tabList.components)}
>
<LibraryComponents libraryId={libraryId} variant="preview" />
</LibrarySection>
</>
)
}
</Stack>
);
};
Expand Down
35 changes: 35 additions & 0 deletions src/library-authoring/LibraryRecentlyModified.tsx
Original file line number Diff line number Diff line change
@@ -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.recentlyModifiedSectionTitle)}
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;
1 change: 1 addition & 0 deletions src/library-authoring/components/LibraryComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Check failure on line 8 in src/library-authoring/components/LibraryComponents.tsx

View workflow job for this annotation

GitHub Actions / tests

'LIBRARY_SECTION_PREVIEW_LIMIT' is defined but never used

type LibraryComponentsProps = {
libraryId: string,
Expand Down
Loading

0 comments on commit 88e5bae

Please sign in to comment.