// 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 => (
))}
);
}
// Single tree row
function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
onToggle, onSetStatus, onAddChild, onDelete, onEdit, onSetPriority,
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
onDragStart, onDragOver, onDrop,
dimmed, query, dropTarget }) {
const [statusOpen, setStatusOpen] = useState(false);
const [priOpen, setPriOpen] = useState(false);
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 (
onDragStart?.(e, item)}
onDragOver={(e) => onDragOver?.(e, item)}
onDrop={(e) => onDrop?.(e, item)}
>
{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)}
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;