Approval request

Pro

A 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.

Markdown

Preview

Install

With the Candor CLI

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

TSX · The example above
"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>

PropTypeDefaultDescription
requestrequiredApprovalData—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.
rejectNoteboolean | undefinedfalseReject 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.
changeReact.ReactNode | undefined—The diff or canonical change, shown on demand.
maxChipsnumber | undefined5How 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>) | undefinedchangeHashWorks out an edited change's hash. Defaults to changeHash: the sha256 of the input's canonical JSON. Use your server's own.
classNamestring | 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.

PropTypeDefaultDescription
decisionrequiredDecision—The question, facts, what continues, the two choices and the state.
onChoose((choiceId: string) => void) | undefined—Called with the chosen answer's id.
lockedstring | undefined—Why the viewer can't answer, if they can't; shown on both buttons.
classNamestring | 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.

KeyEnglish
pendingWaiting for approval
submittingRecording decision
approvedApproved
rejectedRejected
rejected-by-systemRejected automatically
withdrawnWithdrawn
expiredExpired
failedDecision not recorded
copiedCopied
copyFailedCouldn’t copy
days{ one: "{count} day", other: "{count} days" }
hours{ one: "{count} hour", other: "{count} hours" }
labelApproval request: {title}
changesProductionChanges 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.
requestedByRequested by
onBehalfOffor {name}
approvalsApprovals
waitingForDecisionWaiting for a decision
waitingForAnotherDecisionWaiting for another decision
notGivenNot given
whereWhere
productionProduction
nonProductionNon-production
touchesTouches
more{count} more
recordsRecords
rowsUnknownUnknown until it runs
undoUndo
cantUndoCan’t be undone: {reason}
changeHashChange hash
viewChangeView the change
whyRejectWhy reject it?
optional(optional)
cancelCancel
rejectReject
rejectWithNoteReject…
approveApprove
recordingRecording…
expiresExpires
withdrawWithdraw
notRecordedThe decision wasn’t recorded.
failedNothingChanged{reason} Nothing was changed.
tryAgainTry again
noDecisionWithinNo decision within {window}. Ask again to restart it.
noDecisionInTimeNo decision in time. Ask again to restart it.
factsChangedIts facts changed, so it can no longer apply.
decidedBy{state} by
quoted“{note}”
changeChange
beforeBefore
afterAfter
notSetNot set
notRecordedValueNot recorded
valueChange{label}: {from} to {to}
restricted{who} only
restrictedWhyOnly {who} can decide this. {reason}
touchesCount{ one: "Touches {count} thing", other: "Touches {count} things" }
openToDecideOpen to decide
decideInFull{why} Open the full request to decide.
cantUndoShortThis can’t be undone.
productionShortThis changes production.
ifNoAnswerIf nobody decides by then, it expires and nothing changes.
editBeforeApprovingEdit before approving
editLabelThe change, as JSON
editInvalidThat isn’t a JSON object, so it can’t be used.
editChangedYou changed
editUnchangedNothing changed yet.
editProposedNoteThe values above are what the agent proposed. If you approve, your edit runs instead.
useOriginalUse the original
approveEditedApprove as edited
editedHashEdited change hash
hashingWorking out the edited change’s hash…
hashFailedThe edited change’s hash couldn’t be worked out, so it can’t be approved.
editConfirmI’ve checked my edit. It runs exactly as shown.
editConfirmFirstTick that you’ve checked your edit first.
editedBeforeRunEdited before it ran
proposedHashAs proposed: {hash}

The fixed words, in English by default. Change any of them under approvalDecision in <LocaleProvider strings={{ … }}>; the table is exported as approvalDecisionStrings.

KeyEnglish
needsDecisionNeeds your decision
decidedHeadingDecided
meanwhileMeanwhile:
answerByAnswer {time}.
agentsPickAgent’s pick
recordingRecording your answer…
decidedYou chose: {choice}
notRecordedYour answer wasn’t recorded.
tryAgainChoose again to retry.