From d23d0d5313a212ca9977bb831ea0dd0b2cbd5b2d Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 5 Aug 2026 01:00:31 +1000 Subject: [PATCH] refactor(site): replace MUI update-check snackbar with `` (#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 | | --- | --- | | UPDATE_NOTICE_OLD | UPDATE_NOTICE_NEW | --- site/src/api/queries/buildInfo.ts | 2 +- site/src/api/queries/deployment.ts | 4 +- site/src/api/queries/updateCheck.ts | 4 +- .../dashboard/DashboardLayout.stories.tsx | 125 ++++++++++++++++++ .../dashboard/DashboardLayout.test.tsx | 2 +- .../src/modules/dashboard/DashboardLayout.tsx | 64 ++------- .../UpdateCheckNotice.stories.tsx | 47 +++++++ .../UpdateCheckNotice/UpdateCheckNotice.tsx | 66 +++++++++ 8 files changed, 255 insertions(+), 59 deletions(-) create mode 100644 site/src/modules/dashboard/DashboardLayout.stories.tsx create mode 100644 site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.stories.tsx create mode 100644 site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx diff --git a/site/src/api/queries/buildInfo.ts b/site/src/api/queries/buildInfo.ts index b42ff410df..a632a3a152 100644 --- a/site/src/api/queries/buildInfo.ts +++ b/site/src/api/queries/buildInfo.ts @@ -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) => { // The version of the app can't change without reloading the page. diff --git a/site/src/api/queries/deployment.ts b/site/src/api/queries/deployment.ts index e17f2c6b08..bf45559032 100644 --- a/site/src/api/queries/deployment.ts +++ b/site/src/api/queries/deployment.ts @@ -18,9 +18,11 @@ export const deploymentDAUs = () => { }; }; +export const deploymentStatsQueryKey = ["deployment", "stats"]; + export const deploymentStats = () => { return { - queryKey: ["deployment", "stats"], + queryKey: deploymentStatsQueryKey, queryFn: API.getDeploymentStats, }; }; diff --git a/site/src/api/queries/updateCheck.ts b/site/src/api/queries/updateCheck.ts index b0724d9a1e..9d613a751e 100644 --- a/site/src/api/queries/updateCheck.ts +++ b/site/src/api/queries/updateCheck.ts @@ -1,8 +1,10 @@ import { API } from "#/api/api"; +export const updateCheckQueryKey = ["updateCheck"]; + export const updateCheck = () => { return { - queryKey: ["updateCheck"], + queryKey: updateCheckQueryKey, queryFn: () => API.getUpdateCheck(), }; }; diff --git a/site/src/modules/dashboard/DashboardLayout.stories.tsx b/site/src/modules/dashboard/DashboardLayout.stories.tsx new file mode 100644 index 0000000000..c8e290e4b0 --- /dev/null +++ b/site/src/modules/dashboard/DashboardLayout.stories.tsx @@ -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 = ( + +

Workspaces

+

Page content rendered in the dashboard outlet.

+
+); + +const meta: Meta = { + 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; + +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(); + }, +}; diff --git a/site/src/modules/dashboard/DashboardLayout.test.tsx b/site/src/modules/dashboard/DashboardLayout.test.tsx index 5b55f342cb..3147514586 100644 --- a/site/src/modules/dashboard/DashboardLayout.test.tsx +++ b/site/src/modules/dashboard/DashboardLayout.test.tsx @@ -68,7 +68,7 @@ test("Show the new Coder version notification", async () => { renderWithAuth(, { children: [{ element:

Test page

}], }); - await screen.findByTestId("update-check-snackbar"); + await screen.findByTestId("update-check-notice"); }); test("hides AI Governance seat warnings for non-admin users", async () => { diff --git a/site/src/modules/dashboard/DashboardLayout.tsx b/site/src/modules/dashboard/DashboardLayout.tsx index 9bc970f53d..fe2229fe73 100644 --- a/site/src/modules/dashboard/DashboardLayout.tsx +++ b/site/src/modules/dashboard/DashboardLayout.tsx @@ -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 = () => { - ({ - 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={ -
- ({ - fontSize: 16, - height: 20, // 20 is the height of the text line so we can align them - color: theme.palette.info.light, - })} - /> -

- Coder {updateCheck.data?.version} is now available. View the{" "} - release notes and{" "} - - upgrade instructions - {" "} - for more information. -

-
- } - action={ - - } - /> + {updateCheck.isVisible && updateCheck.data && ( + + )} ); diff --git a/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.stories.tsx b/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.stories.tsx new file mode 100644 index 0000000000..45d0786f64 --- /dev/null +++ b/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.stories.tsx @@ -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 = { + 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; + +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, + }, +}; diff --git a/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx b/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx new file mode 100644 index 0000000000..72b8cbf999 --- /dev/null +++ b/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx @@ -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 = ({ + version, + releaseNotesUrl, + onDismiss, + aboveDeploymentBanner = false, +}) => { + return ( +
+ +
+

Coder {version} is now available.

+

+ View the{" "} + + release notes + {" "} + and{" "} + + upgrade instructions + {" "} + for more information. +

+
+ +
+ ); +};