Data Export Request
A GDPR-style data export block — pick scopes against live size estimates, confirm a request that states what the archive contains and who can open it, watch a build that never dresses a queue position up as progress, and download a link that visibly counts down to its own expiry.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/data-export-request.jsonPrompt
Build a React + TypeScript + Tailwind "DataExportRequest" block with zod,
lucide-react and the shadcn primitives Button, Badge and AlertDialog.
Contract
- A zod schema in a sibling contract file is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
scopes: { id, label, estimatedBytes, included, description?,
unavailableReason? }[];
current?: { id?, status: "queued" | "preparing" | "ready" | "failed",
progress (0..1), queuedAhead?, readyAt?, expiresAt?, bytes?,
scopeIds?, href?, error? };
past: { id, requestedAt, scopes: string[], bytes, expiresAt, href? }[];
linkTtlHours? }.
- `estimatedBytes` is an ESTIMATE (pre-compression) and `bytes` is the finished
archive's REAL size. They are never presented as the same number: estimates
are prefixed "≈" and say the finished file will differ.
- Instants (readyAt / expiresAt / requestedAt) are ISO strings, epoch ms or
Dates — never pre-baked "in 5 hours" strings, so the wording is recomputed
instead of going stale between the fetch and the paint.
- The block asserts NOTHING legal. No statutory deadline, no retention promise,
no "your data is encrypted". It renders the instants, sizes and labels it is
given; a `notice` slot takes the consumer's own legal copy, and the only
window it will state before a request is `linkTtlHours`, when supplied.
- Props = z.infer of the schema plus: now? (injected clock);
onRequest?(scopeIds); onDownload?(exportId | null); onRetry?;
requestBlockedReason?; notice?; label? ("Export your data"); emptyState?;
errorMessage?; formatBytes?; formatDuration?; formatAbsolute?;
expiryWarningHours? (6); maxPast? (5); className plus the remaining element
props, ref forwarded to the <section>.
Behavior
- A QUEUE POSITION IS NOT PROGRESS. While `status: "queued"` the component
ignores `progress` even if the API sends one, and renders an INDETERMINATE
progressbar — role="progressbar" with aria-valuetext but deliberately no
aria-valuenow, which is the ARIA way to say "running, amount unknown". The
panel says so in words too ("nothing has been built yet, so there is no
percentage to show"), names the position ("3 exports ahead of yours" / "next
in line") and labels `readyAt` as an estimate that moves with the queue. Only
`preparing` gets a determinate bar and a percentage.
- CONSEQUENCES BEFORE THE CONFIRM. Requesting opens an AlertDialog that lists
the picked scopes with their estimates, states the archive is built from the
data as it stands right now, and states that anyone holding the download link
can open the archive until it expires — plus the exact window when
`linkTtlHours` is given, and otherwise a promise of nothing more than "the
expiry is shown next to the link". onRequest fires at most once per confirm:
the guard is a ref keyed to the current job's identity, because two clicks
dispatched in one task both read the same stale state. The dialog outlives the
state it was opened in — the block is polled — so the confirm re-reads the
blocker against the data the click actually lands on, and refuses rather than
queueing a second archive behind a card that already says one is being built.
- A BLOCKED ACTION EXPLAINS ITSELF. The request button is never a dead disabled
control, and never an absent one: it renders whether or not a picker sits above
it, because every "Request again" in the history leads to it and a re-request
with nowhere to land would otherwise announce advice about a control that was
not on screen. It takes aria-disabled (never the native attribute, which would
drop the tab stop and with it the sentence explaining the blocker), keeps its
aria-describedby pointing at that sentence, and answers EVERY click — not just
the first — by re-announcing it. Blockers, in order: a job is already being
prepared; the consumer's own `requestBlockedReason` (a rate limit, a pending
identity check), which outranks the block's own optimistic "request sent"
marker because a blocker is the ANSWER to the request that marker is waiting
on; nothing available to pick at all; nothing picked. A scope with
`unavailableReason` does the same at row level — it says who can export it
instead of offering a checkbox that silently refuses to tick.
- THE SELECTION IS THE READER'S. Ticks are stored as OVERRIDES keyed by scope
id; anything untouched falls back to that scope's own `included`. A poll that
re-sends the same scopes therefore cannot untick a box mid-thought, while a
scope that appears later still arrives with the default its owner chose.
- Re-request is a prefill, not a second request path: "Request again" on an
expired export (or on a failed job) writes that export's scopes into the
picker and opens the same confirmation. Scope ids that no longer exist are
dropped and SAID SO ("2 of 3 kinds from that export can still be exported"),
and the blocker for that click is recomputed from the selection just applied
— never from the selection as it was a moment earlier.
- EXPIRY IS DERIVED FROM DATA, per row. `expiresAt <= now` flips a ready job or
a history row to "expired": the download affordance disappears (a link that
would 410 is worse than no link) and a re-request takes its place. Expiry is
said of the LINK and nothing else — whether the archive itself is still kept
is a retention claim only `notice` can make. The live
link shows a countdown in a role="timer" — implicit aria-live="off", so it
does not interrupt a screen reader every second — that turns destructive-
coloured inside `expiryWarningHours`.
- THE BLOCK OWNS NO CLOCK when you inject one. Pass `now` and no timer runs at
all, so SSR, a screenshot and a replayed poll agree. Leave it out and ONE
interval ticks for the whole block, subscribed through useSyncExternalStore
(server snapshot = "no clock", so hydration cannot disagree) and cleared both
on unmount and before any resubscribe. With no clock at all, timestamps
render as absolute UTC instants and expiry is not guessed at.
- Percentages that partition the estimate use LARGEST REMAINDER in tenths of a
percent: exact per-mille shares, floored, with the leftover units handed to
the largest fractional parts (ties to the larger value, then the earlier
index). Every selection totals exactly 100.0%, and the bar's segment widths
are drawn from those same integers so the picture and the labels cannot
disagree. One scope in a real account dwarfs the others, which is why tenths
and not whole percents.
- Downloads are honest: an `href` renders a real <a download>, otherwise
`onDownload` renders a button, and with neither the control is simply absent.
History is capped at `maxPast` with an explicit "Showing 5 of 9" note — never
a silent cut, and an empty history says nothing has EVER been built only when
there is no current job either, because a ready archive three lines above turns
that sentence into a contradiction. `status: "ready"` with no scopes, no job
and no history renders the empty branch rather than a picker with zero
checkboxes.
Rendering & styling
- Semantic tokens only: bg-card / border for the shell, bg-muted/30 for the job
panel, bg-primary with descending alpha (75/55/35/20) for the composition
segments — ramps of ONE hue, because the segments are parts of one archive
and a rainbow would read as unrelated things — bg-muted for tracks and
skeletons, text-destructive for a failing job and an expiring link,
accent-primary on the native checkboxes. No hard-coded colours; the only
inline styles are segment and progress widths.
- Every animation is decorative and guarded by motion-reduce: the queued pulse,
the preparing spinner, the width transitions, the skeleton shimmer. The
countdown is not an animation and keeps running.
- One persistent sr-only role="status" line carries request, blocker and
re-request outcomes, stamped with a change token so re-announcing the SAME
sentence still mutates the node instead of being swallowed by React's state
bail-out; the confirmation returns focus to the request button via
onCloseAutoFocus, because the dialog has no trigger for Radix to restore to.
- Sizes and countdowns are tabular-nums; long scope labels and worker errors use
min-w-0 + wrap-anywhere (not break-words: only the former lowers min-content
width) and are never clamped. The whole block reads at 420 px.
Customization levers
- Scope list: any number of scopes, in any order; add `description` for a line
of explanation and `unavailableReason` to state a per-row blocker. The
composition bar and every total follow the data — nothing is hardcoded.
- Units and wording: `formatBytes` swaps SI for binary multiples or another
locale, `formatDuration` swaps the countdown wording, `formatAbsolute` owns
the timestamps and the no-clock fallback.
- Urgency: `expiryWarningHours` decides when a live link turns destructive;
raise it for short-lived links, drop it to 0 to never shout.
- Density: `maxPast` trims the history (the "showing N of M" note appears by
itself); drop the composition bar or the description lines for a compact
settings-page variant without touching the request logic.
- Policy: `requestBlockedReason` closes the request path with your own sentence;
`linkTtlHours` is the only window the confirmation will state; `notice` is
where your retention policy, statutory deadline and contact belong.
- Live time: pass a `now` you tick once per second app-wide instead of letting
each block own an interval — or pass a frozen instant for tests and
screenshots.Concepts
- A queue position is not progress — the honest read while queued is "nothing has been built yet", so the bar goes indeterminate (
role="progressbar"withoutaria-valuenow) and the number on offer is a position in a line, not a percentage. A bar that creeps while a job hasn't started is the single most common lie on an export page, and it teaches people to distrust the one that follows. - Consequences before the confirm — the dialog says what the archive contains, that it is a snapshot of right now, and that whoever holds the link can open it until it expires. That last sentence is the actual risk of a data export, and it belongs in front of the request rather than in a help article afterwards.
- A blocked button keeps its tab stop — blockers are
aria-disabledplus a written reason wired througharia-describedby, never the nativedisabledattribute: disabling removes the control from the tab order and takes the explanation with it, leaving a keyboard reader with a button that cannot be reached and a reason that cannot be found. - Largest remainder, to a tenth — one scope in a real account dwarfs the rest, so whole percents would round the small ones to 0 and the labels would stop adding up. Exact per-mille shares are floored and the leftover units go to the largest fractional parts, which makes every selection total exactly 100.0% — and the segment widths reuse those integers, so the picture cannot drift from the caption.
- Expiry is data, not a hope — every link carries an instant, so a history row can say "expired" on its own authority, withhold a download that would 410, and offer a re-request instead. The re-request is a prefill into the same confirmation, so there is exactly one path that can create an export.
- No clock, and no claims — instants in, wording out: inject
nowand the block starts no timer at all; leave it out and oneuseSyncExternalStoreinterval serves the whole block and dies with it. The same restraint applies to language: retention windows, statutory deadlines and contacts arrive throughnotice, because a component cannot know what your DPA promises.
Webhooks Manager
A four-state webhook endpoint manager — event multi-select, reveal-and-rotate signing secrets with the overlap window stated, test sends that explain when they are blocked, and per-endpoint deliveries with retry.
SSO Setup
A four-state SAML/OIDC connection panel — copyable values in both directions, DNS domain proof, claim mapping, a test verdict that names the broken setting, and an enforcement switch that spells out who gets locked out.