Skip to content
Tier

Change set review

Pro

Many record edits an agent proposes, for a person to keep or drop one by one. Each edit is a record with its fields before and after, marked as added, changed or removed. Nothing starts kept: Apply stays locked until the person keeps at least one, and edits left undecided are not applied. The list filters by kind, pages a large set, and moves from the keyboard (arrows between rows, K keep, D drop; a second press clears the choice). The two letters are strings, keepKey and dropKey, so a translation can pick ones that match its words.

Apply calls back with exactly the kept edits and their change hash (the same changeHash the server can run), so your server applies only what was approved and refuses anything that drifted. A choice made on an edit that later changes is cleared, because it was made on something else. What can and can't be undone is your app's to say, through undo, and it shows beside Apply.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0 and 2.8.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the 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/change-set-review

Brings in: bidi, button, change-hash, chat, entity-chip, heading-level, heading-scope, locale, locked, tokens, use-keyboard-review, work, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import { ChangeSetReview, type ChangeSetEdit } from "@/components/candor/change-set-review"

const proposed: ChangeSetEdit[] = [
  {
    id: "c-1042",
    kind: "change",
    record: { kind: "record", name: "customer/1042", label: "Northwind Traders" },
    fields: [
      { field: "segment", label: "Segment", from: "SMB", to: "Mid-market" },
      { field: "owner", label: "Owner", from: "Priya Shah", to: "Jonas Berg" },
    ],
  },
  {
    id: "c-1187",
    kind: "change",
    record: { kind: "record", name: "customer/1187", label: "Fabrikam" },
    fields: [{ field: "billing_email", label: "Billing email", from: null, to: "ap@fabrikam.example" }],
  },
  {
    id: "c-new-1",
    kind: "add",
    record: { kind: "record", name: "customer/new-1", label: "Tailspin Toys" },
    fields: [
      { field: "segment", label: "Segment", to: "SMB" },
      { field: "owner", label: "Owner", to: "Priya Shah" },
    ],
  },
  {
    id: "c-0981",
    kind: "remove",
    record: { kind: "record", name: "customer/0981", label: "Contoso (duplicate)" },
    fields: [
      { field: "segment", label: "Segment", from: "SMB" },
      { field: "open_invoices", label: "Open invoices", from: "0" },
    ],
  },
]

export default function Example() {
  const [applying, setApplying] = React.useState(false)
  const [sent, setSent] = React.useState<string>()
  return (
    <div className="flex max-w-2xl flex-col gap-3">
      <ChangeSetReview
        title="Customer clean-up proposed by the data agent"
        edits={proposed}
        busy={applying}
        undo={{
          add: { available: true, how: "Delete the new customer from its page." },
          change: { available: true, how: "Each field can be set back from the customer’s history." },
          remove: { available: false, reason: "a removed customer is deleted with its notes." },
        }}
        onApply={(kept, hash) => {
          setApplying(true)
          // Your server rebuilds the kept edits from the ids it holds, recomputes the hash, and applies them only if it matches.
          window.setTimeout(() => {
            setApplying(false)
            setSent(`${kept.map((e) => e.id).join(", ")} · sha256:${hash.slice(0, 12)}…`)
          }, 1200)
        }}
      />
      {sent && <p className="text-xs text-muted-foreground">Sent to the server: {sent}</p>}
    </div>
  )
}

<ChangeSetReview>

