// Backlog tree view: rows, expand/collapse, status cycling, drag handle, add/delete.
const { useState, useRef, useEffect, useMemo } = React;
// Inline priority popover
function PriorityPicker({ value, onChange, onClose }) {
const ref = useRef(null);
useEffect(() => {
const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [onClose]);
return (
e.stopPropagation()}>
{PRIORITIES.map(p => (
))}
);
}
// Inline status popover (replaces ●●● menu)
function StatusPopover({ value, style, onChange, onClose, anchorRect }) {
const ref = useRef(null);
useEffect(() => {
const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [onClose]);
return (
e.stopPropagation()}>
{STATUSES.map(s => (
))}
);
}
// Right-click context menu: set status / archive / delete, fixed at the cursor.
function ItemContextMenu({ item, statusStyle, anchor, onSetStatus, onArchive, onDelete, onClose }) {
const ref = useRef(null);
const [pos, setPos] = useState({ left: anchor.x, top: anchor.y });
// Viewport clamp (upstream ItemPeek pattern): flip left / above when overflowing.
useEffect(() => {
const el = ref.current; if (!el) return;
const r = el.getBoundingClientRect();
const pad = 12, gap = 14;
let left = anchor.x + gap, top = anchor.y + gap;
if (left + r.width > window.innerWidth - pad) left = Math.max(pad, anchor.x - r.width - gap);
if (top + r.height > window.innerHeight - pad) top = Math.max(pad, anchor.y - r.height - gap);
setPos({ left, top });
}, [anchor.x, anchor.y, item]);
// Capture-phase scroll/wheel: any scrollable container must close the cursor-anchored menu.
useEffect(() => {
const onPointerDown = (e) => { if (!ref.current?.contains(e.target)) onClose(); };
const onKeyDown = (e) => { if (e.key === "Escape") onClose(); };
const onMove = () => onClose();
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("scroll", onMove, true);
document.addEventListener("wheel", onMove, true);
window.addEventListener("resize", onMove);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("scroll", onMove, true);
document.removeEventListener("wheel", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [onClose]);
return (
e.preventDefault()}>
Set status
{STATUSES.map(s => (
))}
);
}
// Single tree row
function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
onToggle, onSetStatus, onAddChild, onDelete, onArchive, onEdit, onSetPriority,
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
onDragStart, onDragOver, onDrop,
dimmed, query, dropTarget }) {
const [statusOpen, setStatusOpen] = useState(false);
const [priOpen, setPriOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState(null);
const overdue = isOverdue(item);
const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled";
const isDone = item.status === "done" || item.status === "cancelled";
const renderTitle = () => {
if (!query) return item.title;
const i = item.title.toLowerCase().indexOf(query.toLowerCase());
if (i < 0) return item.title;
return (<>
{item.title.slice(0, i)}
{item.title.slice(i, i + query.length)}
{item.title.slice(i + query.length)}
>);
};
return (
{ setCtxMenu(null); onDragStart?.(e, item); }}
onDragOver={(e) => onDragOver?.(e, item)}
onDrop={(e) => onDrop?.(e, item)}
onContextMenu={(e) => { e.preventDefault(); setStatusOpen(false); setPriOpen(false); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
>
{ctxMenu && ReactDOM.createPortal(
setCtxMenu(null)}/>, document.body)}
{statusOpen && (
setStatusOpen(false)}/>
)}
{priOpen && (
onSetPriority(p)}
onClose={() => setPriOpen(false)}/>
)}
{renderTitle()}
{shouldShowProgress(item) && (
)}
{item.due && (
{dueRelative(item)}
)}
{item.tags?.length > 0 && (
{item.tags.map(t => #{t})}
)}
{item.status === "blocked" && item.reason && (
↳ {item.reason}
)}
{depth < 3 && (
)}
);
}
// Expanded-state map kept in parent so filtering doesn't lose it.
function BacklogTree({ items, expandedMap, setExpanded, onMutate, query, statusStyle, manualOrder }) {
const [draggedId, setDraggedId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const onDragStart = (e, item) => {
setDraggedId(item.id);
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", item.id); } catch (_) {}
};
const onDragOver = (e, item) => {
if (!draggedId || draggedId === item.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTargetId(item.id);
};
const onDrop = (e, item) => {
e.preventDefault();
if (draggedId && draggedId !== item.id) {
onMutate.reorder(draggedId, item.id);
}
setDraggedId(null); setDropTargetId(null);
};
function renderList(list, depth, parentList) {
return list.map((item, idx) => {
const expanded = expandedMap[item.id] ?? !item.collapsed;
const hasChildren = item.children?.length > 0;
const forceOpen = query && item._matches !== true && hasChildren;
const showKids = (expanded || forceOpen) && hasChildren;
// can-move logic: only swap with adjacent same-priority sibling
const samePri = parentList
.map((x, i) => ({ x, i }))
.filter(({ x }) => x.priority === item.priority);
const myIdx = samePri.findIndex(({ x }) => x.id === item.id);
const canMoveUp = myIdx > 0;
const canMoveDown = myIdx >= 0 && myIdx < samePri.length - 1;
return (
setExpanded(item.id, !expanded)}
onSetStatus={(s) => onMutate.setStatus(item.id, s)}
onSetPriority={(p) => onMutate.setPriority(item.id, p)}
onAddChild={() => onMutate.addChild(item.id)}
onDelete={() => onMutate.deleteItem(item.id)}
onArchive={() => onMutate.archiveItem(item.id)}
onEdit={() => onMutate.editItem(item.id)}
onMoveUp={() => onMutate.moveWithinPriority(item.id, -1)}
onMoveDown={() => onMutate.moveWithinPriority(item.id, +1)}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
/>
{showKids && renderList(item.children, depth + 1, item.children)}
);
});
}
return {renderList(items, 0, items)}
;
}
window.BacklogTree = BacklogTree;