feat: polish campaign step controls

Persist wait-day edits only at commit points and use the shared in-app confirm flow for deleting campaign steps. Update sequence deletion cache handling so one mutation instance can remove any step.
This commit is contained in:
Matthew Meszaros
2026-06-04 18:57:24 +02:00
parent cdb2eaa229
commit 25cf6d044f
4 changed files with 81 additions and 73 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ import { useConfirm } from "@/hooks/context/confirm";
const TABS = [
{ label: "Overview", path: "", Icon: BarChart3Icon },
{ label: "Leads", path: "/leads", Icon: UsersIcon },
{ label: "Sequences", path: "/sequences", Icon: ListChecksIcon },
{ label: "Steps", path: "/sequences", Icon: ListChecksIcon },
{ label: "Schedule", path: "/schedule", Icon: CalendarIcon },
{ label: "Settings", path: "/preferences", Icon: Settings2Icon },
] as const;
@@ -1,5 +1,5 @@
import React from "react";
import { ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react";
import { CheckIcon, ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react";
import toast from "react-hot-toast";
import { NumberInput } from "@/components/ui/field";
import SequenceBox from "./SequenceBox";
@@ -8,6 +8,7 @@ import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdate
import useDeleteSequence from "@/lib/api/hooks/app/campaigns/sequences/useDeleteSequence";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { useConfirm } from "@/hooks/context/confirm";
const MAX_STEPS = 5;
@@ -22,7 +23,11 @@ function WaitConnector({
sequence: Sequence;
}) {
const update = useUpdateSequence(campaignId, sequence.id);
// `draft` is the live edited value; it only persists on a commit point
// (blur / Enter / stepper) — never on every keystroke — so an in-flight
// save can't snap the field back mid-typing.
const [draft, setDraft] = React.useState<number>(sequence.wait_after);
const [savedAt, setSavedAt] = React.useState(0);
// Keep the local draft in lockstep with the canonical value when the cache
// updates (e.g. after a save elsewhere).
@@ -33,10 +38,11 @@ function WaitConnector({
const commit = (v: number) => {
const next = Math.max(0, Math.round(v));
setDraft(next);
if (next === sequence.wait_after) return;
if (next === sequence.wait_after) return; // no change → nothing to save
update.mutate(
{ wait_after: next },
{
onSuccess: () => setSavedAt((n) => n + 1),
onError: (err) => {
setDraft(sequence.wait_after);
toast.error(buildError(err as unknown as AppError));
@@ -45,6 +51,15 @@ function WaitConnector({
);
};
// Briefly flash a "saved" tick after a successful commit.
const [showSaved, setShowSaved] = React.useState(false);
React.useEffect(() => {
if (savedAt === 0) return;
setShowSaved(true);
const t = setTimeout(() => setShowSaved(false), 1400);
return () => clearTimeout(t);
}, [savedAt]);
return (
<div className="flex items-stretch gap-2 pl-2.5">
<div className="flex flex-col items-center">
@@ -58,16 +73,22 @@ function WaitConnector({
<span className="text-[11px] text-slate-500">Wait</span>
<NumberInput
value={draft}
onChange={commit}
onChange={setDraft}
onCommit={commit}
min={0}
max={60}
className="w-20"
align="center"
/>
<span className="text-[11px] text-slate-500">days</span>
{update.isPending && (
{update.isPending ? (
<Loader2Icon className="w-3 h-3 text-slate-300 animate-spin" />
)}
) : showSaved ? (
<span className="inline-flex items-center gap-0.5 text-[10.5px] font-medium text-emerald-600">
<CheckIcon className="w-3 h-3" />
Saved
</span>
) : null}
</div>
</div>
</div>
@@ -89,30 +110,26 @@ export default function StepRail({
onCreate: () => void;
creating: boolean;
}) {
const [confirmId, setConfirmId] = React.useState<string | null>(null);
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const deleteSequence = useDeleteSequence(campaignId, confirmId ?? "");
const confirm = useConfirm();
const deleteSequence = useDeleteSequence(campaignId);
const confirmTarget = sequences.find((s) => s.id === confirmId) ?? null;
const confirmIndex = sequences.findIndex((s) => s.id === confirmId);
async function runDelete() {
if (!confirmId) return;
const id = confirmId;
setDeletingId(id);
try {
await deleteSequence.mutateAsync(id);
toast.success("Step removed.");
if (selectedId === id) {
const remaining = sequences.filter((s) => s.id !== id);
onSelect(remaining[0]?.id ?? "");
}
} catch (err) {
toast.error(buildError(err as AppError));
} finally {
setDeletingId(null);
setConfirmId(null);
}
function requestDelete(seq: Sequence, index: number) {
const name = seq.name || `Step ${index + 1}`;
confirm.show(
`Delete step ${index + 1}? "${name}" and its content will be removed from this campaign. This can't be undone.`,
async () => {
try {
await deleteSequence.mutateAsync(seq.id);
toast.success("Step removed.");
if (selectedId === seq.id) {
const remaining = sequences.filter((s) => s.id !== seq.id);
onSelect(remaining[0]?.id ?? "");
}
} catch (err) {
toast.error(buildError(err as AppError));
}
},
);
}
return (
@@ -133,7 +150,7 @@ export default function StepRail({
subject={seq.subject}
active={seq.id === selectedId}
onClick={() => onSelect(seq.id)}
onDelete={() => setConfirmId(seq.id)}
onDelete={() => requestDelete(seq, i)}
/>
</React.Fragment>
))}
@@ -159,40 +176,6 @@ export default function StepRail({
</div>
)}
{confirmTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 px-4">
<div className="w-full max-w-sm rounded-md border border-slate-200 bg-white p-4 shadow-lg">
<p className="text-[13px] font-medium text-slate-900">
Delete step {confirmIndex + 1}?
</p>
<p className="mt-1 text-[11.5px] leading-relaxed text-slate-500">
{confirmTarget.name || `Step ${confirmIndex + 1}`} and its content will
be removed from this campaign. This can&apos;t be undone.
</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmId(null)}
disabled={deletingId === confirmId}
className="h-7 px-2.5 rounded-md border border-slate-200 bg-white text-[12px] font-medium text-slate-700 transition-colors hover:border-slate-300 hover:text-slate-900 disabled:opacity-60"
>
Cancel
</button>
<button
type="button"
onClick={runDelete}
disabled={deletingId === confirmId}
className="h-7 px-2.5 rounded-md bg-rose-600 text-[12px] font-medium text-white transition-colors hover:bg-rose-700 inline-flex items-center gap-1.5 disabled:opacity-60"
>
{deletingId === confirmId && (
<Loader2Icon className="w-3 h-3 animate-spin" />
)}
Delete step
</button>
</div>
</div>
</div>
)}
</div>
);
}
+23 -1
View File
@@ -124,6 +124,7 @@ export function FieldRow({ children, className }: { children: React.ReactNode; c
export function NumberInput({
value,
onChange,
onCommit,
min,
max,
step = 1,
@@ -135,6 +136,12 @@ export function NumberInput({
}: {
value: number;
onChange: (value: number) => void;
// Optional "commit point" distinct from the live onChange: fires on blur,
// on Enter, and on each stepper click — but NOT on every keystroke. Use it
// when the consumer wants to persist (e.g. a network save) only once the
// user settles on a value, instead of mid-typing. Omitting it preserves the
// original onChange-only behavior for every existing call site.
onCommit?: (value: number) => void;
min?: number;
max?: number;
step?: number;
@@ -150,9 +157,12 @@ export function NumberInput({
if (max !== undefined && n > max) return max;
return n;
};
const commitValue = () => onCommit?.(clamp(Number.isFinite(value) ? value : min ?? 0));
const bump = (dir: 1 | -1) => {
if (disabled) return;
onChange(clamp((Number.isFinite(value) ? value : 0) + dir * step));
const next = clamp((Number.isFinite(value) ? value : 0) + dir * step);
onChange(next);
onCommit?.(next);
};
const atMax = max !== undefined && value >= max;
const atMin = min !== undefined && value <= min;
@@ -174,6 +184,18 @@ export function NumberInput({
const raw = e.target.value;
onChange(raw === "" ? min ?? 0 : clamp(Number(raw)));
}}
onBlur={onCommit ? commitValue : undefined}
onKeyDown={
onCommit
? (e) => {
if (e.key === "Enter") {
e.preventDefault();
commitValue();
(e.target as HTMLInputElement).blur();
}
}
: undefined
}
className={cn(
"w-full min-w-0 h-full bg-transparent outline-none px-2.5 text-[12.5px] text-slate-900 tabular-nums disabled:text-slate-400",
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
@@ -2,20 +2,23 @@ import deleteSequence from "@/lib/api/client/app/campaigns/sequences/deleteSeque
import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export default function useDeleteSequence(campaign_id: string, sequence_id: string) {
// Delete a campaign sequence step. The id to delete is the mutate() argument
// (so one mutation instance can delete any step — important for the per-row
// useConfirm flow in StepRail). onSuccess prunes the deleted id from the cache
// using the variable passed to mutate, not a render-time-bound id.
export default function useDeleteSequence(campaign_id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sequence_id: string) => deleteSequence(campaign_id, sequence_id),
onSuccess: () => {
onSuccess: (_data, sequence_id) => {
queryClient.setQueryData<Sequence[]>(
["campaigns", campaign_id, "sequences"],
(oldData) => {
if (!oldData) return oldData;
return oldData.filter((s) => s.id !== sequence_id)
}
)
}
})
return oldData.filter((s) => s.id !== sequence_id);
},
);
},
});
}