All files / src/features/sorting AsyncActionButton.tsx

93.33% Statements 14/15
95% Branches 19/20
100% Functions 2/2
100% Lines 14/14

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 7811x                                                                       80x 80x 80x     23x 23x 23x 23x 23x 23x   5x 5x 5x       80x                                              
"use client";
 
import { useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui";
 
// Shared shell for the sorting control buttons (Keep sorting / Pause / Bring back). Each is a single quiet tap that fires one async action and then
// navigates. The double-submit guard, the identical gentle error notice, and the
// "Just a moment…" busy label all live here so a fix lands in one place.
//
// The caller supplies `action` (the fetch; it throws on a non-ok/rejected
// request) and, on success, decides navigation inside `action` itself via the
// injected router — keeping each button's exact URL/body/nav verbatim. On any
// throw the busy state is rolled back and the gentle notice shows; on success
// the caller has already navigated (the island then unmounts or refreshes).
export function AsyncActionButton({
  action,
  label,
  busyLabel = "Just a moment…",
  variant = "ghost",
  align = "start",
  title,
  ariaLabel,
  fullWidth = false,
  className,
}: {
  action: () => Promise<void>;
  label: string;
  busyLabel?: string;
  variant?: "primary" | "secondary" | "ghost";
  align?: "start" | "end";
  title?: string;
  ariaLabel?: string;
  /** Full width below `md` (the phone action bar), natural width above. */
  fullWidth?: boolean;
  className?: string;
}): ReactNode {
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const inFlight = useRef(false);
 
  async function run() {
    Iif (inFlight.current) return; // a double-tap before `busy` renders is a no-op
    inFlight.current = true;
    setBusy(true);
    setError("");
    try {
      await action(); // caller navigates on success; this island then unmounts/refreshes
    } catch {
      setError("That didn't go through — give it another try in a moment.");
      setBusy(false);
      inFlight.current = false;
    }
  }
 
  return (
    <div className={`flex flex-col gap-1 ${align === "end" ? "items-end" : "items-start"}${fullWidth ? " w-full md:w-auto" : ""}`}>
      <Button
        type="button"
        variant={variant}
        className={[fullWidth && "w-full md:w-auto", className].filter(Boolean).join(" ") || undefined}
        onClick={run}
        disabled={busy}
        title={title}
        aria-busy={busy}
        // While busy the busy label is the accessible name, so it is announced.
        aria-label={busy ? undefined : ariaLabel}
      >
        {busy ? busyLabel : label}
      </Button>
      {error && (
        <span role="status" className="text-sm text-ink-soft">
          {error}
        </span>
      )}
    </div>
  );
}