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
1 change: 1 addition & 0 deletions src/features/mail/MailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const MailView = ({ folder }: MailViewProps) => {
selectRead,
selectUnread,
deleteEmails: (emailIds) => deleteEmails({ emailIds, sourceMailbox: folder }).unwrap(),
moveToFolder: (args) => moveToFolder(args).unwrap(),
});
const previewActions = usePreviewMailActions({
activeMailId,
Expand Down
8 changes: 7 additions & 1 deletion src/hooks/mail/useActionsBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isCurrentPath } from '@/utils/current-path';
import { useLocation } from 'react-router-dom';
import { useCallback, useMemo } from 'react';
import type { FolderType } from '@/types/mail';
import { TrayIcon, WarningOctagonIcon, TrashIcon } from '@phosphor-icons/react';
import { ArchiveIcon, TrayIcon, WarningOctagonIcon, TrashIcon } from '@phosphor-icons/react';
import type { MenuItemType } from '@internxt/ui';

interface UseActionsBarParams {
Expand Down Expand Up @@ -36,6 +36,12 @@ export const useActionsBar = ({
icon: TrayIcon,
onClick: () => onMove('inbox'),
},
{
disabled: () => isActive('/archive') || optionsDisabled,
name: translate('mail.archive'),
icon: ArchiveIcon,
onClick: () => onMove('archive'),
},
{
disabled: () => isActive('/spam') || optionsDisabled,
name: translate('mail.spam'),
Expand Down
137 changes: 121 additions & 16 deletions src/hooks/mail/useListActionContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,22 @@ vi.mock('@/i18n', () => ({
}),
}));

const showMock = vi.fn();
vi.mock('@/services/notifications', () => ({
__esModule: true,
default: {
show: (...args: unknown[]) => showMock(...args),
},
ToastType: { Success: 'success', Error: 'error', Warning: 'warning', Info: 'info', Loading: 'loading' },
}));

const makeParams = () => ({
selectAll: vi.fn(),
selectNone: vi.fn(),
selectRead: vi.fn(),
selectUnread: vi.fn(),
deleteEmails: vi.fn().mockResolvedValue(null),
moveToFolder: vi.fn().mockResolvedValue(null),
});

const renderFor = (folder: FolderType, selectedMails: string[] = []) => {
Expand All @@ -29,6 +39,12 @@ const renderFor = (folder: FolderType, selectedMails: string[] = []) => {
return { result, params };
};

const findByName = (items: ReturnType<typeof getItems>, name: string) => {
const item = items.find((i) => i.name === name);
if (!item) throw new Error(`Bulk item ${name} not found`);
return item;
};

describe('List actions - custom hook', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -41,24 +57,37 @@ describe('List actions - custom hook', () => {
expect(names).toEqual(['filter.all', 'filter.none', 'filter.read', 'filter.unread']);
});

test('When the folder is inbox, then it returns a trashAll bulk action', () => {
test('When the folder is inbox, then it offers move to archive, spam and trash', () => {
const { result } = renderFor('inbox');
const bulk = getItems(result.current.bulkActionContext);
expect(bulk).toHaveLength(1);
expect(bulk[0].name).toBe('actions.trashAll');
const names = getItems(result.current.bulkActionContext).map((a) => a.name);
expect(names).toEqual(['actions.moveAllToArchive', 'actions.moveAllToSpam', 'actions.moveAllToTrash']);
});

test('When trashAll is triggered, then deleteEmails is called with all selected mail ids', async () => {
test('When move-to-trash is triggered from inbox, then deleteEmails is called with all selected ids', async () => {
const ids = Array.from({ length: 25 }, (_, i) => `mail-${i}`);
const { result, params } = renderFor('inbox', ids);
const bulk = getItems(result.current.bulkActionContext);

await bulk[0].action?.(undefined);
await findByName(bulk, 'actions.moveAllToTrash').action?.(undefined);

expect(params.deleteEmails).toHaveBeenCalledOnce();
expect(params.deleteEmails).toHaveBeenCalledWith(ids);
});

test('When move-to-archive is triggered from inbox, then moveToFolder is called with target archive', async () => {
const ids = ['mail-1', 'mail-2'];
const { result, params } = renderFor('inbox', ids);
const bulk = getItems(result.current.bulkActionContext);

await findByName(bulk, 'actions.moveAllToArchive').action?.(undefined);

expect(params.moveToFolder).toHaveBeenCalledWith({
emailIds: ids,
sourceMailbox: 'inbox',
targetMailbox: 'archive',
});
});

test('When the "All" action is triggered, then it only selects all emails without filtering', () => {
const { result, params } = renderFor('inbox');
getItems(result.current.listActionContext)[0].action?.(undefined);
Expand All @@ -83,11 +112,81 @@ describe('List actions - custom hook', () => {
expect(params.selectUnread).toHaveBeenCalledOnce();
});

test('When trashAll completes, then it clears the selection', async () => {
test('When a bulk move completes, then it clears the selection', async () => {
const ids = ['mail-1', 'mail-2'];
const { result, params } = renderFor('inbox', ids);
const bulk = getItems(result.current.bulkActionContext);
await findByName(bulk, 'actions.moveAllToTrash').action?.(undefined);
expect(params.selectNone).toHaveBeenCalledOnce();
});

test('When a bulk move completes, then it shows a toast with an undo action', async () => {
const ids = ['mail-1', 'mail-2'];
const { result } = renderFor('inbox', ids);
const bulk = getItems(result.current.bulkActionContext);

await findByName(bulk, 'actions.moveAllToTrash').action?.(undefined);

expect(showMock).toHaveBeenCalledOnce();
const arg = showMock.mock.calls[0][0];
expect(arg.text).toBe('toastNotification.movedToFolder_many');
expect(arg.action.text).toBe('toastNotification.undo');
expect(typeof arg.action.onClick).toBe('function');
});

test('When a single conversation is bulk-moved, then the singular toast key is used', async () => {
const { result } = renderFor('inbox', ['mail-1']);
const bulk = getItems(result.current.bulkActionContext);

await findByName(bulk, 'actions.moveAllToTrash').action?.(undefined);

expect(showMock.mock.calls[0][0].text).toBe('toastNotification.movedToFolder_one');
});

test('When the toast undo is triggered, then it moves emails back from the target folder to each source group', async () => {
const ids = ['mail-1', 'mail-2'];
const { result, params } = renderFor('inbox', ids);
const bulk = getItems(result.current.bulkActionContext);
await bulk[0].action?.(undefined);

await findByName(bulk, 'actions.moveAllToSpam').action?.(undefined);
const arg = showMock.mock.calls[0][0];
params.moveToFolder.mockClear();
arg.action.onClick();

expect(params.moveToFolder).toHaveBeenCalledWith({
emailIds: ids,
sourceMailbox: 'spam',
targetMailbox: 'inbox',
});
});

test('When move-to-trash undo fires, then emails are moved back from trash to the original folder', async () => {
const ids = ['mail-1'];
const { result, params } = renderFor('inbox', ids);
const bulk = getItems(result.current.bulkActionContext);

await findByName(bulk, 'actions.moveAllToTrash').action?.(undefined);
const arg = showMock.mock.calls[0][0];
arg.action.onClick();

expect(params.moveToFolder).toHaveBeenCalledWith({
emailIds: ids,
sourceMailbox: 'trash',
targetMailbox: 'inbox',
});
});

test('When the bulk move fails, then an error toast is shown and selection is cleared', async () => {
const ids = ['mail-1'];
const params = makeParams();
params.deleteEmails = vi.fn().mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useListActionContext('inbox', ids, params));
const bulk = getItems(result.current.bulkActionContext);

await findByName(bulk, 'actions.moveAllToTrash').action?.(undefined);

expect(showMock).toHaveBeenCalledOnce();
expect(showMock.mock.calls[0][0]).toMatchObject({ text: 'errors.mail.trash', type: 'error' });
expect(params.selectNone).toHaveBeenCalledOnce();
Comment on lines +146 to 190
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add coverage for the other new rejection branches.

This only asserts the inbox → trash failure path. The new undo .catch(...) and the emptyTrashBulkAction error toast are still untested, so regressions there will slip through.

As per coding guidelines, "src/**/*.test.{ts,tsx}: Test the purpose and behavior of code, covering happy path, edge cases, error/rejection paths, and boundary values."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/mail/useListActionContext.test.ts` around lines 146 - 190, The
tests only cover the inbox→trash failure path; add tests exercising the undo
rejection branches and the emptyTrashBulkAction error path by mocking the
promise rejections and asserting the correct error toasts and cleanup calls: for
useListActionContext, add a test where the undo handler (the .catch branch after
actions.moveAllToSpam/ actions.moveAllToTrash undo) rejects (mock
params.moveToFolder to reject) and assert showMock was called with the
appropriate error message and params.selectNone was invoked, and add a test
where invoking emptyTrashBulkAction (or params.emptyTrash) rejects and assert
showMock received the empty-trash error toast and selection cleared; use
existing helpers (renderFor/renderHook, getItems, findByName, showMock, params)
to locate and trigger the actions and verify the expected calls.

});
});
Expand All @@ -112,11 +211,10 @@ describe('List actions - custom hook', () => {
expect(names).toEqual(['filter.all', 'filter.none']);
});

test('When the folder is drafts, then it returns a trashAll bulk action', () => {
test('When the folder is drafts, then it offers only move to trash', () => {
const { result } = renderFor('drafts');
const bulk = getItems(result.current.bulkActionContext);
expect(bulk).toHaveLength(1);
expect(bulk[0].name).toBe('actions.trashAll');
const names = getItems(result.current.bulkActionContext).map((a) => a.name);
expect(names).toEqual(['actions.moveAllToTrash']);
});
});

Expand All @@ -142,11 +240,18 @@ describe('List actions - custom hook', () => {
expect(names).toEqual(['filter.all', 'filter.none', 'filter.read', 'filter.unread']);
});

test('When the folder is spam, then it returns a trashAll bulk action', () => {
test('When the folder is spam, then it offers move to inbox, archive and trash', () => {
const { result } = renderFor('spam');
const bulk = getItems(result.current.bulkActionContext);
expect(bulk).toHaveLength(1);
expect(bulk[0].name).toBe('actions.trashAll');
const names = getItems(result.current.bulkActionContext).map((a) => a.name);
expect(names).toEqual(['actions.moveAllToInbox', 'actions.moveAllToArchive', 'actions.moveAllToTrash']);
});
});

describe('archive', () => {
test('When the folder is archive, then it offers move to inbox, spam and trash', () => {
const { result } = renderFor('archive');
const names = getItems(result.current.bulkActionContext).map((a) => a.name);
expect(names).toEqual(['actions.moveAllToInbox', 'actions.moveAllToSpam', 'actions.moveAllToTrash']);
});
});
});
Loading
Loading