From c4eef7547ac42066ddc0f88e8f6873326d332e46 Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Thu, 27 Mar 2025 18:57:42 +0100 Subject: [PATCH 1/3] chore(components): remove useToast / useConfirmationModal hooks, only provide global functions as interface --- .../src/hooks/use-confirmation.spec.tsx | 90 ++------ .../src/hooks/use-confirmation.tsx | 209 +++++++----------- .../src/hooks/use-error-details.tsx | 58 +++-- .../src/hooks/use-toast.spec.tsx | 23 +- .../src/hooks/use-toast.tsx | 65 +----- packages/compass-components/src/index.ts | 4 +- .../src/components/export-modal.tsx | 7 +- .../src/components/import-modal.tsx | 7 +- .../src/components/validation-editor.tsx | 3 +- .../components/connection-string-input.tsx | 3 +- 10 files changed, 153 insertions(+), 316 deletions(-) diff --git a/packages/compass-components/src/hooks/use-confirmation.spec.tsx b/packages/compass-components/src/hooks/use-confirmation.spec.tsx index 84f57b6852a..15b436401d1 100644 --- a/packages/compass-components/src/hooks/use-confirmation.spec.tsx +++ b/packages/compass-components/src/hooks/use-confirmation.spec.tsx @@ -1,5 +1,4 @@ import { - cleanup, render, screen, waitFor, @@ -10,85 +9,30 @@ import { import { expect } from 'chai'; import React from 'react'; -import { - ConfirmationModalArea, - useConfirmationModal, - showConfirmation, -} from './use-confirmation'; - -const OpenConfirmationModalButton = () => { - const { showConfirmation } = useConfirmationModal(); - return ( - - ); -}; +import { ConfirmationModalArea, showConfirmation } from './use-confirmation'; describe('use-confirmation', function () { - afterEach(cleanup); - - context('useConfirmationModal hook', function () { - let modal: HTMLElement; - beforeEach(function () { - render( - - - - ); - - userEvent.click(screen.getByText('Open Modal')); - modal = screen.getByTestId('confirmation-modal'); - expect(modal).to.exist; - }); - - it('renders modal contents', function () { - expect(within(modal).getByText('Are you sure?')).to.exist; - expect( - within(modal).getByText('This action can not be undone.') - ).to.exist; - expect(within(modal).getByText('Yes')).to.exist; - expect(within(modal).getByText('Cancel')).to.exist; - }); - - it('handles cancel action', async function () { - userEvent.click(within(modal).getByText('Cancel')); - await waitForElementToBeRemoved(() => - screen.getByTestId('confirmation-modal') - ); - }); - - it('handles confirm action', async function () { - userEvent.click(within(modal).getByText('Yes')); - await waitForElementToBeRemoved(() => - screen.getByTestId('confirmation-modal') - ); - }); - }); - context('showConfirmation global function', function () { let modal: HTMLElement; let response: Promise; beforeEach(async function () { render( -
+ ); - response = showConfirmation({ - title: 'Are you sure?', - description: 'This action can not be undone.', - buttonText: 'Yes', - }); + userEvent.click(screen.getByText('Open Modal')); await waitFor(() => { modal = screen.getByTestId('confirmation-modal'); }); @@ -105,12 +49,18 @@ describe('use-confirmation', function () { it('handles cancel action', async function () { userEvent.click(within(modal).getByText('Cancel')); + await waitForElementToBeRemoved(() => + screen.getByTestId('confirmation-modal') + ); const confirmed = await response; expect(confirmed).to.be.false; }); it('handles confirm action', async function () { userEvent.click(within(modal).getByText('Yes')); + await waitForElementToBeRemoved(() => + screen.getByTestId('confirmation-modal') + ); const confirmed = await response; expect(confirmed).to.be.true; }); diff --git a/packages/compass-components/src/hooks/use-confirmation.tsx b/packages/compass-components/src/hooks/use-confirmation.tsx index 1fd30fd1bfa..7d0e2f2828b 100644 --- a/packages/compass-components/src/hooks/use-confirmation.tsx +++ b/packages/compass-components/src/hooks/use-confirmation.tsx @@ -1,4 +1,10 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { Variant as ConfirmationModalVariant } from '@leafygreen-ui/confirmation-modal'; import ConfirmationModal from '../components/modals/confirmation-modal'; import { css } from '@leafygreen-ui/emotion'; @@ -22,125 +28,66 @@ type ConfirmationProperties = Partial< type ConfirmationCallback = (value: boolean) => void; -interface ConfirmationModalContextData { - showConfirmation: (props: ConfirmationProperties) => Promise; - isMounted: boolean; -} - -type ShowConfirmationEventDetail = { - props: ConfirmationProperties & { confirmationId: number }; +type OnShowConfirmationProperties = { + props: ConfirmationProperties; resolve: ConfirmationCallback; reject: (err?: any) => void; + confirmationId: number; }; -interface ConfirmationEventMap { - 'show-confirmation': CustomEvent; -} - -interface GlobalConfirmation extends EventTarget { - addEventListener( - type: K, - listener: (this: GlobalConfirmation, ev: ConfirmationEventMap[K]) => void - ): void; - addEventListener( - type: string, - listener: EventListenerOrEventListenerObject - ): void; - removeEventListener( - type: K, - listener: (this: GlobalConfirmation, ev: ConfirmationEventMap[K]) => void - ): void; - removeEventListener( - type: string, - listener: EventListenerOrEventListenerObject - ): void; +interface ConfirmationModalActions { + showConfirmation: (props: ConfirmationProperties) => Promise; } - -let confirmationId = 0; - -class GlobalConfirmation extends EventTarget { +class GlobalConfirmationModalState implements ConfirmationModalActions { + private confirmationId = 0; + onShowCallback: ((props: OnShowConfirmationProperties) => void) | null = null; showConfirmation(props: ConfirmationProperties) { return new Promise((resolve, reject) => { - this.dispatchEvent( - new CustomEvent('show-confirmation', { - detail: { - props: { ...props, confirmationId: ++confirmationId }, - resolve, - reject, - }, - }) - ); + this.onShowCallback?.({ + props, + resolve, + reject, + confirmationId: ++this.confirmationId, + }); }); } } -const globalConfirmation = new GlobalConfirmation(); -export const showConfirmation = - globalConfirmation.showConfirmation.bind(globalConfirmation); +const confirmationModalState = new GlobalConfirmationModalState(); -const ConfirmationModalContext = - React.createContext({ - isMounted: false, - showConfirmation, - }); +export const showConfirmation = confirmationModalState.showConfirmation.bind( + confirmationModalState +); -type ConfirmationModalAreaProps = Partial< - ShowConfirmationEventDetail['props'] -> & { open: boolean }; +export const showConfirmationModal = showConfirmation; const hideButtonStyles = css({ display: 'none !important', }); -export const ConfirmationModalArea: React.FC = ({ children }) => { - const hasParentContext = useContext(ConfirmationModalContext).isMounted; - - const [confirmationProps, setConfirmationProps] = - useState({ - open: false, - confirmationId: -1, - }); +const _ConfirmationModalArea: React.FunctionComponent = ({ children }) => { + const [confirmationProps, setConfirmationProps] = useState< + Partial & { open: boolean; confirmationId: number } + >({ + open: false, + confirmationId: -1, + }); const callbackRef = useRef(); - - const listenerRef = - useRef<(event: CustomEvent) => void>(); - - const contextValue = React.useMemo( - () => ({ - showConfirmation: (props: ConfirmationProperties) => { - return new Promise((resolve, reject) => { - const event = new CustomEvent( - 'show-confirmation', - { - detail: { - props: { ...props, confirmationId: ++confirmationId }, - resolve, - reject, - }, - } - ); - listenerRef.current?.(event); - }); - }, - isMounted: true, - }), - [] - ); - - useEffect(() => { - return () => { - callbackRef.current?.(false); - }; - }, []); - - // Event listener to use confirmation modal outside of react - useEffect(() => { - const listener = ({ - detail: { resolve, reject, props }, - }: CustomEvent) => { - setConfirmationProps({ open: true, ...props }); + const confirmationModalStateRef = useRef(); + + if (!confirmationModalStateRef.current) { + confirmationModalStateRef.current = confirmationModalState; + confirmationModalStateRef.current.onShowCallback = ({ + props, + resolve, + reject, + confirmationId, + }) => { + setConfirmationProps({ open: true, confirmationId, ...props }); const onAbort = () => { - setConfirmationProps({ open: false, ...props }); + setConfirmationProps((state) => { + return { ...state, open: false }; + }); reject(props.signal?.reason); }; callbackRef.current = (confirmed) => { @@ -149,40 +96,42 @@ export const ConfirmationModalArea: React.FC = ({ children }) => { }; props.signal?.addEventListener('abort', onAbort); }; - listenerRef.current = listener; - globalConfirmation.addEventListener('show-confirmation', listener); + } + + useEffect(() => { return () => { - globalConfirmation.removeEventListener('show-confirmation', listener); + callbackRef.current?.(false); + if (confirmationModalStateRef.current) { + confirmationModalStateRef.current.onShowCallback = null; + } }; }, []); - const handleConfirm = () => { - onUserAction(true); - }; - - const handleCancel = () => { - onUserAction(false); - }; - - const onUserAction = (value: boolean) => { - setConfirmationProps((state) => ({ ...state, open: false })); + const onUserAction = useCallback((value: boolean) => { + setConfirmationProps((state) => { + return { ...state, open: false }; + }); callbackRef.current?.(value); callbackRef.current = undefined; - }; + }, []); - if (hasParentContext) { - return <>{children}; - } + const handleConfirm = useCallback(() => { + onUserAction(true); + }, [onUserAction]); + + const handleCancel = useCallback(() => { + onUserAction(false); + }, [onUserAction]); return ( - + <> {children} { > {confirmationProps.description} - + ); }; -export const useConfirmationModal = () => { - const { isMounted, showConfirmation } = useContext(ConfirmationModalContext); - if (!isMounted) { - throw new Error( - 'useConfirmationModal must be used within a ConfirmationModalArea' - ); +const ConfirmationModalAreaMountedContext = React.createContext(false); + +export const ConfirmationModalArea: React.FunctionComponent = ({ + children, +}) => { + if (useContext(ConfirmationModalAreaMountedContext)) { + return <>{children}; } - return { showConfirmation }; + + return ( + + <_ConfirmationModalArea>{children} + + ); }; diff --git a/packages/compass-components/src/hooks/use-error-details.tsx b/packages/compass-components/src/hooks/use-error-details.tsx index 99302afa1c2..50a6ae270db 100644 --- a/packages/compass-components/src/hooks/use-error-details.tsx +++ b/packages/compass-components/src/hooks/use-error-details.tsx @@ -1,38 +1,30 @@ -import { - type showConfirmation as originalShowConfirmation, - showConfirmation, -} from './use-confirmation'; +import { showConfirmation } from './use-confirmation'; import { Code } from '../components/leafygreen'; import React from 'react'; import { ButtonVariant } from '..'; -const getShowErrorDetails = ( - showConfirmation: typeof originalShowConfirmation -) => { - return ({ - details, - closeAction, - }: { - details: Record; - closeAction: 'back' | 'close'; - }) => - void showConfirmation({ - title: 'Error details', - description: ( - - {JSON.stringify(details, undefined, 2)} - - ), - hideCancelButton: true, - buttonText: closeAction.replace(/\b\w/g, (c) => c.toUpperCase()), - confirmButtonProps: { - variant: ButtonVariant.Default, - }, - }); +export const showErrorDetails = function showErrorDetails({ + details, + closeAction, +}: { + details: Record; + closeAction: 'back' | 'close'; +}) { + void showConfirmation({ + title: 'Error details', + description: ( + + {JSON.stringify(details, undefined, 2)} + + ), + hideCancelButton: true, + buttonText: closeAction.replace(/\b\w/g, (c) => c.toUpperCase()), + confirmButtonProps: { + variant: ButtonVariant.Default, + }, + }); }; - -export const showErrorDetails = getShowErrorDetails(showConfirmation); diff --git a/packages/compass-components/src/hooks/use-toast.spec.tsx b/packages/compass-components/src/hooks/use-toast.spec.tsx index e8e91979254..485191c31fc 100644 --- a/packages/compass-components/src/hooks/use-toast.spec.tsx +++ b/packages/compass-components/src/hooks/use-toast.spec.tsx @@ -1,5 +1,4 @@ import { - cleanup, render, screen, waitForElementToBeRemoved, @@ -8,7 +7,7 @@ import { import { expect } from 'chai'; import React from 'react'; -import { ToastArea, useToast } from './use-toast'; +import { ToastArea, openToast, closeToast } from './use-toast'; import type { ToastProperties } from './use-toast'; const OpenToastButton = ({ @@ -19,9 +18,13 @@ const OpenToastButton = ({ namespace: string; id: string; } & ToastProperties) => { - const { openToast } = useToast(namespace); return ( - ); @@ -34,17 +37,19 @@ const CloseToastButton = ({ namespace: string; id: string; }) => { - const { closeToast } = useToast(namespace); return ( - ); }; -describe('useToast', function () { - afterEach(cleanup); - +describe('openToast / closeToast', function () { it('opens and closes a toast', async function () { render( diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx index 126b57dc80a..f25c6e10b24 100644 --- a/packages/compass-components/src/hooks/use-toast.tsx +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -1,11 +1,4 @@ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, -} from 'react'; +import React, { useContext, useEffect, useMemo, useRef } from 'react'; import type { ToastProps } from '../components/leafygreen'; import { ToastProvider, @@ -109,17 +102,6 @@ export const openToast = toastState.openToast.bind(toastState); export const closeToast = toastState.closeToast.bind(toastState); -const toastActions = { openToast, closeToast }; - -const ToastContext = createContext({ - openToast: () => { - // - }, - closeToast: () => { - // - }, -}); - const _ToastArea: React.FunctionComponent = ({ children }) => { // NB: the way leafygreen implements this hook leads to anything specifying // toast methods in hooks dependencies to constantly update potentially @@ -157,11 +139,7 @@ const _ToastArea: React.FunctionComponent = ({ children }) => { }; }, []); - return ( - - {children} - - ); + return <>{children}; }; const ToastAreaMountedContext = React.createContext(false); @@ -186,42 +164,3 @@ export const ToastArea: React.FunctionComponent = ({ children }) => { ); }; - -/** - * @example - * - * ``` - * const MyButton = () => { - * const { openToast } = useToast('namespace'); - * return + * + * @param props ConfirmationModal rendering properties + */ export const showConfirmation = confirmationModalState.showConfirmation.bind( confirmationModalState ); -export const showConfirmationModal = showConfirmation; - const hideButtonStyles = css({ display: 'none !important', }); diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx index f25c6e10b24..dcd8f09c9c4 100644 --- a/packages/compass-components/src/hooks/use-toast.tsx +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -98,8 +98,56 @@ class GlobalToastState implements ToastActions { const toastState = new GlobalToastState(); +/** + * Programmatically trigger a + * [leafygreen Toast](https://www.mongodb.design/component/toast/code-docs) + * component to appear on the screen. Can be used both inside and __outside__ + * React rendering tree. The latter is especially useful for triggering toast + * showing up for any async business logic flow. + * + * @example + * function insertDocumentAction() { + * dataService.insertOne(...).then( + * () => { + * openToast( + * `insert-doc-${ns}`, + * { variant: 'success', title: 'Successfully inserted document' } + * ) + * }, + * (err) => { + * openToast( + * `insert-doc-${ns}`, + * { variant: 'warning', title: 'Failed to insert document', description: err.message } + * ) + * } + * ) + * } + * + * Same method can be used to update the content of the toast that is already + * displayed + * + * @example + * function insertManyDocuments() { + * let total = 0; + * for (const doc of docs) { + * await dataService.insertOne(doc); + * openToast( + * `insert-doc-${ns}`, + * { variant: 'progress', title: 'Inserting documents', progress: docs.length / ++total } + * ) + * } + * } + * + * @param id unique toast id that can be used to close the toast later + * @param props Toast rendering properties + */ export const openToast = toastState.openToast.bind(toastState); +/** + * Programmatically close a toast with a matching id + * + * @param id unique toast id + */ export const closeToast = toastState.closeToast.bind(toastState); const _ToastArea: React.FunctionComponent = ({ children }) => { diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 868870daff8..62c5190a27f 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -180,7 +180,6 @@ export { ConfirmationModalVariant, ConfirmationModalArea, showConfirmation, - showConfirmationModal, } from './hooks/use-confirmation'; export { showErrorDetails } from './hooks/use-error-details'; export {