diff --git a/.changeset/toast-alerts.md b/.changeset/toast-alerts.md new file mode 100644 index 00000000..9a15f828 --- /dev/null +++ b/.changeset/toast-alerts.md @@ -0,0 +1,5 @@ +--- +"@serverlessworkflow/diagram-editor": minor +--- + +add toast notification system diff --git a/packages/serverless-workflow-diagram-editor/src/components/ui/toast.tsx b/packages/serverless-workflow-diagram-editor/src/components/ui/toast.tsx new file mode 100644 index 00000000..da449223 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/components/ui/toast.tsx @@ -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 ( + { + 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 && ( +
+ +
+ )} +
+ {title && ( + + {title} + + )} + {description && ( + + {description} + + )} +
+ onDismiss(id)} + className={cn( + "dec:-mt-0.5 dec:rounded-md dec:p-1 dec:opacity-100 dec:transition-colors", + styles.close, + )} + > + + +
+ ); + }, +); + +ToastItem.displayName = "ToastItem"; + +function Toaster() { + const { toasts, dismiss } = useToast(); + + return ( + <> + {toasts.map((toast) => ( + + ))} + + + ); +} + +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 4521b121..eb8c543f 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,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 @@ -130,6 +132,9 @@ export const DiagramEditor = (props: DiagramEditorProps) => { }} + + + ); }; diff --git a/packages/serverless-workflow-diagram-editor/src/hooks/useToast.ts b/packages/serverless-workflow-diagram-editor/src/hooks/useToast.ts new file mode 100644 index 00000000..f5051ce0 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/hooks/useToast.ts @@ -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 } + | { 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) { + const id = String(toastCount++); + dispatch({ type: "ADD", toast: { ...props, id } }); + return id; +} + +function useToast() { + const [state, setState] = React.useState(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 }; 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 bdd2d201..401cd0ab 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 691fb7f8..634dd06f 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 "@/hooks/useToast"; export function MermaidActions({ model }: { model: Specification.Workflow }): React.JSX.Element { const { t } = useI18n(); @@ -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, + }); } }; @@ -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, + }); } }; 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 fbd5b0cc..4e806e0c 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 toastHook from "../../src/hooks/useToast"; describe("MermaidActions", () => { + const toastMock = vi.fn(); + const MERMAID_CODE = "mermaid code"; + afterEach(() => { + toastMock.mockClear(); vi.restoreAllMocks(); }); @@ -34,25 +39,84 @@ 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(toastHook, "toast").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({ + 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(, { 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(, { 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", + }); }); });