From 2ba30837a91877bd9c9eef501467a59e4ea500f3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:30:24 +0530 Subject: [PATCH] refactor(automations): enhance TriggerCard component with improved scheduling options, including frequency selection and custom cron input --- .../components/trigger-card.tsx | 180 ++++++++++++++++-- .../edit/automation-edit-content.tsx | 16 +- .../components/automation-edit-header.tsx | 12 +- 3 files changed, 180 insertions(+), 28 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx index 3985e6ffc..74091f123 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx @@ -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 | "custom"; + interface TriggerDraft { - params: Record; - static_inputs: Record; + frequency: SimpleFrequency; + hour: number; + minute: number; + timezone: string; + cron: string; } +const SIMPLE_FREQUENCIES = new Set(["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 ? (
-
- setDraft(next as TriggerDraft)} - collapsed={false} - /> +
+
+ + +
+ + {draft.frequency === "hourly" ? ( +
+ + + setDraft((prev) => ({ + ...prev, + minute: clampInt(event.target.value, 0, 59), + })) + } + /> +
+ ) : draft.frequency !== "custom" ? ( +
+ + { + const [hour, minute] = event.target.value.split(":"); + setDraft((prev) => ({ + ...prev, + hour: clampInt(hour, 0, 23), + minute: clampInt(minute, 0, 59), + })); + }} + /> +
+ ) : ( +
+ + + setDraft((prev) => ({ ...prev, cron: event.target.value })) + } + /> +
+ )} + +
+
Timezone
+ setDraft((prev) => ({ ...prev, timezone }))} + /> +
{issues.length > 0 && ( @@ -205,11 +347,7 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri Cancel
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx index 2c9db217d..c05bff7d9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx @@ -51,9 +51,17 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio } return ( - <> - - - + ( + + )} + /> ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx index 6b2a31822..ca477220e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx @@ -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 -
+

Edit automation

-

{automation.name}

+ {modeSwitcher ?
{modeSwitcher}
: null}
);