Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/toast-alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": minor
---

add toast notification system
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright 2021-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as React from "react";
import { CircleAlert, CircleCheck, Info, TriangleAlert, XIcon } from "lucide-react";
import { Toast as ToastPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { useToast } from "@/hooks/useToast";

type ToastProps = {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
variant?: "default" | "error" | "success" | "warning" | "info";
open: boolean;
};

const TOAST_VARIANT_STYLES_BASE = {
error: {
root: "dec:border-gray-200 dec:bg-white dec:text-gray-900 dec:dark:border-gray-700 dec:dark:bg-[#2d3748] dec:dark:text-gray-100",
close:
"dec:text-gray-600 hover:dec:text-gray-900 dec:dark:text-gray-400 dec:dark:hover:text-gray-100",
icon: CircleAlert,
iconColor: "dec:text-red-500",
borderColor: "dec:border-l-red-500 dec:dark:border-l-red-500",
},
success: {
root: "dec:border-gray-200 dec:bg-white dec:text-gray-900 dec:dark:border-gray-700 dec:dark:bg-[#2d3748] dec:dark:text-gray-100",
close:
"dec:text-gray-600 hover:dec:text-gray-900 dec:dark:text-gray-400 dec:dark:hover:text-gray-100",
icon: CircleCheck,
iconColor: "dec:text-green-500",
borderColor: "dec:border-l-green-500 dec:dark:border-l-green-500",
},
warning: {
root: "dec:border-gray-200 dec:bg-white dec:text-gray-900 dec:dark:border-gray-700 dec:dark:bg-[#2d3748] dec:dark:text-gray-100",
close:
"dec:text-gray-600 hover:dec:text-gray-900 dec:dark:text-gray-400 dec:dark:hover:text-gray-100",
icon: TriangleAlert,
iconColor: "dec:text-orange-500",
borderColor: "dec:border-l-orange-500 dec:dark:border-l-orange-500",
},
info: {
root: "dec:border-gray-200 dec:bg-white dec:text-gray-900 dec:dark:border-gray-700 dec:dark:bg-[#2d3748] dec:dark:text-gray-100",
close:
"dec:text-gray-600 hover:dec:text-gray-900 dec:dark:text-gray-400 dec:dark:hover:text-gray-100",
icon: Info,
iconColor: "dec:text-blue-500",
borderColor: "dec:border-l-blue-500 dec:dark:border-l-blue-500",
},
} as const;

const TOAST_VARIANT_STYLES = {
...TOAST_VARIANT_STYLES_BASE,
default: TOAST_VARIANT_STYLES_BASE.info,
} as const;

const ToastItem = React.memo(
({ toast, onDismiss }: { toast: ToastProps; onDismiss: (id: string) => void }) => {
const { id, open, variant = "default", title, description } = toast;
const styles = TOAST_VARIANT_STYLES[variant];
const Icon = styles.icon;

return (
<ToastPrimitive.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onDismiss(id);
}}
data-slot="toast"
className={cn(
"dec:pointer-events-auto dec:relative dec:flex dec:w-full dec:min-w-[320px] dec:max-w-[680px] dec:items-start dec:gap-4 dec:rounded-[14px] dec:border dec:border-l-4 dec:px-7 dec:py-6 dec:shadow-[0_18px_38px_rgba(22,23,24,0.12),0_8px_16px_rgba(22,23,24,0.08)] dec:transition-all dec:data-[state=open]:animate-in dec:data-[state=closed]:animate-out dec:data-[state=closed]:fade-out-80 dec:data-[state=closed]:slide-out-to-left-full dec:data-[state=open]:slide-in-from-top-full",
styles.root,
styles.borderColor,
)}
>
{Icon && (
<div className="dec:flex-shrink-0 dec:pt-0.5">
<Icon className={cn("dec:size-[20px]", styles.iconColor)} />
</div>
)}
<div className="dec:grid dec:flex-1 dec:gap-2 dec:pr-2">
{title && (
<ToastPrimitive.Title
data-slot="toast-title"
className="dec:text-[18px] dec:font-semibold dec:leading-[1.2] dec:tracking-[-0.01em]"
>
{title}
</ToastPrimitive.Title>
)}
{description && (
<ToastPrimitive.Description
data-slot="toast-description"
className="dec:text-[15px] dec:leading-[1.35] dec:text-[#6f6f78] dec:dark:text-gray-300"
>
{description}
</ToastPrimitive.Description>
)}
</div>
<ToastPrimitive.Close
data-slot="toast-close"
onClick={() => onDismiss(id)}
className={cn(
"dec:-mt-0.5 dec:rounded-md dec:p-1 dec:opacity-100 dec:transition-colors",
styles.close,
)}
>
<XIcon className="dec:size-[18px]" />
</ToastPrimitive.Close>
</ToastPrimitive.Root>
);
},
);

