From 74625f11787f47bbc47aee5496b33d9165d04f09 Mon Sep 17 00:00:00 2001 From: mcasimir Date: Mon, 7 Feb 2022 12:23:00 +0100 Subject: [PATCH 01/15] feat(compass-components): add useToast --- .../src/hooks/use-toast.tsx | 161 ++++++++++++++++++ packages/compass-components/src/index.ts | 3 + packages/compass-home/src/components/home.tsx | 27 +-- .../connection-list/connection-menu.tsx | 123 ++----------- .../src/components/connections.tsx | 13 -- .../src/stores/connections-store.ts | 40 ++--- 6 files changed, 205 insertions(+), 162 deletions(-) create mode 100644 packages/compass-components/src/hooks/use-toast.tsx diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx new file mode 100644 index 00000000000..885abab20ff --- /dev/null +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -0,0 +1,161 @@ +import React, { createContext, useCallback, useContext, useState } from 'react'; +import type { ToastVariant } from '..'; +import { Toast } from '..'; + +type ToastProperties = { + title?: React.ReactNode; + body: React.ReactNode; + variant: ToastVariant; + progress?: number; + timeout?: number; +}; + +type ToastState = ToastProperties & { + timeoutRef?: ReturnType; +}; + +type ToastId = string; + +type ToastContextState = { + toasts: Record; + setToasts: (toasts: Record) => void; +}; + +interface ToastActions { + openToast: (id: ToastId, toastProperties: ToastProperties) => void; + closeToast: (id: ToastId) => void; +} + +const ToastContext = createContext({ + toasts: {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setToasts: (t: Record) => { + return; + }, +}); + +const ToastPile = (): React.ReactElement => { + const { toasts } = useContext(ToastContext); + const { closeToast } = useGlobalToast(); + + return ( + <> + {Object.entries(toasts).map( + ([id, { title, body, variant, progress }]) => ( + closeToast(id)} + /> + ) + )} + + ); +}; + +/** + * @example + * + * ``` + * const MyButton = () => { + * const { openToast } = useToast(); + * return + ); +}; + +describe.only('useToast', function () { + afterEach(cleanup); + + it('opens a toast', async function () { + render( + + + + ); + + fireEvent.click(screen.getByText('Open Toast')); + + await waitFor(() => { + expect(screen.getByText('My Toast')).to.exist; + }); + }); +}); diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx index 55c4ab9a682..b8149fe4c86 100644 --- a/packages/compass-components/src/hooks/use-toast.tsx +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -70,7 +70,7 @@ const ToastPile = (): React.ReactElement => { * * ``` * const MyButton = () => { - * const { openToast } = useToast(); + * const { openToast } = useToast('my-namespace'); * return ); }; -describe.only('useToast', function () { +const CloseToastButton = ({ + namespace, + id, +}: { + namespace: string; + id: string; +}) => { + const { closeToast } = useToast(namespace); + return ; +}; + +describe('useToast', function () { afterEach(cleanup); - it('opens a toast', async function () { + it('opens and closes a toast', async function () { render( + ); fireEvent.click(screen.getByText('Open Toast')); - // await waitFor(() => { - // expect(screen.getByText('My Toast')).to.exist; - // }); + await screen.findByText('My Toast'); + + fireEvent.click(screen.getByText('Close Toast')); + + expect(screen.queryByText('My Toast')).to.not.exist; }); }); diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx index 5dea8c3424d..6d182764f34 100644 --- a/packages/compass-components/src/hooks/use-toast.tsx +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -58,8 +58,6 @@ export const ToastArea: React.FunctionComponent = ({ children }) => { const closeToast = useCallback( (toastId: string): void => { - console.log('real closeToast'); - const { timeoutRef } = toasts[toastId] || {}; if (timeoutRef) { clearTimeout(timeoutRef); @@ -76,8 +74,6 @@ export const ToastArea: React.FunctionComponent = ({ children }) => { const openToast = useCallback( (toastId: string, toastProperties: ToastProperties): void => { - console.log('real openToast'); - // if updating clear timeouts first const { timeoutRef } = toasts[toastId] || {}; if (timeoutRef) { @@ -128,7 +124,6 @@ export function useToast(namespace: string): ToastActions { const openToast = useCallback( (toastId: string, toastProperties: ToastProperties): void => { - console.log('hook open toast'); openGlobalToast(`${namespace}--${toastId}`, toastProperties); }, [namespace, openGlobalToast] @@ -136,7 +131,6 @@ export function useToast(namespace: string): ToastActions { const closeToast = useCallback( (toastId: string): void => { - console.log('hook close toast'); closeGlobalToast(`${namespace}--${toastId}`); }, [namespace, closeGlobalToast] From 4303f752814c00462aea8644651ebf200b8d259a Mon Sep 17 00:00:00 2001 From: mcasimir Date: Wed, 9 Feb 2022 11:53:01 +0100 Subject: [PATCH 14/15] fix tests --- .../src/hooks/use-toast.spec.tsx | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/compass-components/src/hooks/use-toast.spec.tsx b/packages/compass-components/src/hooks/use-toast.spec.tsx index 73b1b138152..1c041f743eb 100644 --- a/packages/compass-components/src/hooks/use-toast.spec.tsx +++ b/packages/compass-components/src/hooks/use-toast.spec.tsx @@ -1,12 +1,7 @@ -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { expect } from 'chai'; import React from 'react'; +import sinon from 'sinon'; import { ToastArea, ToastVariant, useToast } from '..'; @@ -15,6 +10,7 @@ const OpenToastButton = ({ id, title, variant, + timeout, body, }: { namespace: string; @@ -26,7 +22,7 @@ const OpenToastButton = ({ }) => { const { openToast } = useToast(namespace); return ( - ); @@ -63,9 +59,49 @@ describe('useToast', function () { fireEvent.click(screen.getByText('Open Toast')); await screen.findByText('My Toast'); + screen.getByText('Toast body'); fireEvent.click(screen.getByText('Close Toast')); expect(screen.queryByText('My Toast')).to.not.exist; }); + + describe('with timeout', function () { + let clock; + + beforeEach(function () { + clock = sinon.useFakeTimers(); + }); + + afterEach(function () { + clock.restore(); + }); + + it('closes a toast after timeout expires', async function () { + render( + + + + ); + + fireEvent.click(screen.getByText('Open Toast')); + + await screen.findByText('My Toast'); + + clock.tick(2000); + + await screen.findByText('My Toast'); + + clock.tick(3001); + + expect(screen.queryByText('My Toast')).to.not.exist; + }); + }); }); From 87ae53dd468b097dea4dae7fd2c9467eb56d50e3 Mon Sep 17 00:00:00 2001 From: mcasimir Date: Wed, 9 Feb 2022 18:56:34 +0100 Subject: [PATCH 15/15] clear timeouts on unmount --- .../src/hooks/use-toast.tsx | 69 ++++++++++++------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx index 6d182764f34..3437b14018c 100644 --- a/packages/compass-components/src/hooks/use-toast.tsx +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -1,4 +1,11 @@ -import React, { createContext, useCallback, useContext, useState } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import type { ToastVariant } from '..'; import { css } from '..'; import { Toast } from '..'; @@ -11,10 +18,6 @@ type ToastProperties = { timeout?: number; }; -type ToastState = ToastProperties & { - timeoutRef?: ReturnType; -}; - interface ToastActions { openToast: (id: string, toastProperties: ToastProperties) => void; closeToast: (id: string) => void; @@ -22,12 +25,9 @@ interface ToastActions { const ToastContext = createContext({ openToast: () => { - console.log('Fake openToast'); - // }, closeToast: () => { - console.log('Fake closeToast'); // }, }); @@ -54,14 +54,34 @@ const toastStyles = css({ * @returns */ export const ToastArea: React.FunctionComponent = ({ children }) => { - const [toasts, setToasts] = useState>({}); + const [toasts, setToasts] = useState>({}); + const timeouts = useRef>>({}); + + useEffect(() => { + return () => { + Object.values(timeouts).forEach(clearTimeout); + }; + }, [timeouts]); + + const clearTimeoutRef = useCallback( + (id) => { + clearTimeout(timeouts.current[id]); + delete timeouts.current[id]; + }, + [timeouts] + ); + + const setTimeoutRef = useCallback( + (id: string, callback: () => void, timeout: number) => { + clearTimeoutRef(id); + timeouts.current[id] = setTimeout(callback, timeout); + }, + [timeouts, clearTimeoutRef] + ); const closeToast = useCallback( (toastId: string): void => { - const { timeoutRef } = toasts[toastId] || {}; - if (timeoutRef) { - clearTimeout(timeoutRef); - } + clearTimeoutRef(toastId); setToasts((prevToasts) => { const newToasts = { ...prevToasts }; @@ -69,30 +89,31 @@ export const ToastArea: React.FunctionComponent = ({ children }) => { return newToasts; }); }, - [toasts, setToasts] + [setToasts, clearTimeoutRef] ); const openToast = useCallback( (toastId: string, toastProperties: ToastProperties): void => { - // if updating clear timeouts first - const { timeoutRef } = toasts[toastId] || {}; - if (timeoutRef) { - clearTimeout(timeoutRef); + clearTimeoutRef(toastId); + + if (toastProperties.timeout) { + setTimeoutRef( + toastId, + () => { + closeToast(toastId); + }, + toastProperties.timeout + ); } setToasts((prevToasts) => ({ ...prevToasts, [toastId]: { ...toastProperties, - timeoutRef: toastProperties.timeout - ? setTimeout(() => { - closeToast(toastId); - }, toastProperties.timeout) - : undefined, }, })); }, - [toasts, setToasts, closeToast] + [setToasts, setTimeoutRef, clearTimeoutRef, closeToast] ); return (