diff --git a/site/src/api/queries/buildInfo.ts b/site/src/api/queries/buildInfo.ts index b42ff410dfc0d..a632a3a152845 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 e17f2c6b0878e..bf45559032fce 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 b0724d9a1e8be..9d613a751e42a 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 0000000000000..c8e290e4b0319 --- /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 5b55f342cb2ab..3147514586076 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 9bc970f53d987..fe2229fe731e2 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 0000000000000..45d0786f64a8a --- /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 0000000000000..72b8cbf999c07 --- /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. +

+
+ +
+ ); +};