refactor(site): Show update notification as snackbar (#7546)

This commit is contained in:
Bruno Quaresma
2023-05-17 13:56:26 -03:00
committed by GitHub
parent a7f14f89e3
commit 12f87cb98d
7 changed files with 114 additions and 103 deletions
+28
View File
@@ -166,3 +166,31 @@ user.click(screen.getByRole("button"))
const form = screen.getByTestId("form")
user.click(within(form).getByRole("button"))
```
#### `jest.spyOn` with the API is not working
For some unknown reason, we figured out the `jest.spyOn` is not able to mock the API function when they are passed directly into the services XState machine configuration.
❌ Does not work
```ts
import { getUpdateCheck } from "api/api"
createMachine({ ... }, {
services: {
getUpdateCheck,
},
})
```
✅ It works
```ts
import { getUpdateCheck } from "api/api"
createMachine({ ... }, {
services: {
getUpdateCheck: () => getUpdateCheck(),
},
})
```
@@ -0,0 +1,21 @@
import { Route, Routes } from "react-router-dom"
import { renderWithAuth } from "testHelpers/renderHelpers"
import { DashboardLayout } from "./DashboardLayout"
import * as API from "api/api"
import { screen } from "@testing-library/react"
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",
})
renderWithAuth(
<Routes>
<Route element={<DashboardLayout />}>
<Route element={<h1>Test page</h1>} />
</Route>
</Routes>,
)
await screen.findByTestId("update-check-snackbar")
})
@@ -1,18 +1,20 @@
import { makeStyles } from "@mui/styles"
import { useMachine } from "@xstate/react"
import { UpdateCheckResponse } from "api/typesGenerated"
import { DeploymentBanner } from "components/DeploymentBanner/DeploymentBanner"
import { LicenseBanner } from "components/LicenseBanner/LicenseBanner"
import { Loader } from "components/Loader/Loader"
import { Margins } from "components/Margins/Margins"
import { ServiceBanner } from "components/ServiceBanner/ServiceBanner"
import { UpdateCheckBanner } from "components/UpdateCheckBanner/UpdateCheckBanner"
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"
import Box from "@mui/material/Box"
import InfoOutlined from "@mui/icons-material/InfoOutlined"
import Button from "@mui/material/Button"
export const DashboardLayout: FC = () => {
const styles = useStyles()
@@ -22,8 +24,7 @@ export const DashboardLayout: FC = () => {
permissions,
},
})
const { error: updateCheckError, updateCheck } = updateCheckState.context
const { updateCheck } = updateCheckState.context
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
return (
@@ -34,20 +35,6 @@ export const DashboardLayout: FC = () => {
<div className={styles.site}>
<Navbar />
{updateCheckState.matches("show") && (
<div className={styles.updateCheckBanner}>
<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 />
@@ -55,27 +42,73 @@ export const DashboardLayout: FC = () => {
</div>
<DeploymentBanner />
<Snackbar
data-testid="update-check-snackbar"
open={updateCheckState.matches("show")}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
ContentProps={{
sx: (theme) => ({
background: theme.palette.background.paper,
color: theme.palette.text.primary,
maxWidth: theme.spacing(55),
flexDirection: "row",
borderColor: theme.palette.info.light,
"& .MuiSnackbarContent-message": {
flex: 1,
},
"& .MuiSnackbarContent-action": {
marginRight: 0,
},
}),
}}
message={
<Box display="flex" gap={2}>
<InfoOutlined
sx={(theme) => ({
fontSize: 16,
height: 20, // 20 is the height of the text line so we can align them
color: theme.palette.info.light,
})}
/>
<Box>
Coder {updateCheck?.version} is now available. View the{" "}
<Link href={updateCheck?.url}>release notes</Link> and{" "}
<Link href="https://coder.com/docs/coder-oss/latest/admin/upgrade">
upgrade instructions
</Link>{" "}
for more information.
</Box>
</Box>
}
action={
<Button
variant="text"
size="small"
onClick={() => updateCheckSend("DISMISS")}
>
Dismiss
</Button>
}
/>
</div>
</>
)
}
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles({
site: {
display: "flex",
minHeight: "100vh",
flexDirection: "column",
},
updateCheckBanner: {
// Add spacing at the top and remove some from the bottom. Removal
// is necessary to avoid a visual jerk when the banner is dismissed.
// It also give a more pleasant distance to the site content when
// the banner is visible.
marginTop: theme.spacing(2),
marginBottom: theme.spacing(-2),
},
siteContent: {
flex: 1,
paddingBottom: dashboardContentBottomPadding, // Add bottom space since we don't use a footer
},
}))
})
@@ -1,25 +0,0 @@
import { ComponentMeta, Story } from "@storybook/react"
import { UpdateCheckBanner, UpdateCheckBannerProps } from "./UpdateCheckBanner"
export default {
title: "components/UpdateCheckBanner",
component: UpdateCheckBanner,
} as ComponentMeta<typeof UpdateCheckBanner>
const Template: Story<UpdateCheckBannerProps> = (args) => (
<UpdateCheckBanner {...args} />
)
export const UpdateAvailable = Template.bind({})
UpdateAvailable.args = {
updateCheck: {
current: false,
version: "v0.12.9",
url: "https://github.com/coder/coder/releases/tag/v0.12.9",
},
}
export const UpdateCheckError = Template.bind({})
UpdateCheckError.args = {
error: new Error("Something went wrong."),
}
@@ -1,47 +0,0 @@
import Link from "@mui/material/Link"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Trans, useTranslation } from "react-i18next"
import * as TypesGen from "api/typesGenerated"
import { FC } from "react"
export interface UpdateCheckBannerProps {
updateCheck: TypesGen.UpdateCheckResponse
error?: unknown
onDismiss: () => void
}
export const UpdateCheckBanner: FC<
React.PropsWithChildren<UpdateCheckBannerProps>
> = ({ updateCheck, error, onDismiss }) => {
const { t } = useTranslation("common")
return (
<AlertBanner
severity={error ? "error" : "info"}
error={error}
onDismiss={onDismiss}
dismissible
>
<>
{error ? (
t("updateCheck.error")
) : (
<div>
<Trans
t={t}
i18nKey="updateCheck.message"
values={{ version: updateCheck.version }}
>
Coder {"{{version}}"} is now available. View the{" "}
<Link href={updateCheck.url}>release notes</Link> and{" "}
<Link href="https://coder.com/docs/coder-oss/latest/admin/upgrade">
upgrade instructions
</Link>{" "}
for more information.
</Trans>
</div>
)}
</>
</AlertBanner>
)
}
+1
View File
@@ -48,6 +48,7 @@ export let dark = createTheme({
dark: colors.green[15],
},
info: {
light: colors.blue[9],
main: colors.blue[11],
dark: colors.blue[15],
contrastText: colors.gray[4],
@@ -78,7 +78,8 @@ export const updateCheckMachine = createMachine(
},
{
services: {
getUpdateCheck,
// For some reason, when passing values directly, jest.spy does not work.
getUpdateCheck: () => getUpdateCheck(),
},
actions: {
assignUpdateCheck: assign({
@@ -101,7 +102,6 @@ export const updateCheckMachine = createMachine(
shouldShowUpdateCheck: (_, { data }) => {
const isNotDismissed = getDismissedVersionOnLocal() !== data.version
const isOutdated = !data.current
return isNotDismissed && isOutdated
},
},