From 02c02fe72b7d8ebedc185dc9591ad03e030ea6de Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Wed, 28 Aug 2024 15:29:56 -0400 Subject: [PATCH] refactor: wallet send form --- .changeset/bright-swans-type.md | 7 + .changeset/proud-paws-fold.md | 5 + apps/hostd-e2e/src/specs/wallet.spec.ts | 56 +++-- apps/hostd/components/Wallet/index.tsx | 2 +- apps/hostd/dialogs/HostdSendSiacoinDialog.tsx | 4 +- apps/renterd-e2e/src/specs/wallet.spec.ts | 2 +- .../dialogs/RenterdSendSiacoinDialog.tsx | 4 +- .../_sharedWalletSend/SendFlowDialog.tsx | 3 +- .../app/WalletSendSiacoinDialog/Complete.tsx | 7 +- .../app/WalletSendSiacoinDialog/Confirm.tsx | 61 +++--- .../app/WalletSendSiacoinDialog/Generate.tsx | 203 ++++++++++++------ .../app/WalletSendSiacoinDialog/Receipt.tsx | 19 +- .../src/app/WalletSendSiacoinDialog/index.tsx | 46 ++-- .../src/app/WalletSendSiacoinDialog/types.ts | 3 +- .../src/app/WalletSendSiacoinDialog/utils.ts | 27 --- libs/design-system/src/components/Form.tsx | 33 ++- 16 files changed, 258 insertions(+), 224 deletions(-) create mode 100644 .changeset/bright-swans-type.md create mode 100644 .changeset/proud-paws-fold.md delete mode 100644 libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts diff --git a/.changeset/bright-swans-type.md b/.changeset/bright-swans-type.md new file mode 100644 index 000000000..abfd1f29d --- /dev/null +++ b/.changeset/bright-swans-type.md @@ -0,0 +1,7 @@ +--- +'hostd': minor +'renterd': minor +'@siafoundation/design-system': minor +--- + +The send siacoin feature now validates the entered address. diff --git a/.changeset/proud-paws-fold.md b/.changeset/proud-paws-fold.md new file mode 100644 index 000000000..4934f429c --- /dev/null +++ b/.changeset/proud-paws-fold.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +WalletSendSiacoinDialog no longer uses formik. diff --git a/apps/hostd-e2e/src/specs/wallet.spec.ts b/apps/hostd-e2e/src/specs/wallet.spec.ts index e4ef5e68b..992d5ace8 100644 --- a/apps/hostd-e2e/src/specs/wallet.spec.ts +++ b/apps/hostd-e2e/src/specs/wallet.spec.ts @@ -66,20 +66,19 @@ test('send siacoin with include fee off', async ({ page }) => { await expect(sendDialog.getByTestId('transactionId')).toBeVisible() // List. - // TODO: Add this after we migrate to the new events API. - // await sendDialog.getByRole('button', { name: 'Close' }).click() - // await expect(page.getByTestId('eventsTable')).toBeVisible() - // await expect( - // page.getByTestId('eventsTable').locator('tbody tr').first() - // ).toBeVisible() - // await expect( - // page - // .getByTestId('eventsTable') - // .locator('tbody tr') - // .first() - // .getByTestId('amount') - // .getByText(`-${amountWithFeeString}`) - // ).toBeVisible() + await sendDialog.getByRole('button', { name: 'Close' }).click() + await expect(page.getByTestId('eventsTable')).toBeVisible() + await expect( + page.getByTestId('eventsTable').locator('tbody tr').first() + ).toBeVisible() + await expect( + page + .getByTestId('eventsTable') + .locator('tbody tr') + .first() + .getByTestId('amount') + .getByText(`-${amountWithFeeString}`) + ).toBeVisible() }) test('send siacoin with include fee on', async ({ page }) => { @@ -96,7 +95,7 @@ test('send siacoin with include fee on', async ({ page }) => { const sendDialog = page.getByRole('dialog', { name: 'Send siacoin' }) await fillTextInputByName(page, 'address', receiveAddress) await fillTextInputByName(page, 'siacoin', amount.toString()) - await setSwitchByLabel(page, 'include fee', true) + await setSwitchByLabel(page, 'includeFee', true) await expect( sendDialog.getByTestId('networkFee').getByText('0.012 SC') ).toBeVisible() @@ -139,18 +138,17 @@ test('send siacoin with include fee on', async ({ page }) => { await expect(sendDialog.getByTestId('transactionId')).toBeVisible() // List. - // TODO: Add this after we migrate to the new events API. - // await sendDialog.getByRole('button', { name: 'Close' }).click() - // await expect(page.getByTestId('eventsTable')).toBeVisible() - // await expect( - // page.getByTestId('eventsTable').locator('tbody tr').first() - // ).toBeVisible() - // await expect( - // page - // .getByTestId('eventsTable') - // .locator('tbody tr') - // .first() - // .getByTestId('amount') - // .getByText(`-${amountWithFeeString}`) - // ).toBeVisible() + await sendDialog.getByRole('button', { name: 'Close' }).click() + await expect(page.getByTestId('eventsTable')).toBeVisible() + await expect( + page.getByTestId('eventsTable').locator('tbody tr').first() + ).toBeVisible() + await expect( + page + .getByTestId('eventsTable') + .locator('tbody tr') + .first() + .getByTestId('amount') + .getByText(`-${amountString}`) + ).toBeVisible() }) diff --git a/apps/hostd/components/Wallet/index.tsx b/apps/hostd/components/Wallet/index.tsx index 2808d1b06..18535239b 100644 --- a/apps/hostd/components/Wallet/index.tsx +++ b/apps/hostd/components/Wallet/index.tsx @@ -72,7 +72,7 @@ export function Wallet() { /> ) : null} (val ? openDialog(dialog) : closeDialog())} + onOpenChange={onOpenChange} /> ) } diff --git a/apps/renterd-e2e/src/specs/wallet.spec.ts b/apps/renterd-e2e/src/specs/wallet.spec.ts index 47891dc23..f96986943 100644 --- a/apps/renterd-e2e/src/specs/wallet.spec.ts +++ b/apps/renterd-e2e/src/specs/wallet.spec.ts @@ -96,7 +96,7 @@ test('send siacoin with include fee on', async ({ page }) => { const sendDialog = page.getByRole('dialog', { name: 'Send siacoin' }) await fillTextInputByName(page, 'address', receiveAddress) await fillTextInputByName(page, 'siacoin', amount.toString()) - await setSwitchByLabel(page, 'include fee', true) + await setSwitchByLabel(page, 'includeFee', true) await expect( sendDialog.getByTestId('networkFee').getByText('0.012 SC') ).toBeVisible() diff --git a/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx b/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx index 4d75ec0dd..aee416982 100644 --- a/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx +++ b/apps/renterd/dialogs/RenterdSendSiacoinDialog.tsx @@ -11,7 +11,7 @@ import BigNumber from 'bignumber.js' const standardTxnSize = 1200 // bytes export function RenterdSendSiacoinDialog() { - const { dialog, openDialog, closeDialog } = useDialog() + const { dialog, onOpenChange } = useDialog() const wallet = useWallet() const recommendedFee = useTxPoolRecommendedFee() @@ -60,7 +60,7 @@ export function RenterdSendSiacoinDialog() { send={send} fee={fee} open={dialog === 'sendSiacoin'} - onOpenChange={(val) => (val ? openDialog(dialog) : closeDialog())} + onOpenChange={onOpenChange} /> ) } diff --git a/apps/walletd/dialogs/_sharedWalletSend/SendFlowDialog.tsx b/apps/walletd/dialogs/_sharedWalletSend/SendFlowDialog.tsx index 97fa6e939..9e0f4516e 100644 --- a/apps/walletd/dialogs/_sharedWalletSend/SendFlowDialog.tsx +++ b/apps/walletd/dialogs/_sharedWalletSend/SendFlowDialog.tsx @@ -71,8 +71,7 @@ export function SendFlowDialog({ controls={ controls?.form && (
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - form={controls.form}> + {controls.submitLabel}
diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx index 208c320a6..801ee7da8 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Complete.tsx @@ -2,16 +2,16 @@ import BigNumber from 'bignumber.js' import { Text } from '../../core/Text' import { CheckmarkFilled32 } from '@siafoundation/react-icons' import { WalletSendSiacoinReceipt } from './Receipt' -import { SendSiacoinFormData } from './types' +import { SendSiacoinParams } from './types' type Props = { - data: SendSiacoinFormData + data: SendSiacoinParams fee: BigNumber transactionId?: string } export function WalletSendSiacoinComplete({ - data: { address, hastings, includeFee }, + data: { address, hastings }, fee, transactionId, }: Props) { @@ -20,7 +20,6 @@ export function WalletSendSiacoinComplete({ diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx index 4e4a2499a..b4e41f720 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Confirm.tsx @@ -1,59 +1,66 @@ -import { useFormik } from 'formik' +import { useForm } from 'react-hook-form' import BigNumber from 'bignumber.js' import { WalletSendSiacoinReceipt } from './Receipt' -import { SendSiacoinFormData } from './types' +import { SendSiacoinParams } from './types' +import { triggerErrorToast } from '../../lib/toast' +import { useCallback, useMemo } from 'react' type Props = { send: ( - params: SendSiacoinFormData + params: SendSiacoinParams & { includeFee: boolean } ) => Promise<{ transactionId?: string; error?: string }> - formData: SendSiacoinFormData + params: SendSiacoinParams fee: BigNumber onConfirm: (params: { transactionId?: string }) => void } export function useSendSiacoinConfirmForm({ send, - formData, + params, fee, onConfirm, }: Props) { - const { address, hastings, includeFee } = formData || {} - const formik = useFormik({ - initialValues: {}, - onSubmit: async () => { - const { transactionId, error } = await send({ - address, - hastings, - includeFee, - }) + const { address, hastings } = params || {} + const form = useForm({ + defaultValues: {}, + }) - if (error) { - formik.setStatus({ - error, - }) - return - } + const onValid = useCallback(async () => { + const { transactionId, error } = await send({ + address, + hastings, + includeFee: false, + }) - onConfirm({ - transactionId, + if (error) { + triggerErrorToast({ + title: 'Error sending siacoin', + body: error, }) - }, - }) + return + } + + onConfirm({ + transactionId, + }) + }, [onConfirm, address, hastings, send]) + + const submit = useMemo(() => form.handleSubmit(onValid), [form, onValid]) - const form = ( + const el = (
) return { + el, form, - formik, + reset: form.reset, + submit, } } diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx index 89c6f55b0..f64785152 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Generate.tsx @@ -1,38 +1,81 @@ -import { useFormik } from 'formik' -import * as Yup from 'yup' +import { useForm } from 'react-hook-form' import BigNumber from 'bignumber.js' -import { toHastings } from '@siafoundation/units' +import { isValidAddress, toHastings } from '@siafoundation/units' import { Text } from '../../core/Text' import { InfoTip } from '../../core/InfoTip' -import { Switch } from '../../core/Switch' import { ValueSc } from '../../components/ValueSc' -import { FormFieldFormik } from '../../components/FormFormik' -import { SendSiacoinFormData } from './types' -import { getTotalTransactionCost } from './utils' +import { ConfigFields } from '../../form/configurationFields' +import { SendSiacoinParams } from './types' +import { FieldText } from '../../form/FieldText' +import { FieldSwitch } from '../../form/FieldSwitch' +import { FieldSiacoin } from '../../form/FieldSiacoin' const exampleAddr = 'e3b1050aef388438668b52983cf78f40925af8f0aa8b9de80c18eadcefce8388d168a313e3f2' -const initialValues = { +const defaultValues = { address: '', - siacoin: undefined, + siacoin: undefined as BigNumber | undefined, includeFee: false, } -const validationSchema = Yup.object().shape({ - address: Yup.string().required('Required'), - siacoin: Yup.string() - .required('Required') - .test( - 'greater than zero', - 'Must be greater than zero', - (val) => !new BigNumber(val || 0).isZero() - ), -}) +type Values = typeof defaultValues + +function getFields({ + balance, + fee, +}: { + balance?: BigNumber + fee: BigNumber +}): ConfigFields { + return { + address: { + title: 'Address', + type: 'text', + placeholder: exampleAddr, + validation: { + required: 'required', + validate: { + isValidAddress: (val) => + isValidAddress(val as string) || 'Invalid address', + }, + }, + }, + siacoin: { + title: 'Siacoin', + type: 'siacoin', + placeholder: '100', + validation: { + required: 'required', + validate: { + greaterThanZero: (val) => + !new BigNumber((val as BigNumber) || 0).isZero() || + 'Must be greater than zero', + lessThanBalance: (val, values) => { + const hastings = toHastings((val as BigNumber) || 0) + const total = getTotalTransactionCost({ + hastings, + includeFee: values.includeFee, + fee, + }) + return ( + total.isLessThan(balance || 0) || 'Not enough funds in wallet' + ) + }, + }, + }, + }, + includeFee: { + type: 'boolean', + title: 'Include fee', + validation: {}, + }, + } +} type Props = { fee: BigNumber - onComplete: (data: SendSiacoinFormData) => void + onComplete: (data: SendSiacoinParams) => void balance?: BigNumber } @@ -41,65 +84,69 @@ export function useSendSiacoinGenerateForm({ fee, onComplete, }: Props) { - const formik = useFormik({ - initialValues, - validationSchema, - onSubmit: async (values) => { - if (!fee) { - return - } - - if (!values.siacoin) { - return - } - - if (!balance) { - return - } - - if (balance.isLessThan(toHastings(values.siacoin).plus(fee))) { - formik.setStatus({ error: 'Not enough funds in wallet.' }) - return - } - - formik.setStatus({}) - onComplete({ - includeFee: values.includeFee, - address: values.address, - hastings: toHastings(values.siacoin), - }) - }, + const form = useForm({ + defaultValues, }) - const hastings = toHastings(formik.values.siacoin || 0) + const onValid = async (values: Values) => { + if (!values.siacoin) { + return + } + + if (!balance) { + return + } - const form = ( + const hastings = values.includeFee + ? toHastings(values.siacoin).minus(fee) + : toHastings(values.siacoin) + + const total = getTotalTransactionCost({ + hastings, + includeFee: values.includeFee, + fee, + }) + + if (balance.isLessThan(total)) { + return + } + + onComplete({ + address: values.address, + hastings, + }) + } + + const fields = getFields({ balance, fee }) + + const submit = form.handleSubmit(onValid) + + const includeFee = form.watch('includeFee') + const siacoin = form.watch('siacoin') + const hastings = toHastings(siacoin || 0) + + const el = (
- - +
- formik.setFieldValue('includeFee', val)} + form={form} + size="small" + fields={fields} + group={false} > - Include fee - + + Include fee + + Include or exclude the network fee from the above transaction value. @@ -126,7 +173,7 @@ export function useSendSiacoinGenerateForm({ size="14" value={getTotalTransactionCost({ hastings, - includeFee: formik.values.includeFee, + includeFee, fee, })} variant="value" @@ -139,7 +186,21 @@ export function useSendSiacoinGenerateForm({ ) return { - formik, + el, + reset: form.reset, form, + submit, } } + +function getTotalTransactionCost({ + hastings, + includeFee, + fee, +}: { + hastings: BigNumber + includeFee: boolean + fee: BigNumber +}) { + return includeFee ? hastings : hastings.plus(fee) +} diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx index cb59d4e4a..262b17258 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/Receipt.tsx @@ -2,10 +2,9 @@ import { Text } from '../../core/Text' import { ValueSc } from '../../components/ValueSc' import BigNumber from 'bignumber.js' import { ValueCopyable } from '../../components/ValueCopyable' -import { SendSiacoinFormData } from './types' -import { getAmountUserWillReceive, getTotalTransactionCost } from './utils' +import { SendSiacoinParams } from './types' -type Props = SendSiacoinFormData & { +type Props = SendSiacoinParams & { fee: BigNumber transactionId?: string } @@ -14,19 +13,9 @@ export function WalletSendSiacoinReceipt({ address, hastings, fee, - includeFee, transactionId, }: Props) { - const amount = getAmountUserWillReceive({ - hastings, - includeFee, - fee, - }) - const total = getTotalTransactionCost({ - hastings, - includeFee, - fee, - }) + const total = hastings.plus(fee) return (
@@ -43,7 +32,7 @@ export function WalletSendSiacoinReceipt({ diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx b/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx index 57824a993..8aa337c20 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/index.tsx @@ -8,15 +8,14 @@ import { useSendSiacoinGenerateForm } from './Generate' import { useSendSiacoinConfirmForm } from './Confirm' import { ProgressSteps } from '../ProgressSteps' import { WalletSendSiacoinComplete } from './Complete' -import { FormSubmitButtonFormik } from '../../components/FormFormik' -import { SendSiacoinFormData } from './types' +import { FormSubmitButton } from '../../components/Form' +import { SendSiacoinParams } from './types' type Step = 'setup' | 'confirm' | 'done' -const emptyFormData: SendSiacoinFormData = { +const emptyFormData: SendSiacoinParams = { address: '', hastings: new BigNumber(0), - includeFee: false, } type Props = { @@ -26,7 +25,7 @@ type Props = { balance?: BigNumber fee: BigNumber send: ( - params: SendSiacoinFormData + params: SendSiacoinParams & { includeFee: boolean } ) => Promise<{ transactionId?: string; error?: string }> } @@ -40,20 +39,20 @@ export function WalletSendSiacoinDialog({ }: Props) { const [step, setStep] = useState('setup') const [signedTxnId, setSignedTxnId] = useState() - const [formData, setFormData] = useState(emptyFormData) + const [params, setParams] = useState(emptyFormData) // Form for each step const generate = useSendSiacoinGenerateForm({ balance, fee, onComplete: (data) => { - setFormData(data) + setParams(data) setStep('confirm') }, }) const confirm = useSendSiacoinConfirmForm({ fee, - formData, + params, send, onConfirm: ({ transactionId }) => { setSignedTxnId(transactionId) @@ -65,13 +64,19 @@ export function WalletSendSiacoinDialog({ if (step === 'setup') { return { submitLabel: 'Generate transaction', - formik: generate.formik, + el: generate.el, + form: generate.form, + reset: generate.reset, + submit: generate.submit, } } if (step === 'confirm') { return { submitLabel: 'Broadcast transaction', - formik: confirm.formik, + el: confirm.el, + form: confirm.form, + reset: confirm.reset, + submit: confirm.submit, } } return undefined @@ -83,25 +88,20 @@ export function WalletSendSiacoinDialog({ open={open} onOpenChange={(val) => { if (!val) { - generate.formik.resetForm() - confirm.formik.resetForm() + generate.reset() + confirm.reset() setStep('setup') } onOpenChange(val) }} title="Send siacoin" - onSubmit={ - controls - ? (controls.formik - .handleSubmit as React.FormEventHandler) - : undefined - } + onSubmit={controls ? controls.submit : undefined} controls={ controls && (
- + {controls.submitLabel} - +
) } @@ -129,11 +129,11 @@ export function WalletSendSiacoinDialog({ ]} /> - {step === 'setup' && generate.form} - {step === 'confirm' && confirm.form} + {step === 'setup' && generate.el} + {step === 'confirm' && confirm.el} {step === 'done' && ( diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/types.ts b/libs/design-system/src/app/WalletSendSiacoinDialog/types.ts index 872776498..d6206d31e 100644 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/types.ts +++ b/libs/design-system/src/app/WalletSendSiacoinDialog/types.ts @@ -2,8 +2,7 @@ import BigNumber from 'bignumber.js' -export type SendSiacoinFormData = { +export type SendSiacoinParams = { address: string hastings: BigNumber - includeFee: boolean } diff --git a/libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts b/libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts deleted file mode 100644 index e529d4eed..000000000 --- a/libs/design-system/src/app/WalletSendSiacoinDialog/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -'use client' - -import BigNumber from 'bignumber.js' - -export function getTotalTransactionCost({ - hastings, - includeFee, - fee, -}: { - hastings: BigNumber - includeFee: boolean - fee: BigNumber -}) { - return includeFee ? hastings : hastings.plus(fee) -} - -export function getAmountUserWillReceive({ - hastings, - includeFee, - fee, -}: { - hastings: BigNumber - includeFee: boolean - fee: BigNumber -}) { - return includeFee ? hastings.minus(fee) : hastings -} diff --git a/libs/design-system/src/components/Form.tsx b/libs/design-system/src/components/Form.tsx index 49b131122..497dfc45a 100644 --- a/libs/design-system/src/components/Form.tsx +++ b/libs/design-system/src/components/Form.tsx @@ -97,8 +97,10 @@ export function FieldGroup({ ) } -type FormSubmitProps = { - form: UseFormReturn +type FormSubmitProps = { + // The button is agnostic to the form's FieldValues. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: UseFormReturn size?: React.ComponentProps['size'] variant?: React.ComponentProps['variant'] children: React.ReactNode @@ -106,27 +108,22 @@ type FormSubmitProps = { withStatusError?: boolean } -export function FormSubmitButton({ +export function FormSubmitButton({ form, size = 'medium', variant = 'accent', className, children, -}: FormSubmitProps) { +}: FormSubmitProps) { return ( - <> - {/* {withStatusError && formik.status?.error && ( - {formik.status.error} - )} */} - - + ) }