diff --git a/site/src/api/queries/updateCheck.ts b/site/src/api/queries/updateCheck.ts new file mode 100644 index 0000000000000..40fcc6a3cfdde --- /dev/null +++ b/site/src/api/queries/updateCheck.ts @@ -0,0 +1,8 @@ +import * as API from "api/api"; + +export const updateCheck = () => { + return { + queryKey: ["updateCheck"], + queryFn: () => API.getUpdateCheck(), + }; +}; diff --git a/site/src/components/Dashboard/DashboardLayout.test.tsx b/site/src/components/Dashboard/DashboardLayout.test.tsx index 0020a014fd26c..b580225491207 100644 --- a/site/src/components/Dashboard/DashboardLayout.test.tsx +++ b/site/src/components/Dashboard/DashboardLayout.test.tsx @@ -1,14 +1,22 @@ import { renderWithAuth } from "testHelpers/renderHelpers"; import { DashboardLayout } from "./DashboardLayout"; -import * as API from "api/api"; import { screen } from "@testing-library/react"; +import { rest } from "msw"; +import { server } from "testHelpers/server"; test("Show the new Coder version notification", async () => { - jest.spyOn(API, "getUpdateCheck").mockResolvedValue({ - current: false, - version: "v0.12.9", - url: "https://github.com/coder/coder/releases/tag/v0.12.9", - }); + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + current: false, + version: "v0.12.9", + url: "https://github.com/coder/coder/releases/tag/v0.12.9", + }), + ); + }), + ); renderWithAuth(, { children: [{ element:

Test page

}], }); diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx index 8aa96360d2cdc..1947e3ef9cbda 100644 --- a/site/src/components/Dashboard/DashboardLayout.tsx +++ b/site/src/components/Dashboard/DashboardLayout.tsx @@ -1,4 +1,3 @@ -import { useMachine } from "@xstate/react"; import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner"; import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner"; import { Loader } from "components/Loader/Loader"; @@ -7,7 +6,6 @@ import { usePermissions } from "hooks/usePermissions"; import { FC, Suspense } from "react"; import { Outlet } from "react-router-dom"; import { dashboardContentBottomPadding } from "theme/constants"; -import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"; import { Navbar } from "./Navbar/Navbar"; import Snackbar from "@mui/material/Snackbar"; import Link from "@mui/material/Link"; @@ -15,15 +13,11 @@ import Box, { BoxProps } from "@mui/material/Box"; import InfoOutlined from "@mui/icons-material/InfoOutlined"; import Button from "@mui/material/Button"; import { docs } from "utils/docs"; +import { useUpdateCheck } from "./useUpdateCheck"; export const DashboardLayout: FC = () => { const permissions = usePermissions(); - const [updateCheckState, updateCheckSend] = useMachine(updateCheckMachine, { - context: { - permissions, - }, - }); - const { updateCheck } = updateCheckState.context; + const updateCheck = useUpdateCheck(permissions.viewUpdateCheck); const canViewDeployment = Boolean(permissions.viewDeploymentValues); return ( @@ -57,7 +51,7 @@ export const DashboardLayout: FC = () => { { })} /> - Coder {updateCheck?.version} is now available. View the{" "} - release notes and{" "} + Coder {updateCheck.data?.version} is now available. View the{" "} + release notes and{" "} upgrade instructions{" "} for more information. } action={ - } diff --git a/site/src/components/Dashboard/useUpdateCheck.test.tsx b/site/src/components/Dashboard/useUpdateCheck.test.tsx new file mode 100644 index 0000000000000..e5e307c205d79 --- /dev/null +++ b/site/src/components/Dashboard/useUpdateCheck.test.tsx @@ -0,0 +1,121 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useUpdateCheck } from "./useUpdateCheck"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { ReactNode } from "react"; +import { rest } from "msw"; +import { MockUpdateCheck } from "testHelpers/entities"; +import { server } from "testHelpers/server"; + +const createWrapper = () => { + const queryClient = new QueryClient(); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +it("is dismissed when does not have permission to see it", () => { + const { result } = renderHook(() => useUpdateCheck(false), { + wrapper: createWrapper(), + }); + expect(result.current.isVisible).toBeFalsy(); +}); + +it("is dismissed when it is already using current version", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: true, + }), + ); + }), + ); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeFalsy(); + }); +}); + +it("is dismissed when it was dismissed previously", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: false, + }), + ); + }), + ); + window.localStorage.setItem("dismissedVersion", MockUpdateCheck.version); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeFalsy(); + }); +}); + +it("shows when has permission and is outdated", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: false, + }), + ); + }), + ); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeTruthy(); + }); +}); + +it("shows when has permission and is outdated", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: false, + }), + ); + }), + ); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeTruthy(); + }); + + act(() => { + result.current.dismiss(); + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeFalsy(); + }); + expect(localStorage.getItem("dismissedVersion")).toEqual( + MockUpdateCheck.version, + ); +}); diff --git a/site/src/components/Dashboard/useUpdateCheck.ts b/site/src/components/Dashboard/useUpdateCheck.ts new file mode 100644 index 0000000000000..7089d87914591 --- /dev/null +++ b/site/src/components/Dashboard/useUpdateCheck.ts @@ -0,0 +1,45 @@ +import { updateCheck } from "api/queries/updateCheck"; +import { useMemo, useState } from "react"; +import { useQuery } from "react-query"; + +export const useUpdateCheck = (enabled: boolean) => { + const [dismissedVersion, setDismissedVersion] = useState(() => + getDismissedVersionOnLocal(), + ); + const updateCheckQuery = useQuery({ + ...updateCheck(), + enabled, + }); + + const isVisible: boolean = useMemo(() => { + if (!updateCheckQuery.data) { + return false; + } + + const isNotDismissed = dismissedVersion !== updateCheckQuery.data.version; + const isOutdated = !updateCheckQuery.data.current; + return isNotDismissed && isOutdated ? true : false; + }, [dismissedVersion, updateCheckQuery.data]); + + const dismiss = () => { + if (!updateCheckQuery.data) { + return; + } + setDismissedVersion(updateCheckQuery.data.version); + saveDismissedVersionOnLocal(updateCheckQuery.data.version); + }; + + return { + isVisible, + dismiss, + data: updateCheckQuery.data, + }; +}; + +const saveDismissedVersionOnLocal = (version: string): void => { + window.localStorage.setItem("dismissedVersion", version); +}; + +const getDismissedVersionOnLocal = (): string | undefined => { + return localStorage.getItem("dismissedVersion") ?? undefined; +}; diff --git a/site/src/xServices/updateCheck/updateCheckXService.test.ts b/site/src/xServices/updateCheck/updateCheckXService.test.ts deleted file mode 100644 index 0da61689550fc..0000000000000 --- a/site/src/xServices/updateCheck/updateCheckXService.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { waitFor } from "@testing-library/react"; -import { MockPermissions, MockUpdateCheck } from "testHelpers/entities"; -import { interpret } from "xstate"; -import { - clearDismissedVersionOnLocal, - getDismissedVersionOnLocal, - saveDismissedVersionOnLocal, - updateCheckMachine, -} from "./updateCheckXService"; - -describe("updateCheckMachine", () => { - beforeEach(() => { - clearDismissedVersionOnLocal(); - }); - - it("is dismissed when does not have permission to see it", () => { - const machine = updateCheckMachine.withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: false, - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - - it("is dismissed when it is already using current version", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: true, - }), - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - - await waitFor(() => { - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - }); - - it("is dismissed when it was dismissed previously", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: false, - }), - }, - }); - - saveDismissedVersionOnLocal(MockUpdateCheck.version); - const updateCheckService = interpret(machine); - updateCheckService.start(); - - await waitFor(() => { - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - }); - - it("shows when has permission and is outdated", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: false, - }), - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - - await waitFor(() => { - expect(updateCheckService.state.matches("show")).toBeTruthy(); - }); - }); - - it("it is dismissed when the DISMISS event happens", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: false, - }), - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - await waitFor(() => { - expect(updateCheckService.state.matches("show")).toBeTruthy(); - }); - - updateCheckService.send("DISMISS"); - await waitFor(() => { - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - expect(getDismissedVersionOnLocal()).toEqual(MockUpdateCheck.version); - }); -}); diff --git a/site/src/xServices/updateCheck/updateCheckXService.ts b/site/src/xServices/updateCheck/updateCheckXService.ts deleted file mode 100644 index 099c8501b320f..0000000000000 --- a/site/src/xServices/updateCheck/updateCheckXService.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { assign, createMachine } from "xstate"; -import { getUpdateCheck } from "api/api"; -import { AuthorizationResponse, UpdateCheckResponse } from "api/typesGenerated"; -import { checks, Permissions } from "components/AuthProvider/permissions"; - -export interface UpdateCheckContext { - permissions: Permissions; - updateCheck?: UpdateCheckResponse; - error?: unknown; -} - -export type UpdateCheckEvent = { type: "DISMISS" }; - -export const updateCheckMachine = createMachine( - { - id: "updateCheckState", - predictableActionArguments: true, - tsTypes: {} as import("./updateCheckXService.typegen").Typegen0, - schema: { - context: {} as UpdateCheckContext, - events: {} as UpdateCheckEvent, - services: {} as { - checkPermissions: { - data: AuthorizationResponse; - }; - getUpdateCheck: { - data: UpdateCheckResponse; - }; - }, - }, - initial: "checkingPermissions", - states: { - checkingPermissions: { - always: [ - { - target: "fetchingUpdateCheck", - cond: "canViewUpdateCheck", - }, - { - target: "dismissed", - }, - ], - }, - fetchingUpdateCheck: { - invoke: { - src: "getUpdateCheck", - id: "getUpdateCheck", - onDone: [ - { - actions: ["assignUpdateCheck"], - target: "show", - cond: "shouldShowUpdateCheck", - }, - { - target: "dismissed", - }, - ], - onError: [ - { - actions: ["assignError"], - target: "dismissed", - }, - ], - }, - }, - show: { - on: { - DISMISS: { - actions: ["setDismissedVersion"], - target: "dismissed", - }, - }, - }, - dismissed: { - type: "final", - }, - }, - }, - { - services: { - // For some reason, when passing values directly, jest.spy does not work. - getUpdateCheck: () => getUpdateCheck(), - }, - actions: { - assignUpdateCheck: assign({ - updateCheck: (_, event) => event.data, - }), - assignError: assign({ - error: (_, event) => event.data, - }), - setDismissedVersion: ({ updateCheck }) => { - if (!updateCheck) { - throw new Error("Update check is not set"); - } - - saveDismissedVersionOnLocal(updateCheck.version); - }, - }, - guards: { - canViewUpdateCheck: ({ permissions }) => - permissions[checks.viewUpdateCheck] || false, - shouldShowUpdateCheck: (_, { data }) => { - const isNotDismissed = getDismissedVersionOnLocal() !== data.version; - const isOutdated = !data.current; - return isNotDismissed && isOutdated; - }, - }, - }, -); - -// Exporting to be used in the tests -export const saveDismissedVersionOnLocal = (version: string): void => { - window.localStorage.setItem("dismissedVersion", version); -}; - -export const getDismissedVersionOnLocal = (): string | undefined => { - return localStorage.getItem("dismissedVersion") ?? undefined; -}; - -export const clearDismissedVersionOnLocal = (): void => { - localStorage.removeItem("dismissedVersion"); -};