diff --git a/.github/workflows/run_tests_on_pr.yml b/.github/workflows/run_tests_on_pr.yml index 3ce7787de8..a8c4c3c451 100644 --- a/.github/workflows/run_tests_on_pr.yml +++ b/.github/workflows/run_tests_on_pr.yml @@ -13,6 +13,6 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: "12" - - run: yarn install - - run: yarn test:jest + node-version: "14" + - run: yarn --frozen-lockfile + - run: yarn test:jest --maxWorkers=3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9478da3897..e8b8b51e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Unified CPU info in diagnostics dialog ([PR 2818](https://github.com/input-output-hk/daedalus/pull/2818)) - Implemented wallet sorting on sidebar menu ([PR 2775](https://github.com/input-output-hk/daedalus/pull/2775)) - Implemented new token picker ([PR 2787](https://github.com/input-output-hk/daedalus/pull/2787)) +- Improved wallet send form ([PR 2791](https://github.com/input-output-hk/daedalus/pull/2791)) ### Chores diff --git a/source/renderer/app/components/wallet/WalletSendForm.js b/source/renderer/app/components/wallet/WalletSendForm.js index 0ee79642ca..4811c2cbe9 100755 --- a/source/renderer/app/components/wallet/WalletSendForm.js +++ b/source/renderer/app/components/wallet/WalletSendForm.js @@ -4,7 +4,7 @@ import type { Node } from 'react'; import type { Field } from 'mobx-react-form'; import { observer } from 'mobx-react'; import { intlShape, FormattedHTMLMessage } from 'react-intl'; -import { filter, get, indexOf, omit, map, without } from 'lodash'; +import { filter, get, indexOf, omit, map, without, isEmpty } from 'lodash'; import BigNumber from 'bignumber.js'; import classNames from 'classnames'; import SVGInline from 'react-svg-inline'; @@ -37,11 +37,21 @@ import styles from './WalletSendForm.scss'; import Asset from '../../domains/Asset'; import type { HwDeviceStatus } from '../../domains/Wallet'; import type { AssetToken, ApiTokens } from '../../api/assets/types'; +import type { ReactIntlMessage } from '../../types/i18nTypes'; import { DiscreetWalletAmount } from '../../features/discreet-mode'; import WalletTokenPicker from './tokens/wallet-token-picker/WalletTokenPicker'; messages.fieldIsRequired = globalMessages.fieldIsRequired; +type AdaInputState = 'restored' | 'updated' | 'reset' | 'none'; + +const AdaInputStateType: EnumMap = { + Restored: 'restored', + Updated: 'updated', + None: 'none', + Reset: 'reset', +}; + type Props = { currencyMaxIntegerDigits: number, currencyMaxFractionalDigits: number, @@ -81,6 +91,7 @@ type State = { }, }, minimumAda: BigNumber, + adaAmountInputTrack: BigNumber, feeCalculationRequestQue: number, transactionFee: BigNumber, transactionFeeError: ?string | ?Node, @@ -89,6 +100,8 @@ type State = { isReceiverAddressValid: boolean, isTransactionFeeCalculated: boolean, isTokenPickerOpen: boolean, + isCalculatingTransactionFee: boolean, + adaInputState: AdaInputState, }; @observer @@ -100,6 +113,7 @@ export default class WalletSendForm extends Component { state = { formFields: {}, minimumAda: new BigNumber(0), + adaAmountInputTrack: new BigNumber(0), feeCalculationRequestQue: 0, transactionFee: new BigNumber(0), transactionFeeError: null, @@ -108,15 +122,10 @@ export default class WalletSendForm extends Component { isReceiverAddressValid: false, isTransactionFeeCalculated: false, isTokenPickerOpen: false, + isCalculatingTransactionFee: false, + adaInputState: AdaInputStateType.None, }; - // We need to track the fee calculation state in order to disable - // the "Submit" button as soon as either receiver or amount field changes. - // This is required as we are using debounced validation and we need to - // disable the "Submit" button as soon as the value changes and then wait for - // the validation to end in order to see if the button should be enabled or not. - _isCalculatingTransactionFee = false; - // We need to track the mounted state in order to avoid calling // setState promise handling code after the component was already unmounted: // Read more: https://facebook.github.io/react/blog/2015/12/16/ismounted-antipattern.html @@ -202,8 +211,11 @@ export default class WalletSendForm extends Component { }; handleSubmitOnEnter = (event: KeyboardEvent): void => { - if (event.target instanceof HTMLInputElement && event.key === 'Enter') - this.handleOnSubmit(); + if (event.target instanceof HTMLInputElement && event.key === 'Enter') { + setTimeout(() => { + this.handleOnSubmit(); + }, FORM_VALIDATION_DEBOUNCE_WAIT); + } }; handleOnSubmit = () => { @@ -229,7 +241,9 @@ export default class WalletSendForm extends Component { this.setState({ minimumAda: new BigNumber(0), + adaAmountInputTrack: new BigNumber(0), isResetButtonDisabled: true, + adaInputState: AdaInputStateType.None, }); }; @@ -312,9 +326,10 @@ export default class WalletSendForm extends Component { }; isDisabled = () => - this._isCalculatingTransactionFee || + this.state.isCalculatingTransactionFee || !this.state.isTransactionFeeCalculated || - !this.form.isValid; + !this.form.isValid || + this.form.validating; form = new ReactToolboxMobxForm( { @@ -342,7 +357,7 @@ export default class WalletSendForm extends Component { const adaAmountField = form.$('adaAmount'); const isAdaAmountValid = adaAmountField.isValid; if (isValid && isAdaAmountValid) { - this.calculateTransactionFee(); + await this.calculateTransactionFee(); } else { this.resetTransactionFee(); } @@ -376,7 +391,7 @@ export default class WalletSendForm extends Component { formattedAmountToNaturalUnits(amountValue) ); if (isValid) { - this.calculateTransactionFee(); + await this.calculateTransactionFee(); } else { this.resetTransactionFee(); } @@ -419,32 +434,37 @@ export default class WalletSendForm extends Component { prevFeeCalculationRequestQue: number ) => currentFeeCalculationRequestQue - prevFeeCalculationRequestQue === 1; - calculateTransactionFee = async () => { - const { form } = this; - const emptyAssetFieldValue = '0'; - const hasEmptyAssetFields = this.selectedAssetsAmounts.includes( - emptyAssetFieldValue - ); - if (!form.isValid || hasEmptyAssetFields) { - form.showErrors(true); - return; - } + validateEmptyAssets = () => { + return this.selectedAssets + .filter((_, index) => { + const quantity = new BigNumber(this.selectedAssetsAmounts[index]); + return quantity.isZero(); + }) + .forEach(({ uniqueId }) => { + this.form.$(`asset_${uniqueId}`).validate({ showErrors: true }); + }); + }; + calculateTransactionFee = async ( + shouldUpdateMinimumAdaAmount: boolean = false + ) => { + this.validateEmptyAssets(); + + const { form } = this; const receiverField = form.$('receiver'); const receiver = receiverField.value; const adaAmountField = form.$('adaAmount'); const adaAmount = formattedAmountToLovelace(adaAmountField.value); - const assets: ApiTokens = filter( - this.selectedAssets.map(({ policyId, assetName }, index) => { + const assets: ApiTokens = this.selectedAssets + .map(({ policyId, assetName }, index) => { const quantity = new BigNumber(this.selectedAssetsAmounts[index]); return { policy_id: policyId, asset_name: assetName, quantity, // BigNumber or number - prevent parsing a BigNumber to Number (Integer) because of JS number length limitation }; - }), - 'quantity' - ); + }) + .filter(({ quantity }: { quantity: BigNumber }) => quantity.gt(0)); const { selectedAssetUniqueIds, @@ -453,31 +473,45 @@ export default class WalletSendForm extends Component { this.setState((prevState) => ({ feeCalculationRequestQue: prevState.feeCalculationRequestQue + 1, isTransactionFeeCalculated: false, - transactionFee: new BigNumber(0), transactionFeeError: null, + isCalculatingTransactionFee: true, })); + try { - this._isCalculatingTransactionFee = true; const { fee, minimumAda } = await this.props.calculateTransactionFee( receiver, adaAmount, assets ); + if ( this._isMounted && this.isLatestTransactionFeeRequest( this.state.feeCalculationRequestQue, prevFeeCalculationRequestQue - ) && - !this.selectedAssetsAmounts.includes(emptyAssetFieldValue) + ) ) { - this._isCalculatingTransactionFee = false; - this.setState({ + const minimumAdaValue = minimumAda || new BigNumber(0); + const adaAmountValue = new BigNumber(adaAmountField.value || 0); + const nextState = { isTransactionFeeCalculated: true, - minimumAda: minimumAda || new BigNumber(0), + minimumAda: minimumAdaValue, transactionFee: fee, transactionFeeError: null, - }); + isCalculatingTransactionFee: false, + adaInputState: this.state.adaInputState, + }; + + if (shouldUpdateMinimumAdaAmount) { + const adaInputState = await this.checkAdaInputState( + adaAmountValue, + minimumAdaValue + ); + + nextState.adaInputState = adaInputState; + this.trySetMinimumAdaAmount(adaInputState, minimumAdaValue); + } + this.setState(nextState); } } catch (error) { if ( @@ -491,6 +525,11 @@ export default class WalletSendForm extends Component { let transactionFeeError; let localizableError = error; let values; + let nextState = { + isCalculatingTransactionFee: false, + isTransactionFeeCalculated: false, + transactionFee: new BigNumber(0), + }; if (error.id === 'api.errors.utxoTooSmall') { const minimumAda = get(error, 'values.minimumAda'); @@ -499,7 +538,25 @@ export default class WalletSendForm extends Component { ? messages.minAdaRequiredWithAssetTooltip : messages.minAdaRequiredWithNoAssetTooltip; values = { minimumAda }; - this.setState({ minimumAda: new BigNumber(minimumAda) }); + if (shouldUpdateMinimumAdaAmount) { + const minimumAdaValue = new BigNumber(minimumAda); + const adaAmountValue = new BigNumber(adaAmountField.value || 0); + const adaInputState = await this.checkAdaInputState( + adaAmountValue, + minimumAdaValue + ); + this.trySetMinimumAdaAmount(adaInputState, minimumAdaValue); + this.setState({ + ...nextState, + adaInputState, + minimumAda: new BigNumber(minimumAda), + }); + return; + } + nextState = { + ...nextState, + minimumAda: new BigNumber(minimumAda), + }; } } @@ -511,28 +568,124 @@ export default class WalletSendForm extends Component { /> ); } else { - transactionFeeError = ( - + transactionFeeError = this.context.intl.formatMessage( + localizableError, + values ); } - this._isCalculatingTransactionFee = false; this.setState({ - isTransactionFeeCalculated: false, - transactionFee: new BigNumber(0), + ...nextState, transactionFeeError, }); } } }; + checkAdaInputState = async ( + adaAmount: BigNumber, + minimumAda: BigNumber + ): Promise => { + const { + adaAmountInputTrack, + selectedAssetUniqueIds, + adaInputState, + } = this.state; + + if ( + adaAmountInputTrack.gt(minimumAda) && + adaInputState === AdaInputStateType.Updated + ) { + return AdaInputStateType.Restored; + } + + if ( + adaAmountInputTrack.lt(minimumAda) && + !isEmpty(selectedAssetUniqueIds) + ) { + const isValid = await this.props.validateAmount( + formattedAmountToNaturalUnits(minimumAda.toFormat()) + ); + + if (!isValid) { + return AdaInputStateType.None; + } + + return AdaInputStateType.Updated; + } + + if (isEmpty(selectedAssetUniqueIds)) { + return AdaInputStateType.Reset; + } + + return AdaInputStateType.None; + }; + + trySetMinimumAdaAmount = ( + adaInputState: AdaInputState, + minimumAda: BigNumber + ) => { + const { formFields } = this.state; + const { adaAmount: adaAmountField } = formFields.receiver; + + switch (adaInputState) { + case 'updated': + adaAmountField.onChange(minimumAda.toString()); + break; + case 'restored': + case 'reset': + adaAmountField.onChange(this.state.adaAmountInputTrack.toString()); + break; + case 'none': + default: + } + }; + + updateAdaAmount = async () => { + const { minimumAda } = this.state; + const formattedMinimumAda = minimumAda.toFormat(); + const isValid = await this.props.validateAmount( + formattedAmountToNaturalUnits(formattedMinimumAda) + ); + + if (!isValid) { + return; + } + + this.form.$('adaAmount').onChange(formattedMinimumAda); + this.setState({ + adaInputState: AdaInputStateType.None, + adaAmountInputTrack: minimumAda, + }); + }; + + onAdaAmountFieldChange = (value: string) => { + const { formFields } = this.state; + const { adaAmount: adaAmountField } = formFields.receiver; + + adaAmountField.onChange(value != null ? value : ''); + + const adaAmount = new BigNumber(value != null ? value : 0); + + this.setState({ + adaAmountInputTrack: adaAmount, + adaInputState: AdaInputStateType.None, + }); + }; + + isAdaAmountLessThanMinimumRequired = () => { + const adaAmountField = this.form.$('adaAmount'); + const adaAmount = new BigNumber(adaAmountField.value || 0); + return adaAmount.lt(this.state.minimumAda); + }; + resetTransactionFee() { if (this._isMounted) { - this._isCalculatingTransactionFee = false; this.setState({ isTransactionFeeCalculated: false, transactionFee: new BigNumber(0), transactionFeeError: null, + isCalculatingTransactionFee: false, }); } } @@ -554,21 +707,23 @@ export default class WalletSendForm extends Component { const { receiver } = formFields; const assetFields = omit(receiver.assetFields, uniqueId); const assetsDropdown = omit(receiver.assetsDropdown, uniqueId); - this.setState({ - selectedAssetUniqueIds: without(selectedAssetUniqueIds, uniqueId), - formFields: { - ...formFields, - receiver: { - ...receiver, - assetFields, - assetsDropdown, + this.setState( + { + selectedAssetUniqueIds: without(selectedAssetUniqueIds, uniqueId), + formFields: { + ...formFields, + receiver: { + ...receiver, + assetFields, + assetsDropdown, + }, }, }, - }); - this.removeAssetFields(uniqueId); - setTimeout(() => { - this.calculateTransactionFee(); - }); + async () => { + this.removeAssetFields(uniqueId); + await this.calculateTransactionFee(true); + } + ); }; addAssetFields = (uniqueId: string) => { @@ -611,7 +766,7 @@ export default class WalletSendForm extends Component { assetValue.isLessThanOrEqualTo(asset.quantity); const isValid = isValidAmount && isValidRange; if (isValid) { - this.calculateTransactionFee(); + await this.calculateTransactionFee(true); } else { this.resetTransactionFee(); } @@ -657,11 +812,17 @@ export default class WalletSendForm extends Component { this.resetTransactionFee(); }; + getMinimumAdaValue = () => { + const { minimumAda } = this.state; + return minimumAda.isZero() + ? TRANSACTION_MIN_ADA_VALUE + : minimumAda.toFormat(); + }; + renderReceiverRow = (): Node => { const { intl } = this.context; const { formFields, - minimumAda, transactionFeeError, selectedAssetUniqueIds, isReceiverAddressValid, @@ -680,13 +841,10 @@ export default class WalletSendForm extends Component { 40 * selectedAssetUniqueIds.length : assetsSeparatorBasicHeight; - const minimumAdaValue = minimumAda.isZero() - ? TRANSACTION_MIN_ADA_VALUE - : minimumAda.toFormat(); + const minimumAdaValue = this.getMinimumAdaValue(); const addAssetButtonClasses = classNames([ styles.addAssetButton, - !this.hasAvailableAssets ? styles.disabled : null, 'primary', ]); @@ -778,27 +936,48 @@ export default class WalletSendForm extends Component { this.addFocusableField(field); }} className="adaAmount" - value={adaAmountField.value} bigNumberFormat={this.getCurrentNumberFormat()} decimalPlaces={currencyMaxFractionalDigits} numberLocaleOptions={{ minimumFractionDigits: currencyMaxFractionalDigits, }} - onChange={(value) => { - adaAmountField.onChange(value); - }} + onChange={this.onAdaAmountFieldChange} currency={globalMessages.adaUnit} error={adaAmountField.error || transactionFeeError} onKeyPress={this.handleSubmitOnEnter} allowSigns={false} autoFocus={this._isAutoFocusEnabled} /> -
- - {intl.formatMessage(messages.minAdaRequired, { - minimumAda: minimumAdaValue, - })} - +
+ {this.isAdaAmountLessThanMinimumRequired() ? ( + <> +