PropTypeDefaultDescription
editsrequiredChangeSetEdit[]—The proposed edits, in the order to show them. Each `id` is unique in the set.
onApplyrequired(kept: ChangeSetEdit[], hash: string) => void—Called on Apply with the kept edits, in the order of `edits`, and their hash from `hashOf`. Your server recomputes the hash from the edits it holds for those ids and applies nothing if it differs.
undorequiredRollback | Record<ChangeSetKind, Rollback>—What can be undone once applied: one answer for the whole set, or one for each kind (adding may be undone while removing can't). Shown beside Apply. Your app says it; the component doesn't guess.
busyboolean | undefinedfalseThe kept edits are being applied: every choice and Apply are locked, with the reason.
cannotApplystring | undefined—Why this person can't apply the set, when they can't (they proposed it, or lack the role). Locks Apply with it.
requireDecisionboolean | undefinedfalseLock Apply until every edit is kept or dropped. Without it, undecided edits are left out and Apply says how many.
pageSizenumber | undefined25Records per page. Defaults to 25.
hashOf((kept: ChangeSetEdit[]) => string | Promise<string>) | undefinedchangeHashWorks out the kept edits' hash. Defaults to changeHash: the sha256 of their canonical JSON, shown as "sha256:". Use your server's own; its hash is shown without an algorithm's name, since only your app knows it.
titlestring | undefined—A heading over the list, also its accessible name. Defaults to "Proposed changes" for the name, with no heading shown.
headingLevelHeadingLevel | undefined—The heading's level when `title` is set. Defaults to one below the nearest `HeadingScope`, else 2.
classNamestring | undefined—Extra classes for the outer element.

Strings

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

KeyEnglish
labelProposed changes
keptCount{kept} kept
droppedCount{dropped} dropped
toDecide{open} to decide
showShow
allAll
addAdded
changeChanged
removeRemoved
option{kind} {count}
row{kind}: {record}
decisionKeep or drop the change to {record}
keepKeep
dropDrop
keepKeyK
dropKeyD
keepPageKeep all on this page
dropPageDrop all on this page
notSetNot set
notRecordedNot recorded
valueChange{label}: {from} to {to}
valueAdd{label}: {to}
valueRemove{label}: {from}, removed
emptyNo changes proposed.
noneShownNo changes of this kind.
showingShowing {from}–{to} of {total}
previousPagePrevious page
nextPageNext page
firstPageThis is the first page.
lastPageThis is the last page.
page{page} / {pages}
pagesPages
hintMovemove
hintKeepkeep
hintDropdrop
hintEither{a}/{b}
hintItem{keys} {action}
undoUndo
undoAddAdded records
undoChangeChanged records
undoRemoveRemoved records
cantUndoCan’t be undone: {reason}
openNote{ one: "{count} change not decided won’t be applied.", other: "{count} changes not decided won’t be applied." }
hashHash of the kept changes
apply{ one: "Apply {count} change", other: "Apply {count} changes" }
applyingApplying
lockedNoneKeep at least one change to apply.
lockedOpen{ one: "{count} change hasn’t been decided yet.", other: "{count} changes haven’t been decided yet." }
lockedApplyingThe kept changes are being applied.
lockedHashingWorking out the hash of the kept changes…
hashFailedThe hash of the kept changes couldn’t be worked out, so they can’t be applied.

Motion

MomentFamilyWhat it does
tinttintA row's Keep and Drop buttons change their background and border when pressed, and a ring fades in on keyboard focus.

Set with --cd-motion-change-set-review-<moment>-* in your brand file; the families are in the design reference.

Types

ChangeSetKind
What an edit does to its record: adds it, changes some of its fields, or removes it.
"add" | "change" | "remove"
ChangeSetField
One field of a record, before and after, as text your app formatted. `null` means no value ("Not set"). Leaving `from` out of a change means your app didn't record what it was, which is shown as that and never guessed. An added record's fields need only `to`, a removed one's only `from`.
{ field: string; label?: string | undefined; from?: string | null | undefined; to?: string | null | undefined }
ChangeSetEdit
One record edit the agent proposes: the record, what it does to it, and the fields it touches.
{ /** Stable for this edit in this set. It is part of what's hashed, so your server can find the edit by it. */ id: string; kind: ChangeSetKind; /** The record, named by your app: its kind, identifier and label. */ record: EntityRef; /** The fields, in the order to show them. */ fields: ChangeSetField[]; }

Terms used here

Change hash
The SHA-256 of a change in one canonical form (object keys sorted at every depth), the same in the browser and on the server. An approval is made against a hash, so the server can check that what runs is the change the person saw. If the person edits the change before allowing it, the hash is worked out again for the edited version.

Every term is defined in the glossary (Markdown).

Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

Rollback from @/lib/candor/work
{ available: true; how: string } | { available: false; reason: string }
HeadingLevel from @/lib/candor/heading-level
A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.
1 | 2 | 3 | 4 | 5 | 6
EntityRef from @/lib/candor/chat
{ kind: EntityKind; /** The identifier, e.g. "orders.status" or "billing.invoice.due_date". */ name: string; /** A friendlier label when there is one, e.g. "Region". */ label?: string | undefined; /** Where it lives, when more than one place is in scope, e.g. "Shop staging". */ location?: string | undefined; }
EntityKind from @/lib/candor/chat
What kind of thing an entity is: "record", "table", "workflow"… Any string; the app registers each kind's icon and noun (see EntityKindsProvider), and presets cover the kinds of the systems an app commonly works with.
string