From 8692b1cb526a0aace4bddea09db53c29f8bdf7ec Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 8 Sep 2026 05:11:14 -0700 Subject: [PATCH] feat: give the entry-delay picker its own draft state with a separate onCommit callback, so the flow canvas's trigger card saves a typed custom amount on blur, Enter or a stepper click instead of firing one PATCH per keystroke, while the Schedule tab and wizard keep batching every edit into their Save button --- .../app/campaigns/schedule/EntryDelay.tsx | 52 +++++++++++++------ .../app/campaigns/sequences/CampaignFlow.tsx | 5 +- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/web/src/components/app/campaigns/schedule/EntryDelay.tsx b/web/src/components/app/campaigns/schedule/EntryDelay.tsx index b9c1449c..9f0e7623 100644 --- a/web/src/components/app/campaigns/schedule/EntryDelay.tsx +++ b/web/src/components/app/campaigns/schedule/EntryDelay.tsx @@ -19,27 +19,49 @@ const UNIT_OPTIONS: SelectOption[] = [ ]; /** - * Controlled on the value, uncontrolled on which row is showing, so a parent can - * batch into a Save button (Schedule tab) or persist immediately (canvas). + * The amount and unit are held here rather than derived from `value` on every + * render, so the field stays responsive for a parent that only listens for the + * settled value. + * + * `onChange` fires on every edit — for a parent batching into a Save button. + * `onCommit` fires only when the value settles (a preset, a unit change, blur / + * Enter / a stepper click) — for a parent that persists immediately, which must + * not send one request per keystroke. */ export default function EntryDelayPicker({ value, onChange, + onCommit, disabled, }: { value: number; - onChange: (minutes: number) => void; + onChange?: (minutes: number) => void; + onCommit?: (minutes: number) => void; disabled?: boolean; }) { const isPreset = ENTRY_DELAY_PRESETS.some((p) => p.minutes === value); // "Custom" stays open once chosen, even while the typed value happens to // land on a preset, so typing 2 -> 24 hours does not yank the row away. const [custom, setCustom] = React.useState(!isPreset); - const { amount, unit } = splitEntryDelay(value); + const [draft, setDraft] = React.useState(() => splitEntryDelay(value)); + // Re-seed when the value moves for a reason other than this field: a save + // landing, the Schedule tab's Reset, a teammate's edit arriving live. + React.useEffect(() => setDraft(splitEntryDelay(value)), [value]); - const setFromCustom = (nextAmount: number, nextUnit: EntryDelayUnit) => { - const minutes = Math.round(nextAmount) * ENTRY_DELAY_UNIT_MINUTES[nextUnit]; - onChange(Math.max(0, Math.min(ENTRY_DELAY_MAX_MINUTES, minutes))); + const clamp = (minutes: number) => Math.max(0, Math.min(ENTRY_DELAY_MAX_MINUTES, minutes)); + + const pick = (minutes: number) => { + setCustom(false); + setDraft(splitEntryDelay(minutes)); + onChange?.(minutes); + onCommit?.(minutes); + }; + + const editCustom = (amount: number, unit: EntryDelayUnit, settled: boolean) => { + setDraft({ amount, unit }); + const minutes = clamp(Math.round(amount) * ENTRY_DELAY_UNIT_MINUTES[unit]); + onChange?.(minutes); + if (settled) onCommit?.(minutes); }; const chip = (active: boolean) => @@ -57,10 +79,7 @@ export default function EntryDelayPicker({ key={p.minutes} type="button" disabled={disabled} - onClick={() => { - setCustom(false); - onChange(p.minutes); - }} + onClick={() => pick(p.minutes)} className={chip(!custom && value === p.minutes)} > {p.label} @@ -74,16 +93,17 @@ export default function EntryDelayPicker({
setFromCustom(v, unit)} + value={draft.amount} + onChange={(v) => editCustom(v, draft.unit, false)} + onCommit={(v) => editCustom(v, draft.unit, true)} min={0} - max={unit === "days" ? 90 : unit === "hours" ? 2160 : ENTRY_DELAY_MAX_MINUTES} + max={ENTRY_DELAY_MAX_MINUTES / ENTRY_DELAY_UNIT_MINUTES[draft.unit]} disabled={disabled} />
setFromCustom(amount, u as EntryDelayUnit)} + value={draft.unit} + onChange={(u) => editCustom(draft.amount, u as EntryDelayUnit, true)} options={UNIT_OPTIONS} minWidth={130} disabled={disabled} diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index 5e5f5a0e..ed02278f 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -499,7 +499,10 @@ function TriggerNode({ data }: NodeProps) { child of this node, so its clicks would otherwise bubble up the React tree into the canvas's node handler. */}
e.stopPropagation()}> - + {/* onCommit, not onChange: this saves at once, so a + typed custom amount must land on blur / Enter + rather than once per keystroke. */} +

Counted from when the contact entered this campaign.