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 <noreply@anthropic.com>
This commit is contained in:
Steven RYDELL
2026-07-30 17:27:48 +02:00
co-authored by Claude Sonnet 5
parent 3e08dcaff5
commit 7a96fa6786
+74 -4
View File
@@ -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<string, unknown>[]): {
items: Record<string, unknown>[];
depths: Map<string, number>;
} {
const byId = new Set(items.map((item) => item.id as string));
const childrenOf = new Map<string, Record<string, unknown>[]>();
const roots: Record<string, unknown>[] = [];
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<string, unknown>, b: Record<string, unknown>) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''));
roots.sort(byName);
for (const siblings of childrenOf.values()) siblings.sort(byName);
const ordered: Record<string, unknown>[] = [];
const depths = new Map<string, number>();
function visit(item: Record<string, unknown>, 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<string, unknown>, 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<Record<string, unknown>[] | null>(null);
const [clientPage, setClientPage] = useState(0);
const [mailboxDepths, setMailboxDepths] = useState<Map<string, number>>(new Map());
const [refreshOnCooldown, setRefreshOnCooldown] = useState(false);
const refreshCooldownTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div style={{ paddingLeft: depth * 20 }} className="flex items-center gap-1.5">
{depth > 0 && (
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span>{String(item.name ?? '')}</span>
</div>
);
})()
) : (
renderCellValue(
item[col.name],