Skip to the solution
MediumMachine Coding11 min read

Build a Kanban Board with Swimlanes & WIP Limits

68 views

The problem

Question presented to candidate: "You already have a basic drag-and-drop Kanban board. Now the team wants two things. First, work-in-progress limits: Doing may hold at most 2 cards and Review at most 1, and the board must refuse a move that would break that. Second, swimlanes: rows that group cards by assignee, switchable to group by priority. Dragging a card into another row should reassign it. How would you extend the board?"

Requirements:

  • Four columns (To Do, Doing, Review, Done) with an editable WIP limit each (empty means no limit) and a count badge showing count/limit, highlighted at the limit and when over it.
  • Swimlane rows derived from a grouping field (assignee or priority), switchable at runtime; each lane by column cell is a drop target.
  • Dropping into another lane updates the grouping field; dropping into another column updates the status.
  • Moves that would exceed a column limit are refused during the drag (no-drop cursor) and announced; moves inside the same column are always allowed.
  • A keyboard path that goes through the same rule (Shift + arrow keys).
  • Lowering a limit below the current count is allowed and shows the column as over its limit.

What a strong answer should cover:

  • One flat, ordered card list as the source of truth; columns, lanes and counts are derived, never stored twice.
  • A single canMove guard used by the drag hover, the drop commit and the keyboard path.
  • In HTML5 drag and drop, refusing a drop means not calling preventDefault() in dragover, so drop never fires; the rejection is reported from dragend.
  • Re-checking the rule at commit time, not trusting the hover state.
  • Why same-column moves never break a WIP limit, and what "over limit" means after a limit is lowered.
  • Accessible feedback: a role="alert" message for rejections, visible state on the badge, keyboard moves with focus restored.

Clarifying questions expected:

  • "Does the limit count cards across all lanes, or per lane?"
  • "What should happen if a limit is lowered below the current count: block the change, or flag the column?"
  • "Should a refused move be impossible to attempt, or allowed and then rolled back?"

Code / implementation expected: Yes. The derived grid, the guard, the drag and keyboard paths, and the limit editor.

  • React
  • Kanban
  • Drag and Drop
  • Swimlanes

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read ยท 10 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
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); // { lane, status, beforeId, blocked }
  const [message, setMessage] = useState("");
  const [alert, setAlert] = useState("");
  const [focusId, setFocusId] = useState(null);
  const dragId = useRef(null);
  const overRef = useRef(null); // last hover target, read in dragend
  const cardEls = useRef(new Map());
  const lanes = LANES[groupBy];
  const titleOf = (status) => COLUMNS.find((c) => c.id === status).title;

  // Keyboard moves land the card in another cell = another DOM node.
  useEffect(() => {
    if (focusId) cardEls.current.get(focusId)?.focus();
  }, [cards, focusId]);

  // The single entry point for BOTH drag and keyboard: one guard, one commit.
  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);
      // Not calling preventDefault is how HTML5 DnD says "no drop here":
      // the browser shows a no-drop cursor and never fires drop.
      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;
      // guarded again: never trust the hover
      if (id) tryMove(id, status, lane, beforeId);
      overRef.current = null; // handled: dragend must not report it
      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>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 75 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build a Calculator

Continue