From e1a5291123b4de15afca133541cb5cad343c7cbe Mon Sep 17 00:00:00 2001 From: Jakob Schott <154420406+jakobsitory@users.noreply.github.com> Date: Thu, 27 Mar 2025 13:46:56 +0100 Subject: [PATCH] fix: unify alert component (#5002) Co-authored-by: Dhruwang --- .../ui/components/alert/index.test.tsx | 74 +++++ .../web/modules/ui/components/alert/index.tsx | 166 ++++++++--- .../ui/components/alert/stories.test.tsx | 62 ++++ .../modules/ui/components/alert/stories.tsx | 273 +++++++++++++++--- apps/web/tailwind.config.js | 32 +- apps/web/vite.config.mts | 1 + 6 files changed, 535 insertions(+), 73 deletions(-) create mode 100644 apps/web/modules/ui/components/alert/index.test.tsx create mode 100644 apps/web/modules/ui/components/alert/stories.test.tsx diff --git a/apps/web/modules/ui/components/alert/index.test.tsx b/apps/web/modules/ui/components/alert/index.test.tsx new file mode 100644 index 0000000000..08c218f24b --- /dev/null +++ b/apps/web/modules/ui/components/alert/index.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Alert, AlertButton, AlertDescription, AlertTitle } from "./index"; + +describe("Alert", () => { + it("renders with default variant", () => { + render( + + Test Title + Test Description + + ); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + }); + + it("renders with different variants", () => { + const variants = ["default", "error", "warning", "info", "success"] as const; + + variants.forEach((variant) => { + const { container } = render( + + Test Title + + ); + + expect(container.firstChild).toHaveClass( + variant === "default" ? "text-foreground" : `text-${variant}-foreground` + ); + }); + }); + + it("renders with different sizes", () => { + const sizes = ["default", "small"] as const; + + sizes.forEach((size) => { + const { container } = render( + + Test Title + + ); + + expect(container.firstChild).toHaveClass(size === "default" ? "py-3" : "py-2"); + }); + }); + + it("renders with button and handles click", () => { + const handleClick = vi.fn(); + + render( + + Test Title + Click me + + ); + + const button = screen.getByText("Click me"); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("applies custom className", () => { + const { container } = render( + + Test Title + + ); + + expect(container.firstChild).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/modules/ui/components/alert/index.tsx b/apps/web/modules/ui/components/alert/index.tsx index d9e3c89c6e..c17f7f4b4e 100644 --- a/apps/web/modules/ui/components/alert/index.tsx +++ b/apps/web/modules/ui/components/alert/index.tsx @@ -1,49 +1,141 @@ -import { VariantProps, cva } from "class-variance-authority"; -import * as React from "react"; -import { cn } from "@formbricks/lib/cn"; +"use client"; -const alertVariants = cva( - "relative w-full rounded-xl border p-3 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-3 [&>svg]:top-3 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-9", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive", - info: "text-slate-800 bg-brand/5", - warning: "text-yellow-700 bg-yellow-50", - error: "border-error/50 dark:border-error [&>svg]:text-error text-error", - }, +import { VariantProps, cva } from "class-variance-authority"; +import { AlertCircle, AlertTriangle, CheckCircle2Icon, Info } from "lucide-react"; +import * as React from "react"; +import { createContext, useContext } from "react"; +import { cn } from "@formbricks/lib/cn"; +import { Button, ButtonProps } from "../button"; + +// Create a context to share variant and size with child components +interface AlertContextValue { + variant?: "default" | "error" | "warning" | "info" | "success" | null; + size?: "default" | "small" | null; +} + +const AlertContext = createContext({ + variant: "default", + size: "default", +}); + +const useAlertContext = () => useContext(AlertContext); + +// Define alert styles with variants +const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 [&>svg]:text-foreground", { + variants: { + variant: { + default: "text-foreground border-border", + error: + "text-error-foreground border-error/50 [&_button]:bg-error-background [&_button]:text-error-foreground [&_button:hover]:bg-error-background-muted", + warning: + "text-warning-foreground border-warning/50 [&_button]:bg-warning-background [&_button]:text-warning-foreground [&_button:hover]:bg-warning-background-muted", + info: "text-info-foreground border-info/50 [&_button]:bg-info-background [&_button]:text-info-foreground [&_button:hover]:bg-info-background-muted", + success: + "text-success-foreground border-success/50 [&_button]:bg-success-background [&_button]:text-success-foreground [&_button:hover]:bg-success-background-muted", }, - defaultVariants: { - variant: "default", + size: { + default: + "py-3 px-4 text-sm grid grid-cols-[1fr_auto] grid-rows-[auto_auto] gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7", + small: + "px-3 py-2 text-xs flex items-center justify-between gap-2 [&>svg]:flex-shrink-0 [&_button]:text-xs [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0", }, - } -); + }, + defaultVariants: { + variant: "default", + size: "default", + }, +}); + +const alertVariantIcons: Record<"default" | "error" | "warning" | "info" | "success", React.ReactNode> = { + default: null, + error: , + warning: , + info: , + success: , +}; const Alert = React.forwardRef< HTMLDivElement, - React.HTMLAttributes & - VariantProps & { dangerouslySetInnerHTML?: { __html: string } } ->(({ className, variant, ...props }, ref) => ( -
-)); + React.HTMLAttributes & VariantProps +>(({ className, variant, size, ...props }, ref) => { + const variantIcon = variant ? (variant !== "default" ? alertVariantIcons[variant] : null) : null; + + return ( + +
+ {variantIcon} + {props.children} +
+
+ ); +}); Alert.displayName = "Alert"; -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes & { dangerouslySetInnerHTML?: { __html: string } } ->(({ className, ...props }, ref) => ( -
-)); +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => { + const { size } = useAlertContext(); + return ( +
+ ); + } +); + AlertTitle.displayName = "AlertTitle"; -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes & { dangerouslySetInnerHTML?: { __html: string } } ->(({ className, ...props }, ref) => ( -
-)); +const AlertDescription = React.forwardRef>( + ({ className, ...props }, ref) => { + const { size } = useAlertContext(); + + return ( +