Skip to content

Commit 99daed4

Browse files
authored
Merge pull request #33 from internxt/feature/pagination
[PB-1961]: feat/add pagination when fetching mails list
2 parents 8ed714f + f830eda commit 99daed4

6 files changed

Lines changed: 247 additions & 33 deletions

File tree

src/features/mail/MailView.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import type { FolderType } from '@/types/mail';
44
import PreviewMail from './components/mail-preview';
55
import TrayList from './components/tray';
66
import Settings from './components/settings';
7-
import { useGetListFolderQuery, useGetMailMessageQuery, useMarkAsReadMutation } from '@/store/api/mail';
7+
import { useGetMailMessageQuery, useMarkAsReadMutation } from '@/store/api/mail';
88
import { DateService } from '@/services/date';
9-
import { DEFAULT_FOLDER_LIMIT } from '@/constants';
109
import { ErrorService } from '@/services/error';
10+
import useListFolderPaginated from '@/hooks/mail/useListFolderPaginated';
1111

1212
interface MailViewProps {
1313
folder: FolderType;
@@ -16,16 +16,8 @@ interface MailViewProps {
1616
const MailView = ({ folder }: MailViewProps) => {
1717
const { translate } = useTranslationContext();
1818
const [activeMailId, setActiveMailId] = useState<string | undefined>(undefined);
19-
const query = {
20-
mailbox: folder,
21-
limit: DEFAULT_FOLDER_LIMIT,
22-
position: 0,
23-
};
2419

25-
const { data: listFolder, isLoading: isLoadingListFolder } = useGetListFolderQuery(query, {
26-
pollingInterval: 30000,
27-
skip: !folder,
28-
});
20+
const { isLoadingListFolder, listFolder, hasMore: hasMoreItems, onLoadMore } = useListFolderPaginated(folder);
2921
const { data: activeMail } = useGetMailMessageQuery({ emailId: activeMailId! }, { skip: !activeMailId });
3022
const [markAsRead] = useMarkAsReadMutation();
3123

@@ -44,7 +36,7 @@ const MailView = ({ folder }: MailViewProps) => {
4436
try {
4537
await markAsRead({
4638
emailId: id,
47-
query,
39+
mailbox: folder,
4840
});
4941
} catch (error) {
5042
const err = ErrorService.instance.castError(error);
@@ -61,6 +53,8 @@ const MailView = ({ folder }: MailViewProps) => {
6153
isLoadingListFolder={isLoadingListFolder}
6254
activeMailId={activeMailId}
6355
onMailSelected={onSelectEmail}
56+
loadMore={onLoadMore}
57+
hasMoreItems={hasMoreItems}
6458
/>
6559
{/* Mail Preview */}
6660
<div className="flex flex-col w-full">

src/features/mail/components/settings/components/account-popover/index.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ interface AccountPopoverProps {
1212
openPreferences: () => void;
1313
}
1414

15-
export default function AccountPopover({
15+
const AccountPopover = ({
1616
className = '',
1717
user,
1818
percentageUsed,
1919
onLogout,
2020
openPreferences,
21-
}: Readonly<AccountPopoverProps>) {
21+
}: Readonly<AccountPopoverProps>) => {
2222
const { translate } = useTranslationContext();
2323
const name = user?.name ?? '';
2424
const lastName = user?.lastname ?? '';
@@ -46,16 +46,13 @@ export default function AccountPopover({
4646
<p className="text-sm text-gray-50">{translate('accountPopover.spaceUsed', { space: percentageUsed })}</p>
4747
</div>
4848
{separator}
49-
<button
50-
className="flex w-full cursor-pointer items-center px-3 py-2 text-gray-80 no-underline hover:bg-gray-1 hover:text-gray-80 dark:hover:bg-gray-10"
51-
onClick={openPreferences}
52-
>
49+
<Item onClick={openPreferences}>
5350
<GearIcon size={20} />
5451
<p className="ml-3">{translate('accountPopover.settings')}</p>
55-
</button>
52+
</Item>
5653
<Item onClick={onLogout}>
5754
<SignOutIcon size={20} />
58-
<p className="ml-3 truncate" data-test="logout">
55+
<p className="ml-3" data-test="logout">
5956
{translate('accountPopover.logout')}
6057
</p>
6158
</Item>
@@ -65,21 +62,22 @@ export default function AccountPopover({
6562
return (
6663
<Popover className={className} childrenButton={avatarWrapper} panel={() => panel} data-test="app-header-dropdown" />
6764
);
68-
}
65+
};
66+
67+
export default AccountPopover;
6968

7069
interface ItemProps {
7170
children: ReactNode;
7271
onClick: () => void;
7372
}
7473

75-
function Item({ children, onClick }: Readonly<ItemProps>) {
74+
const Item = ({ children, onClick }: Readonly<ItemProps>) => {
7675
return (
7776
<button
78-
className="flex cursor-pointer items-center px-3 py-2 text-gray-80 hover:bg-gray-1 dark:hover:bg-gray-10"
79-
style={{ lineHeight: 1.25 }}
8077
onClick={onClick}
78+
className="flex w-full cursor-pointer items-center px-3 py-2 text-gray-80 no-underline hover:bg-gray-1 hover:text-gray-80 dark:hover:bg-gray-10"
8179
>
8280
{children}
8381
</button>
8482
);
85-
}
83+
};
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { renderHook, act, waitFor } from '@testing-library/react';
2+
import { describe, test, expect, vi, beforeEach } from 'vitest';
3+
import { Provider } from 'react-redux';
4+
import type { PropsWithChildren } from 'react';
5+
import useListFolderPaginated from './useListFolderPaginated';
6+
import { MailService } from '@/services/sdk/mail';
7+
import { getMockedMails } from '@/test-utils/fixtures';
8+
import { createTestStore } from '@/test-utils/createTestStore';
9+
import { DEFAULT_FOLDER_LIMIT } from '@/constants';
10+
import type { FolderType } from '@/types/mail';
11+
12+
const createWrapper = (store: ReturnType<typeof createTestStore>) => {
13+
return ({ children }: PropsWithChildren) => <Provider store={store}>{children}</Provider>;
14+
};
15+
16+
describe('List Folder Paginated - custom hook', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
});
20+
21+
test('When a folder is opened, then the first batch of emails is loaded and more are available', async () => {
22+
const page1 = getMockedMails(DEFAULT_FOLDER_LIMIT);
23+
page1.total = DEFAULT_FOLDER_LIMIT * 3;
24+
vi.spyOn(MailService.instance, 'listFolder').mockResolvedValue(page1);
25+
26+
const store = createTestStore();
27+
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
28+
wrapper: createWrapper(store),
29+
});
30+
31+
waitFor(() => {
32+
expect(result.current.isLoadingListFolder).toBe(true);
33+
34+
expect(result.current.listFolder?.emails).toHaveLength(DEFAULT_FOLDER_LIMIT);
35+
expect(result.current.hasMore).toBeTruthy();
36+
});
37+
});
38+
39+
test('When all emails in the folder have been loaded, then no more are available', async () => {
40+
const page = getMockedMails(DEFAULT_FOLDER_LIMIT);
41+
page.total = DEFAULT_FOLDER_LIMIT;
42+
vi.spyOn(MailService.instance, 'listFolder').mockResolvedValue(page);
43+
44+
const store = createTestStore();
45+
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
46+
wrapper: createWrapper(store),
47+
});
48+
49+
expect(result.current.hasMore).toBeFalsy();
50+
});
51+
52+
test('When the user scrolls to the end of the list, then the next batch of emails is loaded and appended', async () => {
53+
const page1 = getMockedMails(DEFAULT_FOLDER_LIMIT);
54+
const page2 = getMockedMails(DEFAULT_FOLDER_LIMIT);
55+
page1.total = DEFAULT_FOLDER_LIMIT * 2;
56+
page2.total = DEFAULT_FOLDER_LIMIT * 2;
57+
58+
vi.spyOn(MailService.instance, 'listFolder').mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);
59+
60+
const store = createTestStore();
61+
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
62+
wrapper: createWrapper(store),
63+
});
64+
65+
act(() => {
66+
result.current.onLoadMore();
67+
});
68+
69+
waitFor(() => {
70+
expect(result.current.listFolder?.emails).toHaveLength(DEFAULT_FOLDER_LIMIT * 2);
71+
expect(result.current.listFolder?.emails).toStrictEqual([...page1.emails, ...page2.emails]);
72+
expect(result.current.hasMore).toBeFalsy();
73+
});
74+
});
75+
76+
test('When the user scrolls while emails are still loading, then no duplicate request is made', async () => {
77+
const page1 = getMockedMails(DEFAULT_FOLDER_LIMIT);
78+
page1.total = DEFAULT_FOLDER_LIMIT * 2;
79+
const listFolderSpy = vi.spyOn(MailService.instance, 'listFolder').mockResolvedValue(page1);
80+
81+
const store = createTestStore();
82+
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
83+
wrapper: createWrapper(store),
84+
});
85+
86+
act(() => {
87+
result.current.onLoadMore();
88+
});
89+
90+
expect(listFolderSpy).toHaveBeenCalledTimes(1);
91+
});
92+
93+
test('When the user navigates to a different folder, then only the emails from the new folder are shown', async () => {
94+
const inboxEmails = getMockedMails(DEFAULT_FOLDER_LIMIT);
95+
const sentEmails = getMockedMails(DEFAULT_FOLDER_LIMIT);
96+
inboxEmails.total = DEFAULT_FOLDER_LIMIT;
97+
sentEmails.total = DEFAULT_FOLDER_LIMIT;
98+
99+
vi.spyOn(MailService.instance, 'listFolder').mockResolvedValueOnce(inboxEmails).mockResolvedValueOnce(sentEmails);
100+
101+
const store = createTestStore();
102+
const { result, rerender } = renderHook(({ mailbox }) => useListFolderPaginated(mailbox), {
103+
initialProps: { mailbox: 'inbox' as FolderType },
104+
wrapper: createWrapper(store),
105+
});
106+
107+
waitFor(() => {
108+
expect(result.current.listFolder?.emails).toStrictEqual(inboxEmails.emails);
109+
});
110+
111+
rerender({ mailbox: 'sent' });
112+
113+
waitFor(() => {
114+
expect(result.current.listFolder?.emails).toStrictEqual(sentEmails.emails);
115+
expect(result.current.listFolder?.emails).toHaveLength(DEFAULT_FOLDER_LIMIT);
116+
});
117+
});
118+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* eslint-disable react-hooks/set-state-in-effect */
2+
import { DEFAULT_FOLDER_LIMIT } from '@/constants';
3+
import { useGetListFolderQuery } from '@/store/api/mail';
4+
import type { FolderType } from '@/types/mail';
5+
import { useEffect, useState } from 'react';
6+
7+
const useListFolderPaginated = (mailbox: FolderType) => {
8+
const [position, setPosition] = useState(0);
9+
10+
useEffect(() => {
11+
setPosition(0);
12+
}, [mailbox]);
13+
14+
const {
15+
data: listFolder,
16+
isLoading: isLoadingListFolder,
17+
isFetching,
18+
} = useGetListFolderQuery(
19+
{
20+
mailbox,
21+
limit: DEFAULT_FOLDER_LIMIT,
22+
position,
23+
},
24+
{
25+
pollingInterval: 30000,
26+
skip: !mailbox,
27+
},
28+
);
29+
30+
const hasMore = (listFolder?.emails.length ?? 0) < (listFolder?.total ?? 0);
31+
32+
const onLoadMore = () => {
33+
if (isFetching || !hasMore) return;
34+
setPosition((prev) => prev + DEFAULT_FOLDER_LIMIT);
35+
};
36+
37+
return {
38+
listFolder,
39+
isLoadingListFolder,
40+
onLoadMore,
41+
hasMore,
42+
};
43+
};
44+
45+
export default useListFolderPaginated;

