diff --git a/.changeset/toast-alert.md b/.changeset/toast-alert.md
new file mode 100644
index 0000000..5979db0
--- /dev/null
+++ b/.changeset/toast-alert.md
@@ -0,0 +1,5 @@
+---
+"@serverlessworkflow/diagram-editor": minor
+---
+
+add toast alert system through shadcn Sonner
diff --git a/packages/serverless-workflow-diagram-editor/package.json b/packages/serverless-workflow-diagram-editor/package.json
index 98e3422..8b16b01 100644
--- a/packages/serverless-workflow-diagram-editor/package.json
+++ b/packages/serverless-workflow-diagram-editor/package.json
@@ -49,6 +49,7 @@
"elkjs": "catalog:",
"js-yaml": "catalog:",
"radix-ui": "catalog:",
+ "sonner": "catalog:",
"use-sync-external-store": "catalog:"
},
"devDependencies": {
diff --git a/packages/serverless-workflow-diagram-editor/src/components/ui/sonner.css b/packages/serverless-workflow-diagram-editor/src/components/ui/sonner.css
new file mode 100644
index 0000000..bb1bca6
--- /dev/null
+++ b/packages/serverless-workflow-diagram-editor/src/components/ui/sonner.css
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+
+@layer sonner {
+ .dec-root [data-sonner-toaster] { --width: 300px !important; }
+ .dec-root [data-sonner-toast] { background: #ffffff !important; border-radius: 10px !important; border: 1px solid #e9edf4 !important; }
+ .dec-root [data-sonner-toast][data-type="success"] { border-left: 4px solid #22c55e !important; }
+ .dec-root [data-sonner-toast][data-type="error"] { border-left: 4px solid #ef4444 !important; }
+ .dec-root [data-sonner-toast][data-type="warning"] { border-left: 4px solid #f97316 !important; }
+ .dec-root [data-sonner-toast][data-type="info"] { border-left: 4px solid #3b82f6 !important; }
+ .dec-root.dark [data-sonner-toast] { background: #2d3748 !important; color: #ffffff !important; border-top-color: #4a5568 !important; border-right-color: #4a5568 !important; border-bottom-color: #4a5568 !important; }
+ .dec-root.dark [data-sonner-toast] [data-title], .dec-root.dark [data-sonner-toast] [data-description] { color: #ffffff !important; }
+ .dec-root [data-sonner-toast] [data-close-button] {
+ position: absolute !important;
+ top: 50% !important;
+ right: 12px !important;
+ left: auto !important;
+ transform: translateY(-50%) !important;
+ background: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+ color: #6b7280;
+ cursor: pointer;
+ }
+ .dec-root.dark [data-sonner-toast] [data-close-button] { color: #ffffff; }
+}
diff --git a/packages/serverless-workflow-diagram-editor/src/components/ui/sonner.tsx b/packages/serverless-workflow-diagram-editor/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..de31add
--- /dev/null
+++ b/packages/serverless-workflow-diagram-editor/src/components/ui/sonner.tsx
@@ -0,0 +1,55 @@
+/*
+ * 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 {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from "lucide-react";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+import "./sonner.css";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ close: ,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx b/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx
index 4521b12..8271c80 100644
--- a/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx
+++ b/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx
@@ -27,6 +27,7 @@ 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/sonner";
/**
* DiagramEditor component API
@@ -130,6 +131,7 @@ export const DiagramEditor = (props: DiagramEditorProps) => {
}}
+
);
};
diff --git a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts
index bdd2d20..401cd0a 100644
--- a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts
+++ b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts
@@ -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;
diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx
index 691fb7f..a7dc155 100644
--- a/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx
+++ b/packages/serverless-workflow-diagram-editor/src/side-panel/MermaidActions.tsx
@@ -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 "sonner";
export function MermaidActions({ model }: { model: Specification.Workflow }): React.JSX.Element {
const { t } = useI18n();
@@ -51,8 +52,9 @@ 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.error(t("toast.clipboard.error"), {
+ description: error instanceof Error ? error.message : undefined,
+ });
}
};
@@ -66,9 +68,11 @@ export function MermaidActions({ model }: { model: Specification.Workflow }): Re
.substring(0, 200);
const filename = `${sanitizedName}.mmd`;
downloadFile(mermaidCode, filename);
+ toast.success(t("toast.download.success"));
} catch (error) {
- console.error("Failed to download mermaid file:", error);
- // TODO: Create component to show errors to users
+ toast.error(t("toast.download.error"), {
+ description: error instanceof Error ? error.message : undefined,
+ });
}
};
diff --git a/packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx b/packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx
index fbd5b0c..8d3f797 100644
--- a/packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx
+++ b/packages/serverless-workflow-diagram-editor/tests/side-panel/MermaidActions.test.tsx
@@ -24,9 +24,14 @@ 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 sonner from "sonner";
describe("MermaidActions", () => {
+ const toastMock = vi.fn();
+ const MERMAID_CODE = "mermaid code";
+
afterEach(() => {
+ toastMock.mockClear();
vi.restoreAllMocks();
});
@@ -34,25 +39,76 @@ describe("MermaidActions", () => {
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(, { 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(sonner.toast, "error").mockImplementation(toastMock);
+ vi.spyOn(sonner.toast, "success").mockImplementation(toastMock);
+
+ renderWithProviders(, { model });
+
+ const copyButton = screen.getByRole("button", {
+ name: /Copy Mermaid Code/i,
+ });
+
await user.click(copyButton);
- expect(copySpy).toHaveBeenCalledWith("mermaid code");
+ expect(toastMock).toHaveBeenCalledWith(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(sonner.toast, "error").mockImplementation(toastMock);
+ vi.spyOn(sonner.toast, "success").mockImplementation(toastMock);
renderWithProviders(, { 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(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(sonner.toast, "error").mockImplementation(toastMock);
+ vi.spyOn(sonner.toast, "success").mockImplementation(toastMock);
+
+ renderWithProviders(, { 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(expect.any(String), { description: "Download error" });
});
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3f9083f..e1cd390 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -117,6 +117,9 @@ catalogs:
rimraf:
specifier: ^6.1.3
version: 6.1.3
+ sonner:
+ specifier: ^2.0.7
+ version: 2.0.7
storybook:
specifier: ^10.4.6
version: 10.4.6
@@ -230,6 +233,9 @@ importers:
radix-ui:
specifier: 'catalog:'
version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ sonner:
+ specifier: 'catalog:'
+ version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
use-sync-external-store:
specifier: 'catalog:'
version: 1.6.0(react@19.2.7)
@@ -3562,6 +3568,12 @@ packages:
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
engines: {node: '>=20'}
+ sonner@2.0.7:
+ resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -7117,6 +7129,11 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
+ sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
source-map-js@1.2.1: {}
source-map@0.6.1: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 095aab4..b38b2b2 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -38,6 +38,7 @@ catalog:
react: ^19.2.7
react-dom: ^19.2.7
rimraf: ^6.1.3
+ sonner: ^2.0.7
storybook: ^10.4.6
syncpack: ^15.3.2
tailwindcss: ^4.3.1