diff --git a/src/components/unstake/unstake_bundle.tsx b/src/components/unstake/unstake_bundle.tsx index a32f362e..0928e9cf 100644 --- a/src/components/unstake/unstake_bundle.tsx +++ b/src/components/unstake/unstake_bundle.tsx @@ -9,7 +9,7 @@ interface UnstakeBundleProps { stakingApi: StakingApi; bundle: BundleInfo; formDisabled: boolean; - unstake: (amount: BigNumber, nftId: string, max:boolean, bundle: BundleInfo) => void; + unstake: (amount: BigNumber, nftId: string, max:boolean, bundle: BundleInfo) => Promise | void; } export default function UnstakeBundle(props: UnstakeBundleProps) { @@ -23,4 +23,4 @@ export default function UnstakeBundle(props: UnstakeBundleProps) { ); -} \ No newline at end of file +} diff --git a/src/components/unstake/unstake_bundle_form.tsx b/src/components/unstake/unstake_bundle_form.tsx index 79a45efd..746ab930 100644 --- a/src/components/unstake/unstake_bundle_form.tsx +++ b/src/components/unstake/unstake_bundle_form.tsx @@ -2,7 +2,7 @@ import { Alert, Button, Checkbox, FormControlLabel, Grid, InputAdornment, TextFi import { BigNumber } from "ethers"; import { parseEther } from "ethers/lib/utils"; import { useTranslation } from "next-i18next"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { useDispatch, useSelector } from "react-redux"; import { BundleInfo } from "../../backend/bundle_info"; @@ -19,7 +19,7 @@ interface UnstakeBundleFormProps { stakingApi: StakingApi; bundle: BundleInfo; formDisabled: boolean; - unstake: (amount: BigNumber, nftId: string, max: boolean, bundle: BundleInfo) => void; + unstake: (amount: BigNumber, nftId: string, max: boolean, bundle: BundleInfo) => Promise | void; } type IUnstakeFormValues = { @@ -60,6 +60,8 @@ export default function UnstakeBundleForm(props: UnstakeBundleFormProps) { unstakeMaxAmount: false, } }); + const isSubmittingUnstakeRef = useRef(false); + const [isSubmittingUnstake, setIsSubmittingUnstake] = useState(false); const errors = useMemo(() => formState.errors, [formState]); const watchUnstakeMaxAmount = watch("unstakeMaxAmount"); @@ -78,7 +80,7 @@ export default function UnstakeBundleForm(props: UnstakeBundleFormProps) { }, [formState.isValid, dispatch]); const canSubmit = useMemo(() => { - if (props.formDisabled) { + if (props.formDisabled || isSubmittingUnstake) { return false; } if (! formState.isValid) { @@ -92,7 +94,7 @@ export default function UnstakeBundleForm(props: UnstakeBundleFormProps) { return true; } return false; - }, [errors, getValues, props.formDisabled, formState.isValid]); + }, [errors, getValues, isSubmittingUnstake, props.formDisabled, formState.isValid]); function back() { dispatch(bundleSelected(null)); @@ -102,10 +104,21 @@ export default function UnstakeBundleForm(props: UnstakeBundleFormProps) { const onSubmit: SubmitHandler = async data => { const values = getValues(); + if (isSubmittingUnstakeRef.current) { + return; + } + if ((values.unstakedAmount && errors.unstakedAmount === undefined) || values.unstakeMaxAmount) { - const unstakedAmount = parseEther(values.unstakedAmount); - const unstakeMaxAmount = values.unstakeMaxAmount; - props.unstake(unstakedAmount, selectedNft!.nftId, unstakeMaxAmount, props.bundle) + isSubmittingUnstakeRef.current = true; + setIsSubmittingUnstake(true); + try { + const unstakedAmount = parseEther(values.unstakedAmount); + const unstakeMaxAmount = values.unstakeMaxAmount; + await props.unstake(unstakedAmount, selectedNft!.nftId, unstakeMaxAmount, props.bundle); + } finally { + isSubmittingUnstakeRef.current = false; + setIsSubmittingUnstake(false); + } } } diff --git a/tests/components/unstake/unstake_bundle_form.test.tsx b/tests/components/unstake/unstake_bundle_form.test.tsx new file mode 100644 index 00000000..c13b1c76 --- /dev/null +++ b/tests/components/unstake/unstake_bundle_form.test.tsx @@ -0,0 +1,80 @@ +import '@testing-library/jest-dom'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { parseEther } from 'ethers/lib/utils'; +import { BundleInfo } from '../../../src/backend/bundle_info'; +import { NftInfo } from '../../../src/backend/nft_info'; +import UnstakeBundleForm from '../../../src/components/unstake/unstake_bundle_form'; +import { BundleAction, StakesState } from '../../../src/redux/slices/stakes'; +import { StakingState } from '../../../src/redux/slices/staking'; +import { mockStakingApiSimple } from '../../mocks/staking_api'; +import { EMPTY_ROOT_STATE, renderWithProviders } from '../../util/render_with_provider'; + +jest.mock('react-i18next', () => ({ + ...jest.requireActual('react-i18next'), + useTranslation: () => { + return { + t: (str: string) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + }; + }, +})); + +describe('UnstakeBundleForm', () => { + it('submits the unstake transaction only once on a double click', async () => { + const user = userEvent.setup(); + const stakingApi = mockStakingApiSimple(); + const bundle = { + id: "0x1234-1", + nftId: "76594322", + myStakedNfsIds: ["1234"], + } as BundleInfo; + const ownedNfts = [ + { + nftId: "1234", + stakedAmount: parseEther("100").toString(), + targetNftId: "76594322", + unstakingAvailable: true, + } as NftInfo, + ]; + const unstake = jest.fn(() => new Promise(() => {})); + + renderWithProviders( + , + { + preloadedState: { + ...EMPTY_ROOT_STATE, + stakes: { + bundles: [], + selectedBundleIdx: null, + ownedNfts, + isLoadingBundles: false, + bundleAction: BundleAction.None, + pendingFeeless: false, + } as StakesState, + staking: { + step: 3, + stakeingBundle: bundle, + restakingBundle: null, + } as StakingState, + } + } + ); + + fireEvent.change(screen.getByLabelText('stakedAmount'), { target: { value: '10' } }); + + const submitButton = screen.getByRole('button', { name: 'action.unstake' }); + await waitFor(() => expect(submitButton).toBeEnabled()); + + await user.dblClick(submitButton); + + expect(unstake).toHaveBeenCalledTimes(1); + }); +});