feat: migrate Alert component from MUI to shadcn and update to new Alert designs (#18412)

--- Although originally created with Blink most of the PR has been
re-written since then

## Summary

This PR migrates the Alert component from MUI to shadcn implementation
while maintaining full backward compatibility with the existing API.
Updates Alerts to new design in Figma.

Figma design:
https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=3522-3005&m=dev

<img width="1008" height="623" alt="Screenshot 2025-12-18 at 20 37 32"
src="https://github.com/user-attachments/assets/8b2077f1-f746-4a9a-8b58-3ec3477c247b"
/>

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: jaaydenh <1858163+jaaydenh@users.noreply.github.com>
Co-authored-by: Jaayden Halko <jaayden@coder.com>
This commit is contained in:
blink-so[bot]
2025-12-22 11:52:40 +00:00
committed by GitHub
parent 44a46db487
commit 5448a2645d
33 changed files with 251 additions and 117 deletions
@@ -54,3 +54,37 @@ export const WarningWithActionAndDismiss: Story = {
severity: "warning",
},
};
export const Info: Story = {
args: {
children: "This is an informational message",
severity: "info",
},
};
export const ErrorSeverity: Story = {
args: {
children: "This is an error message",
severity: "error",
},
};
export const WarningProminent: Story = {
args: {
children:
"This is a high risk warning. Use this design only for high risk warnings.",
severity: "warning",
prominent: true,
dismissible: true,
},
};
export const ErrorProminent: Story = {
args: {
children:
"This is a crucial error. Use this design only for crucial errors.",
severity: "error",
prominent: true,
dismissible: true,
},
};
+115 -45
View File
@@ -1,21 +1,81 @@
import MuiAlert, {
type AlertColor as MuiAlertColor,
type AlertProps as MuiAlertProps,
} from "@mui/material/Alert";
import Collapse from "@mui/material/Collapse";
import { cva } from "class-variance-authority";
import { Button } from "components/Button/Button";
import {
CircleAlertIcon,
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import {
type FC,
forwardRef,
type PropsWithChildren,
type ReactNode,
useState,
} from "react";
export type AlertColor = MuiAlertColor;
import { cn } from "utils/cn";
export type AlertProps = MuiAlertProps & {
const alertVariants = cva(
"relative w-full rounded-lg border border-solid p-4 text-left",
{
variants: {
severity: {
info: "",
success: "",
warning: "",
error: "",
},
prominent: {
true: "",
false: "",
},
},
compoundVariants: [
{
prominent: false,
className: "border-border-default bg-surface-secondary",
},
{
severity: "success",
prominent: true,
className: "border-border-success bg-surface-green",
},
{
severity: "warning",
prominent: true,
className: "border-border-warning bg-surface-orange",
},
{
severity: "error",
prominent: true,
className: "border-border-destructive bg-surface-red",
},
],
defaultVariants: {
severity: "info",
prominent: false,
},
},
);
const severityIcons = {
info: { icon: InfoIcon, className: "text-highlight-sky" },
success: { icon: CircleCheckIcon, className: "text-content-success" },
warning: { icon: TriangleAlertIcon, className: "text-content-warning" },
error: { icon: CircleAlertIcon, className: "text-content-destructive" },
} as const;
export type AlertColor = "info" | "success" | "warning" | "error";
export type AlertProps = {
actions?: ReactNode;
dismissible?: boolean;
onDismiss?: () => void;
severity?: AlertColor;
prominent?: boolean;
children?: ReactNode;
className?: string;
};
export const Alert: FC<AlertProps> = ({
@@ -23,59 +83,69 @@ export const Alert: FC<AlertProps> = ({
actions,
dismissible,
severity = "info",
prominent = false,
onDismiss,
...alertProps
className,
...props
}) => {
const [open, setOpen] = useState(true);
// Can't only rely on MUI's hiding behavior inside flex layouts, because even
// though MUI will make a dismissed alert have zero height, the alert will
// still behave as a flex child and introduce extra row/column gaps
if (!open) {
return null;
}
return (
<Collapse in>
<MuiAlert
{...alertProps}
css={{ textAlign: "left" }}
severity={severity}
action={
<>
{/* CTAs passed in by the consumer */}
{actions}
const { icon: Icon, className: iconClassName } = severityIcons[severity];
{/* close CTA */}
{dismissible && (
<Button
variant="subtle"
size="sm"
onClick={() => {
setOpen(false);
onDismiss?.();
}}
data-testid="dismiss-banner-btn"
>
Dismiss
</Button>
)}
</>
}
>
{children}
</MuiAlert>
</Collapse>
return (
<div
role="alert"
className={cn(alertVariants({ severity, prominent }), className)}
{...props}
>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="flex flex-row items-start gap-3">
<Icon className={cn("size-icon-sm mt-[3px]", iconClassName)} />
<div className="flex-1">{children}</div>
</div>
<div className="flex items-center gap-2">
{actions}
{dismissible && (
<Button
variant="subtle"
size="icon"
className="!size-auto !min-w-0 !p-0"
onClick={() => {
setOpen(false);
onDismiss?.();
}}
data-testid="dismiss-banner-btn"
aria-label="Dismiss"
>
<XIcon className="!size-icon-sm !p-0" />
</Button>
)}
</div>
</div>
</div>
);
};
export const AlertDetail: FC<PropsWithChildren> = ({ children }) => {
return (
<span
css={(theme) => ({ color: theme.palette.text.secondary, fontSize: 13 })}
data-chromatic="ignore"
>
<span className="m-0 text-sm" data-chromatic="ignore">
{children}
</span>
);
};
export const AlertTitle = forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h1
ref={ref}
className={cn("m-0 mb-1 text-sm font-medium", className)}
{...props}
/>
));
+2 -3
View File
@@ -1,8 +1,7 @@
import AlertTitle from "@mui/material/AlertTitle";
import { getErrorDetail, getErrorMessage, getErrorStatus } from "api/errors";
import type { FC } from "react";
import { Link } from "../Link/Link";
import { Alert, AlertDetail, type AlertProps } from "./Alert";
import { Alert, AlertDetail, type AlertProps, AlertTitle } from "./Alert";
type ErrorAlertProps = Readonly<
Omit<AlertProps, "severity" | "children"> & { error: unknown }
@@ -18,7 +17,7 @@ export const ErrorAlert: FC<ErrorAlertProps> = ({ error, ...alertProps }) => {
const shouldDisplayDetail = message !== detail;
return (
<Alert severity="error" {...alertProps}>
<Alert severity="error" prominent {...alertProps}>
{
// When the error is a Forbidden response we include a link for the user to
// go back to a known viewable page.
+1 -1
View File
@@ -24,7 +24,7 @@ const badgeVariants = cva(
"border border-solid border-border-destructive bg-surface-red text-highlight-red shadow",
green:
"border border-solid border-border-green bg-surface-green text-highlight-green shadow",
info: "border border-solid border-border-sky bg-surface-sky text-highlight-sky shadow",
info: "border border-solid border-border-pending bg-surface-sky text-highlight-sky shadow",
},
size: {
xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5",
@@ -1,11 +1,10 @@
import type { Interpolation, Theme } from "@emotion/react";
import AlertTitle from "@mui/material/AlertTitle";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import type { ApiErrorResponse } from "api/errors";
import type { ExternalAuthDevice } from "api/typesGenerated";
import { isAxiosError } from "axios";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert";
import { CopyButton } from "components/CopyButton/CopyButton";
import { ExternalLinkIcon } from "lucide-react";
import type { FC } from "react";
@@ -102,7 +101,9 @@ export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
break;
case DeviceExchangeError.AccessDenied:
status = (
<Alert severity="error">Access to the Git provider was denied.</Alert>
<Alert severity="error" prominent>
Access to the Git provider was denied.
</Alert>
);
break;
default:
@@ -1,8 +1,13 @@
import type { Theme } from "@emotion/react";
import AlertTitle from "@mui/material/AlertTitle";
import { Alert, type AlertColor, AlertDetail } from "components/Alert/Alert";
import {
Alert,
type AlertColor,
AlertDetail,
AlertTitle,
} from "components/Alert/Alert";
import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
import type { FC } from "react";
import { cn } from "utils/cn";
export enum AlertVariant {
// Alerts are usually styled with a full rounded border and meant to use as a visually distinct element of the page.
// The Standalone variant conforms to this styling.
@@ -21,20 +26,21 @@ interface ProvisionerAlertProps {
variant?: AlertVariant;
}
const getAlertStyles = (variant: AlertVariant, severity: AlertColor) => {
switch (variant) {
case AlertVariant.Inline:
return {
css: (theme: Theme) => ({
borderRadius: 0,
border: 0,
borderBottom: `1px solid ${theme.palette.divider}`,
borderLeft: `2px solid ${theme.palette[severity].main}`,
}),
};
default:
return {};
const severityBorderColors: Record<AlertColor, string> = {
info: "border-l-highlight-sky",
success: "border-l-content-success",
warning: "border-l-content-warning",
error: "border-l-content-destructive",
};
const getAlertClassName = (variant: AlertVariant, severity: AlertColor) => {
if (variant === AlertVariant.Inline) {
return cn(
"rounded-none border-0 border-b border-l-2 border-solid border-b-border-default",
severityBorderColors[severity],
);
}
return undefined;
};
export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
@@ -45,7 +51,7 @@ export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
variant = AlertVariant.Standalone,
}) => {
return (
<Alert severity={severity} {...getAlertStyles(variant, severity)}>
<Alert severity={severity} className={getAlertClassName(variant, severity)}>
<AlertTitle>{title}</AlertTitle>
<AlertDetail>
<div>{detail}</div>
@@ -40,6 +40,7 @@ export const WildcardHostnameWarning: FC<WildcardHostnameWarningProps> = ({
return (
<Alert
severity="warning"
prominent
className={
hasResources
? "rounded-none border-0 border-l-2 border-l-warning border-b-divider"
@@ -16,7 +16,7 @@ export const ClassicParameterFlowDeprecationWarning: FC<
}
return (
<Alert severity="warning" className="mb-2">
<Alert severity="warning" className="mb-2" prominent>
<div>
This template is using the classic parameter flow, which will be{" "}
<strong>deprecated</strong> and removed in a future release. Please
@@ -1,11 +1,10 @@
import { css } from "@emotion/css";
import AlertTitle from "@mui/material/AlertTitle";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import { templateVersions } from "api/queries/templates";
import type { TemplateVersion, Workspace } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { Alert, AlertTitle } from "components/Alert/Alert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
@@ -146,7 +146,7 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
</p>
{!isWorkspaceHealthy && isLoadingFiles && (
<Alert severity="warning">
<Alert severity="warning" prominent>
Your workspace is unhealthy. Some logs may be unavailable for
download.
</Alert>
@@ -428,7 +428,7 @@ const fillNameAndDisplayWithFilename = async (
const ProvisionerWarning: FC = () => {
return (
<Alert severity="warning" css={{ marginBottom: 16 }}>
<Alert severity="warning" css={{ marginBottom: 16 }} prominent>
This organization does not have any provisioners. Before you create a
template, you&apos;ll need to configure a provisioner.{" "}
<Link href={docs("/admin/provisioners#organization-scoped-provisioners")}>
@@ -520,7 +520,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
</hgroup>
<div className="flex flex-col gap-4">
{Boolean(error) && !hasAllRequiredExternalAuth && (
<Alert severity="error">
<Alert severity="error" prominent>
To create a workspace using this template, please connect to
all required external authentication providers listed below.
</Alert>
@@ -69,6 +69,7 @@ export const NotificationEvents: FC<NotificationEventsProps> = ({
{hasWebhookNotifications && !isWebhookConfigured && (
<Alert
severity="warning"
prominent
actions={
<Button variant="subtle" size="sm" asChild>
<a
@@ -88,6 +89,7 @@ export const NotificationEvents: FC<NotificationEventsProps> = ({
{hasSMTPNotifications && !isSMTPConfigured && (
<Alert
severity="warning"
prominent
actions={
<Button variant="subtle" size="sm" asChild>
<a
@@ -1,4 +1,3 @@
import AlertTitle from "@mui/material/AlertTitle";
import type {
DAUsResponse,
Experiment,
@@ -15,7 +14,7 @@ import { Stack } from "components/Stack/Stack";
import type { FC } from "react";
import { useDeploymentOptions } from "utils/deployOptions";
import { docs } from "utils/docs";
import { Alert } from "../../../components/Alert/Alert";
import { Alert, AlertTitle } from "../../../components/Alert/Alert";
import OptionsTable from "../OptionsTable";
import { UserEngagementChart } from "./UserEngagementChart";
@@ -39,6 +39,7 @@ const AccessURLPage = () => {
actions={<HealthMessageDocsLink {...warning} />}
key={warning.code}
severity="warning"
prominent
>
{warning.message}
</Alert>
+1
View File
@@ -67,6 +67,7 @@ const DERPPage: FC = () => {
actions={<HealthMessageDocsLink {...warning} />}
key={warning.code}
severity="warning"
prominent
>
{warning.message}
</Alert>
@@ -81,6 +81,7 @@ const DERPRegionPage: FC = () => {
actions={<HealthMessageDocsLink {...warning} />}
key={warning.code}
severity="warning"
prominent
>
{warning.message}
</Alert>
@@ -37,6 +37,7 @@ const DatabasePage = () => {
actions={<HealthMessageDocsLink {...warning} />}
key={warning.code}
severity="warning"
prominent
>
{warning.message}
</Alert>
@@ -30,7 +30,11 @@ const ProvisionerDaemonsPage: FC = () => {
</Header>
<Main>
{daemons.error && <Alert severity="error">{daemons.error}</Alert>}
{daemons.error && (
<Alert severity="error" prominent>
{daemons.error}
</Alert>
)}
{daemons.warnings.map((warning) => {
return (
<Alert
+6 -2
View File
@@ -38,11 +38,15 @@ const WebsocketPage = () => {
</Header>
<Main>
{websocket.error && <Alert severity="error">{websocket.error}</Alert>}
{websocket.error && (
<Alert severity="error" prominent>
{websocket.error}
</Alert>
)}
{websocket.warnings.map((warning) => {
return (
<Alert key={warning.code} severity="warning">
<Alert key={warning.code} severity="warning" prominent>
{warning.message}
</Alert>
);
@@ -42,7 +42,9 @@ const WorkspaceProxyPage: FC = () => {
<Main>
{workspace_proxy.error && (
<Alert severity="error">{workspace_proxy.error}</Alert>
<Alert severity="error" prominent>
{workspace_proxy.error}
</Alert>
)}
{workspace_proxy.warnings.map((warning) => {
return (
@@ -50,6 +52,7 @@ const WorkspaceProxyPage: FC = () => {
actions={<HealthMessageDocsLink {...warning} />}
key={warning.code}
severity="warning"
prominent
>
{warning.message}
</Alert>
+3 -1
View File
@@ -114,7 +114,9 @@ export const SignInForm: FC<SignInFormProps> = ({
)}
{!passwordEnabled && !oAuthEnabled && (
<Alert severity="error">No authentication methods configured!</Alert>
<Alert severity="error" prominent>
No authentication methods configured!
</Alert>
)}
</div>
);
+2 -3
View File
@@ -1,4 +1,3 @@
import AlertTitle from "@mui/material/AlertTitle";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
import Link from "@mui/material/Link";
@@ -7,7 +6,7 @@ import TextField from "@mui/material/TextField";
import { countries } from "api/countriesGenerated";
import type * as TypesGen from "api/typesGenerated";
import { isAxiosError } from "axios";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { FormFields, VerticalForm } from "components/Form/Form";
@@ -352,7 +351,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
)}
{isAxiosError(error) && error.response?.data?.message && (
<Alert severity="error">
<Alert severity="error" prominent>
<AlertTitle>{error.response.data.message}</AlertTitle>
{error.response.data.detail && (
<AlertDetail>
@@ -334,6 +334,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
>
<Alert
severity="success"
prominent
dismissible
actions={
<Button
+18 -10
View File
@@ -1,8 +1,13 @@
import Link from "@mui/material/Link";
import type { WorkspaceAgent } from "api/typesGenerated";
import { Alert, type AlertProps } from "components/Alert/Alert";
import {
Alert,
type AlertColor,
type AlertProps,
} from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { type FC, useEffect, useRef, useState } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import type { ConnectionStatus } from "./types";
@@ -153,19 +158,22 @@ const LoadedScriptsAlert: FC = () => {
);
};
const severityBorderColors: Record<AlertColor, string> = {
info: "border-l-highlight-sky",
success: "border-l-content-success",
warning: "border-l-content-warning",
error: "border-l-content-destructive",
};
const TerminalAlert: FC<AlertProps> = (props) => {
const severity = props.severity ?? "info";
return (
<Alert
{...props}
css={(theme) => ({
borderRadius: 0,
borderWidth: 0,
borderBottomWidth: 1,
borderBottomColor: theme.palette.divider,
backgroundColor: theme.palette.background.paper,
borderLeft: `3px solid ${theme.palette[props.severity!].light}`,
marginBottom: 1,
})}
className={cn(
"rounded-none border-0 border-b border-l-[3px] border-b-border-default bg-surface-primary mb-px [&>div]:items-center",
severityBorderColors[severity],
)}
/>
);
};
@@ -174,6 +174,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
{build.transition === "delete" && build.job.status === "failed" && (
<Alert
severity="error"
prominent
className="rounded-none border-0 border-b border-solid border-border"
>
<div>
@@ -190,6 +191,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
{build?.job?.logs_overflowed && (
<Alert
severity="warning"
prominent
className="rounded-none border-0 border-b border-solid border-border"
>
Provisioner logs exceeded the max size of 1MB. Will not continue
+10 -14
View File
@@ -1,7 +1,7 @@
import AlertTitle from "@mui/material/AlertTitle";
import type * as TypesGen from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert";
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
import { Link } from "components/Link/Link";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { BlocksIcon, HistoryIcon } from "lucide-react";
import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
@@ -193,14 +193,14 @@ export const Workspace: FC<WorkspaceProps> = ({
)}
{workspace.latest_build.job.error && (
<Alert severity="error">
<Alert severity="error" prominent>
<AlertTitle>Workspace build failed</AlertTitle>
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
</Alert>
)}
{!workspace.health.healthy && (
<Alert severity="warning">
<Alert severity="warning" prominent>
<AlertTitle>Workspace is unhealthy</AlertTitle>
<AlertDetail>
<p>
@@ -208,7 +208,12 @@ export const Workspace: FC<WorkspaceProps> = ({
{workspace.health.failing_agents.length > 1
? `${workspace.health.failing_agents.length} agents are unhealthy`
: "1 agent is unhealthy"}
.
.{" "}
{troubleshootingURL && (
<Link href={troubleshootingURL} target="_blank">
View docs to troubleshoot
</Link>
)}
</p>
{hasActions && (
<div className="flex items-center gap-2">
@@ -219,15 +224,6 @@ export const Workspace: FC<WorkspaceProps> = ({
Restart
</NotificationActionButton>
)}
{troubleshootingURL && (
<NotificationActionButton
onClick={() =>
window.open(troubleshootingURL, "_blank")
}
>
Troubleshooting
</NotificationActionButton>
)}
</div>
)}
</AlertDetail>
@@ -16,7 +16,7 @@ export const WorkspaceDeletedBanner: FC<WorkspaceDeletedBannerProps> = ({
);
return (
<Alert severity="warning" actions={NewWorkspaceButton}>
<Alert severity="warning" prominent actions={NewWorkspaceButton}>
This workspace has been deleted and cannot be edited.
</Alert>
);
@@ -105,7 +105,7 @@ const NotificationItem: FC<NotificationItemProps> = ({ notification }) => {
};
export const NotificationActionButton: FC<ButtonProps> = (props) => {
return <Button variant="outline" size="sm" {...props} />;
return <Button variant="default" size="sm" {...props} />;
};
const styles = {
@@ -84,7 +84,7 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
return (
<>
{disabled && (
<Alert severity="warning">
<Alert severity="warning" prominent>
The template for this workspace requires automatic updates. Update the
workspace to edit parameters.
</Alert>
@@ -112,15 +112,15 @@ export const WorkspaceParametersPageViewExperimental: FC<
return (
<>
{disabled && (
<Alert severity="warning" className="mb-8">
<Alert severity="warning" className="mb-8" prominent>
The template for this workspace requires automatic updates. Update the
workspace to edit parameters.
</Alert>
)}
{hasIncompatibleParameters && (
<Alert severity="error">
<p className="text-lg leading-tight font-bold m-0">
<Alert severity="error" prominent>
<p className="text-lg leading-normal font-bold m-0">
Workspace update blocked
</p>
<p className="mb-0">
@@ -108,7 +108,7 @@ const WorkspaceSchedulePage: FC = () => {
rel="noreferrer"
>
Prebuilt Workspaces Scheduling
</Link>
</Link>{" "}
documentation page.
</Alert>
) : (
+1 -1
View File
@@ -59,7 +59,7 @@ module.exports = {
DEFAULT: "hsl(var(--border-default))",
warning: "hsl(var(--border-warning))",
green: "hsl(var(--border-green))",
sky: "hsl(var(--border-sky))",
pending: "hsl(var(--border-sky))",
destructive: "hsl(var(--border-destructive))",
success: "hsl(var(--border-success))",
hover: "hsl(var(--border-hover))",