# Change set review

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.

- Registry name: `change-set-review` (`@candor/change-set-review`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/change-set-review

## Install

```bash
npx candor-ui add change-set-review
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/change-set-review`.
It brings in: bidi, button, change-hash, chat, entity-chip, heading-level, heading-scope, locale, locked, tokens, use-keyboard-review, work, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"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>

Props:

- `edits: ChangeSetEdit[]`: The proposed edits, in the order to show them. Each `id` is unique in the set.
- `onApply: (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.
- `undo: 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` (default `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` (default `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` (default `25`): Records per page. Defaults to 25.
- `hashOf?: ((kept: ChangeSetEdit[]) => string | Promise<string>) | undefined` (default `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`

## Strings (`changeSetReview`)

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

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

## Types

### ChangeSetKind

What an edit does to its record: adds it, changes some of its fields, or removes it.

```ts
"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`.

```ts
{ 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.

```ts
{
  /** 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[];
}
```

## Types from other items

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

### Rollback

Import: `import type { Rollback } from "@/lib/candor/work"` (item `work`, https://candor.themethodic.com/docs/work.md)

```ts
{ available: true; how: string } | { available: false; reason: string }
```

### HeadingLevel

Import: `import type { HeadingLevel } from "@/lib/candor/heading-level"` (item `heading-level`, https://candor.themethodic.com/docs/heading-level.md)

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.

```ts
1 | 2 | 3 | 4 | 5 | 6
```

### EntityRef

Import: `import type { EntityRef } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

```ts
{
  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

Import: `import type { EntityKind } from "@/lib/candor/chat"` (item `chat`, https://candor.themethodic.com/docs/chat.md)

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.

```ts
string
```

## Motion

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

## 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. (https://candor.themethodic.com/glossary.md#change-hash)

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
