Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / mail / src / app / components / sidebar / MailSidebar.test.tsx
blob4bcf4d7573299d164ce1c19760321f8403014760
1 import { act } from 'react-dom/test-utils';
3 import { fireEvent, getAllByText, screen } from '@testing-library/react';
4 import { addDays, subDays } from 'date-fns';
5 import type { Location } from 'history';
6 import loudRejection from 'loud-rejection';
8 import { getModelState } from '@proton/account/test';
9 import useEventManager from '@proton/components/hooks/useEventManager';
10 import { conversationCountsActions } from '@proton/mail';
11 import { LABEL_TYPE, MAILBOX_LABEL_IDS } from '@proton/shared/lib/constants';
12 import { removeItem, setItem } from '@proton/shared/lib/helpers/storage';
13 import type { Label } from '@proton/shared/lib/interfaces';
14 import { CHECKLIST_DISPLAY_TYPE } from '@proton/shared/lib/interfaces';
15 import type { Folder } from '@proton/shared/lib/interfaces/Folder';
16 import range from '@proton/utils/range';
18 import type { OnboardingChecklistContext } from '../../containers/onboardingChecklist/provider/GetStartedChecklistProvider';
19 import * as GetStartedChecklistProviderModule from '../../containers/onboardingChecklist/provider/GetStartedChecklistProvider';
20 import { assertFocus, clearAll, getDropdown, minimalCache, render } from '../../helpers/test/helper';
21 import { SYSTEM_FOLDER_SECTION } from '../../hooks/useMoveSystemFolders';
22 import MailSidebar from './MailSidebar';
24 jest.mock('../../../../CHANGELOG.md', () => 'ProtonMail Changelog');
26 loudRejection();
28 const labelID = 'labelID';
30 const props = {
31     labelID,
32     location: {} as Location,
33     onToggleExpand: jest.fn(),
36 const folder = { ID: 'folder1', Type: LABEL_TYPE.MESSAGE_FOLDER, Name: 'folder1' } as Folder;
37 const subfolder = { ID: 'folder2', Type: LABEL_TYPE.MESSAGE_FOLDER, Name: 'folder2', ParentID: folder.ID } as Folder;
38 const label = { ID: 'label1', Type: LABEL_TYPE.MESSAGE_LABEL, Name: 'label1' } as Label;
39 const systemFolders = [
40     {
41         ID: MAILBOX_LABEL_IDS.INBOX,
42         Name: 'inbox',
43         Path: 'inbox',
44         Type: LABEL_TYPE.SYSTEM_FOLDER,
45         Order: 1,
46         Display: SYSTEM_FOLDER_SECTION.MAIN,
47     },
48     {
49         ID: MAILBOX_LABEL_IDS.SCHEDULED,
50         Name: 'all scheduled',
51         Path: 'all scheduled',
52         Type: LABEL_TYPE.SYSTEM_FOLDER,
53         Order: 3,
54         Display: SYSTEM_FOLDER_SECTION.MAIN,
55     },
56     {
57         ID: MAILBOX_LABEL_IDS.DRAFTS,
58         Name: 'drafts',
59         Path: 'drafts',
60         Type: LABEL_TYPE.SYSTEM_FOLDER,
61         Order: 4,
62         Display: SYSTEM_FOLDER_SECTION.MAIN,
63     },
64     {
65         ID: MAILBOX_LABEL_IDS.SENT,
66         Name: 'sent',
67         Path: 'sent',
68         Type: LABEL_TYPE.SYSTEM_FOLDER,
69         Order: 5,
70         Display: SYSTEM_FOLDER_SECTION.MAIN,
71     },
72     {
73         ID: MAILBOX_LABEL_IDS.ALL_MAIL,
74         Name: 'all mail',
75         Path: 'all mail',
76         Type: LABEL_TYPE.SYSTEM_FOLDER,
77         Order: 11,
78         Display: SYSTEM_FOLDER_SECTION.MAIN,
79     },
80 ] as Label[];
81 const inboxMessages = { LabelID: MAILBOX_LABEL_IDS.INBOX, Unread: 3, Total: 20 };
82 const allMailMessages = { LabelID: MAILBOX_LABEL_IDS.ALL_MAIL, Unread: 10000, Total: 10001 };
83 const scheduledMessages = { LabelID: MAILBOX_LABEL_IDS.SCHEDULED, Unread: 1, Total: 4 };
84 const folderMessages = { LabelID: folder.ID, Unread: 1, Total: 2 };
85 const labelMessages = { LabelID: label.ID, Unread: 2, Total: 3 };
87 describe('MailSidebar', () => {
88     let mockedUseGetStartedChecklist: jest.SpyInstance<OnboardingChecklistContext, [], any>;
90     beforeEach(() => {
91         mockedUseGetStartedChecklist = jest.spyOn(GetStartedChecklistProviderModule, 'useGetStartedChecklist');
92     });
94     const setupTest = () => {
95         // open the more section otherwise it's closed by default
96         setItem('item-display-more-items', 'true');
98         minimalCache();
100         mockedUseGetStartedChecklist.mockReturnValue({
101             displayState: CHECKLIST_DISPLAY_TYPE.FULL,
102             items: new Set(),
103         } as OnboardingChecklistContext);
104     };
106     const setup = async () => {
107         minimalCache();
108         mockedUseGetStartedChecklist.mockReturnValue({
109             displayState: CHECKLIST_DISPLAY_TYPE.FULL,
110             items: new Set(),
111         } as OnboardingChecklistContext);
113         const result = await render(<MailSidebar {...props} />);
115         return { ...result };
116     };
118     afterEach(() => {
119         clearAll();
120         // We need to remove the item from the localStorage otherwise it will keep the previous state
121         removeItem('item-display-folders');
122         removeItem('item-display-labels');
123     });
125     it('should redirect on inbox when click on logo', async () => {
126         const { getByTestId, history } = await setup();
127         const logo = getByTestId('main-logo') as HTMLAnchorElement;
128         fireEvent.click(logo);
130         expect(history.length).toBe(1);
131         expect(history.location.pathname).toBe('/inbox');
132     });
134     it('should open app dropdown', async () => {
135         const { getByTitle } = await setup();
137         const appsButton = getByTitle('Proton applications');
138         fireEvent.click(appsButton);
140         const dropdown = await getDropdown();
142         getAllByText(dropdown, 'Proton Mail');
143         getAllByText(dropdown, 'Proton Calendar');
144         getAllByText(dropdown, 'Proton Drive');
145         getAllByText(dropdown, 'Proton VPN');
146     });
148     it('should show folder tree', async () => {
149         setupTest();
151         const { getByTestId, queryByTestId } = await render(<MailSidebar {...props} />, {
152             preloadedState: {
153                 categories: getModelState([folder, subfolder]),
154             },
155         });
157         const folderElement = getByTestId(`navigation-link:${folder.ID}`);
158         const folderIcon = folderElement.querySelector('svg:not(.navigation-icon--expand)');
160         expect(folderElement.textContent).toContain(folder.Name);
161         expect((folderIcon?.firstChild as Element).getAttribute('xlink:href')).toBe('#ic-folders');
163         const subfolderElement = getByTestId(`navigation-link:${subfolder.ID}`);
164         const subfolderIcon = subfolderElement.querySelector('svg');
166         expect(subfolderElement.textContent).toContain(subfolder.Name);
167         expect((subfolderIcon?.firstChild as Element).getAttribute('xlink:href')).toBe('#ic-folder');
169         const collapseButton = folderElement.querySelector('button');
171         if (collapseButton) {
172             fireEvent.click(collapseButton);
173         }
175         expect(queryByTestId(`sidebar-item-${subfolder.ID}`)).toBeNull();
176     });
178     it('should show label list', async () => {
179         setupTest();
181         const { getByTestId } = await render(<MailSidebar {...props} />, {
182             preloadedState: {
183                 categories: getModelState([label]),
184             },
185         });
187         const labelElement = getByTestId(`navigation-link:${label.ID}`);
188         const labelIcon = labelElement.querySelector('svg');
190         expect(labelElement.textContent).toContain(label.Name);
191         expect((labelIcon?.firstChild as Element).getAttribute('xlink:href')).toBe('#ic-circle-filled');
192     });
194     it('should show unread counters', async () => {
195         setupTest();
197         const { getByTestId } = await render(<MailSidebar {...props} />, {
198             preloadedState: {
199                 categories: getModelState([folder, label, ...systemFolders]),
200                 conversationCounts: getModelState([inboxMessages, allMailMessages, folderMessages, labelMessages]),
201             },
202         });
204         const inboxElement = getByTestId(`navigation-link:inbox`);
205         const allMailElement = getByTestId(`navigation-link:all-mail`);
206         const folderElement = getByTestId(`navigation-link:${folder.ID}`);
207         const labelElement = getByTestId(`navigation-link:${label.ID}`);
209         const inBoxLocationAside = inboxElement.querySelector('.navigation-counter-item');
210         const allMailLocationAside = allMailElement.querySelector('.navigation-counter-item');
211         const folderLocationAside = folderElement.querySelector('.navigation-counter-item');
212         const labelLocationAside = labelElement.querySelector('.navigation-counter-item');
214         expect(inBoxLocationAside?.innerHTML).toBe(`${inboxMessages.Unread}`);
215         expect(allMailLocationAside?.innerHTML).toBe('9999+');
216         expect(folderLocationAside?.innerHTML).toBe(`${folderMessages.Unread}`);
217         expect(labelLocationAside?.innerHTML).toBe(`${labelMessages.Unread}`);
218     });
220     it('should navigate to the label on click', async () => {
221         setupTest();
223         const { getByTestId, history } = await render(<MailSidebar {...props} />, {
224             preloadedState: {
225                 categories: getModelState([folder]),
226             },
227         });
229         const folderElement = getByTestId(`navigation-link:${folder.ID}`);
231         expect(history.location.pathname).toBe('/inbox');
233         act(() => {
234             fireEvent.click(folderElement);
235         });
237         expect(history.location.pathname).toBe(`/${folder.ID}`);
238     });
240     it('should call event manager on click if already on label', async () => {
241         setupTest();
243         const { getByTestId, history } = await render(<MailSidebar {...props} />, {
244             preloadedState: {
245                 categories: getModelState([folder]),
246             },
247         });
249         const folderElement = getByTestId(`navigation-link:${folder.ID}`);
251         // Click on the label to be redirected in it
252         act(() => {
253             fireEvent.click(folderElement);
254         });
256         // Check if we are in the label
257         expect(history.location.pathname).toBe(`/${folder.ID}`);
259         // Click again on the label to trigger the event manager
260         act(() => {
261             fireEvent.click(folderElement);
262         });
264         expect(useEventManager.call).toHaveBeenCalled();
265     });
267     it('should be updated when counters are updated', async () => {
268         setupTest();
270         const { getByTestId, store } = await render(<MailSidebar {...props} />, {
271             preloadedState: {
272                 categories: getModelState(systemFolders),
273                 conversationCounts: getModelState([inboxMessages]),
274             },
275         });
277         const inboxElement = getByTestId('navigation-link:inbox');
279         const inBoxLocationAside = inboxElement.querySelector('.navigation-counter-item');
280         expect(inBoxLocationAside?.innerHTML).toBe(`${inboxMessages.Unread}`);
282         const inboxMessagesUpdated = { LabelID: '0', Unread: 7, Total: 21 };
284         act(() => {
285             store.dispatch(conversationCountsActions.set([inboxMessagesUpdated]));
286         });
288         expect(inBoxLocationAside?.innerHTML).toBe(`${inboxMessagesUpdated.Unread}`);
289     });
291     it('should not show scheduled sidebar item when feature flag is disabled', async () => {
292         setupTest();
294         const { queryByTestId } = await render(<MailSidebar {...props} />, {
295             preloadedState: {
296                 categories: getModelState(systemFolders),
297                 conversationCounts: getModelState([scheduledMessages]),
298             },
299         });
301         expect(queryByTestId(`Scheduled`)).toBeNull();
302     });
304     it('should show scheduled sidebar item if scheduled messages', async () => {
305         setupTest();
307         const { getByTestId } = await render(<MailSidebar {...props} />, {
308             preloadedState: {
309                 categories: getModelState(systemFolders),
310                 conversationCounts: getModelState([scheduledMessages]),
311             },
312         });
314         const scheduledLocationAside = getByTestId(`navigation-link:unread-count`);
316         // We have two navigation counters for scheduled messages, one to display the number of scheduled messages and one for unread scheduled messages
317         expect(scheduledLocationAside.innerHTML).toBe(`${scheduledMessages.Total}`);
318     });
320     it('should not show scheduled sidebar item without scheduled messages', async () => {
321         setupTest();
323         const { queryByTestId } = await render(<MailSidebar {...props} />);
325         expect(queryByTestId(`Scheduled`)).toBeNull();
326     });
328     describe('Sidebar hotkeys', () => {
329         it('should navigate with the arrow keys', async () => {
330             setupTest();
332             const { getByTestId, getByTitle, container } = await render(<MailSidebar {...props} />, {
333                 preloadedState: {
334                     categories: getModelState([label, folder, ...systemFolders]),
335                 },
336             });
338             const sidebar = container.querySelector('nav > div') as HTMLDivElement;
339             const More = getByTitle('Less'); // When opened, it becomes "LESS"
340             const Folders = getByTitle('Folders');
341             const Labels = getByTitle('Labels');
343             const Inbox = getByTestId('navigation-link:inbox');
344             const Drafts = getByTestId('navigation-link:drafts');
345             const Folder = getByTestId(`navigation-link:${folder.ID}`);
346             const Label = getByTestId(`navigation-link:${label.ID}`);
348             const down = () => fireEvent.keyDown(sidebar, { key: 'ArrowDown' });
349             const up = () => fireEvent.keyDown(sidebar, { key: 'ArrowUp' });
350             const ctrlDown = () => fireEvent.keyDown(sidebar, { key: 'ArrowDown', ctrlKey: true });
351             const ctrlUp = () => fireEvent.keyDown(sidebar, { key: 'ArrowUp', ctrlKey: true });
353             down();
354             assertFocus(Inbox);
355             down();
356             assertFocus(Drafts);
357             range(0, 3).forEach(down);
358             assertFocus(More);
359             down();
360             assertFocus(Folders);
361             down();
362             assertFocus(Folder);
363             down();
364             assertFocus(Labels);
365             down();
366             assertFocus(Label);
368             up();
369             assertFocus(Labels);
370             up();
371             assertFocus(Folder);
372             up();
373             assertFocus(Folders);
374             range(0, 10).forEach(up);
375             assertFocus(Inbox);
377             ctrlDown();
378             assertFocus(Label);
379             ctrlUp();
380             assertFocus(Inbox);
381         });
383         it('should navigate to list with right key', async () => {
384             setupTest();
386             const TestComponent = () => {
387                 return (
388                     <>
389                         <MailSidebar {...props} />
390                         <div data-shortcut-target="item-container" tabIndex={-1}>
391                             test
392                         </div>
393                     </>
394                 );
395             };
397             const { container } = await render(<TestComponent />, {
398                 preloadedState: {
399                     categories: getModelState([label, folder]),
400                 },
401             });
403             const sidebar = container.querySelector('nav > div') as HTMLDivElement;
405             fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
407             const target = document.querySelector('[data-shortcut-target="item-container"]');
409             assertFocus(target);
410         });
411     });
414 describe('Sidebar checklist display', () => {
415     let mockedUseGetStartedChecklist: jest.SpyInstance<OnboardingChecklistContext, [], any>;
417     beforeEach(() => {
418         minimalCache();
419         mockedUseGetStartedChecklist = jest.spyOn(GetStartedChecklistProviderModule, 'useGetStartedChecklist');
420     });
422     it('Should display the checklist if state is reduced', async () => {
423         mockedUseGetStartedChecklist.mockReturnValue({
424             expiresAt: addDays(new Date(), 10),
425             canDisplayChecklist: true,
426             displayState: CHECKLIST_DISPLAY_TYPE.REDUCED,
427             items: new Set(),
428         } as OnboardingChecklistContext);
430         await render(<MailSidebar {...props} />);
431         screen.getByTestId('onboarding-checklist');
432     });
434     it('Should not display the checklist if state is full', async () => {
435         mockedUseGetStartedChecklist.mockReturnValue({
436             displayState: CHECKLIST_DISPLAY_TYPE.FULL,
437             items: new Set(),
438             expiresAt: addDays(new Date(), 10),
439             canDisplayChecklist: true,
440         } as OnboardingChecklistContext);
442         await render(<MailSidebar {...props} />);
443         const checklistWrapper = screen.queryByTestId('onboarding-checklist');
445         expect(checklistWrapper).toBeNull();
446     });
448     it('Should not display the checklist if state is hidden', async () => {
449         mockedUseGetStartedChecklist.mockReturnValue({
450             displayState: CHECKLIST_DISPLAY_TYPE.HIDDEN,
451             items: new Set(),
452             expiresAt: addDays(new Date(), 10),
453             canDisplayChecklist: true,
454         } as OnboardingChecklistContext);
456         await render(<MailSidebar {...props} />);
457         const checklistWrapper = screen.queryByTestId('onboarding-checklist');
459         expect(checklistWrapper).toBeNull();
460     });
462     it('Should hide the checklist when pressing the cross button in the sidebar', async () => {
463         jest.useFakeTimers().setSystemTime(new Date('2024-12-01'));
465         const mockedChangeDisplay = jest.fn();
466         const nowDate = new Date();
468         mockedUseGetStartedChecklist.mockReturnValue({
469             displayState: CHECKLIST_DISPLAY_TYPE.REDUCED,
470             items: new Set(),
471             changeChecklistDisplay: mockedChangeDisplay,
472             createdAt: subDays(nowDate, 19),
473             // List should be expired
474             expiresAt: subDays(nowDate, 1),
475             canDisplayChecklist: true,
476             isChecklistFinished: false,
477             isUserPaid: false,
478             loading: false,
479             markItemsAsDone: jest.fn(),
480             userWasRewarded: false,
481         } as OnboardingChecklistContext);
483         await render(<MailSidebar {...props} />);
485         const closeButton = screen.getByTestId('onboarding-checklist-header-hide-button');
486         fireEvent.click(closeButton);
487         expect(mockedChangeDisplay).toHaveBeenCalledWith(CHECKLIST_DISPLAY_TYPE.HIDDEN);
489         jest.clearAllTimers();
490     });