mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Guard unsaved workflow changes
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user