Feedback

Partial Failure Summary

The receipt for one bulk action that half worked — counts as a sentence, failures grouped by cause, and a retry scoped by whether the successes were committed or rolled back.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronRight, CircleAlert, CircleCheck, Download, RotateCcw, Undo2 } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type {
  PartialFailureSummaryData,
  PartialFailureSummaryFailure,
  PartialFailureSummaryItem,
  PartialFailureSummaryUnit,
} from "./partial-failure-summary.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/partial-failure-summary.json

Prompt

Build a React + TypeScript + Tailwind "PartialFailureSummary" card (lucide-react
icons, zod, shadcn Button + Badge) that reports the immediate result of ONE bulk
action — not a job board, no clock, no polling: every number is final.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; action: string;
    total: number; succeeded: string[];
    failed: { id, label, reason, code?, retryable? }[];
    skipped: { id, label, reason }[];
    transactional: boolean; unit?: { one, other }; verb?: string; runId?: string }.
- Props = z.infer of that schema, plus onRetry?(ids: string[]),
  onDownloadReport?(csv, filename), onReload?(), errorMessage? and the native
  section attributes; forwardRef to the root <section> and spread the rest.
- `succeeded` is ids only — the card needs their count and, in an atomic run,
  their ids to re-run; it never needs 40k labels.
- `skipped` is NOT `failed`: a skipped row was never attempted, so it is counted,
  grouped and coloured separately and stays out of every failure number.

Behavior
- Four first-class branches. `loading` keeps the real action name in the header
  (it is already known) over a skeleton body; `empty` says the run matched
  nothing and wrote nothing; `error` means the REPORT did not load and says so
  explicitly — "that is this panel failing, not the action, it may well have
  finished" — offering reload, never a re-run; `ready` renders the summary.
- Headline: one assembled sentence, e.g. "42 of 50 subscribers imported — 5
  failed, 3 skipped." Build it as a string, not as JSX fragments, so no space
  can be swallowed. Zero failures and zero skips collapse it to "All N …".
- Commitment line, always rendered, derived from `transactional` + the failure
  count: committed / partially committed / rolled back, naming how many rows each
  claim covers. An atomic run that failed also DROPS the consumer's verb from the
  headline ("imported" claims a commit that never happened) and falls back to
  "succeeded". Rows the payload never accounts for are never claimed either way:
  a report whose arrays undershoot `total` names the gap instead of reading the
  silence as "nothing was attempted", and its header badge says "Incomplete"
  rather than "Complete".
- Proportion bar + legend over succeeded / failed / skipped / not-reported, with
  whole percents apportioned by LARGEST REMAINDER so they total exactly 100.
  Arrays that overrun `total` widen the denominator instead of printing >100%;
  arrays that undershoot it show the gap as "not reported". A slice that happened
  never prints 0%: anything non-empty is floored up to 1%, borrowing the point
  from the largest share, so 499 of 500 cannot legend as "Succeeded 100%" beside
  "Failed 0%".
- Failures are grouped by cause (`code` when present, else the reason text),
  biggest group first, ties alphabetical — a stable total order, never insertion
  order. Each group is a disclosure (button aria-expanded + aria-controls over a
  role=region panel) listing every affected row as label + id. The largest
  failure group starts open; skipped groups start closed. Each group's meta line
  promises only what exists: a retryable group reads "included in a retry" when
  `onRetry` was supplied and a neutral "safe to run again" when it was not.
- Retry is SCOPED by the same flag, and is the whole point:
  · non-atomic → onRetry(ids of failures with retryable !== false), labelled
    "Retry N failed", with a note that the already-saved rows are left alone and
    how many failures were excluded because they need the source fixed first;
  · atomic → onRetry(succeeded + failed ids), labelled "Re-run all N", with a
    note that the first attempt wrote nothing so this is not a second write, and
    — when some failures are blocked — that the re-run carries them along and
    will fail on them again until they are fixed at the source;
  · nothing retryable → NO button, atomic or not: re-running a set whose every
    failure needs the source fixed rolls back exactly the same way, so the
    transaction flag cannot buy a button back. State the blocker and the fix.
  · no onRetry prop → no affordance at all. Never render a dead disabled button.
