From 3ab420c0afe2bc6ef96a0bad3a2b22a86a10cffd Mon Sep 17 00:00:00 2001 From: Panteleymonchuk Date: Tue, 17 Dec 2024 11:29:52 +0200 Subject: [PATCH] feat(wallet-dashboard): Add tx details state to the end of send coin flow (#4422) * feat(wallet-dashboard): implement SentSuccess view and update SendTokenDialog flow * feat(dashboard): enhance SendTokenDialog flow and improve transaction handling * feat(dashboard): rename SentSuccessView to TransactionDetailsView and update flow * fix(dashboard): improve error message for transaction info retrieval * feat(dashboard): add loading state with spinner during transaction fetching * feat(dashboard): implement transfer transaction mutation and refactor handling logic * refactor(explorer, core): move useRecognizedPackages from explorer to core. * feat(dashboard): integrate useRecognizedPackages for transaction details * fix(dashboard): correct import path for useTransferTransaction * remove with dialog content * refactor: format --------- Co-authored-by: marc2332 --- apps/core/src/hooks/index.ts | 1 + .../src/hooks/useRecognizedPackages.ts | 11 +- .../src/components/owned-coins/OwnedCoins.tsx | 9 +- apps/explorer/src/hooks/index.ts | 1 - apps/explorer/src/hooks/useResolveVideo.ts | 8 +- .../transaction-result/TransactionData.tsx | 8 +- .../transaction-result/TransactionView.tsx | 9 +- .../transaction-summary/index.tsx | 10 +- .../Dialogs/SendToken/SendTokenDialog.tsx | 105 ++++----- .../SendToken/views/EnterValuesFormView.tsx | 203 ++++++++++-------- .../SendToken/views/ReviewValuesFormView.tsx | 129 +++++------ .../views/TransactionDetailsView.tsx | 47 ++++ .../Dialogs/SendToken/views/index.ts | 1 + .../transaction/TransactionDetailsLayout.tsx | 58 +++++ .../components/Dialogs/transaction/index.ts | 4 + .../transactions/TransactionTile.tsx | 71 +----- apps/wallet-dashboard/hooks/index.ts | 1 + .../hooks/useTransferTransaction.ts | 24 +++ 18 files changed, 402 insertions(+), 298 deletions(-) rename apps/{explorer => core}/src/hooks/useRecognizedPackages.ts (61%) create mode 100644 apps/wallet-dashboard/components/Dialogs/SendToken/views/TransactionDetailsView.tsx create mode 100644 apps/wallet-dashboard/components/Dialogs/transaction/TransactionDetailsLayout.tsx create mode 100644 apps/wallet-dashboard/components/Dialogs/transaction/index.ts create mode 100644 apps/wallet-dashboard/hooks/useTransferTransaction.ts diff --git a/apps/core/src/hooks/index.ts b/apps/core/src/hooks/index.ts index 89aced3f57f..1602541e57b 100644 --- a/apps/core/src/hooks/index.ts +++ b/apps/core/src/hooks/index.ts @@ -47,5 +47,6 @@ export * from './useOwnedNFT'; export * from './useNftDetails'; export * from './useCountdownByTimestamp'; export * from './useStakeRewardStatus'; +export * from './useRecognizedPackages'; export * from './stake'; diff --git a/apps/explorer/src/hooks/useRecognizedPackages.ts b/apps/core/src/hooks/useRecognizedPackages.ts similarity index 61% rename from apps/explorer/src/hooks/useRecognizedPackages.ts rename to apps/core/src/hooks/useRecognizedPackages.ts index 811c2abfdf4..15a62c80447 100644 --- a/apps/explorer/src/hooks/useRecognizedPackages.ts +++ b/apps/core/src/hooks/useRecognizedPackages.ts @@ -2,18 +2,11 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { Feature } from '@iota/core'; import { useFeatureValue } from '@growthbook/growthbook-react'; -import { IOTA_FRAMEWORK_ADDRESS, IOTA_SYSTEM_ADDRESS } from '@iota/iota-sdk/utils'; import { Network } from '@iota/iota-sdk/client'; +import { Feature, DEFAULT_RECOGNIZED_PACKAGES } from '../../'; -import { useNetwork } from './'; - -const DEFAULT_RECOGNIZED_PACKAGES = [IOTA_FRAMEWORK_ADDRESS, IOTA_SYSTEM_ADDRESS]; - -export function useRecognizedPackages(): string[] { - const [network] = useNetwork(); - +export function useRecognizedPackages(network: Network): string[] { const recognizedPackages = useFeatureValue( Feature.RecognizedPackages, DEFAULT_RECOGNIZED_PACKAGES, diff --git a/apps/explorer/src/components/owned-coins/OwnedCoins.tsx b/apps/explorer/src/components/owned-coins/OwnedCoins.tsx index 8df810f8f0c..619fd8e9864 100644 --- a/apps/explorer/src/components/owned-coins/OwnedCoins.tsx +++ b/apps/explorer/src/components/owned-coins/OwnedCoins.tsx @@ -2,14 +2,14 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { getCoinSymbol } from '@iota/core'; +import { getCoinSymbol, useRecognizedPackages } from '@iota/core'; import { useIotaClientQuery } from '@iota/dapp-kit'; -import { type CoinBalance } from '@iota/iota-sdk/client'; +import { type CoinBalance, type Network } from '@iota/iota-sdk/client'; +import { useNetwork } from '~/hooks'; import { normalizeIotaAddress } from '@iota/iota-sdk/utils'; import { FilterList, Warning } from '@iota/ui-icons'; import { useMemo, useState } from 'react'; import OwnedCoinView from './OwnedCoinView'; -import { useRecognizedPackages } from '~/hooks/useRecognizedPackages'; import { Button, ButtonType, @@ -47,7 +47,8 @@ export function OwnedCoins({ id }: OwnerCoinsProps): JSX.Element { const { isPending, data, isError } = useIotaClientQuery('getAllBalances', { owner: normalizeIotaAddress(id), }); - const recognizedPackages = useRecognizedPackages(); + const [network] = useNetwork(); + const recognizedPackages = useRecognizedPackages(network as Network); const balances: Record = useMemo(() => { const balanceData = data?.reduce( diff --git a/apps/explorer/src/hooks/index.ts b/apps/explorer/src/hooks/index.ts index 9fbe82e3302..a52c31b12d6 100644 --- a/apps/explorer/src/hooks/index.ts +++ b/apps/explorer/src/hooks/index.ts @@ -17,7 +17,6 @@ export * from './useInitialPageView'; export * from './useMediaQuery'; export * from './useNetwork'; export * from './useNormalizedMoveModule'; -export * from './useRecognizedPackages'; export * from './useResolveVideo'; export * from './useSearch'; export * from './useVerifiedSourceCode'; diff --git a/apps/explorer/src/hooks/useResolveVideo.ts b/apps/explorer/src/hooks/useResolveVideo.ts index 19d7c871d23..a40622340a3 100644 --- a/apps/explorer/src/hooks/useResolveVideo.ts +++ b/apps/explorer/src/hooks/useResolveVideo.ts @@ -2,12 +2,14 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { type IotaObjectResponse } from '@iota/iota-sdk/client'; +import { type IotaObjectResponse, type Network } from '@iota/iota-sdk/client'; -import { useRecognizedPackages } from './useRecognizedPackages'; +import { useRecognizedPackages } from '@iota/core'; +import { useNetwork } from '~/hooks'; export function useResolveVideo(object: IotaObjectResponse): string | undefined | null { - const recognizedPackages = useRecognizedPackages(); + const [network] = useNetwork(); + const recognizedPackages = useRecognizedPackages(network as Network); const objectType = (object.data?.type ?? object?.data?.content?.dataType === 'package') ? 'package' diff --git a/apps/explorer/src/pages/transaction-result/TransactionData.tsx b/apps/explorer/src/pages/transaction-result/TransactionData.tsx index de3d78e5890..17617d7363a 100644 --- a/apps/explorer/src/pages/transaction-result/TransactionData.tsx +++ b/apps/explorer/src/pages/transaction-result/TransactionData.tsx @@ -2,13 +2,14 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { useTransactionSummary } from '@iota/core'; +import { useTransactionSummary, useRecognizedPackages } from '@iota/core'; import { type ProgrammableTransaction, type IotaTransactionBlockResponse, + type Network, } from '@iota/iota-sdk/client'; import { GasBreakdown } from '~/components'; -import { useRecognizedPackages } from '~/hooks/useRecognizedPackages'; +import { useNetwork } from '~/hooks/useNetwork'; import { InputsCard } from '~/pages/transaction-result/programmable-transaction-view/InputsCard'; import { TransactionsCard } from '~/pages/transaction-result/programmable-transaction-view/TransactionsCard'; @@ -17,7 +18,8 @@ interface TransactionDataProps { } export function TransactionData({ transaction }: TransactionDataProps): JSX.Element { - const recognizedPackagesList = useRecognizedPackages(); + const [network] = useNetwork(); + const recognizedPackagesList = useRecognizedPackages(network as Network); const summary = useTransactionSummary({ transaction, recognizedPackagesList, diff --git a/apps/explorer/src/pages/transaction-result/TransactionView.tsx b/apps/explorer/src/pages/transaction-result/TransactionView.tsx index 06b5f3d6c15..73af5407edf 100644 --- a/apps/explorer/src/pages/transaction-result/TransactionView.tsx +++ b/apps/explorer/src/pages/transaction-result/TransactionView.tsx @@ -2,7 +2,7 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { type IotaTransactionBlockResponse } from '@iota/iota-sdk/client'; +import { type Network, type IotaTransactionBlockResponse } from '@iota/iota-sdk/client'; import clsx from 'clsx'; import { useState } from 'react'; import { ErrorBoundary, SplitPanes } from '~/components'; @@ -11,8 +11,8 @@ import { TransactionData } from '~/pages/transaction-result/TransactionData'; import { TransactionSummary } from '~/pages/transaction-result/transaction-summary'; import { Signatures } from './Signatures'; import { TransactionDetails } from './transaction-summary/TransactionDetails'; -import { useTransactionSummary } from '@iota/core'; -import { useBreakpoint, useRecognizedPackages } from '~/hooks'; +import { useTransactionSummary, useRecognizedPackages } from '@iota/core'; +import { useBreakpoint, useNetwork } from '~/hooks'; import { ButtonSegment, ButtonSegmentType, @@ -42,7 +42,8 @@ export function TransactionView({ transaction }: TransactionViewProps): JSX.Elem const transactionKindName = transaction.transaction?.data.transaction?.kind; const isProgrammableTransaction = transactionKindName === 'ProgrammableTransaction'; - const recognizedPackagesList = useRecognizedPackages(); + const [network] = useNetwork(); + const recognizedPackagesList = useRecognizedPackages(network as Network); const summary = useTransactionSummary({ transaction, recognizedPackagesList, diff --git a/apps/explorer/src/pages/transaction-result/transaction-summary/index.tsx b/apps/explorer/src/pages/transaction-result/transaction-summary/index.tsx index 7ea356fbcb2..ea5d48cbab6 100644 --- a/apps/explorer/src/pages/transaction-result/transaction-summary/index.tsx +++ b/apps/explorer/src/pages/transaction-result/transaction-summary/index.tsx @@ -2,20 +2,20 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { useTransactionSummary } from '@iota/core'; -import { type IotaTransactionBlockResponse } from '@iota/iota-sdk/client'; - +import { useTransactionSummary, useRecognizedPackages } from '@iota/core'; +import { type IotaTransactionBlockResponse, type Network } from '@iota/iota-sdk/client'; import { BalanceChanges } from './BalanceChanges'; import { ObjectChanges } from './ObjectChanges'; import { UpgradedSystemPackages } from './UpgradedSystemPackages'; -import { useRecognizedPackages } from '~/hooks/useRecognizedPackages'; +import { useNetwork } from '~/hooks'; interface TransactionSummaryProps { transaction: IotaTransactionBlockResponse; } export function TransactionSummary({ transaction }: TransactionSummaryProps): JSX.Element { - const recognizedPackagesList = useRecognizedPackages(); + const [network] = useNetwork(); + const recognizedPackagesList = useRecognizedPackages(network as Network); const summary = useTransactionSummary({ transaction, recognizedPackagesList, diff --git a/apps/wallet-dashboard/components/Dialogs/SendToken/SendTokenDialog.tsx b/apps/wallet-dashboard/components/Dialogs/SendToken/SendTokenDialog.tsx index da417f66147..913e362e27d 100644 --- a/apps/wallet-dashboard/components/Dialogs/SendToken/SendTokenDialog.tsx +++ b/apps/wallet-dashboard/components/Dialogs/SendToken/SendTokenDialog.tsx @@ -2,16 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useState } from 'react'; -import { EnterValuesFormView, ReviewValuesFormView } from './views'; +import { EnterValuesFormView, ReviewValuesFormView, TransactionDetailsView } from './views'; import { CoinBalance } from '@iota/iota-sdk/client'; import { useSendCoinTransaction, useNotifications } from '@/hooks'; -import { useSignAndExecuteTransaction } from '@iota/dapp-kit'; import { NotificationType } from '@/stores/notificationStore'; import { CoinFormat, useFormatCoin, useGetAllCoins } from '@iota/core'; -import { Dialog, DialogBody, DialogContent, DialogPosition, Header } from '@iota/apps-ui-kit'; +import { Dialog, DialogContent, DialogPosition } from '@iota/apps-ui-kit'; import { FormDataValues } from './interfaces'; import { INITIAL_VALUES } from './constants'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; +import { useTransferTransactionMutation } from '@/hooks'; interface SendCoinPopupProps { coin: CoinBalance; @@ -23,6 +23,7 @@ interface SendCoinPopupProps { enum FormStep { EnterValues, ReviewValues, + TransactionDetails, } function SendTokenDialogBody({ @@ -34,11 +35,9 @@ function SendTokenDialogBody({ const [selectedCoin, setSelectedCoin] = useState(coin); const [formData, setFormData] = useState(INITIAL_VALUES); const [fullAmount] = useFormatCoin(formData.amount, selectedCoin.coinType, CoinFormat.FULL); - const { addNotification } = useNotifications(); - const { data: coinsData } = useGetAllCoins(selectedCoin.coinType, activeAddress); - const { mutateAsync: signAndExecuteTransaction, isPending } = useSignAndExecuteTransaction(); + const { addNotification } = useNotifications(); const isPayAllIota = selectedCoin.totalBalance === formData.amount && selectedCoin.coinType === IOTA_TYPE_ARG; @@ -51,25 +50,28 @@ function SendTokenDialogBody({ isPayAllIota, ); - function handleTransfer() { + const { + mutate: transfer, + data, + isPending: isLoadingTransfer, + } = useTransferTransactionMutation(); + + async function handleTransfer() { if (!transaction) { addNotification('There was an error with the transaction', NotificationType.Error); return; - } else { - signAndExecuteTransaction({ - transaction, - }) - .then(() => { - setOpen(false); - addNotification('Transfer transaction has been sent'); - }) - .catch(handleTransactionError); } - } - function handleTransactionError() { - setOpen(false); - addNotification('There was an error with the transaction', NotificationType.Error); + transfer(transaction, { + onSuccess: () => { + setStep(FormStep.TransactionDetails); + addNotification('Transfer transaction has been sent', NotificationType.Success); + }, + onError: () => { + setOpen(false); + addNotification('Transfer transaction failed', NotificationType.Error); + }, + }); } function onNext(): void { @@ -87,40 +89,43 @@ function SendTokenDialogBody({ return ( <> -
setOpen(false)} - onBack={step === FormStep.ReviewValues ? onBack : undefined} - /> -
- - {step === FormStep.EnterValues && ( - - )} - {step === FormStep.ReviewValues && ( - - )} - -
+ {step === FormStep.EnterValues && ( + setOpen(false)} + setFormData={setFormData} + initialFormValues={formData} + /> + )} + {step === FormStep.ReviewValues && ( + setOpen(false)} + onBack={onBack} + /> + )} + {step === FormStep.TransactionDetails && data?.digest && ( + { + setOpen(false); + setStep(FormStep.EnterValues); + }} + /> + )} ); } -export function SendTokenDialog(props: SendCoinPopupProps): React.JSX.Element { +export function SendTokenDialog(props: SendCoinPopupProps) { return ( diff --git a/apps/wallet-dashboard/components/Dialogs/SendToken/views/EnterValuesFormView.tsx b/apps/wallet-dashboard/components/Dialogs/SendToken/views/EnterValuesFormView.tsx index f38c0d95cfa..f33ddbc8fef 100644 --- a/apps/wallet-dashboard/components/Dialogs/SendToken/views/EnterValuesFormView.tsx +++ b/apps/wallet-dashboard/components/Dialogs/SendToken/views/EnterValuesFormView.tsx @@ -24,14 +24,16 @@ import { Button, InfoBoxStyle, LoadingIndicator, + Header, } from '@iota/apps-ui-kit'; import { useIotaClientQuery } from '@iota/dapp-kit'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; -import { Form, Formik, FormikProps } from 'formik'; +import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; import { Exclamation } from '@iota/ui-icons'; import { UseQueryResult } from '@tanstack/react-query'; import { FormDataValues } from '../interfaces'; import { INITIAL_VALUES } from '../constants'; +import { DialogLayoutBody, DialogLayoutFooter } from '../../layout'; interface EnterValuesFormProps { coin: CoinBalance; @@ -40,9 +42,10 @@ interface EnterValuesFormProps { setFormData: React.Dispatch>; setSelectedCoin: React.Dispatch>; onNext: () => void; + onClose: () => void; } -interface FormInputsProps extends FormikProps { +interface FormInputsProps { coinType: string; coinDecimals: number; coinBalance: bigint; @@ -52,6 +55,9 @@ interface FormInputsProps extends FormikProps { activeAddress: string; coins: CoinStruct[]; queryResult: UseQueryResult; + formattedAmount: bigint; + hasEnoughBalance: boolean; + isPayAllIota: boolean; } function totalBalance(coins: CoinStruct[]): bigint { @@ -62,29 +68,18 @@ function getBalanceFromCoinStruct(coin: CoinStruct): bigint { } function FormInputs({ - isValid, - isSubmitting, - setFieldValue, - values, - submitForm, - coinType, coinDecimals, coinBalance, - iotaBalance, formattedTokenBalance, symbol, activeAddress, coins, queryResult, + formattedAmount, + hasEnoughBalance, + isPayAllIota, }: FormInputsProps): React.JSX.Element { - const formattedAmount = parseAmount(values.amount, coinDecimals); - const isPayAllIota = formattedAmount === coinBalance && coinType === IOTA_TYPE_ARG; - - const hasEnoughBalance = - isPayAllIota || - iotaBalance > - BigInt(values.gasBudgetEst ?? '0') + - (coinType === IOTA_TYPE_ARG ? formattedAmount : 0n); + const { setFieldValue, values } = useFormikContext(); async function onMaxTokenButtonClick() { await setFieldValue('amount', formattedTokenBalance); @@ -94,46 +89,31 @@ function FormInputs({ formattedAmount === coinBalance || queryResult.isPending || !coinBalance; return ( -
-
-
- {!hasEnoughBalance && ( - } - /> - )} - - +
+ {!hasEnoughBalance && ( + } /> - -
- + )} -
-
-
+ ); } @@ -144,6 +124,7 @@ export function EnterValuesFormView({ setSelectedCoin, onNext, initialFormValues, + onClose, }: EnterValuesFormProps): JSX.Element { // Get all coins of the type const { data: coinsData, isPending: coinsIsPending } = useGetAllCoins( @@ -188,13 +169,14 @@ export function EnterValuesFormView({ const formattedTokenBalance = tokenBalance.replace(/,/g, ''); - if (coinsBalanceIsPending || coinsIsPending || iotaCoinsIsPending) { - return ( -
- -
- ); - } + const formik = useFormik({ + initialValues: initialFormValues, + validationSchema: validationSchemaStepOne, + enableReinitialize: true, + validateOnChange: false, + validateOnBlur: false, + onSubmit: handleFormSubmit, + }); async function handleFormSubmit({ to, amount, gasBudgetEst }: FormDataValues) { const formattedAmount = parseAmount(amount, coinDecimals).toString(); @@ -207,41 +189,72 @@ export function EnterValuesFormView({ onNext(); } + const coinType = coin.coinType; + const formattedAmount = parseAmount(formik.values.amount, coinDecimals); + const isPayAllIota = formattedAmount === coinBalance && coinType === IOTA_TYPE_ARG; + + const hasEnoughBalance = + isPayAllIota || + iotaBalance > + BigInt(formik.values.gasBudgetEst ?? '0') + + (coinType === IOTA_TYPE_ARG ? formattedAmount : 0n); + + if (coinsBalanceIsPending || coinsIsPending || iotaCoinsIsPending) { + return ( +
+ +
+ ); + } + return ( -
- { - setFormData(INITIAL_VALUES); - const coin = coinsBalance?.find((coin) => coin.coinType === coinType); - setSelectedCoin(coin!); - }} - /> - - - {(props: FormikProps) => ( - - )} - -
+ +
+ + { + setFormData(INITIAL_VALUES); + const selectedCoin = coinsBalance?.find( + (coinBalance) => coinBalance.coinType === coinType, + ); + if (selectedCoin) { + setSelectedCoin(selectedCoin); + } + }} + /> + + + + +
- - + + +
+ ); } diff --git a/apps/wallet-dashboard/hooks/index.ts b/apps/wallet-dashboard/hooks/index.ts index 932b903fdba..f33eda9c3fc 100644 --- a/apps/wallet-dashboard/hooks/index.ts +++ b/apps/wallet-dashboard/hooks/index.ts @@ -12,3 +12,4 @@ export * from './useTimelockedUnstakeTransaction'; export * from './useExplorerLinkGetter'; export * from './useGetStardustMigratableObjects'; export * from './useGroupedMigrationObjectsByExpirationDate'; +export * from './useTransferTransaction'; diff --git a/apps/wallet-dashboard/hooks/useTransferTransaction.ts b/apps/wallet-dashboard/hooks/useTransferTransaction.ts new file mode 100644 index 00000000000..ee673368ac0 --- /dev/null +++ b/apps/wallet-dashboard/hooks/useTransferTransaction.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 +import { useMutation } from '@tanstack/react-query'; +import { useIotaClient, useSignAndExecuteTransaction } from '@iota/dapp-kit'; +import { Transaction } from '@iota/iota-sdk/transactions'; + +export function useTransferTransactionMutation() { + const iotaClient = useIotaClient(); + const { mutateAsync: signAndExecuteTransaction } = useSignAndExecuteTransaction(); + + return useMutation({ + mutationFn: async (transaction: Transaction) => { + const executed = await signAndExecuteTransaction({ + transaction, + }); + + const tx = await iotaClient.waitForTransaction({ + digest: executed.digest, + }); + + return tx; + }, + }); +}