Files
PaperClipAI/ui/src/components/RoutineTriggerCard.tsx
T
DottaandPaperclip bf62e3fbf1 feat(ui): routine detail page — variation C sub-sidebar layout (PAP-10732) (#7848)
## 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>
2026-06-09 17:04:25 -05:00

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>
);
}