- The report button builds an RFC 4180 CSV (outcome, id, label, reason, code)
  of every failed and skipped row: fields containing a comma, quote or newline are
  quoted and embedded quotes doubled, records end CRLF, and cells starting with
  = + - @ or a tab/CR get a leading apostrophe so a user-supplied label cannot
  become a spreadsheet formula. Without onDownloadReport the card downloads it
  itself via a Blob URL (UTF-8 BOM so Excel decodes it) through an anchor that is
  appended to the document, clicked and removed — a detached anchor is ignored by
  some browsers — and the URL is revoked a second later on a timeout that is
  cleared on unmount, because revoking it immediately cancels the transfer in
  others. Both bugs ship the user an empty file.
- Accessibility: the summary is announced through an sr-only polite live region
  filled one animation frame AFTER mount (a region that arrives pre-filled
  announces nothing), and the frame is cancelled on unmount and before rebuild.
  Scrollable group panels are focusable so keyboard users can reach the overflow.
  Nothing clips the disclosure focus ring: a full-bleed row inside a rounded
  container rounds its own corners instead of sitting in an `overflow-hidden`
  ancestor, which would paint the ring and then cut it away on every side.

Rendering & styling
- Semantic tokens only: bg-card / border for the card, bg-primary for succeeded,
  bg-destructive (and bg-destructive/10 + text-destructive for counts) for
  failures, bg-muted-foreground/50 for skipped, bg-foreground/20 for unreported,
  text-muted-foreground for supporting copy. No hex, no palette classes.
- Numbers tabular-nums; long labels, reasons and ids use wrap-anywhere so nothing
  is clipped at 420px; cn() merges className.
- Only two transitions (row hover, chevron rotation) and both are disabled under
  motion-reduce; the skeleton's pulse too. Collapsed panels use the `hidden`
  attribute, so there is no animation to reduce in the first place.

Customization levers
- Vocabulary: `unit` and `verb` carry the whole sentence — "files deleted",
  "invites sent", "invoices voided" — no string in the component is action-specific.
- Grouping key: swap `code ?? reason` for a coarser bucket (HTTP class, owning
  team) when your errors carry more structure than one code.
- Density: drop the proportion bar and keep the legend for a compact toast-sized
  card, or drop the legend and keep the bar for a wide one.
- Default disclosure: open every failure group, or none, by changing the single
  `index === 0` fallback in the open-state map.
- Report: hand `onDownloadReport` to a server endpoint, or reuse the exported
  `buildFailureReportCsv` to feed a share sheet or clipboard instead of a file.
- Emphasis: `Badge` variants carry the outcome pill (Complete / Partial /
  Failed) — swap in your own status vocabulary without touching the counts.

Concepts

  • Skipped is not failed — a skipped row was never attempted, so it gets its own count, its own colour and its own groups; folding it into the failure number sends people hunting a bug that does not exist.
  • Commitment statement — the card says outright whether the successful part was committed or rolled back, because the same counts mean opposite things and guessing is how a write gets duplicated.
  • Retry scope follows the transaction — non-atomic runs retry the failures only; atomic runs re-run everything they touched, since the successes were undone too. The button's label and note both change with it.
  • Grouped by cause, expandable to rows — eighty failures with three causes read as three lines; the individual rows stay one disclosure away instead of being summarised out of existence.
  • Blocked, not disabled — when nothing is retryable the card explains why and what to fix instead of rendering a button that could only fail. Atomicity does not buy the button back: re-running a set whose every failure needs the source fixed rolls back exactly the same way.
  • Largest remainder — whole percents floor-then-distribute so the split always totals exactly 100, in every payload the card is handed, and a slice that happened is never rounded down to 0% — one failure in 500 is 1%, not a card claiming 100% success.

On This Page