feat(mail): resizable columns, account in folder tree, mail settings in CRM settings
- Add ResizablePanel component (components/ui/ResizablePanel.tsx) with
drag-to-resize via mouse events, no external dependencies
- Replace fixed-width columns in Mail.tsx with ResizablePanel for all
three panes (folder tree, mail list, mail detail)
- Remove account-select toolbar item from Mail.tsx; account selection
is now integrated into the folder tree
- Restructure MailFolderTree to show mail accounts as top-level nodes
with folders nested underneath, built from flat list via parent_id
- Add expand/collapse chevrons for accounts and folders with children
- Load folders for ALL accounts (not just selected one) so multi-account
tree works
- Derive selectedAccountId from selected folder instead of separate
account selector
- Add Mail tab to CRM Settings page (Settings.tsx) with 📧 icon
- Add /settings/mail route pointing to MailSettingsPage
- Update settings link in mail toolbar to point to /settings/mail
- Update no-accounts empty state link to /settings/mail
This commit is contained in:
@@ -1,22 +1,79 @@
|
||||
/**
|
||||
* Mail folder tree sidebar component.
|
||||
* Shows hierarchical folder list with unread badges.
|
||||
* Shows accounts as top-level nodes with their folders nested underneath.
|
||||
* Folders come as a flat list with parent_id and are built into a tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MailFolder } from '@/api/mail';
|
||||
import type { MailAccount, MailFolder } from '@/api/mail';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
export interface MailFolderTreeProps {
|
||||
accounts: MailAccount[];
|
||||
folders: MailFolder[];
|
||||
selectedFolderId: string | null;
|
||||
onSelect: (folderId: string) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hierarchical tree from a flat folder list for a given account.
|
||||
*/
|
||||
function buildFolderTree(folders: MailFolder[], accountId: string): MailFolder[] {
|
||||
const accountFolders = folders.filter((f) => f.account_id === accountId);
|
||||
const byId = new Map<string, MailFolder & { children: MailFolder[] }>();
|
||||
|
||||
for (const f of accountFolders) {
|
||||
byId.set(f.id, { ...f, children: [] });
|
||||
}
|
||||
|
||||
const roots: MailFolder[] = [];
|
||||
|
||||
for (const f of accountFolders) {
|
||||
const node = byId.get(f.id)!;
|
||||
if (f.parent_id && byId.has(f.parent_id)) {
|
||||
byId.get(f.parent_id)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx('w-3 h-3 transition-transform flex-shrink-0', expanded ? 'rotate-90' : '')}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MailIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7-7 7 7M5 19h10a2 2 0 002-2V8l-5-5H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderNode({
|
||||
folder,
|
||||
selectedFolderId,
|
||||
@@ -28,36 +85,51 @@ function FolderNode({
|
||||
onSelect: (folderId: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isSelected = folder.id === selectedFolderId;
|
||||
const hasChildren = folder.children && folder.children.length > 0;
|
||||
|
||||
return (
|
||||
<div role="treeitem" aria-selected={isSelected} aria-label={folder.name}>
|
||||
<button
|
||||
onClick={() => onSelect(folder.id)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch',
|
||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isSelected
|
||||
? 'bg-primary-50 text-primary-700 font-medium'
|
||||
: 'hover:bg-secondary-50 text-secondary-700',
|
||||
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
className="flex-shrink-0 p-0.5 hover:bg-secondary-100 rounded"
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
data-testid={`folder-chevron-${folder.id}`}
|
||||
>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 flex-shrink-0" />
|
||||
)}
|
||||
data-testid={`folder-${folder.id}`}
|
||||
>
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||
{folder.unread_count > 0 && (
|
||||
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
|
||||
)}
|
||||
{folder.total_count > 0 && folder.unread_count === 0 && (
|
||||
<span className="text-xs text-secondary-400">{folder.total_count}</span>
|
||||
)}
|
||||
</button>
|
||||
{hasChildren && (
|
||||
<div className="ml-4" role="group">
|
||||
<button
|
||||
onClick={() => onSelect(folder.id)}
|
||||
className={clsx(
|
||||
'flex-1 flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch',
|
||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isSelected
|
||||
? 'bg-primary-50 text-primary-700 font-medium'
|
||||
: 'hover:bg-secondary-50 text-secondary-700',
|
||||
)}
|
||||
data-testid={`folder-${folder.id}`}
|
||||
>
|
||||
<FolderIcon />
|
||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||
{folder.unread_count > 0 && (
|
||||
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
|
||||
)}
|
||||
{folder.total_count > 0 && folder.unread_count === 0 && (
|
||||
<span className="text-xs text-secondary-400">{folder.total_count}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div role="group">
|
||||
{folder.children!.map((child) => (
|
||||
<FolderNode
|
||||
key={child.id}
|
||||
@@ -73,7 +145,52 @@ function FolderNode({
|
||||
);
|
||||
}
|
||||
|
||||
export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
|
||||
function AccountNode({
|
||||
account,
|
||||
folders,
|
||||
selectedFolderId,
|
||||
onSelect,
|
||||
}: {
|
||||
account: MailAccount;
|
||||
folders: MailFolder[];
|
||||
selectedFolderId: string | null;
|
||||
onSelect: (folderId: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const accountFolders = useMemo(
|
||||
() => buildFolderTree(folders, account.id),
|
||||
[folders, account.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="treeitem" aria-label={account.email}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-1 px-1 py-1.5 rounded-md text-sm font-semibold text-secondary-900 hover:bg-secondary-50"
|
||||
data-testid={`account-${account.id}`}
|
||||
>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
<MailIcon />
|
||||
<span className="flex-1 truncate text-left">{account.email}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div role="group" className="ml-2">
|
||||
{accountFolders.map((folder) => (
|
||||
<FolderNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={onSelect}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
@@ -88,7 +205,7 @@ export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }:
|
||||
);
|
||||
}
|
||||
|
||||
if (folders.length === 0) {
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('mail.noFolders')}
|
||||
@@ -100,13 +217,13 @@ export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }:
|
||||
|
||||
return (
|
||||
<div className="space-y-1" role="tree" data-testid="folder-tree-list">
|
||||
{folders.map((folder) => (
|
||||
<FolderNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
{accounts.map((account) => (
|
||||
<AccountNode
|
||||
key={account.id}
|
||||
account={account}
|
||||
folders={folders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={onSelect}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user