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');
28 const labelID = '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 = [
41 ID: MAILBOX_LABEL_IDS.INBOX,
44 Type: LABEL_TYPE.SYSTEM_FOLDER,
46 Display: SYSTEM_FOLDER_SECTION.MAIN,
49 ID: MAILBOX_LABEL_IDS.SCHEDULED,
50 Name: 'all scheduled',
51 Path: 'all scheduled',
52 Type: LABEL_TYPE.SYSTEM_FOLDER,
54 Display: SYSTEM_FOLDER_SECTION.MAIN,
57 ID: MAILBOX_LABEL_IDS.DRAFTS,
60 Type: LABEL_TYPE.SYSTEM_FOLDER,
62 Display: SYSTEM_FOLDER_SECTION.MAIN,
65 ID: MAILBOX_LABEL_IDS.SENT,
68 Type: LABEL_TYPE.SYSTEM_FOLDER,
70 Display: SYSTEM_FOLDER_SECTION.MAIN,
73 ID: MAILBOX_LABEL_IDS.ALL_MAIL,
76 Type: LABEL_TYPE.SYSTEM_FOLDER,
78 Display: SYSTEM_FOLDER_SECTION.MAIN,
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>;
91 mockedUseGetStartedChecklist = jest.spyOn(GetStartedChecklistProviderModule, 'useGetStartedChecklist');
94 const setupTest = () => {
95 // open the more section otherwise it's closed by default
96 setItem('item-display-more-items', 'true');
100 mockedUseGetStartedChecklist.mockReturnValue({
101 displayState: CHECKLIST_DISPLAY_TYPE.FULL,
103 } as OnboardingChecklistContext);
106 const setup = async () => {
108 mockedUseGetStartedChecklist.mockReturnValue({
109 displayState: CHECKLIST_DISPLAY_TYPE.FULL,
111 } as OnboardingChecklistContext);
113 const result = await render(<MailSidebar {...props} />);
115 return { ...result };
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');
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');
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');
148 it('should show folder tree', async () => {
151 const { getByTestId, queryByTestId } = await render(<MailSidebar {...props} />, {
153 categories: getModelState([folder, subfolder]),
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);
175 expect(queryByTestId(`sidebar-item-${subfolder.ID}`)).toBeNull();
178 it('should show label list', async () => {
181 const { getByTestId } = await render(<MailSidebar {...props} />, {
183 categories: getModelState([label]),
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');
194 it('should show unread counters', async () => {
197 const { getByTestId } = await render(<MailSidebar {...props} />, {
199 categories: getModelState([folder, label, ...systemFolders]),
200 conversationCounts: getModelState([inboxMessages, allMailMessages, folderMessages, labelMessages]),
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}`);
220 it('should navigate to the label on click', async () => {
223 const { getByTestId, history } = await render(<MailSidebar {...props} />, {
225 categories: getModelState([folder]),
229 const folderElement = getByTestId(`navigation-link:${folder.ID}`);
231 expect(history.location.pathname).toBe('/inbox');
234 fireEvent.click(folderElement);
237 expect(history.location.pathname).toBe(`/${folder.ID}`);
240 it('should call event manager on click if already on label', async () => {
243 const { getByTestId, history } = await render(<MailSidebar {...props} />, {
245 categories: getModelState([folder]),
249 const folderElement = getByTestId(`navigation-link:${folder.ID}`);
251 // Click on the label to be redirected in it
253 fireEvent.click(folderElement);
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
261 fireEvent.click(folderElement);
264 expect(useEventManager.call).toHaveBeenCalled();
267 it('should be updated when counters are updated', async () => {
270 const { getByTestId, store } = await render(<MailSidebar {...props} />, {
272 categories: getModelState(systemFolders),
273 conversationCounts: getModelState([inboxMessages]),
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 };
285 store.dispatch(conversationCountsActions.set([inboxMessagesUpdated]));
288 expect(inBoxLocationAside?.innerHTML).toBe(`${inboxMessagesUpdated.Unread}`);
291 it('should not show scheduled sidebar item when feature flag is disabled', async () => {
294 const { queryByTestId } = await render(<MailSidebar {...props} />, {
296 categories: getModelState(systemFolders),
297 conversationCounts: getModelState([scheduledMessages]),
301 expect(queryByTestId(`Scheduled`)).toBeNull();
304 it('should show scheduled sidebar item if scheduled messages', async () => {
307 const { getByTestId } = await render(<MailSidebar {...props} />, {
309 categories: getModelState(systemFolders),
310 conversationCounts: getModelState([scheduledMessages]),
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}`);
320 it('should not show scheduled sidebar item without scheduled messages', async () => {
323 const { queryByTestId } = await render(<MailSidebar {...props} />);
325 expect(queryByTestId(`Scheduled`)).toBeNull();
328 describe('Sidebar hotkeys', () => {
329 it('should navigate with the arrow keys', async () => {
332 const { getByTestId, getByTitle, container } = await render(<MailSidebar {...props} />, {
334 categories: getModelState([label, folder, ...systemFolders]),
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 });
357 range(0, 3).forEach(down);
360 assertFocus(Folders);
373 assertFocus(Folders);
374 range(0, 10).forEach(up);
383 it('should navigate to list with right key', async () => {
386 const TestComponent = () => {
389 <MailSidebar {...props} />
390 <div data-shortcut-target="item-container" tabIndex={-1}>
397 const { container } = await render(<TestComponent />, {
399 categories: getModelState([label, folder]),
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"]');
414 describe('Sidebar checklist display', () => {
415 let mockedUseGetStartedChecklist: jest.SpyInstance<OnboardingChecklistContext, [], any>;
419 mockedUseGetStartedChecklist = jest.spyOn(GetStartedChecklistProviderModule, 'useGetStartedChecklist');
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,
428 } as OnboardingChecklistContext);
430 await render(<MailSidebar {...props} />);
431 screen.getByTestId('onboarding-checklist');
434 it('Should not display the checklist if state is full', async () => {
435 mockedUseGetStartedChecklist.mockReturnValue({
436 displayState: CHECKLIST_DISPLAY_TYPE.FULL,
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();
448 it('Should not display the checklist if state is hidden', async () => {
449 mockedUseGetStartedChecklist.mockReturnValue({
450 displayState: CHECKLIST_DISPLAY_TYPE.HIDDEN,
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();
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,
471 changeChecklistDisplay: mockedChangeDisplay,
472 createdAt: subDays(nowDate, 19),
473 // List should be expired
474 expiresAt: subDays(nowDate, 1),
475 canDisplayChecklist: true,
476 isChecklistFinished: 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();