Change set review
ProMany 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.
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
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
"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>
| Prop | Type | Default | Description |
|---|---|---|---|
| editsrequired | ChangeSetEdit[] | — | 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. |
| undorequired | Rollback | 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. |
| busy | boolean | undefined | false | The kept edits are being applied: every choice and Apply are locked, with the reason. |
| cannotApply | string | undefined | — | Why this person can't apply the set, when they can't (they proposed it, or lack the role). Locks Apply with it. |
| requireDecision | boolean | undefined | false | Lock Apply until every edit is kept or dropped. Without it, undecided edits are left out and Apply says how many. |
| pageSize | number | undefined | 25 | Records per page. Defaults to 25. |
| hashOf | ((kept: ChangeSetEdit[]) => string | Promise<string>) | undefined | changeHash | Works 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. |
| title | string | undefined | — | A heading over the list, also its accessible name. Defaults to "Proposed changes" for the name, with no heading shown. |
| headingLevel | HeadingLevel | undefined | — | The heading's level when `title` is set. Defaults to one below the nearest `HeadingScope`, else 2. |
| className | string | 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.
| Key | English |
|---|---|
| label | Proposed changes |
| keptCount | {kept} kept |
| droppedCount | {dropped} dropped |
| toDecide | {open} to decide |
| show | Show |
| all | All |
| add | Added |
| change | Changed |
| remove | Removed |
| option | {kind} {count} |
| row | {kind}: {record} |
| decision | Keep or drop the change to {record} |
| keep | Keep |
| drop | Drop |
| keepKey | K |
| dropKey | D |
| keepPage | Keep all on this page |
| dropPage | Drop all on this page |
| notSet | Not set |
| notRecorded | Not recorded |
| valueChange | {label}: {from} to {to} |
| valueAdd | {label}: {to} |
| valueRemove | {label}: {from}, removed |
| empty | No changes proposed. |
| noneShown | No changes of this kind. |
| showing | Showing {from}–{to} of {total} |
| previousPage | Previous page |
| nextPage | Next page |
| firstPage | This is the first page. |
| lastPage | This is the last page. |
| page | {page} / {pages} |
| pages | Pages |
| hintMove | move |
| hintKeep | keep |
| hintDrop | drop |
| hintEither | {a}/{b} |
| hintItem | {keys} {action} |
| undo | Undo |
| undoAdd | Added records |
| undoChange | Changed records |
| undoRemove | Removed records |
| cantUndo | Can’t be undone: {reason} |
| openNote | { one: "{count} change not decided won’t be applied.", other: "{count} changes not decided won’t be applied." } |
| hash | Hash of the kept changes |
| apply | { one: "Apply {count} change", other: "Apply {count} changes" } |
| applying | Applying |
| lockedNone | Keep at least one change to apply. |
| lockedOpen | { one: "{count} change hasn’t been decided yet.", other: "{count} changes haven’t been decided yet." } |
| lockedApplying | The kept changes are being applied. |
| lockedHashing | Working out the hash of the kept changes… |
| hashFailed | The hash of the kept changes couldn’t be worked out, so they can’t be applied. |
Motion
| Moment | Family | What it does |
|---|---|---|
| tint | tint | A 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