ComponentsAI

Proposed change row

A change Flo proposed, waiting for the member. The same row everywhere it shows: Today, a record's Next, the Inbox rail, a chat and Tasks › To review. Nothing applies until someone approves it.

Playground

Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.

  • Sara LeeNew Contact at Halcyon Bank
States
import { ProposedChangeRow } from '@/ui/proposed-change-row';<ProposedChangeRow summary="Sara Lee, New Contact at Halcyon Bank">  New Contact at Halcyon Bank</ProposedChangeRow>

In a group

One row per change: the kind's icon, the record as an inline record chip, then the change, usually a value diff. Hover or focus a row: it takes the hovered fill and the due date gives way to Approve (✓) and Deny (✕), which fade in over 150ms. Try them: the outcome replaces the buttons, and Undo takes an approval back.

  • Maya ChenNotice periodfrom3 monthsto1 month
  • Maya ChenfromLong listtoCV sent
  • Halcyon BankSend Sara the shortlistDue tomorrow
import { useState } from 'react';
import { Lozenge } from '@/ui/lozenge';
import { ProposedChangeRow, type ChangeStatus } from '@/ui/proposed-change-row';
import { RecordChip } from '@/ui/record-chip';
import { ValueDiff } from '@/ui/value-diff';

export default function ProposedChangeRowGroup() {
  const [status, setStatus] = useState<Record<string, ChangeStatus>>({});
  const decide = (id: string, next: ChangeStatus) => () => setStatus((before) => ({ ...before, [id]: next }));
  return (
    <ul className="w-full max-w-120 rounded-xlarge bg-surface shadow-raised">
      <ProposedChangeRow
        kind="field_change"
        record={<RecordChip variant="inline" type="candidate" name="Maya Chen" />}
        summary="Maya Chen, Notice period 3 months to 1 month"
        status={status.notice}
        onApprove={decide('notice', 'approved')}
        onDeny={decide('notice', 'denied')}
        onUndo={decide('notice', 'pending')}
      >
        <ValueDiff label="Notice period" before="3 months" after="1 month" />
      </ProposedChangeRow>
      <ProposedChangeRow
        kind="stage_move"
        record={<RecordChip variant="inline" type="candidate" name="Maya Chen" />}
        summary="Maya Chen, Stage Long list to CV sent"
        status={status.stage}
        onApprove={decide('stage', 'approved')}
        onDeny={decide('stage', 'denied')}
        onUndo={decide('stage', 'pending')}
      >
        <ValueDiff before="Long list" after={<Lozenge phase="client">CV sent</Lozenge>} />
      </ProposedChangeRow>
      <ProposedChangeRow
        kind="task"
        record={<RecordChip variant="inline" type="company" name="Halcyon Bank" src="/examples/halcyon-bank.svg" />}
        summary="Halcyon Bank, Send Sara the shortlist"
        meta="Due tomorrow"
        status={status.task}
        onApprove={decide('task', 'approved')}
        onDeny={decide('task', 'denied')}
      >
        Send Sara the shortlist
      </ProposedChangeRow>
    </ul>
  );
}

One change, waiting

A change shown on its own, or one approved only by its own click, carries Deny and Approve as secondary buttons. The page's primary stays the page's.

  • Sara LeeNew Contact at Halcyon Bank
import { ProposedChangeRow } from '@/ui/proposed-change-row';
import { RecordChip } from '@/ui/record-chip';

export default function ProposedChangeRowAlone() {
  return (
    <ul className="w-full max-w-120 rounded-xlarge bg-surface shadow-raised">
      <ProposedChangeRow
        alone
        kind="new_person"
        record={<RecordChip variant="inline" type="contact" name="Sara Lee" />}
        summary="Sara Lee, New Contact at Halcyon Bank"
        onApprove={() => {}}
        onDeny={() => {}}
      >
        New Contact at Halcyon Bank
      </ProposedChangeRow>
    </ul>
  );
}

Expiring

