All files / src/features/results MakeRevisionButton.tsx

97.36% Statements 37/38
100% Branches 30/30
87.5% Functions 7/8
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 1173x                             3x           34x 34x 34x 34x 34x 34x   34x 34x 34x 2x 2x         7x 7x 7x 7x           7x 4x 4x               3x 3x     4x 2x     2x 2x 2x       34x 3x                     2x             1x 1x                         31x   5x                      
"use client";
 
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui";
import { quietLink } from "@/lib/design/quiet-link";
 
// Spec §4.4: a saved ranking stays as it is; "Make a revision" opens a copy (pinned
// to the saved order) on the Ranking, where saving it issues its own link.
// When the Ranking holds hand placements the copy would move, the server
// answers 409 with the count and the person chooses (purses-w7ss.5).
type Phase = { kind: "idle" } | { kind: "busy" } | { kind: "asking"; pinCount: number };
 
function revisionNote(pinCount: number): string {
  return pinCount === 1
    ? "You've moved 1 piece by hand on your ranking. A revision sets your ranking back to this saved order, so that move won't be kept."
    : `You've moved ${pinCount} pieces by hand on your ranking. A revision sets your ranking back to this saved order, so those moves won't be kept.`;
}
 
export function MakeRevisionButton({ resultId, rankingHref }: { resultId: string; rankingHref: string }) {
  const router = useRouter();
  const [phase, setPhase] = useState<Phase>({ kind: "idle" });
  const [error, setError] = useState("");
  const trigger = useRef<HTMLButtonElement>(null);
  const note = useRef<HTMLParagraphElement>(null);
  const returnFocus = useRef(false);
 
  useEffect(() => {
    if (phase.kind === "asking") note.current?.focus();
    if (phase.kind === "idle" && returnFocus.current) {
      returnFocus.current = false;
      trigger.current?.focus();
    }
  }, [phase.kind]);
 
  async function revise(discardPins: boolean) {
    setPhase({ kind: "busy" });
    setError("");
    try {
      const res = await fetch(`/api/sort-results/${resultId}/supersede`, {
        method: "POST",
        ...(discardPins
          ? { headers: { "content-type": "application/json" }, body: JSON.stringify({ discardPins: true }) }
          : {}),
      });
      if (res.status === 409) {
        const body: unknown = await res.json().catch(() => null);
        if (
          body &&
          typeof body === "object" &&
          "code" in body &&
          body.code === "revision_discards_pins" &&
          "pinCount" in body &&
          typeof body.pinCount === "number"
        ) {
          setPhase({ kind: "asking", pinCount: body.pinCount });
          return;
        }
      }
      if (!res.ok) throw new Error("supersede");
      router.push(rankingHref);
    } catch {
      // A failed confirm unmounts the note; send focus back to the trigger, not <body>.
      if (discardPins) returnFocus.current = true;
      setError("That didn't open just now — nothing was lost. Try again whenever you like.");
      setPhase({ kind: "idle" });
    }
  }
 
  if (phase.kind === "asking") {
    return (
      <div
        role="group"
        aria-label="Before you revise"
        className="flex max-w-prose flex-col items-start gap-3 rounded-control border border-rim bg-glass px-4 py-3"
      >
        <p ref={note} tabIndex={-1} className="text-sm text-ink focus:outline-hidden">
          {revisionNote(phase.pinCount)}
        </p>
        <div className="flex flex-wrap gap-2">
          {/* secondary, not primary: the page's one primary is SharePanel's "Create link". */}
          <Button type="button" variant="secondary" onClick={() => revise(true)}>
            Start the revision
          </Button>
          <Button
            type="button"
            variant="ghost"
            onClick={() => {
              returnFocus.current = true;
              setPhase({ kind: "idle" });
            }}
          >
            Keep my ranking
          </Button>
        </div>
        <Link href={rankingHref} className={`${quietLink} text-sm`}>
          See your ranking first
        </Link>
      </div>
    );
  }
 
  return (
    <div className="flex flex-col items-start gap-1">
      <Button ref={trigger} type="button" variant="secondary" onClick={() => revise(false)} disabled={phase.kind === "busy"}>
        {phase.kind === "busy" ? "Getting ready…" : "Make a revision"}
      </Button>
      {error && (
        <p role="status" className="text-sm text-ink-soft">
          {error}
        </p>
      )}
    </div>
  );
}