mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(web): inline edit on trigger cards
This commit is contained in:
parent
fa0cdb9760
commit
4f202e1fa3
1 changed files with 137 additions and 27 deletions
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { CalendarClock, Clock, Trash2 } from "lucide-react";
|
import { AlertCircle, CalendarClock, Clock, Pencil, Save, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||||
import { JsonView } from "@/components/json-view";
|
import { JsonView } from "@/components/json-view";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type { Trigger } from "@/contracts/types/automation.types";
|
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
|
||||||
import { describeCron } from "@/lib/automations/describe-cron";
|
import { describeCron } from "@/lib/automations/describe-cron";
|
||||||
import { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date";
|
import { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date";
|
||||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||||
|
|
@ -18,20 +19,36 @@ interface TriggerCardProps {
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TriggerDraft {
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
static_inputs: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromTrigger(trigger: Trigger): TriggerDraft {
|
||||||
|
return {
|
||||||
|
params: trigger.params,
|
||||||
|
static_inputs: trigger.static_inputs ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One trigger row in the Triggers section of the detail page. Renders:
|
* One trigger row in the Triggers section of the detail page. Renders:
|
||||||
* - type icon + human-readable schedule + timezone
|
* - type icon + human-readable schedule + timezone
|
||||||
* - last_fired_at / next_fire_at hints
|
* - last_fired_at / next_fire_at hints
|
||||||
* - static_inputs as formatted JSON (when present)
|
* - static_inputs as formatted JSON (when present)
|
||||||
* - enable toggle + remove button (each gated independently)
|
* - enable toggle + remove button + inline edit (each gated independently)
|
||||||
*
|
*
|
||||||
* Editing params (cron, timezone, static_inputs) lives behind the future
|
* Inline edit covers ``params`` and ``static_inputs`` — the two fields the
|
||||||
* raw-JSON path; this card stays read-only-except-for-toggle for v1.
|
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
|
||||||
|
* ``enabled`` stays on the Switch so the two surfaces don't fight.
|
||||||
*/
|
*/
|
||||||
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
|
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
|
||||||
const { mutateAsync: updateTrigger, isPending: updating } =
|
const { mutateAsync: updateTrigger, isPending: updating } =
|
||||||
useAtomValue(updateTriggerMutationAtom);
|
useAtomValue(updateTriggerMutationAtom);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<TriggerDraft>(() => draftFromTrigger(trigger));
|
||||||
|
const [issues, setIssues] = useState<string[]>([]);
|
||||||
|
|
||||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
||||||
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||||
|
|
@ -47,6 +64,38 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setDraft(draftFromTrigger(trigger));
|
||||||
|
setIssues([]);
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIssues([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
setIssues([]);
|
||||||
|
const result = triggerUpdateRequest.safeParse(draft);
|
||||||
|
if (!result.success) {
|
||||||
|
setIssues(
|
||||||
|
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateTrigger({
|
||||||
|
automationId,
|
||||||
|
triggerId: trigger.id,
|
||||||
|
patch: result.data,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
setIssues([(err as Error).message ?? "Update failed"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
<div className="rounded-md border border-border/60 overflow-hidden">
|
||||||
|
|
@ -71,17 +120,29 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
<Switch
|
<Switch
|
||||||
checked={trigger.enabled}
|
checked={trigger.enabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={updating}
|
disabled={updating || isEditing}
|
||||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{canUpdate && !isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground"
|
||||||
|
onClick={startEdit}
|
||||||
|
aria-label="Edit trigger"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={isEditing}
|
||||||
aria-label="Remove trigger"
|
aria-label="Remove trigger"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|
@ -91,29 +152,78 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-3 space-y-3 text-xs">
|
<div className="px-4 py-3 space-y-3 text-xs">
|
||||||
{(trigger.last_fired_at || trigger.next_fire_at) && (
|
{isEditing ? (
|
||||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
|
<>
|
||||||
{trigger.next_fire_at && (
|
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
|
||||||
<TimeRow
|
<JsonView
|
||||||
label="Next fire"
|
src={draft}
|
||||||
iso={trigger.next_fire_at}
|
editable
|
||||||
tense="future"
|
onChange={(next) => setDraft(next as TriggerDraft)}
|
||||||
highlight={trigger.enabled}
|
collapsed={false}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{trigger.last_fired_at && (
|
|
||||||
<TimeRow label="Last fired" iso={trigger.last_fired_at} tense="past" />
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasStaticInputs && (
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground mb-1">Static inputs</div>
|
|
||||||
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
|
|
||||||
<JsonView src={trigger.static_inputs} collapsed={1} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-destructive mb-1">
|
||||||
|
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||||
|
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 text-destructive list-disc list-inside">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<li key={issue}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
disabled={updating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={saveEdit} disabled={updating}>
|
||||||
|
{updating ? (
|
||||||
|
<Spinner size="xs" className="mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(trigger.last_fired_at || trigger.next_fire_at) && (
|
||||||
|
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
|
||||||
|
{trigger.next_fire_at && (
|
||||||
|
<TimeRow
|
||||||
|
label="Next fire"
|
||||||
|
iso={trigger.next_fire_at}
|
||||||
|
tense="future"
|
||||||
|
highlight={trigger.enabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{trigger.last_fired_at && (
|
||||||
|
<TimeRow label="Last fired" iso={trigger.last_fired_at} tense="past" />
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStaticInputs && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-1">Static inputs</div>
|
||||||
|
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
|
||||||
|
<JsonView src={trigger.static_inputs} collapsed={1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue