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>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ResizablePanel — a flex panel with drag-to-resize handle.
|
||||
* No external dependencies; uses React + mouse events only.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ResizablePanelProps {
|
||||
/** Initial width in pixels (only used when resizable=true) */
|
||||
initialWidth?: number;
|
||||
/** Minimum width in pixels */
|
||||
minWidth?: number;
|
||||
/** Maximum width in pixels */
|
||||
maxWidth?: number;
|
||||
/** Panel content */
|
||||
children: React.ReactNode;
|
||||
/** Extra className for the panel container */
|
||||
className?: string;
|
||||
/** When false, panel becomes flex-1 with no drag handle */
|
||||
resizable?: boolean;
|
||||
/** testid forwarded to the container div */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export function ResizablePanel({
|
||||
initialWidth = 224,
|
||||
minWidth = 150,
|
||||
maxWidth = 600,
|
||||
children,
|
||||
className,
|
||||
resizable = true,
|
||||
...rest
|
||||
}: ResizablePanelProps) {
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
const isResizing = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing.current = true;
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = width;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
},
|
||||
[width],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
const delta = e.clientX - startX.current;
|
||||
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + delta));
|
||||
setWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isResizing.current) {
|
||||
isResizing.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [minWidth, maxWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(resizable ? 'relative flex-shrink-0' : 'relative flex-1', className)}
|
||||
style={resizable ? { width: `${width}px` } : undefined}
|
||||
data-testid={rest['data-testid']}
|
||||
>
|
||||
{children}
|
||||
{resizable && (
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary-300 active:bg-primary-400 transition-colors z-10"
|
||||
onMouseDown={handleMouseDown}
|
||||
data-testid="resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user