A folder could only be moved by dragging it, which is slow in a long list
and not offered at all on a touch screen. Its menu now has "Move to…",
which opens the searchable folder picker that moving messages already
uses, with a "Top level" row above the folders.
The picker lists only legal destinations: the same rules as a drop -- not
into itself, its own subtree or the parent it already has -- plus the
rights a picker has to check up front because it shows every folder at
once: mayRename on the folder being moved, which RFC 8621 uses for
reparenting, and mayCreateChild on the destination. Both only say no on
shared mail. The move itself goes through the same path as a drop, so the
toast and the expanded destination are unchanged.
"Move “{name}” to…" and "Top level" are in all nine catalogues.
Closes #355
108 lines
4.4 KiB
TypeScript
108 lines
4.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { canDropFolder, canMoveFolderTo, descendantIds, folderColor, movable } from "../folderMove";
|
|
import type { Id, Mailbox } from "@/jmap/types";
|
|
|
|
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox =>
|
|
({ id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} as Mailbox["myRights"] });
|
|
|
|
/** root ── Work ── Clients ── EU
|
|
* └─ Archive (role)
|
|
* └─ Inbox (role) */
|
|
const tree: Record<Id, Mailbox> = Object.fromEntries([
|
|
mb("inbox", "Inbox", null, "inbox"),
|
|
mb("arch", "Archive", null, "archive"),
|
|
mb("work", "Work", null),
|
|
mb("clients", "Clients", "work"),
|
|
mb("eu", "EU", "clients"),
|
|
mb("news", "Newsletters", null),
|
|
].map((m) => [m.id, m]));
|
|
|
|
describe("movable", () => {
|
|
it("refuses folders the server gave a role", () => {
|
|
expect(movable(tree.inbox!)).toBe(false);
|
|
expect(movable(tree.arch!)).toBe(false);
|
|
expect(movable(tree.work!)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("descendantIds", () => {
|
|
it("finds the whole subtree, not just the children", () => {
|
|
expect([...descendantIds(tree, "work")].sort()).toEqual(["clients", "eu"]);
|
|
expect([...descendantIds(tree, "eu")]).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("canDropFolder", () => {
|
|
it("allows a plain move into another folder", () => {
|
|
expect(canDropFolder(tree, "news", "work")).toBe(true);
|
|
expect(canDropFolder(tree, "eu", "news")).toBe(true);
|
|
});
|
|
|
|
it("allows a move into a role folder, which may hold subfolders", () => {
|
|
expect(canDropFolder(tree, "news", "arch")).toBe(true);
|
|
});
|
|
|
|
it("refuses to move a folder into itself or its own subtree", () => {
|
|
expect(canDropFolder(tree, "work", "work")).toBe(false);
|
|
expect(canDropFolder(tree, "work", "clients")).toBe(false);
|
|
expect(canDropFolder(tree, "work", "eu")).toBe(false); // grandchild, not just child
|
|
});
|
|
|
|
it("refuses a move to the parent it already has", () => {
|
|
expect(canDropFolder(tree, "clients", "work")).toBe(false);
|
|
});
|
|
|
|
it("refuses to move a role folder anywhere", () => {
|
|
expect(canDropFolder(tree, "inbox", "work")).toBe(false);
|
|
expect(canDropFolder(tree, "arch", null)).toBe(false);
|
|
});
|
|
|
|
it("handles the root: allowed from a parent, refused when already there", () => {
|
|
expect(canDropFolder(tree, "eu", null)).toBe(true);
|
|
expect(canDropFolder(tree, "news", null)).toBe(false);
|
|
});
|
|
|
|
it("refuses a target that does not exist", () => {
|
|
expect(canDropFolder(tree, "news", "gone")).toBe(false);
|
|
expect(canDropFolder(tree, "gone", "work")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("canMoveFolderTo", () => {
|
|
const rights = (r: Partial<Mailbox["myRights"]>) => ({ mayRename: true, mayCreateChild: true, ...r }) as Mailbox["myRights"];
|
|
const owned: Record<Id, Mailbox> = Object.fromEntries(Object.values(tree).map((m) => [m.id, { ...m, myRights: rights({}) }]));
|
|
|
|
it("agrees with a drop when every right is granted", () => {
|
|
expect(canMoveFolderTo(owned, "news", "work")).toBe(true);
|
|
expect(canMoveFolderTo(owned, "eu", null)).toBe(true);
|
|
expect(canMoveFolderTo(owned, "work", "eu")).toBe(false);
|
|
expect(canMoveFolderTo(owned, "news", null)).toBe(false);
|
|
});
|
|
|
|
it("refuses a folder the user may not rename, top level included", () => {
|
|
const locked = { ...owned, eu: { ...owned.eu!, myRights: rights({ mayRename: false }) } };
|
|
expect(canMoveFolderTo(locked, "eu", "news")).toBe(false);
|
|
expect(canMoveFolderTo(locked, "eu", null)).toBe(false);
|
|
});
|
|
|
|
it("refuses a destination that may not hold new subfolders", () => {
|
|
const closed = { ...owned, work: { ...owned.work!, myRights: rights({ mayCreateChild: false }) } };
|
|
expect(canMoveFolderTo(closed, "news", "work")).toBe(false);
|
|
expect(canMoveFolderTo(closed, "eu", null)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("folderColor", () => {
|
|
it("returns the colour chosen for that folder, and null for the rest", () => {
|
|
const colors = { work: "#7c3aed" };
|
|
expect(folderColor(colors, "work")).toBe("#7c3aed");
|
|
expect(folderColor(colors, "news")).toBeNull();
|
|
expect(folderColor({}, "work")).toBeNull();
|
|
});
|
|
|
|
it("is keyed by id, so a renamed folder keeps its colour", () => {
|
|
// The id is stable across a rename; the name and path are not.
|
|
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
|
|
});
|
|
});
|