Files
PaperClipAI/ui/src/components/RoutineSaveBar.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

204 lines
6.6 KiB
TypeScript

import { useEffect, useState } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { RoutineHistoryDirtyFieldDescriptor } from "./RoutineHistoryTab";
/**
* Per-section sticky save bar (§1.4–§1.5). Hidden when clean; reveals on dirty.
* On a 409 it swaps to the conflict-recovery surface ("Reload latest" /
* "Overwrite anyway"). Wires ⌘/Ctrl+S → save and Esc → discard-with-confirm.
*/
export function RoutineSaveBar({
dirtyFields,
isSaving,
saveConflict,
onSave,
onDiscard,
onReload,
disabled,
}: {
dirtyFields: RoutineHistoryDirtyFieldDescriptor[];
isSaving: boolean;
saveConflict: boolean;
onSave: () => void;
onDiscard: () => void;
onReload: () => void;
disabled?: boolean;
}) {
const dirtyCount = dirtyFields.length;
const isDirty = dirtyCount > 0;
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
useEffect(() => {
if (!isDirty && !saveConflict) return;
const handler = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
if (!isSaving && !disabled) onSave();
} else if (event.key === "Escape" && isDirty) {
event.preventDefault();
setConfirmDiscardOpen(true);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [isDirty, saveConflict, isSaving, disabled, onSave]);
if (!isDirty && !saveConflict) return null;
return (
<>
<div
className={cn(
"sticky bottom-0 z-10 -mx-8 mt-6 flex h-14 items-center justify-between border-t px-8 backdrop-blur",
"motion-safe:transition-colors motion-safe:duration-200",
"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
saveConflict
? "border-amber-500/30 bg-amber-500/5"
: "border-border bg-background/95",
)}
>
{saveConflict ? (
<div className="flex items-center gap-2 text-sm text-amber-200">
<AlertTriangle className="h-4 w-4" />
<span>Routine changed elsewhere. Reload to merge.</span>
</div>
) : (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm text-foreground hover:text-foreground"
>
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
<span className="font-medium">
{dirtyCount} unsaved {dirtyCount === 1 ? "change" : "changes"}
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Pending changes
</p>
<ul className="space-y-1 text-sm">
{dirtyFields.map((field) => (
<li key={field.key} className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-amber-500" />
<span className="capitalize">{field.label}</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
)}
<div className="flex items-center gap-2">
{saveConflict ? (
<>
<Button variant="outline" size="sm" onClick={onReload}>
Reload latest
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={isSaving || disabled}
onClick={onSave}
>
{isSaving ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Overwrite anyway
</Button>
</TooltipTrigger>
<TooltipContent>
Replaces the newer revision with your local edits.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
disabled={isSaving || disabled}
onClick={() => setConfirmDiscardOpen(true)}
>
Discard
</Button>
<Button
size="sm"
disabled={isSaving || disabled}
onClick={onSave}
>
{isSaving ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Save changes
<kbd className="ml-2 hidden rounded bg-foreground/10 px-1 text-[10px] font-medium sm:inline">
⌘S
</kbd>
</Button>
</>
)}
</div>
</div>
<Dialog open={confirmDiscardOpen} onOpenChange={setConfirmDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard changes?</DialogTitle>
<DialogDescription>
This will revert {dirtyCount} unsaved{" "}
{dirtyCount === 1 ? "change" : "changes"} in this section.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setConfirmDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
onDiscard();
setConfirmDiscardOpen(false);
}}
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/** Read-only strip for non-owners on editable sections (§1.6). */
export function RoutineReadOnlyStrip() {
return (
<div className="-mx-8 mt-6 border-t border-border bg-muted/20 px-8 py-3 text-xs text-muted-foreground">
Read-only — you don't own this routine.
</div>
);
}