diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index 8947f4d..8af252b 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -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, diff --git a/src/hooks/mail/useActionsBar.ts b/src/hooks/mail/useActionsBar.ts index 505bfc7..1d25e87 100644 --- a/src/hooks/mail/useActionsBar.ts +++ b/src/hooks/mail/useActionsBar.ts @@ -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 { @@ -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'), diff --git a/src/hooks/mail/useListActionContext.test.ts b/src/hooks/mail/useListActionContext.test.ts index 251e200..8095fc9 100644 --- a/src/hooks/mail/useListActionContext.test.ts +++ b/src/hooks/mail/useListActionContext.test.ts @@ -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[] = []) => { @@ -29,6 +39,12 @@ const renderFor = (folder: FolderType, selectedMails: string[] = []) => { return { result, params }; }; +const findByName = (items: ReturnType, 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(); @@ -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); @@ -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(); }); }); @@ -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']); }); }); @@ -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']); }); }); }); diff --git a/src/hooks/mail/useListActionContext.ts b/src/hooks/mail/useListActionContext.ts index 6d77e3e..b12af35 100644 --- a/src/hooks/mail/useListActionContext.ts +++ b/src/hooks/mail/useListActionContext.ts @@ -1,7 +1,14 @@ import { useTranslationContext } from '@/i18n'; +import type { TranslationKey } from '@/i18n/types'; +import notificationsService, { ToastType } from '@/services/notifications'; import type { FolderType } from '@/types/mail'; import { type MenuItemType } from '@internxt/ui'; -import { TrashIcon } from '@phosphor-icons/react'; +import { ArchiveIcon, TrashIcon, TrayIcon, WarningOctagonIcon } from '@phosphor-icons/react'; +import { useCallback, useMemo } from 'react'; + +type SourceGroup = { emailIds: string[]; sourceMailbox: FolderType }; + +type MoveTarget = Exclude; interface UseListActionContextParams { selectAll: () => void; @@ -9,6 +16,11 @@ interface UseListActionContextParams { selectRead: () => void; selectUnread: () => void; deleteEmails: (emailIds: string[]) => Promise; + moveToFolder: (args: { + emailIds: string[]; + sourceMailbox: FolderType; + targetMailbox: FolderType; + }) => Promise; } interface UseListActionContextResult { @@ -16,53 +28,143 @@ interface UseListActionContextResult { bulkActionContext: MenuItemType[]; } +const MOVE_TARGETS_BY_SOURCE: Record = { + inbox: ['archive', 'spam', 'trash'], + spam: ['inbox', 'archive', 'trash'], + drafts: ['trash'], + archive: ['inbox', 'spam', 'trash'], + sent: [], + trash: [], +}; + +const TARGET_META: Record = { + inbox: { label: 'actions.moveAllToInbox', folderName: 'mail.inbox', icon: TrayIcon }, + archive: { label: 'actions.moveAllToArchive', folderName: 'mail.archive', icon: ArchiveIcon }, + spam: { label: 'actions.moveAllToSpam', folderName: 'mail.spam', icon: WarningOctagonIcon }, + trash: { label: 'actions.moveAllToTrash', folderName: 'mail.trash', icon: TrashIcon }, +}; + +const NO_BULK_ACTIONS: MenuItemType[] = []; + export const useListActionContext = ( folder: FolderType, selectedMails: string[], - { selectAll, selectNone, selectRead, selectUnread, deleteEmails }: UseListActionContextParams, + { selectAll, selectNone, selectRead, selectUnread, deleteEmails, moveToFolder }: UseListActionContextParams, ): UseListActionContextResult => { const { translate } = useTranslationContext(); + const selectedCount = selectedMails.length; + const isSelectionEmpty = useCallback(() => selectedCount === 0, [selectedCount]); - const baseSelectionActions: MenuItemType[] = [ - { name: translate('filter.all'), action: selectAll }, - { name: translate('filter.none'), action: selectNone }, - ]; - - const inboxSelectionActions: MenuItemType[] = [ - ...baseSelectionActions, - { name: translate('filter.read'), action: selectRead }, - { name: translate('filter.unread'), action: selectUnread }, - ]; - - const trashBulkAction: MenuItemType = { - name: translate('actions.trashAll'), - action: async () => { - await deleteEmails(selectedMails); - selectNone(); + const baseSelectionActions = useMemo[]>( + () => [ + { name: translate('filter.all'), action: selectAll }, + { name: translate('filter.none'), action: selectNone }, + ], + [translate, selectAll, selectNone], + ); + + const inboxSelectionActions = useMemo[]>( + () => [ + ...baseSelectionActions, + { name: translate('filter.read'), action: selectRead }, + { name: translate('filter.unread'), action: selectUnread }, + ], + [baseSelectionActions, translate, selectRead, selectUnread], + ); + + const showMovedToast = useCallback( + (groups: SourceGroup[], target: MoveTarget) => { + const totalCount = groups.reduce((acc, g) => acc + g.emailIds.length, 0); + const folderName = translate(TARGET_META[target].folderName); + const text = + totalCount === 1 + ? translate('toastNotification.movedToFolder_one', { folder: folderName }) + : translate('toastNotification.movedToFolder_many', { count: totalCount, folder: folderName }); + notificationsService.show({ + text, + type: ToastType.Success, + action: { + text: translate('toastNotification.undo'), + onClick: () => { + groups.forEach((g) => { + moveToFolder({ + emailIds: g.emailIds, + sourceMailbox: target, + targetMailbox: g.sourceMailbox, + }).catch(() => { + notificationsService.show({ text: translate('errors.mail.move'), type: ToastType.Error }); + }); + }); + }, + }, + }); }, - icon: TrashIcon, - disabled: () => selectedMails.length === 0, - }; - - const emptyTrashBulkAction: MenuItemType = { - name: translate('actions.emptyTrash'), - action: async () => { - await deleteEmails(selectedMails); - selectNone(); + [translate, moveToFolder], + ); + + const performBulkMove = useCallback( + async (target: MoveTarget) => { + const ids = [...selectedMails]; + if (ids.length === 0) return; + const groups: SourceGroup[] = [{ emailIds: ids, sourceMailbox: folder }]; + try { + if (target === 'trash') { + await deleteEmails(ids); + } else { + await moveToFolder({ emailIds: ids, sourceMailbox: folder, targetMailbox: target }); + } + showMovedToast(groups, target); + } catch { + const key = target === 'trash' ? 'errors.mail.trash' : 'errors.mail.move'; + notificationsService.show({ text: translate(key), type: ToastType.Error }); + } finally { + selectNone(); + } }, - icon: TrashIcon, - disabled: () => selectedMails.length === 0, - }; + [selectedMails, folder, deleteEmails, moveToFolder, selectNone, showMovedToast], + ); + + const moveBulkActions = useMemo[]>( + () => + MOVE_TARGETS_BY_SOURCE[folder].map((target) => ({ + name: translate(TARGET_META[target].label), + icon: TARGET_META[target].icon, + disabled: isSelectionEmpty, + action: () => performBulkMove(target), + })), + [folder, translate, isSelectionEmpty, performBulkMove], + ); + + const emptyTrashBulkAction = useMemo>( + () => ({ + name: translate('actions.emptyTrash'), + action: async () => { + try { + await deleteEmails(selectedMails); + } catch { + notificationsService.show({ text: translate('errors.mail.trash'), type: ToastType.Error }); + } finally { + selectNone(); + } + }, + icon: TrashIcon, + disabled: isSelectionEmpty, + }), + [translate, deleteEmails, selectedMails, selectNone, isSelectionEmpty], + ); + + const emptyTrashBulkActions = useMemo(() => [emptyTrashBulkAction], [emptyTrashBulkAction]); switch (folder) { case 'inbox': case 'spam': - return { listActionContext: inboxSelectionActions, bulkActionContext: [trashBulkAction] }; + case 'archive': + return { listActionContext: inboxSelectionActions, bulkActionContext: moveBulkActions }; case 'sent': - return { listActionContext: baseSelectionActions, bulkActionContext: [] }; + return { listActionContext: baseSelectionActions, bulkActionContext: NO_BULK_ACTIONS }; case 'drafts': - return { listActionContext: baseSelectionActions, bulkActionContext: [trashBulkAction] }; + return { listActionContext: baseSelectionActions, bulkActionContext: moveBulkActions }; case 'trash': - return { listActionContext: baseSelectionActions, bulkActionContext: [emptyTrashBulkAction] }; + return { listActionContext: baseSelectionActions, bulkActionContext: emptyTrashBulkActions }; } }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 358cf24..24c2783 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -14,9 +14,11 @@ "move": "Move", "markAsRead": "Mark as read", "markAsUnread": "Mark as unread", - "trashAll": "Move all to trash", + "moveAllToInbox": "Move all to inbox", + "moveAllToArchive": "Move all to archive", + "moveAllToSpam": "Move all to spam", + "moveAllToTrash": "Move all to trash", "emptyTrash": "Empty trash", - "archiveAll": "Move all to archive", "upgrade": "Upgrade", "send": "Send" }, @@ -102,6 +104,7 @@ "sent": "Sent", "spam": "Spam", "trash": "Trash", + "archive": "Archive", "tray": { "isEmpty": "{{folderName}} is empty" }, @@ -215,6 +218,9 @@ "logout": "Log out" }, "toastNotification": { - "textCopied": "Copied to clipboard" + "textCopied": "Copied to clipboard", + "movedToFolder_one": "Conversation moved to \"{{folder}}\"", + "movedToFolder_many": "{{count}} conversations moved to \"{{folder}}\"", + "undo": "Undo" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e15a940..1def3b2 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -14,9 +14,11 @@ "move": "Mover", "markAsRead": "Marcar como leído", "markAsUnread": "Marcar como no leído", - "trashAll": "Mover todo a la papelera", + "moveAllToInbox": "Mover todo a la bandeja de entrada", + "moveAllToArchive": "Mover todo al archivo", + "moveAllToSpam": "Mover todo a spam", + "moveAllToTrash": "Mover todo a la papelera", "emptyTrash": "Vaciar papelera", - "archiveAll": "Mover todo al archivo", "upgrade": "Mejorar plan", "send": "Enviar" }, @@ -104,6 +106,7 @@ "sent": "Enviados", "spam": "Spam", "trash": "Papelera", + "archive": "Archivo", "tray": { "isEmpty": "{{folderName}} está vacía" }, @@ -217,6 +220,9 @@ "logout": "Cerrar sesión" }, "toastNotification": { - "textCopied": "Copiado al portapapeles" + "textCopied": "Copiado al portapapeles", + "movedToFolder_one": "Conversación movida a \"{{folder}}\"", + "movedToFolder_many": "{{count}} conversaciones movidas a \"{{folder}}\"", + "undo": "Deshacer" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index caa92a9..54fa91c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -14,9 +14,11 @@ "move": "Déplacer", "markAsRead": "Marquer comme lu", "markAsUnread": "Marquer comme non lu", - "trashAll": "Tout déplacer vers la corbeille", + "moveAllToInbox": "Tout déplacer vers la boîte de réception", + "moveAllToArchive": "Tout déplacer vers les archives", + "moveAllToSpam": "Tout déplacer vers les spams", + "moveAllToTrash": "Tout déplacer vers la corbeille", "emptyTrash": "Vider la corbeille", - "archiveAll": "Tout déplacer vers les archives", "upgrade": "Mettre à niveau", "send": "Envoyer" }, @@ -104,6 +106,7 @@ "sent": "Envoyés", "spam": "Spam", "trash": "Corbeille", + "archive": "Archives", "tray": { "isEmpty": "{{folderName}} est vide" }, @@ -217,6 +220,9 @@ "logout": "Se déconnecter" }, "toastNotification": { - "textCopied": "Copié dans le presse-papiers" + "textCopied": "Copié dans le presse-papiers", + "movedToFolder_one": "Conversation déplacée vers « {{folder}} »", + "movedToFolder_many": "{{count}} conversations déplacées vers « {{folder}} »", + "undo": "Annuler" } } \ No newline at end of file diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index e28f40c..b4fc6e3 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -14,9 +14,11 @@ "move": "Sposta", "markAsRead": "Segna come letto", "markAsUnread": "Segna come non letto", - "trashAll": "Sposta tutto nel cestino", + "moveAllToInbox": "Sposta tutto nella posta in arrivo", + "moveAllToArchive": "Sposta tutto nell'archivio", + "moveAllToSpam": "Sposta tutto nello spam", + "moveAllToTrash": "Sposta tutto nel cestino", "emptyTrash": "Svuota il cestino", - "archiveAll": "Sposta tutto nell'archivio", "upgrade": "Aggiorna piano", "send": "Invia" }, @@ -104,6 +106,7 @@ "sent": "Inviati", "spam": "Spam", "trash": "Cestino", + "archive": "Archivio", "tray": { "isEmpty": "{{folderName}} è vuota" }, @@ -217,6 +220,9 @@ "logout": "Esci" }, "toastNotification": { - "textCopied": "Copiato negli appunti" + "textCopied": "Copiato negli appunti", + "movedToFolder_one": "Conversazione spostata in \"{{folder}}\"", + "movedToFolder_many": "{{count}} conversazioni spostate in \"{{folder}}\"", + "undo": "Annulla" } -} +} \ No newline at end of file diff --git a/src/types/mail/index.ts b/src/types/mail/index.ts index 3578940..74e96a5 100644 --- a/src/types/mail/index.ts +++ b/src/types/mail/index.ts @@ -1 +1 @@ -export type FolderType = 'inbox' | 'sent' | 'drafts' | 'spam' | 'trash'; +export type FolderType = 'inbox' | 'sent' | 'drafts' | 'spam' | 'trash' | 'archive';