Move a folder by dragging it
Reparenting a folder meant the Folders settings page, or nothing at all. The tree already accepted messages dropped onto a folder, so folders now travel the same way: drag one onto another to nest it, or onto the Folders heading to bring it back to the top level. The heading says "Drop here for the top level" while a folder is in flight, because an unlabelled strip of heading is not a discoverable target. The row being dragged fades, the row under the pointer is outlined, and only rows that would accept the drop light up. Four drops are refused: a folder onto itself, into its own subtree, onto the parent it already has, and any folder the server gave a role, which is not draggable in the first place. The subtree case is the one that matters -- it would orphan the branch -- and it checks the whole subtree rather than the immediate children. Whether a drop is legal has to be known during dragover, when dataTransfer.getData is blocked, so the tree remembers what is being dragged rather than asking the drag. The move goes through updateMailbox, so the filter rules pointing at the folder follow it, and the target folder is expanded afterwards so the folder can be seen where it landed.
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { canDropFolder, descendantIds, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folder can be moved unless the server gave it a role. Inbox, Sent, Trash and
|
||||||
|
* the rest are structural, and the server refuses to reparent them anyway --
|
||||||
|
* better not to offer the drag at all.
|
||||||
|
*/
|
||||||
|
export function movable(m: Mailbox): boolean {
|
||||||
|
return !m.role || m.role === "subscribed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every folder beneath this one, so a folder cannot be dropped inside itself. */
|
||||||
|
export function descendantIds(mailboxes: Record<Id, Mailbox>, id: Id): Set<Id> {
|
||||||
|
const out = new Set<Id>();
|
||||||
|
const all = Object.values(mailboxes);
|
||||||
|
let frontier = new Set<Id>([id]);
|
||||||
|
// Depth is bounded by the server's own mailbox depth limit; the guard is only
|
||||||
|
// here so a cycle in the data cannot spin forever.
|
||||||
|
for (let depth = 0; depth < 20 && frontier.size; depth++) {
|
||||||
|
const next = new Set<Id>();
|
||||||
|
for (const m of all) {
|
||||||
|
if (m.parentId && frontier.has(m.parentId) && !out.has(m.id)) {
|
||||||
|
out.add(m.id);
|
||||||
|
next.add(m.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `draggedId` may be dropped on `targetId`, where null means the root.
|
||||||
|
*
|
||||||
|
* Four ways it cannot: the folder is not movable at all, it is being dropped on
|
||||||
|
* itself, into its own subtree — which would orphan the branch — or onto the
|
||||||
|
* parent it already has, which would be a no-op dressed up as a move.
|
||||||
|
*/
|
||||||
|
export function canDropFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id | null): boolean {
|
||||||
|
const dragged = mailboxes[draggedId];
|
||||||
|
if (!dragged || !movable(dragged)) return false;
|
||||||
|
if (targetId === null) return dragged.parentId != null;
|
||||||
|
if (targetId === draggedId) return false;
|
||||||
|
if (dragged.parentId === targetId) return false;
|
||||||
|
if (!mailboxes[targetId]) return false;
|
||||||
|
return !descendantIds(mailboxes, draggedId).has(targetId);
|
||||||
|
}
|
||||||
@@ -323,6 +323,9 @@ img { max-width: 100%; }
|
|||||||
.nav-item.active { background: var(--accent-soft); color: var(--accent-soft-fg); font-weight: 650; }
|
.nav-item.active { background: var(--accent-soft); color: var(--accent-soft-fg); font-weight: 650; }
|
||||||
.nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; }
|
.nav-item.active.unread .nav-label, .nav-item.active.unread .nav-count { color: inherit; }
|
||||||
.nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; }
|
.nav-item.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; }
|
||||||
|
.nav-item.folder-row.dragging { opacity: .45; }
|
||||||
|
/* The Folders heading doubles as the way back to the top level while dragging. */
|
||||||
|
.nav-section.drop-target { background: var(--accent-soft); outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); color: var(--accent-soft-fg); }
|
||||||
.nav-item svg { flex: 0 0 auto; color: var(--fg-muted); }
|
.nav-item svg { flex: 0 0 auto; color: var(--fg-muted); }
|
||||||
.nav-item.active svg { color: inherit; }
|
.nav-item.active svg { color: inherit; }
|
||||||
.nav-item .nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
.nav-item .nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
import { loadRaw, saveJson } from "@/lib/storage";
|
import { loadRaw, saveJson } from "@/lib/storage";
|
||||||
|
import { canDropFolder, movable } from "@/lib/folderMove";
|
||||||
|
|
||||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||||
inbox: <Inbox size={20} />,
|
inbox: <Inbox size={20} />,
|
||||||
@@ -23,6 +24,9 @@ const ROLE_ICONS: Record<string, ReactNode> = {
|
|||||||
important: <Tag size={20} />,
|
important: <Tag size={20} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Its own drag type, so a folder can only be dropped where folders belong. */
|
||||||
|
const FOLDER_MIME = "application/x-ihasmail-folder";
|
||||||
|
|
||||||
export function MailboxTree() {
|
export function MailboxTree() {
|
||||||
const mailboxes = useMail((s) => s.mailboxes);
|
const mailboxes = useMail((s) => s.mailboxes);
|
||||||
const loaded = useMail((s) => s.mailboxesLoaded);
|
const loaded = useMail((s) => s.mailboxesLoaded);
|
||||||
@@ -34,6 +38,32 @@ export function MailboxTree() {
|
|||||||
const menu = useMenu();
|
const menu = useMenu();
|
||||||
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
||||||
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
||||||
|
/**
|
||||||
|
* The folder being dragged. Held here rather than read from the drag itself:
|
||||||
|
* dataTransfer.getData is blocked during dragover, so a row cannot ask what
|
||||||
|
* is over it, and every row needs to know whether it is a legal target.
|
||||||
|
*/
|
||||||
|
const [draggingId, setDraggingId] = useState<Id | null>(null);
|
||||||
|
const [rootDrop, setRootDrop] = useState(false);
|
||||||
|
/** Whether the folder in flight may be dropped on this folder, or on the root. */
|
||||||
|
const canDropOn = (targetId: Id | null): boolean => Boolean(draggingId) && canDropFolder(mailboxes, draggingId!, targetId);
|
||||||
|
|
||||||
|
const moveFolder = async (id: Id, parentId: Id | null) => {
|
||||||
|
const m = mailboxes[id];
|
||||||
|
setDraggingId(null);
|
||||||
|
try {
|
||||||
|
await useMail.getState().updateMailbox(id, { parentId });
|
||||||
|
// Show where it landed rather than leaving it hidden in a closed parent.
|
||||||
|
if (parentId) {
|
||||||
|
const next = { ...expanded, [parentId]: true };
|
||||||
|
setExpanded(next);
|
||||||
|
saveJson("mbx-expanded", next);
|
||||||
|
}
|
||||||
|
toast.success(parentId ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and
|
// Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and
|
||||||
// collapsed by default. Expansion state is remembered per folder.
|
// collapsed by default. Expansion state is remembered per folder.
|
||||||
@@ -93,14 +123,46 @@ export function MailboxTree() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
||||||
<div className="nav-section">
|
<div
|
||||||
<span>Folders</span>
|
className={`nav-section${rootDrop ? " drop-target" : ""}`}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!e.dataTransfer.types.includes(FOLDER_MIME) || !canDropOn(null)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (!rootDrop) setRootDrop(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setRootDrop(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setRootDrop(false);
|
||||||
|
const id = e.dataTransfer.getData(FOLDER_MIME);
|
||||||
|
if (id) void moveFolder(id, null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
|
||||||
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
|
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => (
|
{rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => (
|
||||||
<FolderRow key={m.id} mailbox={m} label={m.name} depth={depth} hasChildren={hasChildren} open={open} hiddenUnread={hiddenUnread} childUnread={childUnread} onToggle={() => toggle(m.id)} currentId={currentId} onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }} />
|
<FolderRow
|
||||||
|
key={m.id}
|
||||||
|
mailbox={m}
|
||||||
|
label={m.name}
|
||||||
|
depth={depth}
|
||||||
|
hasChildren={hasChildren}
|
||||||
|
open={open}
|
||||||
|
hiddenUnread={hiddenUnread}
|
||||||
|
childUnread={childUnread}
|
||||||
|
onToggle={() => toggle(m.id)}
|
||||||
|
currentId={currentId}
|
||||||
|
onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }}
|
||||||
|
dragging={draggingId === m.id}
|
||||||
|
acceptsFolder={canDropOn(m.id)}
|
||||||
|
onFolderDragStart={() => setDraggingId(m.id)}
|
||||||
|
onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }}
|
||||||
|
onFolderDrop={(id) => void moveFolder(id, m.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{labelsSidebar && labels.length > 0 && (
|
{labelsSidebar && labels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -127,7 +189,7 @@ export function MailboxTree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void }) {
|
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) {
|
||||||
const [dropping, setDropping] = useState(false);
|
const [dropping, setDropping] = useState(false);
|
||||||
// Scheduled counts like Drafts: everything in it is already read, so the
|
// Scheduled counts like Drafts: everything in it is already read, so the
|
||||||
// useful number is how many messages are waiting, not how many are unseen.
|
// useful number is how many messages are waiting, not how many are unseen.
|
||||||
@@ -139,7 +201,8 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />;
|
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />;
|
||||||
|
|
||||||
const onDragOver = (e: DragEvent) => {
|
const onDragOver = (e: DragEvent) => {
|
||||||
if (!e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
|
const folder = e.dataTransfer.types.includes(FOLDER_MIME);
|
||||||
|
if (folder ? !acceptsFolder : !e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
if (!dropping) setDropping(true);
|
if (!dropping) setDropping(true);
|
||||||
@@ -147,6 +210,11 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDropping(false);
|
setDropping(false);
|
||||||
|
const folderId = e.dataTransfer.getData(FOLDER_MIME);
|
||||||
|
if (folderId) {
|
||||||
|
if (acceptsFolder) onFolderDrop(folderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
|
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
try {
|
try {
|
||||||
@@ -156,12 +224,22 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const onDragStart = (e: DragEvent) => {
|
||||||
|
e.dataTransfer.setData(FOLDER_MIME, m.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
// A folder row is a link, and a link drag would otherwise carry its URL.
|
||||||
|
e.stopPropagation();
|
||||||
|
onFolderDragStart();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/mail/${m.id}`}
|
href={`/mail/${m.id}`}
|
||||||
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""}`}
|
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
|
||||||
title={label}
|
title={label}
|
||||||
|
draggable={movable(m)}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onFolderDragEnd}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={() => setDropping(false)}
|
onDragLeave={() => setDropping(false)}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
|||||||
Reference in New Issue
Block a user