List folders in sidebar order in the move-to picker
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m23s

The picker sorted folders A-Z by path, with Inbox first, so a folder
dragged into place in the sidebar turned up somewhere else when moving
mail. It now walks the tree in compareFolders order, the sidebar's
order with every folder expanded: Inbox, then the saved order, then
the special folders, then A-Z, with subfolders under their parent.

treeOrder lives beside compareFolders. A folder the walk from the top
cannot reach is appended rather than dropped, so it stays pickable as
it was before.

Closes #1

(cherry picked from commit ea03406646062359f74e16ad8a8aed074b4dc409)
This commit is contained in:
jcoffey-dev committed 2026-09-27 16:27:24 -07:00
1 parent fedc34698e
commit c64a23f9d9
4 files changed
+119 -4

No files matched your search

+5 -3
View File
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
import type { Id, Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
import { treeOrder } from "@/lib/mailbox/folderOrder";
/**
* @param need which right a folder has to grant to be worth offering.
@@ -24,10 +25,11 @@ export function MailboxPicker({ title, onClose, onPick, exclude, need = "mayAddI
const [q, setQ] = useState("");
const [active, setActive] = useState(0);
const list = useMemo(() => {
const all = Object.values(mailboxes)
// The sidebar's order, not A–Z by path: a folder dragged into place has to
// be found in the same place here.
const all = treeOrder(mailboxes)
.filter((m) => !exclude?.includes(m.id) && m.myRights[need] && (!allow || allow(m.id)))
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes), pick: () => onPick(m.id) }))
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes), pick: () => onPick(m.id) }));
const rows: { m: Mailbox | null; path: string; pick: () => void }[] = root ? [{ m: null, path: root.label, pick: root.onPick }, ...all] : all;
const ql = q.trim().toLowerCase();
return ql ? rows.filter((x) => x.path.toLowerCase().includes(ql)) : rows;
@@ -0,0 +1,62 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MailboxPicker } from "../MailboxPicker";
import { useMail } from "@/store/mail";
import type { Mailbox, MailboxRole } from "@/jmap/types";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* The move-to picker (v) lists folders in the sidebar's order (#1 on GitLab).
*
* It used to sort A–Z by path, so a folder dragged into place in the sidebar
* turned up somewhere else here. The ordering has its own tests in
* lib/mailbox; these check what the dialog actually shows.
*/
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
const box = (id: string, name: string, parentId: string | null, role: MailboxRole = null, sortOrder = 0): Mailbox => ({
id, name, parentId, role, sortOrder, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: rights, isSubscribed: true,
});
/** Ordered by hand in the sidebar: Zeta dragged to the top, Alpha to the bottom. */
const MAILBOXES = {
inbox: box("inbox", "Inbox", null, "inbox", 10),
zeta: box("zeta", "Zeta", null, null, 20),
sent: box("sent", "Sent", null, "sent", 30),
work: box("work", "Work", null, null, 40),
clients: box("clients", "Clients", "work"),
trash: box("trash", "Deleted Items", null, "trash", 50),
alpha: box("alpha", "Alpha", null, null, 60),
};
describe("the move-to picker", () => {
let host: HTMLDivElement;
let root: Root;
const rows = () => Array.from(document.querySelectorAll('[role="option"]')).map((r) => r.querySelector(".grow")?.textContent);
function open(props: Partial<Parameters<typeof MailboxPicker>[0]> = {}) {
act(() => root.render(<MailboxPicker title="Move to…" onClose={() => {}} onPick={() => {}} {...props} />));
}
beforeEach(() => {
useMail.setState({ mailboxes: MAILBOXES, mailboxesLoaded: true });
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
it("lists folders in the order they were dragged into, not A–Z", () => {
open();
expect(rows()).toEqual(["Inbox", "Zeta", "Sent", "Work", "Work / Clients", "Deleted Items", "Alpha"]);
});
it("keeps that order for the folders left after excluding one", () => {
open({ exclude: ["work"] });
expect(rows()).toEqual(["Inbox", "Zeta", "Sent", "Work / Clients", "Deleted Items", "Alpha"]);
});
});