Approval request
ProA request for second-level approval: what changes, where, what it touches, whether it can be undone, and the hash that ties the decision to exactly this change. Decisions are only shown once recorded. A viewer who can't decide sees why on the buttons. Production requests carry a warning. A request that needs several approvals shows how many it has, and stays pending until it has them all. An agent asking for a person names both. A change to one thing shows its values before and after; a request only certain people may decide carries a badge saying who, and why. density="compact" fits a narrow column or a notification. With onApproveEdited and the request's input, the person deciding can edit the change first: they see what they changed against what the agent proposed, and the approval carries the edited change's own hash.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/approval-request
Brings in: agent-avatar, badge, button, change-hash, collapsible, entity-chip, locale, locked, relative-time, textarea, use-copy, work, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import type { ApprovalRequest as ApprovalData, Decision } from "@/lib/work"
import { ApprovalDecision, ApprovalRequest } from "@/components/approval-request"
const ago = (m: number) => new Date(Date.now() - m * 60_000).toISOString()
const ahead = (h: number) => new Date(Date.now() + h * 3_600_000).toISOString()
const initial: ApprovalData = {
id: "ap1",
title: "Add region to customers",
summary: "A region column on customers, shown on the customer page and backfilled from billing country.",
requester: { agent: { id: "a-orch", name: "Keel", role: "orchestrator" } },
environment: "Acme web",
production: true,
artifactHash: "9f2c4e81b7a03d55c6e1f0a9b8d7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9",
impact: {
components: [
{ kind: "migration", name: "0142_add_customer_region", label: "Add customers.region" },
{ kind: "component", name: "web/src/customers/CustomerHeader.tsx", label: "CustomerHeader" },
{ kind: "job", name: "assign_region", label: "Assign region job" },
],
rows: "unknown",
},
rollback: { available: true, how: "Revert the pull request and run the migration's down step; the backfilled values go with the column." },
createdAt: ago(25),
expiresAt: ahead(7 * 24 - 1),
state: "pending",
}
// A change to one thing: its values before and after, decided only by the owner, shown compact as in a notification.
const roleChange: ApprovalData = {
id: "ap2",
title: "Change Jonas’s role",
summary: "Jonas becomes an Admin on Acme web.",
requester: { name: "Dana" },
environment: "Acme web",
production: false,
detail: "Members",
artifactHash: "4b1e09c2d7f35a8e6b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a",
impact: { components: [{ kind: "member", name: "jonas", label: "Jonas" }], rows: 1 },
rollback: { available: true, how: "Set the role back; nothing else changes." },
createdAt: ago(5),
expiresAt: ahead(48),
state: "pending",
target: { kind: "member", name: "jonas", label: "Jonas" },
values: [{ label: "Role", from: "Member", to: "Admin" }],
restrictedTo: { who: "the owner", reason: "It changes who can approve changes." },
}
// A change the approver may edit first: they see what they changed, and approve the edited change under its own hash.
const batchSize: ApprovalData = {
id: "ap3",
title: "Raise the nightly export’s batch size",
summary: "Bigger batches so the export finishes before the morning reports run.",
requester: { agent: { id: "a-ops", name: "Relay", role: "operator" } },
environment: "Acme web",
detail: "Jobs",
production: false,
artifactHash: "c3d9a7e1f05b2468ace0b1d2c3e4f5061728394a5b6c7d8e9f00112233445566",
impact: { components: [{ kind: "job", name: "nightly_export", label: "Nightly export" }], rows: "unknown" },
rollback: { available: true, how: "Set the batch size back; the next run uses it." },
createdAt: ago(12),
expiresAt: ahead(24),
state: "pending",
target: { kind: "job", name: "nightly_export", label: "Nightly export" },
values: [{ label: "Batch size", from: "2000", to: "10000" }],
input: { job: "nightly_export", batchSize: 10000 },
}
const limitReached: Decision = {
id: "d1",
question: "The backfill needs more than its limit. Raise it, or finish with what’s done?",
facts: [
{ label: "Spent so far", value: "$1.62 of $2.00" },
{ label: "Done", value: "4,120 of 9,800 customers" },
],
continues: "Showing the region on the customer page carries on; it doesn’t need the backfill.",
choices: [
{ id: "raise", label: "Raise the limit to $4", detail: "About $1.90 more to finish." },
{ id: "finish", label: "Finish with what’s done", detail: "The rest keep an empty region." },
],
recommended: "raise",
state: "open",
}
export default function Example() {
const [request, setRequest] = React.useState(initial)
const [decision, setDecision] = React.useState(limitReached)
const [role, setRole] = React.useState(roleChange)
const [batch, setBatch] = React.useState(batchSize)
const decide = (state: "approved" | "rejected", note?: string) => setRequest((r) => ({ ...r, state, decision: { by: "You", at: new Date().toISOString(), note } }))
return (
<div className="flex max-w-2xl flex-col gap-6">
<ApprovalRequest request={request} onApprove={() => decide("approved")} onReject={(_hash, note) => decide("rejected", note)} rejectNote />
<div className="max-w-sm">
<ApprovalRequest
density="compact"
request={role}
onApprove={() => setRole((r) => ({ ...r, state: "approved", decision: { by: "You", at: new Date().toISOString() } }))}
onReject={() => setRole((r) => ({ ...r, state: "rejected", decision: { by: "You", at: new Date().toISOString() } }))}
onOpen={() => {}}
/>
</div>
{/* Edit before approving: the edited change gets its own hash, and the record keeps the one proposed. */}
<ApprovalRequest
request={batch}
onApprove={() => setBatch((r) => ({ ...r, state: "approved", decision: { by: "You", at: new Date().toISOString() } }))}
onApproveEdited={(hash, input) =>
setBatch((r) => ({ ...r, state: "approved", artifactHash: hash, input, values: [{ label: "Batch size", from: "2000", to: String(input.batchSize) }], decision: { by: "You", at: new Date().toISOString(), editedFrom: r.artifactHash } }))
}
onReject={() => setBatch((r) => ({ ...r, state: "rejected", decision: { by: "You", at: new Date().toISOString() } }))}
/>
{/* ApprovalDecision: two equal answers, nothing pre-selected; the agent's pick is marked, not chosen. */}
<ApprovalDecision decision={decision} onChoose={(id) => setDecision((d) => ({ ...d, state: "decided", chosen: id }))} />
</div>
)
}<ApprovalRequest>
| Prop | Type | Default | Description |
|---|---|---|---|
| requestrequired | ApprovalData | — | The request: the change, where, its impact, whether it can be undone, its hash and state. |
| onApprove | ((hash: string) => void) | undefined | — | Called on approve with the change hash, so the decision binds to exactly this change. |
| onReject | ((hash: string, note?: string) => void) | undefined | — | Called on reject with the change hash, and the rejecter's note when `rejectNote` is on and they wrote one. |
| rejectNote | boolean | undefined | false | Reject first opens a box for an optional note saying why, then asks to confirm. Off by default. |
| onWithdraw | (() => void) | undefined | — | Shown to the requester while pending. |
| onRetry | (() => void) | undefined | — | Shows Try again when the decision failed to record; called when it's clicked. |
| change | React.ReactNode | undefined | — | The diff or canonical change, shown on demand. |
| maxChips | number | undefined | 5 | How many affected components show as chips before the rest fold away. Defaults to 5. |
| density | "default" | "compact" | undefined | "default" | "compact" for a narrow column, a phone or a notification: the title, the values or summary, where, how many things it touches, the hash, expiry and what happens if nobody answers. A change that can't be undone or that changes production offers only `onOpen` there, never Approve. |
| onOpen | (() => void) | undefined | — | Compact only: opens the full request (a page, a sheet). Required to decide a change that can't be undone. |
| onApproveEdited | ((hash: string, input: Record<string, unknown>) => void) | undefined | — | Lets the person deciding edit the request's `input` before approving (full density only). Called with the edited change's hash and the edited input; an edit that changes nothing approves through `onApprove` as usual. Your server recomputes the hash from the input it receives and refuses the approval if it differs. |
| hashOf | ((input: Record<string, unknown>) => string | Promise<string>) | undefined | changeHash | Works out an edited change's hash. Defaults to changeHash: the sha256 of the input's canonical JSON. Use your server's own. |
| className | string | undefined | — | Extra classes for the outer element. |
<ApprovalDecision>
A decision the work stops for, with two answers: the question, the facts to decide on, what carries on meanwhile, and two equal buttons. Nothing is pre-selected and neither answer is styled as the default; the agent's pick is marked, not chosen. Once recorded, the card says what was chosen. A variant of ApprovalRequest for a person's own decision rather than a second-level approval of a change.
| Prop | Type | Default | Description |
|---|---|---|---|
| decisionrequired | Decision | — | The question, facts, what continues, the two choices and the state. |
| onChoose | ((choiceId: string) => void) | undefined | — | Called with the chosen answer's id. |
| locked | string | undefined | — | Why the viewer can't answer, if they can't; shown on both buttons. |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under approvalRequest in <LocaleProvider strings={{ … }}>; the table is exported as approvalRequestStrings.
| Key | English |
|---|---|
| pending | Waiting for approval |
| submitting | Recording decision |
| approved | Approved |
| rejected | Rejected |
| rejected-by-system | Rejected automatically |
| withdrawn | Withdrawn |
| expired | Expired |
| failed | Decision not recorded |
| copied | Copied |
| copyFailed | Couldn’t copy |
| days | { one: "{count} day", other: "{count} days" } |
| hours | { one: "{count} hour", other: "{count} hours" } |
| label | Approval request: {title} |
| changesProduction | Changes production in {environment} |
| approvalsProgress | { one: "{given} of {count} approval", other: "{given} of {count} approvals" } |
| stale | {environment} changed since this was requested. The change is re-checked before it applies, and refused if it no longer matches. |
| requestedBy | Requested by |
| onBehalfOf | for {name} |
| approvals | Approvals |
| waitingForDecision | Waiting for a decision |
| waitingForAnotherDecision | Waiting for another decision |
| notGiven | Not given |
| where | Where |
| production | Production |
| nonProduction | Non-production |
| touches | Touches |
| more | {count} more |
| records | Records |
| rowsUnknown | Unknown until it runs |
| undo | Undo |
| cantUndo | Can’t be undone: {reason} |
| changeHash | Change hash |
| viewChange | View the change |
| whyReject | Why reject it? |
| optional | (optional) |
| cancel | Cancel |
| reject | Reject |
| rejectWithNote | Reject… |
| approve | Approve |
| recording | Recording… |
| expires | Expires |
| withdraw | Withdraw |
| notRecorded | The decision wasn’t recorded. |
| failedNothingChanged | {reason} Nothing was changed. |
| tryAgain | Try again |
| noDecisionWithin | No decision within {window}. Ask again to restart it. |
| noDecisionInTime | No decision in time. Ask again to restart it. |
| factsChanged | Its facts changed, so it can no longer apply. |
| decidedBy | {state} by |
| quoted | “{note}” |
| change | Change |
| before | Before |
| after | After |
| notSet | Not set |
| notRecordedValue | Not recorded |
| valueChange | {label}: {from} to {to} |
| restricted | {who} only |
| restrictedWhy | Only {who} can decide this. {reason} |
| touchesCount | { one: "Touches {count} thing", other: "Touches {count} things" } |
| openToDecide | Open to decide |
| decideInFull | {why} Open the full request to decide. |
| cantUndoShort | This can’t be undone. |
| productionShort | This changes production. |
| ifNoAnswer | If nobody decides by then, it expires and nothing changes. |
| editBeforeApproving | Edit before approving |
| editLabel | The change, as JSON |
| editInvalid | That isn’t a JSON object, so it can’t be used. |
| editChanged | You changed |
| editUnchanged | Nothing changed yet. |
| editProposedNote | The values above are what the agent proposed. If you approve, your edit runs instead. |
| useOriginal | Use the original |
| approveEdited | Approve as edited |
| editedHash | Edited change hash |
| hashing | Working out the edited change’s hash… |
| hashFailed | The edited change’s hash couldn’t be worked out, so it can’t be approved. |
| editConfirm | I’ve checked my edit. It runs exactly as shown. |
| editConfirmFirst | Tick that you’ve checked your edit first. |
| editedBeforeRun | Edited before it ran |
| proposedHash | As proposed: {hash} |
The fixed words, in English by default. Change any of them under approvalDecision in <LocaleProvider strings={{ … }}>; the table is exported as approvalDecisionStrings.
| Key | English |
|---|---|
| needsDecision | Needs your decision |
| decidedHeading | Decided |
| meanwhile | Meanwhile: |
| answerBy | Answer {time}. |
| agentsPick | Agent’s pick |
| recording | Recording your answer… |
| decided | You chose: {choice} |
| notRecorded | Your answer wasn’t recorded. |
| tryAgain | Choose again to retry. |