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