From 97c367a39615adb179ab6c906ea39d26e045ee53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 17 Oct 2024 09:20:15 +0200 Subject: [PATCH 01/21] Add token amount input component --- .../src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss | 3 +++ .../src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx | 0 .../src/popup/popupX/shared/Form/TokenAmount/index.ts | 0 3 files changed, 3 insertions(+) create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/index.ts diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss new file mode 100644 index 000000000..495e161d0 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -0,0 +1,3 @@ +.token-amount { + display: block; +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/index.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/index.ts new file mode 100644 index 000000000..e69de29bb From b746b0a81d91df4b98866134cb16466d209b96b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 17 Oct 2024 10:58:09 +0200 Subject: [PATCH 02/21] Move styling from sendfunds component, add storybook story --- .../shared/Form/TokenAmount/TokenAmount.scss | 75 ++++++++++++++++++- .../Form/TokenAmount/TokenAmount.stories.tsx | 21 ++++++ .../shared/Form/TokenAmount/TokenAmount.tsx | 60 +++++++++++++++ .../src/popup/popupX/styles/_elements.scss | 1 + 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 495e161d0..84f8eb3fc 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -1,3 +1,76 @@ .token-amount { - display: block; + display: flex; + flex-direction: column; + border-radius: rem(16px); + background: $gradient-card-bg; + margin-top: rem(16px); + padding: rem(20px) rem(16px); + + .text__main_medium { + color: $color-black; + } + + .capture__additional_small { + padding: rem(4px); + border-radius: rem(4px); + color: $color-black; + background: $secondary-button-bg; + } + + &_token, + &_amount, + &_receiver { + display: flex; + flex-direction: column; + } + + &_token { + .token-selector { + display: flex; + align-items: center; + margin-top: rem(12px); + padding-bottom: rem(12px); + border-bottom: 1px solid rgba($color-black, 0.1); + + .text__additional_small { + margin-left: auto; + } + + .token-icon { + display: flex; + padding: rem(5px); + margin-right: rem(8px); + border-radius: rem(6px); + background: $color-grey-1; + + svg { + width: rem(14px); + height: rem(14px); + } + } + } + } + + &_amount { + margin-top: rem(24px); + + .amount-selector { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: rem(8px) 0; + margin-bottom: rem(4px); + border-bottom: 1px solid rgba($color-black, 0.1); + } + } + + &_receiver { + margin-top: rem(24px); + + .address-selector { + display: flex; + justify-content: space-between; + margin-top: rem(12px); + } + } } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx new file mode 100644 index 000000000..6e8ad4612 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -0,0 +1,21 @@ +import { Meta, StoryObj } from '@storybook/react'; +import TokenAmount from './TokenAmount'; + +export default { + title: 'X/Shared/TokenAmount', + component: TokenAmount, +} as Meta; + +export const Primary: StoryObj = { + args: { + token: { type: 'ccd' }, + }, + beforeEach: () => { + const body = document.getElementsByTagName('body').item(0); + body?.classList.add('popup-x'); + + return () => { + body?.classList.remove('popup-x'); + }; + }, +}; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index e69de29bb..295f51622 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import { ContractAddress, HexString } from '@concordium/web-sdk'; +import { TokenMetadata } from '@shared/storage/types'; +import SideArrow from '@assets/svgX/side-arrow.svg'; +import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; + +type Token = + | { type: 'ccd' } + | { type: 'cis2'; data: TokenMetadata; address: { id: HexString; contract: ContractAddress.Type } }; + +type Props = { + /** The token to specify an amount for. If left undefined, a token picker will be rendered */ + token?: Token; +}; + +export default function TokenAmount({ token }: Props) { + const tokenName = useMemo(() => { + switch (token?.type) { + case 'cis2': { + return ( + token.data.symbol ?? token.data.name ?? `${token.address.id}@${token.address.contract.toString()}` + ); + } + case 'ccd': + default: + return 'CCD'; // FIXME: translation + } + }, [token]); + + return ( +
+
+ Token +
+
+ +
+ {tokenName} + + 17,800 CCD available +
+
+
+ Amount +
+ 12,600.00 + Send max. +
+ Estimated transaction fee: 0.03614 CCD +
+
+ Receiver address +
+ bc1qxy2kgdygq2...0wlh + Address Book +
+
+
+ ); +} diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index 2ce9e9529..653385601 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -36,3 +36,4 @@ @import '../shared/ExternalLink/ExternalLink'; @import '../shared/Carousel/Carousel'; @import '../shared/Form/Form'; +@import '../shared/Form/TokenAmount/TokenAmount'; From 54542227c74e88d5b64ce5427729e06f112f3d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 17 Oct 2024 12:22:56 +0200 Subject: [PATCH 03/21] Express different amount input variants --- .../browser-wallet/.storybook/preview.jsx | 8 ++ .../shared/Form/TokenAmount/TokenAmount.scss | 10 +- .../Form/TokenAmount/TokenAmount.stories.tsx | 47 ++++++- .../shared/Form/TokenAmount/TokenAmount.tsx | 131 +++++++++++++----- 4 files changed, 158 insertions(+), 38 deletions(-) diff --git a/packages/browser-wallet/.storybook/preview.jsx b/packages/browser-wallet/.storybook/preview.jsx index a5d399975..25b32c9f2 100644 --- a/packages/browser-wallet/.storybook/preview.jsx +++ b/packages/browser-wallet/.storybook/preview.jsx @@ -3,6 +3,14 @@ import React, { useEffect } from 'react'; import '../src/popup/index.scss'; import '../src/popup/shell/i18n'; +// Workaround for bigint serialization error: https://github.com/storybookjs/storybook/issues/22452 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +// eslint-disable-next-line no-extend-native, func-names +BigInt.prototype.toJSON = function () { + return this.toString(); +}; + document.getElementsByTagName('html').item(0)?.classList.add('ui-scale-large'); /** diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 84f8eb3fc..4e2d050ea 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -6,10 +6,14 @@ margin-top: rem(16px); padding: rem(20px) rem(16px); - .text__main_medium { + .text__main_medium, .text__main { color: $color-black; } + .capture__main_small { + color: $color-grey-3 + } + .capture__additional_small { padding: rem(4px); border-radius: rem(4px); @@ -61,6 +65,10 @@ padding: rem(8px) 0; margin-bottom: rem(4px); border-bottom: 1px solid rgba($color-black, 0.1); + + .heading_big { + color: $color-black; + } } } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index 6e8ad4612..5a6a33472 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -1,15 +1,11 @@ import { Meta, StoryObj } from '@storybook/react'; +import { AccountAddress, CcdAmount, ContractAddress } from '@concordium/web-sdk'; + import TokenAmount from './TokenAmount'; export default { title: 'X/Shared/TokenAmount', component: TokenAmount, -} as Meta; - -export const Primary: StoryObj = { - args: { - token: { type: 'ccd' }, - }, beforeEach: () => { const body = document.getElementsByTagName('body').item(0); body?.classList.add('popup-x'); @@ -18,4 +14,43 @@ export const Primary: StoryObj = { body?.classList.remove('popup-x'); }; }, +} as Meta; + +type Story = StoryObj; + +export const OnlyAmount: Story = { + args: { + token: 'ccd', + fee: CcdAmount.fromCcd(0.032), + value: { amount: 100n }, + buttonMaxLabel: 'Stake max.', + receiver: false, + }, +}; + +export const WithReceiver: Story = { + args: { + token: 'ccd', + buttonMaxLabel: 'Send max.', + fee: CcdAmount.fromCcd(0.032), + receiver: true, + value: { + amount: 1000n, + receiver: AccountAddress.fromBase58('4UC8o4m8AgTxt5VBFMdLwMCwwJQVJwjesNzW7RPXkACynrULmd'), + }, + }, +}; + +export const TokenWithReceiver: Story = { + args: { + token: 'cis2', + address: { id: '', contract: ContractAddress.create(123, 0) }, + buttonMaxLabel: 'Send max.', + fee: CcdAmount.fromCcd(0.132), + receiver: true, + value: { + amount: 1000n, + receiver: AccountAddress.fromBase58('4UC8o4m8AgTxt5VBFMdLwMCwwJQVJwjesNzW7RPXkACynrULmd'), + }, + }, }; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 295f51622..2839731a6 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -1,60 +1,129 @@ -import React, { useMemo } from 'react'; -import { ContractAddress, HexString } from '@concordium/web-sdk'; +/* eslint-disable react/destructuring-assignment */ +import React, { ReactNode, useCallback, useMemo } from 'react'; +import { AccountAddress, CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; import { TokenMetadata } from '@shared/storage/types'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; +import { addThousandSeparators, displayAsCcd, integerToFractional } from 'wallet-common-helpers'; -type Token = - | { type: 'ccd' } - | { type: 'cis2'; data: TokenMetadata; address: { id: HexString; contract: ContractAddress.Type } }; +type TokenVariants = + | { + /** The token type */ + token?: undefined; + } + | { + /** The token type */ + token: 'ccd'; + } + | { + /** The token type */ + token: 'cis2'; + /** The token address */ + address: { + /** The token ID within the contract */ + id: HexString; + /** The token contract address */ + contract: ContractAddress.Type; + }; + }; -type Props = { - /** The token to specify an amount for. If left undefined, a token picker will be rendered */ - token?: Token; +type FieldProps = { + /** The field value */ + value: V | undefined; + /** A value change handler */ + onChange?(value: V): void; }; -export default function TokenAmount({ token }: Props) { - const tokenName = useMemo(() => { - switch (token?.type) { +type ValueVariants = + | ({ + /** Whether it should be possible to specify a receiver. Defaults to false */ + receiver?: false; + } & FieldProps<{ + /** The amount */ + amount: bigint; + }>) + | ({ + /** Whether it should be possible to specify a receiver. Defaults to false */ + receiver: true; + } & FieldProps<{ + /** The amount */ + amount: bigint; + /** The specified receiver of the amount */ + receiver: AccountAddress.Type; + }>); + +type Props = { + /** The label used for the button setting the amount to the maximum possible */ + buttonMaxLabel: string; + /** The fee associated with the transaction */ + fee: CcdAmount.Type; +} & ValueVariants & + TokenVariants; + +export default function TokenAmount(props: Props) { + const { buttonMaxLabel, fee } = props; + + const selectedToken: { name: string; icon: ReactNode; decimals: number } = useMemo(() => { + switch (props.token) { case 'cis2': { - return ( - token.data.symbol ?? token.data.name ?? `${token.address.id}@${token.address.contract.toString()}` - ); + const { symbol, name, decimals = 0 }: TokenMetadata = { symbol: 'wETH', decimals: 18 }; // FIXME: hook up to actual metadata + const safeName = symbol ?? name ?? `${props.address.id}@${props.address.contract.toString()}`; + const icon = ; // FIXME: get token icon + return { name: safeName, icon, decimals }; } case 'ccd': + case undefined: { + const name = 'CCD'; // FIXME: translation + const icon = ; + return { name, icon, decimals: 6 }; + } default: - return 'CCD'; // FIXME: translation + throw new Error('Unreachable'); } - }, [token]); + }, [props]); + const amount = useMemo(() => props.value?.amount ?? CcdAmount.zero(), [props.value?.amount]); + const formatAmount = useCallback( + (someAmount: bigint) => { + return addThousandSeparators(integerToFractional(selectedToken.decimals)(someAmount)); + }, + [selectedToken] + ); + const balance = 17800021000n; // FIXME: get actual value return (
Token
-
- -
- {tokenName} - - 17,800 CCD available +
{selectedToken.icon}
+ {selectedToken.name} + {props.token === undefined && } + {/* FIXME: translation */} + + {formatAmount(balance)} {selectedToken.name} available +
Amount
- 12,600.00 - Send max. + {/* FIXME: format amount properly, change to input field */} + {amount.toString()}{' '} + {buttonMaxLabel}
- Estimated transaction fee: 0.03614 CCD + {/* FIXME: translate */} + Estimated transaction fee: {displayAsCcd(fee, false, true)}
-
- Receiver address -
- bc1qxy2kgdygq2...0wlh - Address Book + {props.receiver === true && ( +
+ {/* FIXME: translate */} + Receiver address +
+ {/* FIXME: format as design */} + {props.value?.receiver.toString()} +
-
+ )}
); } From 3137152fee9be64feda369a428e455db758948b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 17 Oct 2024 14:07:41 +0200 Subject: [PATCH 04/21] Change token amount component to depend on being fed a form context --- .../Form/TokenAmount/TokenAmount.stories.tsx | 25 ++++++++----- .../shared/Form/TokenAmount/TokenAmount.tsx | 36 ++++++++----------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index 5a6a33472..86f13b7ba 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -1,3 +1,6 @@ +/* eslint-disable prefer-destructuring */ +import React from 'react'; +import { useForm } from 'react-hook-form'; import { Meta, StoryObj } from '@storybook/react'; import { AccountAddress, CcdAmount, ContractAddress } from '@concordium/web-sdk'; @@ -6,6 +9,19 @@ import TokenAmount from './TokenAmount'; export default { title: 'X/Shared/TokenAmount', component: TokenAmount, + decorators: [ + (Story, context) => { + const form = useForm<{ amount: bigint; receiver?: AccountAddress.Type }>({ + defaultValues: { + amount: 1000n, + receiver: AccountAddress.fromBase58('3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB'), + }, + }); + const args = context.args; + args.form = form; + return ; + }, + ], beforeEach: () => { const body = document.getElementsByTagName('body').item(0); body?.classList.add('popup-x'); @@ -22,7 +38,6 @@ export const OnlyAmount: Story = { args: { token: 'ccd', fee: CcdAmount.fromCcd(0.032), - value: { amount: 100n }, buttonMaxLabel: 'Stake max.', receiver: false, }, @@ -34,10 +49,6 @@ export const WithReceiver: Story = { buttonMaxLabel: 'Send max.', fee: CcdAmount.fromCcd(0.032), receiver: true, - value: { - amount: 1000n, - receiver: AccountAddress.fromBase58('4UC8o4m8AgTxt5VBFMdLwMCwwJQVJwjesNzW7RPXkACynrULmd'), - }, }, }; @@ -48,9 +59,5 @@ export const TokenWithReceiver: Story = { buttonMaxLabel: 'Send max.', fee: CcdAmount.fromCcd(0.132), receiver: true, - value: { - amount: 1000n, - receiver: AccountAddress.fromBase58('4UC8o4m8AgTxt5VBFMdLwMCwwJQVJwjesNzW7RPXkACynrULmd'), - }, }, }; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 2839731a6..922cf3a92 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/destructuring-assignment */ import React, { ReactNode, useCallback, useMemo } from 'react'; +import { UseFormReturn } from 'react-hook-form'; import { AccountAddress, CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; import { TokenMetadata } from '@shared/storage/types'; import SideArrow from '@assets/svgX/side-arrow.svg'; @@ -27,30 +28,19 @@ type TokenVariants = }; }; -type FieldProps = { - /** The field value */ - value: V | undefined; - /** A value change handler */ - onChange?(value: V): void; -}; - type ValueVariants = - | ({ + | { /** Whether it should be possible to specify a receiver. Defaults to false */ receiver?: false; - } & FieldProps<{ - /** The amount */ - amount: bigint; - }>) - | ({ + /** The required form control for the inner fields */ + form: UseFormReturn<{ amount: bigint }>; + } + | { /** Whether it should be possible to specify a receiver. Defaults to false */ receiver: true; - } & FieldProps<{ - /** The amount */ - amount: bigint; - /** The specified receiver of the amount */ - receiver: AccountAddress.Type; - }>); + /** The required form control for the inner fields */ + form: UseFormReturn<{ amount: bigint; receiver: AccountAddress.Type }>; + }; type Props = { /** The label used for the button setting the amount to the maximum possible */ @@ -81,7 +71,6 @@ export default function TokenAmount(props: Props) { throw new Error('Unreachable'); } }, [props]); - const amount = useMemo(() => props.value?.amount ?? CcdAmount.zero(), [props.value?.amount]); const formatAmount = useCallback( (someAmount: bigint) => { return addThousandSeparators(integerToFractional(selectedToken.decimals)(someAmount)); @@ -89,6 +78,7 @@ export default function TokenAmount(props: Props) { [selectedToken] ); const balance = 17800021000n; // FIXME: get actual value + const values = props.form.watch() as { amount: bigint; receiver?: AccountAddress.Type }; // This is kind of unsafe so we need to tread carefully, but there is no good way around it... return (
@@ -108,7 +98,7 @@ export default function TokenAmount(props: Props) { Amount
{/* FIXME: format amount properly, change to input field */} - {amount.toString()}{' '} + {values.amount.toString()} {buttonMaxLabel}
{/* FIXME: translate */} @@ -120,7 +110,9 @@ export default function TokenAmount(props: Props) { Receiver address
{/* FIXME: format as design */} - {props.value?.receiver.toString()} + + {values.receiver ? AccountAddress.toBase58(values.receiver) : ''} +
)} From f3d1e2da0c91aa658825d7e534deb7e4690c61c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Thu, 17 Oct 2024 16:56:32 +0200 Subject: [PATCH 05/21] Amount input field --- .../shared/Form/TokenAmount/TokenAmount.scss | 28 +++++- .../Form/TokenAmount/TokenAmount.stories.tsx | 11 ++- .../shared/Form/TokenAmount/TokenAmount.tsx | 90 ++++++++++++++----- 3 files changed, 98 insertions(+), 31 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 4e2d050ea..2c5448f06 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -30,6 +30,7 @@ &_token { .token-selector { + flex-wrap: wrap; display: flex; align-items: center; margin-top: rem(12px); @@ -38,6 +39,7 @@ .text__additional_small { margin-left: auto; + text-wrap: nowrap; } .token-icon { @@ -56,20 +58,40 @@ } &_amount { + position: relative; margin-top: rem(24px); - .amount-selector { + &_selector { display: flex; - align-items: flex-end; + align-items: center; justify-content: space-between; padding: rem(8px) 0; margin-bottom: rem(4px); border-bottom: 1px solid rgba($color-black, 0.1); - .heading_big { + .heading_medium { color: $color-black; } } + + &_field { + flex: 1; + border: none; + width: 100%; + min-width: 0; + background: none; + margin-right: rem(4px); + } + + &_max { + flex: 0; + text-wrap: nowrap; + } + } + + button.capture__additional_small { + border: unset; + cursor: pointer; } &_receiver { diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index 86f13b7ba..af4a3c8a3 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -2,19 +2,19 @@ import React from 'react'; import { useForm } from 'react-hook-form'; import { Meta, StoryObj } from '@storybook/react'; -import { AccountAddress, CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { CcdAmount, ContractAddress } from '@concordium/web-sdk'; import TokenAmount from './TokenAmount'; export default { - title: 'X/Shared/TokenAmount', + title: 'X/Shared/Form/TokenAmount', component: TokenAmount, decorators: [ (Story, context) => { - const form = useForm<{ amount: bigint; receiver?: AccountAddress.Type }>({ + const form = useForm<{ amount: string; receiver?: string }>({ defaultValues: { - amount: 1000n, - receiver: AccountAddress.fromBase58('3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB'), + amount: '1000', + receiver: '3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB', }, }); const args = context.args; @@ -45,7 +45,6 @@ export const OnlyAmount: Story = { export const WithReceiver: Story = { args: { - token: 'ccd', buttonMaxLabel: 'Send max.', fee: CcdAmount.fromCcd(0.032), receiver: true, diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 922cf3a92..8fe820635 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -1,11 +1,43 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react/destructuring-assignment */ -import React, { ReactNode, useCallback, useMemo } from 'react'; +import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useMemo } from 'react'; import { UseFormReturn } from 'react-hook-form'; -import { AccountAddress, CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; +import clsx from 'clsx'; +import { CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; import { TokenMetadata } from '@shared/storage/types'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; import { addThousandSeparators, displayAsCcd, integerToFractional } from 'wallet-common-helpers'; +import { RequiredControlledFieldProps } from '../common/types'; +import { makeControlled } from '../common/utils'; +import Button from '../../Button'; + +type AmountInputProps = Pick< + InputHTMLAttributes, + 'className' | 'type' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' +> & + RequiredControlledFieldProps; + +/** + * @description + * Use as a normal \. Should NOT be used for checkbox or radio. + */ +const AmountInput = forwardRef( + ({ error, className, type = 'text', valid, ...props }, ref) => { + return ( + + ); + } +); + +const FormAmountInput = makeControlled(AmountInput); type TokenVariants = | { @@ -28,18 +60,21 @@ type TokenVariants = }; }; +type AmountForm = { amount: string }; +type AmountReceiveForm = AmountForm & { receiver: string }; + type ValueVariants = | { /** Whether it should be possible to specify a receiver. Defaults to false */ receiver?: false; - /** The required form control for the inner fields */ - form: UseFormReturn<{ amount: bigint }>; + /** The form type control for the inner fields */ + form: UseFormReturn; } | { /** Whether it should be possible to specify a receiver. Defaults to false */ receiver: true; - /** The required form control for the inner fields */ - form: UseFormReturn<{ amount: bigint; receiver: AccountAddress.Type }>; + /** The form type control for the inner fields */ + form: UseFormReturn; }; type Props = { @@ -53,32 +88,36 @@ type Props = { export default function TokenAmount(props: Props) { const { buttonMaxLabel, fee } = props; - const selectedToken: { name: string; icon: ReactNode; decimals: number } = useMemo(() => { + const selectedToken: { name: string; icon: ReactNode; decimals: number; type: 'ccd' | 'cis2' } = useMemo(() => { switch (props.token) { case 'cis2': { const { symbol, name, decimals = 0 }: TokenMetadata = { symbol: 'wETH', decimals: 18 }; // FIXME: hook up to actual metadata const safeName = symbol ?? name ?? `${props.address.id}@${props.address.contract.toString()}`; const icon = ; // FIXME: get token icon - return { name: safeName, icon, decimals }; + return { name: safeName, icon, decimals, type: 'cis2' }; } case 'ccd': case undefined: { const name = 'CCD'; // FIXME: translation const icon = ; - return { name, icon, decimals: 6 }; + return { name, icon, decimals: 6, type: 'ccd' }; } default: throw new Error('Unreachable'); } }, [props]); - const formatAmount = useCallback( - (someAmount: bigint) => { - return addThousandSeparators(integerToFractional(selectedToken.decimals)(someAmount)); - }, - [selectedToken] - ); + + const formatAmount = useCallback(integerToFractional(selectedToken.decimals), [selectedToken]); const balance = 17800021000n; // FIXME: get actual value - const values = props.form.watch() as { amount: bigint; receiver?: AccountAddress.Type }; // This is kind of unsafe so we need to tread carefully, but there is no good way around it... + + const setMax = useCallback(() => { + const available = selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance; + (props.form as UseFormReturn).setValue('amount', formatAmount(available) ?? '', { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + }, [selectedToken, props.form]); return (
@@ -90,16 +129,23 @@ export default function TokenAmount(props: Props) { {props.token === undefined && } {/* FIXME: translation */} - {formatAmount(balance)} {selectedToken.name} available + {addThousandSeparators(formatAmount(balance))} {selectedToken.name} available
Amount -
- {/* FIXME: format amount properly, change to input field */} - {values.amount.toString()} - {buttonMaxLabel} +
+ {/* FIXME: format amount properly */} + {/* TODO: ensure the value does not overflow the max amount of space available */} + ).control} + name="amount" + /> + setMax()}> + {buttonMaxLabel} +
{/* FIXME: translate */} Estimated transaction fee: {displayAsCcd(fee, false, true)} @@ -111,7 +157,7 @@ export default function TokenAmount(props: Props) {
{/* FIXME: format as design */} - {values.receiver ? AccountAddress.toBase58(values.receiver) : ''} + {/* values.receiver ? AccountAddress.toBase58(values.receiver) : '' */}
From 3715b0fa709bf5a0af6b1ec193ed7dd0587f0162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Fri, 18 Oct 2024 13:45:25 +0200 Subject: [PATCH 06/21] Add expected sizes to storybook --- packages/browser-wallet/.storybook/main.js | 1 + .../browser-wallet/.storybook/preview.jsx | 34 ++++++++++++++++++- packages/browser-wallet/package.json | 1 + yarn.lock | 3 +- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/browser-wallet/.storybook/main.js b/packages/browser-wallet/.storybook/main.js index 81c79a823..4899e99f4 100644 --- a/packages/browser-wallet/.storybook/main.js +++ b/packages/browser-wallet/.storybook/main.js @@ -14,6 +14,7 @@ module.exports = { getAbsolutePath('@storybook/addon-interactions'), getAbsolutePath('@storybook/preset-scss'), getAbsolutePath('@storybook/addon-webpack5-compiler-swc'), + getAbsolutePath('@storybook/addon-viewport'), ], framework: { diff --git a/packages/browser-wallet/.storybook/preview.jsx b/packages/browser-wallet/.storybook/preview.jsx index 25b32c9f2..20f9bcfd5 100644 --- a/packages/browser-wallet/.storybook/preview.jsx +++ b/packages/browser-wallet/.storybook/preview.jsx @@ -38,14 +38,46 @@ const themeDecorator = (Story, { globals, parameters }) => { return ; }; +/** + * Adds custom styling to the HTML surrounding individual stories. + */ +const customStyleDecorator = (Story) => { + return ( +
+ +
+ ); +}; + export const parameters = { + viewport: { + viewports: { + Small: { + name: 'Small', + styles: { + width: '312px', + height: '528px', + }, + }, + Normal: { + name: 'Normal', + styles: { + width: '375px', + height: '600px', + }, + }, + }, + // Optionally, you can set default viewports + defaultViewport: 'Normal', + }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, + backgrounds: { default: 'dark' }, }; -export const decorators = [themeDecorator]; +export const decorators = [themeDecorator, customStyleDecorator]; export const tags = ['autodocs']; diff --git a/packages/browser-wallet/package.json b/packages/browser-wallet/package.json index 493b7f5b6..34a1ac9b9 100644 --- a/packages/browser-wallet/package.json +++ b/packages/browser-wallet/package.json @@ -64,6 +64,7 @@ "@storybook/addon-essentials": "^8.3.5", "@storybook/addon-interactions": "^8.3.5", "@storybook/addon-links": "^8.3.5", + "@storybook/addon-viewport": "^8.3.5", "@storybook/addon-webpack5-compiler-swc": "^1.0.5", "@storybook/preset-scss": "^1.0.3", "@storybook/react": "^8.3.5", diff --git a/yarn.lock b/yarn.lock index 08cd888ed..1b519e151 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2184,6 +2184,7 @@ __metadata: "@storybook/addon-essentials": ^8.3.5 "@storybook/addon-interactions": ^8.3.5 "@storybook/addon-links": ^8.3.5 + "@storybook/addon-viewport": ^8.3.5 "@storybook/addon-webpack5-compiler-swc": ^1.0.5 "@storybook/preset-scss": ^1.0.3 "@storybook/react": ^8.3.5 @@ -4221,7 +4222,7 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-viewport@npm:8.3.5": +"@storybook/addon-viewport@npm:8.3.5, @storybook/addon-viewport@npm:^8.3.5": version: 8.3.5 resolution: "@storybook/addon-viewport@npm:8.3.5" dependencies: From a1ea54b750c42eb1fa702ad44b48fee542c0ce5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Fri, 18 Oct 2024 13:48:46 +0200 Subject: [PATCH 07/21] Ensure consistent amount formatting --- .../popup/popupX/pages/MainPage/MainPage.tsx | 12 +--- .../shared/Form/TokenAmount/TokenAmount.scss | 9 ++- .../Form/TokenAmount/TokenAmount.stories.tsx | 11 ++- .../shared/Form/TokenAmount/TokenAmount.tsx | 67 +++++++++++++------ .../src/popup/popupX/shared/utils/helpers.ts | 11 +++ 5 files changed, 74 insertions(+), 36 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx index 04f9eee15..0e49f3b5f 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx @@ -21,6 +21,7 @@ import FileText from '@assets/svgX/file-text.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; import Plant from '@assets/svgX/plant.svg'; import Gear from '@assets/svgX/gear.svg'; +import { formatBalance } from '@popup/popupX/shared/utils/helpers'; /** Hook loading every fungible token added to the account. */ function useAccountFungibleTokens(account: WalletCredential) { @@ -34,17 +35,6 @@ function useAccountTokenBalance(accountAddress: string, contractAddress: string, return balance; } -/** Display a token balance with a number of decimals. Localized. */ -function formatBalance(balance: bigint, decimals: number = 0) { - const padded = balance.toString().padStart(decimals + 1, '0'); - const integer = padded.slice(0, -decimals); - const fraction = padded.slice(-decimals); - const balanceFormatter = new Intl.NumberFormat(undefined, { minimumFractionDigits: decimals }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore format below supports strings, TypeScript is just not aware. - return balanceFormatter.format(`${integer}.${fraction}`); -} - type TokenBalanceProps = { decimals?: number; tokenId: string; contractAddress: string; accountAddress: string }; /** Component for fetching and displaying a CIS-2 token balance of an account. */ function AccountTokenBalance({ decimals, tokenId, contractAddress, accountAddress }: TokenBalanceProps) { diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 2c5448f06..960d8b421 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -76,10 +76,7 @@ &_field { flex: 1; - border: none; - width: 100%; min-width: 0; - background: none; margin-right: rem(4px); } @@ -89,6 +86,12 @@ } } + &_field { + border: none; + width: 100%; + background: none; + } + button.capture__additional_small { border: unset; cursor: pointer; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index af4a3c8a3..ff09a3feb 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -1,10 +1,11 @@ +/* eslint-disable no-console */ /* eslint-disable prefer-destructuring */ import React from 'react'; -import { useForm } from 'react-hook-form'; import { Meta, StoryObj } from '@storybook/react'; import { CcdAmount, ContractAddress } from '@concordium/web-sdk'; import TokenAmount from './TokenAmount'; +import Form, { useForm } from '..'; export default { title: 'X/Shared/Form/TokenAmount', @@ -13,13 +14,17 @@ export default { (Story, context) => { const form = useForm<{ amount: string; receiver?: string }>({ defaultValues: { - amount: '1000', + amount: '1,000.00', receiver: '3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB', }, }); const args = context.args; args.form = form; - return ; + return ( +
+ {() => } + + ); }, ], beforeEach: () => { diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 8fe820635..23444d49d 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -7,26 +7,27 @@ import { CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; import { TokenMetadata } from '@shared/storage/types'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; -import { addThousandSeparators, displayAsCcd, integerToFractional } from 'wallet-common-helpers'; -import { RequiredControlledFieldProps } from '../common/types'; -import { makeControlled } from '../common/utils'; +import { addThousandSeparators, displayAsCcd, fractionalToInteger } from 'wallet-common-helpers'; +import { RequiredUncontrolledFieldProps } from '../common/types'; +import { makeUncontrolled } from '../common/utils'; import Button from '../../Button'; +import { formatTokenAmount } from '../../utils/helpers'; type AmountInputProps = Pick< InputHTMLAttributes, 'className' | 'type' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' > & - RequiredControlledFieldProps; + RequiredUncontrolledFieldProps; /** * @description * Use as a normal \. Should NOT be used for checkbox or radio. */ -const AmountInput = forwardRef( +const InputClear = forwardRef( ({ error, className, type = 'text', valid, ...props }, ref) => { return ( ( } ); -const FormAmountInput = makeControlled(AmountInput); +const FormInputClear = makeUncontrolled(InputClear); type TokenVariants = | { @@ -87,6 +88,7 @@ type Props = { export default function TokenAmount(props: Props) { const { buttonMaxLabel, fee } = props; + const balance = 17800021000n; // FIXME: get actual value const selectedToken: { name: string; icon: ReactNode; decimals: number; type: 'ccd' | 'cis2' } = useMemo(() => { switch (props.token) { @@ -107,8 +109,14 @@ export default function TokenAmount(props: Props) { } }, [props]); - const formatAmount = useCallback(integerToFractional(selectedToken.decimals), [selectedToken]); - const balance = 17800021000n; // FIXME: get actual value + const formatAmount = useCallback( + (amountValue: bigint) => formatTokenAmount(BigInt(amountValue), selectedToken.decimals, 2), + [selectedToken] + ); + const parseAmount = useCallback( + (amountValue: string) => fractionalToInteger(amountValue.replace(/[,]/g, ''), selectedToken.decimals), + [selectedToken] + ); const setMax = useCallback(() => { const available = selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance; @@ -119,6 +127,23 @@ export default function TokenAmount(props: Props) { }); }, [selectedToken, props.form]); + const handleAmountBlur: React.FocusEventHandler = (event) => { + const { value } = event.target; + + if (value === '') { + return; + } + + try { + const formatted = formatAmount(parseAmount(value)); + if (formatted !== value) { + (props.form as UseFormReturn).setValue('amount', formatted ?? ''); + } + } catch { + // Do nothing... + } + }; + return (
@@ -137,11 +162,12 @@ export default function TokenAmount(props: Props) { Amount
{/* FIXME: format amount properly */} - {/* TODO: ensure the value does not overflow the max amount of space available */} - ).control} + ).register} name="amount" + onBlur={handleAmountBlur} + // FIXME: Add validation /> setMax()}> {buttonMaxLabel} @@ -154,12 +180,15 @@ export default function TokenAmount(props: Props) {
{/* FIXME: translate */} Receiver address -
- {/* FIXME: format as design */} - - {/* values.receiver ? AccountAddress.toBase58(values.receiver) : '' */} - -
+ {/* TODO: Figure out what to do with overflowing text? + 1. multiline + 2. show abbreviated version on blur */} + ).register} + name="receiver" + // FIXME: Add validation + />
)}
diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts index c0984c585..b2043d4a2 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts @@ -5,3 +5,14 @@ export async function copyToClipboard(text: string): Promise { // TODO: logging. } } + +/** Display a token amount with a number of decimals. Localized. */ +export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = decimals) { + const padded = amount.toString().padStart(decimals + 1, '0'); // Ensure the string length is minimum decimals + 1 characters. For CCD, this would mean minimum 7 characters long + const integer = padded.slice(0, -decimals); + const fraction = padded.slice(-decimals); + const balanceFormatter = new Intl.NumberFormat(undefined, { minimumFractionDigits: minDecimals }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore format below supports strings, TypeScript is just not aware. + return balanceFormatter.format(`${integer}.${fraction}`); +} From fe467ab7cf5dd2ac79306db8df18c8f6501f7e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Fri, 18 Oct 2024 15:52:16 +0200 Subject: [PATCH 08/21] Amount input validation --- .../popup/popupX/pages/MainPage/MainPage.tsx | 4 +- .../src/popup/popupX/shared/Form/Form.scss | 25 ++-- .../popupX/shared/Form/Password/Password.tsx | 2 +- .../shared/Form/TokenAmount/TokenAmount.scss | 4 + .../Form/TokenAmount/TokenAmount.stories.tsx | 37 +++--- .../shared/Form/TokenAmount/TokenAmount.tsx | 104 ++++++++++----- .../popup/popupX/shared/Form/common/types.ts | 2 +- .../src/popup/popupX/shared/i18n/en.ts | 27 ++++ .../src/popup/popupX/shared/utils/helpers.ts | 5 +- .../shared/utils/transaction-helpers.ts | 119 ++++++++++++++++++ 10 files changed, 265 insertions(+), 64 deletions(-) create mode 100644 packages/browser-wallet/src/popup/popupX/shared/utils/transaction-helpers.ts diff --git a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx index 0e49f3b5f..8b77c4f31 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx @@ -21,7 +21,7 @@ import FileText from '@assets/svgX/file-text.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; import Plant from '@assets/svgX/plant.svg'; import Gear from '@assets/svgX/gear.svg'; -import { formatBalance } from '@popup/popupX/shared/utils/helpers'; +import { formatTokenAmount } from '@popup/popupX/shared/utils/helpers'; /** Hook loading every fungible token added to the account. */ function useAccountFungibleTokens(account: WalletCredential) { @@ -39,7 +39,7 @@ type TokenBalanceProps = { decimals?: number; tokenId: string; contractAddress: /** Component for fetching and displaying a CIS-2 token balance of an account. */ function AccountTokenBalance({ decimals, tokenId, contractAddress, accountAddress }: TokenBalanceProps) { const balanceRaw = useAccountTokenBalance(accountAddress, contractAddress, tokenId) ?? 0n; - const balance = useMemo(() => formatBalance(balanceRaw, decimals), [balanceRaw]); + const balance = useMemo(() => formatTokenAmount(balanceRaw, decimals), [balanceRaw]); return {balance}; } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss index 8aef77e5d..161fbb376 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss @@ -24,8 +24,10 @@ margin-bottom: rem(8px); width: 100%; outline: none; - font-size: rem(14px); /* override base style, remove after release */ - transform: unset; /* override base style, remove after release */ + font-size: rem(14px); + /* override base style, remove after release */ + transform: unset; + /* override base style, remove after release */ @include transition(border-color); &:focus { @@ -42,13 +44,15 @@ @include when-valid { .form-input__field:where(:focus) { - border-color: green; /* ToDo get color */ + border-color: green; + /* ToDo get color */ } } @include when-invalid { .form-input__field { - border-color: red; /* ToDo get color */ + border-color: red; + /* ToDo get color */ } } @@ -76,6 +80,7 @@ transform: unset; svg { + g, path { fill: $color-mineral-3; @@ -110,11 +115,11 @@ $handle-size: rem(20px); .form-toggle-x { &__root { - input:checked + .form-toggle-x__slider { + input:checked+.form-toggle-x__slider { background-color: $color-green-toggle; } - input:checked + .form-toggle-x__slider::before { + input:checked+.form-toggle-x__slider::before { transform: translateX(rem(24px)); } } @@ -191,11 +196,11 @@ $handle-size: rem(20px); } } - &:hover input ~ .checkmark { + &:hover input~.checkmark { background-color: $color-grey-3; } - input:checked ~ .checkmark::after { + input:checked~.checkmark::after { display: block; } @@ -206,3 +211,7 @@ $handle-size: rem(20px); } } } + +.form-error-message { + color: $color-red-attention !important; +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx index 852840ec3..ee99d1a20 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx @@ -32,7 +32,7 @@ type Props = Pick, 'className' | 'autoFocu * Password input with reveal button and optional strength check. */ export function Password({ showStrength = false, value, className, autoFocus, ...props }: Props) { - const { t } = useTranslation(); + const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); const [reveal, setReveal] = useState(false); const strength = useMemo( diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 960d8b421..88b49f43c 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -90,6 +90,10 @@ border: none; width: 100%; background: none; + + &--invalid { + color: $color-red-attention !important; + } } button.capture__additional_small { diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index ff09a3feb..508c3e4a4 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -3,30 +3,31 @@ import React from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { PropsOf } from 'wallet-common-helpers'; -import TokenAmount from './TokenAmount'; +import TokenAmount, { AmountReceiveForm } from './TokenAmount'; import Form, { useForm } from '..'; +function Wrapper(props: PropsOf) { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + amount: '1,000.00', + receiver: '3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB', + }, + }); + return ( +
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {() => } + + ); +} + export default { title: 'X/Shared/Form/TokenAmount', component: TokenAmount, - decorators: [ - (Story, context) => { - const form = useForm<{ amount: string; receiver?: string }>({ - defaultValues: { - amount: '1,000.00', - receiver: '3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB', - }, - }); - const args = context.args; - args.form = form; - return ( -
- {() => } - - ); - }, - ], + render: (props) => , beforeEach: () => { const body = document.getElementsByTagName('body').item(0); body?.classList.add('popup-x'); diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 23444d49d..812fac810 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -1,17 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react/destructuring-assignment */ import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useMemo } from 'react'; -import { UseFormReturn } from 'react-hook-form'; +import { UseFormReturn, Validate } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import { CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; import { TokenMetadata } from '@shared/storage/types'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; -import { addThousandSeparators, displayAsCcd, fractionalToInteger } from 'wallet-common-helpers'; +import { displayAsCcd, fractionalToInteger } from 'wallet-common-helpers'; import { RequiredUncontrolledFieldProps } from '../common/types'; import { makeUncontrolled } from '../common/utils'; import Button from '../../Button'; import { formatTokenAmount } from '../../utils/helpers'; +import { validateAccountAddress, validateTransferAmount } from '../../utils/transaction-helpers'; +import ErrorMessage from '../ErrorMessage'; type AmountInputProps = Pick< InputHTMLAttributes, @@ -24,10 +27,10 @@ type AmountInputProps = Pick< * Use as a normal \. Should NOT be used for checkbox or radio. */ const InputClear = forwardRef( - ({ error, className, type = 'text', valid, ...props }, ref) => { + ({ error, className, type = 'text', ...props }, ref) => { return ( value.replace(/[,]/g, ''); + export default function TokenAmount(props: Props) { + const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); const { buttonMaxLabel, fee } = props; const balance = 17800021000n; // FIXME: get actual value @@ -100,7 +106,7 @@ export default function TokenAmount(props: Props) { } case 'ccd': case undefined: { - const name = 'CCD'; // FIXME: translation + const name = 'CCD'; const icon = ; return { name, icon, decimals: 6, type: 'ccd' }; } @@ -118,68 +124,94 @@ export default function TokenAmount(props: Props) { [selectedToken] ); + const availableAmount = useMemo( + () => (selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance), + [selectedToken, fee, balance] + ); + const setMax = useCallback(() => { - const available = selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance; - (props.form as UseFormReturn).setValue('amount', formatAmount(available) ?? '', { + (props.form as UseFormReturn).setValue('amount', formatAmount(availableAmount) ?? '', { shouldDirty: true, shouldTouch: true, shouldValidate: true, }); - }, [selectedToken, props.form]); + }, [availableAmount, props.form]); - const handleAmountBlur: React.FocusEventHandler = (event) => { - const { value } = event.target; + const handleAmountBlur: React.FocusEventHandler = useCallback( + (event) => { + const { value } = event.target; - if (value === '') { - return; - } + if (value === '') { + return; + } - try { - const formatted = formatAmount(parseAmount(value)); - if (formatted !== value) { - (props.form as UseFormReturn).setValue('amount', formatted ?? ''); + try { + const formatted = formatAmount(parseAmount(value)); + if (formatted !== value) { + (props.form as UseFormReturn).setValue('amount', formatted ?? ''); + } + } catch { + // Do nothing... } - } catch { - // Do nothing... - } - }; + }, + [props.form, formatAmount, parseAmount] + ); + + const validateAmount: Validate = useCallback( + (value: string) => + validateTransferAmount( + removeThousandSeparators(value), + availableAmount, + selectedToken.decimals, + selectedToken.type === 'ccd' ? fee.microCcdAmount : 0n + ), + [availableAmount, selectedToken] + ); return (
- Token + {t('form.tokenAmount.token.label')}
{selectedToken.icon}
{selectedToken.name} {props.token === undefined && } - {/* FIXME: translation */} - {addThousandSeparators(formatAmount(balance))} {selectedToken.name} available + {t('form.tokenAmount.token.available', { + balance: formatAmount(balance), + name: selectedToken.name, + })}
Amount
- {/* FIXME: format amount properly */} ).register} name="amount" onBlur={handleAmountBlur} - // FIXME: Add validation + rules={{ + required: t('utils.amount.required'), + min: { value: 0, message: t('utils.amount.zero') }, + validate: validateAmount, + }} /> setMax()}> {buttonMaxLabel}
- {/* FIXME: translate */} - Estimated transaction fee: {displayAsCcd(fee, false, true)} + + {props.form.formState.errors.amount?.message} + + + {t('form.tokenAmount.amount.fee', { fee: displayAsCcd(fee, false, true) })} +
{props.receiver === true && (
- {/* FIXME: translate */} - Receiver address + {t('form.tokenAmount.address.label')} {/* TODO: Figure out what to do with overflowing text? 1. multiline 2. show abbreviated version on blur */} @@ -187,8 +219,14 @@ export default function TokenAmount(props: Props) { className="text__main" register={(props.form as UseFormReturn).register} name="receiver" - // FIXME: Add validation + rules={{ + required: t('utils.address.required'), + validate: validateAccountAddress, + }} /> + + {props.form.formState.errors.receiver?.message} +
)}
diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts index be0bfb440..5d20cbd6b 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/common/types.ts @@ -9,7 +9,7 @@ export type RequiredFormFieldProps = { /** * Sets valid state of the field. This has no effect if an error is also set. */ - valid?: boolean; + valid?: boolean; // TODO: in practice this is a number, either 0 or 1??? }; export type RequiredControlledFieldProps = RequiredFormFieldProps & Omit, 'ref' | 'onBlur'> & { diff --git a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts index cf5a5fae0..2937b9221 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts @@ -6,6 +6,33 @@ const t = { medium: 'Medium', strong: 'Strong', }, + tokenAmount: { + token: { + label: 'Token', + available: '{{balance}} {{name}} available', + }, + amount: { + label: 'Amount', + fee: 'Estimated transaction fee: {{fee}}', + }, + address: { + label: 'Receiver address', + }, + }, + }, + utils: { + address: { + required: 'Please enter an address', + invalid: 'Invalid address', + }, + amount: { + required: 'Please enter an amount', + invalid: 'Invalid amount', + insufficient: 'Insufficient funds', + zero: 'Amount may not be zero', + belowBakerThreshold: 'Minimum stake: {{ threshold }}', + exceedingDelegationCap: "Amount may not exceed the target pool's cap of {{ max }}.", + }, }, }; diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts index b2043d4a2..7acddba19 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts @@ -11,7 +11,10 @@ export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = de const padded = amount.toString().padStart(decimals + 1, '0'); // Ensure the string length is minimum decimals + 1 characters. For CCD, this would mean minimum 7 characters long const integer = padded.slice(0, -decimals); const fraction = padded.slice(-decimals); - const balanceFormatter = new Intl.NumberFormat(undefined, { minimumFractionDigits: minDecimals }); + const balanceFormatter = new Intl.NumberFormat(undefined, { + minimumFractionDigits: minDecimals, + maximumFractionDigits: decimals, + }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore format below supports strings, TypeScript is just not aware. return balanceFormatter.format(`${integer}.${fraction}`); diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/transaction-helpers.ts b/packages/browser-wallet/src/popup/popupX/shared/utils/transaction-helpers.ts new file mode 100644 index 000000000..fbb2662dd --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/transaction-helpers.ts @@ -0,0 +1,119 @@ +import { + AccountAddress, + AccountInfo, + AccountInfoType, + BakerPoolStatusDetails, + ChainParameters, + ChainParametersV0, +} from '@concordium/web-sdk'; +import i18n from '@popup/shell/i18n'; +import { + ccdToMicroCcd, + displayAsCcd, + fractionalToInteger, + getPublicAccountAmounts, + isValidCcdString, + isValidResolutionString, +} from 'wallet-common-helpers'; + +/** + * Validates if the chosen transfer amount can be sent with the current balance at disposal. + * @param decimals how many decimals can the transfer amount. This is used to convert it from a fractional string to an integer. + * @param estimatedFee additional costs for the transfer. + */ +export function validateTransferAmount( + transferAmount: string, + atDisposal: bigint | undefined, + decimals = 0, + estimatedFee = 0n +): string | undefined { + if (!isValidResolutionString(10n ** BigInt(decimals), false, false, false)(transferAmount)) { + return i18n.t('x:sharedX.utils.amount.invalid'); + } + const amountToValidateInteger = fractionalToInteger(transferAmount, decimals); + if (atDisposal !== undefined && atDisposal < amountToValidateInteger + estimatedFee) { + return i18n.t('x:sharedX.utils.amount.insufficient'); + } + if (amountToValidateInteger === 0n) { + return i18n.t('x:sharedX.utils.amount.zero'); + } + return undefined; +} + +export function validateBakerStake( + amountToValidate: string, + chainParameters?: Exclude, + accountInfo?: AccountInfo, + estimatedFee = 0n +): string | undefined { + if (!isValidCcdString(amountToValidate)) { + return i18n.t('x:sharedX.utils.amount.invalid'); + } + const bakerStakeThreshold = chainParameters?.minimumEquityCapital.microCcdAmount || 0n; + const amount = ccdToMicroCcd(amountToValidate); + + const amountChanged = + accountInfo?.type !== AccountInfoType.Baker || amount !== accountInfo.accountBaker.stakedAmount.microCcdAmount; + + if (amountChanged && bakerStakeThreshold > amount) { + return i18n.t('x:sharedX.utils.amount.belowBakerThreshold', { + threshold: displayAsCcd(bakerStakeThreshold, false), + }); + } + + if ( + accountInfo && + (BigInt(accountInfo.accountAmount.microCcdAmount) < amount + estimatedFee || + // the fee must be paid with the current funds at disposal, because a reduction in delegation amount is not immediate. + getPublicAccountAmounts(accountInfo).atDisposal < estimatedFee) + ) { + return i18n.t('x:sharedX.utils.amount.insufficient'); + } + + return undefined; +} + +export function validateAccountAddress(cand: string): string | undefined { + try { + // eslint-disable-next-line no-new + AccountAddress.fromBase58(cand); + return undefined; + } catch { + return i18n.t('x:sharedX.utils.address.invalid'); + } +} + +export function validateDelegationAmount( + delegatedAmount: string, + accountInfo: AccountInfo, + estimatedFee: bigint, + targetStatus?: BakerPoolStatusDetails +): string | undefined { + if (!isValidCcdString(delegatedAmount)) { + return i18n.t('x:sharedX.utils.amount.invalid'); + } + + const amount = ccdToMicroCcd(delegatedAmount); + + if (amount === 0n) { + return i18n.t('x:sharedX.utils.amount.zero'); + } + + const max = + targetStatus && targetStatus.delegatedCapitalCap && targetStatus.delegatedCapital + ? targetStatus.delegatedCapitalCap.microCcdAmount - targetStatus.delegatedCapital.microCcdAmount + : undefined; + if (max !== undefined && amount > max) { + return i18n.t('x:sharedX.utils.amount.exceedingDelegationCap', { max: displayAsCcd(max) }); + } + + if ( + BigInt(accountInfo.accountAmount.microCcdAmount) < amount + estimatedFee || + // the fee must be paid with the current funds at disposal, because a reduction in delegation amount is not immediate. + getPublicAccountAmounts(accountInfo).atDisposal < estimatedFee + ) { + return i18n.t('x:sharedX.utils.amount.insufficient'); + } + + return undefined; +} From 12e767952b5aa895fe93527d92015759fbc0e905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Fri, 18 Oct 2024 16:07:20 +0200 Subject: [PATCH 09/21] JSDoc --- .../shared/Form/TokenAmount/TokenAmount.tsx | 64 +++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 812fac810..70fe00eea 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -45,15 +45,15 @@ const FormInputClear = makeUncontrolled(InputClear); type TokenVariants = | { - /** The token type */ + /** The token type. If undefined, a token picker is rendered */ token?: undefined; } | { - /** The token type */ + /** The token type. If undefined, a token picker is rendered */ token: 'ccd'; } | { - /** The token type */ + /** The token type. If undefined, a token picker is rendered */ token: 'cis2'; /** The token address */ address: { @@ -64,8 +64,23 @@ type TokenVariants = }; }; -export type AmountForm = { amount: string }; -export type AmountReceiveForm = AmountForm & { receiver: string }; +/** + * @description + * Represents a form with an amount field. + */ +export type AmountForm = { + /** The amount to be transferred */ + amount: string; +}; + +/** + * @description + * Represents a form with an amount field and a receiver field. + */ +export type AmountReceiveForm = AmountForm & { + /** The receiver of the amount */ + receiver: string; +}; type ValueVariants = | { @@ -91,6 +106,45 @@ type Props = { const removeThousandSeparators = (value: string) => value.replace(/[,]/g, ''); +// TODO: Token picker... +// 1. Token picker +// 2. Get values from store + +/** + * TokenAmount component renders a form for transferring tokens with an amount field and optionally a receiver field. + * + * @example + * // Usage with token picker + receiver + * const formMethods = useForm(); + * + * + * @example + * // Usage with CCD token + * const formMethods = useForm(); + * + * + * @example + * // Usage with CIS2 token + receiver + * const formMethods = useForm(); + * + */ export default function TokenAmount(props: Props) { const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); const { buttonMaxLabel, fee } = props; From 40fefcac5a450c38e2da3695c9a85093581f72c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Mon, 21 Oct 2024 07:42:35 +0200 Subject: [PATCH 10/21] Add function for parsing the formatted token amounts --- .../shared/Form/TokenAmount/TokenAmount.tsx | 8 ++++---- .../src/popup/popupX/shared/utils/helpers.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 70fe00eea..711ac55b5 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -12,7 +12,7 @@ import { displayAsCcd, fractionalToInteger } from 'wallet-common-helpers'; import { RequiredUncontrolledFieldProps } from '../common/types'; import { makeUncontrolled } from '../common/utils'; import Button from '../../Button'; -import { formatTokenAmount } from '../../utils/helpers'; +import { formatTokenAmount, parseTokenAmount } from '../../utils/helpers'; import { validateAccountAddress, validateTransferAmount } from '../../utils/transaction-helpers'; import ErrorMessage from '../ErrorMessage'; @@ -174,11 +174,11 @@ export default function TokenAmount(props: Props) { [selectedToken] ); const parseAmount = useCallback( - (amountValue: string) => fractionalToInteger(amountValue.replace(/[,]/g, ''), selectedToken.decimals), + (amountValue: string) => parseTokenAmount(amountValue, selectedToken.decimals), [selectedToken] ); - const availableAmount = useMemo( + const availableAmount: bigint = useMemo( () => (selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance), [selectedToken, fee, balance] ); @@ -212,7 +212,7 @@ export default function TokenAmount(props: Props) { ); const validateAmount: Validate = useCallback( - (value: string) => + (value) => validateTransferAmount( removeThousandSeparators(value), availableAmount, diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts index 7acddba19..8cb823ea6 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts @@ -6,16 +6,28 @@ export async function copyToClipboard(text: string): Promise { } } -/** Display a token amount with a number of decimals. Localized. */ +/** Display a token amount with a number of decimals. */ export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = decimals) { const padded = amount.toString().padStart(decimals + 1, '0'); // Ensure the string length is minimum decimals + 1 characters. For CCD, this would mean minimum 7 characters long const integer = padded.slice(0, -decimals); const fraction = padded.slice(-decimals); - const balanceFormatter = new Intl.NumberFormat(undefined, { + const balanceFormatter = new Intl.NumberFormat('en-US', { minimumFractionDigits: minDecimals, maximumFractionDigits: decimals, + useGrouping: true, }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore format below supports strings, TypeScript is just not aware. return balanceFormatter.format(`${integer}.${fraction}`); } + +/** An inverse of {@linkcode formatTokenAmount}, parsing formatted balances */ +export function parseTokenAmount(amount: string, decimals = 0): bigint { + // Remove grouping separators (e.g., commas) + const sanitizedAmount = amount.replace(/,/g, ''); + const parts = sanitizedAmount.split('.'); + const integerPart = parts[0] || '0'; + const fractionPart = parts[1] ? parts[1].padEnd(decimals, '0') : ''.padEnd(decimals, '0'); + const combined = integerPart + fractionPart; + return BigInt(combined); +} From a758001e938e1fcf22cbc5c6b9a1103e440ecb35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Mon, 21 Oct 2024 09:13:01 +0200 Subject: [PATCH 11/21] Take in props for account and tokens --- .../Form/TokenAmount/TokenAmount.stories.tsx | 43 +++++++-- .../shared/Form/TokenAmount/TokenAmount.tsx | 94 +++++++++++++------ .../src/popup/popupX/shared/Img.tsx | 55 +++++++++++ .../src/popup/popupX/shared/utils/helpers.ts | 6 +- .../src/shared/utils/token-helpers.ts | 47 +++++++++- 5 files changed, 199 insertions(+), 46 deletions(-) create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Img.tsx diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index 508c3e4a4..25be05c36 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -2,13 +2,13 @@ /* eslint-disable prefer-destructuring */ import React from 'react'; import { Meta, StoryObj } from '@storybook/react'; -import { CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { AccountAddress, AccountInfo, AccountInfoType, CcdAmount, ContractAddress } from '@concordium/web-sdk'; import { PropsOf } from 'wallet-common-helpers'; -import TokenAmount, { AmountReceiveForm } from './TokenAmount'; +import TokenAmountView, { AmountReceiveForm } from './TokenAmount'; import Form, { useForm } from '..'; -function Wrapper(props: PropsOf) { +function Wrapper(props: PropsOf) { const form = useForm({ mode: 'onTouched', defaultValues: { @@ -19,14 +19,14 @@ function Wrapper(props: PropsOf) { return (
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {() => } + {() => } ); } export default { title: 'X/Shared/Form/TokenAmount', - component: TokenAmount, + component: TokenAmountView, render: (props) => , beforeEach: () => { const body = document.getElementsByTagName('body').item(0); @@ -36,16 +36,35 @@ export default { body?.classList.remove('popup-x'); }; }, -} as Meta; +} as Meta; -type Story = StoryObj; +type Story = StoryObj; +const accountInfo = { + type: AccountInfoType.Simple, + accountAddress: AccountAddress.fromBase58('3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB'), + accountAvailableBalance: CcdAmount.fromCcd(174000), +} as unknown as AccountInfo; +const tokens = [ + { + id: '', + contract: ContractAddress.create(123, 0), + metadata: { symbol: 'wETH', name: 'Wrapped Ether', decimals: 18 }, + }, + { + id: '', + contract: ContractAddress.create(432, 0), + metadata: { symbol: 'wCCD', name: 'Wrapped CCD', decimals: 6 }, + }, +]; export const OnlyAmount: Story = { args: { - token: 'ccd', + tokenType: 'ccd', fee: CcdAmount.fromCcd(0.032), buttonMaxLabel: 'Stake max.', receiver: false, + accountInfo, + tokens, }, }; @@ -54,15 +73,19 @@ export const WithReceiver: Story = { buttonMaxLabel: 'Send max.', fee: CcdAmount.fromCcd(0.032), receiver: true, + accountInfo, + tokens, }, }; export const TokenWithReceiver: Story = { args: { - token: 'cis2', - address: { id: '', contract: ContractAddress.create(123, 0) }, + tokenType: 'cis2', + tokenAddress: { id: '', contract: ContractAddress.create(123, 0) }, buttonMaxLabel: 'Send max.', fee: CcdAmount.fromCcd(0.132), receiver: true, + accountInfo, + tokens, }, }; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 711ac55b5..6e11610f3 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -4,7 +4,7 @@ import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useMemo import { UseFormReturn, Validate } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; -import { CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; +import { AccountAddress, AccountInfo, CIS2, CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; import { TokenMetadata } from '@shared/storage/types'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; @@ -12,9 +12,12 @@ import { displayAsCcd, fractionalToInteger } from 'wallet-common-helpers'; import { RequiredUncontrolledFieldProps } from '../common/types'; import { makeUncontrolled } from '../common/utils'; import Button from '../../Button'; -import { formatTokenAmount, parseTokenAmount } from '../../utils/helpers'; +import { formatTokenAmount, parseTokenAmount, removeNumberGrouping } from '../../utils/helpers'; import { validateAccountAddress, validateTransferAmount } from '../../utils/transaction-helpers'; import ErrorMessage from '../ErrorMessage'; +import { ensureDefined } from '@shared/utils/basic-helpers'; +import { getMetadataUrlChecked } from '@shared/utils/token-helpers'; +import Img, { DEFAULT_FAILED } from '../../Img'; type AmountInputProps = Pick< InputHTMLAttributes, @@ -46,22 +49,13 @@ const FormInputClear = makeUncontrolled(InputClear); type TokenVariants = | { /** The token type. If undefined, a token picker is rendered */ - token?: undefined; + tokenType?: 'ccd'; } | { /** The token type. If undefined, a token picker is rendered */ - token: 'ccd'; - } - | { - /** The token type. If undefined, a token picker is rendered */ - token: 'cis2'; + tokenType: 'cis2'; /** The token address */ - address: { - /** The token ID within the contract */ - id: HexString; - /** The token contract address */ - contract: ContractAddress.Type; - }; + tokenAddress: CIS2.TokenAddress; }; /** @@ -96,66 +90,108 @@ type ValueVariants = form: UseFormReturn; }; +type TokenInfo = CIS2.TokenAddress & { + /** The token metadata corresponding to the {@linkcode CIS2.TokenAddress} */ + metadata: TokenMetadata; +}; + type Props = { /** The label used for the button setting the amount to the maximum possible */ buttonMaxLabel: string; /** The fee associated with the transaction */ fee: CcdAmount.Type; + /** The account information for the account the token amount is taken from */ + accountInfo: AccountInfo; + /** The set of tokens available for the account specified by `accountInfo` */ + tokens: TokenInfo[]; } & ValueVariants & TokenVariants; -const removeThousandSeparators = (value: string) => value.replace(/[,]/g, ''); +const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; // TODO: Token picker... -// 1. Token picker -// 2. Get values from store +// [ ] Token picker +// [ ] Get values from store /** * TokenAmount component renders a form for transferring tokens with an amount field and optionally a receiver field. * * @example - * // Usage with token picker + receiver * const formMethods = useForm(); + * const accountInfo = { + * accountAddress: '4J1p...8K1p', + * accountNonce: 1, + * accountAmount: 1000000000n, + * accountEncryptedAmount: { startIndex: 0, incomingAmounts: [] }, + * accountReleaseSchedule: [], + * accountDelegation: null, + * accountBaker: null, + * }; + * const tokens = [{ + * id: '0x123', + * contract: { index: 1, subindex: 0 }, + * metadata: { symbol: 'wETH', name: 'Wrapped Ether', decimals: 18 }, + * }]; + * + * // Usage with token picker + receiver * * - * @example * // Usage with CCD token * const formMethods = useForm(); * * - * @example * // Usage with CIS2 token + receiver * const formMethods = useForm(); * */ -export default function TokenAmount(props: Props) { +export default function TokenAmountView(props: Props) { const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); - const { buttonMaxLabel, fee } = props; - const balance = 17800021000n; // FIXME: get actual value + const { buttonMaxLabel, fee, accountInfo, tokens } = props; + const balance = accountInfo.accountAvailableBalance.microCcdAmount; const selectedToken: { name: string; icon: ReactNode; decimals: number; type: 'ccd' | 'cis2' } = useMemo(() => { - switch (props.token) { + switch (props.tokenType) { case 'cis2': { - const { symbol, name, decimals = 0 }: TokenMetadata = { symbol: 'wETH', decimals: 18 }; // FIXME: hook up to actual metadata - const safeName = symbol ?? name ?? `${props.address.id}@${props.address.contract.toString()}`; - const icon = ; // FIXME: get token icon + const { + symbol, + name, + decimals = 0, + display, + thumbnail = display, + }: TokenMetadata = ensureDefined( + tokens.find( + (tk) => + tk.id === props.tokenAddress.id && + ContractAddress.equals(tk.contract, props.tokenAddress.contract) + )?.metadata, + 'Expected the token specified to be available in the set of tokens given' + ); + const safeName = symbol ?? name ?? `${props.tokenAddress.id}@${props.tokenAddress.contract.toString()}`; + const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; + const icon = {name}; return { name: safeName, icon, decimals, type: 'cis2' }; } case 'ccd': @@ -214,7 +250,7 @@ export default function TokenAmount(props: Props) { const validateAmount: Validate = useCallback( (value) => validateTransferAmount( - removeThousandSeparators(value), + removeNumberGrouping(value), availableAmount, selectedToken.decimals, selectedToken.type === 'ccd' ? fee.microCcdAmount : 0n @@ -229,7 +265,7 @@ export default function TokenAmount(props: Props) {
{selectedToken.icon}
{selectedToken.name} - {props.token === undefined && } + {props.tokenType === undefined && } {t('form.tokenAmount.token.available', { balance: formatAmount(balance), diff --git a/packages/browser-wallet/src/popup/popupX/shared/Img.tsx b/packages/browser-wallet/src/popup/popupX/shared/Img.tsx new file mode 100644 index 000000000..879617917 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Img.tsx @@ -0,0 +1,55 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; +import { ClassName } from 'wallet-common-helpers'; + +const DEFAULT_LOADING = '/assets/svg/loading_icon.svg'; +export const DEFAULT_FAILED = '/assets/svg/no_icon.svg'; + +type BaseProps = ClassName & { + src?: string; + alt?: string; +}; + +type WithDefaultsProps = BaseProps & { + withDefaults: true; +}; + +type NoDefaultsProps = BaseProps & { + withDefaults?: false; + loadingImage?: string; + failedImage?: string; +}; + +type Props = WithDefaultsProps | NoDefaultsProps; + +export default function Img({ src, alt, className, ...props }: Props) { + const { loadingImage, failedImage } = props.withDefaults + ? { loadingImage: DEFAULT_LOADING, failedImage: DEFAULT_FAILED } + : props; + + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(!src); + + const shouldHide = (!loaded && loadingImage) || (failed && failedImage); + + const handleError = () => { + setLoaded(true); + setFailed(true); + }; + + return ( + <> + {alt} { + setLoaded(true); + setFailed(false); + }} + onError={handleError} + /> + {shouldHide && {alt}} + + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts index 8cb823ea6..199a8f758 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/helpers.ts @@ -6,7 +6,9 @@ export async function copyToClipboard(text: string): Promise { } } -/** Display a token amount with a number of decimals. */ +export const removeNumberGrouping = (amount: string) => amount.replace(/,/g, ''); + +/** Display a token amount with a number of decimals + number groupings (thousand separators) */ export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = decimals) { const padded = amount.toString().padStart(decimals + 1, '0'); // Ensure the string length is minimum decimals + 1 characters. For CCD, this would mean minimum 7 characters long const integer = padded.slice(0, -decimals); @@ -24,7 +26,7 @@ export function formatTokenAmount(amount: bigint, decimals = 0, minDecimals = de /** An inverse of {@linkcode formatTokenAmount}, parsing formatted balances */ export function parseTokenAmount(amount: string, decimals = 0): bigint { // Remove grouping separators (e.g., commas) - const sanitizedAmount = amount.replace(/,/g, ''); + const sanitizedAmount = removeNumberGrouping(amount); const parts = sanitizedAmount.split('.'); const integerPart = parts[0] || '0'; const fractionPart = parts[1] ? parts[1].padEnd(decimals, '0') : ''.padEnd(decimals, '0'); diff --git a/packages/browser-wallet/src/shared/utils/token-helpers.ts b/packages/browser-wallet/src/shared/utils/token-helpers.ts index a7ed453a3..aeb6f17a6 100644 --- a/packages/browser-wallet/src/shared/utils/token-helpers.ts +++ b/packages/browser-wallet/src/shared/utils/token-helpers.ts @@ -107,21 +107,58 @@ function confirmString(field?: any) { export const getMetadataUnique = ({ unique }: TokenMetadata) => Boolean(unique); export const getMetadataDecimals = ({ decimals }: TokenMetadata) => Number(decimals ?? 0); +export enum TokenMetadataErrorType { + FetchError = 'FetchError', + IncorrectChecksum = 'IncorrectChecksum', +} + +export class TokenMetadataError extends Error { + constructor(public type: TokenMetadataErrorType, message?: string) { + super(message); + this.name = 'TokenMetadataError'; + } +} + /** - * Fetches token metadata from the given url + * Fetches and verifies the metadata URL. + * + * @param {MetadataUrl} url - The metadata URL and optional checksum hash. + * @returns {Promise} - The fetched data. + * @throws {TokenMetadataError} - If the fetch fails, the checksum is incorrect. */ -export async function getTokenMetadata({ url, hash: checksumHash }: MetadataUrl): Promise { +export async function getMetadataUrlChecked({ url, hash: checksumHash }: MetadataUrl): Promise { const resp = await fetch(url, { headers: new Headers({ 'Access-Control-Allow-Origin': '*' }), mode: 'cors' }); if (!resp.ok) { - throw new Error(i18n.t('addTokens.metadata.fetchError', { status: resp.status })); + throw new TokenMetadataError(TokenMetadataErrorType.FetchError, resp.status.toString()); } const body = Buffer.from(await resp.arrayBuffer()); if (checksumHash && sha256([body]).toString('hex') !== checksumHash) { - throw new Error(i18n.t('addTokens.metadata.incorrectChecksum')); + throw new TokenMetadataError(TokenMetadataErrorType.IncorrectChecksum); + } + return body; +} + +/** + * Fetches token metadata from the given url + */ +export async function getTokenMetadata(url: MetadataUrl): Promise { + let body: Buffer; + try { + body = await getMetadataUrlChecked(url); + } catch (e) { + const err = e as TokenMetadataError; + switch (err.type) { + case TokenMetadataErrorType.FetchError: + throw new Error(i18n.t('addTokens.metadata.fetchError', { status: err.message })); + case TokenMetadataErrorType.IncorrectChecksum: + throw new Error(i18n.t('addTokens.metadata.incorrectChecksum')); + default: + throw err; + } } - let metadata; + let metadata: TokenMetadata; try { metadata = JSON.parse(body.toString()); } catch (e) { From d026fa1e33ad99d30e63159d2df0eea4a24dff96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Mon, 21 Oct 2024 10:53:29 +0200 Subject: [PATCH 12/21] Hook for extracting token info for `TokenAmount` --- .../Form/TokenAmount/TokenAmount.stories.tsx | 13 ++++--- .../shared/Form/TokenAmount/TokenAmount.tsx | 31 ++++++--------- .../popupX/shared/Form/TokenAmount/util.ts | 38 +++++++++++++++++++ 3 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/util.ts diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index 25be05c36..0fe73eb3b 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -5,10 +5,10 @@ import { Meta, StoryObj } from '@storybook/react'; import { AccountAddress, AccountInfo, AccountInfoType, CcdAmount, ContractAddress } from '@concordium/web-sdk'; import { PropsOf } from 'wallet-common-helpers'; -import TokenAmountView, { AmountReceiveForm } from './TokenAmount'; +import TokenAmount, { AmountReceiveForm } from './TokenAmount'; import Form, { useForm } from '..'; -function Wrapper(props: PropsOf) { +function Wrapper(props: PropsOf) { const form = useForm({ mode: 'onTouched', defaultValues: { @@ -19,14 +19,14 @@ function Wrapper(props: PropsOf) { return (
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {() => } + {() => } ); } export default { title: 'X/Shared/Form/TokenAmount', - component: TokenAmountView, + component: TokenAmount, render: (props) => , beforeEach: () => { const body = document.getElementsByTagName('body').item(0); @@ -36,14 +36,15 @@ export default { body?.classList.remove('popup-x'); }; }, -} as Meta; +} as Meta; -type Story = StoryObj; +type Story = StoryObj; const accountInfo = { type: AccountInfoType.Simple, accountAddress: AccountAddress.fromBase58('3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB'), accountAvailableBalance: CcdAmount.fromCcd(174000), } as unknown as AccountInfo; + const tokens = [ { id: '', diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 6e11610f3..507e7ec1e 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -18,6 +18,7 @@ import ErrorMessage from '../ErrorMessage'; import { ensureDefined } from '@shared/utils/basic-helpers'; import { getMetadataUrlChecked } from '@shared/utils/token-helpers'; import Img, { DEFAULT_FAILED } from '../../Img'; +import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; type AmountInputProps = Pick< InputHTMLAttributes, @@ -90,11 +91,6 @@ type ValueVariants = form: UseFormReturn; }; -type TokenInfo = CIS2.TokenAddress & { - /** The token metadata corresponding to the {@linkcode CIS2.TokenAddress} */ - metadata: TokenMetadata; -}; - type Props = { /** The label used for the button setting the amount to the maximum possible */ buttonMaxLabel: string; @@ -119,24 +115,19 @@ const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; * @example * const formMethods = useForm(); * const accountInfo = { - * accountAddress: '4J1p...8K1p', - * accountNonce: 1, - * accountAmount: 1000000000n, - * accountEncryptedAmount: { startIndex: 0, incomingAmounts: [] }, - * accountReleaseSchedule: [], - * accountDelegation: null, - * accountBaker: null, + accountAvailableBalance: CcdAmount.fromCcd(1000), + ... * }; * const tokens = [{ - * id: '0x123', - * contract: { index: 1, subindex: 0 }, + * id: '', + * contract: ContractAddress.create(1), * metadata: { symbol: 'wETH', name: 'Wrapped Ether', decimals: 18 }, * }]; * * // Usage with token picker + receiver * (); * (); * */ -export default function TokenAmountView(props: Props) { +export default function TokenAmount(props: Props) { const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); const { buttonMaxLabel, fee, accountInfo, tokens } = props; const balance = accountInfo.accountAvailableBalance.microCcdAmount; @@ -191,7 +182,7 @@ export default function TokenAmountView(props: Props) { ); const safeName = symbol ?? name ?? `${props.tokenAddress.id}@${props.tokenAddress.contract.toString()}`; const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; - const icon = {name}; + const icon = {name}; return { name: safeName, icon, decimals, type: 'cis2' }; } case 'ccd': diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/util.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/util.ts new file mode 100644 index 000000000..27496c64b --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/util.ts @@ -0,0 +1,38 @@ +import { AccountAddress, CIS2, ContractAddress } from '@concordium/web-sdk'; +import { accountTokensFamily } from '@popup/store/token'; +import { TokenMetadata } from '@shared/storage/types'; +import { useAtomValue } from 'jotai'; + +/** Token info in the format expected by the `TokenAmount` component */ +export type TokenInfo = CIS2.TokenAddress & { + /** The token metadata corresponding to the {@linkcode CIS2.TokenAddress} */ + metadata: TokenMetadata; +}; + +type TokenInfoResponse = { loading: true } | { loading: false; value: TokenInfo[] }; + +/** + * Custom hook to fetch token information for a given account. This matches the format expected by the `TokenAmount` component + * + * @param {AccountAddress.Type} account - The account address for which to fetch token information. + * @returns {TokenInfoResponse} - An object containing a loading state and an array of token information. + */ +export function useTokenInfo(account: AccountAddress.Type): TokenInfoResponse { + const { value, loading } = useAtomValue(accountTokensFamily(account.address)); + + if (loading === true) { + return { loading: true }; + } + + const mapped = Object.entries(value).flatMap(([index, tokens]) => + tokens.map( + (t): TokenInfo => ({ + contract: ContractAddress.create(BigInt(index)), + id: t.id, + metadata: t.metadata, + }) + ) + ); + + return { loading: false, value: mapped }; +} From c8b495384f7732d5a79775c68392c6308e4ed613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Mon, 21 Oct 2024 11:53:35 +0200 Subject: [PATCH 13/21] Token images with fallback --- packages/browser-wallet/.storybook/main.js | 17 ++++++++++++++++- .../shared/Form/TokenAmount/TokenAmount.scss | 2 +- .../Form/TokenAmount/TokenAmount.stories.tsx | 7 ++++++- .../shared/Form/TokenAmount/TokenAmount.tsx | 12 ++++++------ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/browser-wallet/.storybook/main.js b/packages/browser-wallet/.storybook/main.js index 4899e99f4..26b73cd75 100644 --- a/packages/browser-wallet/.storybook/main.js +++ b/packages/browser-wallet/.storybook/main.js @@ -7,6 +7,7 @@ const pathToSvgAssets = path.resolve(__dirname, '../src/assets/svg'); module.exports = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + staticDirs: [{ from: '../src/assets', to: '/assets' }], addons: [ getAbsolutePath('@storybook/addon-links'), @@ -35,7 +36,21 @@ module.exports = { rules.push({ test: /\.svg$/, include: pathToSvgAssets, - use: ['@svgr/webpack'], + use: [ + { + loader: '@svgr/webpack', + options: { + svgoConfig: { + plugins: [ + { + name: 'removeViewBox', + active: false, + }, + ], + }, + }, + }, + ], }); config.resolve.plugins = [new TsconfigPathsPlugin()]; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 88b49f43c..2464171e0 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -49,7 +49,7 @@ border-radius: rem(6px); background: $color-grey-1; - svg { + svg, img { width: rem(14px); height: rem(14px); } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index 0fe73eb3b..8306a0bc0 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -49,7 +49,12 @@ const tokens = [ { id: '', contract: ContractAddress.create(123, 0), - metadata: { symbol: 'wETH', name: 'Wrapped Ether', decimals: 18 }, + metadata: { + symbol: 'wETH', + name: 'Wrapped Ether', + decimals: 18, + thumbnail: { url: 'https://s2.coinmarketcap.com/static/img/coins/64x64/2396.png' }, + }, }, { id: '', diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 507e7ec1e..72b9f2c0c 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -4,21 +4,20 @@ import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useMemo import { UseFormReturn, Validate } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; -import { AccountAddress, AccountInfo, CIS2, CcdAmount, ContractAddress, HexString } from '@concordium/web-sdk'; +import { AccountInfo, CIS2, CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { ensureDefined } from '@shared/utils/basic-helpers'; import { TokenMetadata } from '@shared/storage/types'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; -import { displayAsCcd, fractionalToInteger } from 'wallet-common-helpers'; +import { displayAsCcd } from 'wallet-common-helpers'; import { RequiredUncontrolledFieldProps } from '../common/types'; import { makeUncontrolled } from '../common/utils'; import Button from '../../Button'; import { formatTokenAmount, parseTokenAmount, removeNumberGrouping } from '../../utils/helpers'; import { validateAccountAddress, validateTransferAmount } from '../../utils/transaction-helpers'; import ErrorMessage from '../ErrorMessage'; -import { ensureDefined } from '@shared/utils/basic-helpers'; -import { getMetadataUrlChecked } from '@shared/utils/token-helpers'; import Img, { DEFAULT_FAILED } from '../../Img'; -import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { TokenInfo } from './util'; type AmountInputProps = Pick< InputHTMLAttributes, @@ -106,8 +105,9 @@ type Props = { const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; // TODO: Token picker... +// [x] Get values from store +// [x] Token images // [ ] Token picker -// [ ] Get values from store /** * TokenAmount component renders a form for transferring tokens with an amount field and optionally a receiver field. From 78e25c158c7838bb0890208ffc0782d3b462a915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Mon, 21 Oct 2024 14:15:04 +0200 Subject: [PATCH 14/21] Create connected version of token amount component --- .../Form/TokenAmount/TokenAmount.stories.tsx | 28 +- .../shared/Form/TokenAmount/TokenAmount.tsx | 303 +++--------------- .../popupX/shared/Form/TokenAmount/View.tsx | 299 +++++++++++++++++ 3 files changed, 353 insertions(+), 277 deletions(-) create mode 100644 packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx index 8306a0bc0..ca72059ef 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -2,13 +2,13 @@ /* eslint-disable prefer-destructuring */ import React from 'react'; import { Meta, StoryObj } from '@storybook/react'; -import { AccountAddress, AccountInfo, AccountInfoType, CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { CcdAmount, ContractAddress } from '@concordium/web-sdk'; import { PropsOf } from 'wallet-common-helpers'; -import TokenAmount, { AmountReceiveForm } from './TokenAmount'; +import TokenAmountView, { AmountReceiveForm } from './View'; import Form, { useForm } from '..'; -function Wrapper(props: PropsOf) { +function Wrapper(props: PropsOf) { const form = useForm({ mode: 'onTouched', defaultValues: { @@ -19,14 +19,14 @@ function Wrapper(props: PropsOf) { return (
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {() => } + {() => } ); } export default { title: 'X/Shared/Form/TokenAmount', - component: TokenAmount, + component: TokenAmountView, render: (props) => , beforeEach: () => { const body = document.getElementsByTagName('body').item(0); @@ -36,14 +36,9 @@ export default { body?.classList.remove('popup-x'); }; }, -} as Meta; +} as Meta; -type Story = StoryObj; -const accountInfo = { - type: AccountInfoType.Simple, - accountAddress: AccountAddress.fromBase58('3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB'), - accountAvailableBalance: CcdAmount.fromCcd(174000), -} as unknown as AccountInfo; +type Story = StoryObj; const tokens = [ { @@ -69,8 +64,9 @@ export const OnlyAmount: Story = { fee: CcdAmount.fromCcd(0.032), buttonMaxLabel: 'Stake max.', receiver: false, - accountInfo, tokens, + balance: 17004000000n, + onSelectToken: console.log, }, }; @@ -79,8 +75,9 @@ export const WithReceiver: Story = { buttonMaxLabel: 'Send max.', fee: CcdAmount.fromCcd(0.032), receiver: true, - accountInfo, tokens, + balance: 17004000000n, + onSelectToken: console.log, }, }; @@ -91,7 +88,8 @@ export const TokenWithReceiver: Story = { buttonMaxLabel: 'Send max.', fee: CcdAmount.fromCcd(0.132), receiver: true, - accountInfo, tokens, + balance: 17004000000n, + onSelectToken: console.log, }, }; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index 72b9f2c0c..e678ec9c8 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -1,136 +1,51 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable react/destructuring-assignment */ -import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useMemo } from 'react'; -import { UseFormReturn, Validate } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import clsx from 'clsx'; -import { AccountInfo, CIS2, CcdAmount, ContractAddress } from '@concordium/web-sdk'; -import { ensureDefined } from '@shared/utils/basic-helpers'; -import { TokenMetadata } from '@shared/storage/types'; -import SideArrow from '@assets/svgX/side-arrow.svg'; -import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; -import { displayAsCcd } from 'wallet-common-helpers'; -import { RequiredUncontrolledFieldProps } from '../common/types'; -import { makeUncontrolled } from '../common/utils'; -import Button from '../../Button'; -import { formatTokenAmount, parseTokenAmount, removeNumberGrouping } from '../../utils/helpers'; -import { validateAccountAddress, validateTransferAmount } from '../../utils/transaction-helpers'; -import ErrorMessage from '../ErrorMessage'; -import Img, { DEFAULT_FAILED } from '../../Img'; -import { TokenInfo } from './util'; +import React, { useState } from 'react'; +import { atomFamily, selectAtom, useAtomValue } from 'jotai/utils'; +import { AccountAddress, AccountInfo, ContractAddress, CIS2 } from '@concordium/web-sdk'; +import { atom } from 'jotai'; -type AmountInputProps = Pick< - InputHTMLAttributes, - 'className' | 'type' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' -> & - RequiredUncontrolledFieldProps; +import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; +import { contractBalancesFamily } from '@popup/store/token'; +import { ensureDefined } from '@shared/utils/basic-helpers'; +import TokenAmountView, { TokenAmountViewProps, TokenSelectEvent } from './View'; +import { useTokenInfo } from './util'; -/** - * @description - * Use as a normal \. Should NOT be used for checkbox or radio. - */ -const InputClear = forwardRef( - ({ error, className, type = 'text', ...props }, ref) => { - return ( - - ); +const tokenAddressEq = (a: CIS2.TokenAddress | null, b: CIS2.TokenAddress | null) => { + if (a !== null && b !== null) { + return a.id === b.id && ContractAddress.equals(a.contract, b.contract); } -); - -const FormInputClear = makeUncontrolled(InputClear); -type TokenVariants = - | { - /** The token type. If undefined, a token picker is rendered */ - tokenType?: 'ccd'; - } - | { - /** The token type. If undefined, a token picker is rendered */ - tokenType: 'cis2'; - /** The token address */ - tokenAddress: CIS2.TokenAddress; - }; - -/** - * @description - * Represents a form with an amount field. - */ -export type AmountForm = { - /** The amount to be transferred */ - amount: string; + return a === b; }; -/** - * @description - * Represents a form with an amount field and a receiver field. - */ -export type AmountReceiveForm = AmountForm & { - /** The receiver of the amount */ - receiver: string; -}; - -type ValueVariants = - | { - /** Whether it should be possible to specify a receiver. Defaults to false */ - receiver?: false; - /** The form type control for the inner fields */ - form: UseFormReturn; - } - | { - /** Whether it should be possible to specify a receiver. Defaults to false */ - receiver: true; - /** The form type control for the inner fields */ - form: UseFormReturn; - }; - -type Props = { - /** The label used for the button setting the amount to the maximum possible */ - buttonMaxLabel: string; - /** The fee associated with the transaction */ - fee: CcdAmount.Type; - /** The account information for the account the token amount is taken from */ - accountInfo: AccountInfo; - /** The set of tokens available for the account specified by `accountInfo` */ - tokens: TokenInfo[]; -} & ValueVariants & - TokenVariants; - -const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; +const balanceAtomFamily = atomFamily( + ([account, tokenAddress]: [AccountInfo, CIS2.TokenAddress | null]) => { + if (tokenAddress === null) { + return atom(account.accountAvailableBalance.microCcdAmount); + } + const tokens = contractBalancesFamily(account.accountAddress.address, tokenAddress.contract.index.toString()); + return selectAtom(tokens, (ts) => ts[tokenAddress.id]); + }, + ([aa, ta], [ab, tb]) => AccountAddress.equals(aa.accountAddress, ab.accountAddress) && tokenAddressEq(ta, tb) +); -// TODO: Token picker... -// [x] Get values from store -// [x] Token images -// [ ] Token picker +type Props = Omit; /** * TokenAmount component renders a form for transferring tokens with an amount field and optionally a receiver field. * * @example * const formMethods = useForm(); - * const accountInfo = { - accountAvailableBalance: CcdAmount.fromCcd(1000), - ... - * }; * const tokens = [{ * id: '', * contract: ContractAddress.create(1), * metadata: { symbol: 'wETH', name: 'Wrapped Ether', decimals: 18 }, * }]; * - * // Usage with token picker + receiver + * // Usage with token picker & receiver * * @@ -140,8 +55,6 @@ const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; * buttonMaxLabel="Max" * fee={CcdAmount.fromMicroCcd(1000n)} * form={formMethods} - * accountInfo={accountInfo} - * tokens={[]} * token="ccd" * /> * @@ -151,165 +64,31 @@ const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; * buttonMaxLabel="Max" * fee={CcdAmount.fromMicroCcd(1000n)} * form={formMethods} - * accountInfo={accountInfo} - * tokens={tokens} * receiver * token="cis2" * address={{ id: '', contract: ContractAddress.create(1) }} * /> */ export default function TokenAmount(props: Props) { - const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); - const { buttonMaxLabel, fee, accountInfo, tokens } = props; - const balance = accountInfo.accountAvailableBalance.microCcdAmount; - - const selectedToken: { name: string; icon: ReactNode; decimals: number; type: 'ccd' | 'cis2' } = useMemo(() => { - switch (props.tokenType) { - case 'cis2': { - const { - symbol, - name, - decimals = 0, - display, - thumbnail = display, - }: TokenMetadata = ensureDefined( - tokens.find( - (tk) => - tk.id === props.tokenAddress.id && - ContractAddress.equals(tk.contract, props.tokenAddress.contract) - )?.metadata, - 'Expected the token specified to be available in the set of tokens given' - ); - const safeName = symbol ?? name ?? `${props.tokenAddress.id}@${props.tokenAddress.contract.toString()}`; - const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; - const icon = {name}; - return { name: safeName, icon, decimals, type: 'cis2' }; - } - case 'ccd': - case undefined: { - const name = 'CCD'; - const icon = ; - return { name, icon, decimals: 6, type: 'ccd' }; - } - default: - throw new Error('Unreachable'); - } - }, [props]); + const accountInfo = ensureDefined(useSelectedAccountInfo(), 'Expected selected account to be available'); + const tokenInfo = useTokenInfo(accountInfo.accountAddress); + const [tokenAddress, setTokenAddress] = useState(null); + const tokenBalance = useAtomValue(balanceAtomFamily([accountInfo, tokenAddress])); - const formatAmount = useCallback( - (amountValue: bigint) => formatTokenAmount(BigInt(amountValue), selectedToken.decimals, 2), - [selectedToken] - ); - const parseAmount = useCallback( - (amountValue: string) => parseTokenAmount(amountValue, selectedToken.decimals), - [selectedToken] - ); - - const availableAmount: bigint = useMemo( - () => (selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance), - [selectedToken, fee, balance] - ); - - const setMax = useCallback(() => { - (props.form as UseFormReturn).setValue('amount', formatAmount(availableAmount) ?? '', { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }); - }, [availableAmount, props.form]); - - const handleAmountBlur: React.FocusEventHandler = useCallback( - (event) => { - const { value } = event.target; - - if (value === '') { - return; - } - - try { - const formatted = formatAmount(parseAmount(value)); - if (formatted !== value) { - (props.form as UseFormReturn).setValue('amount', formatted ?? ''); - } - } catch { - // Do nothing... - } - }, - [props.form, formatAmount, parseAmount] - ); + if (accountInfo === undefined || tokenInfo === undefined || tokenInfo.loading) { + return null; + } - const validateAmount: Validate = useCallback( - (value) => - validateTransferAmount( - removeNumberGrouping(value), - availableAmount, - selectedToken.decimals, - selectedToken.type === 'ccd' ? fee.microCcdAmount : 0n - ), - [availableAmount, selectedToken] - ); + const handleSelectToken = (e: TokenSelectEvent) => { + setTokenAddress(e); + }; return ( -
-
- {t('form.tokenAmount.token.label')} -
-
{selectedToken.icon}
- {selectedToken.name} - {props.tokenType === undefined && } - - {t('form.tokenAmount.token.available', { - balance: formatAmount(balance), - name: selectedToken.name, - })} - -
-
-
- Amount -
- ).register} - name="amount" - onBlur={handleAmountBlur} - rules={{ - required: t('utils.amount.required'), - min: { value: 0, message: t('utils.amount.zero') }, - validate: validateAmount, - }} - /> - setMax()}> - {buttonMaxLabel} - -
- - {props.form.formState.errors.amount?.message} - - - {t('form.tokenAmount.amount.fee', { fee: displayAsCcd(fee, false, true) })} - -
- {props.receiver === true && ( -
- {t('form.tokenAmount.address.label')} - {/* TODO: Figure out what to do with overflowing text? - 1. multiline - 2. show abbreviated version on blur */} - ).register} - name="receiver" - rules={{ - required: t('utils.address.required'), - validate: validateAccountAddress, - }} - /> - - {props.form.formState.errors.receiver?.message} - -
- )} -
+ ); } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx new file mode 100644 index 000000000..10b3baeee --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx @@ -0,0 +1,299 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/destructuring-assignment */ +import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useEffect, useMemo } from 'react'; +import { UseFormReturn, Validate } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { AccountInfo, CIS2, CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { ensureDefined } from '@shared/utils/basic-helpers'; +import SideArrow from '@assets/svgX/side-arrow.svg'; +import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; +import { displayAsCcd } from 'wallet-common-helpers'; +import { RequiredUncontrolledFieldProps } from '../common/types'; +import { makeUncontrolled } from '../common/utils'; +import Button from '../../Button'; +import { formatTokenAmount, parseTokenAmount, removeNumberGrouping } from '../../utils/helpers'; +import { validateAccountAddress, validateTransferAmount } from '../../utils/transaction-helpers'; +import ErrorMessage from '../ErrorMessage'; +import Img, { DEFAULT_FAILED } from '../../Img'; +import { TokenInfo } from './util'; + +type AmountInputProps = Pick< + InputHTMLAttributes, + 'className' | 'type' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' +> & + RequiredUncontrolledFieldProps; + +/** + * @description + * Use as a normal \. Should NOT be used for checkbox or radio. + */ +const InputClear = forwardRef( + ({ error, className, type = 'text', ...props }, ref) => { + return ( + + ); + } +); + +const FormInputClear = makeUncontrolled(InputClear); + +type TokenVariant = + | { + /** The token type. If undefined, a token picker is rendered */ + tokenType?: 'ccd'; + } + | { + /** The token type. If undefined, a token picker is rendered */ + tokenType: 'cis2'; + /** The token address */ + tokenAddress: CIS2.TokenAddress; + }; + +/** + * @description + * Represents a form with an amount field. + */ +export type AmountForm = { + /** The amount to be transferred */ + amount: string; +}; + +/** + * @description + * Represents a form with an amount field and a receiver field. + */ +export type AmountReceiveForm = AmountForm & { + /** The receiver of the amount */ + receiver: string; +}; + +type ValueVariant = + | { + /** Whether it should be possible to specify a receiver. Defaults to false */ + receiver?: false; + /** The form type control for the inner fields */ + form: UseFormReturn; + } + | { + /** Whether it should be possible to specify a receiver. Defaults to false */ + receiver: true; + /** The form type control for the inner fields */ + form: UseFormReturn; + }; + +export type TokenSelectEvent = null | CIS2.TokenAddress; + +export type TokenAmountViewProps = { + /** The label used for the button setting the amount to the maximum possible */ + buttonMaxLabel: string; + /** The fee associated with the transaction */ + fee: CcdAmount.Type; + /** The set of tokens available for the account specified by `accountInfo` */ + tokens: TokenInfo[]; + /** The token balance. `undefined` should be used to indicate that the balance is not yet available. */ + balance: bigint | undefined; + /** Callback invoked when the user selects a token. This is also invoked when the component renders initially */ + onSelectToken(event: TokenSelectEvent): void; +} & ValueVariant & + TokenVariant; + +const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; + +// TODO: Token picker... +// [x] Get values from store +// [x] Token images +// [ ] Token picker + +/** + * TokenAmount component renders a form for transferring tokens with an amount field and optionally a receiver field. + * + * Generally the version connected to the application store (`TokenAmount`) should be used instead of this. + */ +export default function TokenAmountView(props: TokenAmountViewProps) { + const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); + const { buttonMaxLabel, fee, tokens, balance, onSelectToken } = props; + + const selectedToken: { + name: string; + icon: ReactNode; + decimals: number; + type: 'ccd' | 'cis2'; + address: null | CIS2.TokenAddress; + } = useMemo(() => { + switch (props.tokenType) { + case 'cis2': { + const { + metadata: { symbol, name, decimals = 0, thumbnail }, + id, + contract, + } = ensureDefined( + tokens.find( + (tk) => + tk.id === props.tokenAddress.id && + ContractAddress.equals(tk.contract, props.tokenAddress.contract) + ), + 'Expected the token specified to be available in the set of tokens given' + ); + const safeName = symbol ?? name ?? `${props.tokenAddress.id}@${props.tokenAddress.contract.toString()}`; + const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; + const icon = {name}; + return { name: safeName, icon, decimals, type: 'cis2', address: { id, contract } }; + } + case 'ccd': + case undefined: { + const name = 'CCD'; + const icon = ; + return { name, icon, decimals: 6, type: 'ccd', address: null }; + } + default: + throw new Error('Unreachable'); + } + }, [props]); + + useEffect(() => { + if (selectedToken.type === 'cis2') { + const { id, contract } = ensureDefined( + tokens.find( + (tk) => + tk.id === selectedToken.address!.id && + ContractAddress.equals(tk.contract, selectedToken.address!.contract) + ), + 'Expected selected token to be in tokens list' + ); + onSelectToken({ id, contract }); + } else { + onSelectToken(null); + } + }, [selectedToken]); + + const formatAmount = useCallback( + (amountValue: bigint) => formatTokenAmount(BigInt(amountValue), selectedToken.decimals, 2), + [selectedToken] + ); + const parseAmount = useCallback( + (amountValue: string) => parseTokenAmount(amountValue, selectedToken.decimals), + [selectedToken] + ); + + const availableAmount: bigint | undefined = useMemo(() => { + if (balance === undefined) { + return undefined; + } + return selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance; + }, [selectedToken, fee, balance]); + + const setMax = useCallback(() => { + if (availableAmount === undefined) return; + + (props.form as UseFormReturn).setValue('amount', formatAmount(availableAmount) ?? '', { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + }, [availableAmount, props.form]); + + const handleAmountBlur: React.FocusEventHandler = useCallback( + (event) => { + const { value } = event.target; + + if (value === '') { + return; + } + + try { + const formatted = formatAmount(parseAmount(value)); + if (formatted !== value) { + (props.form as UseFormReturn).setValue('amount', formatted ?? ''); + } + } catch { + // Do nothing... + } + }, + [props.form, formatAmount, parseAmount] + ); + + const validateAmount: Validate = useCallback( + (value) => + validateTransferAmount( + removeNumberGrouping(value), + availableAmount, + selectedToken.decimals, + selectedToken.type === 'ccd' ? fee.microCcdAmount : 0n + ), + [availableAmount, selectedToken] + ); + + return ( +
+
+ {t('form.tokenAmount.token.label')} +
+
{selectedToken.icon}
+ {selectedToken.name} + {props.tokenType === undefined && } + {balance !== undefined && ( + + {t('form.tokenAmount.token.available', { + balance: formatAmount(balance), + name: selectedToken.name, + })} + + )} +
+
+
+ Amount +
+ ).register} + name="amount" + onBlur={handleAmountBlur} + rules={{ + required: t('utils.amount.required'), + min: { value: 0, message: t('utils.amount.zero') }, + validate: validateAmount, + }} + /> + setMax()}> + {buttonMaxLabel} + +
+ + {props.form.formState.errors.amount?.message} + + + {t('form.tokenAmount.amount.fee', { fee: displayAsCcd(fee, false, true) })} + +
+ {props.receiver === true && ( +
+ {t('form.tokenAmount.address.label')} + {/* TODO: Figure out what to do with overflowing text? + 1. multiline + 2. show abbreviated version on blur */} + ).register} + name="receiver" + rules={{ + required: t('utils.address.required'), + validate: validateAccountAddress, + }} + /> + + {props.form.formState.errors.receiver?.message} + +
+ )} +
+ ); +} From 44436cdbaf68f05095b615c4075aa3ead0a7af97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Mon, 21 Oct 2024 15:22:53 +0200 Subject: [PATCH 15/21] Enable selection --- .../shared/Form/TokenAmount/TokenAmount.scss | 29 ++- .../popupX/shared/Form/TokenAmount/View.tsx | 232 +++++++++++++----- 2 files changed, 192 insertions(+), 69 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 2464171e0..01ecd78ad 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -6,7 +6,8 @@ margin-top: rem(16px); padding: rem(20px) rem(16px); - .text__main_medium, .text__main { + .text__main_medium, + .text__main { color: $color-black; } @@ -29,7 +30,7 @@ } &_token { - .token-selector { + .token-selector-container { flex-wrap: wrap; display: flex; align-items: center; @@ -37,6 +38,12 @@ padding-bottom: rem(12px); border-bottom: 1px solid rgba($color-black, 0.1); + .token-selector { + display: flex; + cursor: pointer; + position: relative; + } + .text__additional_small { margin-left: auto; text-wrap: nowrap; @@ -49,11 +56,27 @@ border-radius: rem(6px); background: $color-grey-1; - svg, img { + svg, + img { width: rem(14px); height: rem(14px); } } + + select { + appearance: none; + background: none; + border: none; + position: absolute; + z-index: 0; + color: transparent; + width: 100%; + height: 100%; + + &::selection { + background-color: transparent; + } + } } } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx index 10b3baeee..0034924e9 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react/destructuring-assignment */ -import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useEffect, useMemo } from 'react'; +import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import { UseFormReturn, Validate } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; -import { AccountInfo, CIS2, CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { CIS2, CcdAmount, ContractAddress } from '@concordium/web-sdk'; import { ensureDefined } from '@shared/utils/basic-helpers'; import SideArrow from '@assets/svgX/side-arrow.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; @@ -45,6 +45,111 @@ const InputClear = forwardRef( const FormInputClear = makeUncontrolled(InputClear); +const parseTokenSelectorId = (value: string): null | CIS2.TokenAddress => { + if (value.startsWith('ccd')) { + return null; + } + + const [, index, subindex, id] = value.split(':'); + return { id, contract: ContractAddress.create(BigInt(index), BigInt(subindex)) }; +}; + +const formatTokenSelectorId = (address: null | CIS2.TokenAddress) => { + if (address == null) { + return 'ccd'; + } + return `cis2:${address.contract.index}:${address.contract.subindex}:${address.id}`; +}; + +const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; + +type TokenPickerProps = { + /** null == CCD */ + selectedToken: null | TokenInfo; + /** The set of tokens available for the account specified by `accountInfo` */ + tokens: TokenInfo[]; + /** Callback invoked when a token is selected */ + onSelect(value: null | CIS2.TokenAddress): void; + /** Whether to enable selection */ + canSelect?: boolean; + /** The balance of the selected token */ + selectedTokenBalance: bigint | undefined; + /** function to format token amounts */ + formatAmount(amountValue: bigint): string; +}; + +function TokenPicker({ + selectedToken, + tokens, + onSelect, + canSelect = false, + selectedTokenBalance, + formatAmount, +}: TokenPickerProps) { + const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); + const token: { + name: string; + icon: ReactNode; + decimals: number; + type: 'ccd' | 'cis2'; + address: null | CIS2.TokenAddress; + } = useMemo(() => { + if (selectedToken !== null) { + const { + metadata: { symbol, name, decimals = 0, thumbnail }, + id, + contract, + } = ensureDefined( + tokens.find( + (tk) => tk.id === selectedToken.id && ContractAddress.equals(tk.contract, selectedToken.contract) + ), + 'Expected the token specified to be available in the set of tokens given' + ); + const safeName = symbol ?? name ?? `${selectedToken.id}@${selectedToken.contract.toString()}`; + const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; + const icon = {name}; + return { name: safeName, icon, decimals, type: 'cis2', address: { id, contract } }; + } + const name = 'CCD'; + const icon = ; + return { name, icon, decimals: 6, type: 'ccd', address: null }; + }, [selectedToken]); + + return ( +
+ + {selectedTokenBalance !== undefined && ( + + {t('form.tokenAmount.token.available', { + balance: formatAmount(selectedTokenBalance), + name: token.name, + })} + + )} +
+ ); +} + type TokenVariant = | { /** The token type. If undefined, a token picker is rendered */ @@ -89,6 +194,7 @@ type ValueVariant = form: UseFormReturn; }; +/** The event emitted when a token is selected internally. `null` is used when CCD is selected. */ export type TokenSelectEvent = null | CIS2.TokenAddress; export type TokenAmountViewProps = { @@ -100,18 +206,14 @@ export type TokenAmountViewProps = { tokens: TokenInfo[]; /** The token balance. `undefined` should be used to indicate that the balance is not yet available. */ balance: bigint | undefined; - /** Callback invoked when the user selects a token. This is also invoked when the component renders initially */ + /** + * Callback invoked when the user selects a token. This is also invoked when the component renders initially. + * `null` is used to communicate the native token (CCD) is selected. + */ onSelectToken(event: TokenSelectEvent): void; } & ValueVariant & TokenVariant; -const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; - -// TODO: Token picker... -// [x] Get values from store -// [x] Token images -// [ ] Token picker - /** * TokenAmount component renders a form for transferring tokens with an amount field and optionally a receiver field. * @@ -120,21 +222,10 @@ const DEFAULT_TOKEN_THUMBNAIL = DEFAULT_FAILED; export default function TokenAmountView(props: TokenAmountViewProps) { const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); const { buttonMaxLabel, fee, tokens, balance, onSelectToken } = props; - - const selectedToken: { - name: string; - icon: ReactNode; - decimals: number; - type: 'ccd' | 'cis2'; - address: null | CIS2.TokenAddress; - } = useMemo(() => { + const [selectedToken, setSelectedToken] = useState(() => { switch (props.tokenType) { case 'cis2': { - const { - metadata: { symbol, name, decimals = 0, thumbnail }, - id, - contract, - } = ensureDefined( + return ensureDefined( tokens.find( (tk) => tk.id === props.tokenAddress.id && @@ -142,52 +233,56 @@ export default function TokenAmountView(props: TokenAmountViewProps) { ), 'Expected the token specified to be available in the set of tokens given' ); - const safeName = symbol ?? name ?? `${props.tokenAddress.id}@${props.tokenAddress.contract.toString()}`; - const tokenImage = thumbnail?.url ?? DEFAULT_TOKEN_THUMBNAIL; - const icon = {name}; - return { name: safeName, icon, decimals, type: 'cis2', address: { id, contract } }; } case 'ccd': case undefined: { - const name = 'CCD'; - const icon = ; - return { name, icon, decimals: 6, type: 'ccd', address: null }; + return null; } default: throw new Error('Unreachable'); } - }, [props]); + }); - useEffect(() => { - if (selectedToken.type === 'cis2') { - const { id, contract } = ensureDefined( - tokens.find( - (tk) => - tk.id === selectedToken.address!.id && - ContractAddress.equals(tk.contract, selectedToken.address!.contract) - ), - 'Expected selected token to be in tokens list' - ); - onSelectToken({ id, contract }); - } else { - onSelectToken(null); + const handleTokenSelect = useCallback( + (value: null | CIS2.TokenAddress) => { + if (value === null) { + setSelectedToken(value); + } else { + const selected = ensureDefined( + tokens.find((tk) => tk.id === value.id && ContractAddress.equals(tk.contract, value.contract)), + 'Expected the token specified to be available in the set of tokens given' + ); + setSelectedToken(selected); + } + }, + [tokens, setSelectedToken] + ); + + const tokenDecimals = useMemo(() => { + if (selectedToken === null) { + return 6; } + return selectedToken.metadata.decimals ?? 0; + }, [selectedToken]); + + useEffect(() => { + onSelectToken(selectedToken); }, [selectedToken]); const formatAmount = useCallback( - (amountValue: bigint) => formatTokenAmount(BigInt(amountValue), selectedToken.decimals, 2), - [selectedToken] + (amountValue: bigint) => formatTokenAmount(amountValue, tokenDecimals, 2), + [tokenDecimals] ); const parseAmount = useCallback( - (amountValue: string) => parseTokenAmount(amountValue, selectedToken.decimals), - [selectedToken] + (amountValue: string) => parseTokenAmount(amountValue, tokenDecimals), + [tokenDecimals] ); const availableAmount: bigint | undefined = useMemo(() => { if (balance === undefined) { return undefined; } - return selectedToken.type === 'ccd' ? balance - fee.microCcdAmount : balance; + return selectedToken === null ? balance - fee.microCcdAmount : balance; }, [selectedToken, fee, balance]); const setMax = useCallback(() => { @@ -224,30 +319,35 @@ export default function TokenAmountView(props: TokenAmountViewProps) { (value) => validateTransferAmount( removeNumberGrouping(value), - availableAmount, - selectedToken.decimals, - selectedToken.type === 'ccd' ? fee.microCcdAmount : 0n + balance, + tokenDecimals, + selectedToken === null ? fee.microCcdAmount : 0n ), - [availableAmount, selectedToken] + [balance, tokenDecimals, selectedToken, fee] ); return (
{t('form.tokenAmount.token.label')} -
-
{selectedToken.icon}
- {selectedToken.name} - {props.tokenType === undefined && } - {balance !== undefined && ( - - {t('form.tokenAmount.token.available', { - balance: formatAmount(balance), - name: selectedToken.name, - })} - - )} -
+ {props.tokenType !== undefined ? ( + + ) : ( + + )}
Amount From 4bf06d952fb54bb4ed0a968d63ca774a2ba24295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 22 Oct 2024 07:53:33 +0200 Subject: [PATCH 16/21] Add to index file --- .../src/popup/popupX/shared/Form/TokenAmount/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/index.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/index.ts index e69de29bb..bb13b4006 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/index.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/index.ts @@ -0,0 +1 @@ +export { default } from './TokenAmount'; From ca279d5d3aa34899ff76135038dec66c1c71035c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bruus=20Zeppelin?= Date: Tue, 22 Oct 2024 09:38:27 +0200 Subject: [PATCH 17/21] Change receiver input to textarea --- .../shared/Form/TokenAmount/TokenAmount.scss | 4 +++ .../shared/Form/TokenAmount/TokenAmount.tsx | 10 ++---- .../popupX/shared/Form/TokenAmount/View.tsx | 33 +++++++++++++++---- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss index 01ecd78ad..1ad87a397 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -132,5 +132,9 @@ justify-content: space-between; margin-top: rem(12px); } + + textarea { + resize: none; + } } } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx index e678ec9c8..38451e42a 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -6,7 +6,7 @@ import { atom } from 'jotai'; import { useSelectedAccountInfo } from '@popup/shared/AccountInfoListenerContext/AccountInfoListenerContext'; import { contractBalancesFamily } from '@popup/store/token'; import { ensureDefined } from '@shared/utils/basic-helpers'; -import TokenAmountView, { TokenAmountViewProps, TokenSelectEvent } from './View'; +import TokenAmountView, { TokenAmountViewProps } from './View'; import { useTokenInfo } from './util'; const tokenAddressEq = (a: CIS2.TokenAddress | null, b: CIS2.TokenAddress | null) => { @@ -75,19 +75,15 @@ export default function TokenAmount(props: Props) { const [tokenAddress, setTokenAddress] = useState(null); const tokenBalance = useAtomValue(balanceAtomFamily([accountInfo, tokenAddress])); - if (accountInfo === undefined || tokenInfo === undefined || tokenInfo.loading) { + if (tokenInfo.loading) { return null; } - const handleSelectToken = (e: TokenSelectEvent) => { - setTokenAddress(e); - }; - return ( ); diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx index 0034924e9..18e6a6e55 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx @@ -45,6 +45,30 @@ const InputClear = forwardRef( const FormInputClear = makeUncontrolled(InputClear); +type ReceiverInputProps = Pick< + InputHTMLAttributes, + 'className' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' +> & + RequiredUncontrolledFieldProps; + +/** + * @description + * Use as a normal \