Skip to content

Commit

Permalink
feat: collections tab [FC-0062] (#1257)
Browse files Browse the repository at this point in the history
* feat: add collections query to search results

* feat: collections tab with basic cards

* feat: add collection card also fix inifinite scroll for collections

* feat: collection empty states

* test: add test for collections card
  • Loading branch information
navinkarkera authored Sep 12, 2024
1 parent 4035931 commit 9b61037
Show file tree
Hide file tree
Showing 24 changed files with 1,070 additions and 446 deletions.
1 change: 1 addition & 0 deletions src/generic/block-type-utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const STRUCTURAL_TYPE_ICONS: Record<string, React.ComponentType> = {
vertical: UNIT_TYPE_ICONS_MAP.vertical,
sequential: Folder,
chapter: Folder,
collection: Folder,
};

export const COMPONENT_TYPE_STYLE_COLOR_MAP = {
Expand Down
37 changes: 0 additions & 37 deletions src/hooks.js

This file was deleted.

68 changes: 68 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { history } from '@edx/frontend-platform';

export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => {
const [elementWithHash, setElementWithHash] = useState<string | null>(null);

useEffect(() => {
const currentHash = window.location.hash.substring(1);

if (currentHash) {
const element = document.getElementById(currentHash);
if (element) {
element.scrollIntoView();
history.replace({ hash: '' });
}
setElementWithHash(currentHash);
}
}, [isLoading]);

return { elementWithHash };
};

export const useEscapeClick = ({ onEscape, dependency }: { onEscape: () => void, dependency: any }) => {
useEffect(() => {
const handleEscapeClick = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onEscape();
}
};

window.addEventListener('keydown', handleEscapeClick);

return () => {
window.removeEventListener('keydown', handleEscapeClick);
};
}, [dependency]);
};

