From 6f0fac60b9080fccc4eb95c9bfd4cfcac8663901 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sat, 1 Aug 2026 01:44:43 +0000 Subject: [PATCH 1/3] refactor(site): replace MUI update-check snackbar with `` 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. --- .../dashboard/DashboardLayout.stories.tsx | 121 ++++++++++++++++++ .../dashboard/DashboardLayout.test.tsx | 2 +- .../src/modules/dashboard/DashboardLayout.tsx | 64 ++------- .../UpdateCheckNotice.stories.tsx | 47 +++++++ .../UpdateCheckNotice/UpdateCheckNotice.tsx | 60 +++++++++ 5 files changed, 238 insertions(+), 56 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/modules/dashboard/DashboardLayout.stories.tsx b/site/src/modules/dashboard/DashboardLayout.stories.tsx new file mode 100644 index 00000000000..155e42fd02a --- /dev/null +++ b/site/src/modules/dashboard/DashboardLayout.stories.tsx @@ -0,0 +1,121 @@ +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 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 = { + 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: ["buildInfo"], data: MockBuildInfo }, + { key: ["updateCheck"], data: MockUpdateCheck }, + { key: ["deployment", "stats"], 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: ["buildInfo"], data: MockBuildInfo }, + { key: ["updateCheck"], data: outdatedUpdateCheck }, + { key: ["deployment", "stats"], 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 5b55f342cb2..31475145860 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 9bc970f53d9..fe2229fe731 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 00000000000..45d0786f64a --- /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 00000000000..24584ead050 --- /dev/null +++ b/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx @@ -0,0 +1,60 @@ +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. +

+
+ +
+ ); +}; From 591803ecd97b62a35cc0784b467741364c2be084 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sat, 1 Aug 2026 11:56:10 +1000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix(site):=20address=20Codex?= =?UTF-8?q?=20review=20comments=20on=20update-check=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add accessible name to the dismiss button - Export and reuse canonical query keys in DashboardLayout stories - Derive the outdated update-check variant from MockUpdateCheck --- 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 | 16 ++++++++++------ .../UpdateCheckNotice/UpdateCheckNotice.tsx | 8 +++++++- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/site/src/api/queries/buildInfo.ts b/site/src/api/queries/buildInfo.ts index b42ff410dfc..22d6af19d72 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"] as const; 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 e17f2c6b087..bf45559032f 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 b0724d9a1e8..9d613a751e4 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 index 155e42fd02a..c8e290e4b03 100644 --- a/site/src/modules/dashboard/DashboardLayout.stories.tsx +++ b/site/src/modules/dashboard/DashboardLayout.stories.tsx @@ -4,6 +4,9 @@ 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, @@ -23,6 +26,7 @@ import { 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", @@ -49,9 +53,9 @@ const meta: Meta = { routing: reactRouterOutlet({ path: "/" }, pageContent), }), queries: [ - { key: ["buildInfo"], data: MockBuildInfo }, - { key: ["updateCheck"], data: MockUpdateCheck }, - { key: ["deployment", "stats"], data: MockDeploymentStats }, + { key: buildInfoKey, data: MockBuildInfo }, + { key: updateCheckQueryKey, data: MockUpdateCheck }, + { key: deploymentStatsQueryKey, data: MockDeploymentStats }, ], }, }; @@ -90,9 +94,9 @@ export const ForMember: Story = { export const UpdateAvailable: Story = { parameters: { queries: [ - { key: ["buildInfo"], data: MockBuildInfo }, - { key: ["updateCheck"], data: outdatedUpdateCheck }, - { key: ["deployment", "stats"], data: MockDeploymentStats }, + { key: buildInfoKey, data: MockBuildInfo }, + { key: updateCheckQueryKey, data: outdatedUpdateCheck }, + { key: deploymentStatsQueryKey, data: MockDeploymentStats }, ], }, beforeEach: () => { diff --git a/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx b/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx index 24584ead050..72b8cbf999c 100644 --- a/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx +++ b/site/src/modules/dashboard/UpdateCheckNotice/UpdateCheckNotice.tsx @@ -52,7 +52,13 @@ export const UpdateCheckNotice: FC = ({ for more information.

- From 341f11e20b545cc82b76724cab8842869d3d3056 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 3 Aug 2026 09:22:15 +0000 Subject: [PATCH 3/3] chore: remove `as const` --- site/src/api/queries/buildInfo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/queries/buildInfo.ts b/site/src/api/queries/buildInfo.ts index 22d6af19d72..a632a3a1528 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"; -export 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.