The expiry shows in the warning colour. A row left open past it stops offering Approve; the app sets status="expired".

  • Sara LeeEmail Maya Chen's CVExpires in 20 min
import { ProposedChangeRow } from '@/ui/proposed-change-row';
import { RecordChip } from '@/ui/record-chip';

export default function ProposedChangeRowExpiring() {
  return (
    <ul className="w-full max-w-120 rounded-xlarge bg-surface shadow-raised">
      <ProposedChangeRow
        alone
        kind="email"
        record={<RecordChip variant="inline" type="contact" name="Sara Lee" />}
        summary="Sara Lee, Email Maya Chen's CV"
        expiry="Expires in 20 min"
        onApprove={() => {}}
        onDeny={() => {}}
      >
        Email Maya Chen&apos;s CV
      </ProposedChangeRow>
    </ul>
  );
}

Deciding

While a decision is on its way the row's buttons are disabled, so a second click sends nothing.

  • Priya NairfromCV senttoInterview
import { Lozenge } from '@/ui/lozenge';
import { ProposedChangeRow } from '@/ui/proposed-change-row';
import { RecordChip } from '@/ui/record-chip';
import { ValueDiff } from '@/ui/value-diff';

export default function ProposedChangeRowDeciding() {
  return (
    <ul className="w-full max-w-120 rounded-xlarge bg-surface shadow-raised">
      <ProposedChangeRow
        alone
        status="deciding"
        kind="stage_move"
        record={<RecordChip variant="inline" type="candidate" name="Priya Nair" />}
        summary="Priya Nair, Stage CV sent to Interview"
        onApprove={() => {}}
        onDeny={() => {}}
      >
        <ValueDiff before="CV sent" after={<Lozenge phase="working">Interview</Lozenge>} />
      </ProposedChangeRow>
    </ul>
  );
}

Decided

The outcome replaces the buttons, in words: Approved and Done in success, with a check for the icon; Denied and Expired in subtle; Not done and Didn't work in danger. A note follows the outcome. An approved change offers Undo.

  • Maya ChenfromLong listtoCV sentApproved
  • Owen HartSalaryfrom£95,000to£110,000Denied · 110 includes his bonus
  • Halcyon BankSend Sara the shortlistExpired
  • Tom EllisNew ContactDidn't work
import { Lozenge } from '@/ui/lozenge';
import { ProposedChangeRow } from '@/ui/proposed-change-row';
import { RecordChip } from '@/ui/record-chip';
import { ValueDiff } from '@/ui/value-diff';

const later = () => new Promise<void>((resolve) => setTimeout(resolve, 800));

export default function ProposedChangeRowDecided() {
  return (
    <ul className="w-full max-w-140 rounded-xlarge bg-surface shadow-raised">
      <ProposedChangeRow status="approved" kind="stage_move" record={<RecordChip variant="inline" type="candidate" name="Maya Chen" />} summary="Maya Chen, Stage Long list to CV sent" onUndo={later}>
        <ValueDiff before="Long list" after={<Lozenge phase="client">CV sent</Lozenge>} />
      </ProposedChangeRow>
      <ProposedChangeRow status="denied" kind="field_change" record={<RecordChip variant="inline" type="candidate" name="Owen Hart" />} summary="Owen Hart, Salary" note="110 includes his bonus">
        <ValueDiff label="Salary" before="£95,000" after="£110,000" />
      </ProposedChangeRow>
      <ProposedChangeRow status="expired" kind="task" record={<RecordChip variant="inline" type="company" name="Halcyon Bank" src="/examples/halcyon-bank.svg" />} summary="Halcyon Bank, Send Sara the shortlist">
        Send Sara the shortlist
      </ProposedChangeRow>
      <ProposedChangeRow status="failed" kind="new_person" record={<RecordChip variant="inline" type="contact" name="Tom Ellis" />} summary="Tom Ellis, New Contact">
        New Contact
      </ProposedChangeRow>
    </ul>
  );
}

On this page