/**
* Hook which loads next page of items on scroll
*/
export const useLoadOnScroll = (
hasNextPage: boolean | undefined,
isFetchingNextPage: boolean,
fetchNextPage: () => void,
enabled: boolean,
) => {
useEffect(() => {
if (enabled) {
const onscroll = () => {
// Verify the position of the scroll to implement an infinite scroll.
// Used `loadLimit` to fetch next page before reach the end of the screen.
const loadLimit = 300;
const scrolledTo = window.scrollY + window.innerHeight;
const scrollDiff = document.body.scrollHeight - scrolledTo;
const isNearToBottom = scrollDiff <= loadLimit;
if (isNearToBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
window.addEventListener('scroll', onscroll);
return () => {
window.removeEventListener('scroll', onscroll);
};
}
return () => { };
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
};
22 changes: 16 additions & 6 deletions src/library-authoring/EmptyStates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,37 @@ import messages from './messages';
import { LibraryContext } from './common/context';
import { useContentLibrary } from './data/apiHooks';

export const NoComponents = () => {
type NoSearchResultsProps = {
searchType?: 'collection' | 'component',
};

export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => {
const { openAddContentSidebar } = useContext(LibraryContext);
const { libraryId } = useParams();
const { data: libraryData } = useContentLibrary(libraryId);
const canEditLibrary = libraryData?.canEditLibrary ?? false;

return (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
<FormattedMessage {...messages.noComponents} />
{searchType === 'collection'
? <FormattedMessage {...messages.noCollections} />
: <FormattedMessage {...messages.noComponents} />}
{canEditLibrary && (
<Button iconBefore={Add} onClick={() => openAddContentSidebar()}>
<FormattedMessage {...messages.addComponent} />
{searchType === 'collection'
? <FormattedMessage {...messages.addCollection} />
: <FormattedMessage {...messages.addComponent} />}
</Button>
)}
</Stack>
);
};

export const NoSearchResults = () => (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
<FormattedMessage {...messages.noSearchResults} />
export const NoSearchResults = ({ searchType = 'component' }: NoSearchResultsProps) => (
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
{searchType === 'collection'
? <FormattedMessage {...messages.noSearchResultsCollections} />
: <FormattedMessage {...messages.noSearchResults} />}
<ClearFiltersButton variant="primary" size="md" />
</Stack>
);
76 changes: 60 additions & 16 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ const returnEmptyResult = (_url, req) => {
// 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.
mockEmptyResult.results[0].query = query;
mockEmptyResult.results[2].query = query;
// 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
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockEmptyResult;
};

Expand All @@ -48,10 +51,14 @@ const returnLowNumberResults = (_url, req) => {
newMockResult.results[0].query = query;
// Limit number of results to just 2
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2);
newMockResult.results[0].estimatedTotalHits = 2;
newMockResult.results[2].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
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResult;
};

Expand Down Expand Up @@ -129,42 +136,46 @@ describe('<LibraryAuthoringPage />', () => {

// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();

// Navigate to the components tab
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();

// Navigate to the collections tab
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument();
expect(screen.getByText('Coming soon!')).toBeInTheDocument();
expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument();

// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
});

it('shows a library without components', async () => {
it('shows a library without components and collections', async () => {
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
await renderLibraryPage();

expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();

fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
expect(screen.getByText('You have not added any collection to this library yet.')).toBeInTheDocument();

fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
});

Expand Down Expand Up @@ -211,6 +222,14 @@ describe('<LibraryAuthoringPage />', () => {
// Navigate to the components tab
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument();

// Navigate to the collections tab
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
expect(screen.getByText('No matching collections found in this library.')).toBeInTheDocument();

// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
});

it('should open and close new content sidebar', async () => {
Expand Down Expand Up @@ -282,20 +301,29 @@ describe('<LibraryAuthoringPage />', () => {

// "Recently Modified" header + sort shown
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
expect(screen.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
// There should be two "View All" button, since the Components and Collections count
// are above the preview limit (4)
expect(screen.getByText('View All')).toBeInTheDocument();
expect(screen.getAllByText('View All').length).toEqual(2);

// Clicking on "View All" button should navigate to the Components tab
fireEvent.click(screen.getByText('View All'));
// Clicking on first "View All" button should navigate to the Collections tab
fireEvent.click(screen.getAllByText('View All')[0]);
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.getByText('Collection 1')).toBeInTheDocument();

fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// Clicking on second "View All" button should navigate to the Components tab
fireEvent.click(screen.getAllByText('View All')[1]);
// "Recently Modified" default sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();

Expand All @@ -304,7 +332,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
expect(screen.getByText('Components (10)')).toBeInTheDocument();
});

Expand All @@ -317,7 +345,7 @@ describe('<LibraryAuthoringPage />', () => {

// "Recently Modified" header + sort shown
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
expect(screen.getByText('Collections (2)')).toBeInTheDocument();
expect(screen.getByText('Components (2)')).toBeInTheDocument();
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
Expand Down Expand Up @@ -405,8 +433,8 @@ describe('<LibraryAuthoringPage />', () => {
await renderLibraryPage();

// Click on the first component
waitFor(() => expect(screen.queryByText(displayName)).toBeInTheDocument());
fireEvent.click(screen.getAllByText(displayName)[0]);
expect((await screen.findAllByText(displayName))[0]).toBeInTheDocument();
fireEvent.click((await screen.findAllByText(displayName))[0]);

const sidebar = screen.getByTestId('library-sidebar');

Expand Down Expand Up @@ -518,4 +546,20 @@ describe('<LibraryAuthoringPage />', () => {

expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});

it('shows both components and collections in recently modified section', async () => {
await renderLibraryPage();

expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();

// "Recently Modified" header + sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement;
expect(recentModifiedContainer).toBeTruthy();

const container = within(recentModifiedContainer!);
expect(container.queryAllByText('Text').length).toBeGreaterThan(0);
expect(container.queryAllByText('Collection').length).toBeGreaterThan(0);
});
});
2 changes: 1 addition & 1 deletion src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ const LibraryAuthoringPage = () => {
/>
<Route
path={TabList.collections}
element={<LibraryCollections />}
element={<LibraryCollections variant="full" />}
/>
<Route
path="*"
Expand Down
Loading

0 comments on commit 9b61037

Please sign in to comment.