mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: add global notification component (#996)
* feat: add global notification component * fix: update yarn.lock * fix: pin @testing-library/react-hooks * fix: update yarn.lock * refactor: remove displayError
This commit is contained in:
@ -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",
|
||||
|
@ -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 = () => {
|
||||
<ThemeProvider theme={light}>
|
||||
<CssBaseline />
|
||||
<AppRouter />
|
||||
<GlobalSnackbar />
|
||||
</ThemeProvider>
|
||||
</XServiceProvider>
|
||||
</SWRConfig>
|
||||
|
13
site/src/components/Icons/ErrorIcon.tsx
Normal file
13
site/src/components/Icons/ErrorIcon.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
|
||||
import React from "react"
|
||||
|
||||
export const ErrorIcon = (props: SvgIconProps): JSX.Element => (
|
||||
<SvgIcon {...props} viewBox="0 0 24 24">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.59354 2.26627C7.76403 2.09578 7.99526 2 8.23637 2H15.7636C16.0047 2 16.236 2.09578 16.4065 2.26627L21.7337 7.59354C21.9042 7.76403 22 7.99526 22 8.23636V15.7636C22 16.0047 21.9042 16.236 21.7337 16.4065L16.4065 21.7337C16.236 21.9042 16.0047 22 15.7636 22H8.23637C7.99526 22 7.76403 21.9042 7.59354 21.7337L2.26627 16.4065C2.09578 16.236 2 16.0047 2 15.7636V8.23636C2 7.99526 2.09578 7.76403 2.26627 7.59354L7.59354 2.26627ZM8.61293 3.81818L3.81819 8.61292V15.3871L8.61293 20.1818H15.3871L20.1818 15.3871V8.61292L15.3871 3.81818H8.61293ZM12 7.45455C12.5021 7.45455 12.9091 7.86156 12.9091 8.36364V12C12.9091 12.5021 12.5021 12.9091 12 12.9091C11.4979 12.9091 11.0909 12.5021 11.0909 12V8.36364C11.0909 7.86156 11.4979 7.45455 12 7.45455ZM12 14.7273C11.4979 14.7273 11.0909 15.1343 11.0909 15.6364C11.0909 16.1384 11.4979 16.5455 12 16.5455H12.0091C12.5112 16.5455 12.9182 16.1384 12.9182 15.6364C12.9182 15.1343 12.5112 14.7273 12.0091 14.7273H12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
)
|
24
site/src/components/Snackbar/EnterpriseSnackbar.stories.tsx
Normal file
24
site/src/components/Snackbar/EnterpriseSnackbar.stories.tsx
Normal file
@ -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<EnterpriseSnackbarProps> = (args: EnterpriseSnackbarProps) => <EnterpriseSnackbar {...args} />
|
||||
|
||||
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.",
|
||||
}
|
101
site/src/components/Snackbar/EnterpriseSnackbar.tsx
Normal file
101
site/src/components/Snackbar/EnterpriseSnackbar.tsx
Normal file
@ -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<EnterpriseSnackbarProps> = ({
|
||||
onClose,
|
||||
variant = "info",
|
||||
ContentProps = {},
|
||||
action,
|
||||
...rest
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
{...rest}
|
||||
action={
|
||||
<div className={styles.actionWrapper}>
|
||||
{action}
|
||||
<IconButton onClick={onClose} className={styles.iconButton}>
|
||||
<CloseIcon className={variant === "info" ? styles.closeIcon : styles.closeIconError} />
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
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,
|
||||
},
|
||||
}))
|
109
site/src/components/Snackbar/GlobalSnackbar.tsx
Normal file
109
site/src/components/Snackbar/GlobalSnackbar.tsx
Normal file
@ -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<boolean>(false)
|
||||
const [notification, setNotification] = useState<NotificationMsg>()
|
||||
|
||||
const handleNotification = useCallback<CustomEventListener<NotificationMsg>>((event) => {
|
||||
setNotification(event.detail)
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
useCustomEvent(SnackbarEventType, handleNotification)
|
||||
|
||||
const renderAdditionalMessage = (msg: AdditionalMessage, idx: number) => {
|
||||
if (isNotificationText(msg)) {
|
||||
return (
|
||||
<Typography key={idx} gutterBottom variant="body2" className={styles.messageSubtitle}>
|
||||
{msg}
|
||||
</Typography>
|
||||
)
|
||||
} else if (isNotificationTextPrefixed(msg)) {
|
||||
return (
|
||||
<Typography key={idx} gutterBottom variant="body2" className={styles.messageSubtitle}>
|
||||
<strong>{msg.prefix}:</strong> {msg.text}
|
||||
</Typography>
|
||||
)
|
||||
} else if (isNotificationList(msg)) {
|
||||
return (
|
||||
<ul className={styles.list} key={idx}>
|
||||
{msg.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<Typography variant="body2" className={styles.messageSubtitle}>
|
||||
{item}
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!notification) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<EnterpriseSnackbar
|
||||
open={open}
|
||||
variant={notification.msgType === MsgType.Error ? "error" : "info"}
|
||||
message={
|
||||
<div className={styles.messageWrapper}>
|
||||
{notification.msgType === MsgType.Error && <ErrorIcon className={styles.errorIcon} />}
|
||||
<div className={styles.message}>
|
||||
<Typography variant="body1" className={styles.messageTitle}>
|
||||
{notification.msg}
|
||||
</Typography>
|
||||
{notification.additionalMsgs && notification.additionalMsgs.map(renderAdditionalMessage)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
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),
|
||||
},
|
||||
}))
|
79
site/src/components/Snackbar/index.test.ts
Normal file
79
site/src/components/Snackbar/index.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
68
site/src/components/Snackbar/index.ts
Normal file
68
site/src/components/Snackbar/index.ts
Normal file
@ -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<NotificationMsg>(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)
|
||||
}
|
17
site/src/hooks/events.test.ts
Normal file
17
site/src/hooks/events.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
21
site/src/hooks/events.ts
Normal file
21
site/src/hooks/events.ts
Normal file
@ -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 = <T, E extends string = string>(eventType: E, listener: CustomEventListener<T>): void => {
|
||||
useEffect(() => {
|
||||
const handleEvent: CustomEventListener<T> = (event) => {
|
||||
listener(event)
|
||||
}
|
||||
window.addEventListener(eventType, handleEvent as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(eventType, handleEvent as EventListener)
|
||||
}
|
||||
}, [eventType, listener])
|
||||
}
|
18
site/src/util/events.test.ts
Normal file
18
site/src/util/events.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
47
site/src/util/events.ts
Normal file
47
site/src/util/events.ts
Normal file
@ -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 = <D = unknown>(
|
||||
eventType: string,
|
||||
detail?: D,
|
||||
target: EventTarget = window,
|
||||
): CustomEvent<D> => {
|
||||
const event = new CustomEvent<D>(eventType, { detail })
|
||||
|
||||
target.dispatchEvent(event)
|
||||
|
||||
return event
|
||||
}
|
||||
/** Annotates a custom event listener with descriptive type information. */
|
||||
export type CustomEventListener<D = unknown> = (event: CustomEvent<D>) => 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<MouseEvent> = (event) => {
|
||||
* event.preventDefault()
|
||||
* }
|
||||
*
|
||||
* window.addEventListener('click', handleClick)
|
||||
* window.removeEventListener('click', handleClick)
|
||||
* ```
|
||||
*/
|
||||
export type AnnotatedEventListener<E extends Event> = (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 = <D = unknown>(event: CustomEvent<D> | Event): event is CustomEvent<D> => {
|
||||
return !!(event as CustomEvent).detail
|
||||
}
|
@ -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"
|
||||
|
Reference in New Issue
Block a user