diff --git a/CHANGELOG.md b/CHANGELOG.md index 5929542fe..bdce75f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +- [#112](https://github.com/alleslabs/celatone-frontend/pull/112) Refactor past transactions page, support new messages including Migration, Instantiate2, Update Admin, Clear Admin, and change filter actions to dropdown selection. Add redo modal for instantiate2 and create component for tokens used in past tx page. - [#113](https://github.com/alleslabs/celatone-frontend/pull/113) Update admin page ui and wireup - [#98](https://github.com/alleslabs/celatone-frontend/pull/98) Add migrate, update admin, clear admin menu on contract list and detail -- [#121](https://github.com/alleslabs/celatone-frontend/pull/121) Fix code snippet for query axios - [#102](https://github.com/alleslabs/celatone-frontend/pull/102) Add quick menu in overview and add highlighted in left sidebar - [#125](https://github.com/alleslabs/celatone-frontend/pull/125) Add connect wallet alert in instantiate page - [#126](https://github.com/alleslabs/celatone-frontend/pull/126) Add port id copier for IBC port id @@ -110,6 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug fixes +- [#121](https://github.com/alleslabs/celatone-frontend/pull/121) Fix code snippet for query axios - [#129](https://github.com/alleslabs/celatone-frontend/pull/129) Fix wallet disconnection on network query change - [#124](https://github.com/alleslabs/celatone-frontend/pull/124) Fix public project query, display project image in contract details page - [#125](https://github.com/alleslabs/celatone-frontend/pull/125) Fix incorrect CosmJS execute snippet diff --git a/src/lib/components/Copier.tsx b/src/lib/components/Copier.tsx index 424c6fe96..26d982221 100644 --- a/src/lib/components/Copier.tsx +++ b/src/lib/components/Copier.tsx @@ -8,6 +8,7 @@ interface CopierProps { ml?: string; className?: string; display?: LayoutProps["display"]; + copyLabel?: string; } export const Copier = ({ @@ -15,6 +16,7 @@ export const Copier = ({ ml = "8px", className, display = "flex", + copyLabel = "Copied!", }: CopierProps) => { const { onCopy, hasCopied, setValue } = useClipboard(value); @@ -24,7 +26,7 @@ export const Copier = ({ --; - return <>{formatBalanceWithDenom(coin)}; + return <>{formatBalanceWithDenom({ coin, precision: 6 })}; }; diff --git a/src/lib/components/action-msg/ActionMessages.tsx b/src/lib/components/action-msg/ActionMessages.tsx new file mode 100644 index 000000000..75ebcc3a3 --- /dev/null +++ b/src/lib/components/action-msg/ActionMessages.tsx @@ -0,0 +1,38 @@ +import type { AllTransaction, PastTransaction } from "lib/types"; +import { ActionMsgType } from "lib/types"; +import { extractMsgType } from "lib/utils"; + +import { MultipleActionsMsg } from "./MultipleActionsMsg"; +import { SingleActionMsg } from "./SingleActionMsg"; +import { SingleMsg } from "./SingleMsg"; + +interface RenderActionMessagesProps { + transaction: AllTransaction | PastTransaction; +} + +export const RenderActionMessages = ({ + transaction, +}: RenderActionMessagesProps) => { + if (transaction.actionMsgType === ActionMsgType.SINGLE_ACTION_MSG) { + return ( + + ); + } + if (transaction.actionMsgType === ActionMsgType.MULTIPLE_ACTION_MSG) { + return ; + } + return ( + + ); +}; diff --git a/src/lib/components/action-msg/SingleMsg.tsx b/src/lib/components/action-msg/SingleMsg.tsx index 6c0416f39..8a76f1923 100644 --- a/src/lib/components/action-msg/SingleMsg.tsx +++ b/src/lib/components/action-msg/SingleMsg.tsx @@ -1,8 +1,13 @@ -import { Tag, Text, Box, Flex } from "@chakra-ui/react"; +import { InfoIcon } from "@chakra-ui/icons"; +import { Tag, Text, Box, Flex, Tooltip } from "@chakra-ui/react"; +import type { Coin } from "@cosmjs/stargate"; import { snakeCase } from "snake-case"; +import { Copier } from "../Copier"; import type { LinkType } from "lib/components/ExplorerLink"; import { ExplorerLink } from "lib/components/ExplorerLink"; +import type { Option } from "lib/types"; +import { formatBalanceWithDenom } from "lib/utils"; interface LinkElement { type: LinkType; @@ -10,11 +15,17 @@ interface LinkElement { copyValue?: string; } +interface Token { + id: string; + amount: string; + symbol: Option; + precision: Option; +} export interface SingleMsgProps { type: string; text1?: string; - bolds?: Array; - tags?: Array; + tokens?: Token[]; + tags?: string[]; length?: number; text2?: string; link1?: LinkElement; @@ -25,7 +36,7 @@ export interface SingleMsgProps { export const SingleMsg = ({ type, text1, - bolds, + tokens, tags, length, text2, @@ -37,28 +48,49 @@ export const SingleMsg = ({ return ( {type} {text1} - {bolds && ( - - {bolds.map((bold: string, index: number) => ( - - {bold} - - ))} - - )} + {tokens?.map((token: Token, index: number) => ( + + + {formatBalanceWithDenom({ + coin: { + denom: token.id, + amount: token.amount, + } as Coin, + symbol: token.symbol, + precision: token.precision, + })} + + + + + + + + + ))} {/* Tags */} - {tags && - tags.map((tag: string, index: number) => ( - - {snakeCase(tag)} - - ))} + {tags?.map((tag: string, index: number) => ( + + {snakeCase(tag) || tag} + + ))} {/* Tag left over */} {tags && length && length - tags.length > 0 && ( +{length - tags.length} )} {/* Length */} - {!tags && length && {length}} + {!tags && length && {length}} {/* Text2 */} {text2} {/* Link */} diff --git a/src/lib/components/button/RedoButton.tsx b/src/lib/components/button/RedoButton.tsx new file mode 100644 index 000000000..2d1adf455 --- /dev/null +++ b/src/lib/components/button/RedoButton.tsx @@ -0,0 +1,35 @@ +import { Button } from "@chakra-ui/react"; +import { useWallet } from "@cosmos-kit/react"; +import { BsArrowCounterclockwise } from "react-icons/bs"; + +import { useRedo } from "lib/pages/past-txs/hooks/useRedo"; +import type { Message } from "lib/types"; +import { extractMsgType } from "lib/utils"; + +interface RedoButtonProps { + message: Message; +} + +export const RedoButton = ({ message }: RedoButtonProps) => { + const onClickRedo = useRedo(); + const { currentChainName } = useWallet(); + + return ( + + ); +}; diff --git a/src/lib/components/button/ResendButton.tsx b/src/lib/components/button/ResendButton.tsx new file mode 100644 index 000000000..749c10252 --- /dev/null +++ b/src/lib/components/button/ResendButton.tsx @@ -0,0 +1,32 @@ +import { Button } from "@chakra-ui/react"; +import { useState } from "react"; + +import { FailedModal } from "lib/pages/instantiate/component"; +import { useResend } from "lib/pages/past-txs/hooks/useResend"; +import type { Message } from "lib/types"; + +interface ResendButtonProps { + messages: Message[]; +} +export const ResendButton = ({ messages }: ResendButtonProps) => { + const onClickResend = useResend(); + const [error, setError] = useState(""); + const [isButtonLoading, setIsButtonLoading] = useState(false); + + return ( + <> + + {error && setError("")} />} + + ); +}; diff --git a/src/lib/components/forms/ListSelection.tsx b/src/lib/components/forms/ListSelection.tsx index 0cc78c850..c790f04df 100644 --- a/src/lib/components/forms/ListSelection.tsx +++ b/src/lib/components/forms/ListSelection.tsx @@ -21,8 +21,7 @@ import { MdCheck, MdClose, MdAdd } from "react-icons/md"; import { CreateNewList } from "lib/components/modal/list"; import { useContractStore, useUserKey } from "lib/hooks"; import type { LVPair } from "lib/types"; -import { formatSlugName } from "lib/utils"; -import mergeRefs from "lib/utils/mergeRefs"; +import { formatSlugName, mergeRefs } from "lib/utils"; export interface ListSelectionProps extends InputProps { placeholder?: string; diff --git a/src/lib/components/forms/TagSelection.tsx b/src/lib/components/forms/TagSelection.tsx index c68cb2268..5cab02676 100644 --- a/src/lib/components/forms/TagSelection.tsx +++ b/src/lib/components/forms/TagSelection.tsx @@ -20,7 +20,7 @@ import { useEffect, useState, useRef, forwardRef } from "react"; import { MdCheckCircle, MdClose } from "react-icons/md"; import { useContractStore, useUserKey } from "lib/hooks"; -import mergeRefs from "lib/utils/mergeRefs"; +import { mergeRefs } from "lib/utils"; export interface TagSelectionProps extends InputProps { placeholder?: string; diff --git a/src/lib/components/modal/RedoModal.tsx b/src/lib/components/modal/RedoModal.tsx new file mode 100644 index 000000000..047e8cdbe --- /dev/null +++ b/src/lib/components/modal/RedoModal.tsx @@ -0,0 +1,107 @@ +import { + Modal, + ModalHeader, + Flex, + Icon, + Text, + ModalOverlay, + ModalContent, + ModalCloseButton, + useDisclosure, + ModalBody, + Button, + Heading, + ModalFooter, +} from "@chakra-ui/react"; +import { useWallet } from "@cosmos-kit/react"; +import { BsArrowCounterclockwise } from "react-icons/bs"; +import { MdReplay } from "react-icons/md"; + +import { useRedo } from "lib/pages/past-txs/hooks/useRedo"; +import type { Message } from "lib/types"; +import { extractMsgType } from "lib/utils"; + +interface RedoModalProps { + message: Message; +} + +export const RedoModal = ({ message }: RedoModalProps) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const onClickRedo = useRedo(); + const { currentChainName } = useWallet(); + + return ( + <> + + + + + + + + + + + Redo Instantiate + + + + + + + + + This contract was instantiated through{" "} + + ‘MsgInstantiateContract2’ + + , which our app does not currently support.

You + can instead instantiate the contract using{" "} + + ‘MsgInstantiateContract’ + {" "} + for the time being +
+
+
+
+ + + + + + +
+
+ + ); +}; diff --git a/src/lib/components/table/AccordionTx.tsx b/src/lib/components/table/AccordionTx.tsx new file mode 100644 index 000000000..356c1cbdd --- /dev/null +++ b/src/lib/components/table/AccordionTx.tsx @@ -0,0 +1,69 @@ +import { SlideFade } from "@chakra-ui/react"; +import { useState } from "react"; + +import { SingleActionMsg } from "../action-msg/SingleActionMsg"; +import { AccordionStepperItem } from "lib/components/AccordionStepperItem"; +import { RedoButton } from "lib/components/button/RedoButton"; +import { ResendButton } from "lib/components/button/ResendButton"; +import type { Message } from "lib/types"; +import { extractMsgType } from "lib/utils"; + +import { TableRow } from "./tableComponents"; + +interface AccordionTxProps { + message: Message; + allowFurtherAction: boolean; +} + +interface RenderButtonProps { + message: Message; +} + +const RenderButton = ({ message }: RenderButtonProps) => { + if ( + extractMsgType(message.type) === "MsgExecuteContract" || + extractMsgType(message.type) === "MsgInstantiateContract" + ) + return ; + + if (extractMsgType(message.type) === "MsgSend") + return ; + + return null; +}; + +export const AccordionTx = ({ + message, + allowFurtherAction, +}: AccordionTxProps) => { + const [showButton, setShowButton] = useState(false); + return ( + setShowButton(true)} + onMouseLeave={() => setShowButton(false)} + > + + + {allowFurtherAction && ( + + + + )} + + ); +}; diff --git a/src/lib/components/table/MsgDetail.tsx b/src/lib/components/table/MsgDetail.tsx deleted file mode 100644 index b5746023d..000000000 --- a/src/lib/components/table/MsgDetail.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { SingleActionMsg } from "../action-msg/SingleActionMsg"; -import { AccordionStepperItem } from "lib/components/AccordionStepperItem"; -import type { Message } from "lib/types"; -import { extractMsgType } from "lib/utils"; - -import { TableRow } from "./tableComponents"; - -interface MsgDetailProps { - message: Message; -} - -export const MsgDetail = ({ message }: MsgDetailProps) => ( - - - - -); diff --git a/src/lib/hooks/useSingleMessageProps.ts b/src/lib/hooks/useSingleMessageProps.ts index 89e31ff40..6c15eb4e9 100644 --- a/src/lib/hooks/useSingleMessageProps.ts +++ b/src/lib/hooks/useSingleMessageProps.ts @@ -1,8 +1,10 @@ import { useWallet } from "@cosmos-kit/react"; +import router from "next/router"; import type { SingleMsgProps } from "lib/components/action-msg/SingleMsg"; import type { LinkType } from "lib/components/ExplorerLink"; import { getAddressTypeByLength, useContractStore } from "lib/hooks"; +import { useAssetInfos } from "lib/services/assetService"; import type { ContractLocalInfo } from "lib/stores/contract"; import type { DetailExecute, @@ -14,14 +16,17 @@ import type { Option, DetailMigrate, DetailClearAdmin, + ContractAddr, + AssetInfo, } from "lib/types"; -import { formatBalanceWithDenomList } from "lib/utils"; +import { getFirstQueryParam } from "lib/utils"; import { getExecuteMsgTags } from "lib/utils/executeTags"; /** - * Returns messages variations for MsgInstantiateContract. + * Returns messages variations for MsgInstantiateContract and MsgInstantiateContract2. * * @remarks + * Will render Instantiate2 instead of Instantiate if MsgInstantiateContract2 * More than 1 msg: Instantiate [length] contracts * Only 1 msg: Instantiate contract [name || contract address] from Code ID [code ID] * Fail with more than 1 msg: Failed to instantiate [length] contracts @@ -36,21 +41,30 @@ import { getExecuteMsgTags } from "lib/utils/executeTags"; const instantiateSingleMsgProps = ( isSuccess: boolean, messages: Message[], - getContractLocalInfo: (contractAddress: string) => Option + getContractLocalInfo: ( + contractAddress: ContractAddr + ) => Option, + isInstantiate2: boolean ) => { const detail = messages[0].detail as DetailInstantiate; - const contractLocalInfo = getContractLocalInfo(detail.contractAddress); + // TODO - revisit, instantiate detail response when query from contract transaction table doesn't contain contract addr + const contractAddress = + detail.contractAddress || + (getFirstQueryParam(router.query.contractAddress) as ContractAddr); + + const contractLocalInfo = getContractLocalInfo(contractAddress); + const type = isInstantiate2 ? "Instantiate2" : "Instantiate"; if (messages.length > 1) { return isSuccess ? { - type: "Instantiate", + type, length: messages.length, text2: "contracts", } : { type: "Failed", - text1: "to instantiate", + text1: `to ${type}`, length: messages.length, text2: "contracts", }; @@ -58,12 +72,12 @@ const instantiateSingleMsgProps = ( return isSuccess ? { - type: "Instantiate", + type, text1: "contract", link1: { type: "contract_address" as LinkType, - value: contractLocalInfo?.name || detail.contractAddress, - copyValue: detail.contractAddress, + value: contractLocalInfo?.name || contractAddress, + copyValue: contractAddress, }, text3: "from Code ID", link2: { @@ -73,7 +87,7 @@ const instantiateSingleMsgProps = ( } : { type: "Failed", - text1: "to instantiate contract from Code ID", + text1: `to ${type} contract from Code ID`, link1: { type: "code_id" as LinkType, value: detail.codeId.toString(), @@ -85,9 +99,13 @@ const instantiateSingleMsgProps = ( * Returns messages variations for MsgExecuteContract. * * @remarks - * More than 1 address: Execute [length] messages + * - More than 1 msg: + * With same contract addr: Execute [length] messages on [name \\ contract address] + * With different contract addr: Execute [length] messages * Only 1 address: Execute [msg1] [msg2] [+number] on [name || contract address] - * Fail with more than 1 msg: Failed to execute [length] messages + * Fail with more than 1 msg: + * With same contract addr: Failed to execute [length] messages on [name \\ contract address] + * With diff contract addr: Failed to execute [length] messages * Fail with 1 msg: Failed to execute message from [name || contract address] * * @param isSuccess - boolean of whether tx is succeed or not @@ -101,28 +119,55 @@ const executeSingleMsgProps = ( isSuccess: boolean, messages: Message[], singleMsg: Option, - getContractLocalInfo: (contractAddress: string) => Option + getContractLocalInfo: ( + contractAddress: ContractAddr + ) => Option ) => { const detail = messages[0].detail as DetailExecute; const contractLocalInfo = getContractLocalInfo(detail.contract); - if ( - messages.some((msg) => { - const msgDetail = msg.detail as DetailExecute; - return msgDetail.contract !== detail.contract; - }) - ) { + if (messages.length > 1) { + if ( + messages.some((msg) => { + const msgDetail = msg.detail as DetailExecute; + return msgDetail.contract !== detail.contract; + }) + ) { + return isSuccess + ? { + type: "Execute", + length: messages.length, + text2: "messages", + } + : { + type: "Failed", + // eslint-disable-next-line sonarjs/no-duplicate-string + text1: "to execute", + length: messages.length, + text2: "messages", + }; + } return isSuccess ? { type: "Execute", length: messages.length, - text2: "messages", + text2: "messages on", + link2: { + type: "contract_address" as LinkType, + value: contractLocalInfo?.name || detail.contract, + copyValue: detail.contract, + }, } : { type: "Failed", text1: "to execute", length: messages.length, - text2: "messages", + text2: "messages on", + link2: { + type: "contract_address" as LinkType, + value: contractLocalInfo?.name || detail.contract, + copyValue: detail.contract, + }, }; } @@ -152,9 +197,13 @@ const executeSingleMsgProps = ( * Returns messages variations for MsgSend. * * @remarks - * More than 1 msg: Send assets to [length] addresses + * More than 1 msg: + * With same address: Send assets to [name \\ contract address/ user address] + * With different address: Send assets to [length] addresses * Only 1 msg: Send [amount] [denom] to [contract address / user address] - * Fail with more than 1 msg: Failed to send assets to [length] addresses + * Fail with more than 1 msg: + * With same address: Failed to send assets to [name \\ contract address/ user address] + * With diff address: Failed to send assets to [length] addresses * Fail with 1 msg: Failed to send assets to [contract address / user address] * * @param isSuccess - boolean of whether tx is succeed or not @@ -166,28 +215,74 @@ const executeSingleMsgProps = ( const sendSingleMsgProps = ( isSuccess: boolean, messages: Message[], - chainName: string + chainName: string, + assetInfos: Option>, + getContractLocalInfo: ( + contractAddress: ContractAddr + ) => Option ) => { const detail = messages[0].detail as DetailSend; + const contractLocalInfo = getContractLocalInfo(detail.toAddress); + + const tokens = detail.amount.map((coin) => ({ + symbol: assetInfos?.[coin.denom]?.symbol, + amount: coin.amount, + id: coin.denom, + precision: assetInfos?.[coin.denom]?.precision, + })); if (messages.length > 1) { + if ( + messages.some((msg) => { + const msgDetail = msg.detail as DetailExecute; + return msgDetail.contract !== detail.toAddress; + }) + ) { + return isSuccess + ? { + type: "Send ", + text1: "assets to", + length: messages.length, + text2: "addresses", + } + : { + type: "Failed", + // eslint-disable-next-line sonarjs/no-duplicate-string + text1: "to send assets to", + length: messages.length, + text2: "addresses", + }; + } return isSuccess ? { - type: "Send assets to", - length: messages.length, - text: "addresses", + type: "Send", + text1: "assets to", + link2: { + type: getAddressTypeByLength( + chainName, + detail.toAddress + ) as LinkType, + value: contractLocalInfo?.name || detail.toAddress, + copyValue: detail.toAddress, + }, } : { type: "Failed", text1: "to send assets to", - length: messages.length, - text2: "addresses", + link2: { + type: "contract_address" as LinkType, + value: getAddressTypeByLength( + chainName, + detail.toAddress + ) as LinkType, + copyValue: detail.toAddress, + }, }; } return isSuccess ? { type: "Send", - bolds: formatBalanceWithDenomList(detail.amount), + tokens, text2: "to", link1: { type: getAddressTypeByLength(chainName, detail.toAddress) as LinkType, @@ -222,7 +317,9 @@ const sendSingleMsgProps = ( const migrateSingleMsgProps = ( isSuccess: boolean, messages: Message[], - getContractLocalInfo: (contractAddress: string) => Option + getContractLocalInfo: ( + contractAddress: ContractAddr + ) => Option ) => { const detail = messages[0].detail as DetailMigrate; const contractLocalInfo = getContractLocalInfo(detail.contract); @@ -270,9 +367,9 @@ const migrateSingleMsgProps = ( * * @remarks * More than 1 msg: Update [length] admins - * Only 1 msg: Update admin on [name || contract address] to [user address] + * Only 1 msg: Update admin on [name || contract address] to [user address || contract address] * Fail with more than 1 msg: Failed to update [length] admins - * Fail with 1 msg: Failed to update admin on [name || contract address] to [user address] + * Fail with 1 msg: Failed to update admin on [name || contract address] to [user address || contract address] * * @param isSuccess - boolean of whether tx is succeed or not @@ -284,10 +381,14 @@ const migrateSingleMsgProps = ( const updateAdminSingleMsgProps = ( isSuccess: boolean, messages: Message[], - getContractLocalInfo: (contractAddress: string) => Option + chainName: string, + getContractLocalInfo: ( + contractAddress: ContractAddr + ) => Option ) => { const detail = messages[0].detail as DetailUpdateAdmin; const contractLocalInfo = getContractLocalInfo(detail.contract); + const adminLocalInfo = getContractLocalInfo(detail.newAdmin as ContractAddr); if (messages.length > 1) { return isSuccess @@ -314,8 +415,8 @@ const updateAdminSingleMsgProps = ( }, text3: "to", link2: { - type: "user_address" as LinkType, - value: detail.newAdmin, + type: getAddressTypeByLength(chainName, detail.newAdmin) as LinkType, + value: adminLocalInfo?.name || detail.newAdmin, }, } : { @@ -328,8 +429,8 @@ const updateAdminSingleMsgProps = ( }, text3: "to", link2: { - type: "user_address" as LinkType, - value: detail.newAdmin, + type: getAddressTypeByLength(chainName, detail.newAdmin) as LinkType, + value: adminLocalInfo?.name || detail.newAdmin, }, }; }; @@ -352,7 +453,9 @@ const updateAdminSingleMsgProps = ( const clearAdminSingleMsgProps = ( isSuccess: boolean, messages: Message[], - getContractLocalInfo: (contractAddress: string) => Option + getContractLocalInfo: ( + contractAddress: ContractAddr + ) => Option ) => { const detail = messages[0].detail as DetailClearAdmin; const contractLocalInfo = getContractLocalInfo(detail.contract); @@ -480,6 +583,7 @@ export const useSingleActionMsgProps = ( ): SingleMsgProps => { const { currentChainName } = useWallet(); const { getContractLocalInfo } = useContractStore(); + const assetInfos = useAssetInfos(); switch (type) { case "MsgExecuteContract": @@ -490,19 +594,34 @@ export const useSingleActionMsgProps = ( getContractLocalInfo ); case "MsgSend": - return sendSingleMsgProps(isSuccess, messages, currentChainName); + return sendSingleMsgProps( + isSuccess, + messages, + currentChainName, + assetInfos, + getContractLocalInfo + ); case "MsgMigrateContract": return migrateSingleMsgProps(isSuccess, messages, getContractLocalInfo); case "MsgInstantiateContract": return instantiateSingleMsgProps( isSuccess, messages, - getContractLocalInfo + getContractLocalInfo, + false + ); + case "MsgInstantiateContract2": + return instantiateSingleMsgProps( + isSuccess, + messages, + getContractLocalInfo, + true ); case "MsgUpdateAdmin": return updateAdminSingleMsgProps( isSuccess, messages, + currentChainName, getContractLocalInfo ); case "MsgClearAdmin": diff --git a/src/lib/pages/contract-details/components/tables/transactions/TxsTableRow.tsx b/src/lib/pages/contract-details/components/tables/transactions/TxsTableRow.tsx index 33f8bf0ed..16e480d4f 100644 --- a/src/lib/pages/contract-details/components/tables/transactions/TxsTableRow.tsx +++ b/src/lib/pages/contract-details/components/tables/transactions/TxsTableRow.tsx @@ -10,44 +10,12 @@ import { import { useEffect, useState } from "react"; import { MdCheck, MdClose, MdKeyboardArrowDown } from "react-icons/md"; -import { MultipleActionsMsg } from "lib/components/action-msg/MultipleActionsMsg"; -import { SingleActionMsg } from "lib/components/action-msg/SingleActionMsg"; -import { SingleMsg } from "lib/components/action-msg/SingleMsg"; +import { RenderActionMessages } from "lib/components/action-msg/ActionMessages"; import { ExplorerLink } from "lib/components/ExplorerLink"; import { TableRow } from "lib/components/table"; -import { MsgDetail } from "lib/components/table/MsgDetail"; +import { AccordionTx } from "lib/components/table/AccordionTx"; import type { AllTransaction } from "lib/types"; -import { ActionMsgType } from "lib/types"; -import { dateFromNow, extractMsgType, formatUTC } from "lib/utils"; - -const RenderActionsMessages = ({ - transaction, -}: { - transaction: AllTransaction; -}) => { - if (transaction.actionMsgType === ActionMsgType.SINGLE_ACTION_MSG) { - return ( - - ); - } - if (transaction.actionMsgType === ActionMsgType.MULTIPLE_ACTION_MSG) { - return ; - } - return ( - - ); -}; +import { dateFromNow, formatUTC } from "lib/utils"; interface TxsTableRowProps { transaction: AllTransaction; @@ -89,7 +57,7 @@ export const TxsTableRow = ({ - + {transaction.isIbc && ( IBC @@ -133,7 +101,11 @@ export const TxsTableRow = ({ {isAccordion && ( )} diff --git a/src/lib/pages/past-txs/components/FalseState.tsx b/src/lib/pages/past-txs/components/FalseState.tsx deleted file mode 100644 index 92ca2c4d3..000000000 --- a/src/lib/pages/past-txs/components/FalseState.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Flex, Icon, Text } from "@chakra-ui/react"; -import { MdSearch, MdSearchOff } from "react-icons/md"; - -interface FalseStateProps { - icon: string; - text1: string; - text2: string; -} -export const FalseState = ({ icon, text1, text2 }: FalseStateProps) => { - return ( - - - - {text1} - - - - {text2} - - - ); -}; diff --git a/src/lib/pages/past-txs/components/FilterSelection.tsx b/src/lib/pages/past-txs/components/FilterSelection.tsx new file mode 100644 index 000000000..48a08a0cc --- /dev/null +++ b/src/lib/pages/past-txs/components/FilterSelection.tsx @@ -0,0 +1,219 @@ +import type { InputProps, LayoutProps } from "@chakra-ui/react"; +import { + FormControl, + FormHelperText, + Box, + FormLabel, + Tag, + Flex, + Input, + List, + ListItem, + Icon, + Text, + useOutsideClick, +} from "@chakra-ui/react"; +import { matchSorter } from "match-sorter"; +import { observer } from "mobx-react-lite"; +import type { CSSProperties } from "react"; +import { useState, useRef, forwardRef } from "react"; +import { MdCheck, MdClose } from "react-icons/md"; + +import { displayActionValue, mergeRefs } from "lib/utils"; + +export interface FilterSelectionProps extends InputProps { + placeholder?: string; + result: string[]; + setResult: (option: string, bool: boolean) => void; + helperText?: string; + labelBgColor?: string; + label?: string; + boxWidth?: LayoutProps["width"]; + boxHeight?: LayoutProps["height"]; +} + +const listItemProps: CSSProperties = { + borderRadius: "4px", + margin: "4px 0px", + padding: "8px", + cursor: "pointer", +}; + +const tagItemProps: CSSProperties = { + borderRadius: "24px", + cursor: "pointer", + whiteSpace: "nowrap", + alignItems: "center", + display: "flex", + textTransform: "none", + gap: "4px", + marginRight: "8px", +}; + +// TODO - Refactor this along with TagSelection +export const FilterSelection = observer( + forwardRef( + ( + { + result, + setResult, + placeholder, + helperText, + labelBgColor = "background.main", + label = "Filter by Actions", + boxWidth = "full", + boxHeight = "56px", + ...rest + }: FilterSelectionProps, + ref + ) => { + const options = [ + "isUpload", + "isInstantiate", + "isExecute", + "isSend", + "isIbc", + "isMigrate", + "isClearAdmin", + "isUpdateAdmin", + ]; + + const [partialResult, setPartialResult] = useState([]); + const [displayOptions, setDisplayOptions] = useState(false); + const inputRef = useRef(null); + const boxRef = useRef(null); + + const filterOptions = (value: string) => { + setDisplayOptions(true); + setPartialResult(value ? matchSorter(options, value) : options); + }; + + const isOptionSelected = (option: string) => + result.some((selectedOption) => selectedOption === option); + + const selectOption = (option: string) => { + if (isOptionSelected(option)) { + setResult(option, false); + } else { + setResult(option, true); + } + }; + + useOutsideClick({ + ref: boxRef, + handler: () => setDisplayOptions(false), + }); + + return ( + + + + {result.length > 0 && ( + + {[...result].reverse().map((option) => ( + selectOption(option)} + key={option} + > + + {displayActionValue(option)} + + + + ))} + + )} + + filterOptions(e.currentTarget.value)} + onFocus={() => { + setPartialResult(options); + setDisplayOptions(true); + }} + ref={mergeRefs([inputRef, ref])} + maxLength={36} + style={{ border: "0" }} + {...rest} + /> + + {label} + + + + {helperText} + + + {displayOptions && ( + + {/* option selection section */} + {partialResult.map((option) => ( + selectOption(option)} + > + + {displayActionValue(option)} + + {isOptionSelected(option) && ( + + )} + + + ))} + + )} + + + ); + } + ) +); diff --git a/src/lib/pages/past-txs/components/FurtherActionButton.tsx b/src/lib/pages/past-txs/components/FurtherActionButton.tsx new file mode 100644 index 000000000..12bae4510 --- /dev/null +++ b/src/lib/pages/past-txs/components/FurtherActionButton.tsx @@ -0,0 +1,46 @@ +import { RedoButton } from "lib/components/button/RedoButton"; +import { ResendButton } from "lib/components/button/ResendButton"; +import { RedoModal } from "lib/components/modal/RedoModal"; +import type { PastTransaction } from "lib/types"; +import { MsgFurtherAction } from "lib/types"; +import { extractMsgType } from "lib/utils"; + +interface FurtherActionButtonProps { + transaction: PastTransaction; +} + +/** + * Render redo button, resend button, or nothing. + * + * @remarks + * Redo occurs for transaction that has only 1 message + * Resend should not occurs for instantiate2 + * Redo modal if the message is instantiate2 + * + */ +export const FurtherActionButton = ({ + transaction, +}: FurtherActionButtonProps) => { + const isInstantiate2 = + transaction.isInstantiate && + transaction.messages.some( + (msg) => extractMsgType(msg.type) === "MsgInstantiateContract2" + ); + + if ( + transaction.furtherAction === MsgFurtherAction.RESEND && + !isInstantiate2 + ) { + return ; + } + + if (transaction.furtherAction === MsgFurtherAction.REDO && isInstantiate2) { + return ; + } + + if (transaction.furtherAction === MsgFurtherAction.REDO) { + return ; + } + + return null; +}; diff --git a/src/lib/pages/past-txs/components/MsgDetail.tsx b/src/lib/pages/past-txs/components/MsgDetail.tsx deleted file mode 100644 index 998240fb9..000000000 --- a/src/lib/pages/past-txs/components/MsgDetail.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { Text, Flex, Button, SlideFade } from "@chakra-ui/react"; -import type { EncodeObject } from "@cosmjs/proto-signing"; -import { useWallet } from "@cosmos-kit/react"; -import { useCallback, useMemo, useState } from "react"; -import { BsArrowCounterclockwise } from "react-icons/bs"; - -import { useRedo } from "../hooks/useRedo"; -import { useFabricateFee, useSimulateFee, useResendTx } from "lib/app-provider"; -import { AccordionStepperItem } from "lib/components/AccordionStepperItem"; -import type { SingleMsgProps } from "lib/components/action-msg/SingleMsg"; -import { SingleMsg } from "lib/components/action-msg/SingleMsg"; -import { useContractStore } from "lib/hooks"; -import { FailedModal } from "lib/pages/instantiate/component"; -import { useTxBroadcast } from "lib/providers/tx-broadcast"; -import type { - DetailExecute, - DetailInstantiate, - DetailSend, - DetailUpload, - Message, -} from "lib/types"; -import { - camelToSnake, - encode, - extractMsgType, - formatBalanceWithDenomList, -} from "lib/utils"; - -interface MsgDetailProps { - msg: Message; - success: boolean; -} - -export const MsgDetail = ({ msg, success }: MsgDetailProps) => { - const onClickRedo = useRedo(); - - const [button, setButton] = useState<"redo" | "resend" | "">(""); - const [showButton, setShowButton] = useState(false); - const { currentChainName } = useWallet(); - const { getContractLocalInfo } = useContractStore(); - - // TODO - Refactor to reduce complexity - // eslint-disable-next-line sonarjs/cognitive-complexity - const displayMsg = useMemo(() => { - const type = extractMsgType(msg.type); - // Type Execute - if (type === "MsgExecuteContract") { - const detailExecute = msg.detail as DetailExecute; - const contractLocalInfo = getContractLocalInfo(detailExecute.contract); - // Able to redo even fail transaction - setButton("redo"); - const singleMsgProps: SingleMsgProps = success - ? { - type: "Execute", - tags: [Object.keys(detailExecute.msg)[0]], - text2: "on", - link1: { - type: "contract_address", - value: contractLocalInfo?.name || detailExecute.contract, - copyValue: detailExecute.contract, - }, - } - : { - type: "Failed", - text1: "to execute message from", - link1: { - type: "contract_address", - value: contractLocalInfo?.name || detailExecute.contract, - copyValue: detailExecute.contract, - }, - }; - return ; - } - // Type Upload - if (type === "MsgStoreCode") { - const msgUpload = msg.detail as DetailUpload; - // Not able to resend or redo - setButton(""); - const singleMsgProps: SingleMsgProps = success - ? { - type: "Upload", - text1: "Wasm to Code ID", - link1: { - type: "code_id", - value: msgUpload.id?.toString(), - }, - } - : { - type: "Failed", - text1: "to upload Wasm file", - }; - return ; - } - // Type Instantiate - if (type === "MsgInstantiateContract") { - const msgInstantiate = msg.detail as DetailInstantiate; - const contractLocalInfo = getContractLocalInfo( - msgInstantiate.contractAddress - ); - // Not able to redo if failure - if (!success) { - setButton(""); - return ( - - ); - } - setButton("redo"); - return ( - - ); - } - // Type Send - if (type === "MsgSend") { - const msgSend = msg.detail as DetailSend; - setButton("resend"); - - // Not able to resend if failure - if (!success) { - // Not able to resend if failed - setButton(""); - return ( - - ); - } - return ( - - ); - } - // Type Others - if (type) { - if (!success) { - // Not able to resend if failed - setButton(""); - return ( - - ); - } - setButton("resend"); - return ; - } - return null; - }, [getContractLocalInfo, msg.detail, msg.type, success]); - - const fabricateFee = useFabricateFee(); - const { simulate } = useSimulateFee(); - const resendTx = useResendTx(); - const { broadcast } = useTxBroadcast(); - const [isButtonLoading, setIsButtonLoading] = useState(false); - const [error, setError] = useState(""); - - // TODO - Redundant, refactor - const onClickResend = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation(); - - setIsButtonLoading(true); - const messages = [] as EncodeObject[]; - // TODO - Refactor - if (msg.msg.msg) { - messages.push({ - typeUrl: msg.type, - value: { - ...msg.msg, - msg: encode(JSON.stringify(camelToSnake(msg.msg.msg))), - }, - }); - } else { - messages.push({ - typeUrl: msg.type, - value: { - ...msg.msg, - }, - }); - } - try { - const estimatedGasUsed = await simulate(messages); - let fee; - if (estimatedGasUsed) { - fee = fabricateFee(estimatedGasUsed); - } - const stream = await resendTx({ - onTxSucceed: () => {}, - estimatedFee: fee, - messages, - }); - if (stream) broadcast(stream); - setIsButtonLoading(false); - } catch (err) { - setError((err as Error).message); - setIsButtonLoading(false); - } - }, - [msg, simulate, resendTx, broadcast, fabricateFee] - ); - - return ( - setShowButton(true)} - onMouseLeave={() => setShowButton(false)} - _hover={{ background: "divider.main" }} - css={{ - "&:not(:first-of-type) div#before-stepper": { - visibility: "visible", - }, - }} - > - - - - {displayMsg} - - - {button === "redo" && ( - - )} - {button === "resend" && ( - - )} - - - {error && setError("")} />} - - ); -}; diff --git a/src/lib/pages/past-txs/components/PastTxRow.tsx b/src/lib/pages/past-txs/components/PastTxRow.tsx new file mode 100644 index 000000000..f6e9e29eb --- /dev/null +++ b/src/lib/pages/past-txs/components/PastTxRow.tsx @@ -0,0 +1,106 @@ +import { + Box, + Flex, + Grid, + Icon, + Tag, + Text, + useDisclosure, +} from "@chakra-ui/react"; +import { useEffect, useState } from "react"; +import { MdCheck, MdClose, MdKeyboardArrowDown } from "react-icons/md"; + +import { RenderActionMessages } from "lib/components/action-msg/ActionMessages"; +import { ExplorerLink } from "lib/components/ExplorerLink"; +import { TableRow } from "lib/components/table"; +import { AccordionTx } from "lib/components/table/AccordionTx"; +import type { PastTransaction } from "lib/types"; +import { dateFromNow, formatUTC } from "lib/utils"; + +import { FurtherActionButton } from "./FurtherActionButton"; + +interface PastTxRowProps { + transaction: PastTransaction; + templateColumnsStyle: string; +} + +export const PastTxRow = ({ + transaction, + templateColumnsStyle, +}: PastTxRowProps) => { + const { isOpen, onToggle } = useDisclosure(); + const [isAccordion, setIsAccordion] = useState(false); + + useEffect(() => { + if (transaction.messages.length > 1) setIsAccordion(true); + }, [transaction.messages]); + + return ( + + + + + + + + + + + + {transaction.isIbc && ( + + IBC + + )} + + + + + + {formatUTC(transaction.created)} + + {`(${dateFromNow(transaction.created)})`} + + + + + + + + + {isAccordion && ( + + )} + + + {isAccordion && ( + + )} + + ); +}; diff --git a/src/lib/pages/past-txs/components/PastTxTable.tsx b/src/lib/pages/past-txs/components/PastTxTable.tsx deleted file mode 100644 index adacf09a1..000000000 --- a/src/lib/pages/past-txs/components/PastTxTable.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import { - Td, - Tr, - Flex, - Tag, - Text, - useDisclosure, - Collapse, - Button, - Icon, -} from "@chakra-ui/react"; -import type { EncodeObject } from "@cosmjs/proto-signing"; -import { useWallet } from "@cosmos-kit/react"; -import dayjs from "dayjs"; -import { useCallback, useMemo, useState } from "react"; -import type { MouseEvent } from "react"; -import { BsArrowCounterclockwise } from "react-icons/bs"; -import { MdCheck, MdClose, MdKeyboardArrowDown } from "react-icons/md"; - -import { useRedo } from "../hooks/useRedo"; -import { useFabricateFee, useSimulateFee } from "lib/app-provider"; -import { useResendTx } from "lib/app-provider/tx/resend"; -import type { SingleMsgProps } from "lib/components/action-msg/SingleMsg"; -import { SingleMsg } from "lib/components/action-msg/SingleMsg"; -import { ExplorerLink } from "lib/components/ExplorerLink"; -import { useContractStore } from "lib/hooks"; -import { FailedModal } from "lib/pages/instantiate/component"; -import { useTxBroadcast } from "lib/providers/tx-broadcast"; -import type { - DetailExecute, - DetailInstantiate, - DetailSend, - DetailUpload, - Transaction, -} from "lib/types"; -import { - camelToSnake, - encode, - extractMsgType, - formatBalanceWithDenomList, -} from "lib/utils"; - -import { MsgDetail } from "./MsgDetail"; -import type { MultipleMsgProps } from "./MultipleMsg"; -import { MultipleMsg } from "./MultipleMsg"; - -interface PastTxTableProps { - element: Transaction; -} - -const PastTxTable = ({ element }: PastTxTableProps) => { - const onClickRedo = useRedo(); - const { isOpen, onToggle } = useDisclosure(); - const [isAccordion, setIsAccordion] = useState(false); - const [button, setButton] = useState<"redo" | "resend" | "">(""); - const [isButtonLoading, setIsButtonLoading] = useState(false); - const [error, setError] = useState(""); - const { currentChainName } = useWallet(); - - const { getContractLocalInfo } = useContractStore(); - - const extractMessage = useCallback((data: Transaction) => { - const uploadMsgs: DetailUpload[] = []; - const executeMsgs: DetailExecute[] = []; - const instantiateMsgs: DetailInstantiate[] = []; - const sendMsgs: DetailSend[] = []; - setButton(""); - data.messages.forEach((msgs) => { - const type = extractMsgType(msgs.type); - // Case where msg does not failed - if (Object.keys(msgs.detail).length) { - switch (type) { - case "MsgInstantiateContract": { - const detailInstantiate = msgs.detail as DetailInstantiate; - instantiateMsgs.push(detailInstantiate); - break; - } - - case "MsgStoreCode": { - const detailUpload = msgs.detail as DetailUpload; - uploadMsgs.push(detailUpload); - break; - } - - case "MsgExecuteContract": { - const detailExecute = msgs.detail as DetailExecute; - executeMsgs.push(detailExecute); - break; - } - - case "MsgSend": { - const detailSend = msgs.detail as DetailSend; - sendMsgs.push(detailSend); - break; - } - default: - break; - } - } - }); - - return { uploadMsgs, executeMsgs, instantiateMsgs, sendMsgs } as const; - }, []); - - // TODO - Refactor - const renderUpload = useCallback( - (uploadMsgs: Array) => { - // Multiple Upload/Store code Msgs - if (uploadMsgs.length > 1) { - const multipleMsgProps: MultipleMsgProps = element.success - ? { - type: "Upload", - length: uploadMsgs.length, - text: "Wasm files", - } - : { - type: "Failed to upload", - length: uploadMsgs.length, - text: "Wasm files", - }; - return ; - } - - // Only 1 Upload/Store code Msg - const singleMsgProps: SingleMsgProps = element.success - ? { - type: "Upload", - text1: "Wasm to Code ID", - link1: { - type: "code_id", - value: uploadMsgs[0].id.toString(), - }, - } - : { - type: "Failed", - text1: "to upload Wasm file", - }; - return ; - }, - [element.success] - ); - - // TODO - Refactor - const renderInstantiate = useCallback( - (instantiateMsgs: Array) => { - // Multiple Instantiate Msgs - if (instantiateMsgs.length > 1) { - setIsAccordion(true); - setButton("resend"); - if (!element.success) { - setButton(""); - - return ( - - ); - } - return ( - - ); - } - setButton("redo"); - const contractLocalInfo = getContractLocalInfo( - instantiateMsgs[0].contractAddress - ); - // Only 1 Instantiate Msgs - const singleMsgProps: SingleMsgProps = element.success - ? { - type: "Instantiate", - text1: "contract", - link1: { - type: "contract_address", - value: - contractLocalInfo?.name || instantiateMsgs[0].contractAddress, - copyValue: instantiateMsgs[0].contractAddress, - }, - text3: "from Code ID", - link2: { - type: "code_id", - value: instantiateMsgs[0].codeId.toString(), - }, - } - : { - type: "Failed", - text1: "to instantiate contract from Code ID", - link1: { - type: "code_id", - value: instantiateMsgs[0].codeId.toString(), - }, - }; - return ; - }, - [element.success, getContractLocalInfo] - ); - - // TODO - Refactor - const renderExecute = useCallback( - (executeMsgs: Array) => { - const tags = [Object.keys(executeMsgs[0].msg)[0]]; - const contractAddress = executeMsgs[0].contract; - if (executeMsgs.length > 1) { - tags.push(Object.keys(executeMsgs[1].msg)[0]); - } - - setIsAccordion(true); - setButton("resend"); - // Multiple Execute msgs - if (executeMsgs.some((msg) => msg.contract !== contractAddress)) { - if (!element.success) { - setButton(""); - return ( - - ); - } - - return ( - - ); - } - if (executeMsgs.length === 1) { - setButton("redo"); - setIsAccordion(false); - } - - const contractLocalInfo = getContractLocalInfo(executeMsgs[0].contract); - - // Only 1 Execute Msg - const singleMsgProps: SingleMsgProps = element.success - ? { - type: "Execute", - tags, - length: executeMsgs.length, - text2: "on", - link1: { - type: "contract_address", - value: contractLocalInfo?.name || executeMsgs[0].contract, - copyValue: executeMsgs[0].contract, - }, - } - : { - type: "Failed", - text1: "to execute message from", - link1: { - type: "contract_address", - value: contractLocalInfo?.name || executeMsgs[0].contract, - copyValue: executeMsgs[0].contract, - }, - }; - return ; - }, - [element.success, getContractLocalInfo] - ); - - // TODO - Refactor - const renderSend = useCallback( - (sendMsgs: Array) => { - setButton("resend"); - // Multiple Send msgs - if (sendMsgs.length > 1) { - setIsAccordion(true); - if (!element.success) { - setButton(""); - return ( - - ); - } - return ( - - ); - } - - // Only 1 Send msg -> Resend - if (!element.success) { - setButton(""); - return ; - } - return ( - - ); - }, - [element.success] - ); - - // TODO - Have to refator the way to display text - // eslint-disable-next-line complexity - const displayInfo = useMemo(() => { - // Reset accordion display - setIsAccordion(false); - const { uploadMsgs, executeMsgs, instantiateMsgs, sendMsgs } = - extractMessage(element); - if ( - uploadMsgs.length !== 0 && - executeMsgs.length === 0 && - instantiateMsgs.length === 0 && - sendMsgs.length === 0 - ) { - return renderUpload(uploadMsgs); - } - if ( - instantiateMsgs.length !== 0 && - uploadMsgs.length === 0 && - executeMsgs.length === 0 && - sendMsgs.length === 0 - ) { - return renderInstantiate(instantiateMsgs); - } - if ( - executeMsgs.length !== 0 && - instantiateMsgs.length === 0 && - uploadMsgs.length === 0 && - sendMsgs.length === 0 - ) { - return renderExecute(executeMsgs); - } - if ( - sendMsgs.length !== 0 && - executeMsgs.length === 0 && - instantiateMsgs.length === 0 && - uploadMsgs.length === 0 - ) { - return renderSend(sendMsgs); - } - // Combine all - setIsAccordion(true); - // Disable resend when transaction contains upload - if (uploadMsgs.length === 0 && element.success) { - setButton("resend"); - } - - return ( - <> - {/* Execute */} - {executeMsgs.length !== 0 && ( - <> - Execute {executeMsgs.length} - - )}{" "} - {/* Instantiate */} - {instantiateMsgs.length !== 0 && ( - <> - Instantiate {instantiateMsgs.length} - - )}{" "} - {/* Upload */} - {uploadMsgs.length !== 0 && ( - <> - Upload {uploadMsgs.length} - - )}{" "} - {/* Send */} - {sendMsgs.length !== 0 && ( - <> - Send {sendMsgs.length} - - )} - - ); - }, [ - element, - extractMessage, - renderExecute, - renderInstantiate, - renderSend, - renderUpload, - ]); - - const hideBorder = isAccordion && isOpen ? "none" : ""; - - const fabricateFee = useFabricateFee(); - const { simulate } = useSimulateFee(); - const resendTx = useResendTx(); - const { broadcast } = useTxBroadcast(); - - // TODO - Redundant, refactor - const onClickResend = useCallback( - async (e: MouseEvent) => { - e.stopPropagation(); - - setIsButtonLoading(true); - const messages = [] as EncodeObject[]; - element.messages.forEach((msg) => { - if (msg.msg.msg) { - messages.push({ - typeUrl: msg.type, - value: { - ...msg.msg, - msg: encode(JSON.stringify(camelToSnake(msg.msg.msg))), - }, - }); - } else { - messages.push({ - typeUrl: msg.type, - value: { - ...msg.msg, - }, - }); - } - }); - try { - const estimatedGasUsed = await simulate(messages); - let fee; - if (estimatedGasUsed) { - fee = fabricateFee(estimatedGasUsed); - } - const stream = await resendTx({ - onTxSucceed: () => {}, - estimatedFee: fee, - messages, - }); - if (stream) broadcast(stream); - setIsButtonLoading(false); - } catch (err) { - setError((err as Error).message); - setIsButtonLoading(false); - } - - return null; - }, - [element.messages, simulate, resendTx, broadcast, fabricateFee] - ); - - const renderTimestamp = () => { - const localDate = element.block.timestamp.concat("Z"); - return ( - - - {dayjs(localDate).utc().format("MMM DD, YYYY, h:mm:ss A [UTC]")} - - - ({dayjs(localDate).fromNow()}) - - - ); - }; - - return ( - <> - - - - - - - - {element.success ? ( - - ) : ( - - )} - - - - {displayInfo} - {element.isIbc && ( - - IBC - - )} - - - {renderTimestamp()} - - - {button === "redo" && ( - - )} - {button === "resend" && ( - - )} - - - - {isAccordion && ( - - )} - - - {isAccordion && ( - - - - - {element.messages.map((item, index) => ( - - ))} - - - - - )} - {error && setError("")} />} - - ); -}; - -export default PastTxTable; diff --git a/src/lib/pages/past-txs/components/PastTxsContent.tsx b/src/lib/pages/past-txs/components/PastTxsContent.tsx new file mode 100644 index 000000000..ce10388b2 --- /dev/null +++ b/src/lib/pages/past-txs/components/PastTxsContent.tsx @@ -0,0 +1,89 @@ +import { Flex } from "@chakra-ui/react"; +import { useWallet } from "@cosmos-kit/react"; +import { MdSearch, MdSearchOff } from "react-icons/md"; + +import { Loading } from "lib/components/Loading"; +import { DisconnectedState } from "lib/components/state/DisconnectedState"; +import { EmptyState } from "lib/components/state/EmptyState"; +import type { PastTransaction, Option } from "lib/types"; + +import { PastTxRow } from "./PastTxRow"; +import { PastTxsTableHeader } from "./PastTxsTableHeader"; + +interface PastTxsContentProps { + isLoading: boolean; + txDataError: unknown; + input: string; + filterSelected: string[]; + txData: Option; +} +export const PastTxsContent = ({ + isLoading, + txDataError, + input, + filterSelected, + txData, +}: PastTxsContentProps) => { + const { address } = useWallet(); + + const templateColumnsStyle = + "200px 70px minmax(360px, 1fr) max(300px) max(100px) max(70px)"; + + if (!address) { + return ( + + + + ); + } + + if (isLoading) { + return ; + } + + if ( + txDataError || + ((input !== "" || filterSelected.length !== 0) && !txData?.length) + ) { + return ( + + + + ); + } + + if (!txData?.length) { + return ( + + + + ); + } + return ( + + + {txData.map((transaction) => ( + + ))} + + ); +}; diff --git a/src/lib/pages/past-txs/components/PastTxsTableHeader.tsx b/src/lib/pages/past-txs/components/PastTxsTableHeader.tsx new file mode 100644 index 000000000..6f7d0e89f --- /dev/null +++ b/src/lib/pages/past-txs/components/PastTxsTableHeader.tsx @@ -0,0 +1,21 @@ +import type { GridProps } from "@chakra-ui/react"; +import { Grid } from "@chakra-ui/react"; + +import { TableHeader } from "lib/components/table"; + +export const PastTxsTableHeader = ({ + templateColumns, +}: { + templateColumns: GridProps["templateColumns"]; +}) => { + return ( + + Tx Hash + + Messages + Timestamp + + + + ); +}; diff --git a/src/lib/pages/past-txs/hooks/useRedo.ts b/src/lib/pages/past-txs/hooks/useRedo.ts index 4a2b3227c..347bf356d 100644 --- a/src/lib/pages/past-txs/hooks/useRedo.ts +++ b/src/lib/pages/past-txs/hooks/useRedo.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { useInternalNavigate } from "lib/app-provider"; -import type { Msg } from "lib/types"; +import type { Msg, Option } from "lib/types"; import { libEncode, camelToSnake } from "lib/utils"; export const useRedo = () => { @@ -10,7 +10,7 @@ export const useRedo = () => { return useCallback( ( e: React.MouseEvent, - type: string | undefined, + type: Option, msg: Msg, chainName: string ) => { @@ -22,7 +22,10 @@ export const useRedo = () => { pathname: "/execute", query: { chainName, contract: msg.contract, msg: encodeMsg }, }); - } else if (type === "MsgInstantiateContract") { + } else if ( + type === "MsgInstantiateContract" || + type === "MsgInstantiateContract2" + ) { const encodeMsg = libEncode(JSON.stringify(camelToSnake(msg))); navigate({ pathname: "/instantiate", diff --git a/src/lib/pages/past-txs/hooks/useResend.ts b/src/lib/pages/past-txs/hooks/useResend.ts new file mode 100644 index 000000000..e85e11a83 --- /dev/null +++ b/src/lib/pages/past-txs/hooks/useResend.ts @@ -0,0 +1,71 @@ +import type { EncodeObject } from "@cosmjs/proto-signing"; +import { useCallback } from "react"; + +import { useFabricateFee, useResendTx, useSimulateFee } from "lib/app-provider"; +import { useTxBroadcast } from "lib/providers/tx-broadcast"; +import type { Message } from "lib/types"; +import { camelToSnake, encode } from "lib/utils"; + +export const useResend = () => { + const fabricateFee = useFabricateFee(); + const { simulate } = useSimulateFee(); + const resendTx = useResendTx(); + const { broadcast } = useTxBroadcast(); + + return useCallback( + ( + e: React.MouseEvent, + messages: Message[], + setIsButtonLoading: (isButtonLoading: boolean) => void, + setError: (err: string) => void + ) => { + (async () => { + e.stopPropagation(); + setIsButtonLoading(true); + const formatedMsgs = messages.reduce( + (acc: EncodeObject[], msg: Message) => { + if (msg.msg.msg) { + acc.push({ + typeUrl: msg.type, + value: { + ...msg.msg, + msg: encode(JSON.stringify(camelToSnake(msg.msg.msg))), + }, + }); + } else { + acc.push({ + typeUrl: msg.type, + value: { + ...msg.msg, + }, + }); + } + return acc; + }, + [] + ); + + try { + const estimatedGasUsed = await simulate(formatedMsgs); + let fee; + if (estimatedGasUsed) { + fee = fabricateFee(estimatedGasUsed); + } + const stream = await resendTx({ + onTxSucceed: () => {}, + estimatedFee: fee, + messages: formatedMsgs, + }); + if (stream) broadcast(stream); + setIsButtonLoading(false); + } catch (err) { + setError((err as Error).message); + setIsButtonLoading(false); + } + + return null; + })(); + }, + [broadcast, fabricateFee, resendTx, simulate] + ); +}; diff --git a/src/lib/pages/past-txs/index.tsx b/src/lib/pages/past-txs/index.tsx index 4a0b40954..3cb3c1a4d 100644 --- a/src/lib/pages/past-txs/index.tsx +++ b/src/lib/pages/past-txs/index.tsx @@ -1,47 +1,53 @@ import { - Button, + Box, Flex, Heading, + Icon, Input, - Text, InputGroup, InputRightElement, - Icon, - TableContainer, - Table, - Thead, - Tr, - Th, - Tbody, - Box, } from "@chakra-ui/react"; import { useWallet } from "@cosmos-kit/react"; import type { ChangeEvent } from "react"; -import { useMemo, useState, useEffect, useCallback } from "react"; +import { useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; import { MdSearch } from "react-icons/md"; -import { Loading } from "lib/components/Loading"; import { Pagination } from "lib/components/pagination"; import { usePaginator } from "lib/components/pagination/usePaginator"; -import { DisconnectedState } from "lib/components/state/DisconnectedState"; -import type { Transaction } from "lib/types/tx/transaction"; +import type { HumanAddr } from "lib/types"; -import { FalseState } from "./components/FalseState"; -import PastTxTable from "./components/PastTxTable"; -import { useTxQuery } from "./query/useTxQuery"; +import { FilterSelection } from "./components/FilterSelection"; +import { PastTxsContent } from "./components/PastTxsContent"; +import { useTxQuery, useTxQueryCount } from "./query/useTxQuery"; const PastTxs = () => { - const [input, setInput] = useState(""); + const { address } = useWallet(); - // TODO Combine state - const [uploadButton, setUploadButton] = useState(false); - const [instantiateButton, setInstantiateButton] = useState(false); - const [executeButton, setExecuteButton] = useState(false); - const [ibcButton, setIbcButton] = useState(false); - const [sendButton, setSendButton] = useState(false); + const { watch, setValue } = useForm({ + defaultValues: { + search: "", + filters: { + isExecute: false, + isInstantiate: false, + isUpload: false, + isIbc: false, + isSend: false, + isMigrate: false, + isUpdateAdmin: false, + isClearAdmin: false, + }, + }, + mode: "all", + }); - const [data, setData] = useState(); - const [totalData, setTotalData] = useState(0); + const pastTxsState = watch(); + + const { data: countTxs = 0 } = useTxQueryCount( + address, + pastTxsState.search, + pastTxsState.filters + ); const { pagesQuantity, @@ -51,7 +57,7 @@ const PastTxs = () => { setPageSize, offset, } = usePaginator({ - total: totalData, + total: countTxs, initialState: { pageSize: 10, currentPage: 1, @@ -59,294 +65,96 @@ const PastTxs = () => { }, }); - const { address } = useWallet(); const { data: txData, - refetch: refetchTxData, error: txDataError, - isLoading: isTxDataLoading, + isLoading, } = useTxQuery( - address, - executeButton, - instantiateButton, - uploadButton, - ibcButton, - sendButton, - input, + address as HumanAddr, + pastTxsState.search, + pastTxsState.filters, pageSize, offset ); - // TODO Refactor useEffect - // Set data and total number of data when new data is passed - useEffect(() => { - if (txData) { - setData(txData.transactions); - setTotalData(txData.count); - } - }, [txData]); - - // Refetch data - useEffect(() => { - refetchTxData(); - }, [ - uploadButton, - instantiateButton, - executeButton, - ibcButton, - sendButton, - address, - pageSize, - offset, - refetchTxData, - ]); + const setFilter = (filter: string, bool: boolean) => { + setValue("filters", { ...pastTxsState.filters, [filter]: bool }); + }; - // When buttons are pressed, move back to the first page - useEffect(() => { - setCurrentPage(1); - }, [ - executeButton, - instantiateButton, - ibcButton, - sendButton, - uploadButton, - pageSize, - setCurrentPage, - ]); + const onPageChange = (nextPage: number) => { + setCurrentPage(nextPage); + }; - // Auto search when text input is filled after a period of time - useEffect(() => { - const timeoutId = setTimeout(() => { - refetchTxData(); - }, 600); - // Move back to the first page + const onPageSizeChange = (e: ChangeEvent) => { + const size = Number(e.target.value); + setPageSize(size); setCurrentPage(1); - return () => clearTimeout(timeoutId); - }, [input, refetchTxData, setCurrentPage]); - - // Display each row in table - const displayRow = useMemo(() => { - const displayComponents: JSX.Element[] = []; - if (data) { - data.forEach((element) => { - displayComponents.push( - - ); - }); - } - return displayComponents; - }, [data]); - - // Set input field - const onChangeSearch = (value: string) => { - setInput(value); }; - // Page change - const onPageChange = useCallback( - (nextPage: number) => { - setCurrentPage(nextPage); - }, - [setCurrentPage] - ); - - // Page Sizing - const onPageSizeChange = useCallback( - (e: ChangeEvent) => { - const size = Number(e.target.value); - setPageSize(size); - }, - [setPageSize] - ); - - // Determine which component should be shown - const displayContent = useMemo(() => { - const isSomeButtonPressed = - executeButton || - instantiateButton || - uploadButton || - sendButton || - ibcButton; - if (!address) { - return ( - - - - ); - } - // Loading state - if (isTxDataLoading) { - return ; - } - // No data found - if ( - (data?.length === 0 && (input !== "" || isSomeButtonPressed)) || - txDataError - ) { - return ( - - ); - } - - // No input data to search - if (data?.length === 0) { - return ( - - ); - } - - // Data found, display table - return ( - <> - - - - - - - - - - {displayRow} -
- Tx Hash - - - Messages - - Timestamp - - -
-
- - + const filterSelected = useMemo(() => { + return Object.keys(pastTxsState.filters).reduce( + (acc: string[], key: string) => { + if (pastTxsState.filters[key as keyof typeof pastTxsState.filters]) { + acc.push(key); + } + return acc; + }, + [] ); - }, [ - address, - currentPage, - data?.length, - displayRow, - executeButton, - onPageChange, - onPageSizeChange, - ibcButton, - input, - instantiateButton, - isTxDataLoading, - offset, - pageSize, - pagesQuantity, - sendButton, - totalData, - txDataError, - uploadButton, - ]); + }, [pastTxsState]); + + useEffect(() => { + setCurrentPage(1); + }, [pastTxsState.filters, pastTxsState.search, setCurrentPage]); return ( - + Past Transactions + - + onChangeSearch(e.target.value)} + value={pastTxsState.search} + onChange={(e) => setValue("search", e.target.value)} placeholder="Search with transaction hash or contract address" focusBorderColor="primary.main" h="full" /> - + + - - - - Filter by actions - - - - - - - - - - - - {displayContent} - + + {countTxs > 10 && ( + + )} ); }; diff --git a/src/lib/pages/past-txs/query/generateWhere.ts b/src/lib/pages/past-txs/query/generateWhere.ts new file mode 100644 index 000000000..df9812b71 --- /dev/null +++ b/src/lib/pages/past-txs/query/generateWhere.ts @@ -0,0 +1,80 @@ +import type { Filters, HumanAddr, ContractAddr } from "lib/types"; + +/** + * Generate action filter for where clause used in graphql. Only return action that is true + * + * @example + * is_send: {_eq: true}, is_execute: {_eq: true} + * + */ +export const actionsFilter = (filters: Filters) => { + const actions = { + isExecute: "is_execute", + isInstantiate: "is_instantiate", + isUpload: "is_store_code", + isIbc: "is_ibc", + isSend: "is_send", + isMigrate: "is_migrate", + isUpdateAdmin: "is_update_admin", + isClearAdmin: "is_clear_admin", + }; + + return Object.keys(filters).reduce((acc: string, key: string) => { + if (filters[key as keyof typeof filters]) { + return `${acc} ${actions[key as keyof Filters]}: {_eq: true },`; + } + return acc; + }, ""); +}; + +interface GenerateWhereForContractTx { + userAddress: HumanAddr; + contractAddress: ContractAddr; + filters: Filters; +} + +/** + * @remark + * For contract_transactions table + * + */ +export const generateWhereForContractTx = ({ + userAddress, + contractAddress, + filters, +}: GenerateWhereForContractTx) => { + const actionFilter = actionsFilter(filters); + return `{ + transaction: { + account: { address: { _eq: "${userAddress}" } }, + ${actionFilter !== "" ? `${actionFilter},` : ""} + } + ${ + contractAddress && `contract: { address: { _eq: "${contractAddress}" } },` + } + }`; +}; + +interface GenerateWhereForTx { + userAddress: HumanAddr; + txHash?: string; + filters: Filters; +} + +/** + * @remark + * For transactions table + * + */ +export const generateWhereForTx = ({ + userAddress, + txHash, + filters, +}: GenerateWhereForTx) => { + const actionFilter = actionsFilter(filters); + return ` { + account: { address: { _eq: "${userAddress}" } }, + ${actionFilter !== "" ? `${actionFilter},` : ""} + ${txHash && `hash: {_eq: "\\\\x${txHash}"}, `} + }`; +}; diff --git a/src/lib/pages/past-txs/query/graphqlQuery.ts b/src/lib/pages/past-txs/query/graphqlQuery.ts index f04fc7d92..71f490f95 100644 --- a/src/lib/pages/past-txs/query/graphqlQuery.ts +++ b/src/lib/pages/past-txs/query/graphqlQuery.ts @@ -1,180 +1,85 @@ import { gql } from "graphql-request"; -// Handle normal case where msg of none 4 main types are included -export const queryShowallFromTxs = (search: string) => { - const hash = `hash: {_eq: "\\\\x${search}"} `; +export const queryTransactionsFromTxs = (where: string) => { return gql` - query QueryShowAllFromTxs($userAddr: String = "", $pageSize: Int!, $offset: Int!) { - transactions( - where: { - account: { address: { _eq: $userAddr } }, - ${search !== "" ? `${hash}` : ""}, - _and: { - _or: [ - { is_execute: { _eq: true } } - { is_instantiate: { _eq: true } } - { - _and: [ - { is_ibc: { _eq: true} } - { - _or: [ - { is_execute: { _neq: false } } - { is_instantiate: { _neq: false } } - { is_store_code: { _neq: false } } - { is_send: { _neq: false } } - ] - } - ] - } - { is_store_code: { _eq: true } } - { is_send: { _eq: true } } - ] - } - - } - limit: $pageSize - offset: $offset - order_by: { block_height: desc } - ) { - hash - is_send - is_execute - is_ibc - is_instantiate - is_store_code - messages - success - block { + query QueryTransactionsFromTxs($pageSize: Int!, $offset: Int!) { + transactions( + where: ${where} + limit: $pageSize + offset: $offset + order_by: {block: {timestamp: desc}} + ) { + hash + is_send + is_execute + is_ibc + is_instantiate + is_store_code + is_clear_admin + is_migrate + is_update_admin + messages + success + block { timestamp - } - } - transactions_aggregate( - where: { - account: { - address: { _eq: $userAddr }, - transactions: { - _and: { - _or: [ - { is_execute: { _eq: true } } - { is_instantiate: { _eq: true } } - { is_store_code: { _eq: true } } - { - _and: [ - { is_ibc: { _eq: true} } - { - _or: [ - { is_execute: { _neq: false } } - { is_instantiate: { _neq: false } } - { is_store_code: { _neq: false } } - { is_send: { _neq: false } } - ] - } - ] - } - { is_send: { _eq: true } } - ] - } - } - } - ${search !== "" ? `${hash}` : ""}, - } - ) { - aggregate { - count - } - } - } - `; + } + } + + }`; }; -// Handle the case where action buttons are pressed -export const queryWithActionsFromTxs = ( - search: string, - actionsFilter: string -) => { - const hash = `hash: {_eq: "\\\\x${search}"} `; +export const queryTransactionsCountFromTxs = (where: string) => { return gql` - query QueryWithActionsFromTxs($userAddr: String!, $pageSize: Int!, $offset: Int!) { - transactions( - where: { account: { address: { _eq: $userAddr } }, - ${search !== "" ? `${hash}` : ""}, - ${actionsFilter} }, - limit: $pageSize, - offset: $offset, - order_by: { block_height: desc } - ) { - hash - is_send - is_execute - is_ibc - is_instantiate - is_store_code - messages - success - block { - timestamp - } + query QueryTransactionsCountFromTxs { + transactions_aggregate( + where: ${where} + ) { + aggregate { + count } - transactions_aggregate( - where: { - account: { - address: { _eq: $userAddr }, - } - ${actionsFilter} - ${search !== "" ? `${hash}` : ""}, - } - ) { - aggregate { - count - } - } - } - `; + } + }`; }; // Handle the case where contract address is searched -export const queryAddrFromContracts = (actionsFilter: string) => { +export const queryTransactionFromContractTxs = (where: string) => { return gql` - query QueryAddrFromContracts($userAddr: String!, $contractAddress: String!, $pageSize: Int!, $offset: Int!) { - contract_transactions( - where: { - transaction: { - account: { address: { _eq: $userAddr } }, - ${actionsFilter !== "" ? `${actionsFilter},` : ""} - } - contract: { address: { _eq: $contractAddress } } - } - limit: $pageSize, - offset: $offset, - order_by: { transaction: { block_height: desc } } - ) { - transaction { - hash - is_send - is_execute - is_ibc - is_instantiate - is_store_code - messages - success - block { - timestamp - } + query QueryTransactionFromContractsTxs($pageSize: Int!, $offset: Int!) { + contract_transactions( + where: ${where}, + limit: $pageSize, + offset: $offset, + order_by: { transaction: {block: {timestamp: desc}} } + ) { + transaction { + hash + is_send + is_execute + is_ibc + is_instantiate + is_store_code + is_clear_admin + is_migrate + is_update_admin + messages + success + block { + timestamp } } - contract_transactions_aggregate( - where: { - transaction: { - account: { address: { _eq: $userAddr } }, - ${actionsFilter !== "" ? `${actionsFilter},` : ""} - } - contract: { address: { _eq: $contractAddress } } - } + } + }`; +}; + +export const queryTransactionCountFromContractTxs = (where: string) => { + return gql` + query QueryTransactionCountFromContractsTxs { + contract_transactions_aggregate( + where: ${where} ) { aggregate { count } } - } - `; + }`; }; diff --git a/src/lib/pages/past-txs/query/useTxQuery.ts b/src/lib/pages/past-txs/query/useTxQuery.ts index 8632680ed..1527fb60b 100644 --- a/src/lib/pages/past-txs/query/useTxQuery.ts +++ b/src/lib/pages/past-txs/query/useTxQuery.ts @@ -1,150 +1,231 @@ +import type { UseQueryResult } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; import { useCelatoneApp } from "lib/app-provider"; -import type { Transaction } from "lib/types/tx/transaction"; -import { snakeToCamel } from "lib/utils/formatter"; +import { useGetAddressType } from "lib/hooks"; +import type { + Filters, + Message, + Option, + PastTransaction, + HumanAddr, + ContractAddr, +} from "lib/types"; +import { + getActionMsgType, + parseDateDefault, + parseTxHash, + snakeToCamel, + getMsgFurtherAction, +} from "lib/utils"; import { - queryAddrFromContracts, - queryShowallFromTxs, - queryWithActionsFromTxs, + actionsFilter, + generateWhereForContractTx, + generateWhereForTx, +} from "./generateWhere"; +import { + queryTransactionCountFromContractTxs, + queryTransactionFromContractTxs, + queryTransactionsCountFromTxs, + queryTransactionsFromTxs, } from "./graphqlQuery"; -interface Action { - execute: boolean; - instantiate: boolean; - upload: boolean; - ibc: boolean; - send: boolean; -} - -interface Response { - transactions: Array; - count: number; +interface GraphqlTransactionsResponse { + hash: string; + isSend: boolean; + isExecute: boolean; + isIbc: boolean; + isInstantiate: boolean; + isStoreCode: boolean; + isClearAdmin: boolean; + isMigrate: boolean; + isUpdateAdmin: boolean; + messages: Message[]; + success: boolean; + block: { + timestamp: string; + }; } -const actions = { - execute: "is_execute", - instantiate: "is_instantiate", - upload: "is_store_code", - ibc: "is_ibc", - send: "is_send", -}; - export const useTxQuery = ( - userAddr: string | undefined, - execute: boolean, - instantiate: boolean, - upload: boolean, - ibc: boolean, - send: boolean, + userAddress: Option, search: string, + filters: Filters, pageSize: number, offset: number ) => { + const getAddressType = useGetAddressType(); const { indexerGraphClient } = useCelatoneApp(); + // Filter when action buttons are pressed - const actionsFilter = () => { - const actionsObj = { - execute, - instantiate, - upload, - send, - ibc, - }; + const queryFn = useCallback(async () => { + if (!userAddress) throw new Error("User address not found"); - let filter = ""; - Object.keys(actionsObj).forEach((key) => { - if (actionsObj[key as keyof typeof actionsObj]) { - // Remove message that contain only ibc - if (key === "ibc") { - filter += ` - - _and: [ - { is_ibc: { _eq: true} } - { - _or: [ - { is_execute: { _neq: false } } - { is_instantiate: { _neq: false } } - { is_store_code: { _neq: false } } - { is_send: { _neq: false } } - ] - } - ] - - `; - } else { - filter += `${actions[key as keyof Action]}: {_eq: true}`; - filter += `,`; - } - } + // Search with contract address -> query from contract transaction table + if (getAddressType(search) === "contract_address") { + const where = generateWhereForContractTx({ + userAddress, + contractAddress: search as ContractAddr, + filters, + }); + return indexerGraphClient + .request(queryTransactionFromContractTxs(where), { + pageSize, + offset, + }) + .then(({ contract_transactions }) => { + const contractTransactionsToCamel = snakeToCamel( + contract_transactions + ) as { transaction: GraphqlTransactionsResponse }[]; + return contractTransactionsToCamel.map( + (contractTx: { + transaction: GraphqlTransactionsResponse; + }): PastTransaction => ({ + hash: parseTxHash(contractTx.transaction.hash), + messages: snakeToCamel( + contractTx.transaction.messages + ) as Message[], + // TODO - Remove default case + created: parseDateDefault( + contractTx.transaction.block?.timestamp + ), + success: contractTx.transaction.success, + actionMsgType: getActionMsgType([ + contractTx.transaction.isExecute, + contractTx.transaction.isInstantiate, + contractTx.transaction.isSend, + contractTx.transaction.isStoreCode, + contractTx.transaction.isMigrate, + contractTx.transaction.isUpdateAdmin, + contractTx.transaction.isClearAdmin, + ]), + furtherAction: getMsgFurtherAction( + contractTx.transaction.messages.length, + { + isExecute: contractTx.transaction.isExecute, + isInstantiate: contractTx.transaction.isInstantiate, + isSend: contractTx.transaction.isSend, + isUpload: contractTx.transaction.isStoreCode, + isMigrate: contractTx.transaction.isMigrate, + isUpdateAdmin: contractTx.transaction.isUpdateAdmin, + isClearAdmin: contractTx.transaction.isClearAdmin, + isIbc: contractTx.transaction.isIbc, + } + ), + isIbc: contractTx.transaction.isIbc, + isInstantiate: contractTx.transaction.isInstantiate, + }) + ); + }); + } + + const where = generateWhereForTx({ + userAddress, + txHash: search, + filters, }); + return indexerGraphClient + .request(queryTransactionsFromTxs(where), { + pageSize, + offset, + }) + .then(({ transactions }) => { + const transactionsToCamel = snakeToCamel( + transactions + ) as GraphqlTransactionsResponse[]; + return transactionsToCamel.map((transaction): PastTransaction => { + return { + hash: parseTxHash(transaction.hash), + messages: snakeToCamel(transaction.messages) as Message[], + // TODO - Remove default case + created: parseDateDefault(transaction.block?.timestamp), + success: transaction.success, + actionMsgType: getActionMsgType([ + transaction.isExecute, + transaction.isInstantiate, + transaction.isSend, + transaction.isStoreCode, + transaction.isMigrate, + transaction.isUpdateAdmin, + transaction.isClearAdmin, + ]), + furtherAction: getMsgFurtherAction(transaction.messages.length, { + isExecute: transaction.isExecute, + isInstantiate: transaction.isInstantiate, + isSend: transaction.isSend, + isUpload: transaction.isStoreCode, + isMigrate: transaction.isMigrate, + isUpdateAdmin: transaction.isUpdateAdmin, + isClearAdmin: transaction.isClearAdmin, + isIbc: transaction.isIbc, + }), + isIbc: transaction.isIbc, + isInstantiate: transaction.isInstantiate, + }; + }); + }); + }, [ + filters, + getAddressType, + indexerGraphClient, + offset, + pageSize, + search, + userAddress, + ]); - // Remove last , - filter = filter.substring(0, filter.length - 1); - return filter; - }; + return useQuery({ + queryKey: [ + "past-transaction", + userAddress, + search, + filters, + offset, + pageSize, + ], + queryFn, + }); +}; - const queryFn = async (): Promise => { - // Determine endpoint - if (userAddr === "") { - return { transactions: [], count: 0 } as Response; - } +export const useTxQueryCount = ( + userAddress: Option, + search: string, + filters: Filters +): UseQueryResult => { + const getAddressType = useGetAddressType(); + const { indexerGraphClient } = useCelatoneApp(); - // Tx hash and no search -> query from transactions table - if (search.length === 64 || search.length === 0) { - const isShowAll = !execute && !instantiate && !upload && !ibc && !send; - // Show all 4 main action types - let response; - if (isShowAll) { - response = await indexerGraphClient.request( - queryShowallFromTxs(search), - { - userAddr, - pageSize, - offset, - } - ); - // When buttons are pressed - } else { - response = indexerGraphClient.request( - queryWithActionsFromTxs(search, actionsFilter()), - { - userAddr, - pageSize, - offset, - } - ); - } - return snakeToCamel({ - transactions: response.transactions, - count: response.transactions_aggregate?.aggregate?.count, - }) as Response; + const queryFn = useCallback(async () => { + if (!userAddress) throw new Error("User address not found"); - // Contract address -> query from contracts table - } - if (search.length === 63) { - const response = await indexerGraphClient.request( - queryAddrFromContracts(actionsFilter()), - { - userAddr, + if (getAddressType(search) === "contract_address") { + return indexerGraphClient + .request(queryTransactionCountFromContractTxs(actionsFilter(filters)), { + userAddress, contractAddress: search, - pageSize, - offset, - } - ); - return snakeToCamel({ - transactions: response.contract_transactions.map( - (tx: { transaction: Transaction }) => tx.transaction - ), - count: response.contract_transactions_aggregate?.aggregate?.count, - }) as Response; + }) + .then( + ({ contract_transactions_aggregate }) => + contract_transactions_aggregate.aggregate.count + ); } - return { transactions: [], count: 0 } as Response; - }; + + const where = generateWhereForTx({ + userAddress: userAddress as HumanAddr, + txHash: search, + filters, + }); + return indexerGraphClient + .request(queryTransactionsCountFromTxs(where)) + .then( + ({ transactions_aggregate }) => transactions_aggregate.aggregate.count + ); + }, [filters, getAddressType, indexerGraphClient, search, userAddress]); + return useQuery({ - queryKey: ["transaction", userAddr, search], + queryKey: ["past-transaction-count", userAddress, search, filters], queryFn, - refetchInterval: 2000, }); }; diff --git a/src/lib/types/tx/msg.ts b/src/lib/types/tx/msg.ts index 4e260d03a..c8f064f60 100644 --- a/src/lib/types/tx/msg.ts +++ b/src/lib/types/tx/msg.ts @@ -86,7 +86,7 @@ export interface DetailClearAdmin { export interface DetailUpdateAdmin { contract: ContractAddr; - newAdmin: HumanAddr; + newAdmin: HumanAddr | ContractAddr; sender: HumanAddr; } diff --git a/src/lib/types/tx/transaction.ts b/src/lib/types/tx/transaction.ts index 186741839..ee856fcfa 100644 --- a/src/lib/types/tx/transaction.ts +++ b/src/lib/types/tx/transaction.ts @@ -10,22 +10,27 @@ import type { DetailUpload, } from "./msg"; -export interface Transaction { +export enum ActionMsgType { + SINGLE_ACTION_MSG = "SINGLE_ACTION_MSG", + MULTIPLE_ACTION_MSG = "MULTIPLE_ACTION_MSG", + OTHER_ACTION_MSG = "OTHER_ACTION_MSG", +} + +export enum MsgFurtherAction { + REDO = "REDO", + RESEND = "RESEND", + NONE = "NONE", +} + +export interface PastTransaction { hash: string; - isSend?: boolean; - isExecute?: boolean; - isInstantiate?: boolean; - isStoreCode?: boolean; - isIbc?: boolean; messages: Message[]; + created: Date; success: boolean; - account?: { - address: string; - }; - block: { - height?: number; - timestamp: string; - }; + actionMsgType: ActionMsgType; + furtherAction: MsgFurtherAction; + isIbc: boolean; + isInstantiate: boolean; } export interface Message { @@ -61,12 +66,18 @@ export interface ExecuteTransaction { success: boolean; } -export enum ActionMsgType { - SINGLE_ACTION_MSG = "SINGLE_ACTION_MSG", - MULTIPLE_ACTION_MSG = "MULTIPLE_ACTION_MSG", - OTHER_ACTION_MSG = "OTHER_ACTION_MSG", -} export interface AllTransaction extends ExecuteTransaction { actionMsgType: ActionMsgType; isIbc: boolean; } + +export interface Filters { + isExecute: boolean; + isInstantiate: boolean; + isUpload: boolean; + isIbc: boolean; + isSend: boolean; + isMigrate: boolean; + isUpdateAdmin: boolean; + isClearAdmin: boolean; +} diff --git a/src/lib/utils/extractActionValue.ts b/src/lib/utils/extractActionValue.ts new file mode 100644 index 000000000..da238839c --- /dev/null +++ b/src/lib/utils/extractActionValue.ts @@ -0,0 +1,22 @@ +export const displayActionValue = (isActionName: string) => { + switch (isActionName) { + case "isUpload": + return "Upload"; + case "isInstantiate": + return "Instantiate"; + case "isExecute": + return "Execute"; + case "isSend": + return "Send"; + case "isIbc": + return "IBC"; + case "isMigrate": + return "Migrate"; + case "isClearAdmin": + return "Clear Admin"; + case "isUpdateAdmin": + return "Update Admin"; + default: + return ""; + } +}; diff --git a/src/lib/utils/formatter/camelToSnake.ts b/src/lib/utils/formatter/camelToSnake.ts index 7b3d05049..4c264834a 100644 --- a/src/lib/utils/formatter/camelToSnake.ts +++ b/src/lib/utils/formatter/camelToSnake.ts @@ -23,7 +23,7 @@ export const camelToSnake = (obj: unknown): unknown => { if (typeof obj === "object") { return mapObject(obj, (key, value) => { - const newKey = snakeCase(key); + const newKey = snakeCase(key) || key; if (key !== newKey && newKey in obj) { throw new Error( `Snake case key ${newKey} would overwrite existing key of the given JSON object` diff --git a/src/lib/utils/formatter/currency.format.ts b/src/lib/utils/formatter/currency.format.ts index c29122ebd..f6666da12 100644 --- a/src/lib/utils/formatter/currency.format.ts +++ b/src/lib/utils/formatter/currency.format.ts @@ -11,5 +11,5 @@ export function formatToken( if (denom[0] === "u") { return formatTokenWithPrecision(amount, 6); } - return amount.toString(); + return formatTokenWithPrecision(amount, 0); } diff --git a/src/lib/utils/formatter/formatBalanceWithDenom.ts b/src/lib/utils/formatter/formatBalanceWithDenom.ts index 0867b7404..c67a280f5 100644 --- a/src/lib/utils/formatter/formatBalanceWithDenom.ts +++ b/src/lib/utils/formatter/formatBalanceWithDenom.ts @@ -2,14 +2,22 @@ import type { Coin } from "@cosmjs/stargate"; import type { Token, U } from "lib/types"; -import { formatToken } from "./currency.format"; +import { formatTokenWithPrecision } from "./token"; import { getTokenLabel } from "./tokenType"; -export const formatBalanceWithDenom = (coin: Coin) => { - return `${formatToken(coin.amount as U, coin.denom)} ${getTokenLabel( - coin.denom - )}`; -}; +interface FormatBalanceWithDenom { + coin: Coin; + symbol?: string; + precision?: number; +} -export const formatBalanceWithDenomList = (coins: Coin[]) => - coins.map(formatBalanceWithDenom); +export const formatBalanceWithDenom = ({ + coin, + symbol, + precision, +}: FormatBalanceWithDenom) => { + return `${formatTokenWithPrecision( + coin.amount as U, + precision || 0 + )} ${getTokenLabel(symbol || coin.denom)}`; +}; diff --git a/src/lib/utils/formatter/snakeToCamel.ts b/src/lib/utils/formatter/snakeToCamel.ts index 051020f3a..bc82b7fe1 100644 --- a/src/lib/utils/formatter/snakeToCamel.ts +++ b/src/lib/utils/formatter/snakeToCamel.ts @@ -22,7 +22,7 @@ export const snakeToCamel = (obj: unknown): unknown => { if (typeof obj === "object") { return mapObject(obj, (key, value) => { - const newKey = camelCase(key); + const newKey = camelCase(key) || key; if (key !== newKey && newKey in obj) { throw new Error( `Snake case key ${newKey} would overwrite existing key of the given JSON object` diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index c4c0280d6..0b92e80ec 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -18,3 +18,6 @@ export * from "./option"; export * from "./description"; export * from "./tags"; export * from "./countMessages"; +export * from "./msgFurtherAction"; +export * from "./extractActionValue"; +export * from "./mergeRefs"; diff --git a/src/lib/utils/mergeRefs.ts b/src/lib/utils/mergeRefs.ts index 1af59896c..f4107a50b 100644 --- a/src/lib/utils/mergeRefs.ts +++ b/src/lib/utils/mergeRefs.ts @@ -1,9 +1,9 @@ /* eslint-disable no-param-reassign */ import type * as React from "react"; -export default function mergeRefs( +export const mergeRefs = ( refs: Array | React.LegacyRef> -): React.RefCallback { +): React.RefCallback => { return (value) => { refs.forEach((ref) => { if (typeof ref === "function") { @@ -13,4 +13,4 @@ export default function mergeRefs( } }); }; -} +}; diff --git a/src/lib/utils/msgFurtherAction.ts b/src/lib/utils/msgFurtherAction.ts new file mode 100644 index 000000000..828a0bb59 --- /dev/null +++ b/src/lib/utils/msgFurtherAction.ts @@ -0,0 +1,22 @@ +import type { Filters } from "lib/types"; +import { MsgFurtherAction } from "lib/types"; + +export const getMsgFurtherAction = (length: number, filters: Filters) => { + // Redo: instantiate, execute, check length === 1 + if (length === 1 && (filters.isExecute || filters.isInstantiate)) { + return MsgFurtherAction.REDO; + } + // Resend: messages with execute, instantiate, or send + if ( + !filters.isClearAdmin && + !filters.isUpload && + !filters.isIbc && + !filters.isClearAdmin && + !filters.isMigrate && + !filters.isUpdateAdmin && + (filters.isExecute || filters.isInstantiate || filters.isSend) + ) { + return MsgFurtherAction.RESEND; + } + return MsgFurtherAction.NONE; +};