refactor(automations): enhance TriggerCard component with improved scheduling options, including frequency selection and custom cron input

This commit is contained in:
Anish Sarkar 2026-06-03 21:30:24 +05:30
parent 282c0495c0
commit 2ba30837a9
3 changed files with 180 additions and 28 deletions

View file

@ -1,9 +1,8 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, MoreHorizontal, Pencil, Save, Trash2 } from "lucide-react";
import { AlertCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { useState } from "react";
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import { JsonView } from "@/components/json-view";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
@ -12,11 +11,26 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
import { describeCron } from "@/lib/automations/describe-cron";
import { formatRelativeFutureDate } from "@/lib/format-date";
import {
DEFAULT_SCHEDULE,
fromCron,
type ScheduleFrequency,
toCron,
} from "@/lib/automations/schedule-builder";
import { TimezoneCombobox } from "../../components/builder/timezone-combobox";
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
interface TriggerCardProps {
@ -26,26 +40,58 @@ interface TriggerCardProps {
canDelete: boolean;
}
type SimpleFrequency = Extract<ScheduleFrequency, "hourly" | "daily" | "weekdays"> | "custom";
interface TriggerDraft {
params: Record<string, unknown>;
static_inputs: Record<string, unknown>;
frequency: SimpleFrequency;
hour: number;
minute: number;
timezone: string;
cron: string;
}
const SIMPLE_FREQUENCIES = new Set<ScheduleFrequency>(["hourly", "daily", "weekdays"]);
function draftFromTrigger(trigger: Trigger): TriggerDraft {
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : "";
const timezone = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
const model = fromCron(cron);
if (model && SIMPLE_FREQUENCIES.has(model.frequency)) {
return {
frequency: model.frequency as SimpleFrequency,
hour: model.hour,
minute: model.minute,
timezone,
cron,
};
}
return {
params: trigger.params,
static_inputs: trigger.static_inputs ?? {},
frequency: "custom",
hour: DEFAULT_SCHEDULE.hour,
minute: DEFAULT_SCHEDULE.minute,
timezone,
cron,
};
}
function pad(value: number): string {
return value.toString().padStart(2, "0");
}
function clampInt(raw: string, min: number, max: number): number {
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value)) return min;
return Math.min(max, Math.max(min, value));
}
/**
* One trigger row in the Triggers section of the detail page. Renders:
* - human-readable schedule
* - compact enable toggle
* - dropdown actions for edit/remove
*
* Inline edit covers ``params`` and ``static_inputs`` the two fields the
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
* Inline edit keeps schedule editing intentionally small: common frequencies,
* time, timezone, and raw cron only for schedules outside the simple model.
* ``enabled`` stays on the Switch so the two surfaces don't fight.
*/
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
@ -82,7 +128,22 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
async function saveEdit() {
setIssues([]);
const result = triggerUpdateRequest.safeParse(draft);
const params =
draft.frequency === "custom"
? { cron: draft.cron.trim(), timezone: draft.timezone }
: {
cron: toCron({
...DEFAULT_SCHEDULE,
frequency: draft.frequency,
hour: draft.hour,
minute: draft.minute,
}),
timezone: draft.timezone,
};
const result = triggerUpdateRequest.safeParse({
params,
static_inputs: trigger.static_inputs ?? {},
});
if (!result.success) {
setIssues(
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
@ -169,13 +230,94 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
{isEditing ? (
<div className="space-y-3 border-t border-border/60 px-4 py-3 text-xs">
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
<JsonView
src={draft}
editable
onChange={(next) => setDraft(next as TriggerDraft)}
collapsed={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-runs">
Runs
</label>
<Select
value={draft.frequency}
onValueChange={(value) =>
setDraft((prev) => ({ ...prev, frequency: value as SimpleFrequency }))
}
>
<SelectTrigger id="trigger-runs" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Every hour</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekdays">Weekdays</SelectItem>
<SelectItem value="custom">Custom cron</SelectItem>
</SelectContent>
</Select>
</div>
{draft.frequency === "hourly" ? (
<div className="space-y-1.5">
<label
className="text-xs font-medium text-muted-foreground"
htmlFor="trigger-minute"
>
At minute
</label>
<Input
id="trigger-minute"
type="number"
min={0}
max={59}
value={draft.minute}
onChange={(event) =>
setDraft((prev) => ({
...prev,
minute: clampInt(event.target.value, 0, 59),
}))
}
/>
</div>
) : draft.frequency !== "custom" ? (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-time">
Time
</label>
<Input
id="trigger-time"
type="time"
value={`${pad(draft.hour)}:${pad(draft.minute)}`}
onChange={(event) => {
const [hour, minute] = event.target.value.split(":");
setDraft((prev) => ({
...prev,
hour: clampInt(hour, 0, 23),
minute: clampInt(minute, 0, 59),
}));
}}
/>
</div>
) : (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-cron">
Schedule expression
</label>
<Input
id="trigger-cron"
value={draft.cron}
placeholder="0 9 * * 1-5"
className="font-mono"
onChange={(event) =>
setDraft((prev) => ({ ...prev, cron: event.target.value }))
}
/>
</div>
)}
<div className="space-y-1.5 sm:col-span-2">
<div className="text-xs font-medium text-muted-foreground">Timezone</div>
<TimezoneCombobox
value={draft.timezone}
onChange={(timezone) => setDraft((prev) => ({ ...prev, timezone }))}
/>
</div>
</div>
{issues.length > 0 && (
@ -205,11 +347,7 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
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" />
)}
{updating ? <Spinner size="xs" className="mr-1.5" /> : null}
Save
</Button>
</div>

View file

@ -51,9 +51,17 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
}
return (
<>
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
</>
<AutomationBuilderForm
mode="edit"
searchSpaceId={searchSpaceId}
automation={automation}
renderModeSwitcher={(modeSwitcher) => (
<AutomationEditHeader
automation={automation}
searchSpaceId={searchSpaceId}
modeSwitcher={modeSwitcher}
/>
)}
/>
);
}

View file

@ -1,15 +1,21 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import type { Automation } from "@/contracts/types/automation.types";
interface AutomationEditHeaderProps {
automation: Automation;
searchSpaceId: number;
modeSwitcher?: ReactNode;
}
export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) {
export function AutomationEditHeader({
automation,
searchSpaceId,
modeSwitcher,
}: AutomationEditHeaderProps) {
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
return (
@ -20,11 +26,11 @@ export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEd
Back to automation
</Link>
</Button>
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
Edit automation
</h1>
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
{modeSwitcher ? <div className="ml-auto">{modeSwitcher}</div> : null}
</div>
</div>
);