diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index d62def974..67c3d9979 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -11,6 +11,8 @@ import { MoreHorizontal, Pencil, Play, + Power, + PowerOff, Send, SmilePlus, Timer, @@ -31,12 +33,14 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { getWorkflowCardLabel, getWorkflowDescription, getWorkflowDisplayStatus, + getWorkflowEnabled, getWorkflowPrimaryAction, getWorkflowPrimaryActionChannel, getWorkflowPrimaryActionEmoji, @@ -50,7 +54,9 @@ type WorkflowCardProps = { workflow: Workflow; channelName?: string; isActive?: boolean; + isTogglingEnabled?: boolean; onTrigger: (workflowId: string) => void; + onToggleEnabled: (workflow: Workflow) => void; onEdit: (workflow: Workflow) => void; onDuplicate: (workflow: Workflow) => void; onDelete: (workflow: Workflow) => void; @@ -182,12 +188,15 @@ export function WorkflowCard({ workflow, channelName, isActive = false, + isTogglingEnabled = false, onTrigger, + onToggleEnabled, onEdit, onDuplicate, onDelete, }: WorkflowCardProps) { const customEmoji = useCustomEmoji(); + const isEnabled = getWorkflowEnabled(workflow.definition); const displayStatus = getWorkflowDisplayStatus(workflow); const description = getWorkflowDescription(workflow.definition); const configuredTrigger = getWorkflowTriggerConfig(workflow.definition); @@ -321,6 +330,18 @@ export function WorkflowCard({ Duplicate + onToggleEnabled(workflow)} + > + {isEnabled ? ( + + ) : ( + + )} + {isEnabled ? "Disable" : "Enable"} + + onDelete(workflow)} diff --git a/desktop/src/features/workflows/ui/WorkflowsView.tsx b/desktop/src/features/workflows/ui/WorkflowsView.tsx index f05c91429..1ae3847d7 100644 --- a/desktop/src/features/workflows/ui/WorkflowsView.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsView.tsx @@ -1,6 +1,7 @@ import { Plus, RefreshCw, Zap } from "lucide-react"; import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { stringify as yamlStringify } from "yaml"; import { allWorkflowsQueryKey, @@ -10,11 +11,16 @@ import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard"; import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog"; import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel"; import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog"; +import { + getWorkflowEnabled, + withWorkflowEnabled, +} from "@/features/workflows/ui/workflowDefinition"; import type { Channel, Workflow } from "@/shared/api/types"; import { deleteWorkflow, getChannelsWorkflows, triggerWorkflow, + updateWorkflow, } from "@/shared/api/tauriWorkflows"; import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -126,6 +132,26 @@ export function WorkflowsView({ }, }); + const toggleEnabledMutation = useMutation({ + mutationFn: (workflow: Workflow) => + updateWorkflow( + workflow.id, + yamlStringify( + withWorkflowEnabled( + workflow.definition, + !getWorkflowEnabled(workflow.definition), + ), + ), + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "workflows" || + query.queryKey[0] === "workflows-all", + }); + }, + }); + const triggerOne = triggerMutation.mutate; const handleTrigger = React.useCallback( (workflowId: string) => triggerOne(workflowId), @@ -156,6 +182,12 @@ export function WorkflowsView({ [], ); + const toggleEnabled = toggleEnabledMutation.mutate; + const handleToggleEnabled = React.useCallback( + (workflow: Workflow) => toggleEnabled(workflow), + [toggleEnabled], + ); + const handleDialogOpenChange = React.useCallback((open: boolean) => { if (!open) { setDialogState({ mode: "closed" }); @@ -235,10 +267,15 @@ export function WorkflowsView({ diff --git a/desktop/src/features/workflows/ui/workflowDefinition.test.mjs b/desktop/src/features/workflows/ui/workflowDefinition.test.mjs index ffe3e01bd..ad3777e9d 100644 --- a/desktop/src/features/workflows/ui/workflowDefinition.test.mjs +++ b/desktop/src/features/workflows/ui/workflowDefinition.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { getWorkflowCardLabel, getWorkflowPrimaryActionEmoji, + withWorkflowEnabled, } from "./workflowDefinition.ts"; test("builds a plain-language workflow card label", () => { @@ -111,6 +112,23 @@ test("returns the primary add-reaction emoji for rich card rendering", () => { ); }); +test("toggles workflow enabled state without mutating its definition", () => { + const definition = { + name: "deploy", + enabled: false, + trigger: { on: "message_posted" }, + future_field: { keep: true }, + }; + + assert.deepEqual(withWorkflowEnabled(definition, true), { + name: "deploy", + trigger: { on: "message_posted" }, + future_field: { keep: true }, + }); + assert.deepEqual(withWorkflowEnabled(definition, false), definition); + assert.equal(definition.enabled, false); +}); + test("summarizes common and custom schedules", () => { assert.equal( getWorkflowCardLabel({ diff --git a/desktop/src/features/workflows/ui/workflowDefinition.ts b/desktop/src/features/workflows/ui/workflowDefinition.ts index ee3799bff..db3dfe120 100644 --- a/desktop/src/features/workflows/ui/workflowDefinition.ts +++ b/desktop/src/features/workflows/ui/workflowDefinition.ts @@ -362,6 +362,19 @@ export function getWorkflowEnabled( return definition.enabled !== false; } +export function withWorkflowEnabled( + definition: Record, + enabled: boolean, +): Record { + const updated = { ...definition }; + if (enabled) { + delete updated.enabled; + } else { + updated.enabled = false; + } + return updated; +} + export function getWorkflowDisplayStatus( workflow: Workflow, ): Workflow["status"] | "disabled" { diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index d83b53f04..1218c837f 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -922,6 +922,27 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => { await expect(card).toContainText("disabled"); }); +test("enables and disables a workflow from its card menu", async ({ page }) => { + const workflowName = `toggle_workflow_${Date.now()}`; + + await navigateToWorkflows(page); + await createWorkflow(page, workflowName); + + const card = page + .locator('[data-testid^="workflow-card-"]') + .filter({ hasText: workflowName }) + .first(); + const actions = card.getByRole("button", { name: "Workflow actions" }); + + await actions.click(); + await page.getByRole("menuitem", { name: "Disable" }).click(); + await expect(card.getByText("disabled", { exact: true })).toBeVisible(); + + await actions.click(); + await page.getByRole("menuitem", { name: "Enable" }).click(); + await expect(card.getByText("active", { exact: true })).toBeVisible(); +}); + test("shows the webhook secret dialog after saving a webhook workflow", async ({ page, }) => {