ToastItem.displayName = "ToastItem";

function Toaster() {
const { toasts, dismiss } = useToast();

return (
<>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
))}
<ToastPrimitive.Viewport className="dec:fixed dec:top-0 dec:left-0 dec:z-[100] dec:flex dec:max-w-[100vw] dec:flex-col dec:gap-3 dec:p-6 dec:pointer-events-none" />
</>
);
}

export { Toaster };
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { useResolvedColorMode } from "../hooks/useResolvedColorMode";
import { SidebarProvider } from "@/components/ui/sidebar";
import { SidePanel } from "@/side-panel/SidePanel";
import { DiagramEditorErrorBoundary } from "./error-pages/DiagramEditorErrorBoundary";
import { Toaster } from "@/components/ui/toast";
import { Toast as ToastPrimitive } from "radix-ui";

/**
* DiagramEditor component API
Expand Down Expand Up @@ -130,6 +132,9 @@ export const DiagramEditor = (props: DiagramEditorProps) => {
}}
</DiagramEditorInner>
</I18nProvider>
<ToastPrimitive.Provider>
<Toaster />
</ToastPrimitive.Provider>
</div>
);
};
73 changes: 73 additions & 0 deletions packages/serverless-workflow-diagram-editor/src/hooks/useToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2021-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as React from "react";

type ToastProps = {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
variant?: "default" | "error" | "success" | "warning" | "info";
open: boolean;
};

type Action =
| { type: "ADD"; toast: Omit<ToastProps, "open"> }
| { type: "DISMISS"; id: string }
| { type: "REMOVE"; id: string };

let toasts: ToastProps[] = [];
let toastCount = 0;
const listeners: Array<(toasts: ToastProps[]) => void> = [];

function dispatch(action: Action) {
if (action.type === "ADD") {
toasts = [...toasts, { ...action.toast, open: true }];
setTimeout(() => dispatch({ type: "DISMISS", id: action.toast.id }), 5000);
} else if (action.type === "DISMISS") {
toasts = toasts.map((t) => (t.id === action.id ? { ...t, open: false } : t));
setTimeout(() => dispatch({ type: "REMOVE", id: action.id }), 300);
} else if (action.type === "REMOVE") {
toasts = toasts.filter((t) => t.id !== action.id);
}
listeners.forEach((listener) => listener(toasts));
}

function toast(props: Omit<ToastProps, "id" | "open">) {
const id = String(toastCount++);
dispatch({ type: "ADD", toast: { ...props, id } });
return id;
}

function useToast() {
const [state, setState] = React.useState<ToastProps[]>(toasts);

React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) listeners.splice(index, 1);
};
}, []);

return {
toasts: state,
show: toast,
dismiss: (id: string) => dispatch({ type: "DISMISS", id }),
};
}

export { useToast, toast };
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const en = {
"aria.panel.workflowInfo": "Workflow information panel",
"aria.panel.content": "Panel content",
"aria.panel.exportActions": "Export actions",
"toast.clipboard.error": "Failed to copy",
"toast.download.success": "Download started",
"toast.download.error": "Download failed",
} as const;

export type TranslationKeys = keyof typeof en;
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { exportToMermaid } from "@/core";
import { copyToClipboard } from "@/lib/clipboard";
import { downloadFile } from "@/lib/download";
import type { Specification } from "@serverlessworkflow/sdk";
import { toast } from "@/hooks/useToast";

export function MermaidActions({ model }: { model: Specification.Workflow }): React.JSX.Element {
const { t } = useI18n();
Expand Down Expand Up @@ -51,8 +52,11 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re
copyTimeoutRef.current = null;
}, 2000);
} catch (error) {
console.error("Failed to copy mermaid code:", error);
// TODO: Create component to show errors to users
toast({
variant: "error",
title: t("toast.clipboard.error"),
description: error instanceof Error ? error.message : undefined,
});
}
};

Expand All @@ -66,9 +70,13 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re
.substring(0, 200);
const filename = `${sanitizedName}.mmd`;
downloadFile(mermaidCode, filename);
toast({ variant: "success", title: t("toast.download.success") });
} catch (error) {
console.error("Failed to download mermaid file:", error);
// TODO: Create component to show errors to users
toast({
variant: "error",
title: t("toast.download.error"),
description: error instanceof Error ? error.message : undefined,
});
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,99 @@ import { WORKFLOW_WITH_METADATA_JSON } from "../fixtures/workflows";
import * as clipboard from "../../src/lib/clipboard";
import * as core from "../../src/core";
import * as download from "../../src/lib/download";
import * as toastHook from "../../src/hooks/useToast";

describe("MermaidActions", () => {
const toastMock = vi.fn();
const MERMAID_CODE = "mermaid code";

afterEach(() => {
toastMock.mockClear();
vi.restoreAllMocks();
});

it("should call copyMermaidToClipboard when copy button is clicked", async () => {
const user = userEvent.setup();
const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON);
const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined);
vi.spyOn(core, "exportToMermaid").mockReturnValue("mermaid code");
vi.spyOn(core, "exportToMermaid").mockReturnValue(MERMAID_CODE);

renderWithProviders(<MermaidActions model={model!} />, { model });
const copyButton = screen.getByText(/Copy Mermaid Code/i);

const copyButton = screen.getByRole("button", {
name: /Copy Mermaid Code/i,
});

await user.click(copyButton);

expect(copySpy).toHaveBeenCalledWith(MERMAID_CODE);
});

it("should show error toast when clipboard copy fails", async () => {
const user = userEvent.setup();
const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON);
vi.spyOn(clipboard, "copyToClipboard").mockRejectedValue(new Error("Clipboard error"));
vi.spyOn(core, "exportToMermaid").mockReturnValue(MERMAID_CODE);
vi.spyOn(toastHook, "toast").mockImplementation(toastMock);

renderWithProviders(<MermaidActions model={model!} />, { model });

const copyButton = screen.getByRole("button", {
name: /Copy Mermaid Code/i,
});

await user.click(copyButton);

expect(copySpy).toHaveBeenCalledWith("mermaid code");
expect(toastMock).toHaveBeenCalledWith({
variant: "error",
title: expect.any(String),
description: "Clipboard error",
});
});

it("should call downloadMermaidFile when download button is clicked", async () => {
it("should call downloadMermaidFile and show success toast when download button is clicked", async () => {
const user = userEvent.setup();
const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON);
const downloadSpy = vi.spyOn(download, "downloadFile").mockImplementation(() => {});
vi.spyOn(core, "exportToMermaid").mockReturnValue("mermaid code");
vi.spyOn(core, "exportToMermaid").mockReturnValue(MERMAID_CODE);
vi.spyOn(toastHook, "toast").mockImplementation(toastMock);

renderWithProviders(<MermaidActions model={model!} />, { model });
const downloadButton = screen.getByText(/Download as Mermaid File/i);

const downloadButton = screen.getByRole("button", {
name: /Download as Mermaid File/i,
});

await user.click(downloadButton);

expect(downloadSpy).toHaveBeenCalledWith(MERMAID_CODE, "test-wf.mmd");
expect(toastMock).toHaveBeenCalledWith({
variant: "success",
title: expect.any(String),
});
});

it("should show error toast when download fails", async () => {
const user = userEvent.setup();
const { model } = parseWorkflow(WORKFLOW_WITH_METADATA_JSON);
vi.spyOn(download, "downloadFile").mockImplementation(() => {
throw new Error("Download error");
});
vi.spyOn(core, "exportToMermaid").mockReturnValue(MERMAID_CODE);
vi.spyOn(toastHook, "toast").mockImplementation(toastMock);

renderWithProviders(<MermaidActions model={model!} />, { model });

const downloadButton = screen.getByRole("button", {
name: /Download as Mermaid File/i,
});

await user.click(downloadButton);

expect(downloadSpy).toHaveBeenCalledWith("mermaid code", "test-wf.mmd");
expect(toastMock).toHaveBeenCalledWith({
variant: "error",
title: expect.any(String),
description: "Download error",
});
});
});
Loading