refactor: Refactor update check banner (#5708)

This commit is contained in:
Bruno Quaresma 2023-01-13 13:48:45 -03:00 committed by GitHub
parent d6543c042f
commit de16e29566
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 258 additions and 233 deletions

View File

@ -1,7 +1,5 @@
import CssBaseline from "@material-ui/core/CssBaseline" import CssBaseline from "@material-ui/core/CssBaseline"
import ThemeProvider from "@material-ui/styles/ThemeProvider" import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { LicenseBanner } from "components/LicenseBanner/LicenseBanner"
import { ServiceBanner } from "components/ServiceBanner/ServiceBanner"
import { FC } from "react" import { FC } from "react"
import { HelmetProvider } from "react-helmet-async" import { HelmetProvider } from "react-helmet-async"
import { BrowserRouter as Router } from "react-router-dom" import { BrowserRouter as Router } from "react-router-dom"
@ -20,8 +18,6 @@ export const App: FC = () => {
<CssBaseline /> <CssBaseline />
<ErrorBoundary> <ErrorBoundary>
<XServiceProvider> <XServiceProvider>
<ServiceBanner />
<LicenseBanner />
<AppRouter /> <AppRouter />
<GlobalSnackbar /> <GlobalSnackbar />
</XServiceProvider> </XServiceProvider>

View File

@ -1,49 +1,56 @@
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import { useActor } from "@xstate/react" import { useMachine } from "@xstate/react"
import { Loader } from "components/Loader/Loader" import { Loader } from "components/Loader/Loader"
import { FC, Suspense, useContext, useEffect } from "react" import { FC, Suspense } from "react"
import { XServiceContext } from "../../xServices/StateContext"
import { Navbar } from "../Navbar/Navbar" import { Navbar } from "../Navbar/Navbar"
import { UpdateCheckBanner } from "components/UpdateCheckBanner/UpdateCheckBanner" import { UpdateCheckBanner } from "components/UpdateCheckBanner/UpdateCheckBanner"
import { Margins } from "components/Margins/Margins" import { Margins } from "components/Margins/Margins"
import { Outlet } from "react-router-dom" import { Outlet } from "react-router-dom"
import { LicenseBanner } from "components/LicenseBanner/LicenseBanner"
import { ServiceBanner } from "components/ServiceBanner/ServiceBanner"
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
import { usePermissions } from "hooks/usePermissions"
import { UpdateCheckResponse } from "api/typesGenerated"
export const DashboardLayout: FC = () => { export const DashboardLayout: FC = () => {
const styles = useStyles() const styles = useStyles()
const xServices = useContext(XServiceContext) const permissions = usePermissions()
const [authState] = useActor(xServices.authXService) const [updateCheckState, updateCheckSend] = useMachine(updateCheckMachine, {
const [updateCheckState, updateCheckSend] = useActor( context: {
xServices.updateCheckXService, permissions,
) },
})
useEffect(() => { const { error: updateCheckError, updateCheck } = updateCheckState.context
if (authState.matches("signedIn")) {
updateCheckSend("CHECK")
} else {
updateCheckSend("CLEAR")
}
}, [authState, updateCheckSend])
return ( return (
<div className={styles.site}> <>
<Navbar /> <ServiceBanner />
{updateCheckState.context.show && ( <LicenseBanner />
<div className={styles.updateCheckBanner}>
<Margins> <div className={styles.site}>
<UpdateCheckBanner <Navbar />
updateCheck={updateCheckState.context.updateCheck}
error={updateCheckState.context.error} {updateCheckState.matches("show") && (
onDismiss={() => updateCheckSend("DISMISS")} <div className={styles.updateCheckBanner}>
/> <Margins>
</Margins> <UpdateCheckBanner
// We can trust when it is show, the update check is filled
// unfortunately, XState does not has typed state - context yet
updateCheck={updateCheck as UpdateCheckResponse}
error={updateCheckError}
onDismiss={() => updateCheckSend("DISMISS")}
/>
</Margins>
</div>
)}
<div className={styles.siteContent}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</div> </div>
)}
<div className={styles.siteContent}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</div> </div>
</div> </>
) )
} }

View File

@ -1,51 +0,0 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import i18next from "i18next"
import { MockUpdateCheck, render } from "testHelpers/renderHelpers"
import { UpdateCheckBanner } from "./UpdateCheckBanner"
describe("UpdateCheckBanner", () => {
it("shows an update notification when one is available", () => {
const { t } = i18next
render(
<UpdateCheckBanner
updateCheck={{ ...MockUpdateCheck, current: false }}
/>,
)
const updateText = t("updateCheck.message", {
ns: "common",
version: MockUpdateCheck.version,
})
// Message contatins HTML elements so we check it in parts.
for (const text of updateText.split(/<\/?[0-9]+>/)) {
expect(screen.getByText(text, { exact: false })).toBeInTheDocument()
}
expect(screen.getAllByRole("link")[0]).toHaveAttribute(
"href",
MockUpdateCheck.url,
)
})
it("is hidden when dismissed", async () => {
const dismiss = jest.fn()
const { container } = render(
<UpdateCheckBanner
onDismiss={dismiss}
updateCheck={{ ...MockUpdateCheck, current: false }}
/>,
)
fireEvent.click(screen.getByRole("button"))
await waitFor(() => expect(dismiss).toBeCalledTimes(1), { timeout: 2000 })
expect(container.firstChild).toBeNull()
})
it("does not show when up-to-date", async () => {
const { container } = render(
<UpdateCheckBanner updateCheck={{ ...MockUpdateCheck, current: true }} />,
)
expect(container.firstChild).toBeNull()
})
})

View File

@ -2,12 +2,12 @@ import Link from "@material-ui/core/Link"
import { AlertBanner } from "components/AlertBanner/AlertBanner" import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Trans, useTranslation } from "react-i18next" import { Trans, useTranslation } from "react-i18next"
import * as TypesGen from "api/typesGenerated" import * as TypesGen from "api/typesGenerated"
import { FC, useState } from "react" import { FC } from "react"
export interface UpdateCheckBannerProps { export interface UpdateCheckBannerProps {
updateCheck?: TypesGen.UpdateCheckResponse updateCheck: TypesGen.UpdateCheckResponse
error?: Error | unknown error?: unknown
onDismiss?: () => void onDismiss: () => void
} }
export const UpdateCheckBanner: FC< export const UpdateCheckBanner: FC<
@ -15,45 +15,33 @@ export const UpdateCheckBanner: FC<
> = ({ updateCheck, error, onDismiss }) => { > = ({ updateCheck, error, onDismiss }) => {
const { t } = useTranslation("common") const { t } = useTranslation("common")
const isOutdated = updateCheck && !updateCheck.current
const [show, setShow] = useState(error || isOutdated)
const dismiss = () => {
onDismiss && onDismiss()
setShow(false)
}
return ( return (
<> <AlertBanner
{show && ( severity={error ? "error" : "info"}
<AlertBanner error={error}
severity={error ? "error" : "info"} onDismiss={onDismiss}
error={error} dismissible
onDismiss={dismiss} >
dismissible <>
> {error ? (
<> t("updateCheck.error")
{error && <>{t("updateCheck.error")} </>} ) : (
{isOutdated && ( <div>
<div> <Trans
<Trans t={t}
t={t} i18nKey="updateCheck.message"
i18nKey="updateCheck.message" values={{ version: updateCheck.version }}
values={{ version: updateCheck.version }} >
> Coder {"{{version}}"} is now available. View the{" "}
Coder {"{{version}}"} is now available. View the{" "} <Link href={updateCheck.url}>release notes</Link> and{" "}
<Link href={updateCheck.url}>release notes</Link> and{" "} <Link href="https://coder.com/docs/coder-oss/latest/admin/upgrade">
<Link href="https://coder.com/docs/coder-oss/latest/admin/upgrade"> upgrade instructions
upgrade instructions </Link>{" "}
</Link>{" "} for more information.
for more information. </Trans>
</Trans> </div>
</div> )}
)} </>
</> </AlertBanner>
</AlertBanner>
)}
</>
) )
} }

View File

@ -1096,6 +1096,7 @@ export const MockPermissions: Permissions = {
updateUsers: true, updateUsers: true,
viewAuditLog: true, viewAuditLog: true,
viewDeploymentConfig: true, viewDeploymentConfig: true,
viewUpdateCheck: true,
} }
export const MockAppearance: TypesGen.AppearanceConfig = { export const MockAppearance: TypesGen.AppearanceConfig = {

View File

@ -3,7 +3,6 @@ import { createContext, FC, ReactNode } from "react"
import { ActorRefFrom } from "xstate" import { ActorRefFrom } from "xstate"
import { authMachine } from "./auth/authXService" import { authMachine } from "./auth/authXService"
import { buildInfoMachine } from "./buildInfo/buildInfoXService" import { buildInfoMachine } from "./buildInfo/buildInfoXService"
import { updateCheckMachine } from "./updateCheck/updateCheckXService"
import { entitlementsMachine } from "./entitlements/entitlementsXService" import { entitlementsMachine } from "./entitlements/entitlementsXService"
import { appearanceMachine } from "./appearance/appearanceXService" import { appearanceMachine } from "./appearance/appearanceXService"
@ -12,7 +11,6 @@ interface XServiceContextType {
buildInfoXService: ActorRefFrom<typeof buildInfoMachine> buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
entitlementsXService: ActorRefFrom<typeof entitlementsMachine> entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
appearanceXService: ActorRefFrom<typeof appearanceMachine> appearanceXService: ActorRefFrom<typeof appearanceMachine>
updateCheckXService: ActorRefFrom<typeof updateCheckMachine>
} }
/** /**
@ -33,7 +31,6 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
buildInfoXService: useInterpret(buildInfoMachine), buildInfoXService: useInterpret(buildInfoMachine),
entitlementsXService: useInterpret(entitlementsMachine), entitlementsXService: useInterpret(entitlementsMachine),
appearanceXService: useInterpret(appearanceMachine), appearanceXService: useInterpret(appearanceMachine),
updateCheckXService: useInterpret(updateCheckMachine),
}} }}
> >
{children} {children}

View File

@ -16,6 +16,7 @@ export const checks = {
viewAuditLog: "viewAuditLog", viewAuditLog: "viewAuditLog",
viewDeploymentConfig: "viewDeploymentConfig", viewDeploymentConfig: "viewDeploymentConfig",
createGroup: "createGroup", createGroup: "createGroup",
viewUpdateCheck: "viewUpdateCheck",
} as const } as const
export const permissionsToCheck = { export const permissionsToCheck = {
@ -67,6 +68,12 @@ export const permissionsToCheck = {
}, },
action: "create", action: "create",
}, },
[checks.viewUpdateCheck]: {
object: {
resource_type: "update_check",
},
action: "read",
},
} as const } as const
export type Permissions = Record<keyof typeof permissionsToCheck, boolean> export type Permissions = Record<keyof typeof permissionsToCheck, boolean>

View File

@ -0,0 +1,138 @@
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)
})
})
})

View File

@ -1,33 +1,15 @@
import { assign, createMachine } from "xstate" import { assign, createMachine } from "xstate"
import { checkAuthorization, getUpdateCheck } from "api/api" import { getUpdateCheck } from "api/api"
import { AuthorizationResponse, UpdateCheckResponse } from "api/typesGenerated" import { AuthorizationResponse, UpdateCheckResponse } from "api/typesGenerated"
import { checks, Permissions } from "xServices/auth/authXService"
export const checks = {
viewUpdateCheck: "viewUpdateCheck",
}
export const permissionsToCheck = {
[checks.viewUpdateCheck]: {
object: {
resource_type: "update_check",
},
action: "read",
},
}
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>
export interface UpdateCheckContext { export interface UpdateCheckContext {
show: boolean permissions: Permissions
updateCheck?: UpdateCheckResponse updateCheck?: UpdateCheckResponse
permissions?: Permissions
error?: Error | unknown error?: Error | unknown
} }
export type UpdateCheckEvent = export type UpdateCheckEvent = { type: "DISMISS" }
| { type: "CHECK" }
| { type: "CLEAR" }
| { type: "DISMISS" }
export const updateCheckMachine = createMachine( export const updateCheckMachine = createMachine(
{ {
@ -46,36 +28,8 @@ export const updateCheckMachine = createMachine(
} }
}, },
}, },
context: { initial: "checkingPermissions",
show: false,
},
initial: "idle",
states: { states: {
idle: {
on: {
CHECK: {
target: "fetchingPermissions",
},
},
},
fetchingPermissions: {
invoke: {
src: "checkPermissions",
id: "checkPermissions",
onDone: [
{
actions: ["assignPermissions"],
target: "checkingPermissions",
},
],
onError: [
{
actions: ["assignError"],
target: "show",
},
],
},
},
checkingPermissions: { checkingPermissions: {
always: [ always: [
{ {
@ -83,8 +37,7 @@ export const updateCheckMachine = createMachine(
cond: "canViewUpdateCheck", cond: "canViewUpdateCheck",
}, },
{ {
target: "dismissOrClear", target: "dismissed",
cond: "canNotViewUpdateCheck",
}, },
], ],
}, },
@ -94,36 +47,28 @@ export const updateCheckMachine = createMachine(
id: "getUpdateCheck", id: "getUpdateCheck",
onDone: [ onDone: [
{ {
actions: ["assignUpdateCheck", "clearError"], actions: ["assignUpdateCheck"],
target: "show", target: "show",
cond: "shouldShowUpdateCheck",
},
{
target: "dismissed",
}, },
], ],
onError: [ onError: [
{ {
actions: ["assignError", "clearUpdateCheck"], actions: ["assignError"],
target: "show", target: "dismissed",
}, },
], ],
}, },
}, },
show: { show: {
entry: "assignShow",
always: [
{
target: "dismissOrClear",
},
],
},
dismissOrClear: {
on: { on: {
DISMISS: { DISMISS: {
actions: ["assignHide", "setDismissedVersion"], actions: ["setDismissedVersion"],
target: "dismissed", target: "dismissed",
}, },
CLEAR: {
actions: ["clearUpdateCheck", "clearError", "assignHide"],
target: "idle",
},
}, },
}, },
dismissed: { dismissed: {
@ -133,48 +78,45 @@ export const updateCheckMachine = createMachine(
}, },
{ {
services: { services: {
checkPermissions: async () => getUpdateCheck,
checkAuthorization({ checks: permissionsToCheck }),
getUpdateCheck: getUpdateCheck,
}, },
actions: { actions: {
assignPermissions: assign({
permissions: (_, event) => event.data as Permissions,
}),
assignShow: assign((context) => ({
show:
localStorage.getItem("dismissedVersion") !==
context.updateCheck?.version,
})),
assignHide: assign({
show: false,
}),
assignUpdateCheck: assign({ assignUpdateCheck: assign({
updateCheck: (_, event) => event.data, updateCheck: (_, event) => event.data,
}), }),
clearUpdateCheck: assign((context) => ({
...context,
updateCheck: undefined,
})),
assignError: assign({ assignError: assign({
error: (_, event) => event.data, error: (_, event) => event.data,
}), }),
clearError: assign((context) => ({ setDismissedVersion: ({ updateCheck }) => {
...context, if (!updateCheck) {
error: undefined, throw new Error("Update check is not set")
})),
setDismissedVersion: (context) => {
if (context.updateCheck?.version) {
// We use localStorage to ensure users who have dismissed the UpdateCheckBanner are not plagued by its reappearance on page reload
localStorage.setItem("dismissedVersion", context.updateCheck.version)
} }
saveDismissedVersionOnLocal(updateCheck.version)
}, },
}, },
guards: { guards: {
canViewUpdateCheck: (context) => canViewUpdateCheck: ({ permissions }) =>
context.permissions?.[checks.viewUpdateCheck] || false, permissions[checks.viewUpdateCheck] || false,
canNotViewUpdateCheck: (context) => shouldShowUpdateCheck: (_, { data }) => {
!context.permissions?.[checks.viewUpdateCheck], 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")
}