refactor(site): replace MUI update-check snackbar with <UpdateCheckNotice/> (#27728)

Replaces the MUI `Snackbar` / `Link` update-check toast in
`DashboardLayout` with a Tailwind `UpdateCheckNotice`, and adds
Storybook coverage for the layout and notice.

The notice stays declarative (not Sonner), offsets above the deployment
banner when the user can view deployment stats, and otherwise sits at
the normal bottom margin.

| Old | New |
| --- | --- |
| <img width="460" height="158" alt="UPDATE_NOTICE_OLD"
src="https://github.com/user-attachments/assets/d7ac9e5a-f69a-47de-89d5-9b6c6df4e1aa"
/> | <img width="440" height="157" alt="UPDATE_NOTICE_NEW"
src="https://github.com/user-attachments/assets/a5eee05b-0232-4668-bc3e-fd3ffdbad9b8"
/> |
This commit is contained in:
Jake Howell
2026-08-05 01:00:31 +10:00
committed by GitHub
parent 209c990888
commit d23d0d5313
8 changed files with 255 additions and 59 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import type { BuildInfoResponse } from "#/api/typesGenerated";
import type { MetadataState } from "#/hooks/useEmbeddedMetadata";
import { cachedQuery } from "./util";
const buildInfoKey = ["buildInfo"] as const;
export const buildInfoKey = ["buildInfo"];
export const buildInfo = (metadata: MetadataState<BuildInfoResponse>) => {
// The version of the app can't change without reloading the page.
+3 -1
View File
@@ -18,9 +18,11 @@ export const deploymentDAUs = () => {
};
};
export const deploymentStatsQueryKey = ["deployment", "stats"];
export const deploymentStats = () => {
return {
queryKey: ["deployment", "stats"],
queryKey: deploymentStatsQueryKey,
queryFn: API.getDeploymentStats,
};
};
+3 -1
View File
@@ -1,8 +1,10 @@
import { API } from "#/api/api";
export const updateCheckQueryKey = ["updateCheck"];
export const updateCheck = () => {
return {
queryKey: ["updateCheck"],
queryKey: updateCheckQueryKey,
queryFn: () => API.getUpdateCheck(),
};
};
@@ -0,0 +1,125 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, within } from "storybook/test";
import {
reactRouterOutlet,
reactRouterParameters,
} from "storybook-addon-remix-react-router";
import { buildInfoKey } from "#/api/queries/buildInfo";
import { deploymentStatsQueryKey } from "#/api/queries/deployment";
import { updateCheckQueryKey } from "#/api/queries/updateCheck";
import type { UpdateCheckResponse } from "#/api/typesGenerated";
import {
MockBuildInfo,
MockDeploymentStats,
MockNoPermissions,
MockPermissions,
MockUpdateCheck,
MockUserMember,
MockUserOwner,
} from "#/testHelpers/entities";
import { pixelWithTablet } from "#/testHelpers/pixel";
import {
withAuthProvider,
withDashboardProvider,
withProxyProvider,
} from "#/testHelpers/storybook";
import { DashboardFullPage, DashboardLayout } from "./DashboardLayout";
const outdatedUpdateCheck: UpdateCheckResponse = {
...MockUpdateCheck,
current: false,
version: "v0.12.9",
url: "https://github.com/coder/coder/releases/tag/v0.12.9",
};
const pageContent = (
<DashboardFullPage className="p-6">
<h1>Workspaces</h1>
<p>Page content rendered in the dashboard outlet.</p>
</DashboardFullPage>
);
const meta: Meta<typeof DashboardLayout> = {
title: "modules/dashboard/DashboardLayout",
component: DashboardLayout,
decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()],
parameters: {
layout: "fullscreen",
pixel: { matrix: pixelWithTablet },
user: MockUserOwner,
permissions: MockPermissions,
reactRouter: reactRouterParameters({
location: { path: "/" },
routing: reactRouterOutlet({ path: "/" }, pageContent),
}),
queries: [
{ key: buildInfoKey, data: MockBuildInfo },
{ key: updateCheckQueryKey, data: MockUpdateCheck },
{ key: deploymentStatsQueryKey, data: MockDeploymentStats },
],
},
};
export default meta;
type Story = StoryObj<typeof DashboardLayout>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("heading", { name: "Workspaces" }),
).toBeVisible();
await expect(
canvas.getByRole("link", { name: "Skip to main content" }),
).toBeInTheDocument();
},
};
export const ForMember: Story = {
parameters: {
user: MockUserMember,
permissions: MockNoPermissions,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("heading", { name: "Workspaces" }),
).toBeVisible();
await expect(
screen.queryByTestId("update-check-notice"),
).not.toBeInTheDocument();
},
};
export const UpdateAvailable: Story = {
parameters: {
queries: [
{ key: buildInfoKey, data: MockBuildInfo },
{ key: updateCheckQueryKey, data: outdatedUpdateCheck },
{ key: deploymentStatsQueryKey, data: MockDeploymentStats },
],
},
beforeEach: () => {
localStorage.removeItem("dismissedVersion");
},
play: async () => {
const notice = await screen.findByTestId("update-check-notice");
await expect(notice).toBeVisible();
await expect(
screen.getByText(/Coder v0\.12\.9 is now available/),
).toBeVisible();
},
};
export const SkipToMainContent: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const skipLink = canvas.getByRole("link", {
name: "Skip to main content",
});
const main = canvas.getByRole("main");
await userEvent.click(skipLink);
await expect(main).toHaveFocus();
},
};
@@ -68,7 +68,7 @@ test("Show the new Coder version notification", async () => {
renderWithAuth(<DashboardLayout />, {
children: [{ element: <h1>Test page</h1> }],
});
await screen.findByTestId("update-check-snackbar");
await screen.findByTestId("update-check-notice");
});
test("hides AI Governance seat warnings for non-admin users", async () => {
+9 -55
View File
@@ -1,17 +1,13 @@
import Link from "@mui/material/Link";
import Snackbar from "@mui/material/Snackbar";
import { InfoIcon } from "lucide-react";
import { type FC, type HTMLAttributes, Suspense } from "react";
import { Outlet } from "react-router";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { AnnouncementBanners } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBanners";
import { LicenseBanner } from "#/modules/dashboard/LicenseBanner/LicenseBanner";
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner";
import { Navbar } from "./Navbar/Navbar";
import { UpdateCheckNotice } from "./UpdateCheckNotice/UpdateCheckNotice";
import { useUpdateCheck } from "./useUpdateCheck";
export const DashboardLayout: FC = () => {
@@ -54,56 +50,14 @@ export const DashboardLayout: FC = () => {
<DeploymentBanner />
<Snackbar
data-testid="update-check-snackbar"
open={updateCheck.isVisible}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
ContentProps={{
sx: (theme) => ({
background: theme.palette.background.paper,
color: theme.palette.text.primary,
maxWidth: 440,
flexDirection: "row",
borderColor: theme.palette.info.light,
"& .MuiSnackbarContent-message": {
flex: 1,
},
"& .MuiSnackbarContent-action": {
marginRight: 0,
},
}),
}}
message={
<div className="flex gap-4">
<InfoIcon
className="size-icon-xs"
css={(theme) => ({
fontSize: 16,
height: 20, // 20 is the height of the text line so we can align them
color: theme.palette.info.light,
})}
/>
<p>
Coder {updateCheck.data?.version} is now available. View the{" "}
<Link href={updateCheck.data?.url}>release notes</Link> and{" "}
<Link href={docs("/install/upgrade")}>
upgrade instructions
</Link>{" "}
for more information.
</p>
</div>
}
action={
<Button variant="subtle" size="sm" onClick={updateCheck.dismiss}>
Dismiss
</Button>
}
/>
{updateCheck.isVisible && updateCheck.data && (
<UpdateCheckNotice
version={updateCheck.data.version}
releaseNotesUrl={updateCheck.data.url}
onDismiss={updateCheck.dismiss}
aboveDeploymentBanner={Boolean(permissions.viewDeploymentStats)}
/>
)}
</div>
</>
);
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { UpdateCheckNotice } from "./UpdateCheckNotice";
const meta: Meta<typeof UpdateCheckNotice> = {
title: "modules/dashboard/UpdateCheckNotice",
component: UpdateCheckNotice,
args: {
version: "v0.12.9",
releaseNotesUrl: "https://github.com/coder/coder/releases/tag/v0.12.9",
onDismiss: fn(),
aboveDeploymentBanner: true,
},
parameters: {
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj<typeof UpdateCheckNotice>;
export const AboveDeploymentBanner: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText(/Coder v0\.12\.9 is now available/),
).toBeVisible();
await expect(
canvas.getByRole("link", { name: "release notes" }),
).toHaveAttribute(
"href",
"https://github.com/coder/coder/releases/tag/v0.12.9",
);
await expect(
canvas.getByRole("link", { name: "upgrade instructions" }),
).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Dismiss" }));
await expect(args.onDismiss).toHaveBeenCalled();
},
};
export const WithoutDeploymentBanner: Story = {
args: {
aboveDeploymentBanner: false,
},
};
@@ -0,0 +1,66 @@
import { InfoIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
type UpdateCheckNoticeProps = {
version: string;
releaseNotesUrl: string;
onDismiss: () => void;
aboveDeploymentBanner?: boolean;
};
export const UpdateCheckNotice: FC<UpdateCheckNoticeProps> = ({
version,
releaseNotesUrl,
onDismiss,
aboveDeploymentBanner = false,
}) => {
return (
<div
data-testid="update-check-notice"
role="status"
className={cn(
"fixed right-6 z-50 flex max-w-[420px] items-start gap-4 rounded border border-solid border-highlight-sky bg-surface-primary p-4 text-sm text-content-primary shadow",
// 60px keeps a 24px gap above the 36px deployment banner.
aboveDeploymentBanner ? "bottom-[60px]" : "bottom-6",
)}
>
<InfoIcon className="mt-0.5 size-icon-sm shrink-0 text-highlight-sky" />
<div className="flex flex-col gap-1">
<p className="m-0 font-semibold">Coder {version} is now available. </p>
<p className="m-0 flex-1 leading-5">
View the{" "}
<a
href={releaseNotesUrl}
target="_blank"
rel="noreferrer"
className="text-content-link underline hover:no-underline"
>
release notes
</a>{" "}
and{" "}
<a
href={docs("/install/upgrade")}
target="_blank"
rel="noreferrer"
className="text-content-link underline hover:no-underline"
>
upgrade instructions
</a>{" "}
for more information.
</p>
</div>
<Button
aria-label="Dismiss"
size="icon"
variant="subtle"
onClick={onDismiss}
className="-m-2"
>
<XIcon />
</Button>
</div>
);
};