Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions src/features/mail/MailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import type { FolderType } from '@/types/mail';
import PreviewMail from './components/mail-preview';
import TrayList from './components/tray';
import Settings from './components/settings';
import { useGetListFolderQuery, useGetMailMessageQuery, useMarkAsReadMutation } from '@/store/api/mail';
import { useGetMailMessageQuery, useMarkAsReadMutation } from '@/store/api/mail';
import { DateService } from '@/services/date';
import { DEFAULT_FOLDER_LIMIT } from '@/constants';
import { ErrorService } from '@/services/error';
import useListFolderPaginated from '@/hooks/mail/useListFolderPaginated';

interface MailViewProps {
folder: FolderType;
Expand All @@ -16,16 +16,8 @@ interface MailViewProps {
const MailView = ({ folder }: MailViewProps) => {
const { translate } = useTranslationContext();
const [activeMailId, setActiveMailId] = useState<string | undefined>(undefined);
const query = {
mailbox: folder,
limit: DEFAULT_FOLDER_LIMIT,
position: 0,
};

const { data: listFolder, isLoading: isLoadingListFolder } = useGetListFolderQuery(query, {
pollingInterval: 30000,
skip: !folder,
});
const { isLoadingListFolder, listFolder, hasMore: hasMoreItems, onLoadMore } = useListFolderPaginated(folder);
const { data: activeMail } = useGetMailMessageQuery({ emailId: activeMailId! }, { skip: !activeMailId });
const [markAsRead] = useMarkAsReadMutation();

Expand All @@ -44,7 +36,7 @@ const MailView = ({ folder }: MailViewProps) => {
try {
await markAsRead({
emailId: id,
query,
mailbox: folder,
});
} catch (error) {
const err = ErrorService.instance.castError(error);
Expand All @@ -61,6 +53,8 @@ const MailView = ({ folder }: MailViewProps) => {
isLoadingListFolder={isLoadingListFolder}
activeMailId={activeMailId}
onMailSelected={onSelectEmail}
loadMore={onLoadMore}
hasMoreItems={hasMoreItems}
/>
{/* Mail Preview */}
<div className="flex flex-col w-full">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ interface AccountPopoverProps {
openPreferences: () => void;
}

export default function AccountPopover({
const AccountPopover = ({
className = '',
user,
percentageUsed,
onLogout,
openPreferences,
}: Readonly<AccountPopoverProps>) {
}: Readonly<AccountPopoverProps>) => {
const { translate } = useTranslationContext();
const name = user?.name ?? '';
const lastName = user?.lastname ?? '';
Expand Down Expand Up @@ -46,16 +46,13 @@ export default function AccountPopover({
<p className="text-sm text-gray-50">{translate('accountPopover.spaceUsed', { space: percentageUsed })}</p>
</div>
{separator}
<button
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"
onClick={openPreferences}
>
<Item onClick={openPreferences}>
<GearIcon size={20} />
<p className="ml-3">{translate('accountPopover.settings')}</p>
</button>
</Item>
<Item onClick={onLogout}>
<SignOutIcon size={20} />
<p className="ml-3 truncate" data-test="logout">
<p className="ml-3" data-test="logout">
{translate('accountPopover.logout')}
</p>
</Item>
Expand All @@ -65,21 +62,22 @@ export default function AccountPopover({
return (
<Popover className={className} childrenButton={avatarWrapper} panel={() => panel} data-test="app-header-dropdown" />
);
}
};

export default AccountPopover;

interface ItemProps {
children: ReactNode;
onClick: () => void;
}

function Item({ children, onClick }: Readonly<ItemProps>) {
const Item = ({ children, onClick }: Readonly<ItemProps>) => {
return (
<button
className="flex cursor-pointer items-center px-3 py-2 text-gray-80 hover:bg-gray-1 dark:hover:bg-gray-10"
style={{ lineHeight: 1.25 }}
onClick={onClick}
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"
>
{children}
</button>
);
}
};
118 changes: 118 additions & 0 deletions src/hooks/mail/useListFolderPaginated.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { Provider } from 'react-redux';
import type { PropsWithChildren } from 'react';
import useListFolderPaginated from './useListFolderPaginated';
import { MailService } from '@/services/sdk/mail';
import { getMockedMails } from '@/test-utils/fixtures';
import { createTestStore } from '@/test-utils/createTestStore';
import { DEFAULT_FOLDER_LIMIT } from '@/constants';
import type { FolderType } from '@/types/mail';

const createWrapper = (store: ReturnType<typeof createTestStore>) => {
return ({ children }: PropsWithChildren) => <Provider store={store}>{children}</Provider>;
};

describe('List Folder Paginated - custom hook', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('When a folder is opened, then the first batch of emails is loaded and more are available', async () => {
const page1 = getMockedMails(DEFAULT_FOLDER_LIMIT);
page1.total = DEFAULT_FOLDER_LIMIT * 3;
vi.spyOn(MailService.instance, 'listFolder').mockResolvedValue(page1);

const store = createTestStore();
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
wrapper: createWrapper(store),
});

waitFor(() => {
expect(result.current.isLoadingListFolder).toBe(true);

expect(result.current.listFolder?.emails).toHaveLength(DEFAULT_FOLDER_LIMIT);
expect(result.current.hasMore).toBeTruthy();
});
});

test('When all emails in the folder have been loaded, then no more are available', async () => {
const page = getMockedMails(DEFAULT_FOLDER_LIMIT);
page.total = DEFAULT_FOLDER_LIMIT;
vi.spyOn(MailService.instance, 'listFolder').mockResolvedValue(page);

const store = createTestStore();
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
wrapper: createWrapper(store),
});

expect(result.current.hasMore).toBeFalsy();
});

test('When the user scrolls to the end of the list, then the next batch of emails is loaded and appended', async () => {
const page1 = getMockedMails(DEFAULT_FOLDER_LIMIT);
const page2 = getMockedMails(DEFAULT_FOLDER_LIMIT);
page1.total = DEFAULT_FOLDER_LIMIT * 2;
page2.total = DEFAULT_FOLDER_LIMIT * 2;

vi.spyOn(MailService.instance, 'listFolder').mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);

const store = createTestStore();
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
wrapper: createWrapper(store),
});

act(() => {
result.current.onLoadMore();
});

waitFor(() => {
expect(result.current.listFolder?.emails).toHaveLength(DEFAULT_FOLDER_LIMIT * 2);
expect(result.current.listFolder?.emails).toStrictEqual([...page1.emails, ...page2.emails]);
expect(result.current.hasMore).toBeFalsy();
});
});

test('When the user scrolls while emails are still loading, then no duplicate request is made', async () => {
const page1 = getMockedMails(DEFAULT_FOLDER_LIMIT);
page1.total = DEFAULT_FOLDER_LIMIT * 2;
const listFolderSpy = vi.spyOn(MailService.instance, 'listFolder').mockResolvedValue(page1);

const store = createTestStore();
const { result } = renderHook(() => useListFolderPaginated('inbox'), {
wrapper: createWrapper(store),
});

act(() => {
result.current.onLoadMore();
});

expect(listFolderSpy).toHaveBeenCalledTimes(1);
});

test('When the user navigates to a different folder, then only the emails from the new folder are shown', async () => {
const inboxEmails = getMockedMails(DEFAULT_FOLDER_LIMIT);
const sentEmails = getMockedMails(DEFAULT_FOLDER_LIMIT);
inboxEmails.total = DEFAULT_FOLDER_LIMIT;
sentEmails.total = DEFAULT_FOLDER_LIMIT;

vi.spyOn(MailService.instance, 'listFolder').mockResolvedValueOnce(inboxEmails).mockResolvedValueOnce(sentEmails);

const store = createTestStore();
const { result, rerender } = renderHook(({ mailbox }) => useListFolderPaginated(mailbox), {
initialProps: { mailbox: 'inbox' as FolderType },
wrapper: createWrapper(store),
});

waitFor(() => {
expect(result.current.listFolder?.emails).toStrictEqual(inboxEmails.emails);
});

rerender({ mailbox: 'sent' });

waitFor(() => {
expect(result.current.listFolder?.emails).toStrictEqual(sentEmails.emails);
expect(result.current.listFolder?.emails).toHaveLength(DEFAULT_FOLDER_LIMIT);
});
});
});
45 changes: 45 additions & 0 deletions src/hooks/mail/useListFolderPaginated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { DEFAULT_FOLDER_LIMIT } from '@/constants';
import { useGetListFolderQuery } from '@/store/api/mail';
import type { FolderType } from '@/types/mail';
import { useEffect, useState } from 'react';

const useListFolderPaginated = (mailbox: FolderType) => {
const [position, setPosition] = useState(0);

useEffect(() => {
setPosition(0);
}, [mailbox]);

const {
data: listFolder,
isLoading: isLoadingListFolder,
isFetching,
} = useGetListFolderQuery(
{
mailbox,
limit: DEFAULT_FOLDER_LIMIT,
position,
},
{
pollingInterval: 30000,
skip: !mailbox,
},
);

const hasMore = (listFolder?.emails.length ?? 0) < (listFolder?.total ?? 0);

const onLoadMore = () => {
if (isFetching || !hasMore) return;
setPosition((prev) => prev + DEFAULT_FOLDER_LIMIT);
};

return {
listFolder,
isLoadingListFolder,
onLoadMore,
hasMore,
};
};

export default useListFolderPaginated;
23 changes: 19 additions & 4 deletions src/store/api/mail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { api } from '../base';
import { FetchMailboxesInfoError, FetchMessageError, FetchListFolderError, UpdateMailError } from '@/errors';
import { ErrorService } from '@/services/error';
import { MailService } from '@/services/sdk/mail';
import type { FolderType } from '@/types/mail';
import type { EmailListResponse, EmailResponse, ListEmailsQuery, MailboxResponse } from '@internxt/sdk';

export const mailApi = api.injectEndpoints({
Expand All @@ -19,6 +20,20 @@ export const mailApi = api.injectEndpoints({
providesTags: ['Mailbox'],
}),
getListFolder: builder.query<EmailListResponse, ListEmailsQuery>({
serializeQueryArgs: ({ queryArgs }) => ({ mailbox: queryArgs?.mailbox }),
merge: (currentCache, newItems, { arg }) => {
const currentPosition = arg?.position ?? 0;

// This prevents the concatenation of the existent cached emails with the new ones (repeated emails)
if (currentPosition === 0) {
currentCache.emails = newItems.emails;
} else {
currentCache.emails.push(...newItems.emails);
}
currentCache.total = newItems.total;
},
forceRefetch: ({ currentArg, previousArg }) =>
currentArg?.mailbox !== previousArg?.mailbox || currentArg?.position !== previousArg?.position,
async queryFn(query) {
try {
const mailList = await MailService.instance.listFolder(query);
Expand All @@ -42,7 +57,7 @@ export const mailApi = api.injectEndpoints({
},
providesTags: ['MailMessage'],
}),
markAsRead: builder.mutation<null, { emailId: string; query: ListEmailsQuery }>({
markAsRead: builder.mutation<null, { emailId: string; mailbox: string }>({
async queryFn({ emailId }) {
try {
await MailService.instance.updateEmailStatus(emailId, { isRead: true });
Expand All @@ -52,11 +67,11 @@ export const mailApi = api.injectEndpoints({
return { error: new UpdateMailError(err.message, 'markAsRead', err.requestId) };
}
},
async onQueryStarted({ emailId, query }, { dispatch, queryFulfilled }) {
async onQueryStarted({ emailId, mailbox }, { dispatch, queryFulfilled }) {
let shouldUpdateUnreadCount = false;

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

const patchMailboxes = dispatch(
mailApi.util.updateQueryData('getMailboxesInfo', undefined, (draft) => {
const entry = draft.find((m) => m.type === query?.mailbox);
const entry = draft.find((m) => m.type === mailbox);
if (entry) entry.unreadEmails = Math.max(0, entry.unreadEmails - 1);
}),
);
Expand Down
Loading
Loading