feat: list your own folders in a separate section of the sidebar #1205

This commit is contained in:
Linus Rath
2026-10-17 19:18:22 +02:00
parent 8583c9efef
commit edf675b2f4
29 changed files with 325 additions and 3 deletions
@@ -0,0 +1,150 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// #1205: the folders the user made get a "Your folders" section of their own
// below the system folders, instead of being mixed in with Inbox, Sent and
// Trash. Users with no folders of their own see no change.
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/contexts/drag-drop-context', () => ({
useDragDropContext: () => ({ isDragging: false }),
}));
vi.mock('@/hooks/use-mailbox-drop', () => ({
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
}));
vi.mock('@/hooks/use-tag-drop', () => ({
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
}));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const { Sidebar } = await import('../sidebar');
const { useSettingsStore } = await import('@/stores/settings-store');
const { useUIStore } = await import('@/stores/ui-store');
const { useAccountStore } = await import('@/stores/account-store');
const { useAuthStore } = await import('@/stores/auth-store');
const box = (id: string, name: string, role: string | null, sortOrder: number, parentId: string | null = null) =>
({ id, name, role, parentId, totalEmails: 0, unreadEmails: 0, sortOrder, myRights: { mayAddItems: true } });
const systemOnly = [
box('inbox', 'Inbox', 'inbox', 0),
box('drafts', 'Drafts', 'drafts', 1),
box('sent', 'Sent', 'sent', 2),
box('trash', 'Trash', 'trash', 3),
];
const withCustom = [
...systemOnly,
box('receipts', 'Receipts', null, 4),
box('projects', 'Projects', null, 5),
box('inbox-sub', 'Newsletters', null, 6, 'inbox'),
] as unknown as Mailbox[];
const folderRow = (name: string) => document.querySelector(`[data-folder-name="${name}"]`);
const inYourFolders = (name: string) =>
document.querySelector(`[data-testid="section-your-folders"] [data-folder-name="${name}"]`);
describe('Sidebar "Your folders" section (#1205)', () => {
beforeEach(() => {
localStorage.clear();
useUIStore.setState({ sidebarCollapsed: false } as never);
useAccountStore.setState({ accounts: [] } as never);
useSettingsStore.setState({
emailKeywords: [],
enableUnifiedMailbox: false,
hideAccountSwitcher: true,
} as never);
});
it('lists the folders you made under their own heading, apart from the system folders', () => {
render(<Sidebar mailboxes={withCustom} selectedMailbox="inbox" />);
expect(screen.getByText('your_folders')).toBeInTheDocument();
expect(inYourFolders('Receipts')).not.toBeNull();
expect(inYourFolders('Projects')).not.toBeNull();
for (const name of ['Inbox', 'Drafts', 'Sent', 'Trash']) {
expect(folderRow(name)).not.toBeNull();
expect(inYourFolders(name)).toBeNull();
}
// The system folders all come before the first folder of your own.
const rows = [...document.querySelectorAll('[data-folder-name]')].map((r) => r.getAttribute('data-folder-name'));
expect(rows.indexOf('Trash')).toBeLessThan(rows.indexOf('Receipts'));
});
it('keeps a folder nested under a system folder with its parent', () => {
localStorage.setItem('expandedMailboxes', JSON.stringify(['inbox']));
render(<Sidebar mailboxes={withCustom} selectedMailbox="inbox" />);
expect(folderRow('Newsletters')).not.toBeNull();
expect(inYourFolders('Newsletters')).toBeNull();
});
it('shows no section when there are no folders of your own', () => {
render(<Sidebar mailboxes={systemOnly as unknown as Mailbox[]} selectedMailbox="inbox" />);
expect(screen.queryByText('your_folders')).toBeNull();
expect(document.querySelector('[data-testid="section-your-folders"]')).toBeNull();
expect(folderRow('Trash')).not.toBeNull();
});
it('collapses on its own and remembers it', () => {
render(<Sidebar mailboxes={withCustom} selectedMailbox="inbox" />);
fireEvent.click(screen.getByText('your_folders'));
expect(folderRow('Receipts')).toBeNull();
// The system folders are untouched.
expect(folderRow('Inbox')).not.toBeNull();
cleanup();
render(<Sidebar mailboxes={withCustom} selectedMailbox="inbox" />);
expect(screen.getByText('your_folders')).toBeInTheDocument();
expect(folderRow('Receipts')).toBeNull();
fireEvent.click(screen.getByText('your_folders'));
expect(folderRow('Receipts')).not.toBeNull();
});
it('creates a new folder from the heading', () => {
const onCreateFolder = vi.fn();
render(<Sidebar mailboxes={withCustom} selectedMailbox="inbox" onCreateFolder={onCreateFolder} />);
fireEvent.click(screen.getByLabelText('create_folder'));
expect(onCreateFolder).toHaveBeenCalledTimes(1);
// The click does not also fold the section away.
expect(folderRow('Receipts')).not.toBeNull();
});
it('leaves Mountain View with its Labels list', () => {
render(<Sidebar mailboxes={withCustom} selectedMailbox="inbox" mvShell />);
expect(screen.queryByText('your_folders')).toBeNull();
expect(document.querySelector('[data-mv-labels] [data-folder-name="Receipts"]')).not.toBeNull();
});
it('splits every account group in the Pro layout', () => {
const account = (id: string) => ({
id, label: `${id}@example.com`, email: `${id}@example.com`, username: `${id}@example.com`,
cookieSlot: id === 'a' ? 0 : 1, isConnected: true,
});
useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
useAuthStore.setState({ activeAccountId: 'a' } as never);
const other = [box('b-inbox', 'Inbox', 'inbox', 0), box('b-clients', 'Clients', null, 1)] as unknown as Mailbox[];
render(
<Sidebar mailboxes={withCustom} selectedMailbox="inbox" multiAccountMode accountMailboxes={{ b: other }} />,
);
expect(screen.getAllByText('your_folders')).toHaveLength(2);
expect(inYourFolders('Receipts')).not.toBeNull();
expect(inYourFolders('Clients')).not.toBeNull();
expect(inYourFolders('Inbox')).toBeNull();
// Each account's section folds on its own.
fireEvent.click(screen.getByTestId('section-your-folders-header-b'));
expect(folderRow('Clients')).toBeNull();
expect(folderRow('Receipts')).not.toBeNull();
});
});
+121 -3
View File
@@ -447,6 +447,11 @@ function SidebarRow({
);
}
/** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */
const isSystemFolder = (node: MailboxNode) => !!node.role;
/** Key of the single-account "Your folders" section in the collapsed set. */
const OWN_FOLDERS_KEY = '__own__';
function SidebarSectionHeader({
label,
expanded,
@@ -460,12 +465,16 @@ function SidebarSectionHeader({
testId,
onContextMenu,
status,
onAdd,
addTitle,
}: {
label: string;
expanded: boolean;
onToggle: () => void;
onSettings?: () => void;
settingsTitle?: string;
onAdd?: () => void;
addTitle?: string;
isCollapsed: boolean;
first?: boolean;
icon?: ReactNode;
@@ -508,6 +517,28 @@ function SidebarSectionHeader({
{label}
</span>
{status}
{onAdd && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onAdd();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onAdd();
}
}}
className="ms-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={addTitle}
aria-label={addTitle}
>
<Plus className="w-3.5 h-3.5" />
</span>
)}
{onSettings && (
<span
role="button"
@@ -523,7 +554,7 @@ function SidebarSectionHeader({
onSettings();
}
}}
className="ms-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
className={cn(!onAdd && "ms-auto", "p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer")}
title={settingsTitle}
>
<Settings className="w-3.5 h-3.5" />
@@ -966,6 +997,15 @@ function SidebarImpl({
} catch { /* fall through */ }
return new Set();
});
// "Your folders" sections the user has collapsed: OWN_FOLDERS_KEY for the
// single-account list, the accountId for each Pro account group.
const [collapsedCustomFolders, setCollapsedCustomFolders] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem('sidebarCollapsedCustomFolders');
if (stored !== null) return new Set(JSON.parse(stored) as string[]);
} catch { /* fall through */ }
return new Set();
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const isEmbedded = useIsEmbedded();
@@ -1102,7 +1142,12 @@ function SidebarImpl({
const mvFlat = mvShell && !isCollapsed;
const isPrimaryFolder = (node: MailboxNode) =>
node.role === 'inbox' || node.role === 'sent' || node.role === 'drafts';
const primaryTree = mvFlat ? ownTree.filter(isPrimaryFolder) : ownTree;
// Everywhere else the folders the user made sit in a "Your folders" section
// of their own below the system folders (#1205). Folders nested under a
// system folder stay with their parent. With no folders of your own there is
// no section, so the list looks as it always did.
const primaryTree = mvFlat ? ownTree.filter(isPrimaryFolder) : ownTree.filter(isSystemFolder);
const customTree = mvFlat ? [] : ownTree.filter((node) => !isSystemFolder(node));
const secondaryTree = mvFlat ? ownTree.filter((node) => !isPrimaryFolder(node)) : [];
// Of those, the system folders (archive, spam, trash...) go behind "More",
// and the folders the user made are listed with the tags under "Labels",
@@ -1185,6 +1230,59 @@ function SidebarImpl({
);
};
// The "Your folders" section: the user's own top-level folders, under a
// heading of their own that collapses and offers to make a new one.
const renderCustomFolders = (
key: string,
tree: MailboxNode[],
{
sub = false,
selected,
onSelect,
active = true,
dragAccountId,
}: {
sub?: boolean;
selected: string;
onSelect?: (mailboxId: string) => void;
active?: boolean;
dragAccountId?: string | null;
},
) => {
if (tree.length === 0) return null;
const expanded = !collapsedCustomFolders.has(key);
return (
// A Pro account group already carries the folders menu for the whole group.
<div data-testid="section-your-folders" onContextMenu={active && !sub ? handleFoldersHeaderContextMenu : undefined}>
<SidebarSectionHeader
label={t("your_folders")}
expanded={expanded}
onToggle={() => toggleCustomFolders(key)}
onAdd={active && onCreateFolder ? () => onCreateFolder() : undefined}
addTitle={t("create_folder")}
isCollapsed={isCollapsed}
sub={sub}
testId={`section-your-folders-header-${key}`}
/>
{((expanded && !isCollapsed) || isCollapsed) && tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selected}
expandedFolders={expandedFolders}
onMailboxSelect={onSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={active ? handleMailboxContextMenu : undefined}
dragAccountId={dragAccountId}
/>
))}
</div>
);
};
const getUnifiedIcon = (role: UnifiedMailboxRole) => {
switch (role) {
case 'inbox': return Inbox;
@@ -1278,6 +1376,14 @@ function SidebarImpl({
return next;
});
};
const toggleCustomFolders = (key: string) => {
setCollapsedCustomFolders((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
try { localStorage.setItem('sidebarCollapsedCustomFolders', JSON.stringify(Array.from(next))); } catch { /* */ }
return next;
});
};
// The tab now travels in the URL (#733) rather than through sessionStorage,
// so a gear click is a link the user can also bookmark or share. It still
@@ -1516,7 +1622,7 @@ function SidebarImpl({
</div>
) : (
<>
{tree.map((node) => (
{tree.filter(isSystemFolder).map((node) => (
<Fragment key={node.id}>
<MailboxTreeItem
node={node}
@@ -1536,6 +1642,13 @@ function SidebarImpl({
</Fragment>
))}
{isActive && !tree.some((node) => node.role === 'drafts') && renderScheduledRow(`scheduled-${account.id}`)}
{renderCustomFolders(account.id, tree.filter((node) => !isSystemFolder(node)), {
sub: true,
selected: selectedKeyword || !isViewing ? "" : selectedMailbox,
onSelect: (mailboxId) => onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId),
active: isActive,
dragAccountId: isActive ? null : account.id,
})}
</>
)}
</>
@@ -1628,6 +1741,11 @@ function SidebarImpl({
</div>
)}
{!useMultiAccount && renderCustomFolders(OWN_FOLDERS_KEY, customTree, {
selected: selectedKeyword ? "" : selectedMailbox,
onSelect: onMailboxSelect,
})}
{mvFlat && !useMultiAccount && (
<div data-mv-labels="">
<div className="flex items-center justify-between ps-[26px] pe-3 pt-4 pb-1">
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "إظهار الكل ({count})",
"show_fewer_tags": "إظهار أقل",
"folders": "المجلدات",
"your_folders": "مجلداتك",
"create_folder": "إنشاء مجلد",
"show_more": "المزيد",
"show_fewer": "أقل",
"shared": "مشترك",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Mostra-ho tot ({count})",
"show_fewer_tags": "Mostra'n menys",
"folders": "Carpetes",
"your_folders": "Les teves carpetes",
"create_folder": "Crea una carpeta",
"show_more": "Més",
"show_fewer": "Menys",
"shared": "Compartit",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Zobrazit vše ({count})",
"show_fewer_tags": "Zobrazit méně",
"folders": "Složky",
"your_folders": "Vaše složky",
"create_folder": "Vytvořit složku",
"show_more": "Více",
"show_fewer": "Méně",
"shared": "Sdílené",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Vis alle ({count})",
"show_fewer_tags": "Vis færre",
"folders": "Mapper",
"your_folders": "Dine mapper",
"create_folder": "Opret mappe",
"show_more": "Mere",
"show_fewer": "Mindre",
"shared": "Delt",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Alle anzeigen ({count})",
"show_fewer_tags": "Weniger anzeigen",
"folders": "Ordner",
"your_folders": "Eigene Ordner",
"create_folder": "Ordner erstellen",
"show_more": "Mehr",
"show_fewer": "Weniger",
"mail": "E-Mail",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Show all ({count})",
"show_fewer_tags": "Show less",
"folders": "Folders",
"your_folders": "Your folders",
"create_folder": "Create folder",
"show_more": "More",
"show_fewer": "Less",
"shared": "Shared",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Mostrar todo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Carpetas",
"your_folders": "Tus carpetas",
"create_folder": "Crear carpeta",
"show_more": "Más",
"show_fewer": "Menos",
"mail": "Correo",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "نمایش همه ({count})",
"show_fewer_tags": "نمایش کمتر",
"folders": "پوشه‌ها",
"your_folders": "پوشه‌های شما",
"create_folder": "ایجاد پوشه",
"show_more": "بیشتر",
"show_fewer": "کمتر",
"shared": "اشتراکی",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Tout afficher ({count})",
"show_fewer_tags": "Afficher moins",
"folders": "Dossiers",
"your_folders": "Vos dossiers",
"create_folder": "Créer un dossier",
"show_more": "Plus",
"show_fewer": "Moins",
"mail": "Messagerie",
+2
View File
@@ -133,6 +133,8 @@
"show_all_tags": "הצג הכל ({count})",
"show_fewer_tags": "הצג פחות",
"folders": "תיקיות",
"your_folders": "התיקיות שלך",
"create_folder": "יצירת תיקייה",
"show_more": "עוד",
"show_fewer": "פחות",
"mail": "דוֹאַר",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Összes megjelenítése ({count})",
"show_fewer_tags": "Kevesebb megjelenítése",
"folders": "Mappák",
"your_folders": "Saját mappák",
"create_folder": "Mappa létrehozása",
"show_more": "Több",
"show_fewer": "Kevesebb",
"shared": "Megosztott",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Mostra tutto ({count})",
"show_fewer_tags": "Mostra meno",
"folders": "Cartelle",
"your_folders": "Le tue cartelle",
"create_folder": "Crea cartella",
"show_more": "Altro",
"show_fewer": "Riduci",
"mail": "Posta",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "すべて表示({count})",
"show_fewer_tags": "表示を減らす",
"folders": "フォルダ",
"your_folders": "マイフォルダ",
"create_folder": "フォルダを作成",
"show_more": "もっと見る",
"show_fewer": "折りたたむ",
"mail": "メール",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "전체 보기({count})",
"show_fewer_tags": "간략히 보기",
"folders": "폴더",
"your_folders": "내 폴더",
"create_folder": "폴더 만들기",
"show_more": "더보기",
"show_fewer": "간략히",
"mail": "메일",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Rādīt visus ({count})",
"show_fewer_tags": "Rādīt mazāk",
"folders": "Mapes",
"your_folders": "Jūsu mapes",
"create_folder": "Izveidot mapi",
"show_more": "Vairāk",
"show_fewer": "Mazāk",
"mail": "Pasts",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Бүгдийг нь харуулах ({count})",
"show_fewer_tags": "Бага харуулах",
"folders": "Хавтас",
"your_folders": "Таны хавтаснууд",
"create_folder": "Хавтас үүсгэх",
"show_more": "Илүү",
"show_fewer": "Бага",
"shared": "Хуваалцсан",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Vis alle ({count})",
"show_fewer_tags": "Vis færre",
"folders": "Mapper",
"your_folders": "Dine mapper",
"create_folder": "Opprett mappe",
"show_more": "Mer",
"show_fewer": "Mindre",
"shared": "Delt",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Alles tonen ({count})",
"show_fewer_tags": "Minder tonen",
"folders": "Mappen",
"your_folders": "Je mappen",
"create_folder": "Map maken",
"show_more": "Meer",
"show_fewer": "Minder",
"mail": "E-mail",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Pokaż wszystkie ({count})",
"show_fewer_tags": "Pokaż mniej",
"folders": "Foldery",
"your_folders": "Twoje foldery",
"create_folder": "Utwórz folder",
"show_more": "Więcej",
"show_fewer": "Mniej",
"mail": "Poczta",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Mostrar tudo ({count})",
"show_fewer_tags": "Mostrar menos",
"folders": "Pastas",
"your_folders": "As suas pastas",
"create_folder": "Criar pasta",
"show_more": "Mais",
"show_fewer": "Menos",
"mail": "E-mail",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Afișează tot ({count})",
"show_fewer_tags": "Afișează mai puține",
"folders": "Dosare",
"your_folders": "Dosarele tale",
"create_folder": "Creează dosar",
"show_more": "Mai multe",
"show_fewer": "Mai puține",
"shared": "Partajat",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Показать все ({count})",
"show_fewer_tags": "Показать меньше",
"folders": "Папки",
"your_folders": "Ваши папки",
"create_folder": "Создать папку",
"show_more": "Ещё",
"show_fewer": "Свернуть",
"mail": "Почта",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Zobraziť všetko ({count})",
"show_fewer_tags": "Zobraziť menej",
"folders": "Priečinky",
"your_folders": "Vaše priečinky",
"create_folder": "Vytvoriť priečinok",
"show_more": "Viac",
"show_fewer": "Menej",
"shared": "Zdieľané",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Tümünü göster ({count})",
"show_fewer_tags": "Daha az göster",
"folders": "Klasörler",
"your_folders": "Klasörleriniz",
"create_folder": "Klasör oluştur",
"show_more": "Diğer",
"show_fewer": "Daha az",
"shared": "Paylaşılan",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "Показати всі ({count})",
"show_fewer_tags": "Показати менше",
"folders": "Папки",
"your_folders": "Ваші теки",
"create_folder": "Створити теку",
"show_more": "Більше",
"show_fewer": "Менше",
"mail": "Пошта",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "顯示全部({count})",
"show_fewer_tags": "顯示較少",
"folders": "信件匣",
"your_folders": "我的資料夾",
"create_folder": "建立資料夾",
"show_more": "更多",
"show_fewer": "收合",
"shared": "共用",
+2
View File
@@ -142,6 +142,8 @@
"show_all_tags": "显示全部({count})",
"show_fewer_tags": "收起",
"folders": "文件夹",
"your_folders": "我的文件夹",
"create_folder": "新建文件夹",
"show_more": "更多",
"show_fewer": "收起",
"mail": "邮件",