From 282c0495c0c2701a2e3f30dabbb1e86df763f63a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:40:27 +0530 Subject: [PATCH] refactor(automations): enhance automation definition section with collapsible execution defaults, improve layout, and update UI elements for better readability --- .../automation-definition-section.tsx | 63 ++-- .../components/automation-detail-header.tsx | 30 +- .../automation-triggers-section.tsx | 2 +- .../components/execution-summary.tsx | 9 +- .../components/inputs-schema-preview.tsx | 59 +++- .../components/plan-step-card.tsx | 166 ++++++++--- .../components/trigger-card.tsx | 276 ++++++++---------- .../builder/automation-builder-form.tsx | 7 +- 8 files changed, 355 insertions(+), 257 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx index 4ff9b8b8c..4d6382a60 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx @@ -1,6 +1,8 @@ "use client"; -import { ListOrdered, Settings2, Tag, Target } from "lucide-react"; +import { Dot } from "lucide-react"; +import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import type { AutomationDefinition } from "@/contracts/types/automation.types"; import { ExecutionSummary } from "./execution-summary"; import { InputsSchemaPreview } from "./inputs-schema-preview"; @@ -11,34 +13,30 @@ interface AutomationDefinitionSectionProps { } /** - * The Definition card. Read view; editing happens on the sibling /edit - * route (Edit button in the header). Layout is top-down: - * goal → tags → execution defaults → inputs schema (if any) → plan - * - * The schema_version is rendered as a small badge next to the section - * title so it's discoverable but doesn't fight for attention. + * User-facing read view of the saved automation definition. Editing happens on + * the sibling /edit route; this card should summarize behavior, not expose the + * raw persisted schema. */ export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) { const hasTags = definition.metadata.tags.length > 0; const hasInputs = !!definition.inputs; + const [advancedOpen, setAdvancedOpen] = useState(false); + const stepCount = `${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`; return ( - - Definition - - v{definition.schema_version} - + + Automation details {definition.goal && ( - +

{definition.goal}

)} {hasTags && ( - +
{definition.metadata.tags.map((tag) => ( )} - - - - {hasInputs && ( - + {definition.inputs && } )} + Plan + + {stepCount} + + } >
{definition.plan.map((step, idx) => ( ))}
+ + + {advancedOpen ? "Hide advanced options" : "Advanced options"} + + +
+
+ Execution defaults +
+ +
+
+
@@ -78,20 +90,15 @@ export function AutomationDefinitionSection({ definition }: AutomationDefinition } function Field({ - icon: Icon, label, children, }: { - icon: typeof Target; - label: string; + label: React.ReactNode; children: React.ReactNode; }) { return (
-
- - {label} -
+
{label}
{children}
); diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx index 8c8058082..4a6537385 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx @@ -79,9 +79,15 @@ export function AutomationDetailHeader({
{canUpdate && ( - @@ -89,28 +95,30 @@ export function AutomationDetailHeader({ {canToggle && ( )} {canDelete && ( )} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx index 558a089ac..2f4eea7b8 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx @@ -27,7 +27,7 @@ export function AutomationTriggersSection({ Triggers

- When this automation fires. v1 supports scheduled triggers only. + When this automation runs

diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx index 5c4dc381c..82abce173 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx @@ -15,8 +15,8 @@ export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
- - + + {execution.on_failure.length > 0 && ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx index 29d79d99b..dce6ac4a7 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx @@ -1,5 +1,4 @@ "use client"; -import { JsonView } from "@/components/json-view"; import type { Inputs } from "@/contracts/types/automation.types"; interface InputsSchemaPreviewProps { @@ -13,9 +12,63 @@ interface InputsSchemaPreviewProps { * is null. */ export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) { + const fields = getInputFields(inputs.schema); + + if (fields.length === 0) { + return

No extra inputs are required.

; + } + return ( -
- +
+ {fields.map((field) => ( +
+
+
{field.name}
+ {field.description ? ( +
{field.description}
+ ) : null} +
+
+ {field.type} + {field.required ? " · required" : ""} +
+
+ ))}
); } + +function getInputFields(schema: Record): { + name: string; + type: string; + description?: string; + required: boolean; +}[] { + const properties = schema.properties; + if (!properties || typeof properties !== "object" || Array.isArray(properties)) { + return []; + } + + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + return Object.entries(properties as Record).map(([name, value]) => { + const field = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + return { + name, + type: formatType((field as Record).type), + description: + typeof (field as Record).description === "string" + ? ((field as Record).description as string) + : undefined, + required: required.has(name), + }; + }); +} + +function formatType(value: unknown): string { + if (Array.isArray(value)) return value.join(" or "); + if (typeof value === "string") return value; + return "value"; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx index 27cecf3bf..15a285322 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx @@ -1,6 +1,4 @@ "use client"; -import { ArrowRightCircle, GitCommitHorizontal } from "lucide-react"; -import { JsonView } from "@/components/json-view"; import type { PlanStep } from "@/contracts/types/automation.types"; interface PlanStepCardProps { @@ -9,62 +7,35 @@ interface PlanStepCardProps { } /** - * Read-only view of one plan step. Renders the step_id + action prominently, - * then a definition list of the per-step knobs, and finally the params as - * formatted JSON. Editable mode is out of scope here — definition edits live - * on the (future) raw-JSON path. + * Read-only view of one plan step. Keep this user-facing: summarize what the + * step does and only show advanced step controls when they are explicitly set. */ export function PlanStepCard({ step, index }: PlanStepCardProps) { + const title = getStepTitle(step); + const details = getStepDetails(step); + return ( -
-
- +
+
+ {index + 1} - {step.step_id} - - {step.action} -
- -
- {(step.when || - step.output_as || - step.max_retries != null || - step.timeout_seconds != null) && ( -
- {step.when && ( - {step.when}} /> - )} - {step.output_as && ( - {step.output_as}} - /> - )} - {step.max_retries != null && ( - - )} - {step.timeout_seconds != null && ( - - )} -
- )} - -
-
- - Params -
-
- -
+
+

{title}

+ {details.length > 0 ? ( +
+ {details.map((detail) => ( + + ))} +
+ ) : null}
); } -function DefRow({ label, value }: { label: string; value: React.ReactNode }) { +function DefRow({ label, value }: { label: string; value: string }) { return (
{label}:
@@ -72,3 +43,104 @@ function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
); } + +function getStepTitle(step: PlanStep): string { + if (step.action === "agent_task") { + return readStringParam(step.params, "query") ?? "Run an agent task"; + } + return sentenceCase(formatAction(step.action)); +} + +function getStepDetails(step: PlanStep): { label: string; value: string }[] { + const details: { label: string; value: string }[] = []; + + if (step.action === "agent_task") { + if (typeof step.params.auto_approve_all === "boolean") { + details.push({ + label: "Approval", + value: step.params.auto_approve_all ? "Auto-approve agent actions" : "Ask before actions", + }); + } + + const mentionSummary = summarizeMentions(step.params); + if (mentionSummary) { + details.push({ label: "Scope", value: mentionSummary }); + } + } else { + const readableParams = Object.entries(step.params) + .filter(([, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => `${sentenceCase(formatKey(key))}: ${formatValue(value)}`); + if (readableParams.length > 0) { + details.push({ label: "Details", value: readableParams.join(" · ") }); + } + } + + if (step.when) details.push({ label: "Runs when", value: step.when }); + if (step.output_as) details.push({ label: "Saves output as", value: step.output_as }); + if (step.max_retries != null) details.push({ label: "Max retries", value: String(step.max_retries) }); + if (step.timeout_seconds != null) details.push({ label: "Timeout", value: `${step.timeout_seconds}s` }); + + return details; +} + +function readStringParam(params: Record, key: string): string | null { + const value = params[key]; + return typeof value === "string" && value.trim() ? value : null; +} + +function summarizeMentions(params: Record): string | null { + const parts: string[] = []; + addMentionTitles(parts, params.mentioned_documents, "Documents and folders"); + addMentionTitles(parts, params.mentioned_connectors, "Connectors"); + if (parts.length === 0) { + addCount(parts, params.mentioned_document_ids, "document"); + addCount(parts, params.mentioned_folder_ids, "folder"); + addCount(parts, params.mentioned_connector_ids, "connector"); + } + return parts.length > 0 ? parts.join(", ") : null; +} + +function addMentionTitles(parts: string[], value: unknown, label: string): void { + if (!Array.isArray(value) || value.length === 0) return; + const titles = value + .map((entry) => { + const record = asRecord(entry); + const title = typeof record.title === "string" ? record.title : null; + const accountName = typeof record.account_name === "string" ? record.account_name : null; + return title ?? accountName; + }) + .filter((title): title is string => !!title); + if (titles.length === 0) return; + parts.push(`${label}: ${titles.join(", ")}`); +} + +function addCount(parts: string[], value: unknown, singular: string): void { + if (!Array.isArray(value) || value.length === 0) return; + parts.push(`${value.length} ${singular}${value.length === 1 ? "" : "s"}`); +} + +function formatAction(action: string): string { + return formatKey(action); +} + +function formatKey(key: string): string { + return key.replace(/_/g, " "); +} + +function sentenceCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function formatValue(value: unknown): string { + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (typeof value === "string" || typeof value === "number") return String(value); + if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`; + if (value && typeof value === "object") return "Configured"; + return String(value); +} 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 1b3c9786f..3985e6ffc 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,16 +1,22 @@ "use client"; import { useAtomValue } from "jotai"; -import { AlertCircle, CalendarClock, Clock, Pencil, Save, Trash2 } from "lucide-react"; +import { AlertCircle, MoreHorizontal, Pencil, Save, 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; 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 { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date"; +import { formatRelativeFutureDate } from "@/lib/format-date"; import { DeleteTriggerDialog } from "./delete-trigger-dialog"; interface TriggerCardProps { @@ -34,10 +40,9 @@ function draftFromTrigger(trigger: Trigger): TriggerDraft { /** * One trigger row in the Triggers section of the detail page. Renders: - * - type icon + human-readable schedule + timezone - * - last_fired_at / next_fire_at hints - * - static_inputs as formatted JSON (when present) - * - enable toggle + remove button + inline edit (each gated independently) + * - 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``. @@ -52,10 +57,9 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri const [issues, setIssues] = useState([]); const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined; - const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC"; const human = cron ? describeCron(cron) : trigger.type; - const triggerLabel = cron ? `${human} · ${tz}` : trigger.type; - const hasStaticInputs = Object.keys(trigger.static_inputs ?? {}).length > 0; + const triggerLabel = human; + const showActions = (canUpdate && !isEditing) || canDelete; async function handleToggle(checked: boolean) { await updateTrigger({ @@ -99,136 +103,118 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri return ( <> -
-
-
- -
-
- {human} - · {tz} -
- {cron && {cron}} +
+
+
{human}
+ +
+ {canUpdate && ( + + )} + {showActions && ( + + + + + + {canUpdate && !isEditing && ( + + + Edit + + )} + {canDelete && ( + setDeleteOpen(true)}> + + Delete + + )} + + + )} +
+
+ + {!isEditing && trigger.next_fire_at ? ( +
+
+ Next fire: +
+
+ {formatRelativeFutureDate(trigger.next_fire_at)}
+ ) : null} -
- {canUpdate && ( -
- - {trigger.enabled ? "Enabled" : "Off"} - - -
+ {isEditing ? ( +
+
+ setDraft(next as TriggerDraft)} + collapsed={false} + /> +
+ + {issues.length > 0 && ( + + + + {issues.length === 1 ? "1 issue" : `${issues.length} issues`} + + +
    + {issues.map((issue) => ( +
  • {issue}
  • + ))} +
+
+
)} - {canUpdate && !isEditing && ( + +
- )} - {canDelete && ( - - )} +
-
- -
- {isEditing ? ( - <> -
- setDraft(next as TriggerDraft)} - collapsed={false} - /> -
- - {issues.length > 0 && ( - - - - {issues.length === 1 ? "1 issue" : `${issues.length} issues`} - - -
    - {issues.map((issue) => ( -
  • {issue}
  • - ))} -
-
-
- )} - -
- - -
- - ) : ( - <> - {(trigger.last_fired_at || trigger.next_fire_at) && ( -
- {trigger.next_fire_at && ( - - )} - {trigger.last_fired_at && ( - - )} -
- )} - - {hasStaticInputs && ( -
-
Static inputs
-
- -
-
- )} - - )} -
+ ) : null}
{canDelete && ( @@ -243,35 +229,3 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri ); } - -function TimeRow({ - label, - iso, - tense, - highlight = false, -}: { - label: string; - iso: string; - tense: "past" | "future"; - highlight?: boolean; -}) { - const formatted = tense === "future" ? formatRelativeFutureDate(iso) : formatRelativeDate(iso); - return ( - <> -
- - {label} -
-
- {formatted} -
- - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx index 3af86428a..59967080f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx @@ -495,12 +495,11 @@ export function AutomationBuilderForm({ type="button" size="sm" disabled={submitting} + className="relative" onClick={() => (activeMode === "json" ? submitJson() : submitForm())} > - {submitting ? ( - - ) : null} - {submitLabel} + {submitLabel} + {submitting && } )}