src/store/api/mail/index.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { api } from '../base';
22
import { FetchMailboxesInfoError, FetchMessageError, FetchListFolderError, UpdateMailError } from '@/errors';
33
import { ErrorService } from '@/services/error';
44
import { MailService } from '@/services/sdk/mail';
5+
import type { FolderType } from '@/types/mail';
56
import type { EmailListResponse, EmailResponse, ListEmailsQuery, MailboxResponse } from '@internxt/sdk';
67

78
export const mailApi = api.injectEndpoints({
@@ -19,6 +20,20 @@ export const mailApi = api.injectEndpoints({
1920
providesTags: ['Mailbox'],
2021
}),
2122
getListFolder: builder.query<EmailListResponse, ListEmailsQuery>({
23+
serializeQueryArgs: ({ queryArgs }) => ({ mailbox: queryArgs?.mailbox }),
24+
merge: (currentCache, newItems, { arg }) => {
25+
const currentPosition = arg?.position ?? 0;
26+
27+
// This prevents the concatenation of the existent cached emails with the new ones (repeated emails)
28+
if (currentPosition === 0) {
29+
currentCache.emails = newItems.emails;
30+
} else {
31+
currentCache.emails.push(...newItems.emails);
32+
}
33+
currentCache.total = newItems.total;
34+
},
35+
forceRefetch: ({ currentArg, previousArg }) =>
36+
currentArg?.mailbox !== previousArg?.mailbox || currentArg?.position !== previousArg?.position,
2237
async queryFn(query) {
2338
try {
2439
const mailList = await MailService.instance.listFolder(query);
@@ -42,7 +57,7 @@ export const mailApi = api.injectEndpoints({
4257
},
4358
providesTags: ['MailMessage'],
4459
}),
45-
markAsRead: builder.mutation<null, { emailId: string; query: ListEmailsQuery }>({
60+
markAsRead: builder.mutation<null, { emailId: string; mailbox: string }>({
4661
async queryFn({ emailId }) {
4762
try {
4863
await MailService.instance.updateEmailStatus(emailId, { isRead: true });
@@ -52,11 +67,11 @@ export const mailApi = api.injectEndpoints({
5267
return { error: new UpdateMailError(err.message, 'markAsRead', err.requestId) };
5368
}
5469
},
55-
async onQueryStarted({ emailId, query }, { dispatch, queryFulfilled }) {
70+
async onQueryStarted({ emailId, mailbox }, { dispatch, queryFulfilled }) {
5671
let shouldUpdateUnreadCount = false;
5772

5873
const patchEmailList = dispatch(
59-
mailApi.util.updateQueryData('getListFolder', query, (draft) => {
74+
mailApi.util.updateQueryData('getListFolder', { mailbox: mailbox as FolderType }, (draft) => {
6075
const mail = draft.emails.find((m) => m.id === emailId);
6176
if (mail && !mail.isRead) {
6277
mail.isRead = true;
@@ -69,7 +84,7 @@ export const mailApi = api.injectEndpoints({
6984

7085
const patchMailboxes = dispatch(
7186
mailApi.util.updateQueryData('getMailboxesInfo', undefined, (draft) => {
72-
const entry = draft.find((m) => m.type === query?.mailbox);
87+
const entry = draft.find((m) => m.type === mailbox);
7388
if (entry) entry.unreadEmails = Math.max(0, entry.unreadEmails - 1);
7489
}),
7590
);

0 commit comments

Comments
 (0)