fix: unify alert component (#5002)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Jakob Schott
2025-03-27 13:46:56 +01:00
committed by GitHub
parent ef41f35209
commit e1a5291123
6 changed files with 535 additions and 73 deletions

View File

@@ -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(
<Alert>
<AlertTitle>Test Title</AlertTitle>
<AlertDescription>Test Description</AlertDescription>
</Alert>
);
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(
<Alert variant={variant}>
<AlertTitle>Test Title</AlertTitle>
</Alert>
);
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(
<Alert size={size}>
<AlertTitle>Test Title</AlertTitle>
</Alert>
);
expect(container.firstChild).toHaveClass(size === "default" ? "py-3" : "py-2");
});
});
it("renders with button and handles click", () => {
const handleClick = vi.fn();
render(
<Alert>
<AlertTitle>Test Title</AlertTitle>
<AlertButton onClick={handleClick}>Click me</AlertButton>
</Alert>
);
const button = screen.getByText("Click me");
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("applies custom className", () => {
const { container } = render(
<Alert className="custom-class">
<AlertTitle>Test Title</AlertTitle>
</Alert>
);
expect(container.firstChild).toHaveClass("custom-class");
});
});

View File

@@ -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<AlertContextValue>({
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: <AlertCircle className="size-4" />,
warning: <AlertTriangle className="size-4" />,
info: <Info className="size-4" />,
success: <CheckCircle2Icon className="size-4" />,
};
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertVariants> & { dangerouslySetInnerHTML?: { __html: string } }
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, size, ...props }, ref) => {
const variantIcon = variant ? (variant !== "default" ? alertVariantIcons[variant] : null) : null;
return (
<AlertContext.Provider value={{ variant, size }}>
<div ref={ref} role="alert" className={cn(alertVariants({ variant, size }), className)} {...props}>
{variantIcon}
{props.children}
</div>
</AlertContext.Provider>
);
});
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement> & { dangerouslySetInnerHTML?: { __html: string } }
>(({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 cursor-default font-medium leading-none", className)} {...props} />
));
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => {
const { size } = useAlertContext();
return (
<h5
ref={ref}
className={cn(
"col-start-1 row-start-1 font-medium leading-none tracking-tight",
size === "small" ? "min-w-0 flex-shrink truncate" : "col-start-1 row-start-1",
className
)}
{...props}
/>
);
}
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> & { dangerouslySetInnerHTML?: { __html: string } }
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("cursor-default text-sm [&_p]:leading-relaxed", className)} {...props} />
));
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { size } = useAlertContext();
return (
<div
ref={ref}
className={cn(
"[&_p]:leading-relaxed",
size === "small"
? "hidden min-w-0 flex-shrink flex-grow truncate opacity-80 sm:block" // Hidden on very small screens, limited width
: "col-start-1 row-start-2",
className
)}
{...props}
/>
);
}
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertDescription, AlertTitle };
const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ 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 (
<div
className={cn(
"self-end",
alertSize === "small"
? "-my-2 -mr-3 ml-auto flex-shrink-0"
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
)}>
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
{children}
</Button>
</div>
);
}
);
AlertButton.displayName = "AlertButton";
// Export the new component
export { Alert, AlertTitle, AlertDescription, AlertButton };

View File

@@ -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();
});
});

View File

@@ -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<typeof Alert> & StoryOptions;
const meta: Meta<StoryProps> = {
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) => (
<Alert {...args}>
<AlertTitle>This is an alert</AlertTitle>
<AlertDescription>This is a description</AlertDescription>
</Alert>
),
} satisfies Meta<typeof Alert>;
};
export default meta;
type Story = StoryObj<typeof meta>;
// Our story type just specifies Alert props plus our story options
type Story = StoryObj<typeof Alert> & { 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) => (
<Alert {...args}>
<AlertCircle className="h-4 w-4" />
<AlertTitle>This is an alert</AlertTitle>
<AlertDescription>This is a description</AlertDescription>
return (
<Alert variant={variant} size={size} className={className}>
{showIcon && <LightbulbIcon />}
<AlertTitle className={showIcon ? "pl-7" : ""}>{title}</AlertTitle>
{description && <AlertDescription className={showIcon ? "pl-7" : ""}>{description}</AlertDescription>}
{showButton && <AlertButton onClick={() => alert("Button clicked")}>{actionButtonText}</AlertButton>}
</Alert>
),
);
};
// 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.",
},
},
},
};

View File

@@ -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 / <alpha-value>)",
// error: "rgb(from var(--formbricks-error) r g b / <alpha-value>)",
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: {

View File

@@ -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",