+ {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"