Guard unsaved workflow changes

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 14:11:53 -07:00
parent 03e8542648
commit a6e7e05b6e
3 changed files with 111 additions and 29 deletions
@@ -102,7 +102,7 @@ export function ChannelCombobox({
<span className="flex min-w-0 items-center justify-center gap-2">
{selected ? <ChannelPrivacyIcon channel={selected} /> : null}
<span className="truncate">
{selected ? selected.name : "Select a channel..."}
{selected ? selected.name : "Choose a channel"}
</span>
<ChevronDown className="ml-1 h-5 w-5 shrink-0 text-muted-foreground opacity-50 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
</span>
@@ -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 ? (
<div>
<div className="space-y-1">
<ChannelCombobox
channels={channels}
disabled={mutation.isPending}
@@ -257,13 +277,13 @@ export function WorkflowDialog({
}}
value={selectedChannelId}
/>
{!selectedChannel ? (
<p className="text-xs text-muted-foreground">
{channels.length === 0 ? (
<p className="text-center text-xs text-muted-foreground">
Join or create a channel before adding a workflow.
</p>
) : null}
</div>
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
) : mode === "edit" && selectedChannel ? (
<div className="px-3 py-2">
<p className="text-lg font-semibold text-foreground">
{selectedChannel.name}
@@ -309,6 +329,32 @@ export function WorkflowDialog({
</DialogContent>
</Dialog>
<AlertDialog
onOpenChange={setDiscardConfirmationOpen}
open={discardConfirmationOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
Your unsaved workflow changes will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Keep editing
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={closeDialog} type="button" variant="destructive">
Discard changes
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{savedWebhookInfo ? (
<WorkflowWebhookSecretDialog
onOpenChange={(nextOpen) => {
+36
View File
@@ -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();