From c53fccbc79fc6e36027573e292763a74457eccf5 Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 21 Nov 2023 19:11:02 +0100 Subject: [PATCH 1/4] feat: recovery info modals + widgets --- public/images/common/propose-recovery.svg | 29 +++ src/components/common/PageLayout/index.tsx | 5 +- .../dashboard/RecoveryHeader/index.test.tsx | 58 ++++++ .../dashboard/RecoveryHeader/index.tsx | 55 ++++++ .../RecoveryInProgress/index.test.tsx | 182 ------------------ .../dashboard/RecoveryInProgress/index.tsx | 91 --------- src/components/dashboard/index.tsx | 4 +- .../RecoveryModal/RecoveryInProgress.tsx | 104 ++++++++++ .../RecoveryModal/RecoveryProposal.tsx | 118 ++++++++++++ .../__tests__/RecoveryInProgress.test.tsx | 125 ++++++++++++ .../__tests__/RecoveryProposal.test.tsx | 66 +++++++ .../RecoveryModal/__tests__/index.test.tsx | 108 +++++++++++ .../recovery/RecoveryModal/index.tsx | 73 +++++++ .../recovery/RecoveryModal/styles.module.css | 13 ++ .../sidebar/SidebarNavigation/index.tsx | 4 +- .../EnableRecoveryFlowReview.tsx | 2 +- .../RecoverAccountFlowSetup.tsx | 5 +- src/hooks/useIsGuardian.ts | 8 + src/hooks/useRecoveryQueue.ts | 13 ++ 19 files changed, 782 insertions(+), 281 deletions(-) create mode 100644 public/images/common/propose-recovery.svg create mode 100644 src/components/dashboard/RecoveryHeader/index.test.tsx create mode 100644 src/components/dashboard/RecoveryHeader/index.tsx delete mode 100644 src/components/dashboard/RecoveryInProgress/index.test.tsx delete mode 100644 src/components/dashboard/RecoveryInProgress/index.tsx create mode 100644 src/components/recovery/RecoveryModal/RecoveryInProgress.tsx create mode 100644 src/components/recovery/RecoveryModal/RecoveryProposal.tsx create mode 100644 src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx create mode 100644 src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx create mode 100644 src/components/recovery/RecoveryModal/__tests__/index.test.tsx create mode 100644 src/components/recovery/RecoveryModal/index.tsx create mode 100644 src/components/recovery/RecoveryModal/styles.module.css create mode 100644 src/hooks/useIsGuardian.ts create mode 100644 src/hooks/useRecoveryQueue.ts diff --git a/public/images/common/propose-recovery.svg b/public/images/common/propose-recovery.svg new file mode 100644 index 0000000000..897a20a7e5 --- /dev/null +++ b/public/images/common/propose-recovery.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/PageLayout/index.tsx b/src/components/common/PageLayout/index.tsx index 317150384b..0df0489f86 100644 --- a/src/components/common/PageLayout/index.tsx +++ b/src/components/common/PageLayout/index.tsx @@ -11,6 +11,7 @@ import useDebounce from '@/hooks/useDebounce' import { useRouter } from 'next/router' import { TxModalContext } from '@/components/tx-flow' import BatchSidebar from '@/components/batch/BatchSidebar' +import { RecoveryModal } from '@/components/recovery/RecoveryModal' const isNoSidebarRoute = (pathname: string): boolean => { return [ @@ -60,7 +61,9 @@ const PageLayout = ({ pathname, children }: { pathname: string; children: ReactE })} >
- {children} + + {children} +
diff --git a/src/components/dashboard/RecoveryHeader/index.test.tsx b/src/components/dashboard/RecoveryHeader/index.test.tsx new file mode 100644 index 0000000000..fe89e17587 --- /dev/null +++ b/src/components/dashboard/RecoveryHeader/index.test.tsx @@ -0,0 +1,58 @@ +import { BigNumber } from 'ethers' + +import { _RecoveryHeader } from '.' +import { render } from '@/tests/test-utils' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +describe('RecoveryHeader', () => { + it('should not render a widget if the chain does not support recovery', () => { + const { container } = render( + <_RecoveryHeader + isOwner + isGuardian + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + supportsRecovery={false} + />, + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render the in-progress widget if there is a queue for guardians', () => { + const { queryByText } = render( + <_RecoveryHeader + isOwner={false} + isGuardian + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + supportsRecovery + />, + ) + + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the in-progress widget if there is a queue for owners', () => { + const { queryByText } = render( + <_RecoveryHeader + isOwner + isGuardian={false} + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + supportsRecovery + />, + ) + + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the proposal widget when there is no queue for guardians', () => { + const { queryByText } = render(<_RecoveryHeader isOwner={false} isGuardian queue={[]} supportsRecovery />) + + expect(queryByText('Recover this Account')).toBeTruthy() + }) + + it('should not render the proposal widget when there is no queue for owners', () => { + const { container } = render(<_RecoveryHeader isOwner isGuardian={false} queue={[]} supportsRecovery />) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/dashboard/RecoveryHeader/index.tsx b/src/components/dashboard/RecoveryHeader/index.tsx new file mode 100644 index 0000000000..6775d910c3 --- /dev/null +++ b/src/components/dashboard/RecoveryHeader/index.tsx @@ -0,0 +1,55 @@ +import { Grid } from '@mui/material' +import type { ReactElement } from 'react' + +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' +import { useIsGuardian } from '@/hooks/useIsGuardian' +import madProps from '@/utils/mad-props' +import { FEATURES } from '@/utils/chains' +import { useHasFeature } from '@/hooks/useChains' +import { RecoveryProposal } from '@/components/recovery/RecoveryModal/RecoveryProposal' +import { RecoveryInProgress } from '@/components/recovery/RecoveryModal/RecoveryInProgress' +import { WidgetContainer, WidgetBody } from '../styled' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +export function _RecoveryHeader({ + isGuardian, + supportsRecovery, + queue, +}: { + isOwner: boolean + isGuardian: boolean + supportsRecovery: boolean + queue: Array +}): ReactElement | null { + const next = queue[0] + + if (!supportsRecovery) { + return null + } + + const modal = next ? ( + + ) : isGuardian ? ( + + ) : null + + if (modal) { + return ( + + + {modal} + + + ) + } + return null +} + +// Appease TypeScript +const _useSupportedRecovery = () => useHasFeature(FEATURES.RECOVERY) + +export const RecoveryHeader = madProps(_RecoveryHeader, { + isGuardian: useIsGuardian, + supportsRecovery: _useSupportedRecovery, + queue: useRecoveryQueue, +}) diff --git a/src/components/dashboard/RecoveryInProgress/index.test.tsx b/src/components/dashboard/RecoveryInProgress/index.test.tsx deleted file mode 100644 index cc0d8ff299..0000000000 --- a/src/components/dashboard/RecoveryInProgress/index.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { render } from '@testing-library/react' -import { BigNumber } from 'ethers' - -import { _RecoveryInProgress } from '.' -import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' - -jest.mock('@/hooks/useRecoveryTxState') - -const mockUseRecoveryTxState = useRecoveryTxState as jest.MockedFunction - -describe('RecoveryInProgress', () => { - beforeEach(() => { - jest.resetAllMocks() - }) - - it('should return null if the chain does not support recovery', () => { - mockUseRecoveryTxState.mockReturnValue({} as any) - - const result = render( - <_RecoveryInProgress - supportsRecovery={false} - timestamp={0} - queuedTxs={[{ timestamp: BigNumber.from(0) } as RecoveryQueueItem]} - />, - ) - - expect(result.container).toBeEmptyDOMElement() - }) - - it('should return null if there are no delayed transactions', () => { - mockUseRecoveryTxState.mockReturnValue({} as any) - - const result = render(<_RecoveryInProgress supportsRecovery={true} timestamp={69420} queuedTxs={[]} />) - - expect(result.container).toBeEmptyDOMElement() - }) - - it('should return null if all the delayed transactions are expired and invalid', () => { - mockUseRecoveryTxState.mockReturnValue({} as any) - - const result = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={69420} - queuedTxs={[ - { - timestamp: BigNumber.from(0), - validFrom: BigNumber.from(69), - expiresAt: BigNumber.from(420), - } as RecoveryQueueItem, - ]} - />, - ) - - expect(result.container).toBeEmptyDOMElement() - }) - - it('should return the countdown of the next non-expired/invalid transactions if none are non-expired/valid', () => { - mockUseRecoveryTxState.mockReturnValue({ - remainingSeconds: 69 * 420 * 1337, - isExecutable: false, - isNext: true, - } as any) - - const mockBlockTimestamp = BigNumber.from(69420) - - const { queryByText } = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={mockBlockTimestamp.toNumber()} - queuedTxs={[ - { - timestamp: mockBlockTimestamp.add(1), - validFrom: mockBlockTimestamp.add(1), // Invalid - expiresAt: mockBlockTimestamp.add(1), // Non-expired - } as RecoveryQueueItem, - { - // Older - should render this - timestamp: mockBlockTimestamp, - validFrom: mockBlockTimestamp.mul(4), // Invalid - expiresAt: null, // Non-expired - } as RecoveryQueueItem, - ]} - />, - ) - - expect(queryByText('Account recovery in progress')).toBeInTheDocument() - expect( - queryByText('The recovery process has started. This Account will be ready to recover in:'), - ).toBeInTheDocument() - ;['day', 'hr', 'min'].forEach((unit) => { - // May be pluralised - expect(queryByText(unit, { exact: false })).toBeInTheDocument() - }) - // Days - expect(queryByText('448')).toBeInTheDocument() - // Hours - expect(queryByText('10')).toBeInTheDocument() - // Mins - expect(queryByText('51')).toBeInTheDocument() - }) - - it('should return the info of the next non-expired/valid transaction', () => { - mockUseRecoveryTxState.mockReturnValue({ isExecutable: true, remainingSeconds: 0 } as any) - - const mockBlockTimestamp = BigNumber.from(69420) - - const { queryByText } = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={mockBlockTimestamp.toNumber()} - queuedTxs={[ - { - timestamp: mockBlockTimestamp.sub(1), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: mockBlockTimestamp.sub(1), // Non-expired - } as RecoveryQueueItem, - { - // Older - should render this - timestamp: mockBlockTimestamp.sub(2), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: null, // Non-expired - } as RecoveryQueueItem, - ]} - />, - ) - - expect(queryByText('Account recovery possible')).toBeInTheDocument() - expect(queryByText('The recovery process is possible. This Account can be recovered.')).toBeInTheDocument() - ;['day', 'hr', 'min'].forEach((unit) => { - // May be pluralised - expect(queryByText(unit, { exact: false })).not.toBeInTheDocument() - }) - }) - - it('should return the intemediary info for of the queued, non-expired/valid transactions', () => { - mockUseRecoveryTxState.mockReturnValue({ - isExecutable: false, - isNext: false, - remainingSeconds: 69 * 420 * 1337, - } as any) - - const mockBlockTimestamp = BigNumber.from(69420) - - const { queryByText } = render( - <_RecoveryInProgress - supportsRecovery={true} - timestamp={mockBlockTimestamp.toNumber()} - queuedTxs={[ - { - timestamp: mockBlockTimestamp.sub(1), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: mockBlockTimestamp.sub(1), // Non-expired - } as RecoveryQueueItem, - { - // Older - should render this - timestamp: mockBlockTimestamp.sub(2), - validFrom: mockBlockTimestamp.sub(1), // Invalid - expiresAt: null, // Non-expired - } as RecoveryQueueItem, - ]} - />, - ) - - expect(queryByText('Account recovery in progress')).toBeInTheDocument() - expect( - queryByText( - 'The recovery process has started. This Account can be recovered after previous attempts are executed or skipped and the delay period has passed:', - ), - ) - ;['day', 'hr', 'min'].forEach((unit) => { - // May be pluralised - expect(queryByText(unit, { exact: false })).toBeInTheDocument() - }) - // Days - expect(queryByText('448')).toBeInTheDocument() - // Hours - expect(queryByText('10')).toBeInTheDocument() - // Mins - }) -}) diff --git a/src/components/dashboard/RecoveryInProgress/index.tsx b/src/components/dashboard/RecoveryInProgress/index.tsx deleted file mode 100644 index 90972699ec..0000000000 --- a/src/components/dashboard/RecoveryInProgress/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Card, Grid, Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import { useAppSelector } from '@/store' -import { useClock } from '@/hooks/useClock' -import { WidgetContainer, WidgetBody } from '../styled' -import RecoveryPending from '@/public/images/common/recovery-pending.svg' -import ExternalLink from '@/components/common/ExternalLink' -import { useHasFeature } from '@/hooks/useChains' -import { FEATURES } from '@/utils/chains' -import { selectRecoveryQueues } from '@/store/recoverySlice' -import madProps from '@/utils/mad-props' -import { Countdown } from '@/components/common/Countdown' -import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' - -export function _RecoveryInProgress({ - timestamp, - supportsRecovery, - queuedTxs, -}: { - timestamp: number - supportsRecovery: boolean - queuedTxs: Array -}): ReactElement | null { - const nonExpiredTxs = queuedTxs.filter((queuedTx) => { - return queuedTx.expiresAt ? queuedTx.expiresAt.gt(timestamp) : true - }) - - if (!supportsRecovery || nonExpiredTxs.length === 0) { - return null - } - - // Conditional hook - return <_RecoveryInProgressWidget nextTx={nonExpiredTxs[0]} /> -} - -function _RecoveryInProgressWidget({ nextTx }: { nextTx: RecoveryQueueItem }): ReactElement { - const { isExecutable, isNext, remainingSeconds } = useRecoveryTxState(nextTx) - - // TODO: Migrate `isValid` components when https://github.com/safe-global/safe-wallet-web/issues/2758 is done - return ( - - - - - - - - - - - {isExecutable ? 'Account recovery possible' : 'Account recovery in progress'} - - - {isExecutable - ? 'The recovery process is possible. This Account can be recovered.' - : !isNext - ? remainingSeconds > 0 - ? 'The recovery process has started. This Account can be recovered after previous attempts are executed or skipped and the delay period has passed:' - : 'The recovery process has started. This Account can be recovered after previous attempts are executed or skipped.' - : 'The recovery process has started. This Account will be ready to recover in:'} - - - - - - Learn more - - - - - - - - ) -} - -// Appease React TypeScript warnings -const _useTimestamp = () => useClock(60_000) // Countdown does not display -const _useSupportsRecovery = () => useHasFeature(FEATURES.RECOVERY) -const _useQueuedRecoveryTxs = () => useAppSelector(selectRecoveryQueues) - -export const RecoveryInProgress = madProps(_RecoveryInProgress, { - timestamp: _useTimestamp, - supportsRecovery: _useSupportsRecovery, - queuedTxs: _useQueuedRecoveryTxs, -}) diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 007d9179c7..d51d5f15cb 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -11,7 +11,7 @@ import { Recovery } from './Recovery' import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' import { CREATION_MODAL_QUERY_PARM } from '../new-safe/create/logic' -import { RecoveryInProgress } from './RecoveryInProgress' +import { RecoveryHeader } from './RecoveryHeader' const Dashboard = (): ReactElement => { const router = useRouter() @@ -21,7 +21,7 @@ const Dashboard = (): ReactElement => { return ( <> - + diff --git a/src/components/recovery/RecoveryModal/RecoveryInProgress.tsx b/src/components/recovery/RecoveryModal/RecoveryInProgress.tsx new file mode 100644 index 0000000000..95fcf6fdc3 --- /dev/null +++ b/src/components/recovery/RecoveryModal/RecoveryInProgress.tsx @@ -0,0 +1,104 @@ +import { Button, Card, Divider, Grid, Typography } from '@mui/material' +import { useRouter } from 'next/dist/client/router' +import type { ReactElement } from 'react' + +import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' +import { Countdown } from '@/components/common/Countdown' +import RecoveryPending from '@/public/images/common/recovery-pending.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { AppRoutes } from '@/config/routes' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +import css from './styles.module.css' + +type Props = + | { + variant?: 'modal' + onClose: () => void + recovery: RecoveryQueueItem + } + | { + variant: 'widget' + onClose?: never + recovery: RecoveryQueueItem + } + +export function RecoveryInProgress({ variant = 'modal', onClose, recovery }: Props): ReactElement { + const { isExecutable, remainingSeconds } = useRecoveryTxState(recovery) + const router = useRouter() + + const onClick = async () => { + await router.push({ + pathname: AppRoutes.home, + query: router.query, + }) + onClose?.() + } + + const icon = + const title = isExecutable ? 'Account recovery possible' : 'Account recovery in progress' + const desc = isExecutable + ? 'The recovery process is possible. This Account can be recovered.' + : 'The recovery process has started. This Account will be ready to recover in:' + + const link = ( + + Learn more + + ) + + if (variant === 'widget') { + return ( + + + {icon} + + + + {title} + + + + {desc} + + + + + + {link} + + + ) + } + + return ( + + + + {icon} + + {link} + + + + + {title} + + + {desc} + + + + + + + + + + ) +} diff --git a/src/components/recovery/RecoveryModal/RecoveryProposal.tsx b/src/components/recovery/RecoveryModal/RecoveryProposal.tsx new file mode 100644 index 0000000000..08ebb3713e --- /dev/null +++ b/src/components/recovery/RecoveryModal/RecoveryProposal.tsx @@ -0,0 +1,118 @@ +import { Button, Card, Divider, Grid, Typography } from '@mui/material' +import { useContext } from 'react' +import type { ReactElement } from 'react' + +import ProposeRecovery from '@/public/images/common/propose-recovery.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { RecoverAccountFlow } from '@/components/tx-flow/flows/RecoverAccount' +import useSafeInfo from '@/hooks/useSafeInfo' +import madProps from '@/utils/mad-props' +import { TxModalContext } from '@/components/tx-flow' +import type { TxModalContextType } from '@/components/tx-flow' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import css from './styles.module.css' + +type Props = + | { + variant?: 'modal' + onClose: () => void + safe: SafeInfo + setTxFlow: TxModalContextType['setTxFlow'] + } + | { + variant: 'widget' + onClose?: never + safe: SafeInfo + setTxFlow: TxModalContextType['setTxFlow'] + } + +export function _RecoveryProposal({ variant = 'modal', onClose, safe, setTxFlow }: Props): ReactElement { + const onRecover = async () => { + onClose?.() + setTxFlow() + } + + const icon = + const title = 'Recover this Account' + const desc = `The connect wallet was chosen as a trusted guardian. You can help the owner${ + safe.owners.length > 1 ? 's' : '' + } regain access by updating the owner list.` + + const link = ( + + Learn more + + ) + + const recoveryButton = ( + + ) + + if (variant === 'widget') { + return ( + + + {icon} + + + + {title} + + + + {desc} + + + {link} + + + {recoveryButton} + + + ) + } + + return ( + + + + {icon} + + {link} + + + + + {title} + + + + {desc} + + + + + + + + {recoveryButton} + + + + ) +} + +// Appease TypeScript +const _useSafe = () => useSafeInfo().safe +const _useSetTxFlow = () => useContext(TxModalContext).setTxFlow + +export const RecoveryProposal = madProps(_RecoveryProposal, { + safe: _useSafe, + setTxFlow: _useSetTxFlow, +}) diff --git a/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx b/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx new file mode 100644 index 0000000000..8b45428347 --- /dev/null +++ b/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx @@ -0,0 +1,125 @@ +import { BigNumber } from 'ethers' +import { fireEvent, waitFor } from '@testing-library/react' + +import { render } from '@/tests/test-utils' +import { RecoveryInProgress } from '../RecoveryInProgress' +import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +jest.mock('@/hooks/useRecoveryTxState') + +const mockUseRecoveryTxState = useRecoveryTxState as jest.MockedFunction + +describe('RecoveryInProgress', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('modal', () => { + it('should render executable recovery state correctly', async () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: true, + remainingSeconds: 0, + } as any) + + const mockClose = jest.fn() + + const { queryByText } = render( + , + ) + + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeFalsy() + }) + + expect(queryByText('Account recovery possible')).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + + const dashboardButton = queryByText('Go to dashboard') + expect(dashboardButton).toBeTruthy() + + fireEvent.click(dashboardButton!) + + await waitFor(() => { + expect(mockClose).toHaveBeenCalled() + }) + }) + + it('should render non-executable recovery state correctly', async () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: false, + remainingSeconds: 420 * 69 * 1337, + } as any) + + const mockClose = jest.fn() + + const { queryByText } = render( + , + ) + + expect(queryByText('Account recovery in progress')).toBeTruthy() + expect(queryByText('The recovery process has started. This Account will be ready to recover in:')).toBeTruthy() + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeTruthy() + }) + expect(queryByText('Learn more')).toBeTruthy() + + const dashboardButton = queryByText('Go to dashboard') + expect(dashboardButton).toBeTruthy() + + fireEvent.click(dashboardButton!) + + await waitFor(() => { + expect(mockClose).toHaveBeenCalled() + }) + }) + }) + describe('widget', () => { + it('should render executable recovery state correctly', () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: true, + remainingSeconds: 0, + } as any) + + const { queryByText } = render( + , + ) + + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeFalsy() + }) + expect(queryByText('Go to dashboard')).toBeFalsy() + + expect(queryByText('Account recovery possible')).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + }) + + it('should render non-executable recovery state correctly', () => { + mockUseRecoveryTxState.mockReturnValue({ + isExecutable: false, + remainingSeconds: 420 * 69 * 1337, + } as any) + + const { queryByText } = render( + , + ) + + expect(queryByText('Go to dashboard')).toBeFalsy() + + expect(queryByText('Account recovery in progress')).toBeTruthy() + expect(queryByText('The recovery process has started. This Account will be ready to recover in:')).toBeTruthy() + ;['days', 'hrs', 'mins'].forEach((unit) => { + expect(queryByText(unit)).toBeTruthy() + }) + expect(queryByText('Learn more')).toBeTruthy() + }) + }) +}) diff --git a/src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx b/src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx new file mode 100644 index 0000000000..f5184c0507 --- /dev/null +++ b/src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx @@ -0,0 +1,66 @@ +import { faker } from '@faker-js/faker' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { fireEvent, render } from '@/tests/test-utils' +import { _RecoveryProposal } from '../RecoveryProposal' + +describe('RecoveryProposal', () => { + describe('modal', () => { + it('should render correctly', () => { + const mockClose = jest.fn() + const mockSetTxFlow = jest.fn() + + const { queryByText } = render( + <_RecoveryProposal + variant="modal" + onClose={mockClose} + safe={{ owners: [{ value: faker.finance.ethereumAddress() }] } as SafeInfo} + setTxFlow={mockSetTxFlow} + />, + ) + + expect(queryByText('Recover this Account')).toBeTruthy() + expect( + queryByText( + 'The connect wallet was chosen as a trusted guardian. You can help the owner regain access by updating the owner list.', + ), + ).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + + const recoveryButton = queryByText('Start recovery') + expect(recoveryButton).toBeTruthy() + + fireEvent.click(recoveryButton!) + + expect(mockClose).toHaveBeenCalled() + expect(mockSetTxFlow).toHaveBeenCalled() + }) + }) + describe('widget', () => {}) + it('should render correctly', () => { + const mockSetTxFlow = jest.fn() + + const { queryByText } = render( + <_RecoveryProposal + variant="widget" + safe={{ owners: [{ value: faker.finance.ethereumAddress() }] } as SafeInfo} + setTxFlow={mockSetTxFlow} + />, + ) + + expect(queryByText('Recover this Account')).toBeTruthy() + expect( + queryByText( + 'The connect wallet was chosen as a trusted guardian. You can help the owner regain access by updating the owner list.', + ), + ).toBeTruthy() + expect(queryByText('Learn more')).toBeTruthy() + + const recoveryButton = queryByText('Start recovery') + expect(recoveryButton).toBeTruthy() + + fireEvent.click(recoveryButton!) + + expect(mockSetTxFlow).toHaveBeenCalled() + }) +}) diff --git a/src/components/recovery/RecoveryModal/__tests__/index.test.tsx b/src/components/recovery/RecoveryModal/__tests__/index.test.tsx new file mode 100644 index 0000000000..2d1fff928f --- /dev/null +++ b/src/components/recovery/RecoveryModal/__tests__/index.test.tsx @@ -0,0 +1,108 @@ +import { BigNumber } from 'ethers' +import * as router from 'next/router' + +import { render, waitFor } from '@/tests/test-utils' +import { _RecoveryModal } from '..' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +describe('RecoveryModal', () => { + it('should not render the modal if there is a queue but the user is not an owner or guardian', () => { + const { queryByText } = render( + <_RecoveryModal + isOwner={false} + isGuardian={false} + queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} + > + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue user and the user is a guardian', () => { + const { queryByText } = render( + <_RecoveryModal isOwner={false} isGuardian queue={[]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue user and the user is an owner', () => { + const { queryByText } = render( + <_RecoveryModal isOwner isGuardian={false} queue={[]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should render the in-progress modal when there is a queue for guardians', () => { + const { queryByText } = render( + <_RecoveryModal isOwner={false} isGuardian queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the in-progress modal when there is a queue for owners', () => { + const { queryByText } = render( + <_RecoveryModal isOwner isGuardian={false} queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the proposal modal when there is no queue for guardians', () => { + const { queryByText } = render( + <_RecoveryModal isOwner={false} isGuardian queue={[]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Recover this Account')).toBeTruthy() + }) + + it('should close the modal when the user navigates away', async () => { + let onClose = () => {} + + jest.spyOn(router, 'useRouter').mockImplementation( + () => + ({ + events: { + on: jest.fn((_, callback) => { + onClose = callback + }), + }, + } as any), + ) + + const { queryByText } = render( + <_RecoveryModal isOwner isGuardian={false} queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + + onClose() + + await waitFor(() => { + expect(queryByText('Account recovery in progress')).toBeFalsy() + }) + }) +}) diff --git a/src/components/recovery/RecoveryModal/index.tsx b/src/components/recovery/RecoveryModal/index.tsx new file mode 100644 index 0000000000..78b76f5271 --- /dev/null +++ b/src/components/recovery/RecoveryModal/index.tsx @@ -0,0 +1,73 @@ +import { Backdrop, Fade } from '@mui/material' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import type { ReactElement, ReactNode } from 'react' + +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' +import { RecoveryInProgress } from './RecoveryInProgress' +import { RecoveryProposal } from './RecoveryProposal' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsGuardian } from '@/hooks/useIsGuardian' +import madProps from '@/utils/mad-props' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +import css from './styles.module.css' + +export function _RecoveryModal({ + children, + isOwner, + isGuardian, + queue, +}: { + children: ReactNode + isOwner: boolean + isGuardian: boolean + queue: Array +}): ReactElement { + const [modal, setModal] = useState(null) + const router = useRouter() + + const next = queue[0] + + const onClose = () => { + setModal(null) + } + + // Trigger modal + useEffect(() => { + setModal(() => { + if (next) { + return + } + if (isGuardian && queue.length === 0) { + return + } + return null + }) + }, [queue.length, isOwner, isGuardian, next]) + + // Close modal on navigation + useEffect(() => { + router.events.on('routeChangeComplete', onClose) + return () => { + router.events.off('routeChangeComplete', onClose) + } + }, [router]) + + return ( + <> + + + {modal} + + + {children} + + ) +} + +export const RecoveryModal = madProps(_RecoveryModal, { + isOwner: useIsSafeOwner, + isGuardian: useIsGuardian, + queue: useRecoveryQueue, +}) diff --git a/src/components/recovery/RecoveryModal/styles.module.css b/src/components/recovery/RecoveryModal/styles.module.css new file mode 100644 index 0000000000..ce22c8fe25 --- /dev/null +++ b/src/components/recovery/RecoveryModal/styles.module.css @@ -0,0 +1,13 @@ +.backdrop { + z-index: 3; + background-color: var(--color-background-main); +} + +.card { + max-width: 576px; + padding: var(--space-4); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index 3ab0044252..b08b26428d 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -14,7 +14,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { AppRoutes } from '@/config/routes' import useTxQueue from '@/hooks/useTxQueue' import { useAppSelector } from '@/store' -import { selectAllRecoveryQueues } from '@/store/recoverySlice' +import { selectRecoveryQueues } from '@/store/recoverySlice' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] @@ -25,7 +25,7 @@ const Navigation = (): ReactElement => { const { safe } = useSafeInfo() const currentSubdirectory = getSubdirectory(router.pathname) const hasQueuedTxs = Boolean(useTxQueue().page?.results.length) - const hasRecoveryTxs = Boolean(useAppSelector(selectAllRecoveryQueues).length) + const hasRecoveryTxs = Boolean(useAppSelector(selectRecoveryQueues).length) // Indicate whether the current Safe needs an upgrade const setupItem = navItems.find((item) => item.href === AppRoutes.settings.setup) diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx index 0b5322c5c9..6b9d75e9b3 100644 --- a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx +++ b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx @@ -53,7 +53,7 @@ export function EnableRecoveryFlowReview({ params }: { params: EnableRecoveryFlo null}> This transaction will enable the Account recovery feature once executed. - + diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx index a11d7ba3d7..b51ab5f7a8 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx @@ -11,6 +11,7 @@ import { Tooltip, } from '@mui/material' import { useForm, FormProvider, useFieldArray, Controller } from 'react-hook-form' +import { Fragment } from 'react' import type { ReactElement } from 'react' import TxCard from '../../common/TxCard' @@ -64,7 +65,7 @@ export function RecoverAccountFlowSetup({ {fields.map((field, index) => ( - <> + )} - + ))} diff --git a/src/hooks/useIsGuardian.ts b/src/hooks/useIsGuardian.ts new file mode 100644 index 0000000000..eb2ae9eb4e --- /dev/null +++ b/src/hooks/useIsGuardian.ts @@ -0,0 +1,8 @@ +import { useAppSelector } from '@/store' +import { selectDelayModifierByGuardian } from '@/store/recoverySlice' +import useWallet from './wallets/useWallet' + +export function useIsGuardian() { + const wallet = useWallet() + return !!useAppSelector((state) => selectDelayModifierByGuardian(state, wallet?.address ?? '')) +} diff --git a/src/hooks/useRecoveryQueue.ts b/src/hooks/useRecoveryQueue.ts new file mode 100644 index 0000000000..69bf04458c --- /dev/null +++ b/src/hooks/useRecoveryQueue.ts @@ -0,0 +1,13 @@ +import { useAppSelector } from '@/store' +import { selectRecoveryQueues } from '@/store/recoverySlice' +import { useClock } from './useClock' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +export function useRecoveryQueue(): Array { + const queue = useAppSelector(selectRecoveryQueues) + const clock = useClock() + + return queue.filter(({ expiresAt }) => { + return expiresAt ? expiresAt.gt(clock) : true + }) +} From 7740037baeece7d13f91bcf109e5e525892566f5 Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 21 Nov 2023 19:42:19 +0100 Subject: [PATCH 2/4] fix: remove unnecessary component + comment --- src/components/settings/Recovery/index.tsx | 8 +------- src/hooks/useRecoveryTxState.ts | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index 5aa56a805b..1c40ee5d0a 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -175,13 +175,7 @@ export function Recovery(): ReactElement { ) : ( - <> - - {/* TODO: Move to correct location when widget is ready */} - - + )} diff --git a/src/hooks/useRecoveryTxState.ts b/src/hooks/useRecoveryTxState.ts index 97b6f103eb..4c39fe56a3 100644 --- a/src/hooks/useRecoveryTxState.ts +++ b/src/hooks/useRecoveryTxState.ts @@ -3,7 +3,6 @@ import { useAppSelector } from '@/store' import { selectDelayModifierByTxHash } from '@/store/recoverySlice' import type { RecoveryQueueItem } from '@/store/recoverySlice' -// TODO: Test export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args }: RecoveryQueueItem): { isNext: boolean isExecutable: boolean From da2a2c06fbb998e8258a8d2fccd41fa1d085b534 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 22 Nov 2023 12:27:04 +0100 Subject: [PATCH 3/4] fix: cache modal dismissals --- .../dashboard/RecoveryHeader/index.tsx | 8 +- .../RecoveryInProgressCard.tsx} | 8 +- .../RecoveryProposalCard.tsx} | 16 +- .../RecoveryInProgressCard.test.tsx} | 26 +- .../__tests__/RecoveryProposalCard.test.tsx} | 16 +- .../styles.module.css | 5 - .../RecoveryModal/__tests__/index.test.tsx | 108 -------- .../recovery/RecoveryModal/index.test.tsx | 252 ++++++++++++++++++ .../recovery/RecoveryModal/index.tsx | 127 ++++++++- 9 files changed, 408 insertions(+), 158 deletions(-) rename src/components/recovery/{RecoveryModal/RecoveryInProgress.tsx => RecoveryCards/RecoveryInProgressCard.tsx} (92%) rename src/components/recovery/{RecoveryModal/RecoveryProposal.tsx => RecoveryCards/RecoveryProposalCard.tsx} (85%) rename src/components/recovery/{RecoveryModal/__tests__/RecoveryInProgress.test.tsx => RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx} (85%) rename src/components/recovery/{RecoveryModal/__tests__/RecoveryProposal.test.tsx => RecoveryCards/__tests__/RecoveryProposalCard.test.tsx} (85%) rename src/components/recovery/{RecoveryModal => RecoveryCards}/styles.module.css (63%) delete mode 100644 src/components/recovery/RecoveryModal/__tests__/index.test.tsx create mode 100644 src/components/recovery/RecoveryModal/index.test.tsx diff --git a/src/components/dashboard/RecoveryHeader/index.tsx b/src/components/dashboard/RecoveryHeader/index.tsx index 6775d910c3..b2005b8e39 100644 --- a/src/components/dashboard/RecoveryHeader/index.tsx +++ b/src/components/dashboard/RecoveryHeader/index.tsx @@ -6,8 +6,8 @@ import { useIsGuardian } from '@/hooks/useIsGuardian' import madProps from '@/utils/mad-props' import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' -import { RecoveryProposal } from '@/components/recovery/RecoveryModal/RecoveryProposal' -import { RecoveryInProgress } from '@/components/recovery/RecoveryModal/RecoveryInProgress' +import { RecoveryProposalCard } from '@/components/recovery/RecoveryCards/RecoveryProposalCard' +import { RecoveryInProgressCard } from '@/components/recovery/RecoveryCards/RecoveryInProgressCard' import { WidgetContainer, WidgetBody } from '../styled' import type { RecoveryQueueItem } from '@/store/recoverySlice' @@ -28,9 +28,9 @@ export function _RecoveryHeader({ } const modal = next ? ( - + ) : isGuardian ? ( - + ) : null if (modal) { diff --git a/src/components/recovery/RecoveryModal/RecoveryInProgress.tsx b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx similarity index 92% rename from src/components/recovery/RecoveryModal/RecoveryInProgress.tsx rename to src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx index 95fcf6fdc3..2598a8b222 100644 --- a/src/components/recovery/RecoveryModal/RecoveryInProgress.tsx +++ b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx @@ -13,17 +13,17 @@ import css from './styles.module.css' type Props = | { - variant?: 'modal' + orientation?: 'vertical' onClose: () => void recovery: RecoveryQueueItem } | { - variant: 'widget' + orientation: 'horizontal' onClose?: never recovery: RecoveryQueueItem } -export function RecoveryInProgress({ variant = 'modal', onClose, recovery }: Props): ReactElement { +export function RecoveryInProgressCard({ orientation = 'vertical', onClose, recovery }: Props): ReactElement { const { isExecutable, remainingSeconds } = useRecoveryTxState(recovery) const router = useRouter() @@ -50,7 +50,7 @@ export function RecoveryInProgress({ variant = 'modal', onClose, recovery }: Pro ) - if (variant === 'widget') { + if (orientation === 'horizontal') { return ( diff --git a/src/components/recovery/RecoveryModal/RecoveryProposal.tsx b/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx similarity index 85% rename from src/components/recovery/RecoveryModal/RecoveryProposal.tsx rename to src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx index 08ebb3713e..3ace2208aa 100644 --- a/src/components/recovery/RecoveryModal/RecoveryProposal.tsx +++ b/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx @@ -15,19 +15,19 @@ import css from './styles.module.css' type Props = | { - variant?: 'modal' + orientation?: 'vertical' onClose: () => void safe: SafeInfo setTxFlow: TxModalContextType['setTxFlow'] } | { - variant: 'widget' + orientation: 'horizontal' onClose?: never safe: SafeInfo setTxFlow: TxModalContextType['setTxFlow'] } -export function _RecoveryProposal({ variant = 'modal', onClose, safe, setTxFlow }: Props): ReactElement { +export function _RecoveryProposalCard({ orientation = 'vertical', onClose, safe, setTxFlow }: Props): ReactElement { const onRecover = async () => { onClose?.() setTxFlow() @@ -49,19 +49,19 @@ export function _RecoveryProposal({ variant = 'modal', onClose, safe, setTxFlow ) const recoveryButton = ( - ) - if (variant === 'widget') { + if (orientation === 'horizontal') { return ( {icon} - + {title} @@ -88,7 +88,7 @@ export function _RecoveryProposal({ variant = 'modal', onClose, safe, setTxFlow - + {title} @@ -112,7 +112,7 @@ export function _RecoveryProposal({ variant = 'modal', onClose, safe, setTxFlow const _useSafe = () => useSafeInfo().safe const _useSetTxFlow = () => useContext(TxModalContext).setTxFlow -export const RecoveryProposal = madProps(_RecoveryProposal, { +export const RecoveryProposalCard = madProps(_RecoveryProposalCard, { safe: _useSafe, setTxFlow: _useSetTxFlow, }) diff --git a/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx similarity index 85% rename from src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx rename to src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx index 8b45428347..91b0d95cbe 100644 --- a/src/components/recovery/RecoveryModal/__tests__/RecoveryInProgress.test.tsx +++ b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx @@ -2,7 +2,7 @@ import { BigNumber } from 'ethers' import { fireEvent, waitFor } from '@testing-library/react' import { render } from '@/tests/test-utils' -import { RecoveryInProgress } from '../RecoveryInProgress' +import { RecoveryInProgressCard } from '../RecoveryInProgressCard' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' import type { RecoveryQueueItem } from '@/store/recoverySlice' @@ -10,12 +10,12 @@ jest.mock('@/hooks/useRecoveryTxState') const mockUseRecoveryTxState = useRecoveryTxState as jest.MockedFunction -describe('RecoveryInProgress', () => { +describe('RecoveryInProgressCard', () => { beforeEach(() => { jest.clearAllMocks() }) - describe('modal', () => { + describe('vertical', () => { it('should render executable recovery state correctly', async () => { mockUseRecoveryTxState.mockReturnValue({ isExecutable: true, @@ -25,8 +25,8 @@ describe('RecoveryInProgress', () => { const mockClose = jest.fn() const { queryByText } = render( - , @@ -58,8 +58,8 @@ describe('RecoveryInProgress', () => { const mockClose = jest.fn() const { queryByText } = render( - , @@ -82,7 +82,7 @@ describe('RecoveryInProgress', () => { }) }) }) - describe('widget', () => { + describe('horizontal', () => { it('should render executable recovery state correctly', () => { mockUseRecoveryTxState.mockReturnValue({ isExecutable: true, @@ -90,7 +90,10 @@ describe('RecoveryInProgress', () => { } as any) const { queryByText } = render( - , + , ) ;['days', 'hrs', 'mins'].forEach((unit) => { @@ -109,7 +112,10 @@ describe('RecoveryInProgress', () => { } as any) const { queryByText } = render( - , + , ) expect(queryByText('Go to dashboard')).toBeFalsy() diff --git a/src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx b/src/components/recovery/RecoveryCards/__tests__/RecoveryProposalCard.test.tsx similarity index 85% rename from src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx rename to src/components/recovery/RecoveryCards/__tests__/RecoveryProposalCard.test.tsx index f5184c0507..8ca5df4bed 100644 --- a/src/components/recovery/RecoveryModal/__tests__/RecoveryProposal.test.tsx +++ b/src/components/recovery/RecoveryCards/__tests__/RecoveryProposalCard.test.tsx @@ -2,17 +2,17 @@ import { faker } from '@faker-js/faker' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { fireEvent, render } from '@/tests/test-utils' -import { _RecoveryProposal } from '../RecoveryProposal' +import { _RecoveryProposalCard } from '../RecoveryProposalCard' -describe('RecoveryProposal', () => { - describe('modal', () => { +describe('RecoveryProposalCard', () => { + describe('vertical', () => { it('should render correctly', () => { const mockClose = jest.fn() const mockSetTxFlow = jest.fn() const { queryByText } = render( - <_RecoveryProposal - variant="modal" + <_RecoveryProposalCard + orientation="vertical" onClose={mockClose} safe={{ owners: [{ value: faker.finance.ethereumAddress() }] } as SafeInfo} setTxFlow={mockSetTxFlow} @@ -36,13 +36,13 @@ describe('RecoveryProposal', () => { expect(mockSetTxFlow).toHaveBeenCalled() }) }) - describe('widget', () => {}) + describe('horizontal', () => {}) it('should render correctly', () => { const mockSetTxFlow = jest.fn() const { queryByText } = render( - <_RecoveryProposal - variant="widget" + <_RecoveryProposalCard + orientation="horizontal" safe={{ owners: [{ value: faker.finance.ethereumAddress() }] } as SafeInfo} setTxFlow={mockSetTxFlow} />, diff --git a/src/components/recovery/RecoveryModal/styles.module.css b/src/components/recovery/RecoveryCards/styles.module.css similarity index 63% rename from src/components/recovery/RecoveryModal/styles.module.css rename to src/components/recovery/RecoveryCards/styles.module.css index ce22c8fe25..6aa366375a 100644 --- a/src/components/recovery/RecoveryModal/styles.module.css +++ b/src/components/recovery/RecoveryCards/styles.module.css @@ -1,8 +1,3 @@ -.backdrop { - z-index: 3; - background-color: var(--color-background-main); -} - .card { max-width: 576px; padding: var(--space-4); diff --git a/src/components/recovery/RecoveryModal/__tests__/index.test.tsx b/src/components/recovery/RecoveryModal/__tests__/index.test.tsx deleted file mode 100644 index 2d1fff928f..0000000000 --- a/src/components/recovery/RecoveryModal/__tests__/index.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { BigNumber } from 'ethers' -import * as router from 'next/router' - -import { render, waitFor } from '@/tests/test-utils' -import { _RecoveryModal } from '..' -import type { RecoveryQueueItem } from '@/store/recoverySlice' - -describe('RecoveryModal', () => { - it('should not render the modal if there is a queue but the user is not an owner or guardian', () => { - const { queryByText } = render( - <_RecoveryModal - isOwner={false} - isGuardian={false} - queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]} - > - Test - , - ) - - expect(queryByText('Test')).toBeTruthy() - expect(queryByText('recovery')).toBeFalsy() - }) - - it('should not render the modal if there is no queue user and the user is a guardian', () => { - const { queryByText } = render( - <_RecoveryModal isOwner={false} isGuardian queue={[]}> - Test - , - ) - - expect(queryByText('Test')).toBeTruthy() - expect(queryByText('recovery')).toBeFalsy() - }) - - it('should not render the modal if there is no queue user and the user is an owner', () => { - const { queryByText } = render( - <_RecoveryModal isOwner isGuardian={false} queue={[]}> - Test - , - ) - - expect(queryByText('Test')).toBeTruthy() - expect(queryByText('recovery')).toBeFalsy() - }) - - it('should render the in-progress modal when there is a queue for guardians', () => { - const { queryByText } = render( - <_RecoveryModal isOwner={false} isGuardian queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> - Test - , - ) - - expect(queryByText('Test')).toBeTruthy() - expect(queryByText('Account recovery in progress')).toBeTruthy() - }) - - it('should render the in-progress modal when there is a queue for owners', () => { - const { queryByText } = render( - <_RecoveryModal isOwner isGuardian={false} queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> - Test - , - ) - - expect(queryByText('Test')).toBeTruthy() - expect(queryByText('Account recovery in progress')).toBeTruthy() - }) - - it('should render the proposal modal when there is no queue for guardians', () => { - const { queryByText } = render( - <_RecoveryModal isOwner={false} isGuardian queue={[]}> - Test - , - ) - - expect(queryByText('Test')).toBeTruthy() - expect(queryByText('Recover this Account')).toBeTruthy() - }) - - it('should close the modal when the user navigates away', async () => { - let onClose = () => {} - - jest.spyOn(router, 'useRouter').mockImplementation( - () => - ({ - events: { - on: jest.fn((_, callback) => { - onClose = callback - }), - }, - } as any), - ) - - const { queryByText } = render( - <_RecoveryModal isOwner isGuardian={false} queue={[{ validFrom: BigNumber.from(0) } as RecoveryQueueItem]}> - Test - , - ) - - expect(queryByText('Test')).toBeTruthy() - expect(queryByText('Account recovery in progress')).toBeTruthy() - - onClose() - - await waitFor(() => { - expect(queryByText('Account recovery in progress')).toBeFalsy() - }) - }) -}) diff --git a/src/components/recovery/RecoveryModal/index.test.tsx b/src/components/recovery/RecoveryModal/index.test.tsx new file mode 100644 index 0000000000..5e8c2a0e95 --- /dev/null +++ b/src/components/recovery/RecoveryModal/index.test.tsx @@ -0,0 +1,252 @@ +import { BigNumber } from 'ethers' +import { faker } from '@faker-js/faker' +import { renderHook } from '@testing-library/react' +import * as router from 'next/router' + +import { render, waitFor } from '@/tests/test-utils' +import { safeInfoBuilder } from '@/tests/builders/safe' +import { connectedWalletBuilder } from '@/tests/builders/wallet' +import * as safeInfo from '@/hooks/useSafeInfo' +import { _useDidDismissProposal } from './index' +import type { RecoveryQueueItem } from '@/store/recoverySlice' + +describe('RecoveryModal', () => { + describe('component', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let _RecoveryModal: typeof import('./index')._RecoveryModal + + beforeEach(() => { + localStorage.clear() + + // Clear cache in between tests + _RecoveryModal = require('./index')._RecoveryModal + }) + + it('should not render the modal if there is a queue but the user is not an owner or guardian', () => { + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0) } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue and the user is a guardian', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should not render the modal if there is no queue and the user is an owner', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('recovery')).toBeFalsy() + }) + + it('should render the in-progress modal when there is a queue for guardians', () => { + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0) } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the in-progress modal when there is a queue for owners', () => { + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0) } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + }) + + it('should render the proposal modal when there is no queue for guardians', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner={false} isGuardian queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Recover this Account')).toBeTruthy() + }) + + it('should not render the proposal modal when there is no queue for owners', () => { + const wallet = connectedWalletBuilder().build() + const queue = [] as Array + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Recover this Account')).toBeFalsy() + }) + + it('should close the modal when the user navigates away', async () => { + const mockUseRouter = { + push: jest.fn(), + query: {}, + events: { + on: jest.fn(), + off: jest.fn(), + }, + } + + jest.spyOn(router, 'useRouter').mockReturnValue(mockUseRouter as any) + + const wallet = connectedWalletBuilder().build() + const queue = [{ validFrom: BigNumber.from(0), transactionHash: faker.string.hexadecimal() } as RecoveryQueueItem] + + const { queryByText } = render( + <_RecoveryModal wallet={wallet} isOwner isGuardian={false} queue={queue}> + Test + , + ) + + expect(queryByText('Test')).toBeTruthy() + expect(queryByText('Account recovery in progress')).toBeTruthy() + + // Trigger the route change + mockUseRouter.events.on.mock.calls[0][1]() + + await waitFor(() => { + expect(queryByText('Account recovery in progress')).toBeFalsy() + }) + }) + }) + + describe('hooks', () => { + beforeEach(() => { + localStorage.clear() + + const safe = safeInfoBuilder().build() + jest + .spyOn(safeInfo, 'default') + .mockReturnValue({ safe, safeAddress: safe.address.value } as ReturnType) + }) + + describe('useDidDismissProposal', () => { + it('should return false if the proposal was not dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result } = renderHook(() => _useDidDismissProposal()) + + expect(result.current.wasProposalDismissed(guardianAddress)).toBeFalsy() + }) + + it('should return true if the proposal was dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result, rerender } = renderHook(() => _useDidDismissProposal()) + + expect(result.current.wasProposalDismissed(guardianAddress)).toBeFalsy() + result.current.dismissProposal(guardianAddress) + + rerender() + + expect(result.current.wasProposalDismissed(guardianAddress)).toBeTruthy() + }) + + it('should persist dismissals between sessions', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const firstRender = renderHook(() => _useDidDismissProposal()) + + expect(firstRender.result.current.wasProposalDismissed(guardianAddress)).toBeFalsy() + firstRender.result.current.dismissProposal(guardianAddress) + + firstRender.rerender() + + expect(firstRender.result.current.wasProposalDismissed(guardianAddress)).toBeTruthy() + + firstRender.unmount() + + const secondRender = renderHook(() => _useDidDismissProposal()) + expect(secondRender.result.current.wasProposalDismissed(guardianAddress)).toBeTruthy() + }) + }) + + describe('useDidDismissInProgress', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let _useDidDismissInProgress: typeof import('./index')._useDidDismissInProgress + + beforeEach(() => { + localStorage.clear() + + // Clear cache in between tests + _useDidDismissInProgress = require('./index')._useDidDismissInProgress + }) + + it('should return false if in-progress was not dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result } = renderHook(() => _useDidDismissInProgress()) + + expect(result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + }) + + it('should return true if in-progress was not dismissed before', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const { result } = renderHook(() => _useDidDismissInProgress()) + + expect(result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + result.current.dismissInProgress(guardianAddress) + expect(result.current.wasInProgressDismissed(guardianAddress)).toBeTruthy() + }) + + it('should not persist dismissals between sessions', () => { + const guardianAddress = faker.finance.ethereumAddress() + + const firstRender = renderHook(() => _useDidDismissInProgress()) + + expect(firstRender.result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + firstRender.result.current.dismissInProgress(guardianAddress) + expect(firstRender.result.current.wasInProgressDismissed(guardianAddress)).toBeTruthy() + + firstRender.unmount() + + const secondRender = renderHook(() => _useDidDismissInProgress()) + expect(secondRender.result.current.wasInProgressDismissed(guardianAddress)).toBeFalsy() + }) + }) + }) +}) diff --git a/src/components/recovery/RecoveryModal/index.tsx b/src/components/recovery/RecoveryModal/index.tsx index 78b76f5271..703865d947 100644 --- a/src/components/recovery/RecoveryModal/index.tsx +++ b/src/components/recovery/RecoveryModal/index.tsx @@ -1,34 +1,42 @@ import { Backdrop, Fade } from '@mui/material' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useRouter } from 'next/router' import type { ReactElement, ReactNode } from 'react' import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' -import { RecoveryInProgress } from './RecoveryInProgress' -import { RecoveryProposal } from './RecoveryProposal' +import { RecoveryInProgressCard } from '../RecoveryCards/RecoveryInProgressCard' +import { RecoveryProposalCard } from '../RecoveryCards/RecoveryProposalCard' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { useIsGuardian } from '@/hooks/useIsGuardian' import madProps from '@/utils/mad-props' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import useWallet from '@/hooks/wallets/useWallet' +import useSafeInfo from '@/hooks/useSafeInfo' +import { sameAddress } from '@/utils/addresses' import type { RecoveryQueueItem } from '@/store/recoverySlice' -import css from './styles.module.css' - export function _RecoveryModal({ children, isOwner, isGuardian, queue, + wallet, }: { children: ReactNode isOwner: boolean isGuardian: boolean queue: Array + wallet: ReturnType }): ReactElement { + const { wasProposalDismissed, dismissProposal } = _useDidDismissProposal() + const { wasInProgressDismissed, dismissInProgress } = _useDidDismissInProgress() + const [modal, setModal] = useState(null) const router = useRouter() const next = queue[0] + // Close modal const onClose = () => { setModal(null) } @@ -36,15 +44,37 @@ export function _RecoveryModal({ // Trigger modal useEffect(() => { setModal(() => { - if (next) { - return + if (next && !wasInProgressDismissed(next.transactionHash)) { + const onCloseWithDismiss = () => { + dismissInProgress(next.transactionHash) + onClose() + } + + return } - if (isGuardian && queue.length === 0) { - return + + if (wallet?.address && !isOwner && !wasProposalDismissed(wallet.address)) { + const onCloseWithDismiss = () => { + dismissProposal(wallet.address) + onClose() + } + + return } + return null }) - }, [queue.length, isOwner, isGuardian, next]) + }, [ + dismissInProgress, + dismissProposal, + isGuardian, + isOwner, + next, + queue.length, + wallet, + wasInProgressDismissed, + wasProposalDismissed, + ]) // Close modal on navigation useEffect(() => { @@ -57,7 +87,7 @@ export function _RecoveryModal({ return ( <> - + palette.background.main }}> {modal} @@ -70,4 +100,79 @@ export const RecoveryModal = madProps(_RecoveryModal, { isOwner: useIsSafeOwner, isGuardian: useIsGuardian, queue: useRecoveryQueue, + wallet: useWallet, }) + +export function _useDidDismissProposal() { + const LS_KEY = 'dismissedRecoveryProposals' + + type Guardian = string + type DismissedProposalCache = { [chainId: string]: { [safeAddress: string]: Guardian } } + + const { safe, safeAddress } = useSafeInfo() + const chainId = safe.chainId + + const [dismissedProposals, setDismissedProposals] = useLocalStorage(LS_KEY) + + // Cache dismissal of proposal modal + const dismissProposal = useCallback( + (guardianAddress: string) => { + const dismissed = dismissedProposals?.[chainId] ?? {} + + setDismissedProposals({ + ...(dismissedProposals ?? {}), + [chainId]: { + ...dismissed, + [safeAddress]: guardianAddress, + }, + }) + }, + [dismissedProposals, chainId, safeAddress, setDismissedProposals], + ) + + const wasProposalDismissed = useCallback( + (guardianAddress: string) => { + // If no proposals, is guardian and didn't ever dismiss + return sameAddress(dismissedProposals?.[chainId]?.[safeAddress], guardianAddress) + }, + [chainId, dismissedProposals, safeAddress], + ) + + return { wasProposalDismissed, dismissProposal } +} + +export function _useDidDismissInProgress() { + type TxHash = string + type DismissedInProgressCache = { [chainId: string]: { [safeAddress: string]: TxHash } } + + const { safe, safeAddress } = useSafeInfo() + const chainId = safe.chainId + + const dismissedInProgress = useRef({}) + + // Cache dismissal of in-progress modal + const dismissInProgress = useCallback( + (txHash: string) => { + const dismissed = dismissedInProgress.current?.[chainId] ?? {} + + dismissedInProgress.current = { + ...dismissedInProgress.current, + [chainId]: { + ...dismissed, + [safeAddress]: txHash, + }, + } + }, + [chainId, safeAddress], + ) + + const wasInProgressDismissed = useCallback( + (txHash: string) => { + // If proposal and did not notify during current session of Safe + return sameAddress(txHash, dismissedInProgress.current?.[chainId]?.[safeAddress]) + }, + [chainId, safeAddress], + ) + + return { wasInProgressDismissed, dismissInProgress } +} From 165c9d3cff110b8042bae202c610e031c05fcec5 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 22 Nov 2023 12:45:29 +0100 Subject: [PATCH 4/4] fix: lint --- src/components/dashboard/RecoveryHeader/index.tsx | 6 ++++-- .../recovery/RecoveryCards/RecoveryProposalCard.tsx | 6 +++--- src/components/settings/Recovery/index.tsx | 1 - 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/dashboard/RecoveryHeader/index.tsx b/src/components/dashboard/RecoveryHeader/index.tsx index b2005b8e39..533b8aae2a 100644 --- a/src/components/dashboard/RecoveryHeader/index.tsx +++ b/src/components/dashboard/RecoveryHeader/index.tsx @@ -9,6 +9,7 @@ import { useHasFeature } from '@/hooks/useChains' import { RecoveryProposalCard } from '@/components/recovery/RecoveryCards/RecoveryProposalCard' import { RecoveryInProgressCard } from '@/components/recovery/RecoveryCards/RecoveryInProgressCard' import { WidgetContainer, WidgetBody } from '../styled' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' import type { RecoveryQueueItem } from '@/store/recoverySlice' export function _RecoveryHeader({ @@ -28,9 +29,9 @@ export function _RecoveryHeader({ } const modal = next ? ( - + ) : isGuardian ? ( - + ) : null if (modal) { @@ -49,6 +50,7 @@ export function _RecoveryHeader({ const _useSupportedRecovery = () => useHasFeature(FEATURES.RECOVERY) export const RecoveryHeader = madProps(_RecoveryHeader, { + isOwner: useIsSafeOwner, isGuardian: useIsGuardian, supportsRecovery: _useSupportedRecovery, queue: useRecoveryQueue, diff --git a/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx b/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx index 3ace2208aa..9ac434f099 100644 --- a/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx +++ b/src/components/recovery/RecoveryCards/RecoveryProposalCard.tsx @@ -49,7 +49,7 @@ export function _RecoveryProposalCard({ orientation = 'vertical', onClose, safe, ) const recoveryButton = ( - ) @@ -61,7 +61,7 @@ export function _RecoveryProposalCard({ orientation = 'vertical', onClose, safe, {icon} - + {title} @@ -88,7 +88,7 @@ export function _RecoveryProposalCard({ orientation = 'vertical', onClose, safe, - + {title} diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index 1c40ee5d0a..38fd83f4a7 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -6,7 +6,6 @@ import { EnableRecoveryFlow } from '@/components/tx-flow/flows/EnableRecovery' import { TxModalContext } from '@/components/tx-flow' import { Chip } from '@/components/common/Chip' import ExternalLink from '@/components/common/ExternalLink' -import { RecoverAccountFlow } from '@/components/tx-flow/flows/RecoverAccount' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { useAppSelector } from '@/store' import { selectRecovery } from '@/store/recoverySlice'