mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user