Skip to content

Commit 9b61037

Browse files
authored
feat: collections tab [FC-0062] (#1257)
* 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
1 parent 4035931 commit 9b61037

24 files changed

+1070
-446
lines changed

src/generic/block-type-utils/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const STRUCTURAL_TYPE_ICONS: Record<string, React.ComponentType> = {
5151
vertical: UNIT_TYPE_ICONS_MAP.vertical,
5252
sequential: Folder,
5353
chapter: Folder,
54+
collection: Folder,
5455
};
5556

5657
export const COMPONENT_TYPE_STYLE_COLOR_MAP = {

src/hooks.js

Lines changed: 0 additions & 37 deletions
This file was deleted.

src/hooks.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useEffect, useState } from 'react';
2+
import { history } from '@edx/frontend-platform';
3+
4+
export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => {
5+
const [elementWithHash, setElementWithHash] = useState<string | null>(null);
6+
7+
useEffect(() => {
8+
const currentHash = window.location.hash.substring(1);
9+
10+
if (currentHash) {
11+
const element = document.getElementById(currentHash);
12+
if (element) {
13+
element.scrollIntoView();
14+
history.replace({ hash: '' });
15+
}
16+
setElementWithHash(currentHash);
17+
}
18+
}, [isLoading]);
19+
20+
return { elementWithHash };
21+
};
22+
23+
export const useEscapeClick = ({ onEscape, dependency }: { onEscape: () => void, dependency: any }) => {
24+
useEffect(() => {
25+
const handleEscapeClick = (event: KeyboardEvent) => {
26+
if (event.key === 'Escape') {
27+
onEscape();
28+
}
29+
};
30+
31+
window.addEventListener('keydown', handleEscapeClick);
32+
33+
return () => {
34+
window.removeEventListener('keydown', handleEscapeClick);
35+
};
36+
}, [dependency]);
37+
};
38+
39+
/**
40+
* Hook which loads next page of items on scroll
41+
*/
42+
export const useLoadOnScroll = (
43+
hasNextPage: boolean | undefined,
44+
isFetchingNextPage: boolean,
45+
fetchNextPage: () => void,
46+
enabled: boolean,
47+
) => {
48+
useEffect(() => {
49+
if (enabled) {
50+
const onscroll = () => {
51+
// Verify the position of the scroll to implement an infinite scroll.
52+
// Used `loadLimit` to fetch next page before reach the end of the screen.
53+
const loadLimit = 300;
54+
const scrolledTo = window.scrollY + window.innerHeight;
55+
const scrollDiff = document.body.scrollHeight - scrolledTo;
56+
const isNearToBottom = scrollDiff <= loadLimit;
57+
if (isNearToBottom && hasNextPage && !isFetchingNextPage) {
58+
fetchNextPage();
59+
}
60+
};
61+
window.addEventListener('scroll', onscroll);
62+
return () => {
63+
window.removeEventListener('scroll', onscroll);
64+
};
65+
}
66+
return () => { };
67+
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
68+
};

src/library-authoring/EmptyStates.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,37 @@ import messages from './messages';
1010
import { LibraryContext } from './common/context';
1111
import { useContentLibrary } from './data/apiHooks';
1212

13-
export const NoComponents = () => {
13+
type NoSearchResultsProps = {
14+
searchType?: 'collection' | 'component',
15+
};
16+
17+
export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => {
1418
const { openAddContentSidebar } = useContext(LibraryContext);
1519
const { libraryId } = useParams();
1620
const { data: libraryData } = useContentLibrary(libraryId);
1721
const canEditLibrary = libraryData?.canEditLibrary ?? false;
1822

1923
return (
2024
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
21-
<FormattedMessage {...messages.noComponents} />
25+
{searchType === 'collection'
26+
? <FormattedMessage {...messages.noCollections} />
27+
: <FormattedMessage {...messages.noComponents} />}
2228
{canEditLibrary && (
2329
<Button iconBefore={Add} onClick={() => openAddContentSidebar()}>
24-
<FormattedMessage {...messages.addComponent} />
30+
{searchType === 'collection'
31+
? <FormattedMessage {...messages.addCollection} />
32+
: <FormattedMessage {...messages.addComponent} />}
2533
</Button>
2634
)}
2735
</Stack>
2836
);
2937
};
3038

31-
export const NoSearchResults = () => (
32-
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
33-
<FormattedMessage {...messages.noSearchResults} />
39+
export const NoSearchResults = ({ searchType = 'component' }: NoSearchResultsProps) => (
40+
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
41+
{searchType === 'collection'
42+
? <FormattedMessage {...messages.noSearchResultsCollections} />
43+
: <FormattedMessage {...messages.noSearchResults} />}
3444
<ClearFiltersButton variant="primary" size="md" />
3545
</Stack>
3646
);

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ const returnEmptyResult = (_url, req) => {
2828
// We have to replace the query (search keywords) in the mock results with the actual query,
2929
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
3030
mockEmptyResult.results[0].query = query;
31+
mockEmptyResult.results[2].query = query;
3132
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
3233
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
3334
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
35+
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
36+
mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
3437
return mockEmptyResult;
3538
};
3639

@@ -48,10 +51,14 @@ const returnLowNumberResults = (_url, req) => {
4851
newMockResult.results[0].query = query;
4952
// Limit number of results to just 2
5053
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
54+
newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2);
5155
newMockResult.results[0].estimatedTotalHits = 2;
56+
newMockResult.results[2].estimatedTotalHits = 2;
5257
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
5358
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
5459
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
60+
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
61+
newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
5562
return newMockResult;
5663
};
5764

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

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

136143
// Navigate to the components tab
137144
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
138145
// "Recently Modified" default sort shown
139146
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
140-
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
147+
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
141148
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
142149

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

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

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

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

175+
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
176+
expect(screen.getByText('You have not added any collection to this library yet.')).toBeInTheDocument();
177+
178+
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
168179
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
169180
});
170181

@@ -211,6 +222,14 @@ describe('<LibraryAuthoringPage />', () => {
211222
// Navigate to the components tab
212223
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
213224
expect(screen.getByText('No matching components found in this library.')).toBeInTheDocument();
225+
226+
// Navigate to the collections tab
227+
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
228+
expect(screen.getByText('No matching collections found in this library.')).toBeInTheDocument();
229+
230+
// Go back to Home tab
231+
// This step is necessary to avoid the url change leak to other tests
232+
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
214233
});
215234

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

283302
// "Recently Modified" header + sort shown
284303
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
285-
expect(screen.getByText('Collections (0)')).toBeInTheDocument();
304+
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
286305
expect(screen.getByText('Components (10)')).toBeInTheDocument();
287306
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
288307
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
289308

290-
// There should only be one "View All" button, since the Components count
309+
// There should be two "View All" button, since the Components and Collections count
291310
// are above the preview limit (4)
292-
expect(screen.getByText('View All')).toBeInTheDocument();
311+
expect(screen.getAllByText('View All').length).toEqual(2);
293312

294-
// Clicking on "View All" button should navigate to the Components tab
295-
fireEvent.click(screen.getByText('View All'));
313+
// Clicking on first "View All" button should navigate to the Collections tab
314+
fireEvent.click(screen.getAllByText('View All')[0]);
296315
// "Recently Modified" default sort shown
297316
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
298-
expect(screen.queryByText('Collections (0)')).not.toBeInTheDocument();
317+
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
318+
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
319+
expect(screen.getByText('Collection 1')).toBeInTheDocument();
320+
321+
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
322+
// Clicking on second "View All" button should navigate to the Components tab
323+
fireEvent.click(screen.getAllByText('View All')[1]);
324+
// "Recently Modified" default sort shown
325+
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
326+
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
299327
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
300328
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
301329

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

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

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

407435
// Click on the first component
408-
waitFor(() => expect(screen.queryByText(displayName)).toBeInTheDocument());
409-
fireEvent.click(screen.getAllByText(displayName)[0]);
436+
expect((await screen.findAllByText(displayName))[0]).toBeInTheDocument();
437+
fireEvent.click((await screen.findAllByText(displayName))[0]);
410438

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

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

519547
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
520548
});
549+
550+
it('shows both components and collections in recently modified section', async () => {
551+
await renderLibraryPage();
552+
553+
expect(await screen.findByText('Content library')).toBeInTheDocument();
554+
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
555+
556+
// "Recently Modified" header + sort shown
557+
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
558+
const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement;
559+
expect(recentModifiedContainer).toBeTruthy();
560+
561+
const container = within(recentModifiedContainer!);
562+
expect(container.queryAllByText('Text').length).toBeGreaterThan(0);
563+
expect(container.queryAllByText('Collection').length).toBeGreaterThan(0);
564+
});
521565
});

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ const LibraryAuthoringPage = () => {
206206
/>
207207
<Route
208208
path={TabList.collections}
209-
element={<LibraryCollections />}
209+
element={<LibraryCollections variant="full" />}
210210
/>
211211
<Route
212212
path="*"

0 commit comments

Comments
 (0)