From b1e160f002ef400735ac5cd85d7543ddfb008e3e Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Wed, 31 Dec 2025 18:34:08 +0200 Subject: [PATCH] feat: improved toasts infra and better display of manual run toast --- src/assets/image/icons/FileArrowRight.svg | 1 + src/assets/image/icons/index.ts | 2 + src/components/molecules/toast.tsx | 256 +++++++++++------- .../project/manualRun/manualRunButtons.tsx | 14 +- .../manualRun/manualRunSettingsDrawer.tsx | 14 +- .../manualRunSuccessToastMessage.tsx | 40 +-- src/interfaces/components/toast.interface.ts | 8 +- src/locales/en/deployments/translation.json | 2 +- src/store/useToastStore.ts | 11 +- src/types/components/index.ts | 2 +- src/types/components/toasterTypes.type.ts | 2 + 11 files changed, 202 insertions(+), 150 deletions(-) create mode 100644 src/assets/image/icons/FileArrowRight.svg diff --git a/src/assets/image/icons/FileArrowRight.svg b/src/assets/image/icons/FileArrowRight.svg new file mode 100644 index 0000000000..15606efcfa --- /dev/null +++ b/src/assets/image/icons/FileArrowRight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/image/icons/index.ts b/src/assets/image/icons/index.ts index 7a178b26b0..065b053c66 100644 --- a/src/assets/image/icons/index.ts +++ b/src/assets/image/icons/index.ts @@ -96,6 +96,8 @@ export { default as CheckCircleIcon } from "@assets/image/icons/CheckCircleIcon. export { default as CheckMarkIcon } from "@assets/image/icons/CheckMark.svg?react"; // Taken from: https://tabler.io/icons/icon/exclamation-circle export { default as ErrorIcon } from "@assets/image/icons/Error.svg?react"; +// Taken from: https://tabler.io/icons/icon/file-arrow-right +export { default as FileArrowRightIcon } from "@assets/image/icons/FileArrowRight.svg?react"; // Taken from: https://www.svgrepo.com/svg/500390/invoice export { default as InvoiceBillIcon } from "@assets/image/icons/InvoiceBill.svg?react"; //https://www.svgrepo.com/svg/472726/messages diff --git a/src/components/molecules/toast.tsx b/src/components/molecules/toast.tsx index e3c03fee3f..c8de9dc82d 100644 --- a/src/components/molecules/toast.tsx +++ b/src/components/molecules/toast.tsx @@ -1,4 +1,4 @@ -import React, { useLayoutEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslation } from "react-i18next"; @@ -18,80 +18,95 @@ export const Toast = () => { const { removeToast, toasts } = useToastStore(); const { setSystemLogHeight, systemLogHeight } = useLoggerStore(); const { t } = useTranslation("toasts"); - const toastRefs = useRef<(HTMLDivElement | null)[]>([]); - const timerRefs = useRef<{ [id: string]: NodeJS.Timeout }>({}); + const [positions, setPositions] = useState<{ [key: string]: { bottom?: number; top?: number } }>({}); const [hoveredToasts, setHoveredToasts] = useState<{ [id: string]: boolean }>({}); - const updateToastPositions = () => { - let currentBottom = 26; - toastRefs.current.forEach((ref) => { - if (ref) { - ref.style.bottom = `${currentBottom}px`; - currentBottom += ref.offsetHeight + 8; - } - }); - }; + const timerRefs = useRef<{ [key: string]: NodeJS.Timeout }>({}); + const toastRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); - const startTimer = (id: string) => { + const startTimer = useCallback((id: string) => { if (timerRefs.current[id]) { clearTimeout(timerRefs.current[id]); } timerRefs.current[id] = setTimeout(() => removeToast(id), closeToastDuration); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const pauseTimer = (id: string) => { + const handleMouseEnter = useCallback((id: string) => { + setHoveredToasts((prev) => ({ ...prev, [id]: true })); if (timerRefs.current[id]) { clearTimeout(timerRefs.current[id]); } - }; + }, []); - const handleMouseEnter = (id: string) => { - setHoveredToasts((prev) => ({ ...prev, [id]: true })); - pauseTimer(id); - }; - - const handleMouseLeave = (id: string) => { + const handleMouseLeave = useCallback((id: string) => { setHoveredToasts((prev) => ({ ...prev, [id]: false })); startTimer(id); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useLayoutEffect(() => { + const topToasts = toasts.filter((t) => t.position === "top-right"); + const bottomToasts = toasts.filter((t) => t.position === "default" || !t.position); + + const newPositions: { [key: string]: { bottom?: number; top?: number } } = {}; + + let topOffset = topToasts[0]?.offset || 15; + topToasts.forEach((toast) => { + newPositions[toast.id] = { top: topOffset }; + const height = toastRefs.current[toast.id]?.offsetHeight || 80; + topOffset += height + 10; + }); + + let bottomOffset = bottomToasts[0]?.offset || 26; + bottomToasts.forEach((toast) => { + newPositions[toast.id] = { bottom: bottomOffset }; + const height = toastRefs.current[toast.id]?.offsetHeight || 95; + bottomOffset += height + 10; + }); + + setPositions(newPositions); + }, [toasts]); + + useEffect(() => { toasts.forEach((toast) => { if (!hoveredToasts[toast.id]) { startTimer(toast.id); } }); - updateToastPositions(); - - const currentTimerRefs = timerRefs.current; - return () => { - Object.values(currentTimerRefs).forEach(clearTimeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + const timers = { ...timerRefs.current }; + Object.values(timers).forEach(clearTimeout); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toasts]); - - const baseStyle = (toastType: ToasterTypes, isHovered: boolean) => - cn("fixed right-20 z-toast max-w-420 rounded-4xl border px-4 py-3 pl-6 transition-colors duration-200", { - "bg-black": !isHovered, - "bg-gray-1250": isHovered, - "border-error": toastType === "error", - "border-green-800": toastType === "success", - "border-yellow-500": toastType === "warning", - }); - - const titleStyle = (toastType: ToasterTypes) => - cn("w-full font-semibold", { - "text-error": toastType === "error", - "text-green-800": toastType === "success", - "text-yellow-500": toastType === "warning", - }); - - const variants = { - hidden: { opacity: 0, y: 50 }, - visible: { opacity: 1, y: 0 }, - }; + }, [toasts, hoveredToasts, startTimer]); + + const baseStyle = useCallback( + (type: ToasterTypes, isHovered: boolean, className = "") => + cn( + "fixed right-5 z-toast max-w-420 rounded-4xl border px-4 py-3 pl-6", + { + "bg-black": !isHovered, + "bg-gray-1250": isHovered, + "border-error": type === "error", + "border-green-800": type === "success", + "border-yellow-500": type === "warning", + }, + className + ), + [] + ); + + const titleStyle = useCallback( + (type: ToasterTypes) => + cn("w-full font-semibold", { + "text-error": type === "error", + "text-green-800": type === "success", + "text-yellow-500": type === "warning", + }), + [] + ); const showMoreButtonStyle = (toastType: ToasterTypes) => cn("cursor-pointer gap-1.5 p-0 font-medium text-error underline", { @@ -103,62 +118,93 @@ export const Toast = () => { "fill-yellow-500": toastType === "warning", }); - const renderToasts = () => - toasts.map(({ id, message, type, hideSystemLogLinkOnError }, index) => { - const title = t(`titles.${type}`); - const ariaLabel = typeof message === "string" ? message : undefined; - const closeButtonToastTestId = - typeof message === "string" ? getTestIdFromText("toast-close-btn", message) : undefined; - const toastTestId = typeof message === "string" ? getTestIdFromText("toast", message) : undefined; - - return ( - - handleMouseEnter(id)} - onMouseLeave={() => handleMouseLeave(id)} - ref={(el) => (toastRefs.current[index] = el)} - transition={{ duration: 0.3 }} - variants={variants} - > -
+ {toasts.map( + ({ + type, + id, + className, + customTitle, + closeOnClick, + message, + hideSystemLogLinkOnError, + hiddenCloseButton, + }) => { + const title = t(`titles.${type}`); + const ariaLabel = typeof message === "string" ? message : undefined; + const closeButtonToastTestId = + typeof message === "string" ? getTestIdFromText("toast-close-btn", message) : undefined; + const toastTestId = typeof message === "string" ? getTestIdFromText("toast", message) : undefined; + + return ( + handleMouseEnter(id)} + onMouseLeave={() => handleMouseLeave(id)} + ref={(el) => (toastRefs.current[id] = el)} + style={{ + ...positions[id], + transition: "all 0.2s ease-out", + }} + transition={{ duration: 0.2 }} > -
-

{title}

- {message} - {(type === "error" || type === "warning") && !hideSystemLogLinkOnError ? ( - + + {(type === "error" || type === "warning") && !hideSystemLogLinkOnError ? ( + + ) : null} +
+ + {!hiddenCloseButton ? ( + removeToast(id)} + title={`Close "${title} ${message}" toast`} > - {t("showMore")} - - + + ) : null}
- removeToast(id)} - title={`Close "${title} ${message}" toast`} - > - - - -
-
- ); - }); - - return toasts.length ? renderToasts() : null; + + ); + } + )} + + ); }; diff --git a/src/components/organisms/topbar/project/manualRun/manualRunButtons.tsx b/src/components/organisms/topbar/project/manualRun/manualRunButtons.tsx index 7c1d417472..2c6788e428 100644 --- a/src/components/organisms/topbar/project/manualRun/manualRunButtons.tsx +++ b/src/components/organisms/topbar/project/manualRun/manualRunButtons.tsx @@ -119,14 +119,14 @@ export const ManualRunButtons = () => { } addToast({ - message: ( - - ), + message: , type: "success", + position: "top-right", + offset: 50, + hiddenCloseButton: true, + className: "rounded-2xl p-0 border-2", + customTitle: " ", + closeOnClick: true, }); setTimeout(() => { diff --git a/src/components/organisms/topbar/project/manualRun/manualRunSettingsDrawer.tsx b/src/components/organisms/topbar/project/manualRun/manualRunSettingsDrawer.tsx index 3c0b2fc885..48a352bae1 100644 --- a/src/components/organisms/topbar/project/manualRun/manualRunSettingsDrawer.tsx +++ b/src/components/organisms/topbar/project/manualRun/manualRunSettingsDrawer.tsx @@ -95,14 +95,14 @@ export const ManualRunSettingsDrawer = () => { } addToast({ - message: ( - - ), + message: , type: "success", + position: "top-right", + offset: 50, + hiddenCloseButton: true, + className: "rounded-2xl p-0 border-2", + customTitle: " ", + closeOnClick: true, }); if (projectId) { closeDrawer(projectId, DrawerName.projectManualRunSettings); diff --git a/src/components/organisms/topbar/project/manualRun/manualRunSuccessToastMessage.tsx b/src/components/organisms/topbar/project/manualRun/manualRunSuccessToastMessage.tsx index af9b8d3933..b943efe4e1 100644 --- a/src/components/organisms/topbar/project/manualRun/manualRunSuccessToastMessage.tsx +++ b/src/components/organisms/topbar/project/manualRun/manualRunSuccessToastMessage.tsx @@ -4,36 +4,22 @@ import { useTranslation } from "react-i18next"; import { useNavigateWithSettings } from "@utilities"; -import { Button } from "@components/atoms"; - -import { ExternalLinkIcon } from "@assets/image/icons"; - -export const ManualRunSuccessToastMessage = ({ - deploymentId, - projectId, - sessionId, -}: { - deploymentId?: string; - projectId?: string; - sessionId?: string; -}) => { - const { t } = useTranslation("deployments", { - keyPrefix: "history.manualRun", - }); +import { FileArrowRightIcon } from "@assets/image/icons"; +export const ManualRunSuccessToastMessage = ({ projectId, sessionId }: { projectId?: string; sessionId?: string }) => { + const { t } = useTranslation("deployments", { keyPrefix: "history.manualRun" }); const navigateWithSettings = useNavigateWithSettings(); - const goToSession = () => - deploymentId - ? navigateWithSettings(`/projects/${projectId}/deployments/${deploymentId}/sessions/${sessionId}`) - : navigateWithSettings(`/projects/${projectId}/sessions/${sessionId}`); return ( - <> - {t("executionSucceed")} - - + ); }; diff --git a/src/interfaces/components/toast.interface.ts b/src/interfaces/components/toast.interface.ts index 032f8b9468..d5a5bb81fb 100644 --- a/src/interfaces/components/toast.interface.ts +++ b/src/interfaces/components/toast.interface.ts @@ -1,10 +1,16 @@ -import { ToasterTypes } from "@src/types/components"; +import { ToasterTypes, ToastPosition } from "@src/types/components"; export interface Toast { id: string; message: React.ReactNode; type: ToasterTypes; hideSystemLogLinkOnError?: boolean; + position?: ToastPosition; + offset?: number; + className?: string; + hiddenCloseButton?: boolean; + customTitle?: React.ReactNode | string; + closeOnClick?: boolean; } export interface ToastStore { diff --git a/src/locales/en/deployments/translation.json b/src/locales/en/deployments/translation.json index 12dfbc4d50..1576043d37 100644 --- a/src/locales/en/deployments/translation.json +++ b/src/locales/en/deployments/translation.json @@ -59,7 +59,7 @@ "executionFailed": "Session execution failed", "executionFailedExtended": "Session execution failed, projectID - {{projectId}}, error - {{error}}", "executionSucceed": "Session execution succeed", - "showMore": "Show more", + "viewSessionOutput": "View session output", "missingActiveDeployment": "Missing last deployment", "missingnEntrypoint": "Missing entrypoint file or function", "useJsonEditor": "Use JSON editor" diff --git a/src/store/useToastStore.ts b/src/store/useToastStore.ts index 61fc0daf10..65e7235de3 100644 --- a/src/store/useToastStore.ts +++ b/src/store/useToastStore.ts @@ -9,7 +9,16 @@ export const useToastStore = create( const id = Date.now().toString(); return set((state) => ({ - toasts: [...state.toasts, { ...toast, id }], + toasts: [ + ...state.toasts, + { + ...toast, + id, + position: toast.position, + offset: toast.offset, + className: toast.className, + }, + ], })); }, removeToast: (id) => diff --git a/src/types/components/index.ts b/src/types/components/index.ts index 796611c623..8e6c9c3e3b 100644 --- a/src/types/components/index.ts +++ b/src/types/components/index.ts @@ -10,7 +10,7 @@ export type { SortableColumns } from "@type/components/tables"; export type { SessionStatsFilterType } from "@type/components/sessionStatsFilter.type"; export type { ApexChartItemType } from "@src/types/components/executionFlowActivitiesChart.type"; export type { GoogleIntegrationType } from "./googleIntegration.type"; -export type { ToasterTypes } from "@src/types/components/toasterTypes.type"; +export type { ToasterTypes, ToastPosition } from "@src/types/components/toasterTypes.type"; export type { LoaderColorType } from "./loader.type"; export type { LogType } from "@src/types/components/log.type"; export type { ProjectActionType, MetadataResult } from "@src/types/components/projectActions.type"; diff --git a/src/types/components/toasterTypes.type.ts b/src/types/components/toasterTypes.type.ts index cb74353fe0..b616ca801f 100644 --- a/src/types/components/toasterTypes.type.ts +++ b/src/types/components/toasterTypes.type.ts @@ -1 +1,3 @@ export type ToasterTypes = "error" | "info" | "success" | "warning"; + +export type ToastPosition = "top-right" | "default";