diff --git a/components/email/__tests__/email-list-plugin-filters.test.tsx b/components/email/__tests__/email-list-plugin-filters.test.tsx new file mode 100644 index 000000000..4e9c534b0 --- /dev/null +++ b/components/email/__tests__/email-list-plugin-filters.test.tsx @@ -0,0 +1,84 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailList } from '../email-list'; +import { useEmailStore } from '@/stores/email-store'; +import { resetListFilters, setPluginListFilters } from '@/lib/plugin-sandbox/list-filters'; +import { confirmGroups, resetConfirmedGroups } from '@/lib/plugin-sandbox/account-kind'; +import type { Mailbox } from '@/lib/jmap/types'; + +// The plugins' list filter chips in the message list: shown in a team +// folder's own list, loading the list again when one is turned on, and not +// in a search, a tag view or the merged views. + +const client = vi.hoisted(() => ({ + getAccountId: () => 'ci', + getAccountInfo: (id: string) => (id === 'ch' ? { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: {} } : null), + getSharedAccounts: () => [ + { id: 'ci', name: 'org-alice@example.org', isPrimary: true, capabilities: { mail: true } }, + { id: 'ch', name: 'org-team@example.org', isPrimary: false, capabilities: { mail: true } }, + ], + getPrincipalTypes: async () => ({ ch: 'group' }), +})); + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ + client, identities: [], username: null, activeAccountId: 'acct-1', + getClientForAccount: () => client, getAllConnectedClients: () => new Map(), + })), + }; +}); +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined }; + return { + useAccountStore: Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const teamInbox = { id: 'ch:a', originalId: 'a', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'ch', totalEmails: 0, unreadEmails: 0, sortOrder: 0 } as unknown as Mailbox; +const initialEmailState = useEmailStore.getState(); +const fetchEmails = vi.fn(async () => {}); + +beforeEach(async () => { + resetListFilters(); + resetConfirmedGroups(); + fetchEmails.mockClear(); + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ selectedMailbox: 'ch:a', mailboxes: [teamInbox], fetchEmails }); + setPluginListFilters('team-inbox', 'Team inbox', [{ id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'] }]); + await confirmGroups(client as never); +}); + +describe('plugin list filters in the message list', () => { + it('shows the chip in a team folder and loads the list when it is turned on and off', async () => { + render(); + const chip = await screen.findByRole('button', { name: 'Open' }); + expect(fetchEmails).not.toHaveBeenCalled(); + act(() => { fireEvent.click(chip); }); + expect(chip).toHaveAttribute('aria-pressed', 'true'); + expect(fetchEmails).toHaveBeenCalledTimes(1); + expect(fetchEmails).toHaveBeenCalledWith(client); + act(() => { fireEvent.click(chip); }); + expect(fetchEmails).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['a search', { searchQuery: 'invoice' }], + ['a tag view', { selectedKeyword: 'work' }], + ['the merged views', { isUnifiedView: true }], + ])('leaves %s without chips', async (_name, state) => { + useEmailStore.setState(state); + render(); + await act(async () => {}); + expect(screen.queryByTestId('plugin-list-filters')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/plugin-list-filters.test.tsx b/components/email/__tests__/plugin-list-filters.test.tsx new file mode 100644 index 000000000..99be089c9 --- /dev/null +++ b/components/email/__tests__/plugin-list-filters.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { PluginListFilters } from '../plugin-list-filters'; +import { + activeListFilterCondition, clearPluginListFilters, resetListFilters, setPluginListFilters, +} from '@/lib/plugin-sandbox/list-filters'; + +// The chips plugins' list filters get in the list header (api.ui.setListFilters). +// The global next-intl mock returns the key, so labels read as keys. + +const TEAM = { role: 'inbox', accountKind: 'group' as const }; + +beforeEach(() => { + resetListFilters(); + setPluginListFilters('team-inbox', 'Team inbox', [ + { id: 'mine', label: 'Assigned to me', icon: 'user', hasKeyword: '$assigned:ci', accounts: ['group'], order: 1 }, + { id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'], order: 2 }, + ]); +}); + +describe('PluginListFilters', () => { + it('shows the chips of a team folder, off at first', () => { + render( {}} />); + const chips = screen.getAllByRole('button'); + expect(chips.map((c) => c.textContent)).toEqual(['Assigned to me', 'Open']); + expect(chips.every((c) => c.getAttribute('aria-pressed') === 'false')).toBe(true); + expect(screen.getByRole('group')).toHaveAttribute('aria-label', 'filters'); + }); + + it('shows nothing in the user\'s own folders', () => { + const { container } = render( {}} />); + expect(container).toBeEmptyDOMElement(); + }); + + it('turns a filter on with a click, loads the list, and removes it with the next', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + // The name stays the label; pressed says it's on, the tooltip how to remove it. + const active = screen.getByRole('button', { name: 'Open' }); + expect(active).toHaveAttribute('aria-pressed', 'true'); + expect(active).toHaveAttribute('title', 'remove_filter'); + expect(active.querySelector('svg')).not.toBeNull(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(activeListFilterCondition(TEAM)).toEqual({ notKeyword: '$done' }); + + fireEvent.click(active); + expect(screen.getByRole('button', { name: 'Open' })).toHaveAttribute('aria-pressed', 'false'); + expect(onChange).toHaveBeenCalledTimes(2); + expect(activeListFilterCondition(TEAM)).toBeNull(); + }); + + it('loads the list again when the plugin that offered an active filter goes away', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + onChange.mockClear(); + + act(() => clearPluginListFilters('team-inbox')); + expect(screen.queryByRole('group')).toBeNull(); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx index 149d9a559..5db64285b 100644 --- a/components/email/__tests__/plugin-row-badges.test.tsx +++ b/components/email/__tests__/plugin-row-badges.test.tsx @@ -89,6 +89,22 @@ describe('plugin badges in message-list rows', () => { expect(ctx.email).not.toHaveProperty('mailboxIds.inbox'); }); + it('draws a badge\'s icon before its label, and none for a name that isn\'t one', async () => { + renderHooks.onEmailListItemRender.register('test', async () => [ + { key: 'done', label: 'Done', icon: 'check' }, + { key: 'bad', label: 'Bad', icon: 'x">