From 3d7532d7a3f511828f3474872dbca808d7210ded Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Mon, 21 Aug 2023 23:25:30 +0100 Subject: [PATCH 1/8] Add batch header --- package.json | 2 + src/views/batch/BatchDisplay.tsx | 78 +++++++++++--------------------- src/views/batch/BatchView.tsx | 58 +++++------------------- yarn.lock | 16 +++++++ 4 files changed, 56 insertions(+), 98 deletions(-) diff --git a/package.json b/package.json index fcd22e288..f17f166da 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@types/jest": "27.5.1", "@types/node": "18.15.0", "@types/papaparse": "^5.3.7", + "@types/pluralize": "^0.0.30", "@types/prop-types": "^15.7.5", "@types/react": "18.2.0", "@types/react-dom": "18.2.0", @@ -110,6 +111,7 @@ "mockdate": "^3.0.5", "os-browserify": "^0.3.0", "papaparse": "^5.4.1", + "pluralize": "^8.0.0", "prettier": "^2.8.8", "process": "^0.11.10", "prop-types": "^15.8.1", diff --git a/src/views/batch/BatchDisplay.tsx b/src/views/batch/BatchDisplay.tsx index ff4e170a0..8772ae12e 100644 --- a/src/views/batch/BatchDisplay.tsx +++ b/src/views/batch/BatchDisplay.tsx @@ -10,8 +10,6 @@ import { Tbody, Td, Text, - Th, - Thead, Tr, } from "@chakra-ui/react"; import React from "react"; @@ -20,7 +18,7 @@ import { FiExternalLink } from "react-icons/fi"; import AddressPill from "../../components/AddressPill/AddressPill"; import { IconAndTextBtnLink } from "../../components/IconAndTextBtn"; import { AccountOperations } from "../../components/sendForm/types"; -import { Account, AccountType } from "../../types/Account"; +import { Account } from "../../types/Account"; import { Operation } from "../../types/Operation"; import { formatTokenAmount, tokenSymbol } from "../../types/Token"; import { formatPkh, prettyTezAmount } from "../../utils/format"; @@ -30,6 +28,8 @@ import { getIPFSurl } from "../../utils/token/nftUtils"; import { buildTzktAddressUrl } from "../../utils/tzkt/helpers"; import { AccountSmallTile } from "../../components/AccountSelector/AccountSmallTile"; import colors from "../../style/colors"; +import pluralize from "pluralize"; +import { headerText } from "../../components/SendFlow/SignPageHeader"; const renderAmount = (operation: Operation, getToken: TokenLookup) => { switch (operation.type) { @@ -64,61 +64,41 @@ const renderAmount = (operation: Operation, getToken: TokenLookup) => { } }; -const RightPanel = ({ - account, - onDelete, - onSend, -}: { - account: Account; - onDelete: () => void; - onSend: () => void; -}) => { - return ( - - - - - } /> - - - ); -}; - export const BatchDisplay: React.FC<{ account: Account; operations: AccountOperations; - onDelete: () => void; - onSend: () => void; -}> = ({ account, operations, onDelete, onSend }) => { +}> = ({ account, operations: accountOperations }) => { + const { operations, type: operationsType } = accountOperations; const network = useSelectedNetwork(); const getToken = useGetToken(); return ( - - - - - {/* TODO: use pluralize.js for that */} - {`${operations.operations.length} transaction${ - operations.operations.length > 1 ? "s" : "" - }`} - + + + + + + + + {pluralize("transaction", operations.length, true)} + + + } /> + - - - - - - - - - {operations.operations.map((operation, i) => ( + {operations.map((operation, i) => ( // TODO: add better key for operations // If you add two 1-tez transfers to the same recipient, the key will be the same // `i` should not be used in the key @@ -136,10 +116,7 @@ export const BatchDisplay: React.FC<{ )} ))} @@ -147,7 +124,6 @@ export const BatchDisplay: React.FC<{
Type:Subject:Contract:Recipient:
- {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */} - {"recipient" in operation && operation.recipient && ( - - )} + {"recipient" in operation && }
-
); }; diff --git a/src/views/batch/BatchView.tsx b/src/views/batch/BatchView.tsx index 4c861a7d4..cae742426 100644 --- a/src/views/batch/BatchView.tsx +++ b/src/views/batch/BatchView.tsx @@ -6,20 +6,17 @@ import { IconAndTextBtn } from "../../components/IconAndTextBtn"; import { TopBar } from "../../components/TopBar"; import colors from "../../style/colors"; import { navigateToExternalLink } from "../../utils/helpers"; -import { useConfirmation } from "../../utils/hooks/confirmModal"; -import { useAppDispatch } from "../../utils/redux/hooks"; -import { useSendFormModal } from "../home/useSendFormModal"; import { BatchDisplay } from "./BatchDisplay"; import NoItems from "../../components/NoItems"; -import { useBatches, useClearBatch } from "../../utils/hooks/assetsHooks"; import { DynamicModalContext } from "../../components/DynamicModal"; import SendTezForm from "../../components/SendFlow/Tez/FormPage"; import CSVFileUploadForm from "../../components/CSVFileUploader/CSVFileUploadForm"; +import { useBatches } from "../../utils/hooks/assetsHooks"; export const FilterController: React.FC<{ batchPending: number }> = props => { return ( - + {props.batchPending} Pending @@ -44,52 +41,21 @@ export const FilterController: React.FC<{ batchPending: number }> = props => { const BatchView = () => { const batches = useBatches(); - const dispatch = useAppDispatch(); - const clearBatch = useClearBatch(); - const { openWith } = useContext(DynamicModalContext); - const { onOpen: openSendForm, modalElement: sendFormModalEl } = useSendFormModal(); - const { onOpen, element: confirmationElement, onClose } = useConfirmation(); - - const batchEls = batches.map(operations => { - const sender = operations.sender; - - const onConfirm = () => { - dispatch(clearBatch(sender)); - onClose(); - }; - - return operations.operations.length > 0 ? ( - - openSendForm({ - sender: sender.address.pkh, - mode: { - type: "batch", - data: operations, - }, - }) - } - onDelete={() => - onOpen({ - onConfirm, - body: "Are you sure you want to delete the batch?", - }) - } - key={sender.address.pkh} - account={sender} - operations={operations} - /> - ) : null; - }); return ( - + - {batchEls.length > 0 ? ( - batchEls + {batches.length > 0 ? ( + batches.map(operations => ( + + )) ) : ( { /> )} - {sendFormModalEl} - {confirmationElement} ); }; diff --git a/yarn.lock b/yarn.lock index c20cf19bb..308e33732 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8419,6 +8419,13 @@ __metadata: languageName: node linkType: hard +"@types/pluralize@npm:^0.0.30": + version: 0.0.30 + resolution: "@types/pluralize@npm:0.0.30" + checksum: 7a9a4a24aac1f74bd63e592a824ebec114486e0635a94496e92aa37fa388fac585be108d3ccfc7b2c490963679ec4900fbf394d22fa6cbf419f22cc499ab5a29 + languageName: node + linkType: hard + "@types/prettier@npm:^2.1.5": version: 2.7.3 resolution: "@types/prettier@npm:2.7.3" @@ -19134,6 +19141,13 @@ __metadata: languageName: node linkType: hard +"pluralize@npm:^8.0.0": + version: 8.0.0 + resolution: "pluralize@npm:8.0.0" + checksum: 08931d4a6a4a5561a7f94f67a31c17e6632cb21e459ab3ff4f6f629d9a822984cf8afef2311d2005fbea5d7ef26016ebb090db008e2d8bce39d0a9a9d218736e + languageName: node + linkType: hard + "pnp-webpack-plugin@npm:^1.7.0": version: 1.7.0 resolution: "pnp-webpack-plugin@npm:1.7.0" @@ -23675,6 +23689,7 @@ __metadata: "@types/jest": 27.5.1 "@types/node": 18.15.0 "@types/papaparse": ^5.3.7 + "@types/pluralize": ^0.0.30 "@types/prop-types": ^15.7.5 "@types/react": 18.2.0 "@types/react-dom": 18.2.0 @@ -23707,6 +23722,7 @@ __metadata: mockdate: ^3.0.5 os-browserify: ^0.3.0 papaparse: ^5.4.1 + pluralize: ^8.0.0 prettier: ^2.8.8 process: ^0.11.10 prop-types: ^15.8.1 From 51ce7f53df3ee1ba776973c2636d5f077bc49b3e Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Mon, 21 Aug 2023 23:32:24 +0100 Subject: [PATCH 2/8] Add batch footer --- src/views/batch/BatchDisplay.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/views/batch/BatchDisplay.tsx b/src/views/batch/BatchDisplay.tsx index 8772ae12e..69aa4352b 100644 --- a/src/views/batch/BatchDisplay.tsx +++ b/src/views/batch/BatchDisplay.tsx @@ -64,6 +64,18 @@ const renderAmount = (operation: Operation, getToken: TokenLookup) => { } }; +const RightHeader = ({ operations }: { operations: AccountOperations }) => ( + + + {pluralize("transaction", operations.operations.length, true)} + + + } /> + +); + export const BatchDisplay: React.FC<{ account: Account; operations: AccountOperations; @@ -123,6 +135,17 @@ export const BatchDisplay: React.FC<{ + {operations.length > 10 && ( + + + + )}
); From 743aad521c8b0689545ace60100e421203c2dfed Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Tue, 22 Aug 2023 12:34:36 +0100 Subject: [PATCH 3/8] Add initial Batch view setup --- package.json | 1 + src/views/batch/BatchDisplay.tsx | 254 +++++++++++++++++-------------- yarn.lock | 8 + 3 files changed, 148 insertions(+), 115 deletions(-) diff --git a/package.json b/package.json index f17f166da..525be15b8 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "jest-fail-on-console": "^3.1.1", "lodash": "^4.17.21", "mockdate": "^3.0.5", + "nano-id": "^1.1.0", "os-browserify": "^0.3.0", "papaparse": "^5.4.1", "pluralize": "^8.0.0", diff --git a/src/views/batch/BatchDisplay.tsx b/src/views/batch/BatchDisplay.tsx index 69aa4352b..0a91d2614 100644 --- a/src/views/batch/BatchDisplay.tsx +++ b/src/views/batch/BatchDisplay.tsx @@ -1,152 +1,176 @@ -import { - AspectRatio, - Box, - Button, - Flex, - IconButton, - Image, - Table, - TableContainer, - Tbody, - Td, - Text, - Tr, -} from "@chakra-ui/react"; +import { Box, Button, Divider, Flex, Heading, IconButton, Text } from "@chakra-ui/react"; import React from "react"; -import { BsTrash } from "react-icons/bs"; -import { FiExternalLink } from "react-icons/fi"; -import AddressPill from "../../components/AddressPill/AddressPill"; -import { IconAndTextBtnLink } from "../../components/IconAndTextBtn"; import { AccountOperations } from "../../components/sendForm/types"; import { Account } from "../../types/Account"; import { Operation } from "../../types/Operation"; -import { formatTokenAmount, tokenSymbol } from "../../types/Token"; -import { formatPkh, prettyTezAmount } from "../../utils/format"; -import { useSelectedNetwork } from "../../utils/hooks/assetsHooks"; -import { TokenLookup, useGetToken } from "../../utils/hooks/tokensHooks"; -import { getIPFSurl } from "../../utils/token/nftUtils"; -import { buildTzktAddressUrl } from "../../utils/tzkt/helpers"; +import { prettyTezAmount } from "../../utils/format"; +import { useClearBatch } from "../../utils/hooks/assetsHooks"; import { AccountSmallTile } from "../../components/AccountSelector/AccountSmallTile"; import colors from "../../style/colors"; import pluralize from "pluralize"; import { headerText } from "../../components/SendFlow/SignPageHeader"; +import Trash from "../../assets/icons/Trash"; +import { nanoid } from "nanoid"; +import AddressPill from "../../components/AddressPill/AddressPill"; +import { TEZ } from "../../utils/tezos"; -const renderAmount = (operation: Operation, getToken: TokenLookup) => { +const RightHeader = ({ + operations: { type: operationsType, sender, operations }, +}: { + operations: AccountOperations; +}) => { + const clearBatch = useClearBatch(); + return ( + + + {pluralize("transaction", operations.length, true)} + + + clearBatch(sender)} // TODO: add a confirmation modal + aria-label="remove-batch" + ml="18px" + variant="circle" + borderRadius="4px" + icon={} + /> + + ); +}; + +// TODO: test +export const prettyOperationType = (operation: Operation) => { switch (operation.type) { case "fa1.2": - case "fa2": { - const token = getToken(operation.contract.pkh, operation.tokenId); - if (!token) { - throw new Error(`Token not found ${operation.contract.pkh} ${operation.tokenId}}`); - } - const amount = formatTokenAmount(operation.amount, token.metadata?.decimals); + case "fa2": + return "Token Transfer"; + case "undelegation": + return "End Delegation"; + case "delegation": + return "Start Delegation"; // TODO: fix + case "tez": + return `${TEZ} Transfer`; + case "contract_origination": + case "contract_call": + return ""; + } +}; + +const OperationDisplay = ({ operation }: { operation: Operation }) => { + switch (operation.type) { + case "tez": return ( - {amount} - - {token.type === "nft" ? ( - - - - ) : ( - {tokenSymbol(token)} - )} + {prettyTezAmount(operation.amount)} ); - } - case "tez": - return prettyTezAmount(operation.amount); + case "fa1.2": + case "fa2": case "delegation": case "undelegation": case "contract_origination": case "contract_call": - return ""; + return null; } }; -const RightHeader = ({ operations }: { operations: AccountOperations }) => ( - - - {pluralize("transaction", operations.operations.length, true)} - - - } /> - -); +const OperationRecipient = ({ operation }: { operation: Operation }) => { + let address; + + switch (operation.type) { + case "undelegation": + case "contract_origination": + address = undefined; + break; + case "tez": + case "fa1.2": + case "fa2": + case "delegation": + address = operation.recipient; + break; + + case "contract_call": + address = operation.contract; + break; + } + if (!address) { + return N/A; + } + return ( + <> + + To: + + + + ); +}; export const BatchDisplay: React.FC<{ account: Account; operations: AccountOperations; }> = ({ account, operations: accountOperations }) => { - const { operations, type: operationsType } = accountOperations; - const network = useSelectedNetwork(); - const getToken = useGetToken(); + const { operations } = accountOperations; return ( - - + + + + + + + + + {operations.map((operation, i) => ( + + + + + + + + + + + + + {prettyOperationType(operation)} + + } + borderRadius="full" + size="xs" + width="24px" + variant="circle" + /> + + + + {i < operations.length - 1 && } + + ))} + + {operations.length > 9 && ( - - - - - - {pluralize("transaction", operations.length, true)} - - - } /> - + - - - - {operations.map((operation, i) => ( - // TODO: add better key for operations - // If you add two 1-tez transfers to the same recipient, the key will be the same - // `i` should not be used in the key - - - - - - - ))} - -
{operation.type !== "delegation" ? "Transaction" : operation.type}{renderAmount(operation, getToken)} - {(operation.type === "fa2" || operation.type === "fa1.2") && ( - - )} - - {"recipient" in operation && } -
-
- {operations.length > 10 && ( - - - - )} -
-
+ )} + ); }; diff --git a/yarn.lock b/yarn.lock index 308e33732..8e32dd58a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18145,6 +18145,13 @@ __metadata: languageName: node linkType: hard +"nano-id@npm:^1.1.0": + version: 1.1.0 + resolution: "nano-id@npm:1.1.0" + checksum: 83c786ac58279ed2bc5e6e2ba59df0ee727eaddafc2e927e91904165fca462635b30ab01d892da00dd5f7c621b49be82708e859c702e0b9170b4ba4019499087 + languageName: node + linkType: hard + "nano-time@npm:1.0.0": version: 1.0.0 resolution: "nano-time@npm:1.0.0" @@ -23720,6 +23727,7 @@ __metadata: jest-fail-on-console: ^3.1.1 lodash: ^4.17.21 mockdate: ^3.0.5 + nano-id: ^1.1.0 os-browserify: ^0.3.0 papaparse: ^5.4.1 pluralize: ^8.0.0 From acd5a499e0779196f559e4ed182cc8e2a4bb305b Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Thu, 31 Aug 2023 09:32:57 +0100 Subject: [PATCH 4/8] Add basic operation titles --- .../AccountCard/AssetsPanel/TokenList.tsx | 4 +- src/types/Token.ts | 7 +++- src/views/batch/BatchDisplay.tsx | 39 ++++++++++++++++++- src/views/operations/operationsUtils.ts | 4 +- src/views/tokens/AccountTokensTile.tsx | 4 +- 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/components/AccountCard/AssetsPanel/TokenList.tsx b/src/components/AccountCard/AssetsPanel/TokenList.tsx index 39bd64137..18f5b8054 100644 --- a/src/components/AccountCard/AssetsPanel/TokenList.tsx +++ b/src/components/AccountCard/AssetsPanel/TokenList.tsx @@ -2,14 +2,14 @@ import { Box, Flex, Heading, Icon, Image, Text } from "@chakra-ui/react"; import { MdGeneratingTokens } from "react-icons/md"; import colors from "../../../style/colors"; import { FA12TokenBalance, FA2TokenBalance } from "../../../types/TokenBalance"; -import { httpIconUri, tokenName, tokenPrettyBalance, tokenSymbol } from "../../../types/Token"; +import { httpIconUri, tokenName, tokenPrettyAmount, tokenSymbol } from "../../../types/Token"; import NoItems from "../../NoItems"; const TokenTile = ({ token }: { token: FA12TokenBalance | FA2TokenBalance }) => { const name = tokenName(token); const symbol = tokenSymbol(token); const iconUri = httpIconUri(token); - const prettyAmount = tokenPrettyBalance(token.balance, token, { showSymbol: false }); + const prettyAmount = tokenPrettyAmount(token.balance, token, { showSymbol: false }); return ( { + if (token.type === "nft") { + return amount; + } const symbol = tokenSymbol(token); const decimals = token.metadata?.decimals; const trailingSymbol = options?.showSymbol ? ` ${symbol}` : ""; diff --git a/src/views/batch/BatchDisplay.tsx b/src/views/batch/BatchDisplay.tsx index 0a91d2614..ee4350738 100644 --- a/src/views/batch/BatchDisplay.tsx +++ b/src/views/batch/BatchDisplay.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Divider, Flex, Heading, IconButton, Text } from "@chakra-ui/react"; +import { Box, Button, Divider, Flex, Heading, IconButton, Text, Tooltip } from "@chakra-ui/react"; import React from "react"; import { AccountOperations } from "../../components/sendForm/types"; import { Account } from "../../types/Account"; @@ -13,6 +13,8 @@ import Trash from "../../assets/icons/Trash"; import { nanoid } from "nanoid"; import AddressPill from "../../components/AddressPill/AddressPill"; import { TEZ } from "../../utils/tezos"; +import { useGetToken } from "../../utils/hooks/tokensHooks"; +import { tokenName, tokenPrettyAmount } from "../../types/Token"; const RightHeader = ({ operations: { type: operationsType, sender, operations }, @@ -59,6 +61,7 @@ export const prettyOperationType = (operation: Operation) => { }; const OperationDisplay = ({ operation }: { operation: Operation }) => { + const getToken = useGetToken(); switch (operation.type) { case "tez": return ( @@ -67,7 +70,39 @@ const OperationDisplay = ({ operation }: { operation: Operation }) => { ); case "fa1.2": - case "fa2": + case "fa2": { + const token = getToken(operation.contract.pkh, operation.tokenId); + if (token?.type === "nft") { + // TODO: Add tooltip + return ( + + {Number(operation.amount) > 1 && ( + <> + + x{operation.amount} + +   + + )} + {tokenName(token)} + + ); + } + + const prettyAmount = token + ? tokenPrettyAmount(operation.amount, token, { showSymbol: true }) + : operation.amount; + const name = token ? tokenName(token) : undefined; + // TODO: Finish it. looks ugly. no idea what the token it is + return ( + + + {prettyAmount} + + + ); + } + // TODO: Add some title case "delegation": case "undelegation": case "contract_origination": diff --git a/src/views/operations/operationsUtils.ts b/src/views/operations/operationsUtils.ts index 39a2c0696..3d45fa119 100644 --- a/src/views/operations/operationsUtils.ts +++ b/src/views/operations/operationsUtils.ts @@ -1,6 +1,6 @@ import { formatRelative } from "date-fns"; import { z } from "zod"; -import { tokenPrettyBalance } from "../../types/Token"; +import { tokenPrettyAmount } from "../../types/Token"; import { OperationDisplay, TezTransfer, TokenTransfer } from "../../types/Transfer"; import { fromRaw } from "../../types/Token"; import { compact } from "lodash"; @@ -171,7 +171,7 @@ export const getTokenOperationDisplay = ( if (token.type === "nft") { prettyAmount = transfer.amount; } else { - prettyAmount = tokenPrettyBalance(transfer.amount, token, { showSymbol: true }); + prettyAmount = tokenPrettyAmount(transfer.amount, token, { showSymbol: true }); } const result: OperationDisplay = { diff --git a/src/views/tokens/AccountTokensTile.tsx b/src/views/tokens/AccountTokensTile.tsx index a7586d026..bb9f3fdf0 100644 --- a/src/views/tokens/AccountTokensTile.tsx +++ b/src/views/tokens/AccountTokensTile.tsx @@ -22,7 +22,7 @@ import { Identicon } from "../../components/Identicon"; import colors from "../../style/colors"; import { Account } from "../../types/Account"; import { FA12TokenBalance, FA2TokenBalance } from "../../types/TokenBalance"; -import { httpIconUri, tokenName, tokenPrettyBalance } from "../../types/Token"; +import { httpIconUri, tokenName, tokenPrettyAmount } from "../../types/Token"; import { formatPkh } from "../../utils/format"; import { useSelectedNetwork } from "../../utils/hooks/assetsHooks"; import { buildTzktAddressUrl } from "../../utils/tzkt/helpers"; @@ -105,7 +105,7 @@ const AccountTokensTile: React.FC<{ textFirst /> - {tokenPrettyBalance(token.balance, token, { showSymbol: false })} + {tokenPrettyAmount(token.balance, token, { showSymbol: false })} {/* TODO: fetch token values */} From c5550375da81195bd8f78df0dbc75447532bb0c7 Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Thu, 31 Aug 2023 09:45:31 +0100 Subject: [PATCH 5/8] Remove account props from BatchDisplay --- src/views/batch/BatchDisplay.tsx | 10 ++++------ src/views/batch/BatchView.tsx | 6 +----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/views/batch/BatchDisplay.tsx b/src/views/batch/BatchDisplay.tsx index ee4350738..8b0b1ac6e 100644 --- a/src/views/batch/BatchDisplay.tsx +++ b/src/views/batch/BatchDisplay.tsx @@ -1,7 +1,6 @@ import { Box, Button, Divider, Flex, Heading, IconButton, Text, Tooltip } from "@chakra-ui/react"; import React from "react"; import { AccountOperations } from "../../components/sendForm/types"; -import { Account } from "../../types/Account"; import { Operation } from "../../types/Operation"; import { prettyTezAmount } from "../../utils/format"; import { useClearBatch } from "../../utils/hooks/assetsHooks"; @@ -144,13 +143,12 @@ const OperationRecipient = ({ operation }: { operation: Operation }) => { }; export const BatchDisplay: React.FC<{ - account: Account; operations: AccountOperations; -}> = ({ account, operations: accountOperations }) => { - const { operations } = accountOperations; +}> = ({ operations: accountOperations }) => { + const { operations, sender } = accountOperations; return ( - + - + diff --git a/src/views/batch/BatchView.tsx b/src/views/batch/BatchView.tsx index cae742426..2da70832b 100644 --- a/src/views/batch/BatchView.tsx +++ b/src/views/batch/BatchView.tsx @@ -50,11 +50,7 @@ const BatchView = () => { {batches.length > 0 ? ( batches.map(operations => ( - + )) ) : ( Date: Mon, 4 Sep 2023 21:26:30 +0100 Subject: [PATCH 6/8] Complete BatchView UI --- src/Router.tsx | 4 +- .../AccountCard/AccountCard.test.tsx | 2 +- .../MultisigDecodedOperationItem.test.tsx | 2 +- .../MultisigDecodedOperationItem.tsx | 8 +- .../AccountCard/AssetsPanel/TokenList.tsx | 11 +- src/components/SendFlow/Token/FormPage.tsx | 9 +- src/components/SendFlow/Token/SignPage.tsx | 4 +- src/components/sendForm/steps/FillStep.tsx | 4 +- src/types/Token.test.tsx | 51 ++- src/types/Token.ts | 32 +- src/utils/hooks/assetsHooks.ts | 6 + src/views/batch/BatchDisplay.tsx | 209 ------------ src/views/batch/BatchPage.test.tsx | 192 +++++++++++ src/views/batch/BatchPage.tsx | 69 ++++ src/views/batch/BatchView.test.tsx | 204 ++--------- src/views/batch/BatchView.tsx | 316 ++++++++++++++---- src/views/operations/operationUtils.test.ts | 14 +- src/views/tokens/AccountTokensTile.tsx | 4 +- 18 files changed, 638 insertions(+), 503 deletions(-) delete mode 100644 src/views/batch/BatchDisplay.tsx create mode 100644 src/views/batch/BatchPage.test.tsx create mode 100644 src/views/batch/BatchPage.tsx diff --git a/src/Router.tsx b/src/Router.tsx index 713aa6287..122902987 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -17,7 +17,7 @@ import SettingsView from "./views/settings/SettingsView"; import { withSideMenu } from "./views/withSideMenu"; import HelpView from "./views/help/HelpView"; import AddressBookView from "./views/addressBook/AddressBookView"; -import BatchView from "./views/batch/BatchView"; +import BatchPage from "./views/batch/BatchPage"; import { resetBeacon, useBeaconInit } from "./utils/beacon/beacon"; import TokensView from "./views/tokens/TokensView"; import { useDeeplinkHandler } from "./utils/useDeeplinkHandler"; @@ -55,7 +55,7 @@ const MemoizedRouter = React.memo(() => { )} /> )} /> )} /> - )} /> + )} /> } /> {dynamicModal.content} diff --git a/src/components/AccountCard/AccountCard.test.tsx b/src/components/AccountCard/AccountCard.test.tsx index b0f92eb45..7521d168c 100644 --- a/src/components/AccountCard/AccountCard.test.tsx +++ b/src/components/AccountCard/AccountCard.test.tsx @@ -124,7 +124,7 @@ describe("", () => { { const { getByTestId } = within(tokenTiles[0]); expect(getByTestId("token-name")).toHaveTextContent("Hedgehoge"); - expect(getByTestId("token-balance")).toHaveTextContent("10000"); + expect(getByTestId("token-balance")).toHaveTextContent("10,000.000000"); expect(getByTestId("token-symbol")).toHaveTextContent("HEH"); } diff --git a/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.test.tsx b/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.test.tsx index 324c91943..f693bc43d 100644 --- a/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.test.tsx +++ b/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.test.tsx @@ -95,7 +95,7 @@ describe("", () => { /> ); - expect(screen.getByTestId("decoded-fa-amount")).toHaveTextContent("-3 mockSymbol"); + expect(screen.getByTestId("decoded-fa-amount")).toHaveTextContent("-3.00 mockSymbol"); }); it("NFT amount renders correctly", () => { diff --git a/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.tsx b/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.tsx index b4ce93e49..8d8933fa9 100644 --- a/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.tsx +++ b/src/components/AccountCard/AssetsPanel/MultisigPendingAccordion/MultisigDecodedOperationItem.tsx @@ -2,7 +2,7 @@ import { Box, Flex, Heading, Icon, Text } from "@chakra-ui/react"; import { FiArrowUpRight } from "react-icons/fi"; import { Operation } from "../../../../types/Operation"; import colors from "../../../../style/colors"; -import { formatTokenAmount, tokenDecimals, tokenName, tokenSymbol } from "../../../../types/Token"; +import { tokenNameSafe, tokenPrettyAmount } from "../../../../types/Token"; import { prettyTezAmount } from "../../../../utils/format"; import { useGetToken } from "../../../../utils/hooks/tokensHooks"; import AddressPill from "../../../AddressPill/AddressPill"; @@ -66,9 +66,7 @@ const MultisigOperationAmount: React.FC<{ if (!asset) { return null; } - const symbol = tokenSymbol(asset); - const decimals = tokenDecimals(asset); - const name = tokenName(asset); + const name = tokenNameSafe(asset); const isNFT = asset.type === "nft"; return ( @@ -80,7 +78,7 @@ const MultisigOperationAmount: React.FC<{ ) : ( - -{formatTokenAmount(operation.amount, decimals)} {symbol} + -{tokenPrettyAmount(operation.amount, asset, { showSymbol: true })} )} diff --git a/src/components/AccountCard/AssetsPanel/TokenList.tsx b/src/components/AccountCard/AssetsPanel/TokenList.tsx index 18f5b8054..219409c9f 100644 --- a/src/components/AccountCard/AssetsPanel/TokenList.tsx +++ b/src/components/AccountCard/AssetsPanel/TokenList.tsx @@ -2,12 +2,17 @@ import { Box, Flex, Heading, Icon, Image, Text } from "@chakra-ui/react"; import { MdGeneratingTokens } from "react-icons/md"; import colors from "../../../style/colors"; import { FA12TokenBalance, FA2TokenBalance } from "../../../types/TokenBalance"; -import { httpIconUri, tokenName, tokenPrettyAmount, tokenSymbol } from "../../../types/Token"; +import { + httpIconUri, + tokenNameSafe, + tokenPrettyAmount, + tokenSymbolSafe, +} from "../../../types/Token"; import NoItems from "../../NoItems"; const TokenTile = ({ token }: { token: FA12TokenBalance | FA2TokenBalance }) => { - const name = tokenName(token); - const symbol = tokenSymbol(token); + const name = tokenNameSafe(token); + const symbol = tokenSymbolSafe(token); const iconUri = httpIconUri(token); const prettyAmount = tokenPrettyAmount(token.balance, token, { showSymbol: false }); return ( diff --git a/src/components/SendFlow/Token/FormPage.tsx b/src/components/SendFlow/Token/FormPage.tsx index 9b0cfd283..8274a942f 100644 --- a/src/components/SendFlow/Token/FormPage.tsx +++ b/src/components/SendFlow/Token/FormPage.tsx @@ -27,7 +27,12 @@ import { useOpenSignPageFormAction, } from "../onSubmitFormActionHooks"; import { FA12TokenBalance, FA2TokenBalance } from "../../../types/TokenBalance"; -import { formatTokenAmount, getRealAmount, tokenDecimals, tokenSymbol } from "../../../types/Token"; +import { + formatTokenAmount, + getRealAmount, + tokenDecimals, + tokenSymbolSafe, +} from "../../../types/Token"; import FormPageHeader from "../FormPageHeader"; import { FormErrorMessage } from "../../FormErrorMessage"; @@ -116,7 +121,7 @@ const FormPage: React.FC< placeholder={smallestUnit} /> - {tokenSymbol(token)} + {tokenSymbolSafe(token)} {errors.prettyAmount && ( diff --git a/src/components/SendFlow/Token/SignPage.tsx b/src/components/SendFlow/Token/SignPage.tsx index 920edbf64..7c8e2153d 100644 --- a/src/components/SendFlow/Token/SignPage.tsx +++ b/src/components/SendFlow/Token/SignPage.tsx @@ -16,7 +16,7 @@ import SignButton from "../../sendForm/components/SignButton"; import { SignPageProps, useSignPageHelpers } from "../utils"; import { SignPageHeader, headerText } from "../SignPageHeader"; import { FATokenBalance } from "./FormPage"; -import { formatTokenAmount, tokenSymbol } from "../../../types/Token"; +import { formatTokenAmount, tokenSymbolSafe } from "../../../types/Token"; import { FA12Transfer } from "../../../types/Operation"; import { OperationSignerSelector } from "../OperationSignerSelector"; import SignPageFee from "../SignPageFee"; @@ -51,7 +51,7 @@ const SignPage: React.FC> = props => { value={formatTokenAmount(amount, token.metadata?.decimals)} /> - {tokenSymbol(token)} + {tokenSymbolSafe(token)} diff --git a/src/components/sendForm/steps/FillStep.tsx b/src/components/sendForm/steps/FillStep.tsx index 96d2f1e82..4008c8c3f 100644 --- a/src/components/sendForm/steps/FillStep.tsx +++ b/src/components/sendForm/steps/FillStep.tsx @@ -22,7 +22,7 @@ import React from "react"; import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { AccountType, MultisigAccount } from "../../../types/Account"; import { parseImplicitPkh, parsePkh, RawPkh } from "../../../types/Address"; -import { getRealAmount, tokenSymbol } from "../../../types/Token"; +import { getRealAmount, tokenSymbolSafe } from "../../../types/Token"; import { Operation } from "../../../types/Operation"; import { tezToMutez } from "../../../utils/format"; import { @@ -120,7 +120,7 @@ const getAmountSymbol = (asset?: Token) => { return "editions"; } - return tokenSymbol(asset); + return tokenSymbolSafe(asset); }; export const FillBatchForm: React.FC<{ diff --git a/src/types/Token.test.tsx b/src/types/Token.test.tsx index 5744e111b..49362f5aa 100644 --- a/src/types/Token.test.tsx +++ b/src/types/Token.test.tsx @@ -6,6 +6,7 @@ import { FA12Token, FA2Token, artifactUri, + formatTokenAmount, fromRaw, httpIconUri, metadataUri, @@ -13,8 +14,8 @@ import { royalties, thumbnailUri, tokenDecimals, - tokenName, - tokenSymbol, + tokenNameSafe, + tokenSymbolSafe, } from "./Token"; import type { Metadata } from "./Token"; import { TezosNetwork } from "./TezosNetwork"; @@ -142,84 +143,84 @@ describe("fromRaw", () => { }); }); -describe("tokenName", () => { +describe("tokenNameSafe", () => { test("when metadata.name exists", () => { const fa1token: FA12Token = { type: "fa1.2", contract: "KT1QTcAXeefhJ3iXLurRt81WRKdv7YqyYFmo", tokenId: "0", }; - expect(tokenName(fa1token)).toEqual("FA1.2 token"); + expect(tokenNameSafe(fa1token)).toEqual("FA1.2 token"); const fa1tokenWithName = { ...fa1token, metadata: { name: "some token name", }, }; - expect(tokenName(fa1tokenWithName)).toEqual("some token name"); + expect(tokenNameSafe(fa1tokenWithName)).toEqual("some token name"); const fa2token: FA2Token = { type: "fa2", contract: "KT1QTcAXeefhJ3iXLurRt81WRKdv7YqyYFmo", tokenId: "123", }; - expect(tokenName(fa2token)).toEqual("FA2 token"); + expect(tokenNameSafe(fa2token)).toEqual("FA2 token"); const fa2tokenWithName = { ...fa2token, metadata: { name: "some token name", }, }; - expect(tokenName(fa2tokenWithName)).toEqual("some token name"); + expect(tokenNameSafe(fa2tokenWithName)).toEqual("some token name"); }); test("get tokenName for NFT", () => { const nft = mockNFT(0); - expect(tokenName(nft)).toEqual("Tezzardz #0"); + expect(tokenNameSafe(nft)).toEqual("Tezzardz #0"); nft.metadata = {}; - expect(tokenName(nft)).toEqual("NFT"); + expect(tokenNameSafe(nft)).toEqual("NFT"); }); }); -describe("tokenSymbol", () => { +describe("tokenSymbolSafe", () => { test("when metadata.symbol exists", () => { const fa1token: FA12Token = { type: "fa1.2", contract: "KT1QTcAXeefhJ3iXLurRt81WRKdv7YqyYFmo", tokenId: "0", }; - expect(tokenSymbol(fa1token)).toEqual("FA1.2"); + expect(tokenSymbolSafe(fa1token)).toEqual("FA1.2"); const fa1tokenWithSymbol = { ...fa1token, metadata: { symbol: "some token symbol", }, }; - expect(tokenSymbol(fa1tokenWithSymbol)).toEqual("some token symbol"); + expect(tokenSymbolSafe(fa1tokenWithSymbol)).toEqual("some token symbol"); const fa2token: FA2Token = { type: "fa2", contract: "KT1QTcAXeefhJ3iXLurRt81WRKdv7YqyYFmo", tokenId: "123", }; - expect(tokenSymbol(fa2token)).toEqual("FA2"); + expect(tokenSymbolSafe(fa2token)).toEqual("FA2"); const fa2tokenWithSymbol = { ...fa2token, metadata: { symbol: "some token symbol", }, }; - expect(tokenSymbol(fa2tokenWithSymbol)).toEqual("some token symbol"); + expect(tokenSymbolSafe(fa2tokenWithSymbol)).toEqual("some token symbol"); }); test("get token symbol for NFT", () => { const nft = mockNFT(0); - expect(tokenSymbol(nft)).toEqual("FKR0"); + expect(tokenSymbolSafe(nft)).toEqual("FKR0"); nft.metadata = {}; - expect(tokenSymbol(nft)).toEqual("NFT"); + expect(tokenSymbolSafe(nft)).toEqual("NFT"); }); }); @@ -393,3 +394,21 @@ describe("tokenDecimal", () => { expect(tokenDecimals(fa2token)).toEqual("0"); }); }); + +describe("formatTokenAmount", () => { + it("returns raw amount if no decimals are present", () => { + expect(formatTokenAmount("1000")).toEqual("1,000"); + }); + + it("returns raw amount if decimals field is 0", () => { + expect(formatTokenAmount("1000", "0")).toEqual("1,000"); + }); + + it("returns pretty amount if decimals field is present", () => { + expect(formatTokenAmount("1000", "5")).toEqual("0.01000"); + }); + + it("shows all decimals even if amount is integer", () => { + expect(formatTokenAmount("100000", "3")).toEqual("100.000"); + }); +}); diff --git a/src/types/Token.ts b/src/types/Token.ts index 9a98042bb..0a8046660 100644 --- a/src/types/Token.ts +++ b/src/types/Token.ts @@ -164,10 +164,12 @@ const defaultTokenName = (asset: Token): string => { } }; -export const tokenName = (asset: Token): string => { - return asset.metadata?.name || defaultTokenName(asset); +export const tokenNameSafe = (token: Token): string => { + return tokenName(token) || defaultTokenName(token); }; +export const tokenName = (token: Token): string | undefined => token.metadata?.name; + const defaultTokenSymbol = (token: Token): string => { switch (token.type) { case "fa1.2": @@ -179,9 +181,10 @@ const defaultTokenSymbol = (token: Token): string => { } }; -export const tokenSymbol = (token: Token): string => { - return token.metadata?.symbol || defaultTokenSymbol(token); -}; +export const tokenSymbolSafe = (token: Token): string => + tokenSymbol(token) || defaultTokenSymbol(token); + +export const tokenSymbol = (token: Token): string | undefined => token.metadata?.symbol; export const tokenDecimals = (asset: Token): string => { return asset.metadata?.decimals === undefined ? DEFAULT_TOKEN_DECIMALS : asset.metadata.decimals; @@ -212,8 +215,13 @@ export const getRealAmount = (asset: Token, prettyAmount: string): BigNumber => return amount.multipliedBy(new BigNumber(10).exponentiatedBy(decimals)); }; -export const formatTokenAmount = (amountStr: string, decimals = DEFAULT_TOKEN_DECIMALS) => { - return Number(amountStr) / Math.pow(10, Number(decimals)); +export const formatTokenAmount = (amount: string, decimals = DEFAULT_TOKEN_DECIMALS): string => { + const realAmount = BigNumber(amount).dividedBy(BigNumber(10).pow(decimals)); + const formatter = new Intl.NumberFormat("en-US", { + minimumFractionDigits: Number(decimals), + maximumFractionDigits: Number(decimals), + }); + return formatter.format(realAmount.toNumber()); }; export const tokenPrettyAmount = ( @@ -224,7 +232,7 @@ export const tokenPrettyAmount = ( if (token.type === "nft") { return amount; } - const symbol = tokenSymbol(token); + const symbol = tokenSymbolSafe(token); const decimals = token.metadata?.decimals; const trailingSymbol = options?.showSymbol ? ` ${symbol}` : ""; const result = formatTokenAmount(amount, decimals); @@ -258,8 +266,12 @@ export const royalties = (nft: NFT): Array<{ address: string; share: number }> = return shares; }; -export const metadataUri = (nft: NFT, network: TezosNetwork) => { - return `https://${network}.tzkt.io/${nft.contract}/tokens/${nft.tokenId}/metadata`; +export const metadataUri = ({ contract, tokenId }: Token, network: TezosNetwork) => { + return `https://${network}.tzkt.io/${contract}/tokens/${tokenId}/metadata`; +}; + +export const tokenUri = ({ contract, tokenId }: Token, network: TezosNetwork) => { + return `https://${network}.tzkt.io/${contract}/tokens/${tokenId}`; }; export const DEFAULT_FA1_NAME = "FA1.2 token"; diff --git a/src/utils/hooks/assetsHooks.ts b/src/utils/hooks/assetsHooks.ts index 004af5c82..69bcb0b88 100644 --- a/src/utils/hooks/assetsHooks.ts +++ b/src/utils/hooks/assetsHooks.ts @@ -172,6 +172,12 @@ export const useClearBatch = () => { dispatch(assetsSlice.actions.clearBatch({ pkh: account.address.pkh })); }; +export const useRemoveBatchItem = () => { + const dispatch = useAppDispatch(); + return (account: Account, index: number) => + dispatch(assetsSlice.actions.removeBatchItem({ pkh: account.address.pkh, index })); +}; + export const useBakerList = (): Delegate[] => { return useAppSelector(state => state.assets.bakers); }; diff --git a/src/views/batch/BatchDisplay.tsx b/src/views/batch/BatchDisplay.tsx deleted file mode 100644 index 8b0b1ac6e..000000000 --- a/src/views/batch/BatchDisplay.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { Box, Button, Divider, Flex, Heading, IconButton, Text, Tooltip } from "@chakra-ui/react"; -import React from "react"; -import { AccountOperations } from "../../components/sendForm/types"; -import { Operation } from "../../types/Operation"; -import { prettyTezAmount } from "../../utils/format"; -import { useClearBatch } from "../../utils/hooks/assetsHooks"; -import { AccountSmallTile } from "../../components/AccountSelector/AccountSmallTile"; -import colors from "../../style/colors"; -import pluralize from "pluralize"; -import { headerText } from "../../components/SendFlow/SignPageHeader"; -import Trash from "../../assets/icons/Trash"; -import { nanoid } from "nanoid"; -import AddressPill from "../../components/AddressPill/AddressPill"; -import { TEZ } from "../../utils/tezos"; -import { useGetToken } from "../../utils/hooks/tokensHooks"; -import { tokenName, tokenPrettyAmount } from "../../types/Token"; - -const RightHeader = ({ - operations: { type: operationsType, sender, operations }, -}: { - operations: AccountOperations; -}) => { - const clearBatch = useClearBatch(); - return ( - - - {pluralize("transaction", operations.length, true)} - - - clearBatch(sender)} // TODO: add a confirmation modal - aria-label="remove-batch" - ml="18px" - variant="circle" - borderRadius="4px" - icon={} - /> - - ); -}; - -// TODO: test -export const prettyOperationType = (operation: Operation) => { - switch (operation.type) { - case "fa1.2": - case "fa2": - return "Token Transfer"; - case "undelegation": - return "End Delegation"; - case "delegation": - return "Start Delegation"; // TODO: fix - case "tez": - return `${TEZ} Transfer`; - case "contract_origination": - case "contract_call": - return ""; - } -}; - -const OperationDisplay = ({ operation }: { operation: Operation }) => { - const getToken = useGetToken(); - switch (operation.type) { - case "tez": - return ( - - {prettyTezAmount(operation.amount)} - - ); - case "fa1.2": - case "fa2": { - const token = getToken(operation.contract.pkh, operation.tokenId); - if (token?.type === "nft") { - // TODO: Add tooltip - return ( - - {Number(operation.amount) > 1 && ( - <> - - x{operation.amount} - -   - - )} - {tokenName(token)} - - ); - } - - const prettyAmount = token - ? tokenPrettyAmount(operation.amount, token, { showSymbol: true }) - : operation.amount; - const name = token ? tokenName(token) : undefined; - // TODO: Finish it. looks ugly. no idea what the token it is - return ( - - - {prettyAmount} - - - ); - } - // TODO: Add some title - case "delegation": - case "undelegation": - case "contract_origination": - case "contract_call": - return null; - } -}; - -const OperationRecipient = ({ operation }: { operation: Operation }) => { - let address; - - switch (operation.type) { - case "undelegation": - case "contract_origination": - address = undefined; - break; - case "tez": - case "fa1.2": - case "fa2": - case "delegation": - address = operation.recipient; - break; - - case "contract_call": - address = operation.contract; - break; - } - if (!address) { - return N/A; - } - return ( - <> - - To: - - - - ); -}; - -export const BatchDisplay: React.FC<{ - operations: AccountOperations; -}> = ({ operations: accountOperations }) => { - const { operations, sender } = accountOperations; - - return ( - - - - - - - - - {operations.map((operation, i) => ( - - - - - - - - - - - - - {prettyOperationType(operation)} - - } - borderRadius="full" - size="xs" - width="24px" - variant="circle" - /> - - - - {i < operations.length - 1 && } - - ))} - - {operations.length > 9 && ( - - - - )} - - ); -}; diff --git a/src/views/batch/BatchPage.test.tsx b/src/views/batch/BatchPage.test.tsx new file mode 100644 index 000000000..4689770fa --- /dev/null +++ b/src/views/batch/BatchPage.test.tsx @@ -0,0 +1,192 @@ +import { TezosToolkit } from "@taquito/taquito"; +import { makeAccountOperations } from "../../components/sendForm/types"; +import { mockImplicitAccount, mockImplicitAddress } from "../../mocks/factories"; +import { dispatchMockAccounts, mockEstimatedFee } from "../../mocks/helpers"; +import { act, fireEvent, render, screen, waitFor, within } from "../../mocks/testUtils"; +import { TezosNetwork } from "../../types/TezosNetwork"; +import { useGetSecretKey } from "../../utils/hooks/accountUtils"; +import store from "../../utils/redux/store"; +import { estimateAndUpdateBatch } from "../../utils/redux/thunks/estimateAndUpdateBatch"; +import { executeOperations, makeToolkit } from "../../utils/tezos"; +import BatchPage from "./BatchPage"; + +// These tests might take long in the CI +jest.setTimeout(10000); + +jest.mock("../../utils/hooks/accountUtils"); +jest.mock("../../utils/tezos"); + +const useGetSecretKeyMock = jest.mocked(useGetSecretKey); + +beforeEach(() => { + dispatchMockAccounts([mockImplicitAccount(1), mockImplicitAccount(2), mockImplicitAccount(3)]); + mockEstimatedFee(10); + + useGetSecretKeyMock.mockReturnValue(async (_a, _b) => "mockSk"); + jest.mocked(executeOperations).mockResolvedValue({ opHash: "foo" }); +}); + +describe("", () => { + describe("Given no batch has beed added", () => { + it("a message 'no batches are present' is displayed", () => { + render(); + + expect(screen.getByText(/0 pending/i)).toBeInTheDocument(); + expect(screen.getByText(/your batch is currently empty/i)).toBeInTheDocument(); + }); + }); + + describe("Given batches have been added", () => { + const MOCK_TEZOS_TOOLKIT = {}; + beforeEach(async () => { + await store.dispatch( + estimateAndUpdateBatch( + makeAccountOperations(mockImplicitAccount(1), mockImplicitAccount(1), [ + { + type: "tez", + recipient: mockImplicitAddress(1), + amount: "1000000", + }, + { + type: "tez", + recipient: mockImplicitAddress(2), + amount: "2000000", + }, + { + type: "tez", + recipient: mockImplicitAddress(3), + amount: "3000000", + }, + ]), + TezosNetwork.MAINNET + ) + ); + + store.dispatch( + estimateAndUpdateBatch( + makeAccountOperations(mockImplicitAccount(2), mockImplicitAccount(2), [ + { + type: "tez", + recipient: mockImplicitAddress(9), + amount: "4", + }, + { + type: "tez", + recipient: mockImplicitAddress(4), + amount: "5", + }, + { + type: "tez", + recipient: mockImplicitAddress(5), + amount: "6", + }, + ]), + TezosNetwork.MAINNET + ) + ); + jest.mocked(makeToolkit).mockResolvedValue(MOCK_TEZOS_TOOLKIT as TezosToolkit); + }); + + test("a batch can be deleted by clicking the delete button and confirming", () => { + render(); + const firstBatch = screen.getAllByTestId(/batch-table/i)[0]; + const { getByLabelText } = within(firstBatch); + const deleteBtn = getByLabelText(/Delete Batch/i); + fireEvent.click(deleteBtn); + expect(screen.getByText(/Are you sure/i)).toBeTruthy(); + const confirmBtn = screen.getByRole("button", { name: /confirm/i }); + fireEvent.click(confirmBtn); + expect(screen.getAllByTestId(/batch-table/i)).toHaveLength(1); + }); + + const clickSubmitOnFirstBatch = () => { + const batchTable = screen.getAllByTestId(/batch-table/i)[0]; + + const { getByRole } = within(batchTable); + const submitBatchBtn = getByRole("button", { name: /submit batch/i }); + fireEvent.click(submitBatchBtn); + }; + + test("clicking submit batch button displays 'preview' form", () => { + render(); + act(() => { + clickSubmitOnFirstBatch(); + }); + const modal = screen.getByRole("dialog"); + const { getByText, getByLabelText } = within(modal); + expect(getByText(/transaction details/i)).toBeInTheDocument(); + + const txsAmount = getByLabelText(/transactions-amount/i); + expect(txsAmount).toHaveTextContent("3"); + + expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument(); + }); + + test("estimating and submiting a batch executes the batch of transactions and empties it after successful submition", async () => { + mockEstimatedFee(10); + render(); + act(() => { + clickSubmitOnFirstBatch(); + }); + + expect( + screen.getByTestId("batch-table-" + mockImplicitAccount(2).address.pkh) + ).toBeInTheDocument(); + expect( + screen.getByTestId("batch-table-" + mockImplicitAccount(1).address.pkh) + ).toBeInTheDocument(); + + const previewBtn = screen.getByRole("button", { name: /preview/i }); + fireEvent.click(previewBtn); + + const passwordInput = await screen.findByLabelText(/password/i); + fireEvent.change(passwordInput, { target: { value: "mockPass" } }); + + const submit = screen.getByRole("button", { + name: /submit transaction/i, + }); + + await waitFor(() => { + expect(submit).toBeEnabled(); + }); + fireEvent.click(submit); + + await waitFor(() => { + expect(screen.getByText(/Operation Submitted/i)).toBeInTheDocument(); + }); + + expect(screen.getByTestId(/tzkt-link/i)).toHaveProperty( + "href", + "https://mainnet.tzkt.io/foo" + ); + + expect(jest.mocked(executeOperations)).toHaveBeenCalledWith( + makeAccountOperations(mockImplicitAccount(1), mockImplicitAccount(1), [ + { + amount: "1000000", + recipient: { pkh: "tz1UZFB9kGauB6F5c2gfJo4hVcvrD8MeJ3Vf", type: "implicit" }, + type: "tez", + }, + { + amount: "2000000", + recipient: { pkh: "tz1ikfEcj3LmsmxpcC1RMZNzBHbEmybCc43D", type: "implicit" }, + type: "tez", + }, + { + amount: "3000000", + recipient: { pkh: "tz1g7Vk9dxDALJUp4w1UTnC41ssvRa7Q4XyS", type: "implicit" }, + type: "tez", + }, + ]), + MOCK_TEZOS_TOOLKIT + ); + + expect( + screen.getByTestId("batch-table-" + mockImplicitAccount(2).address.pkh) + ).toBeInTheDocument(); + expect( + screen.queryByTestId("batch-table-" + mockImplicitAccount(1).address.pkh) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/views/batch/BatchPage.tsx b/src/views/batch/BatchPage.tsx new file mode 100644 index 000000000..67b9c6981 --- /dev/null +++ b/src/views/batch/BatchPage.tsx @@ -0,0 +1,69 @@ +import { Box, Flex, Heading } from "@chakra-ui/react"; +import React, { useContext } from "react"; +import { TfiNewWindow } from "react-icons/tfi"; +import CSVFileUploader from "../../components/CSVFileUploader"; +import { IconAndTextBtn } from "../../components/IconAndTextBtn"; +import { TopBar } from "../../components/TopBar"; +import colors from "../../style/colors"; +import { navigateToExternalLink } from "../../utils/helpers"; +import { BatchView } from "./BatchView"; +import NoItems from "../../components/NoItems"; +import { DynamicModalContext } from "../../components/DynamicModal"; +import SendTezForm from "../../components/SendFlow/Tez/FormPage"; +import CSVFileUploadForm from "../../components/CSVFileUploader/CSVFileUploadForm"; +import { useBatches } from "../../utils/hooks/assetsHooks"; + +export const FilterController: React.FC<{ batchPending: number }> = props => { + return ( + + + {props.batchPending} Pending + + + { + navigateToExternalLink( + "https://github.com/trilitech/umami-v2/blob/main/doc/Batch-File-Format-Specifications.md" + ); + }} + /> + + ); +}; + +const BatchPage = () => { + const batches = useBatches(); + + const { openWith } = useContext(DynamicModalContext); + + return ( + + + + + {batches.length > 0 ? ( + batches.map(operations => ( + + )) + ) : ( + openWith()} + secondaryText="Load CSV file" + onClickSecondary={() => openWith()} + /> + )} + + + ); +}; + +export default BatchPage; diff --git a/src/views/batch/BatchView.test.tsx b/src/views/batch/BatchView.test.tsx index 561f55be1..bb22eb7eb 100644 --- a/src/views/batch/BatchView.test.tsx +++ b/src/views/batch/BatchView.test.tsx @@ -1,194 +1,28 @@ -import { TezosToolkit } from "@taquito/taquito"; -import { makeAccountOperations } from "../../components/sendForm/types"; -import { mockImplicitAccount, mockImplicitAddress } from "../../mocks/factories"; -import { dispatchMockAccounts, mockEstimatedFee } from "../../mocks/helpers"; -import { act, fireEvent, render, screen, waitFor, within } from "../../mocks/testUtils"; -import { TezosNetwork } from "../../types/TezosNetwork"; -import { useGetSecretKey } from "../../utils/hooks/accountUtils"; -import store from "../../utils/redux/store"; -import { estimateAndUpdateBatch } from "../../utils/redux/thunks/estimateAndUpdateBatch"; -import { executeOperations, makeToolkit } from "../../utils/tezos"; -import BatchView from "./BatchView"; +import { ghostFA2, ghostTezzard } from "../../mocks/tokens"; +import { tokenTitle } from "./BatchView"; -// These tests might take long in the CI -jest.setTimeout(10000); +describe("", () => {}); -jest.mock("../../utils/hooks/accountUtils"); -jest.mock("../../utils/tezos"); - -const useGetSecretKeyMock = jest.mocked(useGetSecretKey); - -const fixture = () => ; - -beforeEach(() => { - dispatchMockAccounts([mockImplicitAccount(1), mockImplicitAccount(2), mockImplicitAccount(3)]); - mockEstimatedFee(10); - - useGetSecretKeyMock.mockReturnValue(async (_a, _b) => "mockSk"); - jest.mocked(executeOperations).mockResolvedValue({ opHash: "foo" }); -}); - -describe("", () => { - describe("Given no batch has beed added", () => { - it("a message 'no batches are present' is displayed", () => { - render(fixture()); - - expect(screen.getByText(/0 pending/i)).toBeInTheDocument(); - expect(screen.getByText(/your batch is currently empty/i)).toBeInTheDocument(); - }); +describe("tokenTitle", () => { + it("returns raw amount if token is missing", () => { + expect(tokenTitle(undefined, "1000000")).toBe("1000000"); }); - describe("Given batches have been added", () => { - const MOCK_TEZOS_TOOLKIT = {}; - beforeEach(async () => { - await store.dispatch( - estimateAndUpdateBatch( - makeAccountOperations(mockImplicitAccount(1), mockImplicitAccount(1), [ - { - type: "tez", - recipient: mockImplicitAddress(1), - amount: "1000000", - }, - { - type: "tez", - recipient: mockImplicitAddress(2), - amount: "2000000", - }, - { - type: "tez", - recipient: mockImplicitAddress(3), - amount: "3000000", - }, - ]), - TezosNetwork.MAINNET - ) - ); - - store.dispatch( - estimateAndUpdateBatch( - makeAccountOperations(mockImplicitAccount(2), mockImplicitAccount(2), [ - { - type: "tez", - recipient: mockImplicitAddress(9), - amount: "4", - }, - { - type: "tez", - recipient: mockImplicitAddress(4), - amount: "5", - }, - { - type: "tez", - recipient: mockImplicitAddress(5), - amount: "6", - }, - ]), - TezosNetwork.MAINNET - ) - ); - jest.mocked(makeToolkit).mockResolvedValue(MOCK_TEZOS_TOOLKIT as TezosToolkit); - }); - - test("a batch can be deleted by clicking the delete button and confirming", () => { - render(fixture()); - const firstBatch = screen.getAllByTestId(/batch-table/i)[0]; - const { getByLabelText } = within(firstBatch); - const deleteBtn = getByLabelText(/Delete Batch/i); - fireEvent.click(deleteBtn); - expect(screen.getByText(/Are you sure/i)).toBeTruthy(); - const confirmBtn = screen.getByRole("button", { name: /confirm/i }); - fireEvent.click(confirmBtn); - expect(screen.getAllByTestId(/batch-table/i)).toHaveLength(1); - }); - - const clickSubmitOnFirstBatch = () => { - const batchTable = screen.getAllByTestId(/batch-table/i)[0]; - - const { getByRole } = within(batchTable); - const submitBatchBtn = getByRole("button", { name: /submit batch/i }); - fireEvent.click(submitBatchBtn); - }; - - test("clicking submit batch button displays 'preview' form", () => { - render(fixture()); - act(() => { - clickSubmitOnFirstBatch(); - }); - const modal = screen.getByRole("dialog"); - const { getByText, getByLabelText } = within(modal); - expect(getByText(/transaction details/i)).toBeInTheDocument(); - - const txsAmount = getByLabelText(/transactions-amount/i); - expect(txsAmount).toHaveTextContent("3"); - - expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument(); - }); - - test("estimating and submiting a batch executes the batch of transactions and empties it after successful submition", async () => { - mockEstimatedFee(10); - render(fixture()); - act(() => { - clickSubmitOnFirstBatch(); - }); - - expect( - screen.getByTestId("batch-table-" + mockImplicitAccount(2).address.pkh) - ).toBeInTheDocument(); - expect( - screen.getByTestId("batch-table-" + mockImplicitAccount(1).address.pkh) - ).toBeInTheDocument(); - - const previewBtn = screen.getByRole("button", { name: /preview/i }); - fireEvent.click(previewBtn); - - const passwordInput = await screen.findByLabelText(/password/i); - fireEvent.change(passwordInput, { target: { value: "mockPass" } }); - - const submit = screen.getByRole("button", { - name: /submit transaction/i, - }); - - await waitFor(() => { - expect(submit).toBeEnabled(); - }); - fireEvent.click(submit); - - await waitFor(() => { - expect(screen.getByText(/Operation Submitted/i)).toBeInTheDocument(); - }); + it("doesn't return symbol if token name is absent", () => { + expect(tokenTitle(ghostTezzard, "1000000")).toBe("1000000 Tezzardz #24"); + }); - expect(screen.getByTestId(/tzkt-link/i)).toHaveProperty( - "href", - "https://mainnet.tzkt.io/foo" - ); + it("returns symbol if name is absent", () => { + const token = ghostTezzard; + delete token.metadata.name; + expect(tokenTitle(token, "1000000")).toBe("1000000 FKR"); + }); - expect(jest.mocked(executeOperations)).toHaveBeenCalledWith( - makeAccountOperations(mockImplicitAccount(1), mockImplicitAccount(1), [ - { - amount: "1000000", - recipient: { pkh: "tz1UZFB9kGauB6F5c2gfJo4hVcvrD8MeJ3Vf", type: "implicit" }, - type: "tez", - }, - { - amount: "2000000", - recipient: { pkh: "tz1ikfEcj3LmsmxpcC1RMZNzBHbEmybCc43D", type: "implicit" }, - type: "tez", - }, - { - amount: "3000000", - recipient: { pkh: "tz1g7Vk9dxDALJUp4w1UTnC41ssvRa7Q4XyS", type: "implicit" }, - type: "tez", - }, - ]), - MOCK_TEZOS_TOOLKIT - ); + it("returns just amount if neither name nor symbol are present", () => { + expect(tokenTitle({ ...ghostTezzard, metadata: {} }, "1000000")).toBe("1000000"); + }); - expect( - screen.getByTestId("batch-table-" + mockImplicitAccount(2).address.pkh) - ).toBeInTheDocument(); - expect( - screen.queryByTestId("batch-table-" + mockImplicitAccount(1).address.pkh) - ).not.toBeInTheDocument(); - }); + it("returns pretty amount if decimals field is present", () => { + expect(tokenTitle(ghostFA2, "1000321")).toBe("10.00321 Klondike3"); }); }); diff --git a/src/views/batch/BatchView.tsx b/src/views/batch/BatchView.tsx index 2da70832b..193632b3f 100644 --- a/src/views/batch/BatchView.tsx +++ b/src/views/batch/BatchView.tsx @@ -1,69 +1,273 @@ -import { Box, Flex, Heading } from "@chakra-ui/react"; -import React, { useContext } from "react"; -import { TfiNewWindow } from "react-icons/tfi"; -import CSVFileUploader from "../../components/CSVFileUploader"; -import { IconAndTextBtn } from "../../components/IconAndTextBtn"; -import { TopBar } from "../../components/TopBar"; +import { + AspectRatio, + Box, + Button, + Divider, + Flex, + Heading, + IconButton, + Link, + Text, + Tooltip, + Image, +} from "@chakra-ui/react"; +import React from "react"; +import { AccountOperations } from "../../components/sendForm/types"; +import { Operation } from "../../types/Operation"; +import { prettyTezAmount } from "../../utils/format"; +import { + useClearBatch, + useRemoveBatchItem, + useSelectedNetwork, +} from "../../utils/hooks/assetsHooks"; +import { AccountSmallTile } from "../../components/AccountSelector/AccountSmallTile"; import colors from "../../style/colors"; -import { navigateToExternalLink } from "../../utils/helpers"; -import { BatchDisplay } from "./BatchDisplay"; -import NoItems from "../../components/NoItems"; -import { DynamicModalContext } from "../../components/DynamicModal"; -import SendTezForm from "../../components/SendFlow/Tez/FormPage"; -import CSVFileUploadForm from "../../components/CSVFileUploader/CSVFileUploadForm"; -import { useBatches } from "../../utils/hooks/assetsHooks"; +import pluralize from "pluralize"; +import { headerText } from "../../components/SendFlow/SignPageHeader"; +import Trash from "../../assets/icons/Trash"; +import { nanoid } from "nanoid"; +import AddressPill from "../../components/AddressPill/AddressPill"; +import { TEZ } from "../../utils/tezos"; +import { useGetToken } from "../../utils/hooks/tokensHooks"; +import { + Token, + thumbnailUri, + tokenName, + tokenNameSafe, + tokenPrettyAmount, + tokenSymbol, + tokenUri, +} from "../../types/Token"; +import { getIPFSurl } from "../../utils/token/nftUtils"; +import { compact } from "lodash"; -export const FilterController: React.FC<{ batchPending: number }> = props => { +const RightHeader = ({ + operations: { type: operationsType, sender, operations }, +}: { + operations: AccountOperations; +}) => { + const clearBatch = useClearBatch(); return ( - - - {props.batchPending} Pending - - - { - navigateToExternalLink( - "https://github.com/trilitech/umami-v2/blob/main/doc/Batch-File-Format-Specifications.md" - ); - }} + + + {pluralize("transaction", operations.length, true)} + + + clearBatch(sender)} // TODO: add a confirmation modal + aria-label="remove-batch" + ml="18px" + variant="circle" + borderRadius="4px" + icon={} /> - + ); }; -const BatchView = () => { - const batches = useBatches(); +const prettyOperationType = (operation: Operation) => { + switch (operation.type) { + case "fa1.2": + case "fa2": + return "Token Transfer"; + case "undelegation": + case "delegation": + return "Delegation"; + case "tez": + return `${TEZ} Transfer`; + case "contract_origination": + case "contract_call": + throw new Error(`${operation.type} is not suported yet`); + } +}; + +export const tokenTitle = (token: Token | undefined, amount: string) => { + const name = token ? tokenName(token) : undefined; + + const prettyAmount = token ? tokenPrettyAmount(amount, token, { showSymbol: false }) : amount; + + // don't show the symbol if the token name is present + const symbol = !name && token ? tokenSymbol(token) : undefined; + return compact([prettyAmount, symbol, name]).join(" "); +}; + +export const OperationView = ({ operation }: { operation: Operation }) => { + const getToken = useGetToken(); + const network = useSelectedNetwork(); - const { openWith } = useContext(DynamicModalContext); + switch (operation.type) { + case "tez": + return ( + + {prettyTezAmount(operation.amount)} + + ); + case "fa1.2": + case "fa2": { + const token = getToken(operation.contract.pkh, operation.tokenId); + if (token?.type === "nft") { + return ( + + {Number(operation.amount) > 1 && ( + <> + + x{operation.amount} + +   + + )} + + + + + } + > + {tokenNameSafe(token)} + + + + ); + } + + return ( + + + + {tokenTitle(token, operation.amount)} + + + + ); + } + case "delegation": + return ( + + Delegate + + ); + case "undelegation": + return ( + + End Delegation + + ); + case "contract_origination": + case "contract_call": + throw new Error(`${operation.type} is not suported yet`); + } +}; +const OperationRecipient = ({ operation }: { operation: Operation }) => { + let address; + + switch (operation.type) { + case "undelegation": + case "contract_origination": + address = undefined; + break; + case "tez": + case "fa1.2": + case "fa2": + case "delegation": + address = operation.recipient; + break; + + case "contract_call": + address = operation.contract; + break; + } + if (!address) { + return N/A; + } return ( - - - - - {batches.length > 0 ? ( - batches.map(operations => ( - - )) - ) : ( - openWith()} - secondaryText="Load CSV file" - onClickSecondary={() => openWith()} - /> - )} - - + <> + + To: + + + ); }; -export default BatchView; +export const BatchView: React.FC<{ + operations: AccountOperations; +}> = ({ operations: accountOperations }) => { + const { operations, sender } = accountOperations; + const removeItem = useRemoveBatchItem(); + + return ( + + + + + + + + 9 ? 0 : "8px"} + > + {operations.map((operation, index) => ( + + + + + + + + + + + + + {prettyOperationType(operation)} + + } + borderRadius="full" + size="xs" + width="24px" + variant="circle" + onClick={() => removeItem(sender, index)} + /> + + + + {index < operations.length - 1 && } + + ))} + + {operations.length > 9 && ( + + + + )} + + ); +}; diff --git a/src/views/operations/operationUtils.test.ts b/src/views/operations/operationUtils.test.ts index 7bf460e3b..c8cbbe2a8 100644 --- a/src/views/operations/operationUtils.test.ts +++ b/src/views/operations/operationUtils.test.ts @@ -281,7 +281,7 @@ describe("getTezOperationDisplay", () => { tzktUrl: "https://mainnet.tzkt.io/transactions/109855131041792", amount: { id: 10898231001089, - prettyDisplay: "+7.1685 KL3", + prettyDisplay: "+7.16850 KL3", url: undefined, }, prettyTimestamp: "today at 3:27 PM", @@ -329,7 +329,7 @@ describe("getTezOperationDisplay", () => { tzktUrl: "https://mainnet.tzkt.io/transactions/109855493849088", amount: { id: 10898231001089, - prettyDisplay: "-4.51 KL3", + prettyDisplay: "-4.51000 KL3", url: undefined, }, prettyTimestamp: "today at 3:29 PM", @@ -372,7 +372,7 @@ describe("getTezOperationDisplay", () => { tzktUrl: "https://mainnet.tzkt.io/transactions/109855847219200", amount: { id: 10897625972737, - prettyDisplay: "+27400 FA1.2", + prettyDisplay: "+27,400 FA1.2", url: undefined, }, prettyTimestamp: "today at 3:30 PM", @@ -411,7 +411,7 @@ describe("getOperationDisplays", () => { id: 109855847219201, amount: { id: 10897625972737, - prettyDisplay: "+27400 FA1.2", + prettyDisplay: "+27,400 FA1.2", url: undefined, }, prettyTimestamp: "today at 3:30 PM", @@ -426,7 +426,7 @@ describe("getOperationDisplays", () => { id: 109855493849090, amount: { id: 10898231001089, - prettyDisplay: "-4.51 KL3", + prettyDisplay: "-4.51000 KL3", url: undefined, }, prettyTimestamp: "today at 3:29 PM", @@ -441,7 +441,7 @@ describe("getOperationDisplays", () => { id: 109855131041793, amount: { id: 10898231001089, - prettyDisplay: "+7.1685 KL3", + prettyDisplay: "+7.16850 KL3", url: undefined, }, prettyTimestamp: "today at 3:27 PM", @@ -512,7 +512,7 @@ describe("getOperationDisplays", () => { id: 109511935262721, amount: { id: 10898194300929, - prettyDisplay: "+2.1 KL2", + prettyDisplay: "+2.10000 KL2", url: undefined, }, prettyTimestamp: "yesterday at 4:38 PM", diff --git a/src/views/tokens/AccountTokensTile.tsx b/src/views/tokens/AccountTokensTile.tsx index bb9f3fdf0..a4040f314 100644 --- a/src/views/tokens/AccountTokensTile.tsx +++ b/src/views/tokens/AccountTokensTile.tsx @@ -22,7 +22,7 @@ import { Identicon } from "../../components/Identicon"; import colors from "../../style/colors"; import { Account } from "../../types/Account"; import { FA12TokenBalance, FA2TokenBalance } from "../../types/TokenBalance"; -import { httpIconUri, tokenName, tokenPrettyAmount } from "../../types/Token"; +import { httpIconUri, tokenNameSafe, tokenPrettyAmount } from "../../types/Token"; import { formatPkh } from "../../utils/format"; import { useSelectedNetwork } from "../../utils/hooks/assetsHooks"; import { buildTzktAddressUrl } from "../../utils/tzkt/helpers"; @@ -93,7 +93,7 @@ const AccountTokensTile: React.FC<{ )} - {tokenName(token)} + {tokenNameSafe(token)} From 0d4fb8caa3adadcda78a6d4494719cf56d2675e7 Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Mon, 4 Sep 2023 21:40:58 +0100 Subject: [PATCH 7/8] Add confirmation modal --- src/components/ConfirmationModal.test.tsx | 38 +++++++++++++++++ src/components/ConfirmationModal.tsx | 52 +++++++++++++++++++++++ src/components/DynamicModal.tsx | 9 ++-- src/utils/hooks/confirmModal.tsx | 1 + src/views/batch/BatchView.tsx | 22 ++++++++-- 5 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 src/components/ConfirmationModal.test.tsx create mode 100644 src/components/ConfirmationModal.tsx diff --git a/src/components/ConfirmationModal.test.tsx b/src/components/ConfirmationModal.test.tsx new file mode 100644 index 000000000..afa7e0c7d --- /dev/null +++ b/src/components/ConfirmationModal.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from "../mocks/testUtils"; +import { ConfirmationModal } from "./ConfirmationModal"; + +describe("", () => { + it("shows title", () => { + render(); + expect(screen.getByRole("heading")).toHaveTextContent("Some title"); + }); + + it("shows description", () => { + render( + + ); + expect(screen.getByTestId("description")).toHaveTextContent("Some description"); + }); + + it("doesn't render body if no description is provided", () => { + render(); + expect(screen.queryByTestId("description")).not.toBeInTheDocument(); + }); + + it("executes the passed in onSubmit callback", () => { + const onSubmit = jest.fn(); + render( + + ); + const submitButton = screen.getByRole("button", { name: "Do dangerous things" }); + expect(submitButton).toBeInTheDocument(); + + fireEvent.click(submitButton); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ConfirmationModal.tsx b/src/components/ConfirmationModal.tsx new file mode 100644 index 000000000..74e930a96 --- /dev/null +++ b/src/components/ConfirmationModal.tsx @@ -0,0 +1,52 @@ +import { + Box, + Button, + Heading, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + Text, +} from "@chakra-ui/react"; +import { useContext } from "react"; +import { DynamicModalContext } from "./DynamicModal"; +import WarningIcon from "../assets/icons/Warning"; +import colors from "../style/colors"; + +export const ConfirmationModal: React.FC<{ + title: string; + buttonLabel: string; + description?: string; + onSubmit: () => void; +}> = ({ title, description, buttonLabel, onSubmit }) => { + const { onClose } = useContext(DynamicModalContext); + const onClick = () => { + onSubmit(); + onClose(); + }; + + return ( + + + + + + {title} + + + {description && ( + + + {description} + + + )} + + + + + ); +}; diff --git a/src/components/DynamicModal.tsx b/src/components/DynamicModal.tsx index 305ef625c..91a1fe4b1 100644 --- a/src/components/DynamicModal.tsx +++ b/src/components/DynamicModal.tsx @@ -1,9 +1,9 @@ -import { Modal, ModalOverlay, useDisclosure } from "@chakra-ui/react"; +import { Modal, ModalOverlay, ModalProps, useDisclosure } from "@chakra-ui/react"; import { createContext, ReactElement, useState } from "react"; // this should be used in components as useContext(DynamicModalContext); export const DynamicModalContext = createContext<{ - openWith: (content: ReactElement) => Promise; + openWith: (content: ReactElement, size?: ModalProps["size"]) => Promise; onClose: () => void; isOpen: boolean; }>({ @@ -24,8 +24,10 @@ export const DynamicModalContext = createContext<{ export const useDynamicModal = () => { const { isOpen, onClose, onOpen } = useDisclosure(); const [modalContent, setModalContent] = useState(null); + const [size, setSize] = useState("md"); - const openWith = async (content: ReactElement) => { + const openWith = async (content: ReactElement, size: ModalProps["size"] = "md") => { + setSize(size); setModalContent(content); onOpen(); }; @@ -40,6 +42,7 @@ export const useDynamicModal = () => { onClose={onClose} closeOnOverlayClick={false} autoFocus={false} + size={size} isCentered > diff --git a/src/utils/hooks/confirmModal.tsx b/src/utils/hooks/confirmModal.tsx index 1ef7cc013..27393a520 100644 --- a/src/utils/hooks/confirmModal.tsx +++ b/src/utils/hooks/confirmModal.tsx @@ -10,6 +10,7 @@ import { } from "@chakra-ui/react"; import { useRef } from "react"; +// TODO: replace with ConfirmationModal export const useConfirmation = () => { const { isOpen, onOpen, onClose } = useDisclosure(); diff --git a/src/views/batch/BatchView.tsx b/src/views/batch/BatchView.tsx index 193632b3f..762fb8408 100644 --- a/src/views/batch/BatchView.tsx +++ b/src/views/batch/BatchView.tsx @@ -11,7 +11,7 @@ import { Tooltip, Image, } from "@chakra-ui/react"; -import React from "react"; +import React, { useContext } from "react"; import { AccountOperations } from "../../components/sendForm/types"; import { Operation } from "../../types/Operation"; import { prettyTezAmount } from "../../utils/format"; @@ -40,13 +40,16 @@ import { } from "../../types/Token"; import { getIPFSurl } from "../../utils/token/nftUtils"; import { compact } from "lodash"; +import { DynamicModalContext } from "../../components/DynamicModal"; +import { ConfirmationModal } from "../../components/ConfirmationModal"; +import { Account } from "../../types/Account"; const RightHeader = ({ operations: { type: operationsType, sender, operations }, }: { operations: AccountOperations; }) => { - const clearBatch = useClearBatch(); + const { openWith } = useContext(DynamicModalContext); return ( @@ -56,7 +59,7 @@ const RightHeader = ({ {headerText(operationsType, "batch")} clearBatch(sender)} // TODO: add a confirmation modal + onClick={() => openWith(, "sm")} aria-label="remove-batch" ml="18px" variant="circle" @@ -83,6 +86,19 @@ const prettyOperationType = (operation: Operation) => { } }; +const ClearBatchConfirmationModal = ({ sender }: { sender: Account }) => { + const clearBatch = useClearBatch(); + + return ( + clearBatch(sender)} + buttonLabel="Clear" + /> + ); +}; + export const tokenTitle = (token: Token | undefined, amount: string) => { const name = token ? tokenName(token) : undefined; From 1fac9011f15789b51321133c2e9c445320c5bcfc Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Tue, 5 Sep 2023 23:00:59 +0100 Subject: [PATCH 8/8] Add tests for Batch components --- src/mocks/factories.ts | 2 + src/mocks/nftTokens.ts | 2 +- src/types/Token.ts | 2 +- src/views/batch/BatchPage.test.tsx | 219 +++++++------------- src/views/batch/BatchView.test.tsx | 51 ++++- src/views/batch/BatchView.tsx | 191 ++++------------- src/views/batch/OperationRecipient.test.tsx | 57 +++++ src/views/batch/OperationRecipient.tsx | 41 ++++ src/views/batch/OperationView.test.tsx | 108 ++++++++++ src/views/batch/OperationView.tsx | 84 ++++++++ 10 files changed, 458 insertions(+), 299 deletions(-) create mode 100644 src/views/batch/OperationRecipient.test.tsx create mode 100644 src/views/batch/OperationRecipient.tsx create mode 100644 src/views/batch/OperationView.test.tsx create mode 100644 src/views/batch/OperationView.tsx diff --git a/src/mocks/factories.ts b/src/mocks/factories.ts index 778c356ed..5aedc3c7a 100644 --- a/src/mocks/factories.ts +++ b/src/mocks/factories.ts @@ -334,6 +334,8 @@ export const mockNftOperation = (index: number): FA2Transfer => ({ tokenId: String(index), }); +export const mockFA2Operation = mockNftOperation; + export const mockDelegationOperation = (index: number): Delegation => { return { type: "delegation", diff --git a/src/mocks/nftTokens.ts b/src/mocks/nftTokens.ts index 68be395c7..ef7840557 100644 --- a/src/mocks/nftTokens.ts +++ b/src/mocks/nftTokens.ts @@ -1,6 +1,6 @@ import { RawTokenBalance } from "../types/TokenBalance"; -export const ghotnetThezard: RawTokenBalance = { +export const ghostnetThezard: RawTokenBalance = { id: 139206711050241, account: { address: "KT1MYis2J1hpjxVcfF92Mf7AfXouzaxsYfKm", diff --git a/src/types/Token.ts b/src/types/Token.ts index 0a8046660..ba243648d 100644 --- a/src/types/Token.ts +++ b/src/types/Token.ts @@ -170,7 +170,7 @@ export const tokenNameSafe = (token: Token): string => { export const tokenName = (token: Token): string | undefined => token.metadata?.name; -const defaultTokenSymbol = (token: Token): string => { +export const defaultTokenSymbol = (token: Token): string => { switch (token.type) { case "fa1.2": return DEFAULT_FA1_SYMBOL; diff --git a/src/views/batch/BatchPage.test.tsx b/src/views/batch/BatchPage.test.tsx index 4689770fa..d60dc9a12 100644 --- a/src/views/batch/BatchPage.test.tsx +++ b/src/views/batch/BatchPage.test.tsx @@ -1,17 +1,12 @@ -import { TezosToolkit } from "@taquito/taquito"; import { makeAccountOperations } from "../../components/sendForm/types"; -import { mockImplicitAccount, mockImplicitAddress } from "../../mocks/factories"; +import { mockImplicitAccount, mockTezOperation } from "../../mocks/factories"; import { dispatchMockAccounts, mockEstimatedFee } from "../../mocks/helpers"; import { act, fireEvent, render, screen, waitFor, within } from "../../mocks/testUtils"; -import { TezosNetwork } from "../../types/TezosNetwork"; import { useGetSecretKey } from "../../utils/hooks/accountUtils"; import store from "../../utils/redux/store"; -import { estimateAndUpdateBatch } from "../../utils/redux/thunks/estimateAndUpdateBatch"; -import { executeOperations, makeToolkit } from "../../utils/tezos"; +import { executeOperations } from "../../utils/tezos"; import BatchPage from "./BatchPage"; - -// These tests might take long in the CI -jest.setTimeout(10000); +import { assetsActions } from "../../utils/redux/slices/assetsSlice"; jest.mock("../../utils/hooks/accountUtils"); jest.mock("../../utils/tezos"); @@ -27,166 +22,102 @@ beforeEach(() => { }); describe("", () => { - describe("Given no batch has beed added", () => { - it("a message 'no batches are present' is displayed", () => { + it("shows empty batch message by default", () => { + render(); + + expect(screen.getByText(/your batch is currently empty/i)).toBeInTheDocument(); + }); + + describe("pending", () => { + it("shows 0 when no batches exist", () => { render(); expect(screen.getByText(/0 pending/i)).toBeInTheDocument(); - expect(screen.getByText(/your batch is currently empty/i)).toBeInTheDocument(); }); - }); - describe("Given batches have been added", () => { - const MOCK_TEZOS_TOOLKIT = {}; - beforeEach(async () => { - await store.dispatch( - estimateAndUpdateBatch( + it("shows the number of different pending batches", () => { + store.dispatch( + assetsActions.addToBatch( makeAccountOperations(mockImplicitAccount(1), mockImplicitAccount(1), [ - { - type: "tez", - recipient: mockImplicitAddress(1), - amount: "1000000", - }, - { - type: "tez", - recipient: mockImplicitAddress(2), - amount: "2000000", - }, - { - type: "tez", - recipient: mockImplicitAddress(3), - amount: "3000000", - }, - ]), - TezosNetwork.MAINNET + mockTezOperation(0), + mockTezOperation(0), + ]) ) ); + render(); + expect(screen.getByText(/1 pending/i)).toBeInTheDocument(); + act(() => { + store.dispatch( + assetsActions.addToBatch( + makeAccountOperations(mockImplicitAccount(2), mockImplicitAccount(2), [ + mockTezOperation(0), + mockTezOperation(0), + ]) + ) + ); + }); + expect(screen.getByText(/2 pending/i)).toBeInTheDocument(); + }); + }); + + it("renders all the batches", () => { + store.dispatch( + assetsActions.addToBatch( + makeAccountOperations(mockImplicitAccount(1), mockImplicitAccount(1), [ + mockTezOperation(0), + mockTezOperation(0), + ]) + ) + ); + store.dispatch( + assetsActions.addToBatch( + makeAccountOperations(mockImplicitAccount(2), mockImplicitAccount(2), [ + mockTezOperation(0), + mockTezOperation(0), + ]) + ) + ); + + render(); + + expect(screen.getAllByTestId(/batch-table/i)).toHaveLength(2); + }); + + describe("action buttons", () => { + beforeEach(() => { store.dispatch( - estimateAndUpdateBatch( + assetsActions.addToBatch( makeAccountOperations(mockImplicitAccount(2), mockImplicitAccount(2), [ - { - type: "tez", - recipient: mockImplicitAddress(9), - amount: "4", - }, - { - type: "tez", - recipient: mockImplicitAddress(4), - amount: "5", - }, - { - type: "tez", - recipient: mockImplicitAddress(5), - amount: "6", - }, - ]), - TezosNetwork.MAINNET + mockTezOperation(0), + mockTezOperation(0), + ]) ) ); - jest.mocked(makeToolkit).mockResolvedValue(MOCK_TEZOS_TOOLKIT as TezosToolkit); }); - test("a batch can be deleted by clicking the delete button and confirming", () => { + test("delete batch", () => { render(); - const firstBatch = screen.getAllByTestId(/batch-table/i)[0]; - const { getByLabelText } = within(firstBatch); - const deleteBtn = getByLabelText(/Delete Batch/i); - fireEvent.click(deleteBtn); - expect(screen.getByText(/Are you sure/i)).toBeTruthy(); - const confirmBtn = screen.getByRole("button", { name: /confirm/i }); - fireEvent.click(confirmBtn); - expect(screen.getAllByTestId(/batch-table/i)).toHaveLength(1); - }); - const clickSubmitOnFirstBatch = () => { - const batchTable = screen.getAllByTestId(/batch-table/i)[0]; - - const { getByRole } = within(batchTable); - const submitBatchBtn = getByRole("button", { name: /submit batch/i }); - fireEvent.click(submitBatchBtn); - }; + const deleteButton = screen.getByTestId("remove-batch"); + fireEvent.click(deleteButton); + expect(screen.getByText(/Are you sure/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.queryByTestId(/batch-table/i)).not.toBeInTheDocument(); + }); - test("clicking submit batch button displays 'preview' form", () => { + // TODO: write a complete test after migration to dynamic modal + test("submit batch", async () => { render(); - act(() => { - clickSubmitOnFirstBatch(); - }); + const submitBatchButton = screen.getByRole("button", { name: /confirm batch/i }); + fireEvent.click(submitBatchButton); const modal = screen.getByRole("dialog"); - const { getByText, getByLabelText } = within(modal); - expect(getByText(/transaction details/i)).toBeInTheDocument(); - - const txsAmount = getByLabelText(/transactions-amount/i); - expect(txsAmount).toHaveTextContent("3"); - - expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument(); - }); - - test("estimating and submiting a batch executes the batch of transactions and empties it after successful submition", async () => { mockEstimatedFee(10); - render(); - act(() => { - clickSubmitOnFirstBatch(); - }); - - expect( - screen.getByTestId("batch-table-" + mockImplicitAccount(2).address.pkh) - ).toBeInTheDocument(); - expect( - screen.getByTestId("batch-table-" + mockImplicitAccount(1).address.pkh) - ).toBeInTheDocument(); - - const previewBtn = screen.getByRole("button", { name: /preview/i }); - fireEvent.click(previewBtn); - - const passwordInput = await screen.findByLabelText(/password/i); - fireEvent.change(passwordInput, { target: { value: "mockPass" } }); - - const submit = screen.getByRole("button", { - name: /submit transaction/i, - }); + fireEvent.click(within(modal).getByRole("button", { name: "Preview" })); await waitFor(() => { - expect(submit).toBeEnabled(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); }); - fireEvent.click(submit); - - await waitFor(() => { - expect(screen.getByText(/Operation Submitted/i)).toBeInTheDocument(); - }); - - expect(screen.getByTestId(/tzkt-link/i)).toHaveProperty( - "href", - "https://mainnet.tzkt.io/foo" - ); - - expect(jest.mocked(executeOperations)).toHaveBeenCalledWith( - makeAccountOperations(mockImplicitAccount(1), mockImplicitAccount(1), [ - { - amount: "1000000", - recipient: { pkh: "tz1UZFB9kGauB6F5c2gfJo4hVcvrD8MeJ3Vf", type: "implicit" }, - type: "tez", - }, - { - amount: "2000000", - recipient: { pkh: "tz1ikfEcj3LmsmxpcC1RMZNzBHbEmybCc43D", type: "implicit" }, - type: "tez", - }, - { - amount: "3000000", - recipient: { pkh: "tz1g7Vk9dxDALJUp4w1UTnC41ssvRa7Q4XyS", type: "implicit" }, - type: "tez", - }, - ]), - MOCK_TEZOS_TOOLKIT - ); - - expect( - screen.getByTestId("batch-table-" + mockImplicitAccount(2).address.pkh) - ).toBeInTheDocument(); - expect( - screen.queryByTestId("batch-table-" + mockImplicitAccount(1).address.pkh) - ).not.toBeInTheDocument(); }); }); }); diff --git a/src/views/batch/BatchView.test.tsx b/src/views/batch/BatchView.test.tsx index bb22eb7eb..ea998ac5e 100644 --- a/src/views/batch/BatchView.test.tsx +++ b/src/views/batch/BatchView.test.tsx @@ -1,11 +1,56 @@ +import { makeAccountOperations } from "../../components/sendForm/types"; +import { mockImplicitAccount, mockTezOperation } from "../../mocks/factories"; +import { render, screen, within } from "../../mocks/testUtils"; import { ghostFA2, ghostTezzard } from "../../mocks/tokens"; -import { tokenTitle } from "./BatchView"; +import { Operation } from "../../types/Operation"; +import { BatchView, tokenTitle } from "./BatchView"; -describe("", () => {}); +describe("", () => { + test("header", () => { + const operations = makeAccountOperations(mockImplicitAccount(0), mockImplicitAccount(0), [ + mockTezOperation(0), + ]); + render(); + const header = screen.getByTestId("header"); + expect(header).toBeInTheDocument(); + expect(within(header).getByTestId("right-header")).toBeInTheDocument(); + }); + + test("body", () => { + const operations = makeAccountOperations(mockImplicitAccount(0), mockImplicitAccount(0), [ + mockTezOperation(0), + mockTezOperation(0), + mockTezOperation(0), + ]); + render(); + expect(screen.getAllByTestId("operation").length).toEqual(3); + }); + + describe("footer", () => { + it("is hidden until there are > 9 operations", () => { + const operations = makeAccountOperations(mockImplicitAccount(0), mockImplicitAccount(0), [ + mockTezOperation(0), + ]); + render(); + expect(screen.queryByTestId("footer")).not.toBeInTheDocument(); + }); + + it("shows up when there are too many operations", () => { + const ops: Operation[] = []; + for (let i = 0; i < 10; i++) { + ops.push(mockTezOperation(i)); + } + const operations = makeAccountOperations(mockImplicitAccount(0), mockImplicitAccount(0), ops); + render(); + const footer = screen.getByTestId("footer"); + expect(within(footer).getByTestId("right-header")).toBeInTheDocument(); + }); + }); +}); describe("tokenTitle", () => { it("returns raw amount if token is missing", () => { - expect(tokenTitle(undefined, "1000000")).toBe("1000000"); + expect(tokenTitle(undefined, "1000000")).toBe("1000000 Unknown Token"); }); it("doesn't return symbol if token name is absent", () => { diff --git a/src/views/batch/BatchView.tsx b/src/views/batch/BatchView.tsx index 762fb8408..5ea2b5e92 100644 --- a/src/views/batch/BatchView.tsx +++ b/src/views/batch/BatchView.tsx @@ -1,61 +1,48 @@ -import { - AspectRatio, - Box, - Button, - Divider, - Flex, - Heading, - IconButton, - Link, - Text, - Tooltip, - Image, -} from "@chakra-ui/react"; +import { Box, Button, Divider, Flex, IconButton, Text } from "@chakra-ui/react"; import React, { useContext } from "react"; import { AccountOperations } from "../../components/sendForm/types"; import { Operation } from "../../types/Operation"; -import { prettyTezAmount } from "../../utils/format"; -import { - useClearBatch, - useRemoveBatchItem, - useSelectedNetwork, -} from "../../utils/hooks/assetsHooks"; +import { useClearBatch, useRemoveBatchItem } from "../../utils/hooks/assetsHooks"; import { AccountSmallTile } from "../../components/AccountSelector/AccountSmallTile"; import colors from "../../style/colors"; import pluralize from "pluralize"; import { headerText } from "../../components/SendFlow/SignPageHeader"; import Trash from "../../assets/icons/Trash"; import { nanoid } from "nanoid"; -import AddressPill from "../../components/AddressPill/AddressPill"; import { TEZ } from "../../utils/tezos"; -import { useGetToken } from "../../utils/hooks/tokensHooks"; -import { - Token, - thumbnailUri, - tokenName, - tokenNameSafe, - tokenPrettyAmount, - tokenSymbol, - tokenUri, -} from "../../types/Token"; -import { getIPFSurl } from "../../utils/token/nftUtils"; +import { Token, tokenName, tokenPrettyAmount, tokenSymbol } from "../../types/Token"; import { compact } from "lodash"; import { DynamicModalContext } from "../../components/DynamicModal"; import { ConfirmationModal } from "../../components/ConfirmationModal"; import { Account } from "../../types/Account"; +import { OperationView } from "./OperationView"; +import { OperationRecipient } from "./OperationRecipient"; +import { useSendFormModal } from "../home/useSendFormModal"; -const RightHeader = ({ - operations: { type: operationsType, sender, operations }, -}: { - operations: AccountOperations; -}) => { +const RightHeader = ({ operations: accountOperations }: { operations: AccountOperations }) => { + const { type: operationsType, sender, operations } = accountOperations; const { openWith } = useContext(DynamicModalContext); + + const { onOpen: openSendForm, modalElement: sendFormModalEl } = useSendFormModal(); + return ( - + {pluralize("transaction", operations.length, true)} - } + data-testid="remove-batch" /> + {sendFormModalEl} ); }; @@ -100,118 +89,16 @@ const ClearBatchConfirmationModal = ({ sender }: { sender: Account }) => { }; export const tokenTitle = (token: Token | undefined, amount: string) => { - const name = token ? tokenName(token) : undefined; - - const prettyAmount = token ? tokenPrettyAmount(amount, token, { showSymbol: false }) : amount; - - // don't show the symbol if the token name is present - const symbol = !name && token ? tokenSymbol(token) : undefined; - return compact([prettyAmount, symbol, name]).join(" "); -}; - -export const OperationView = ({ operation }: { operation: Operation }) => { - const getToken = useGetToken(); - const network = useSelectedNetwork(); - - switch (operation.type) { - case "tez": - return ( - - {prettyTezAmount(operation.amount)} - - ); - case "fa1.2": - case "fa2": { - const token = getToken(operation.contract.pkh, operation.tokenId); - if (token?.type === "nft") { - return ( - - {Number(operation.amount) > 1 && ( - <> - - x{operation.amount} - -   - - )} - - - - - } - > - {tokenNameSafe(token)} - - - - ); - } - - return ( - - - - {tokenTitle(token, operation.amount)} - - - - ); - } - case "delegation": - return ( - - Delegate - - ); - case "undelegation": - return ( - - End Delegation - - ); - case "contract_origination": - case "contract_call": - throw new Error(`${operation.type} is not suported yet`); + if (!token) { + return `${amount} Unknown Token`; } -}; - -const OperationRecipient = ({ operation }: { operation: Operation }) => { - let address; + const name = tokenName(token); + const prettyAmount = tokenPrettyAmount(amount, token, { showSymbol: false }); - switch (operation.type) { - case "undelegation": - case "contract_origination": - address = undefined; - break; - case "tez": - case "fa1.2": - case "fa2": - case "delegation": - address = operation.recipient; - break; + // don't show the symbol if the token name is present + const symbol = name ? undefined : tokenSymbol(token); - case "contract_call": - address = operation.contract; - break; - } - if (!address) { - return N/A; - } - return ( - <> - - To: - - - - ); + return compact([prettyAmount, symbol, name]).join(" "); }; export const BatchView: React.FC<{ @@ -220,6 +107,8 @@ export const BatchView: React.FC<{ const { operations, sender } = accountOperations; const removeItem = useRemoveBatchItem(); + const showFooter = operations.length > 9; + return ( @@ -238,10 +128,10 @@ export const BatchView: React.FC<{ px="30px" py="20px" flexDirection="column" - borderBottomRadius={operations.length > 9 ? 0 : "8px"} + borderBottomRadius={showFooter ? 0 : "8px"} > {operations.map((operation, index) => ( - + @@ -273,13 +163,14 @@ export const BatchView: React.FC<{ ))} - {operations.length > 9 && ( + {showFooter && ( diff --git a/src/views/batch/OperationRecipient.test.tsx b/src/views/batch/OperationRecipient.test.tsx new file mode 100644 index 000000000..fc21a3f72 --- /dev/null +++ b/src/views/batch/OperationRecipient.test.tsx @@ -0,0 +1,57 @@ +import { + mockDelegationOperation, + mockFA12Operation, + mockFA2Operation, + mockImplicitAddress, + mockNftOperation, + mockTezOperation, + mockUndelegationOperation, +} from "../../mocks/factories"; +import { render, screen } from "../../mocks/testUtils"; +import { OperationRecipient } from "./OperationRecipient"; + +describe("", () => { + test("undelegation", () => { + render(); + expect(screen.getByTestId("recipient")).toHaveTextContent("N/A"); + }); + + test("contract_origination", () => { + render( + + ); + expect(screen.getByTestId("recipient")).toHaveTextContent("N/A"); + }); + + test("delegation", () => { + render(); + expect(screen.getByTestId("recipient")).toHaveTextContent("tz1UZ...eJ3Vf"); + }); + + test("tez", () => { + render(); + expect(screen.getByTestId("recipient")).toHaveTextContent("tz1ik...Cc43D"); + }); + + test("fa1.2", () => { + render(); + expect(screen.getByTestId("recipient")).toHaveTextContent("tz1ik...Cc43D"); + }); + + test("fa2", () => { + render(); + expect(screen.getByTestId("recipient")).toHaveTextContent("tz1ik...Cc43D"); + }); + + test("nft", () => { + render(); + expect(screen.getByTestId("recipient")).toHaveTextContent("tz1ik...Cc43D"); + }); +}); diff --git a/src/views/batch/OperationRecipient.tsx b/src/views/batch/OperationRecipient.tsx new file mode 100644 index 000000000..5120a51a5 --- /dev/null +++ b/src/views/batch/OperationRecipient.tsx @@ -0,0 +1,41 @@ +import { Text } from "@chakra-ui/react"; +import { Operation } from "../../types/Operation"; +import colors from "../../style/colors"; +import AddressPill from "../../components/AddressPill/AddressPill"; + +// TODO: add tests +export const OperationRecipient = ({ operation }: { operation: Operation }) => { + let address; + + switch (operation.type) { + case "undelegation": + case "contract_origination": + address = undefined; + break; + case "tez": + case "fa1.2": + case "fa2": + case "delegation": + address = operation.recipient; + break; + + case "contract_call": + address = operation.contract; // TODO: consider using recipient for the contract_call instead of contract + break; + } + if (!address) { + return ( + + N/A + + ); + } + return ( + <> + + To: + + + + ); +}; diff --git a/src/views/batch/OperationView.test.tsx b/src/views/batch/OperationView.test.tsx new file mode 100644 index 000000000..3123546ca --- /dev/null +++ b/src/views/batch/OperationView.test.tsx @@ -0,0 +1,108 @@ +import { hedgehoge } from "../../mocks/fa12Tokens"; +import { uUSD } from "../../mocks/fa2Tokens"; +import { + mockDelegationOperation, + mockFA12Operation, + mockFA2Operation, + mockImplicitAddress, + mockTezOperation, + mockUndelegationOperation, +} from "../../mocks/factories"; +import { ghostnetThezard } from "../../mocks/nftTokens"; +import { render, screen } from "../../mocks/testUtils"; +import { parseContractPkh } from "../../types/Address"; +import { FA12Transfer, FA2Transfer } from "../../types/Operation"; +import { TezosNetwork } from "../../types/TezosNetwork"; +import { tokensActions } from "../../utils/redux/slices/tokensSlice"; +import store from "../../utils/redux/store"; +import { TEZ } from "../../utils/tezos"; +import { OperationView } from "./OperationView"; + +describe("", () => { + test("tez transfer", () => { + render(); + expect(screen.getByRole("heading", { name: `0.000001 ${TEZ}` })).toBeInTheDocument(); + }); + + test("delegation", () => { + render(); + expect(screen.getByRole("heading", { name: "Delegate" })).toBeInTheDocument(); + }); + + test("undelegation", () => { + render(); + expect(screen.getByRole("heading", { name: "End Delegation" })).toBeInTheDocument(); + }); + + describe("tokens", () => { + test("unknown token", () => { + render(); + expect(screen.getByRole("heading", { name: "1234 Unknown Token" })).toBeInTheDocument(); + expect(screen.getByTestId("link")).not.toHaveAttribute("href"); + }); + + test("fa1.2", () => { + const token = hedgehoge(mockImplicitAddress(0)); + store.dispatch( + tokensActions.addTokens({ network: TezosNetwork.MAINNET, tokens: [token.token] }) + ); + const operation: FA12Transfer = { + ...mockFA12Operation(2), + contract: parseContractPkh(token.token.contract.address as string), + amount: "1234", + }; + render(); + expect(screen.getByRole("heading", { name: "0.001234 Hedgehoge" })).toBeInTheDocument(); + expect(screen.getByTestId("link")).toHaveAttribute( + "href", + "https://mainnet.tzkt.io/KT1G1cCRNBgQ48mVDjopHjEmTN5Sbtar8nn9/tokens/0" + ); + }); + + test("fa2", () => { + const token = uUSD(mockImplicitAddress(0)); + token.token.standard = "fa2"; + token.token.tokenId = "5"; + store.dispatch( + tokensActions.addTokens({ network: TezosNetwork.MAINNET, tokens: [token.token] }) + ); + const operation: FA2Transfer = { + ...mockFA2Operation(2), + contract: parseContractPkh(token.token.contract.address as string), + tokenId: token.token.tokenId as string, + amount: "1234", + }; + render(); + expect( + screen.getByRole("heading", { name: "0.000000001234 youves uUSD" }) + ).toBeInTheDocument(); + expect(screen.getByTestId("link")).toHaveAttribute( + "href", + "https://mainnet.tzkt.io/KT1QTcAXeefhJ3iXLurRt81WRKdv7YqyYFmo/tokens/5" + ); + }); + + test("nft", () => { + const token = ghostnetThezard; + token.token.standard = "fa2"; + token.token.tokenId = "15"; + store.dispatch( + tokensActions.addTokens({ network: TezosNetwork.MAINNET, tokens: [token.token] }) + ); + const operation: FA2Transfer = { + ...mockFA2Operation(2), + contract: parseContractPkh(token.token.contract.address as string), + tokenId: token.token.tokenId as string, + amount: "12345", + }; + render(); + expect(screen.getByRole("heading", { name: "x12345" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Tezzardz #24" })).toBeInTheDocument(); + + expect(screen.getByTestId("link")).toHaveAttribute( + "href", + "https://mainnet.tzkt.io/KT1GVhG7dQNjPAt4FNBNmc9P9zpiQex4Mxob/tokens/15" + ); + }); + }); +}); diff --git a/src/views/batch/OperationView.tsx b/src/views/batch/OperationView.tsx new file mode 100644 index 000000000..886a53a9e --- /dev/null +++ b/src/views/batch/OperationView.tsx @@ -0,0 +1,84 @@ +import { AspectRatio, Flex, Heading, Image, Link, Tooltip } from "@chakra-ui/react"; +import { Operation } from "../../types/Operation"; +import { useSelectedNetwork } from "../../utils/hooks/assetsHooks"; +import { useGetToken } from "../../utils/hooks/tokensHooks"; +import { prettyTezAmount } from "../../utils/format"; +import colors from "../../style/colors"; +import { getIPFSurl } from "../../utils/token/nftUtils"; +import { thumbnailUri, tokenNameSafe, tokenUri } from "../../types/Token"; +import { tokenTitle } from "./BatchView"; + +export const OperationView = ({ operation }: { operation: Operation }) => { + const getToken = useGetToken(); + const network = useSelectedNetwork(); + + switch (operation.type) { + case "tez": + return ( + + {prettyTezAmount(operation.amount)} + + ); + case "fa1.2": + case "fa2": { + const token = getToken(operation.contract.pkh, operation.tokenId); + if (token?.type === "nft") { + return ( + + {Number(operation.amount) > 1 && ( + <> + + x{operation.amount} + +   + + )} + + + + + } + > + + {tokenNameSafe(token)} + + + + + ); + } + + return ( + + + + {tokenTitle(token, operation.amount)} + + + + ); + } + case "delegation": + return ( + + Delegate + + ); + case "undelegation": + return ( + + End Delegation + + ); + case "contract_origination": + case "contract_call": + throw new Error(`${operation.type} is not suported yet`); + } +};