Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds sort widget to search manager and library component page [FC-0059] #1147

Merged
6 changes: 4 additions & 2 deletions src/library-authoring/EmptyStates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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>
);
168 changes: 158 additions & 10 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '';
Expand All @@ -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) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment explaining what this function is doing; it's not obvious to me from the name.

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 @@ -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();
Expand All @@ -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' }));
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();

Expand Down Expand Up @@ -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('', '');
});
});
20 changes: 16 additions & 4 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,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 />;
}
Expand All @@ -78,7 +80,10 @@ const LibraryAuthoringPage = () => {
}

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

return (
Expand Down Expand Up @@ -116,6 +121,7 @@ const LibraryAuthoringPage = () => {
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<Tabs
variant="tabs"
Expand All @@ -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}
Expand Down
64 changes: 35 additions & 29 deletions src/library-authoring/LibraryHome.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Expand Down
Loading
Loading