mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Summary Rebuilds the routine detail page as **variation C** — a sub-sidebar shell that splits the page into **ROUTINE** (Overview · Triggers · Variables · Secrets · Delivery) and **OPERATE** (Runs · Activity · History), per the engineering spec on PAP-10730. Replaces the previous 5-tab `?tab=…` layout in `ui/src/pages/RoutineDetail.tsx`. Implements PAP-10732. Design source of truth: PAP-10730 `spec` document; approved direction PAP-10709. ## What changed - **Routing** (`ui/src/App.tsx`): real sub-routes under `routines/:routineId/:section`. Bare `/routines/:id` redirects to the last-viewed section (`localStorage`) or `overview`; old `?tab=…` URLs redirect to the matching section for back-compat. Every section URL is bookmarkable. - **Shell** (`RoutineDetail.tsx`): slim 56px sticky header (title + managed-by-plugin chip + Run / Active toggle), page-local sub-sidebar, full-canvas section body, per-section sticky save bar. All routine state/mutations stay in the shell and flow to sections via a `RoutineDetailContext`. - **New components**: `RoutineSubSidebar` (+ mobile `<Select>` picker, roving keyboard nav), `RoutineSaveBar` (scoped dirty count, ⌘/Ctrl+S save, Esc-discard confirm, 409 conflict recovery with Reload / Overwrite), `RadioCard` primitive (Delivery), `RoutineTriggerCard` (extracted from the inline editor, with human-readable cron), `RoutineActivityRow` (expandable JSON), `lib/cron-readable`, and the per-section components. - **Reuse**: History mounts the existing `RoutineHistoryTab`; Variables mounts `RoutineVariablesEditor` with a provenance banner; Secrets reuses `EnvVarEditor` + the one-time reveal banner. No backend or schema changes. - **States**: per-section loading/empty/error/save-conflict and read-only strip scaffolding (§1.6). ## Testing - New unit tests: sub-sidebar navigation/active/dirty markers, save-bar dirty + ⌘S + conflict recovery, cron helper. - Existing routine tests still pass: `Routines.test.tsx`, `RoutineHistoryTab.test.tsx`, `RoutineRunVariablesDialog.test.tsx`. - `vitest run` (routine scope): **36 passed**. Production `vite build`: **green**. - Screenshots at 1440×900 + 390×844 attached to [PAP-10732](https://example.invalid) (rendered via a new Storybook story with fixture data). ## Out of scope (per spec) - `/routines` list-page redo (follow-up). - Non-owner secret-value visibility (Open Q6 — CEO escalation; built with the spec default). --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
565 lines
20 KiB
TypeScript
565 lines
20 KiB
TypeScript
import { useMemo } from "react";
|
|
import {
|
|
ArrowRight,
|
|
Braces,
|
|
Clock3,
|
|
Edit3,
|
|
KeyRound,
|
|
Play,
|
|
} from "lucide-react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { RadioCardGroup } from "@/components/ui/radio-card";
|
|
import { timeAgo } from "../../lib/timeAgo";
|
|
import { EmptyState } from "../EmptyState";
|
|
import { InlineEntitySelector } from "../InlineEntitySelector";
|
|
import { AgentIcon } from "../AgentIconPicker";
|
|
import { MarkdownEditor } from "../MarkdownEditor";
|
|
import { ScheduleEditor } from "../ScheduleEditor";
|
|
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
|
|
import { RoutineTriggerCard } from "../RoutineTriggerCard";
|
|
import { EnvVarEditor } from "../EnvVarEditor";
|
|
import { useRoutineDetail } from "./context";
|
|
import type { EnvBinding } from "@paperclipai/shared";
|
|
|
|
const concurrencyPolicyOptions = [
|
|
{
|
|
value: "coalesce_if_active",
|
|
title: "Coalesce if active",
|
|
description: "Keep one follow-up run queued while an active run is still working.",
|
|
},
|
|
{
|
|
value: "always_enqueue",
|
|
title: "Always enqueue",
|
|
description: "Queue every trigger occurrence, even if several runs stack up.",
|
|
},
|
|
{
|
|
value: "skip_if_active",
|
|
title: "Skip if active",
|
|
description: "Drop overlapping trigger occurrences while the routine is already active.",
|
|
},
|
|
];
|
|
|
|
const catchUpPolicyOptions = [
|
|
{
|
|
value: "skip_missed",
|
|
title: "Skip missed",
|
|
description: "Ignore schedule windows that were missed while paused.",
|
|
},
|
|
{
|
|
value: "enqueue_missed_with_cap",
|
|
title: "Enqueue missed with cap",
|
|
description: "Catch up missed schedule windows in capped batches after recovery.",
|
|
},
|
|
];
|
|
|
|
const triggerKinds = ["schedule", "webhook"];
|
|
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
|
|
const signingModeDescriptions: Record<string, string> = {
|
|
bearer: "Expect a shared bearer token in the Authorization header.",
|
|
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
|
|
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
|
|
none: "No authentication — the webhook URL itself acts as a shared secret.",
|
|
};
|
|
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
|
|
|
|
export function OverviewSection() {
|
|
const ctx = useRoutineDetail();
|
|
const {
|
|
routine,
|
|
editDraft,
|
|
setEditDraft,
|
|
assigneeOptions,
|
|
projectOptions,
|
|
recentAssigneeIds,
|
|
recentProjectIds,
|
|
agentById,
|
|
projectById,
|
|
currentAssignee,
|
|
currentProject,
|
|
mentionOptions,
|
|
assigneeSelectorRef,
|
|
projectSelectorRef,
|
|
descriptionEditorRef,
|
|
routineRuns,
|
|
activity,
|
|
saveRoutine,
|
|
navigateToSection,
|
|
} = ctx;
|
|
|
|
const activeTriggers = routine.triggers.length;
|
|
const nextFire = useMemo(() => {
|
|
const upcoming = routine.triggers
|
|
.filter((trigger) => trigger.kind === "schedule" && trigger.nextRunAt)
|
|
.map((trigger) => new Date(trigger.nextRunAt as Date))
|
|
.sort((a, b) => a.getTime() - b.getTime())[0];
|
|
return upcoming ? upcoming.toLocaleString() : null;
|
|
}, [routine.triggers]);
|
|
const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0;
|
|
const lastRun = (routineRuns ?? [])[0] ?? null;
|
|
const recentActivity = (activity ?? []).slice(0, 5);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Assignment row */}
|
|
<div className="overflow-x-auto overscroll-x-contain">
|
|
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
|
<span>For</span>
|
|
<InlineEntitySelector
|
|
ref={assigneeSelectorRef}
|
|
value={editDraft.assigneeAgentId}
|
|
options={assigneeOptions}
|
|
recentOptionIds={recentAssigneeIds}
|
|
placeholder="Assignee"
|
|
noneLabel="No assignee"
|
|
searchPlaceholder="Search assignees..."
|
|
emptyMessage="No assignees found."
|
|
onChange={(assigneeAgentId) =>
|
|
setEditDraft((current) => ({ ...current, assigneeAgentId }))
|
|
}
|
|
onConfirm={() => {
|
|
if (editDraft.projectId) {
|
|
descriptionEditorRef.current?.focus();
|
|
} else {
|
|
projectSelectorRef.current?.focus();
|
|
}
|
|
}}
|
|
renderTriggerValue={(option) =>
|
|
option ? (
|
|
currentAssignee ? (
|
|
<>
|
|
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="truncate">{option.label}</span>
|
|
)
|
|
) : (
|
|
<span className="text-muted-foreground">Assignee</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const assignee = agentById.get(option.id);
|
|
return (
|
|
<>
|
|
{assignee ? (
|
|
<AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : null}
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
<span>in</span>
|
|
<InlineEntitySelector
|
|
ref={projectSelectorRef}
|
|
value={editDraft.projectId}
|
|
options={projectOptions}
|
|
recentOptionIds={recentProjectIds}
|
|
placeholder="Project"
|
|
noneLabel="No project"
|
|
searchPlaceholder="Search projects..."
|
|
emptyMessage="No projects found."
|
|
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
|
|
onConfirm={() => descriptionEditorRef.current?.focus()}
|
|
renderTriggerValue={(option) =>
|
|
option && currentProject ? (
|
|
<>
|
|
<span
|
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
|
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Project</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const project = projectById.get(option.id);
|
|
return (
|
|
<>
|
|
<span
|
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
|
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{!routine.assigneeAgentId ? (
|
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
|
|
Default agent required. This routine can stay as a draft and still run manually, but
|
|
automation stays paused until you assign a default agent.
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Instructions */}
|
|
<MarkdownEditor
|
|
ref={descriptionEditorRef}
|
|
value={editDraft.description}
|
|
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
|
placeholder="Add instructions..."
|
|
bordered={false}
|
|
contentClassName="min-h-[120px] text-[15px] leading-7"
|
|
mentions={mentionOptions}
|
|
onSubmit={() => {
|
|
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
|
saveRoutine.mutate();
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Variables peek */}
|
|
<div className="space-y-3">
|
|
<RoutineVariablesHint />
|
|
<RoutineVariablesEditor
|
|
title={editDraft.title}
|
|
description={editDraft.description}
|
|
value={editDraft.variables}
|
|
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Summary cards */}
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<SummaryCard
|
|
icon={Clock3}
|
|
label="Triggers"
|
|
value={activeTriggers === 0 ? "None" : `${activeTriggers} active`}
|
|
hint={nextFire ? `Next fire ${nextFire}` : "No schedule"}
|
|
to={() => navigateToSection("triggers")}
|
|
ariaLabel={`${activeTriggers} triggers. Open triggers.`}
|
|
/>
|
|
<SummaryCard
|
|
icon={KeyRound}
|
|
label="Secrets"
|
|
value={boundSecrets === 0 ? "None" : `${boundSecrets} bound`}
|
|
hint="Manage bound secrets"
|
|
to={() => navigateToSection("secrets")}
|
|
ariaLabel={`${boundSecrets} secrets bound. Open secrets.`}
|
|
/>
|
|
<SummaryCard
|
|
icon={Play}
|
|
label="Last run"
|
|
value={lastRun ? lastRun.status.replaceAll("_", " ") : "No runs"}
|
|
hint={lastRun ? timeAgo(lastRun.triggeredAt) : "Trigger a run"}
|
|
to={() => navigateToSection("runs")}
|
|
ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."}
|
|
/>
|
|
</div>
|
|
|
|
{/* Recent activity */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Recent activity
|
|
</p>
|
|
{recentActivity.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
|
) : (
|
|
<div className="divide-y divide-border/60">
|
|
{recentActivity.map((event) => (
|
|
<div key={event.id} className="flex items-center gap-2 py-1.5 text-xs">
|
|
<Badge variant="outline" className="shrink-0 font-mono">
|
|
{event.action}
|
|
</Badge>
|
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
{event.details && Object.keys(event.details).length > 0
|
|
? Object.keys(event.details).slice(0, 3).join(" · ")
|
|
: ""}
|
|
</span>
|
|
<span className="shrink-0 text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => navigateToSection("activity")}
|
|
className="flex items-center gap-1 pt-2 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
View all activity <ArrowRight className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
hint,
|
|
to,
|
|
ariaLabel,
|
|
}: {
|
|
icon: typeof Clock3;
|
|
label: string;
|
|
value: string;
|
|
hint: string;
|
|
to: () => void;
|
|
ariaLabel: string;
|
|
}) {
|
|
return (
|
|
<button type="button" onClick={to} aria-label={ariaLabel} className="text-left">
|
|
<Card className="gap-2 p-4 transition-colors hover:border-border hover:bg-accent/30">
|
|
<CardContent className="space-y-1 p-0">
|
|
<div className="flex items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
<ArrowRight className="ml-auto h-3.5 w-3.5 text-muted-foreground/60" />
|
|
</div>
|
|
<p className="text-lg font-semibold">{value}</p>
|
|
<p className="text-xs text-muted-foreground">{hint}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function TriggersSection() {
|
|
const ctx = useRoutineDetail();
|
|
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Add trigger form */}
|
|
<div className="space-y-3 rounded-lg border border-border p-4">
|
|
<p className="text-sm font-medium">Add trigger</p>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Kind</Label>
|
|
<Select
|
|
value={newTrigger.kind}
|
|
onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{triggerKinds.map((kind) => (
|
|
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
|
|
{kind}
|
|
{kind === "webhook" ? " — COMING SOON" : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{newTrigger.kind === "schedule" && (
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label className="text-xs">Schedule</Label>
|
|
<ScheduleEditor
|
|
value={newTrigger.cronExpression}
|
|
onChange={(cronExpression) =>
|
|
setNewTrigger((current) => ({ ...current, cronExpression }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{newTrigger.kind === "webhook" && (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Signing mode</Label>
|
|
<Select
|
|
value={newTrigger.signingMode}
|
|
onValueChange={(signingMode) =>
|
|
setNewTrigger((current) => ({ ...current, signingMode }))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{signingModes.map((mode) => (
|
|
<SelectItem key={mode} value={mode}>
|
|
{mode}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{signingModeDescriptions[newTrigger.signingMode]}
|
|
</p>
|
|
</div>
|
|
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Replay window (seconds)</Label>
|
|
<Input
|
|
value={newTrigger.replayWindowSec}
|
|
onChange={(event) =>
|
|
setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-end">
|
|
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
|
|
{createTrigger.isPending ? "Adding..." : "Add trigger"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Existing triggers */}
|
|
{routine.triggers.length === 0 ? (
|
|
<EmptyState icon={Clock3} message="No triggers yet." />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{routine.triggers.map((trigger) => (
|
|
<RoutineTriggerCard
|
|
key={trigger.id}
|
|
trigger={trigger}
|
|
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
|
|
onRotate={(id) => rotateTrigger.mutate(id)}
|
|
onDelete={(id) => deleteTrigger.mutate(id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function VariablesSection() {
|
|
const ctx = useRoutineDetail();
|
|
const { editDraft, setEditDraft, navigateToSection } = ctx;
|
|
const hasVariables = editDraft.variables.length > 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 px-4 py-3 text-xs">
|
|
<span className="flex-1 text-muted-foreground">
|
|
Variables are auto-detected from <code className="font-mono">{"{{placeholders}}"}</code> in
|
|
the title & instructions. The variable name is read-only — rename by editing the
|
|
placeholder.
|
|
</span>
|
|
<Button variant="secondary" size="sm" onClick={() => navigateToSection("overview")}>
|
|
<Edit3 className="mr-1.5 h-3.5 w-3.5" />
|
|
Edit instructions
|
|
</Button>
|
|
</div>
|
|
|
|
{hasVariables ? (
|
|
<RoutineVariablesEditor
|
|
title={editDraft.title}
|
|
description={editDraft.description}
|
|
value={editDraft.variables}
|
|
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
icon={Braces}
|
|
message="No variables yet. Add a {{placeholder}} in the title or instructions to create one."
|
|
action="Edit instructions"
|
|
onAction={() => navigateToSection("overview")}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SecretsSection() {
|
|
const ctx = useRoutineDetail();
|
|
const { editDraft, setEditDraft, availableSecrets, createSecret, secretMessage, copySecretValue } = ctx;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
|
Routine secrets apply to every task this routine creates. They override matching keys in
|
|
project and agent env. <span className="font-mono">PAPERCLIP_*</span> names are reserved.
|
|
</div>
|
|
|
|
{secretMessage ? (
|
|
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm">
|
|
<div>
|
|
<p className="font-medium">{secretMessage.title}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Save this now. Paperclip will not show the secret value again.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{secretMessage.entries.map((entry, index) => (
|
|
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Input value={entry.webhookUrl} readOnly className="flex-1" />
|
|
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
|
|
URL
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Input value={entry.webhookSecret} readOnly className="flex-1" />
|
|
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
|
|
Secret
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<EnvVarEditor
|
|
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
|
|
secrets={availableSecrets}
|
|
onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })}
|
|
onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DeliverySection() {
|
|
const ctx = useRoutineDetail();
|
|
const { editDraft, setEditDraft } = ctx;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
Concurrency
|
|
</p>
|
|
<RadioCardGroup
|
|
ariaLabel="Concurrency policy"
|
|
value={editDraft.concurrencyPolicy}
|
|
onValueChange={(concurrencyPolicy) =>
|
|
setEditDraft((current) => ({ ...current, concurrencyPolicy }))
|
|
}
|
|
options={concurrencyPolicyOptions}
|
|
/>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
Catch-up
|
|
</p>
|
|
<RadioCardGroup
|
|
ariaLabel="Catch-up policy"
|
|
value={editDraft.catchUpPolicy}
|
|
onValueChange={(catchUpPolicy) =>
|
|
setEditDraft((current) => ({ ...current, catchUpPolicy }))
|
|
}
|
|
options={catchUpPolicyOptions}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|