fix: name folders in settings as the sidebar does

Folders & tags and the All mail folder list showed the server's names
("Sent Items", "Junk Mail", "Deleted Items") with a second chip naming
the role. They now use the sidebar's names (Sent, Spam, Trash) through
the same helper, and the chip is gone.

The role-label test asserted the chip; it now asserts the sidebar names
and that no chip is drawn. The bulk-move test found the Inbox row by
its server name, which the row no longer shows.
This commit is contained in:
Linus Rath
2026-11-04 08:08:04 +01:00
parent 5a020cf2f9
commit 51beeb957b
5 changed files with 129 additions and 28 deletions
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import { MultipleAccountsSection } from '../layout-settings';
// settings-9 (usability pass 2026-11-04): the folders that feed All mail are
// named as in the sidebar, not by the server ("Sent Items", "Junk Mail").
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const mailbox = (id: string, name: string, role?: string) =>
({ id, name, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, isShared: false, myRights: {} } as unknown as Mailbox);
const initial = {
account: useAccountStore.getState(),
email: useEmailStore.getState(),
policy: usePolicyStore.getState(),
settings: useSettingsStore.getState(),
};
beforeEach(() => {
useAccountStore.setState({
accounts: [
{ id: 'a', email: 'a@example.org', displayName: 'A', isConnected: true },
{ id: 'b', email: 'b@example.org', displayName: 'B', isConnected: true },
],
activeAccountId: 'a',
} as never);
useEmailStore.setState({
mailboxes: [mailbox('sent', 'Sent Items', 'sent'), mailbox('junk', 'Junk Mail', 'junk'), mailbox('work', 'Work')],
} as never);
usePolicyStore.setState({
isSettingLocked: () => false,
isSettingHidden: () => false,
isFeatureEnabled: () => true,
} as never);
useSettingsStore.setState({ enableUnifiedMailbox: true, enableCrossAllView: true, allMailFolderIds: {} });
});
afterEach(() => {
useAccountStore.setState(initial.account, true);
useEmailStore.setState(initial.email, true);
usePolicyStore.setState(initial.policy, true);
useSettingsStore.setState(initial.settings, true);
});
describe('the All mail folder list', () => {
it('names role folders as the sidebar does', () => {
render(<MultipleAccountsSection />);
expect(screen.getByText('mailboxes.sent')).toBeInTheDocument();
expect(screen.getByText('mailboxes.spam')).toBeInTheDocument();
expect(screen.getByText('Work')).toBeInTheDocument();
expect(screen.queryByText('Sent Items')).toBeNull();
expect(screen.queryByText('Junk Mail')).toBeNull();
});
});
@@ -84,7 +84,8 @@ describe('FolderSettings bulk move (#1173)', () => {
fireEvent.click(checkboxFor('A3'), { shiftKey: true });
expect(['A1', 'A2', 'A3'].map(isTicked)).toEqual([true, true, true]);
expect(['Inbox', 'A4', 'Old mailbox'].map(isTicked)).toEqual([false, false, false]);
// The Inbox row carries the sidebar's name (mailboxes.inbox under the key-echo mock).
expect(['mailboxes.inbox', 'A4', 'Old mailbox'].map(isTicked)).toEqual([false, false, false]);
expect(screen.getByText('selected_count')).toBeTruthy();
});
@@ -5,8 +5,9 @@ import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Mailbox } from '@/lib/jmap/types';
// #495: Stalwart's Scheduled mailbox carries role "scheduled"; the folder list
// renders `role_<role>` and the key must exist (see translations.test.ts).
// Folders with a role are named as in the sidebar (settings-9, 2026-11-04):
// the server's "Sent Items" or "Junk Mail" never shows, and no second chip
// repeats the role next to the name.
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
@@ -39,21 +40,51 @@ function makeMailbox(overrides: Partial<Mailbox>): Mailbox {
} as Mailbox;
}
describe('FolderSettings role label for the Scheduled mailbox (#495)', () => {
describe('FolderSettings names role folders as the sidebar does', () => {
beforeEach(() => {
useAuthStore.setState({ client: null } as never);
useEmailStore.setState({
mailboxes: [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sched', name: 'Scheduled', role: 'scheduled' as never }),
makeMailbox({ id: 'sent', name: 'Sent Items', role: 'sent', sortOrder: 1 }),
makeMailbox({ id: 'junk', name: 'Junk Mail', role: 'junk', sortOrder: 2 }),
makeMailbox({ id: 'trash', name: 'Deleted Items', role: 'trash', sortOrder: 3 }),
makeMailbox({ id: 'sched', name: 'Scheduled', role: 'scheduled' as never, sortOrder: 4 }),
makeMailbox({ id: 'work', name: 'Work', sortOrder: 5 }),
],
fetchMailboxes: vi.fn(),
} as never);
});
it('renders the role badge through the role_scheduled key', () => {
it('shows the sidebar name for role folders, never the server name', () => {
render(<FolderSettings />);
// The test i18n mock echoes the key, so the badge shows the key itself.
expect(screen.getByText('role_scheduled')).toBeInTheDocument();
// The test i18n mock echoes the key: the sidebar's mailboxes.<role> keys.
const rowName = (text: string) => screen.getAllByText(text).find(el => el.tagName === 'SPAN');
expect(rowName('mailboxes.sent')).toBeTruthy();
expect(rowName('mailboxes.spam')).toBeTruthy();
expect(rowName('mailboxes.trash')).toBeTruthy();
for (const serverName of ['Sent Items', 'Junk Mail', 'Deleted Items']) {
expect(screen.queryByText(serverName)).toBeNull();
expect(screen.queryByTitle(serverName)).toBeNull();
}
// Folders the sidebar has no name for keep their own.
expect(rowName('Work')).toBeTruthy();
expect(rowName('Scheduled')).toBeTruthy();
});
it('draws no role chip next to the name', () => {
render(<FolderSettings />);
// role_<role> is only the label of its row in Standard folder roles.
for (const role of ['inbox', 'sent', 'junk', 'trash']) {
expect(screen.getAllByText(`role_${role}`)).toHaveLength(1);
}
expect(screen.queryByText('role_scheduled')).toBeNull();
});
it('offers the sidebar names in the move-to and role pickers', () => {
render(<FolderSettings />);
const options = Array.from(document.querySelectorAll('option')).map(o => o.textContent);
expect(options).not.toContain('Sent Items');
expect(options).not.toContain('Junk Mail');
});
});
+21 -19
View File
@@ -40,6 +40,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { isFolderCountMode } from '@/lib/folder-counts';
import { localizeMailboxName } from '@/lib/mailbox-label';
// Must match the per-level indentation used in the row styles below: dragging
// a folder this many pixels right/left targets one nesting level deeper/higher.
@@ -164,6 +165,11 @@ function SortableFolderRow({ id, title, children }: { id: string; title: string;
export function FolderSettings() {
const t = useTranslations('settings.folders');
const tSidebar = useTranslations('sidebar');
// Folders with a role read as in the sidebar (Sent, Spam, Trash), never
// by the server's name ("Sent Items", "Junk Mail").
const folderName = (mb: { role?: string | null; name: string }): string =>
localizeMailboxName(mb.role, mb.name, (k) => tSidebar(`mailboxes.${k}`));
const { client } = useAuthStore();
const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes, moveMailbox, moveMailboxes } = useEmailStore();
@@ -304,15 +310,15 @@ export function FolderSettings() {
};
moveRoots.forEach(blockSubtree);
const getFolderPath = (mb: { parentId?: string; name: string }): string => {
const parts = [mb.name];
const getFolderPath = (mb: { parentId?: string; name: string; role?: string | null }): string => {
const parts = [folderName(mb)];
const seen = new Set<string>();
let parentId = mb.parentId;
while (parentId && !seen.has(parentId)) {
seen.add(parentId);
const parent = ownMailboxes.find(p => p.id === parentId);
if (!parent) break;
parts.unshift(parent.name);
parts.unshift(folderName(parent));
parentId = parent.parentId;
}
return parts.join(' / ');
@@ -515,7 +521,8 @@ export function FolderSettings() {
const renderCreateInline = (parentId: string | null, depth: number) => {
if (!isCreating || creatingParentId !== parentId) return null;
const parentName = parentId ? ownMailboxes.find(m => m.id === parentId)?.name : null;
const parent = parentId ? ownMailboxes.find(m => m.id === parentId) : undefined;
const parentName = parent ? folderName(parent) : null;
return (
<div
className="flex flex-wrap items-center gap-2 mt-1 p-2.5 bg-muted/30 rounded-md border border-border"
@@ -637,7 +644,7 @@ export function FolderSettings() {
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t('select_folder', { name: mb.name })}
aria-label={t('select_folder', { name: folderName(mb) })}
title={t('select_hint')}
// Keep Shift+click from selecting the page text in between.
onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }}
@@ -658,7 +665,7 @@ export function FolderSettings() {
{/* Expand/collapse toggle for folders with children */}
{hasChildren ? (
<IconButton
label={isExpanded ? t('collapse', { name: mb.name }) : t('expand', { name: mb.name })}
label={isExpanded ? t('collapse', { name: folderName(mb) }) : t('expand', { name: folderName(mb) })}
aria-expanded={isExpanded}
onClick={() => toggleExpanded(mb.id)}
className="h-5 w-5 text-muted-foreground flex-shrink-0"
@@ -707,12 +714,7 @@ export function FolderSettings() {
/>
)}
</div>
<span className="text-sm text-foreground truncate" title={mb.name} dir="auto">{mb.name}</span>
{mb.role && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
{t(`role_${mb.role}`)}
</span>
)}
<span className="text-sm text-foreground truncate" title={folderName(mb)} dir="auto">{folderName(mb)}</span>
{mb.unreadEmails > 0 && (
<span className="text-xs tabular-nums px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground font-medium flex-shrink-0">
{mb.unreadEmails}
@@ -722,7 +724,7 @@ export function FolderSettings() {
{/* Phone: the counter and the row actions don't fit next to the
name, so they sit behind a ⋮ (right-click opens it too). */}
<IconButton
label={t('actions', { name: mb.name })}
label={t('actions', { name: folderName(mb) })}
className="sm:hidden flex-shrink-0 text-muted-foreground"
onClick={(e) => openContextMenuFrom(e.currentTarget, mb, 'end')}
>
@@ -732,7 +734,7 @@ export function FolderSettings() {
<Select
value={folderCountModes?.[mb.id] ?? ''}
onChange={(value) => setCountMode(mb.id, value)}
ariaLabel={t('count_mode', { name: mb.name })}
ariaLabel={t('count_mode', { name: folderName(mb) })}
className="px-1.5 py-0.5 text-xs max-w-[9rem] pointer-coarse:max-w-[12rem]"
options={countModeOptions}
/>
@@ -906,11 +908,11 @@ export function FolderSettings() {
const orderedMailboxes = flattenMailboxTree(folderTree);
// Disambiguate duplicate folder names by appending parent path
const nameCounts = new Map<string, number>();
orderedMailboxes.forEach(mb => nameCounts.set(mb.name, (nameCounts.get(mb.name) || 0) + 1));
orderedMailboxes.forEach(mb => nameCounts.set(folderName(mb), (nameCounts.get(folderName(mb)) || 0) + 1));
const getParentPath = (mb: { parentId?: string; name: string }) => {
if (!mb.parentId) return '';
const parent = ownMailboxes.find(p => p.id === mb.parentId);
return parent ? `${parent.name}/` : '';
return parent ? `${folderName(parent)}/` : '';
};
return (
@@ -922,9 +924,9 @@ export function FolderSettings() {
{ value: '', label: t('role_none') },
...orderedMailboxes.map(mb => ({
value: mb.id,
label: (nameCounts.get(mb.name) || 0) > 1
? `${getParentPath(mb)}${mb.name} (${mb.id.slice(-6)})`
: mb.name,
label: (nameCounts.get(folderName(mb)) || 0) > 1
? `${getParentPath(mb)}${folderName(mb)} (${mb.id.slice(-6)})`
: folderName(mb),
})),
]}
/>
+5 -1
View File
@@ -10,6 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
import { hiddenSharedMailIds, setSharedMailAccountHidden } from '@/lib/shared-mail-visibility';
import { localizeMailboxName } from '@/lib/mailbox-label';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -238,6 +239,7 @@ export function SidebarSection() {
*/
export function MultipleAccountsSection() {
const t = useTranslations('settings.appearance');
const tSidebar = useTranslations('sidebar');
const { enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, tintListRowsByAccount, showAccountDot, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
@@ -391,7 +393,9 @@ export function MultipleAccountsSection() {
{checked && <Check className="w-3 h-3" />}
</span>
<Folder className={cn("w-4 h-4 flex-shrink-0", mb.role ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm text-foreground truncate">{mb.name}</span>
<span className="text-sm text-foreground truncate">
{localizeMailboxName(mb.role, mb.name, (k) => tSidebar(`mailboxes.${k}`))}
</span>
</button>
);
})}