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 ( + + ); + } +); AlertDescription.displayName = "AlertDescription"; -export { Alert, AlertDescription, AlertTitle }; +const AlertButton = React.forwardRef( + ({ className, variant, size, children, ...props }, ref) => { + const { size: alertSize } = useAlertContext(); + + // Determine button styling based on alert context + const buttonVariant = variant || (alertSize === "small" ? "link" : "secondary"); + const buttonSize = size || (alertSize === "small" ? "sm" : "default"); + + return ( + + + {children} + + + ); + } +); + +AlertButton.displayName = "AlertButton"; + +// Export the new component +export { Alert, AlertTitle, AlertDescription, AlertButton }; diff --git a/apps/web/modules/ui/components/alert/stories.test.tsx b/apps/web/modules/ui/components/alert/stories.test.tsx new file mode 100644 index 0000000000..af33eef348 --- /dev/null +++ b/apps/web/modules/ui/components/alert/stories.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { Default, Error, Info, Small, Success, Warning, withButtonAndIcon } from "./stories"; + +describe("Alert Stories", () => { + const renderStory = (Story: any) => { + return render(Story.render(Story.args)); + }; + + afterEach(() => { + cleanup(); + }); + + it("renders Default story", () => { + renderStory(Default); + expect(screen.getByText("Alert Title")).toBeInTheDocument(); + expect(screen.getByText("This is an important notification.")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("renders Small story", () => { + renderStory(Small); + expect(screen.getByText("Information Alert")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("Learn more")).toBeInTheDocument(); + }); + + it("renders withButtonAndIcon story", () => { + renderStory(withButtonAndIcon); + expect(screen.getByText("Alert Title")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("Learn more")).toBeInTheDocument(); + }); + + it("renders Error story", () => { + renderStory(Error); + expect(screen.getByText("Error Alert")).toBeInTheDocument(); + expect(screen.getByText("Your session has expired. Please log in again.")).toBeInTheDocument(); + expect(screen.getByText("Log in")).toBeInTheDocument(); + }); + + it("renders Warning story", () => { + renderStory(Warning); + expect(screen.getByText("Warning Alert")).toBeInTheDocument(); + expect(screen.getByText("You are editing sensitive data. Be cautious")).toBeInTheDocument(); + expect(screen.getByText("Proceed")).toBeInTheDocument(); + }); + + it("renders Info story", () => { + renderStory(Info); + expect(screen.getByText("Info Alert")).toBeInTheDocument(); + expect(screen.getByText("There was an update to your application.")).toBeInTheDocument(); + expect(screen.getByText("Refresh")).toBeInTheDocument(); + }); + + it("renders Success story", () => { + renderStory(Success); + expect(screen.getByText("Success Alert")).toBeInTheDocument(); + expect(screen.getByText("This worked! Please proceed.")).toBeInTheDocument(); + expect(screen.getByText("Close")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ui/components/alert/stories.tsx b/apps/web/modules/ui/components/alert/stories.tsx index ff9017829b..4d45ed1edb 100644 --- a/apps/web/modules/ui/components/alert/stories.tsx +++ b/apps/web/modules/ui/components/alert/stories.tsx @@ -1,49 +1,252 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { AlertCircle } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "./index"; +import { Meta, StoryObj } from "@storybook/react"; +import { LightbulbIcon } from "lucide-react"; +import { Alert, AlertButton, AlertDescription, AlertTitle } from "./index"; -const meta = { - title: "ui/Alert", +// We'll define the story options separately from the component props +interface StoryOptions { + title: string; + description: string; + showIcon: boolean; + showButton: boolean; + actionButtonText: string; +} + +type StoryProps = React.ComponentProps & StoryOptions; + +const meta: Meta = { + title: "UI/Alert", component: Alert, tags: ["autodocs"], - argTypes: { - variant: { - control: "radio", - options: ["default", "error"], + parameters: { + controls: { + sort: "requiredFirst", + exclude: [], }, }, - args: { - variant: "default", + // These argTypes are for story controls, not component props + argTypes: { + variant: { + control: "select", + options: ["default", "error", "warning", "info", "success"], + description: "Style variant of the alert", + table: { + category: "Appearance", + type: { summary: "string" }, + defaultValue: { summary: "default" }, + }, + order: 1, + }, + size: { + control: "select", + options: ["default", "small"], + description: "Size of the alert component", + table: { + category: "Appearance", + type: { summary: "string" }, + defaultValue: { summary: "default" }, + }, + order: 2, + }, + showIcon: { + control: "boolean", + description: "Whether to show an icon", + table: { + category: "Appearance", + type: { summary: "boolean" }, + }, + order: 3, + }, + showButton: { + control: "boolean", + description: "Whether to show action buttons", + table: { + category: "Appearance", + type: { summary: "boolean" }, + }, + order: 4, + }, + title: { + control: "text", + description: "Alert title text", + table: { + category: "Content", + type: { summary: "string" }, + }, + order: 1, + }, + description: { + control: "text", + description: "Alert description text", + table: { + category: "Content", + type: { summary: "string" }, + }, + order: 2, + }, + actionButtonText: { + control: "text", + description: "Text for the action button", + table: { + category: "Content", + type: { summary: "string" }, + }, + order: 2, + }, }, - render: (args) => ( - - This is an alert - This is a description - - ), -} satisfies Meta; +}; export default meta; -type Story = StoryObj; +// Our story type just specifies Alert props plus our story options +type Story = StoryObj & { args: StoryOptions }; -export const Default: Story = {}; +// Create a common render function to reduce duplication +const renderAlert = (args: StoryProps) => { + // Extract component props + const { variant = "default", size = "default", className = "" } = args; -export const Error: Story = { - args: { - variant: "error", - }, -}; + // Extract story content options + const { + title = "", + description = "", + showIcon = false, + showButton = false, + actionButtonText = "", + } = args as StoryOptions; -export const WithIcon: Story = { - args: { - variant: "error", - }, - render: (args) => ( - - - This is an alert - This is a description + return ( + + {showIcon && } + {title} + {description && {description}} + {showButton && alert("Button clicked")}>{actionButtonText}} - ), + ); +}; + +// Basic example with direct props +export const Default: Story = { + render: renderAlert, + args: { + variant: "default", + showIcon: false, + showButton: false, + title: "Alert Title", + description: "This is an important notification.", + actionButtonText: "Learn more", + }, +}; + +// Small size example +export const Small: Story = { + render: renderAlert, + args: { + variant: "default", + size: "small", + title: "Information Alert", + description: "This is an important notification.", + showIcon: false, + showButton: true, + actionButtonText: "Learn more", + }, + parameters: { + docs: { + description: { + story: "Use if space is limited or the alert is not the main focus.", + }, + }, + }, +}; + +// With custom icon +export const withButtonAndIcon: Story = { + render: renderAlert, + args: { + variant: "default", + title: "Alert Title", + description: "This is an important notification.", + showIcon: true, + showButton: true, + actionButtonText: "Learn more", + }, +}; + +// Error variant +export const Error: Story = { + render: renderAlert, + args: { + variant: "error", + title: "Error Alert", + description: "Your session has expired. Please log in again.", + showIcon: false, + showButton: true, + actionButtonText: "Log in", + }, + parameters: { + docs: { + description: { + story: "Only use if the user needs to take immediate action or there is a critical error.", + }, + }, + }, +}; + +// Warning variant +export const Warning: Story = { + render: renderAlert, + args: { + variant: "warning", + title: "Warning Alert", + description: "You are editing sensitive data. Be cautious", + showIcon: false, + showButton: true, + actionButtonText: "Proceed", + }, + parameters: { + docs: { + description: { + story: "Use this to make the user aware of potential issues.", + }, + }, + }, +}; + +// Info variant +export const Info: Story = { + render: renderAlert, + args: { + variant: "info", + title: "Info Alert", + description: "There was an update to your application.", + showIcon: false, + showButton: true, + actionButtonText: "Refresh", + }, + parameters: { + docs: { + description: { + story: "Use this to give contextual information and support the user.", + }, + }, + }, +}; + +// Success variant +export const Success: Story = { + render: renderAlert, + args: { + variant: "success", + title: "Success Alert", + description: "This worked! Please proceed.", + showIcon: false, + showButton: true, + actionButtonText: "Close", + }, + parameters: { + docs: { + description: { + story: "Use this to give positive feedback.", + }, + }, + }, }; diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 774c90f9e0..120265f69d 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,3 +1,5 @@ +const colors = require("tailwindcss/colors"); + module.exports = { content: [ // app content @@ -39,7 +41,7 @@ module.exports = { dark: "#00C4B8", }, focus: "var(--formbricks-focus, #1982fc)", - error: "rgb(from var(--formbricks-error) r g b / )", + // error: "rgb(from var(--formbricks-error) r g b / )", brandnew: "var(--formbricks-brand, #038178)", primary: { DEFAULT: "#0f172a", @@ -57,6 +59,34 @@ module.exports = { DEFAULT: "#f4f6f8", // light gray background foreground: "#0f172a", // same as primary default for consistency }, + info: { + DEFAULT: colors.blue[600], + foreground: colors.blue[900], + muted: colors.blue[700], + background: colors.blue[50], + "background-muted": colors.blue[100], + }, + warning: { + DEFAULT: colors.amber[500], + foreground: colors.amber[900], + muted: colors.amber[700], + background: colors.amber[50], + "background-muted": colors.amber[100], + }, + success: { + DEFAULT: colors.green[600], + foreground: colors.green[900], + muted: colors.green[700], + background: colors.green[50], + "background-muted": colors.green[100], + }, + error: { + DEFAULT: colors.red[600], + foreground: colors.red[900], + muted: colors.red[700], + background: colors.red[50], + "background-muted": colors.red[100], + }, }, keyframes: { fadeIn: { diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index cd78a29348..25297608c9 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -26,6 +26,7 @@ export default defineConfig({ "modules/email/components/email-template.tsx", "modules/email/emails/survey/follow-up.tsx", "modules/ui/components/post-hog-client/*.tsx", + "modules/ui/components/alert/*.tsx", "app/(app)/environments/**/layout.tsx", "app/(app)/environments/**/settings/(organization)/general/page.tsx", "app/(app)/environments/**/components/PosthogIdentify.tsx",