From 7a96fa67867ecb818f788dffe20e3ec61acc6acc Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Thu, 30 Jul 2026 17:27:48 +0200 Subject: [PATCH] feat: show mailbox hierarchy in the Mailboxes list Fixes stalwartlabs/webui#16. Mailboxes had no visual indication of parent/child relationships. Since a mailbox's parent can land on a different server page than the mailbox itself, the Mailbox list now always fetches the full set (like the existing client-filter path) and orders it depth-first by parentId, then indents each row's name with a corner connector proportional to its depth. Verified against a live instance with a 3-level nested hierarchy (Projects > Alpha > Docs) alongside the default flat mailboxes. Co-Authored-By: Claude Sonnet 5 --- src/components/lists/DynamicList.tsx | 78 ++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index aabea87..6e868ea 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -21,6 +21,7 @@ import { Search, RotateCcw, RefreshCw, + CornerDownRight, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -185,6 +186,49 @@ function formatNumber(value: unknown): string { return value.toLocaleString(); } +// Orders mailboxes so each parent is immediately followed by its +// descendants (siblings alphabetical), and records each row's depth. +// Requires the full set (not just one page) since a mailbox's parent +// could be on a different page than the mailbox itself. +function sortMailboxesByHierarchy(items: Record[]): { + items: Record[]; + depths: Map; +} { + const byId = new Set(items.map((item) => item.id as string)); + const childrenOf = new Map[]>(); + const roots: Record[] = []; + + for (const item of items) { + const parentId = item.parentId as string | null | undefined; + if (parentId && byId.has(parentId)) { + const siblings = childrenOf.get(parentId) ?? []; + siblings.push(item); + childrenOf.set(parentId, siblings); + } else { + roots.push(item); + } + } + + const byName = (a: Record, b: Record) => + String(a.name ?? '').localeCompare(String(b.name ?? '')); + roots.sort(byName); + for (const siblings of childrenOf.values()) siblings.sort(byName); + + const ordered: Record[] = []; + const depths = new Map(); + + function visit(item: Record, depth: number) { + ordered.push(item); + depths.set(item.id as string, depth); + for (const child of childrenOf.get(item.id as string) ?? []) { + visit(child, depth + 1); + } + } + for (const root of roots) visit(root, 0); + + return { items: ordered, depths }; +} + function formatUserRole(item: Record, schema: Schema): React.ReactNode { // The x:Account list merges field definitions across its User/Group // variants (see getFieldsRecord), and Group's `roles` property points to @@ -440,6 +484,7 @@ export function DynamicList({ viewName }: DynamicListProps) { const isWebApplications = viewName === 'x:Application' || objectName === 'x:Application'; const isLogEntries = viewName === 'x:Log' || objectName === 'x:Log'; const isAccountsList = viewName === 'x:Account/User'; + const isMailboxList = viewName === 'Mailbox'; const displayColumns = useMemo(() => { const columns = resolved?.list?.columns ?? []; @@ -504,6 +549,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ); const [clientAllItems, setClientAllItems] = useState[] | null>(null); const [clientPage, setClientPage] = useState(0); + const [mailboxDepths, setMailboxDepths] = useState>(new Map()); const [refreshOnCooldown, setRefreshOnCooldown] = useState(false); const refreshCooldownTimer = useRef | null>(null); @@ -553,6 +599,7 @@ export function DynamicList({ viewName }: DynamicListProps) { setError(null); setClientAllItems(null); setClientPage(0); + setMailboxDepths(new Map()); if (refreshCooldownTimer.current) clearTimeout(refreshCooldownTimer.current); setRefreshOnCooldown(false); @@ -604,22 +651,33 @@ export function DynamicList({ viewName }: DynamicListProps) { properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas'); } } + if (isMailboxList && !properties.includes('parentId')) { + properties.push('parentId'); + } const filter = buildFilter(); const sortArr = buildSort(); - if (activeClientFilters.length > 0) { + if (activeClientFilters.length > 0 || isMailboxList) { // No server-side pagination possible once a client-only filter is // active: fetch every server-matching row up front, narrow it in - // the browser, then paginate the in-memory result locally. + // the browser, then paginate the in-memory result locally. Mailbox + // hierarchy needs this too — a mailbox's parent can land on a + // different server page than the mailbox itself, so the full set + // is required to place each row under its parent correctly. const { list: fullList } = await jmapQueryAllAndGet( obj.objectName, accountId, { filter: Object.keys(filter).length > 0 ? filter : undefined, sort: sortArr }, properties, ); - const matched = fullList.filter((item) => + let matched = fullList.filter((item) => activeClientFilters.every((f) => String(item[f.field] ?? '') === f.value), ); + if (isMailboxList) { + const { items: ordered, depths } = sortMailboxesByHierarchy(matched); + matched = ordered; + setMailboxDepths(depths); + } setClientAllItems(matched); setClientPage(0); setTotal(matched.length); @@ -675,7 +733,7 @@ export function DynamicList({ viewName }: DynamicListProps) { setLoading(false); } }, - [resolved, schema, buildFilter, buildSort, isWebApplications, isAccountsList, activeClientFilters], + [resolved, schema, buildFilter, buildSort, isWebApplications, isAccountsList, isMailboxList, activeClientFilters], ); useEffect(() => { @@ -1582,6 +1640,18 @@ export function DynamicList({ viewName }: DynamicListProps) { formatUserRole(item, schema!) ) : isAccountsList && col.name === 'quotaUsage' ? ( formatQuotaUsage(item, t) + ) : isMailboxList && col.name === 'name' ? ( + (() => { + const depth = mailboxDepths.get(item.id as string) ?? 0; + return ( +
+ {depth > 0 && ( + + )} + {String(item.name ?? '')} +
+ ); + })() ) : ( renderCellValue( item[col.name],