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();