mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +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>
195 lines
6.5 KiB
TypeScript
195 lines
6.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Clock3, RefreshCw, Save, Trash2, Webhook, Zap } from "lucide-react";
|
|
import type { RoutineTrigger } from "@paperclipai/shared";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ScheduleEditor } from "./ScheduleEditor";
|
|
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
|
import { describeCron } from "../lib/cron-readable";
|
|
|
|
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
|
|
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
|
|
|
|
function getLocalTimezone(): string {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
} catch {
|
|
return "UTC";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single trigger card with its own field-edit + save state (§3.2). Extracted
|
|
* from the previous inline `TriggerEditor` in `RoutineDetail.tsx` — same logic.
|
|
*/
|
|
export function RoutineTriggerCard({
|
|
trigger,
|
|
onSave,
|
|
onRotate,
|
|
onDelete,
|
|
disabled,
|
|
}: {
|
|
trigger: RoutineTrigger;
|
|
onSave: (id: string, patch: Record<string, unknown>) => void;
|
|
onRotate: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const [draft, setDraft] = useState({
|
|
label: trigger.label ?? "",
|
|
cronExpression: trigger.cronExpression ?? "",
|
|
signingMode: trigger.signingMode ?? "bearer",
|
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
|
});
|
|
|
|
useEffect(() => {
|
|
setDraft({
|
|
label: trigger.label ?? "",
|
|
cronExpression: trigger.cronExpression ?? "",
|
|
signingMode: trigger.signingMode ?? "bearer",
|
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
|
});
|
|
}, [trigger]);
|
|
|
|
const KindIcon =
|
|
trigger.kind === "schedule" ? Clock3 : trigger.kind === "webhook" ? Webhook : Zap;
|
|
const humanCron = trigger.kind === "schedule" ? describeCron(draft.cronExpression) : null;
|
|
const lastResultOk =
|
|
trigger.lastResult != null &&
|
|
/succeed|success|ok|200|delivered/i.test(String(trigger.lastResult));
|
|
|
|
return (
|
|
<form
|
|
aria-label={`Trigger: ${trigger.label ?? trigger.kind}`}
|
|
className="space-y-4 rounded-lg border border-border p-4"
|
|
onSubmit={(event) => event.preventDefault()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<KindIcon className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="truncate">{trigger.label ?? trigger.kind}</span>
|
|
</div>
|
|
{humanCron ? (
|
|
<p id={`cron-readable-${trigger.id}`} className="text-xs text-muted-foreground">
|
|
{humanCron}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{trigger.lastResult ? (
|
|
<Badge variant={lastResultOk ? "secondary" : "destructive"}>
|
|
{String(trigger.lastResult)}
|
|
</Badge>
|
|
) : null}
|
|
<span className="text-xs text-muted-foreground">
|
|
{trigger.kind === "schedule" && trigger.nextRunAt
|
|
? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}`
|
|
: trigger.kind === "webhook"
|
|
? "Webhook"
|
|
: "API"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Label</Label>
|
|
<Input
|
|
value={draft.label}
|
|
disabled={disabled}
|
|
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
|
|
/>
|
|
</div>
|
|
{trigger.kind === "schedule" && (
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label className="text-xs">Schedule</Label>
|
|
<ScheduleEditor
|
|
value={draft.cronExpression}
|
|
onChange={(cronExpression) =>
|
|
setDraft((current) => ({ ...current, cronExpression }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{trigger.kind === "webhook" && (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Signing mode</Label>
|
|
<Select
|
|
value={draft.signingMode}
|
|
onValueChange={(signingMode) =>
|
|
setDraft((current) => ({ ...current, signingMode }))
|
|
}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{signingModes.map((mode) => (
|
|
<SelectItem key={mode} value={mode}>
|
|
{mode}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Replay window (seconds)</Label>
|
|
<Input
|
|
value={draft.replayWindowSec}
|
|
disabled={disabled}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{!disabled && (
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mr-auto text-muted-foreground hover:text-destructive"
|
|
onClick={() => onDelete(trigger.id)}
|
|
>
|
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
Delete
|
|
</Button>
|
|
{trigger.kind === "webhook" && (
|
|
<Button variant="outline" size="sm" onClick={() => onRotate(trigger.id)}>
|
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
|
Rotate secret
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))
|
|
}
|
|
>
|
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
|
Save trigger
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|