Skip to content

Commit

Permalink
Feat/simple native send form (#12040)
Browse files Browse the repository at this point in the history
* fix(suite-native): crypto amount formatter supports multi-word network symbols

* feat(suite-native): TextInputField allow transform function

* feat(suite-native): add feeReducer to suite-native

* feat(suite-native): sendForm validation schema

* feat(suite-native): sendForm component
  • Loading branch information
PeKne authored Apr 22, 2024
1 parent ae3a242 commit 7a2f6c1
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 17 deletions.
7 changes: 5 additions & 2 deletions suite-common/wallet-core/src/fees/feesReducer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { createReducer } from '@reduxjs/toolkit';

import { FeeInfo } from '@suite-common/wallet-types';
import { Network, networksCompatibility } from '@suite-common/wallet-config';
import { NetworkSymbol, networksCompatibility } from '@suite-common/wallet-config';

import { blockchainActions } from '../blockchain/blockchainActions';

export type FeesState = {
[key in Network['symbol']]: FeeInfo;
[key in NetworkSymbol]: FeeInfo;
};

export type FeesRootState = {
Expand Down Expand Up @@ -37,3 +37,6 @@ export const feesReducer = createReducer(initialState, builder => {
};
});
});

export const selectNetworkFeeInfo = (state: FeesRootState, networkSymbol?: NetworkSymbol) =>
networkSymbol ? state.wallet.fees[networkSymbol] : null;
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const CryptoAmountFormatter = ({
// split value and currency, format value with thousands commas
const splitValue = formattedValue.split(' ');
if (splitValue.length > 1) {
formattedValue = `${formatNumberWithThousandCommas(splitValue[0])} ${splitValue[1]}`;
formattedValue = `${formatNumberWithThousandCommas(splitValue[0])} ${splitValue.slice(1).join(' ')}`;
} else if (splitValue.length > 0) {
formattedValue = formatNumberWithThousandCommas(splitValue[0]);
}
Expand Down
5 changes: 3 additions & 2 deletions suite-native/forms/src/fields/TextInputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ export interface FieldProps extends AllowedTextInputFieldProps, AllowedInputWrap
label: string;
onBlur?: () => void;
defaultValue?: string;
valueTransformer?: (value: string) => string;
}

export const TextInputField = forwardRef<TextInput, FieldProps>(
({ name, label, hint, onBlur, defaultValue = '', ...otherProps }, ref) => {
const field = useField({ name, label, defaultValue });
({ name, label, hint, onBlur, defaultValue = '', valueTransformer, ...otherProps }, ref) => {
const field = useField({ name, label, defaultValue, valueTransformer });
const { errorMessage, onBlur: hookFormOnBlur, onChange, value, hasError } = field;

const handleOnBlur = () => {
Expand Down
14 changes: 12 additions & 2 deletions suite-native/forms/src/hooks/useField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ interface UseFieldArgs {
name: FieldName;
label: string;
defaultValue?: unknown;
valueTransformer?: (value: string) => string;
}

export const useField = ({ name, label, defaultValue }: UseFieldArgs) => {
export const useField = ({
name,
label,
defaultValue,
valueTransformer = value => value,
}: UseFieldArgs) => {
// TODO: once react-hook-form is updated to 7+ we can use the `errors` from `fieldState` on useController
const { control } = useContext(FormContext);

Expand All @@ -27,14 +33,18 @@ export const useField = ({ name, label, defaultValue }: UseFieldArgs) => {
defaultValue,
});

// Inspired by https://react-hook-form.com/advanced-usage#TransformandParse.
// Allows to parse/transform the value before it's set to the input.
const transformedValue = valueTransformer(value);

// TODO: proper error message resolution using intl
const errorMessage = error?.message?.replace(name, label);
const hasError = !!error;

return {
errorMessage,
hasError,
value,
value: transformedValue,
onBlur,
onChange,
};
Expand Down
9 changes: 9 additions & 0 deletions suite-native/module-send/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,23 @@
"type-check": "yarn g:tsc --build"
},
"dependencies": {
"@mobily/ts-belt": "^3.13.1",
"@react-navigation/native": "6.1.10",
"@react-navigation/native-stack": "6.9.18",
"@reduxjs/toolkit": "1.9.5",
"@suite-common/validators": "workspace:*",
"@suite-common/wallet-config": "workspace:*",
"@suite-common/wallet-core": "workspace:*",
"@suite-common/wallet-types": "workspace:*",
"@suite-common/wallet-utils": "workspace:*",
"@suite-native/accounts": "workspace:*",
"@suite-native/atoms": "workspace:*",
"@suite-native/device-manager": "workspace:*",
"@suite-native/formatters": "workspace:*",
"@suite-native/forms": "workspace:*",
"@suite-native/navigation": "workspace:*",
"@trezor/styles": "workspace:*",
"bignumber.js": "^9.1.2",
"react": "18.2.0",
"react-redux": "8.0.7"
}
Expand Down
93 changes: 93 additions & 0 deletions suite-native/module-send/src/components/SendForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import { useSelector } from 'react-redux';

import { formInputsMaxLength } from '@suite-common/validators';
import { VStack, Button, Text } from '@suite-native/atoms';
import { useForm, Form, TextInputField } from '@suite-native/forms';
import { AccountKey } from '@suite-common/wallet-types';
import {
AccountsRootState,
FeesRootState,
selectAccountByKey,
selectNetworkFeeInfo,
} from '@suite-common/wallet-core';

import { SendFormValues, sendFormValidationSchema } from '../sendFormSchema';

type SendFormProps = {
accountKey: AccountKey;
};

const amountTransformer = (value: string) =>
value
.replace(/[^0-9\.]/g, '') // remove all non-numeric characters
.replace(/(?<=\..*)\./g, '') // keep only first appearance of the '.' symbol
.replace(/(?<=^0+)0/g, ''); // remove all leading zeros except the first one

export const SendForm = ({ accountKey }: SendFormProps) => {
const account = useSelector((state: AccountsRootState) =>
selectAccountByKey(state, accountKey),
);

const networkFeeInfo = useSelector((state: FeesRootState) =>
selectNetworkFeeInfo(state, account?.symbol),
);

const form = useForm<SendFormValues>({
validation: sendFormValidationSchema,
context: {
networkFeeInfo,
networkSymbol: account?.symbol,
availableAccountBalance: account?.availableBalance,
},
defaultValues: {
address: '',
amount: '',
},
});

const {
handleSubmit,
formState: { isValid },
} = form;

const handleValidateForm = handleSubmit(() => {
// TODO: start on-device inputs validation via TrezorConnect
});

return (
<Form form={form}>
<VStack spacing="medium" padding="medium">
<TextInputField
multiline
label="Address"
name="address"
maxLength={formInputsMaxLength.address}
accessibilityLabel="address input"
autoCapitalize="none"
testID="@send/address-input"
/>
<TextInputField
label="Amount to send"
name="amount"
keyboardType="numeric"
accessibilityLabel="amount to send input"
testID="@send/amount-input"
valueTransformer={amountTransformer}
/>
<VStack>
<Button
accessibilityRole="button"
accessibilityLabel="validate send form"
testID="@send/form-submit-button"
onPress={handleValidateForm}
>
Validate form
</Button>
</VStack>
{/* TODO: remove this message in followup PR */}
{isValid && <Text color="textSecondaryHighlight">Form is valid 🎉</Text>}
</VStack>
</Form>
);
};
28 changes: 19 additions & 9 deletions suite-native/module-send/src/screens/SendFormScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
SendStackRoutes,
StackProps,
} from '@suite-native/navigation';
import { Box, Text, VStack } from '@suite-native/atoms';
import { HStack, Text, VStack } from '@suite-native/atoms';
import { AccountsRootState, selectAccountByKey } from '@suite-common/wallet-core';
import { CryptoAmountFormatter } from '@suite-native/formatters';

import { SendForm } from '../components/SendForm';

export const SendFormScreen = ({
route: { params },
Expand All @@ -27,15 +30,22 @@ export const SendFormScreen = ({
<Screen
subheader={<ScreenSubHeader content={'Send form screen'} leftIcon={<GoBackIcon />} />}
>
<VStack flex={1} justifyContent="center" alignItems="center">
<Box>
<Text textAlign="center">Send Form Screen mockup of account:</Text>
<VStack>
<VStack justifyContent="center" alignItems="center">
<Text textAlign="center">Send form prototype of account:</Text>
<Text textAlign="center">{account.accountLabel}</Text>
</Box>
<Text textAlign="center">
This screen will soon contain a form that allows users to send crypto from
Trezor Suite Lite. Fingers crossed.
</Text>
<HStack>
<Text textAlign="center">account balance:</Text>
<CryptoAmountFormatter
variant="body"
color="textDefault"
value={account.availableBalance}
network={account.symbol}
isBalance={false}
/>
</HStack>
</VStack>
<SendForm accountKey={accountKey} />
</VStack>
</Screen>
);
Expand Down
120 changes: 120 additions & 0 deletions suite-native/module-send/src/sendFormSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { G } from '@mobily/ts-belt';
import BigNumber from 'bignumber.js';

import { NetworkSymbol } from '@suite-common/wallet-config';
import { formatNetworkAmount, isAddressValid } from '@suite-common/wallet-utils';
import { FeeInfo } from '@suite-common/wallet-types';
import { yup } from '@suite-common/validators';

export type SendFormFormContext = {
networkSymbol?: NetworkSymbol;
availableAccountBalance?: string;
networkFeeInfo?: FeeInfo;
};

const isAmountDust = ({
value,
networkSymbol,
isValueInSats,
networkFeeInfo,
}: {
value: string;
networkSymbol: NetworkSymbol;
isValueInSats: boolean;
networkFeeInfo: FeeInfo;
}) => {
const valueBigNumber = new BigNumber(value);
const rawDust = networkFeeInfo.dustLimit?.toString();

const dustThreshold =
rawDust && (isValueInSats ? rawDust : formatNetworkAmount(rawDust, networkSymbol));

if (!dustThreshold) {
return false;
}

return valueBigNumber.lt(dustThreshold);
};

const isAmountHigherThanBalance = ({
value,
networkSymbol,
isValueInSats,
availableAccountBalance,
}: {
value: string;
networkSymbol: NetworkSymbol;
isValueInSats: boolean;
availableAccountBalance: string;
}) => {
const formattedAvailableBalance = isValueInSats
? availableAccountBalance
: formatNetworkAmount(availableAccountBalance, networkSymbol);

const valueBig = new BigNumber(value);

return valueBig.gt(formattedAvailableBalance);
};

// TODO: change error messages copy when is design ready
export const sendFormValidationSchema = yup.object({
address: yup
.string()
.required()
.test(
'is-invalid-address',
'Address is not valid.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
const networkSymbol = context?.networkSymbol;

return (
G.isNotNullable(value) &&
G.isNotNullable(networkSymbol) &&
isAddressValid(value, networkSymbol)
);
},
),
amount: yup
.string()
.required()
.matches(/^\d+.*\d+$/, 'Invalid decimal value.')
.test(
'is-dust-amount',
'The value is lower than dust threshold.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
if (!context || !value) return false;
const { networkSymbol, networkFeeInfo } = context;

if (!networkSymbol || !networkFeeInfo) return false;

return !isAmountDust({
value,
networkSymbol,
isValueInSats: false,
networkFeeInfo,
});
},
)
.test(
'is-higher-than-balance',
'Amount is higher than available balance.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
if (!context || !value) return false;
const { networkSymbol, networkFeeInfo, availableAccountBalance } = context;

if (!networkSymbol || !networkFeeInfo || !availableAccountBalance) return false;

return !isAmountHigherThanBalance({
value,
networkSymbol,
isValueInSats: false,
availableAccountBalance,
});
},
),
// TODO: other validations have to be added in the following PRs
// 1) validation of token amount
// 2) check if the amount is not higher than XRP reserve
});

export type SendFormValues = yup.InferType<typeof sendFormValidationSchema>;
14 changes: 13 additions & 1 deletion suite-native/module-send/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "libDev" },
"references": [
{
"path": "../../suite-common/validators"
},
{
"path": "../../suite-common/wallet-config"
},
{
"path": "../../suite-common/wallet-core"
},
{
"path": "../../suite-common/wallet-types"
},
{
"path": "../../suite-common/wallet-utils"
},
{ "path": "../accounts" },
{ "path": "../atoms" },
{ "path": "../device-manager" },
{ "path": "../navigation" }
{ "path": "../formatters" },
{ "path": "../forms" },
{ "path": "../navigation" },
{ "path": "../../packages/styles" }
]
}
Loading

0 comments on commit 7a2f6c1

Please sign in to comment.