import { useEffect, useRef, useState } from "react";
import {
COLUMNS,
LANES,
initialCards,
initialLimits,
countIn,
cellCards,
canMove,
moveCard,
} from "./src/utils/model";
import Cell from "./src/components/Cell";
export default function App() {
const [cards, setCards] = useState(initialCards);
const [limits, setLimits] = useState(initialLimits);
const [groupBy, setGroupBy] = useState("assignee");
const [over, setOver] = useState(null);
const [message, setMessage] = useState("");
const [alert, setAlert] = useState("");
const [focusId, setFocusId] = useState(null);
const dragId = useRef(null);
const overRef = useRef(null);
const cardEls = useRef(new Map());
const lanes = LANES[groupBy];
const titleOf = (status) => COLUMNS.find((c) => c.id === status).title;
useEffect(() => {
if (focusId) cardEls.current.get(focusId)?.focus();
}, [cards, focusId]);
function tryMove(id, status, lane, beforeId) {
const card = cards.find((c) => c.id === id);
if (!canMove(cards, limits, id, status)) {
setAlert(
titleOf(status) +
" is at its WIP limit (" +
countIn(cards, status) +
"/" +
limits[status] +
"). " +
card.title +
" was not moved.",
);
return false;
}
setCards(moveCard(cards, id, { status, [groupBy]: lane }, beforeId));
setAlert("");
setMessage(card.title + " moved to " + titleOf(status) + ", " + lane);
return true;
}
const drag = {
start(e, id) {
dragId.current = id;
setFocusId(null);
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", id);
}
},
over(e, lane, status, beforeId) {
if (!dragId.current) return;
const blocked = !canMove(cards, limits, dragId.current, status);
if (!blocked) e.preventDefault();
const o = overRef.current;
if (
!o ||
o.lane !== lane ||
o.status !== status ||
o.beforeId !== beforeId
) {
overRef.current = { lane, status, beforeId, blocked };
setOver(overRef.current);
}
},
drop(e, lane, status, beforeId) {
e.preventDefault();
const id = dragId.current;
if (id) tryMove(id, status, lane, beforeId);
overRef.current = null;
drag.end();
},
end() {
const o = overRef.current;
if (o && o.blocked && dragId.current)
tryMove(dragId.current, o.status, o.lane, null);
dragId.current = null;
overRef.current = null;
setOver(null);
},
};
function onCardKey(e, card) {
if (!e.shiftKey) return;
const c = COLUMNS.findIndex((col) => col.id === card.status);
const l = lanes.indexOf(card[groupBy]);
let to = null;
if (e.key === "ArrowLeft" && c > 0) to = [COLUMNS[c - 1].id, lanes[l]];
if (e.key === "ArrowRight" && c < COLUMNS.length - 1)
to = [COLUMNS[c + 1].id, lanes[l]];
if (e.key === "ArrowUp" && l > 0) to = [card.status, lanes[l - 1]];
if (e.key === "ArrowDown" && l < lanes.length - 1)
to = [card.status, lanes[l + 1]];
if (!to) return;
e.preventDefault();
if (tryMove(card.id, to[0], to[1], null)) setFocusId(card.id);
}
function setLimit(status, raw) {
const n = raw === "" ? null : Math.max(0, parseInt(raw, 10) || 0);
setLimits((l) => ({ ...l, [status]: n }));
}
return (
<main className="app">
<header className="top">
<h1>Team board</h1>
<label>
Swimlanes by{" "}
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
data-testid="group-by"
>
<option value="assignee">Assignee</option>
<option value="priority">Priority</option>
</select>
</label>
</header>
<p id="kb-help" className="hint">
Drag cards between cells, or focus a card and press Shift + arrow keys.
</p>
<div className="scroller">
<div
className="grid"
style={{
gridTemplateColumns:
"96px repeat(" + COLUMNS.length + ", minmax(150px, 1fr))",
}}
>
<div className="corner" />
{COLUMNS.map((col) => {
const n = countIn(cards, col.id);
const limit = limits[col.id];
const state =
limit == null
? ""
: n > limit
? " over-limit"
: n === limit
? " at-limit"
: "";
return (
<div key={col.id} className="col-head" data-col={col.id}>
<span>{col.title}</span>
<span
className={"badge" + state}
data-testid={"badge-" + col.id}
>
{limit == null ? n : n + "/" + limit}
</span>
<input
type="number"
min="0"
placeholder="no limit"
value={limit == null ? "" : limit}
onChange={(e) => setLimit(col.id, e.target.value)}
aria-label={"WIP limit for " + col.title}
data-testid={"limit-" + col.id}
/>
</div>
);
})}
{lanes.map((lane) => [
<h2 key={lane} className="lane-head" data-lane={lane}>
{lane}
</h2>,
...COLUMNS.map((col) => (
<Cell
key={lane + col.id}
lane={lane}
col={col}
cards={cellCards(cards, groupBy, lane, col.id)}
over={
over && over.lane === lane && over.status === col.id
? over
: null
}
drag={drag}
onCardKey={onCardKey}
cardRef={(id, el) =>
el ? cardEls.current.set(id, el) : cardEls.current.delete(id)
}
/>
)),
])}
</div>
</div>
<p role="status" className="sr-only" data-testid="live">
{message}
</p>
<p role="alert" className="alert" data-testid="alert">
{alert}
</p>
</main>
);
}