mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
refactor(automations): enhance automation definition section with collapsible execution defaults, improve layout, and update UI elements for better readability
This commit is contained in:
parent
14f339bba0
commit
282c0495c0
8 changed files with 355 additions and 257 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
"use client";
|
"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 { 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 type { AutomationDefinition } from "@/contracts/types/automation.types";
|
||||||
import { ExecutionSummary } from "./execution-summary";
|
import { ExecutionSummary } from "./execution-summary";
|
||||||
import { InputsSchemaPreview } from "./inputs-schema-preview";
|
import { InputsSchemaPreview } from "./inputs-schema-preview";
|
||||||
|
|
@ -11,34 +13,30 @@ interface AutomationDefinitionSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Definition card. Read view; editing happens on the sibling /edit
|
* User-facing read view of the saved automation definition. Editing happens on
|
||||||
* route (Edit button in the header). Layout is top-down:
|
* the sibling /edit route; this card should summarize behavior, not expose the
|
||||||
* goal → tags → execution defaults → inputs schema (if any) → plan
|
* raw persisted schema.
|
||||||
*
|
|
||||||
* The schema_version is rendered as a small badge next to the section
|
|
||||||
* title so it's discoverable but doesn't fight for attention.
|
|
||||||
*/
|
*/
|
||||||
export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) {
|
export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) {
|
||||||
const hasTags = definition.metadata.tags.length > 0;
|
const hasTags = definition.metadata.tags.length > 0;
|
||||||
const hasInputs = !!definition.inputs;
|
const hasInputs = !!definition.inputs;
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const stepCount = `${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/60 bg-accent">
|
<Card className="border-border/60 bg-accent">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-base font-semibold">Definition</CardTitle>
|
<CardTitle className="text-base font-semibold">Automation details</CardTitle>
|
||||||
<span className="text-xs font-mono text-muted-foreground border border-border/60 rounded px-1.5 py-0.5">
|
|
||||||
v{definition.schema_version}
|
|
||||||
</span>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{definition.goal && (
|
{definition.goal && (
|
||||||
<Field icon={Target} label="Goal">
|
<Field label="Goal">
|
||||||
<p className="text-sm text-foreground">{definition.goal}</p>
|
<p className="text-sm text-foreground">{definition.goal}</p>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasTags && (
|
{hasTags && (
|
||||||
<Field icon={Tag} label="Tags">
|
<Field label="Tags">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{definition.metadata.tags.map((tag) => (
|
{definition.metadata.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
|
|
@ -52,25 +50,39 @@ export function AutomationDefinitionSection({ definition }: AutomationDefinition
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field icon={Settings2} label="Execution defaults">
|
|
||||||
<ExecutionSummary execution={definition.execution} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{hasInputs && (
|
{hasInputs && (
|
||||||
<Field icon={Settings2} label="Inputs schema">
|
<Field label="Inputs">
|
||||||
{definition.inputs && <InputsSchemaPreview inputs={definition.inputs} />}
|
{definition.inputs && <InputsSchemaPreview inputs={definition.inputs} />}
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
icon={ListOrdered}
|
label={
|
||||||
label={`Plan · ${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`}
|
<span className="inline-flex items-center">
|
||||||
|
Plan
|
||||||
|
<Dot className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
{stepCount}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{definition.plan.map((step, idx) => (
|
{definition.plan.map((step, idx) => (
|
||||||
<PlanStepCard key={step.step_id} step={step} index={idx} />
|
<PlanStepCard key={step.step_id} step={step} index={idx} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-3">
|
||||||
|
<CollapsibleTrigger className="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline">
|
||||||
|
{advancedOpen ? "Hide advanced options" : "Advanced options"}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="mt-3 rounded-md border border-border/60 bg-background/30 p-3">
|
||||||
|
<div className="mb-2 text-sm font-medium text-muted-foreground">
|
||||||
|
Execution defaults
|
||||||
|
</div>
|
||||||
|
<ExecutionSummary execution={definition.execution} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</Field>
|
</Field>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -78,20 +90,15 @@ export function AutomationDefinitionSection({ definition }: AutomationDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
icon: Icon,
|
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon: typeof Target;
|
label: React.ReactNode;
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-sm font-medium text-muted-foreground">{label}</div>
|
||||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,15 @@ export function AutomationDetailHeader({
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{canUpdate && (
|
{canUpdate && (
|
||||||
<Button asChild type="button" variant="outline" size="sm">
|
<Button
|
||||||
|
asChild
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||||
|
>
|
||||||
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
|
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-1 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -89,28 +95,30 @@ export function AutomationDetailHeader({
|
||||||
{canToggle && (
|
{canToggle && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleTogglePause}
|
onClick={handleTogglePause}
|
||||||
disabled={updating}
|
disabled={updating}
|
||||||
|
className="relative justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||||
>
|
>
|
||||||
{updating ? (
|
<span className={updating ? "inline-flex items-center whitespace-nowrap opacity-0" : "inline-flex items-center whitespace-nowrap"}>
|
||||||
<Spinner size="xs" className="mr-2" />
|
<PauseIcon className="mr-1 h-4 w-4" />
|
||||||
) : (
|
{pauseLabel}
|
||||||
<PauseIcon className="mr-2 h-4 w-4" />
|
</span>
|
||||||
|
{updating && (
|
||||||
|
<Spinner size="xs" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
)}
|
)}
|
||||||
{pauseLabel}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function AutomationTriggersSection({
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
|
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
When this automation fires. v1 supports scheduled triggers only.
|
When this automation runs
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-xs">
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-xs">
|
||||||
<Item label="Timeout" value={`${execution.timeout_seconds}s`} />
|
<Item label="Timeout" value={`${execution.timeout_seconds}s`} />
|
||||||
<Item label="Max retries" value={String(execution.max_retries)} />
|
<Item label="Max retries" value={String(execution.max_retries)} />
|
||||||
<Item label="Retry backoff" value={execution.retry_backoff} />
|
<Item label="Retry backoff" value={formatEnumValue(execution.retry_backoff)} />
|
||||||
<Item label="Concurrency" value={execution.concurrency} />
|
<Item label="Concurrency" value={formatEnumValue(execution.concurrency)} />
|
||||||
{execution.on_failure.length > 0 && (
|
{execution.on_failure.length > 0 && (
|
||||||
<Item
|
<Item
|
||||||
label="On failure"
|
label="On failure"
|
||||||
|
|
@ -27,6 +27,11 @@ export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatEnumValue(value: string): string {
|
||||||
|
const text = value.replace(/_/g, " ");
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
function Item({ label, value }: { label: string; value: string }) {
|
function Item({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { JsonView } from "@/components/json-view";
|
|
||||||
import type { Inputs } from "@/contracts/types/automation.types";
|
import type { Inputs } from "@/contracts/types/automation.types";
|
||||||
|
|
||||||
interface InputsSchemaPreviewProps {
|
interface InputsSchemaPreviewProps {
|
||||||
|
|
@ -13,9 +12,63 @@ interface InputsSchemaPreviewProps {
|
||||||
* is null.
|
* is null.
|
||||||
*/
|
*/
|
||||||
export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) {
|
export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) {
|
||||||
|
const fields = getInputFields(inputs.schema);
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No extra inputs are required.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-72 overflow-auto">
|
<div className="rounded-md border border-border/60 bg-background/30">
|
||||||
<JsonView src={inputs.schema} collapsed={2} />
|
{fields.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
className="flex items-start justify-between gap-4 border-border/60 px-3 py-2 text-sm not-last:border-b"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-foreground">{field.name}</div>
|
||||||
|
{field.description ? (
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">{field.description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{field.type}
|
||||||
|
{field.required ? " · required" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInputFields(schema: Record<string, unknown>): {
|
||||||
|
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<string, unknown>).map(([name, value]) => {
|
||||||
|
const field = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: formatType((field as Record<string, unknown>).type),
|
||||||
|
description:
|
||||||
|
typeof (field as Record<string, unknown>).description === "string"
|
||||||
|
? ((field as Record<string, unknown>).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";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { ArrowRightCircle, GitCommitHorizontal } from "lucide-react";
|
|
||||||
import { JsonView } from "@/components/json-view";
|
|
||||||
import type { PlanStep } from "@/contracts/types/automation.types";
|
import type { PlanStep } from "@/contracts/types/automation.types";
|
||||||
|
|
||||||
interface PlanStepCardProps {
|
interface PlanStepCardProps {
|
||||||
|
|
@ -9,62 +7,35 @@ interface PlanStepCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only view of one plan step. Renders the step_id + action prominently,
|
* Read-only view of one plan step. Keep this user-facing: summarize what the
|
||||||
* then a definition list of the per-step knobs, and finally the params as
|
* step does and only show advanced step controls when they are explicitly set.
|
||||||
* formatted JSON. Editable mode is out of scope here — definition edits live
|
|
||||||
* on the (future) raw-JSON path.
|
|
||||||
*/
|
*/
|
||||||
export function PlanStepCard({ step, index }: PlanStepCardProps) {
|
export function PlanStepCard({ step, index }: PlanStepCardProps) {
|
||||||
|
const title = getStepTitle(step);
|
||||||
|
const details = getStepDetails(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
<div className="rounded-md border border-border/60 bg-background/30 px-4 py-3">
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/60 bg-muted/30">
|
<div className="flex items-start gap-3">
|
||||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
<span className="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-foreground">{step.step_id}</span>
|
<div className="min-w-0 flex-1">
|
||||||
<ArrowRightCircle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||||
<span className="text-xs font-mono text-muted-foreground">{step.action}</span>
|
{details.length > 0 ? (
|
||||||
</div>
|
<dl className="mt-3 grid grid-cols-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||||
|
{details.map((detail) => (
|
||||||
<div className="px-4 py-3 space-y-3">
|
<DefRow key={detail.label} label={detail.label} value={detail.value} />
|
||||||
{(step.when ||
|
))}
|
||||||
step.output_as ||
|
</dl>
|
||||||
step.max_retries != null ||
|
) : null}
|
||||||
step.timeout_seconds != null) && (
|
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
|
||||||
{step.when && (
|
|
||||||
<DefRow label="When" value={<code className="font-mono">{step.when}</code>} />
|
|
||||||
)}
|
|
||||||
{step.output_as && (
|
|
||||||
<DefRow
|
|
||||||
label="Output as"
|
|
||||||
value={<code className="font-mono">{step.output_as}</code>}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{step.max_retries != null && (
|
|
||||||
<DefRow label="Max retries" value={String(step.max_retries)} />
|
|
||||||
)}
|
|
||||||
{step.timeout_seconds != null && (
|
|
||||||
<DefRow label="Timeout" value={`${step.timeout_seconds}s`} />
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1.5">
|
|
||||||
<GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
Params
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
|
|
||||||
<JsonView src={step.params} collapsed={1} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
|
function DefRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-2 min-w-0">
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
<dt className="text-muted-foreground shrink-0">{label}:</dt>
|
<dt className="text-muted-foreground shrink-0">{label}:</dt>
|
||||||
|
|
@ -72,3 +43,104 @@ function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, unknown>, key: string): string | null {
|
||||||
|
const value = params[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMentions(params: Record<string, unknown>): 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<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useAtomValue } from "jotai";
|
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 { 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { type Trigger, triggerUpdateRequest } 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 { formatRelativeFutureDate } from "@/lib/format-date";
|
||||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||||
|
|
||||||
interface TriggerCardProps {
|
interface TriggerCardProps {
|
||||||
|
|
@ -34,10 +40,9 @@ function draftFromTrigger(trigger: Trigger): TriggerDraft {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* - human-readable schedule
|
||||||
* - last_fired_at / next_fire_at hints
|
* - compact enable toggle
|
||||||
* - static_inputs as formatted JSON (when present)
|
* - dropdown actions for edit/remove
|
||||||
* - enable toggle + remove button + inline edit (each gated independently)
|
|
||||||
*
|
*
|
||||||
* Inline edit covers ``params`` and ``static_inputs`` — the two fields the
|
* Inline edit covers ``params`` and ``static_inputs`` — the two fields the
|
||||||
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
|
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
|
||||||
|
|
@ -52,10 +57,9 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
const [issues, setIssues] = useState<string[]>([]);
|
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 human = cron ? describeCron(cron) : trigger.type;
|
const human = cron ? describeCron(cron) : trigger.type;
|
||||||
const triggerLabel = cron ? `${human} · ${tz}` : trigger.type;
|
const triggerLabel = human;
|
||||||
const hasStaticInputs = Object.keys(trigger.static_inputs ?? {}).length > 0;
|
const showActions = (canUpdate && !isEditing) || canDelete;
|
||||||
|
|
||||||
async function handleToggle(checked: boolean) {
|
async function handleToggle(checked: boolean) {
|
||||||
await updateTrigger({
|
await updateTrigger({
|
||||||
|
|
@ -99,136 +103,118 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
<div className="rounded-md border border-border/60 bg-background/30">
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-3 border-b border-border/60">
|
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="min-w-0 truncate text-sm font-medium text-foreground">{human}</div>
|
||||||
<CalendarClock className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
|
|
||||||
<div className="min-w-0">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
{canUpdate && (
|
||||||
<span className="font-medium text-foreground">{human}</span>
|
<Switch
|
||||||
<span className="text-muted-foreground">· {tz}</span>
|
checked={trigger.enabled}
|
||||||
</div>
|
onCheckedChange={handleToggle}
|
||||||
{cron && <code className="text-xs font-mono text-muted-foreground">{cron}</code>}
|
disabled={updating || isEditing}
|
||||||
|
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||||
|
className="h-5 w-9 [&>span]:h-4 [&>span]:w-4 [&>span[data-state=checked]]:translate-x-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showActions && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 hover:bg-transparent"
|
||||||
|
disabled={isEditing}
|
||||||
|
aria-label="Trigger actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-32 z-80">
|
||||||
|
{canUpdate && !isEditing && (
|
||||||
|
<DropdownMenuItem onSelect={startEdit}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<DropdownMenuItem onSelect={() => setDeleteOpen(true)}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && trigger.next_fire_at ? (
|
||||||
|
<div className="flex items-center gap-3 border-t border-border/60 px-4 py-3 text-sm">
|
||||||
|
<div className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<span>Next fire:</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
trigger.enabled
|
||||||
|
? "min-w-0 truncate font-medium text-foreground"
|
||||||
|
: "min-w-0 truncate text-muted-foreground"
|
||||||
|
}
|
||||||
|
title={new Date(trigger.next_fire_at).toLocaleString()}
|
||||||
|
>
|
||||||
|
{formatRelativeFutureDate(trigger.next_fire_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
{isEditing ? (
|
||||||
{canUpdate && (
|
<div className="space-y-3 border-t border-border/60 px-4 py-3 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
|
||||||
<span className="text-xs text-muted-foreground">
|
<JsonView
|
||||||
{trigger.enabled ? "Enabled" : "Off"}
|
src={draft}
|
||||||
</span>
|
editable
|
||||||
<Switch
|
onChange={(next) => setDraft(next as TriggerDraft)}
|
||||||
checked={trigger.enabled}
|
collapsed={false}
|
||||||
onCheckedChange={handleToggle}
|
/>
|
||||||
disabled={updating || isEditing}
|
</div>
|
||||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
|
||||||
/>
|
{issues.length > 0 && (
|
||||||
</div>
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle aria-hidden />
|
||||||
|
<AlertTitle>
|
||||||
|
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-inside list-disc">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<li key={issue}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{canUpdate && !isEditing && (
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
className="h-8 w-8 text-muted-foreground"
|
onClick={cancelEdit}
|
||||||
onClick={startEdit}
|
disabled={updating}
|
||||||
aria-label="Edit trigger"
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button type="button" size="sm" onClick={saveEdit} disabled={updating}>
|
||||||
{canDelete && (
|
{updating ? (
|
||||||
<Button
|
<Spinner size="xs" className="mr-1.5" />
|
||||||
variant="ghost"
|
) : (
|
||||||
size="icon"
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
)}
|
||||||
onClick={() => setDeleteOpen(true)}
|
Save
|
||||||
disabled={isEditing}
|
|
||||||
aria-label="Remove trigger"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<div className="px-4 py-3 space-y-3 text-xs">
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{issues.length > 0 && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle aria-hidden />
|
|
||||||
<AlertTitle>
|
|
||||||
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<ul className="list-inside list-disc">
|
|
||||||
{issues.map((issue) => (
|
|
||||||
<li key={issue}>{issue}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
{canDelete && (
|
{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 (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground inline-flex items-center gap-1.5 whitespace-nowrap">
|
|
||||||
<Clock className="h-3 w-3" aria-hidden />
|
|
||||||
{label}
|
|
||||||
</dt>
|
|
||||||
<dd
|
|
||||||
className={
|
|
||||||
highlight
|
|
||||||
? "text-foreground font-medium min-w-0 truncate"
|
|
||||||
: "text-muted-foreground min-w-0 truncate"
|
|
||||||
}
|
|
||||||
title={new Date(iso).toLocaleString()}
|
|
||||||
>
|
|
||||||
{formatted}
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -495,12 +495,11 @@ export function AutomationBuilderForm({
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
|
className="relative"
|
||||||
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
|
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
<span className={submitting ? "opacity-0" : ""}>{submitLabel}</span>
|
||||||
<Spinner size="xs" className="mr-2" />
|
{submitting && <Spinner size="xs" className="absolute" />}
|
||||||
) : null}
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue