mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
refactor(site): Show update notification as snackbar (#7546)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user