Files
backlog/web/tree.jsx
T
2026-08-28 19:47:46 +02:00

302 lines
12 KiB
React

// 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 (
<div className="pri-pop" ref={ref} onClick={(e) => e.stopPropagation()}>
{PRIORITIES.map(p => (
<button key={p} className={`pri-pop-item pri-${p} ${value === p ? "active" : ""}`}
onClick={() => { onChange(p); onClose(); }}>
{p === "P0" && <span className="p0-dot"/>}
<span>{p}</span>
<span className="pri-pop-hint">{p === "P0" ? "Burning" : p === "P1" ? "High" : p === "P2" ? "Normal" : "Low"}</span>
</button>
))}
</div>
);
}
// 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 (
<div className="status-pop" ref={ref} onClick={(e) => e.stopPropagation()}>
{STATUSES.map(s => (
<button key={s.key} className={`status-pop-item ${value === s.key ? "active" : ""}`}
onClick={() => { onChange(s.key); onClose(); }}>
<StatusIcon status={s.key} style={style} size={14}/>
<span>{s.label}</span>
</button>
))}
</div>
);
}
// 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 (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onContextMenu={(e) => e.preventDefault()}>
<div className="ctx-menu-label">Set status</div>
{STATUSES.map(s => (
<button key={s.key} className={`ctx-menu-item ${s.key === item.status ? "active" : ""}`}
onClick={() => { onSetStatus(s.key); onClose(); }}>
<StatusIcon status={s.key} style={statusStyle} size={13}/>
<span>{s.label}</span>
</button>
))}
<div className="ctx-menu-sep"/>
<button className="ctx-menu-item" onClick={() => { onArchive(); onClose(); }}>
<Icon name="archive"/>
<span>Archive</span>
</button>
<button className="ctx-menu-item danger" onClick={() => { onDelete(); onClose(); }}>
<Icon name="trash"/>
<span>Delete</span>
</button>
</div>
);
}
// 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)}
<mark className="hl">{item.title.slice(i, i + query.length)}</mark>
{item.title.slice(i + query.length)}
</>);
};
return (
<div
className={`row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`}
draggable
onDragStart={(e) => { 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(<ItemContextMenu item={item} statusStyle={statusStyle} anchor={ctxMenu} onSetStatus={onSetStatus} onArchive={onArchive} onDelete={onDelete} onClose={() => setCtxMenu(null)}/>, document.body)}
<span className="row-rail" style={{ width: depth * 18 }}/>
<button
className={`twisty ${hasChildren ? "" : "empty"}`}
onClick={hasChildren ? onToggle : undefined}
aria-label={expanded ? "Collapse" : "Expand"}
tabIndex={hasChildren ? 0 : -1}
>
{hasChildren && <Icon name={expanded ? "chevron" : "chevronRight"} size={12}/>}
</button>
<span className="drag-handle" title="Drag to reorder within priority"><Icon name="drag" size={14}/></span>
<span className="status-wrap">
<button
className="status-btn"
onClick={() => setStatusOpen(v => !v)}
title={`${STATUS_BY_KEY[item.status].label} — click to change`}
>
<StatusIcon status={item.status} style={statusStyle} size={14}/>
</button>
{statusOpen && (
<StatusPopover value={item.status} style={statusStyle}
onChange={onSetStatus} onClose={() => setStatusOpen(false)}/>
)}
</span>
<span className="pri-wrap">
<button className={`pri pri-${item.priority}`} onClick={() => setPriOpen(v => !v)} title="Change priority">
{item.priority === "P0" && <span className="p0-dot"/>}
{item.priority}
</button>
{priOpen && (
<PriorityPicker value={item.priority}
onChange={(p) => onSetPriority(p)}
onClose={() => setPriOpen(false)}/>
)}
</span>
<span className={`title ${isDone ? "done" : ""}`} onDoubleClick={onEdit}>
{renderTitle()}
</span>
{shouldShowProgress(item) && (
<ProgressGauge value={progressFor(item)} />
)}
{item.due && (
<span className={`due ${overdue ? "overdue" : ""}`}>{dueRelative(item)}</span>
)}
{item.tags?.length > 0 && (
<span className="tags">
{item.tags.map(t => <span key={t} className="tag">#{t}</span>)}
</span>
)}
{item.status === "blocked" && item.reason && (
<span className="reason" title={item.reason}>↳ {item.reason}</span>
)}
<span className="row-spacer"/>
<span className="row-actions">
<button className="icon-btn" onClick={onMoveUp} disabled={!canMoveUp} data-tip="Move up" aria-label="Move up">
<svg width="14" height="14" viewBox="0 0 16 16"><polyline points="4,10 8,5 12,10" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<button className="icon-btn" onClick={onMoveDown} disabled={!canMoveDown} data-tip="Move down" aria-label="Move down">
<svg width="14" height="14" viewBox="0 0 16 16"><polyline points="4,6 8,11 12,6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="action-sep"/>
{depth < 3 && (
<button className="icon-btn" onClick={onAddChild} data-tip="Add sub-item" aria-label="Add sub-item">
<Icon name="addChild" size={14}/>
</button>
)}
<button className="icon-btn" onClick={onEdit} data-tip="Edit item" aria-label="Edit item">
<Icon name="edit" size={13}/>
</button>
<button className="icon-btn danger" onClick={onDelete} data-tip="Delete" aria-label="Delete">
<Icon name="trash" size={13}/>
</button>
</span>
</div>
);
}
// 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 (
<React.Fragment key={item.id}>
<ItemRow
item={item} depth={depth}
expanded={expanded || forceOpen}
hasChildren={hasChildren}
statusStyle={statusStyle}
dimmed={query && item._matches === false}
dropTarget={dropTargetId === item.id && draggedId !== item.id}
query={query}
onToggle={() => 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)}
</React.Fragment>
);
});
}
return <div className="tree">{renderList(items, 0, items)}</div>;
}
window.BacklogTree = BacklogTree;