mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: show the folder hierarchy in the search folder dropdown
This commit is contained in:
@@ -4012,9 +4012,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
whatever the user picked. Both folder-less scopes span
|
||||
the own AND the group/shared accounts' folders (#1082);
|
||||
"All folders" also searches Trash and Junk. The shared
|
||||
folders are listed under their owner. The unified
|
||||
views already search across every account's folders,
|
||||
so it is hidden there. */}
|
||||
folders are listed under their owner, and subfolders
|
||||
are indented under their parent as in the sidebar
|
||||
(a native select has no tree, so the indent is
|
||||
non-breaking spaces, as in the filter rule modal).
|
||||
The unified views already search across every
|
||||
account's folders, so it is hidden there. */}
|
||||
{!isUnifiedView && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
|
||||
@@ -4032,14 +4035,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<option value={SEARCH_SCOPE_ALL_FOLDERS}>{t("advanced_search.all_folders")}</option>
|
||||
{searchScopeFolders.own.map((mb) => (
|
||||
<option key={mb.id} value={mb.id}>
|
||||
{mb.name}
|
||||
{"\u00A0".repeat(mb.depth * 3)}{mb.name}
|
||||
</option>
|
||||
))}
|
||||
{searchScopeFolders.shared.map((group) => (
|
||||
<optgroup key={group.ownerId} label={group.label}>
|
||||
{group.mailboxes.map((mb) => (
|
||||
<option key={mb.id} value={mb.id}>
|
||||
{mb.name}
|
||||
{"\u00A0".repeat(mb.depth * 3)}{mb.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
|
||||
@@ -3,17 +3,21 @@ import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { groupSearchScopeFolders } from '@/lib/search-scope-folders';
|
||||
|
||||
// The search panel's Folder dropdown lists the shared/group folders under
|
||||
// their owner account instead of as bare names among the own ones (#1082).
|
||||
// their owner account instead of as bare names among the own ones (#1082),
|
||||
// and shows the folder hierarchy: each subfolder right after its parent,
|
||||
// with its nesting depth for the indent.
|
||||
|
||||
const mb = (over: Partial<Mailbox> & { id: string }): Mailbox =>
|
||||
({ name: over.id, isShared: false, ...over } as Mailbox);
|
||||
({ name: over.id, isShared: false, sortOrder: 0, totalEmails: 0, unreadEmails: 0, ...over } as Mailbox);
|
||||
|
||||
const rows = (list: { id: string; depth: number }[]) => list.map(({ id, depth }) => [id, depth]);
|
||||
|
||||
describe('groupSearchScopeFolders', () => {
|
||||
it('keeps the own folders flat and groups shared folders by owner', () => {
|
||||
it('keeps the own folders ungrouped and groups shared folders by owner', () => {
|
||||
const folders = [
|
||||
mb({ id: 'inbox', name: 'Inbox' }),
|
||||
mb({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||
mb({ id: 'g1:inbox', name: 'Inbox', isShared: true, accountId: 'g1', accountName: 'chamados@server.tld' }),
|
||||
mb({ id: 'sent', name: 'Sent' }),
|
||||
mb({ id: 'sent', name: 'Sent', role: 'sent' }),
|
||||
mb({ id: 'g2:inbox', name: 'Inbox', isShared: true, accountId: 'g2', accountName: 'vendas@server.tld' }),
|
||||
mb({ id: 'g1:done', name: 'Done', isShared: true, accountId: 'g1', accountName: 'chamados@server.tld' }),
|
||||
];
|
||||
@@ -21,9 +25,42 @@ describe('groupSearchScopeFolders', () => {
|
||||
const { own, shared } = groupSearchScopeFolders(folders);
|
||||
|
||||
expect(own.map((m) => m.id)).toEqual(['inbox', 'sent']);
|
||||
expect(shared).toEqual([
|
||||
{ ownerId: 'g1', label: 'chamados@server.tld', mailboxes: [folders[1], folders[4]] },
|
||||
{ ownerId: 'g2', label: 'vendas@server.tld', mailboxes: [folders[3]] },
|
||||
expect(shared.map(({ ownerId, label, mailboxes }) => ({ ownerId, label, ids: mailboxes.map((m) => m.id) }))).toEqual([
|
||||
{ ownerId: 'g1', label: 'chamados@server.tld', ids: ['g1:done', 'g1:inbox'] },
|
||||
{ ownerId: 'g2', label: 'vendas@server.tld', ids: ['g2:inbox'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists each subfolder right after its parent with its depth', () => {
|
||||
const { own } = groupSearchScopeFolders([
|
||||
mb({ id: 'projects', name: 'Projects' }),
|
||||
mb({ id: 'client-b', name: 'Client B', parentId: 'projects' }),
|
||||
mb({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
|
||||
mb({ id: 'invoices', name: 'Invoices', parentId: 'client-b' }),
|
||||
mb({ id: 'client-a', name: 'Client A', parentId: 'projects' }),
|
||||
mb({ id: 'archive', name: 'Archive', role: 'archive' }),
|
||||
]);
|
||||
|
||||
expect(rows(own)).toEqual([
|
||||
['inbox', 0],
|
||||
['archive', 0],
|
||||
['projects', 0],
|
||||
['client-a', 1],
|
||||
['client-b', 1],
|
||||
['invoices', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('nests shared subfolders from the owner account top level', () => {
|
||||
const { shared } = groupSearchScopeFolders([
|
||||
mb({ id: 'g1:inbox', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'g1', accountName: 'team' }),
|
||||
mb({ id: 'g1:open', name: 'Open', parentId: 'g1:inbox', isShared: true, accountId: 'g1', accountName: 'team' }),
|
||||
]);
|
||||
|
||||
expect(shared).toHaveLength(1);
|
||||
expect(rows(shared[0].mailboxes)).toEqual([
|
||||
['g1:inbox', 0],
|
||||
['g1:open', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
+20
-17
@@ -1,4 +1,5 @@
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* `searchMailboxId` of the "All folders" search scope: every folder of every
|
||||
@@ -31,13 +32,15 @@ export interface SearchScopeFolderGroup {
|
||||
ownerId: string;
|
||||
/** Owner account label (`accountName`, else the owner id). */
|
||||
label: string;
|
||||
mailboxes: Mailbox[];
|
||||
/** The owner's folders in sidebar order, `depth` counted from its top level. */
|
||||
mailboxes: MailboxNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the sidebar's folder list into the login's own folders and the
|
||||
* group/shared folders grouped by their owner account, for the search
|
||||
* panel's Folder dropdown.
|
||||
* panel's Folder dropdown. Both come in sidebar order, each subfolder right
|
||||
* after its parent with its nesting `depth`, so the dropdown can indent it.
|
||||
*
|
||||
* Shared folders carry their owner's name in the sidebar, but the flat
|
||||
* dropdown listed them by bare name, so a group's "Inbox" was
|
||||
@@ -45,21 +48,21 @@ export interface SearchScopeFolderGroup {
|
||||
*/
|
||||
export function groupSearchScopeFolders(
|
||||
mailboxes: Mailbox[],
|
||||
): { own: Mailbox[]; shared: SearchScopeFolderGroup[] } {
|
||||
const own: Mailbox[] = [];
|
||||
const byOwner = new Map<string, SearchScopeFolderGroup>();
|
||||
for (const mailbox of mailboxes) {
|
||||
if (!mailbox.isShared) {
|
||||
own.push(mailbox);
|
||||
continue;
|
||||
): { own: MailboxNode[]; shared: SearchScopeFolderGroup[] } {
|
||||
const ownRoots: MailboxNode[] = [];
|
||||
const shared: SearchScopeFolderGroup[] = [];
|
||||
for (const node of buildMailboxTree(mailboxes)) {
|
||||
// buildMailboxTree wraps each shared account's folders in a virtual
|
||||
// `shared-account-<id>` node, the dropdown's optgroup.
|
||||
if (node.id.startsWith('shared-account-')) {
|
||||
shared.push({
|
||||
ownerId: node.accountId ?? '',
|
||||
label: node.name,
|
||||
mailboxes: flattenMailboxTree(node.children),
|
||||
});
|
||||
} else {
|
||||
ownRoots.push(node);
|
||||
}
|
||||
const ownerId = mailbox.accountId ?? '';
|
||||
let group = byOwner.get(ownerId);
|
||||
if (!group) {
|
||||
group = { ownerId, label: mailbox.accountName || ownerId, mailboxes: [] };
|
||||
byOwner.set(ownerId, group);
|
||||
}
|
||||
group.mailboxes.push(mailbox);
|
||||
}
|
||||
return { own, shared: Array.from(byOwner.values()) };
|
||||
return { own: flattenMailboxTree(ownRoots), shared };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user