diff --git a/site/package.json b/site/package.json index 3556f7542e..02493c98dc 100644 --- a/site/package.json +++ b/site/package.json @@ -29,6 +29,7 @@ "@material-ui/core": "4.9.4", "@material-ui/icons": "4.5.1", "@material-ui/lab": "4.0.0-alpha.42", + "@testing-library/react-hooks": "8.0.0", "@xstate/inspect": "0.6.5", "@xstate/react": "3.0.0", "axios": "0.26.1", diff --git a/site/src/app.tsx b/site/src/app.tsx index 607602f043..e1fcf6d1d3 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -4,6 +4,7 @@ import React from "react" import { BrowserRouter as Router } from "react-router-dom" import { SWRConfig } from "swr" import { AppRouter } from "./AppRouter" +import { GlobalSnackbar } from "./components/Snackbar/GlobalSnackbar" import { light } from "./theme" import "./theme/global-fonts" import { XServiceProvider } from "./xServices/StateContext" @@ -33,6 +34,7 @@ export const App: React.FC = () => { + diff --git a/site/src/components/Icons/ErrorIcon.tsx b/site/src/components/Icons/ErrorIcon.tsx new file mode 100644 index 0000000000..591ae2d9ea --- /dev/null +++ b/site/src/components/Icons/ErrorIcon.tsx @@ -0,0 +1,13 @@ +import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon" +import React from "react" + +export const ErrorIcon = (props: SvgIconProps): JSX.Element => ( + + + +) diff --git a/site/src/components/Snackbar/EnterpriseSnackbar.stories.tsx b/site/src/components/Snackbar/EnterpriseSnackbar.stories.tsx new file mode 100644 index 0000000000..981c64667c --- /dev/null +++ b/site/src/components/Snackbar/EnterpriseSnackbar.stories.tsx @@ -0,0 +1,24 @@ +import { Story } from "@storybook/react" +import React from "react" +import { EnterpriseSnackbar, EnterpriseSnackbarProps } from "./EnterpriseSnackbar" + +export default { + title: "Snackbar/EnterpriseSnackbar", + component: EnterpriseSnackbar, +} + +const Template: Story = (args: EnterpriseSnackbarProps) => + +export const Error = Template.bind({}) +Error.args = { + variant: "error", + open: true, + message: "Oops, something wrong happened.", +} + +export const Info = Template.bind({}) +Info.args = { + variant: "info", + open: true, + message: "Hey, something happened.", +} diff --git a/site/src/components/Snackbar/EnterpriseSnackbar.tsx b/site/src/components/Snackbar/EnterpriseSnackbar.tsx new file mode 100644 index 0000000000..b435e47778 --- /dev/null +++ b/site/src/components/Snackbar/EnterpriseSnackbar.tsx @@ -0,0 +1,101 @@ +import IconButton from "@material-ui/core/IconButton" +import Snackbar, { SnackbarProps as MuiSnackbarProps } from "@material-ui/core/Snackbar" +import { makeStyles } from "@material-ui/core/styles" +import CloseIcon from "@material-ui/icons/Close" +import React from "react" +import { combineClasses } from "../../util/combineClasses" + +type EnterpriseSnackbarVariant = "error" | "info" + +export interface EnterpriseSnackbarProps extends MuiSnackbarProps { + /** Called when the snackbar should close, either from timeout or clicking close */ + onClose: () => void + /** Variant of snackbar, for theming */ + variant?: EnterpriseSnackbarVariant +} + +/** + * Wrapper around Material UI's Snackbar component, provides pre-configured + * themes and convenience props. Coder UI's Snackbars require a close handler, + * since they always render a close button. + * + * Snackbars do _not_ automatically appear in the top-level position when + * rendered, you'll need to use ReactDom portals or the Material UI Portal + * component for that. + * + * See original component's Material UI documentation here: https://material-ui.com/components/snackbars/ + */ +export const EnterpriseSnackbar: React.FC = ({ + onClose, + variant = "info", + ContentProps = {}, + action, + ...rest +}) => { + const styles = useStyles() + + return ( + + {action} + + + + + } + ContentProps={{ + ...ContentProps, + className: combineClasses({ + [styles.snackbarContent]: true, + [styles.snackbarContentInfo]: variant === "info", + [styles.snackbarContentError]: variant === "error", + }), + }} + onClose={onClose} + /> + ) +} + +const useStyles = makeStyles((theme) => ({ + actionWrapper: { + display: "flex", + alignItems: "center", + }, + iconButton: { + padding: 0, + }, + closeIcon: { + width: 25, + height: 25, + color: theme.palette.info.contrastText, + }, + closeIconError: { + width: 25, + height: 25, + color: theme.palette.error.contrastText, + }, + snackbarContent: { + borderLeft: `4px solid ${theme.palette.primary.main}`, + borderRadius: 0, + padding: `${theme.spacing(1)}px ${theme.spacing(3)}px ${theme.spacing(1)}px ${theme.spacing(2)}px`, + boxShadow: theme.shadows[6], + alignItems: "inherit", + }, + snackbarContentInfo: { + backgroundColor: theme.palette.info.main, + // Use primary color as a highlight + borderLeftColor: theme.palette.primary.main, + color: theme.palette.info.contrastText, + }, + snackbarContentError: { + backgroundColor: theme.palette.error.dark, + borderLeftColor: theme.palette.error.main, + color: theme.palette.error.contrastText, + }, +})) diff --git a/site/src/components/Snackbar/GlobalSnackbar.tsx b/site/src/components/Snackbar/GlobalSnackbar.tsx new file mode 100644 index 0000000000..cbaa70330c --- /dev/null +++ b/site/src/components/Snackbar/GlobalSnackbar.tsx @@ -0,0 +1,109 @@ +import { makeStyles } from "@material-ui/core/styles" +import React, { useCallback, useState } from "react" +import { + AdditionalMessage, + isNotificationList, + isNotificationText, + isNotificationTextPrefixed, + MsgType, + NotificationMsg, + SnackbarEventType, +} from "." +import { useCustomEvent } from "../../hooks/events" +import { CustomEventListener } from "../../util/events" +import { ErrorIcon } from "../Icons/ErrorIcon" +import { Typography } from "../Typography/Typography" +import { EnterpriseSnackbar } from "./EnterpriseSnackbar" + +export const GlobalSnackbar: React.FC = () => { + const styles = useStyles() + const [open, setOpen] = useState(false) + const [notification, setNotification] = useState() + + const handleNotification = useCallback>((event) => { + setNotification(event.detail) + setOpen(true) + }, []) + + useCustomEvent(SnackbarEventType, handleNotification) + + const renderAdditionalMessage = (msg: AdditionalMessage, idx: number) => { + if (isNotificationText(msg)) { + return ( + + {msg} + + ) + } else if (isNotificationTextPrefixed(msg)) { + return ( + + {msg.prefix}: {msg.text} + + ) + } else if (isNotificationList(msg)) { + return ( +
    + {msg.map((item, idx) => ( +
  • + + {item} + +
  • + ))} +
+ ) + } + return null + } + + if (!notification) { + return null + } + + return ( + + {notification.msgType === MsgType.Error && } +
+ + {notification.msg} + + {notification.additionalMsgs && notification.additionalMsgs.map(renderAdditionalMessage)} +
+ + } + onClose={() => setOpen(false)} + autoHideDuration={notification.msgType === MsgType.Error ? 22000 : 6000} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + /> + ) +} + +const useStyles = makeStyles((theme) => ({ + list: { + paddingLeft: 0, + }, + messageWrapper: { + display: "flex", + }, + message: { + maxWidth: 670, + }, + messageTitle: { + fontSize: 14, + fontWeight: 600, + }, + messageSubtitle: { + marginTop: theme.spacing(1.5), + }, + errorIcon: { + color: theme.palette.error.contrastText, + marginRight: theme.spacing(2), + }, +})) diff --git a/site/src/components/Snackbar/index.test.ts b/site/src/components/Snackbar/index.test.ts new file mode 100644 index 0000000000..9d7c9999a5 --- /dev/null +++ b/site/src/components/Snackbar/index.test.ts @@ -0,0 +1,79 @@ +import { displaySuccess, isNotificationTextPrefixed, MsgType, NotificationMsg } from "./index" + +describe("Snackbar", () => { + describe("isNotificationTextPrefixed", () => { + // Regression test for case found in #10436 + it("does not crash on null values", () => { + // Given + const msg = null + + // When + const isTextPrefixed = isNotificationTextPrefixed(msg) + + // Then + expect(isTextPrefixed).toBe(false) + }) + }) + + describe("displaySuccess", () => { + const originalWindowDispatchEvent = window.dispatchEvent + let dispatchEventMock: jest.Mock + + // Helper function to extract the notification event + // that was sent to `dispatchEvent`. This lets us validate + // the contents of the notification event are what we expect. + const extractNotificationEvent = (dispatchEventMock: jest.Mock): NotificationMsg => { + // The jest mock API isn't typesafe - but we know in our usage that + // this will always be a `NotificationMsg`. + + // calls[0] is the first call made to the mock (this is reset in `beforeEach`) + // calls[0][0] is the first argument of the first call + // calls[0][0].detail is the 'detail' argument passed to the `CustomEvent` - + // this is the `NotificationMsg` object that gets sent to `dispatchEvent` + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return dispatchEventMock.mock.calls[0][0].detail as NotificationMsg + } + + beforeEach(() => { + dispatchEventMock = jest.fn() + window.dispatchEvent = dispatchEventMock + }) + + afterEach(() => { + window.dispatchEvent = originalWindowDispatchEvent + }) + + it("can be called with only a title", () => { + // Given + const expected: NotificationMsg = { + msgType: MsgType.Success, + msg: "Test", + additionalMsgs: undefined, + } + + // When + displaySuccess("Test") + + // Then + expect(dispatchEventMock).toBeCalledTimes(1) + expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(expected) + }) + + it("can be called with a title and additional message", () => { + // Given + const expected: NotificationMsg = { + msgType: MsgType.Success, + msg: "Test", + additionalMsgs: ["additional message"], + } + + // When + displaySuccess("Test", "additional message") + + // Then + expect(dispatchEventMock).toBeCalledTimes(1) + expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(expected) + }) + }) +}) diff --git a/site/src/components/Snackbar/index.ts b/site/src/components/Snackbar/index.ts new file mode 100644 index 0000000000..52de0ddab1 --- /dev/null +++ b/site/src/components/Snackbar/index.ts @@ -0,0 +1,68 @@ +import { dispatchCustomEvent } from "../../util/events" + +/////////////////////////////////////////////////////////////////////////////// +// Notification Component +/////////////////////////////////////////////////////////////////////////////// + +export { GlobalSnackbar } from "./GlobalSnackbar" + +/////////////////////////////////////////////////////////////////////////////// +// Notification Types +/////////////////////////////////////////////////////////////////////////////// + +export enum MsgType { + Info, + Success, + Error, +} + +/** + * Display a prefixed paragraph inside a notification. + */ +export type NotificationTextPrefixed = { + prefix: string + text: string +} + +export type AdditionalMessage = NotificationTextPrefixed | string[] | string + +export const isNotificationText = (msg: AdditionalMessage): msg is string => { + return !Array.isArray(msg) && typeof msg === "string" +} + +export const isNotificationTextPrefixed = (msg: AdditionalMessage | null): msg is NotificationTextPrefixed => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return typeof (msg as NotificationTextPrefixed)?.prefix !== "undefined" +} + +export const isNotificationList = (msg: AdditionalMessage): msg is string[] => { + return Array.isArray(msg) +} + +export interface NotificationMsg { + msgType: MsgType + msg: string + additionalMsgs?: AdditionalMessage[] +} + +export const SnackbarEventType = "coder:notification" + +/////////////////////////////////////////////////////////////////////////////// +// Notification Functions +/////////////////////////////////////////////////////////////////////////////// + +function dispatchNotificationEvent(msgType: MsgType, msg: string, additionalMsgs?: AdditionalMessage[]) { + dispatchCustomEvent(SnackbarEventType, { + msgType, + msg, + additionalMsgs, + }) +} + +export const displayMsg = (msg: string, additionalMsg?: string): void => { + dispatchNotificationEvent(MsgType.Info, msg, additionalMsg ? [additionalMsg] : undefined) +} + +export const displaySuccess = (msg: string, additionalMsg?: string): void => { + dispatchNotificationEvent(MsgType.Success, msg, additionalMsg ? [additionalMsg] : undefined) +} diff --git a/site/src/hooks/events.test.ts b/site/src/hooks/events.test.ts new file mode 100644 index 0000000000..8cb166806c --- /dev/null +++ b/site/src/hooks/events.test.ts @@ -0,0 +1,17 @@ +import { waitFor } from "@testing-library/react" +import { renderHook } from "@testing-library/react-hooks" +import { dispatchCustomEvent } from "../util/events" +import { useCustomEvent } from "./events" + +describe("useCustomEvent", () => { + it("should listem a custom event", async () => { + const callback = jest.fn() + const detail = { title: "Test event" } + renderHook(() => useCustomEvent("testEvent", callback)) + dispatchCustomEvent("testEvent", detail) + await waitFor(() => { + expect(callback).toBeCalledTimes(1) + expect(callback.mock.calls[0][0].detail).toBe(detail) + }) + }) +}) diff --git a/site/src/hooks/events.ts b/site/src/hooks/events.ts new file mode 100644 index 0000000000..f24c37f278 --- /dev/null +++ b/site/src/hooks/events.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react" +import { CustomEventListener } from "../util/events" + +/** + * Handles a custom event with descriptive type information. + * + * @param eventType a unique name defining the type of the event. e.g. `"coder:workspace:ready"` + * @param listener a custom event listener. + */ +export const useCustomEvent = (eventType: E, listener: CustomEventListener): void => { + useEffect(() => { + const handleEvent: CustomEventListener = (event) => { + listener(event) + } + window.addEventListener(eventType, handleEvent as EventListener) + + return () => { + window.removeEventListener(eventType, handleEvent as EventListener) + } + }, [eventType, listener]) +} diff --git a/site/src/util/events.test.ts b/site/src/util/events.test.ts new file mode 100644 index 0000000000..805c1f8759 --- /dev/null +++ b/site/src/util/events.test.ts @@ -0,0 +1,18 @@ +import { dispatchCustomEvent, isCustomEvent } from "./events" + +describe("events", () => { + describe("dispatchCustomEvent", () => { + it("dispatch a custom event", (done) => { + const eventDetail = { title: "Event title" } + + window.addEventListener("eventType", (event) => { + if (isCustomEvent(event)) { + expect(event.detail).toEqual(eventDetail) + done() + } + }) + + dispatchCustomEvent("eventType", eventDetail) + }) + }) +}) diff --git a/site/src/util/events.ts b/site/src/util/events.ts new file mode 100644 index 0000000000..dcf7a4e68f --- /dev/null +++ b/site/src/util/events.ts @@ -0,0 +1,47 @@ +/** + * Dispatches a custom event with descriptive type information. + * + * @param eventType a unique name defining the type of the event. e.g. `"coder:workspace:ready"` + * @param detail an optional payload accessible to an event listener. + * @param target an optional event target. Defaults to current `window`. + */ +export const dispatchCustomEvent = ( + eventType: string, + detail?: D, + target: EventTarget = window, +): CustomEvent => { + const event = new CustomEvent(eventType, { detail }) + + target.dispatchEvent(event) + + return event +} +/** Annotates a custom event listener with descriptive type information. */ +export type CustomEventListener = (event: CustomEvent) => void + +/** + * An event listener is a function an Event-like object. + * + * Especially helpful when using `element.addEventListener` with a predeclared function. + * e.g. + * + * ```ts + * const handleClick = AnnotatedEventListener = (event) => { + * event.preventDefault() + * } + * + * window.addEventListener('click', handleClick) + * window.removeEventListener('click', handleClick) + * ``` + */ +export type AnnotatedEventListener = (event: E) => void + +/** + * Determines if an Event object is a CustomEvent. + * + * @remark this is especially necessary when an event originates from an iframe + * as `instanceof` will not match against another origin's prototype chain. + */ +export const isCustomEvent = (event: CustomEvent | Event): event is CustomEvent => { + return !!(event as CustomEvent).detail +} diff --git a/site/yarn.lock b/site/yarn.lock index efb485d66a..a0387d8370 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -2727,6 +2727,14 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/react-hooks@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz#7d0164bffce4647f506039de0a97f6fcbd20f4bf" + integrity sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@12.1.4": version "12.1.4" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" @@ -11421,6 +11429,13 @@ react-element-to-jsx-string@^14.3.4: is-plain-object "5.0.0" react-is "17.0.2" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"