diff --git a/desktop/src/features/workflows/ui/ChannelCombobox.tsx b/desktop/src/features/workflows/ui/ChannelCombobox.tsx index 901e540d4..7f219a102 100644 --- a/desktop/src/features/workflows/ui/ChannelCombobox.tsx +++ b/desktop/src/features/workflows/ui/ChannelCombobox.tsx @@ -102,7 +102,7 @@ export function ChannelCombobox({ {selected ? : null} - {selected ? selected.name : "Select a channel..."} + {selected ? selected.name : "Choose a channel"} diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index 119b2aac3..e5d97df42 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -8,6 +8,16 @@ import { } from "@/features/workflows/hooks"; import type { Channel, Workflow } from "@/shared/api/types"; import { getRelayHttpUrl } from "@/shared/api/tauri"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { @@ -78,9 +88,7 @@ export function WorkflowDialog({ workflow, }: WorkflowDialogProps) { const channelId = - mode === "edit" && workflow?.channelId - ? workflow.channelId - : (channels[0]?.id ?? ""); + mode === "edit" && workflow?.channelId ? workflow.channelId : ""; const [selectedChannelId, setSelectedChannelId] = React.useState(channelId); const [yamlDefinition, setYamlDefinition] = React.useState(() => @@ -99,6 +107,12 @@ export function WorkflowDialog({ webhookSecret: string; workflowId: string; } | null>(null); + const [discardConfirmationOpen, setDiscardConfirmationOpen] = + React.useState(false); + const initialValuesRef = React.useRef({ + channelId, + yaml: getInitialYaml(mode, workflow), + }); const createMutation = useCreateWorkflowMutation(selectedChannelId); const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? ""); @@ -107,7 +121,6 @@ export function WorkflowDialog({ const selectedChannel = channels.find((c) => c.id === selectedChannelId) ?? null; - const defaultChannelId = channels[0]?.id ?? ""; const workflowChannelId = workflow?.channelId ?? null; const resetCreate = createMutation.reset; const resetUpdate = updateMutation.reset; @@ -116,37 +129,45 @@ export function WorkflowDialog({ React.useEffect(() => { if (open) { const newChannelId = - mode === "edit" && workflowChannelId - ? workflowChannelId - : defaultChannelId; + mode === "edit" && workflowChannelId ? workflowChannelId : ""; setSelectedChannelId(newChannelId); const initialYaml = getInitialYaml(mode, workflow); + initialValuesRef.current = { + channelId: newChannelId, + yaml: initialYaml, + }; setYamlDefinition(initialYaml); setEditorMode(getInitialEditorMode(initialYaml)); setEditorParseError(null); setSavedWebhookInfo(null); + setDiscardConfirmationOpen(false); resetCreate(); resetUpdate(); } - }, [ - open, - mode, - workflow, - workflowChannelId, - defaultChannelId, - resetCreate, - resetUpdate, - ]); + }, [open, mode, workflow, workflowChannelId, resetCreate, resetUpdate]); + + const closeDialog = React.useCallback(() => { + resetCreate(); + resetUpdate(); + setDiscardConfirmationOpen(false); + onOpenChange(false); + }, [onOpenChange, resetCreate, resetUpdate]); + + const isDirty = + yamlDefinition !== initialValuesRef.current.yaml || + selectedChannelId !== initialValuesRef.current.channelId; const handleOpenChange = React.useCallback( (nextOpen: boolean) => { - if (!nextOpen) { - resetCreate(); - resetUpdate(); + if (nextOpen) { + onOpenChange(true); + } else if (isDirty) { + setDiscardConfirmationOpen(true); + } else { + closeDialog(); } - onOpenChange(nextOpen); }, - [onOpenChange, resetCreate, resetUpdate], + [closeDialog, isDirty, onOpenChange], ); async function handleSubmit() { @@ -154,7 +175,7 @@ export function WorkflowDialog({ try { const saved = await mutation.mutateAsync(yamlDefinition); - handleOpenChange(false); + closeDialog(); if (saved.webhookSecret) { const relayHttpUrl = await getRelayHttpUrl(); setSavedWebhookInfo({ @@ -195,8 +216,7 @@ export function WorkflowDialog({ [editorMode, yamlDefinition], ); - const showChannelSelector = mode !== "edit" && channels.length > 1; - const showChannelInfo = mode !== "edit" && channels.length === 1; + const showChannelSelector = mode !== "edit"; return ( <> @@ -246,7 +266,7 @@ export function WorkflowDialog({ parseError={editorParseError} scopeField={ showChannelSelector ? ( -
+
- {!selectedChannel ? ( -

+ {channels.length === 0 ? ( +

Join or create a channel before adding a workflow.

) : null}
- ) : (showChannelInfo || mode === "edit") && selectedChannel ? ( + ) : mode === "edit" && selectedChannel ? (

{selectedChannel.name} @@ -309,6 +329,32 @@ export function WorkflowDialog({ + + + + Discard changes? + + Your unsaved workflow changes will be lost. + + + + + + + + + + + + + {savedWebhookInfo ? ( { diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 2b83a579c..01d768e4c 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -21,6 +21,15 @@ async function navigateToWorkflows(page: import("@playwright/test").Page) { await expect(page.getByTestId("workflows-view")).toBeVisible(); } +async function selectFirstChannel(dialog: import("@playwright/test").Locator) { + await dialog.getByRole("combobox", { name: "Channel" }).click(); + await dialog + .getByTestId("channel-combobox-list") + .getByRole("button") + .first() + .click(); +} + async function createWorkflow( page: import("@playwright/test").Page, name: string, @@ -36,6 +45,11 @@ async function createWorkflow( await page.getByRole("button", { name: "Create Workflow" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Choose a channel")).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Create workflow" }), + ).toBeDisabled(); + await selectFirstChannel(dialog); await dialog.getByLabel("Workflow name").fill(name); if (options?.description) { @@ -124,6 +138,27 @@ test("switches an empty workflow between form and YAML modes", async ({ await expect(dialog.getByLabel("Workflow name")).toBeVisible(); }); +test("confirms before discarding workflow changes", async ({ page }) => { + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog", { name: "Create workflow" }); + await dialog.getByLabel("Workflow name").fill("unfinished_workflow"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + const confirmation = page.getByRole("alertdialog"); + await expect(confirmation).toContainText("Discard changes?"); + await confirmation.getByRole("button", { name: "Keep editing" }).click(); + await expect(confirmation).not.toBeVisible(); + await expect(dialog.getByLabel("Workflow name")).toHaveValue( + "unfinished_workflow", + ); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await confirmation.getByRole("button", { name: "Discard changes" }).click(); + await expect(dialog).not.toBeVisible(); +}); + test("scrolls the channel list with the mouse wheel", async ({ page }) => { await navigateToWorkflows(page); @@ -314,6 +349,7 @@ test("duplicates a workflow", async ({ page }) => { await expect(page.getByText("Duplicate workflow")).toBeVisible(); // Submit the duplicate + await selectFirstChannel(page.getByRole("dialog")); await page.getByRole("button", { name: "Create copy" }).click(); await expect(page.getByRole("dialog")).not.toBeVisible();