Merge pull request #40 from LINUXexpert-org/folder-drag-to-move
Move a folder by dragging it
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.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.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.active svg { color: inherit; }
|
||||
.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 { ShareDialog } from "../settings/ShareDialog";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { canDropFolder, movable } from "@/lib/folderMove";
|
||||
|
||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||
inbox: <Inbox size={20} />,
|
||||
@@ -23,6 +24,9 @@ const ROLE_ICONS: Record<string, ReactNode> = {
|
||||
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() {
|
||||
const mailboxes = useMail((s) => s.mailboxes);
|
||||
const loaded = useMail((s) => s.mailboxesLoaded);
|
||||
@@ -34,6 +38,32 @@ export function MailboxTree() {
|
||||
const menu = useMenu();
|
||||
const [menuTarget, setMenuTarget] = 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
|
||||
// collapsed by default. Expansion state is remembered per folder.
|
||||
@@ -93,14 +123,46 @@ export function MailboxTree() {
|
||||
return (
|
||||
<>
|
||||
<nav aria-label="Folders" style={{ marginTop: 6 }}>
|
||||
<div className="nav-section">
|
||||
<span>Folders</span>
|
||||
<div
|
||||
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)}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{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 && (
|
||||
<>
|
||||
@@ -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);
|
||||
// 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.
|
||||
@@ -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 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.dataTransfer.dropEffect = "move";
|
||||
if (!dropping) setDropping(true);
|
||||
@@ -147,6 +210,11 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
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");
|
||||
if (!raw) return;
|
||||
try {
|
||||
@@ -156,12 +224,22 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
/* 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 (
|
||||
<Link
|
||||
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}
|
||||
draggable={movable(m)}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onFolderDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={() => setDropping(false)}
|
||||
onDrop={onDrop}
|
||||
|
||||
Reference in New Issue
Block a user