From 43867deebcf8aa6ba60bd5400de75299f9b864b0 Mon Sep 17 00:00:00 2001 From: luca Date: Sun, 15 Sep 2024 15:16:21 +0800 Subject: [PATCH] feat: add upload contract UI --- .../common/Header/ChainDropdown.tsx | 16 +- .../components/common/Radio/Radio.module.css | 24 +-- .../components/common/Radio/Radio.tsx | 38 ++++- .../components/contract/BackButton.tsx | 21 +++ .../components/contract/CreateFromUpload.tsx | 18 ++ .../components/contract/InputField.tsx | 47 ++++++ .../contract/InstantiatePermissionRadio.tsx | 154 ++++++++++++++++++ .../components/contract/MyContractsTab.tsx | 7 +- .../components/contract/MyContractsTable.tsx | 6 +- .../components/contract/UploadContract.tsx | 105 ++++++++++++ .../components/contract/WasmFileUploader.tsx | 129 +++++++++++++++ examples/chain-template/config/theme.ts | 2 + .../hooks/contract/useStoreCodeTx.tsx | 15 +- examples/chain-template/pages/_app.tsx | 24 +-- .../public/images/contract-file.svg | 14 ++ .../chain-template/public/images/upload.svg | 4 + examples/chain-template/utils/common.ts | 28 +++- examples/chain-template/utils/contract.ts | 5 - 18 files changed, 580 insertions(+), 77 deletions(-) create mode 100644 examples/chain-template/components/contract/BackButton.tsx create mode 100644 examples/chain-template/components/contract/CreateFromUpload.tsx create mode 100644 examples/chain-template/components/contract/InputField.tsx create mode 100644 examples/chain-template/components/contract/InstantiatePermissionRadio.tsx create mode 100644 examples/chain-template/components/contract/UploadContract.tsx create mode 100644 examples/chain-template/components/contract/WasmFileUploader.tsx create mode 100644 examples/chain-template/public/images/contract-file.svg create mode 100644 examples/chain-template/public/images/upload.svg diff --git a/examples/chain-template/components/common/Header/ChainDropdown.tsx b/examples/chain-template/components/common/Header/ChainDropdown.tsx index fccb3e337..84c39de03 100644 --- a/examples/chain-template/components/common/Header/ChainDropdown.tsx +++ b/examples/chain-template/components/common/Header/ChainDropdown.tsx @@ -6,6 +6,7 @@ import { Box, Combobox, Skeleton, Stack, Text } from '@interchain-ui/react'; import { useStarshipChains, useDetectBreakpoints } from '@/hooks'; import { chainStore, useChainStore } from '@/contexts'; import { chainOptions } from '@/config'; +import { getSignerOptions } from '@/utils'; export const ChainDropdown = () => { const { selectedChain } = useChainStore(); @@ -18,12 +19,19 @@ export const ChainDropdown = () => { const { addChains, getChainLogo } = useManager(); useEffect(() => { - if (starshipChains) { - // @ts-ignore - addChains(starshipChains.chains, starshipChains.assets); + if ( + starshipChains?.chains.length && + starshipChains?.assets.length && + !isChainsAdded + ) { + addChains( + starshipChains.chains, + starshipChains.assets, + getSignerOptions(), + ); setIsChainsAdded(true); } - }, [starshipChains]); + }, [starshipChains, isChainsAdded]); const chains = isChainsAdded ? chainOptions.concat(starshipChains?.chains ?? []) diff --git a/examples/chain-template/components/common/Radio/Radio.module.css b/examples/chain-template/components/common/Radio/Radio.module.css index 7c1f9dbe9..e8e19cda5 100644 --- a/examples/chain-template/components/common/Radio/Radio.module.css +++ b/examples/chain-template/components/common/Radio/Radio.module.css @@ -11,30 +11,14 @@ } .radio:checked { - border: 1px solid #7310ff; -} - -.radio:checked::before { - content: ''; - display: block; - width: 8px; - height: 8px; - border-radius: 50%; - background: #7310ff; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + border-color: #7310ff; + border-width: 4px; } .radioDark { - border: 1px solid #343c44; + border-color: #323a42; } .radioDark:checked { - border: 1px solid #ab6fff; -} - -.radioDark:checked::before { - background: #ab6fff; + border-color: #ab6fff; } diff --git a/examples/chain-template/components/common/Radio/Radio.tsx b/examples/chain-template/components/common/Radio/Radio.tsx index acca42afd..edca8dc85 100644 --- a/examples/chain-template/components/common/Radio/Radio.tsx +++ b/examples/chain-template/components/common/Radio/Radio.tsx @@ -1,9 +1,10 @@ -import { Box, useTheme } from '@interchain-ui/react'; +import { Box, Icon, IconName, Text, useTheme } from '@interchain-ui/react'; import styles from './Radio.module.css'; type RadioProps = { children: React.ReactNode; value: string; + icon?: IconName | React.ReactNode; name?: string; checked?: boolean; onChange?: (event: React.ChangeEvent) => void; @@ -14,21 +15,30 @@ export const Radio = ({ value, checked, name, + icon, onChange, }: RadioProps) => { const { theme } = useTheme(); + const color = checked ? '$purple600' : '$blackAlpha600'; return ( - {children} + + {typeof icon === 'string' ? ( + + ) : ( + icon + )} + + + {children} + ); }; diff --git a/examples/chain-template/components/contract/BackButton.tsx b/examples/chain-template/components/contract/BackButton.tsx new file mode 100644 index 000000000..dfcc39191 --- /dev/null +++ b/examples/chain-template/components/contract/BackButton.tsx @@ -0,0 +1,21 @@ +import { Box, Icon, Text } from '@interchain-ui/react'; + +export const BackButton = ({ onClick }: { onClick: () => void }) => { + return ( + + + + Back + + + ); +}; diff --git a/examples/chain-template/components/contract/CreateFromUpload.tsx b/examples/chain-template/components/contract/CreateFromUpload.tsx new file mode 100644 index 000000000..4d9216067 --- /dev/null +++ b/examples/chain-template/components/contract/CreateFromUpload.tsx @@ -0,0 +1,18 @@ +import { Box } from '@interchain-ui/react'; +import { UploadContract } from './UploadContract'; +import { BackButton } from './BackButton'; + +type CreateFromUploadProps = { + onBack: () => void; +}; + +export const CreateFromUpload = ({ onBack }: CreateFromUploadProps) => { + return ( + + + + + + + ); +}; diff --git a/examples/chain-template/components/contract/InputField.tsx b/examples/chain-template/components/contract/InputField.tsx new file mode 100644 index 000000000..06baf8013 --- /dev/null +++ b/examples/chain-template/components/contract/InputField.tsx @@ -0,0 +1,47 @@ +import { Box, Text } from '@interchain-ui/react'; + +const InputField = ({ + children, + title, + required = false, +}: { + title: string; + children: React.ReactNode; + required?: boolean; +}) => { + return ( + + + {title}{' '} + {required && ( + + * + + )} + + {children} + + ); +}; + +const Description = ({ + children, + intent = 'default', +}: { + children: string; + intent?: 'error' | 'default'; +}) => { + return ( + + {children} + + ); +}; + +InputField.Description = Description; + +export { InputField }; diff --git a/examples/chain-template/components/contract/InstantiatePermissionRadio.tsx b/examples/chain-template/components/contract/InstantiatePermissionRadio.tsx new file mode 100644 index 000000000..9d4347929 --- /dev/null +++ b/examples/chain-template/components/contract/InstantiatePermissionRadio.tsx @@ -0,0 +1,154 @@ +import { useEffect } from 'react'; +import { Box, Text, TextField } from '@interchain-ui/react'; +import { HiOutlineTrash } from 'react-icons/hi'; +import { LuPlus } from 'react-icons/lu'; +import { cosmwasm } from 'interchain-query'; +import { useChain } from '@cosmos-kit/react'; +import { GrGroup } from 'react-icons/gr'; +import { MdOutlineHowToVote } from 'react-icons/md'; +import { MdChecklistRtl } from 'react-icons/md'; + +import { Button, Radio, RadioGroup } from '../common'; +import { InputField } from './InputField'; +import { validateChainAddress } from '@/utils'; +import { useChainStore } from '@/contexts'; + +export const AccessType = cosmwasm.wasm.v1.AccessType; + +export type Permission = (typeof AccessType)[keyof typeof AccessType]; + +export type Address = { + value: string; + isValid?: boolean; + errorMsg?: string | null; +}; + +type Props = { + addresses: Address[]; + permission: Permission; + setAddresses: (addresses: Address[]) => void; + setPermission: (permission: Permission) => void; +}; + +export const InstantiatePermissionRadio = ({ + addresses, + permission, + setAddresses, + setPermission, +}: Props) => { + const { selectedChain } = useChainStore(); + const { chain } = useChain(selectedChain); + + const onAddAddress = () => { + setAddresses([...addresses, { value: '' }]); + }; + + const onDeleteAddress = (index: number) => { + const newAddresses = [...addresses]; + newAddresses.splice(index, 1); + setAddresses(newAddresses); + }; + + const onAddressChange = (value: string, index: number) => { + const newAddresses = [...addresses]; + newAddresses[index].value = value; + setAddresses(newAddresses); + }; + + useEffect(() => { + if (permission !== AccessType.ACCESS_TYPE_ANY_OF_ADDRESSES) return; + + const newAddresses = addresses.map((addr, index) => { + const isDuplicate = + addresses.findIndex((a) => a.value === addr.value) !== index; + + const errorMsg = isDuplicate + ? 'Address already exists' + : validateChainAddress(addr.value, chain.bech32_prefix); + + return { + ...addr, + isValid: !!addr.value && !errorMsg, + errorMsg: addr.value ? errorMsg : null, + }; + }); + + setAddresses(newAddresses); + }, [JSON.stringify(addresses.map((addr) => addr.value))]); + + return ( + <> + { + setPermission(Number(val) as Permission); + }} + > + } + value={AccessType.ACCESS_TYPE_EVERYBODY.toString()} + > + Everybody + + } + value={AccessType.ACCESS_TYPE_NOBODY.toString()} + > + Governance only + + } + value={AccessType.ACCESS_TYPE_ANY_OF_ADDRESSES.toString()} + > + Approved addresses + + + + {permission === AccessType.ACCESS_TYPE_ANY_OF_ADDRESSES && ( + + {addresses.map(({ value, errorMsg }, index) => ( + + + onAddressChange(e.target.value, index)} + attributes={{ width: '100%' }} + intent={errorMsg ? 'error' : 'default'} + autoComplete="off" + /> + {errorMsg && ( + + {errorMsg} + + )} + + + + )} + + ); +}; diff --git a/examples/chain-template/components/contract/MyContractsTab.tsx b/examples/chain-template/components/contract/MyContractsTab.tsx index 902f092e7..e6d8323c0 100644 --- a/examples/chain-template/components/contract/MyContractsTab.tsx +++ b/examples/chain-template/components/contract/MyContractsTab.tsx @@ -4,6 +4,7 @@ import { Box } from '@interchain-ui/react'; import { Button } from '../common'; import { PopoverSelect } from './PopoverSelect'; import { MyContractsTable } from './MyContractsTable'; +import { CreateFromUpload } from './CreateFromUpload'; const ContentViews = { MY_CONTRACTS: 'my_contracts', @@ -29,7 +30,7 @@ export const MyContractsTab = ({ show, switchTab }: MyContractsTabProps) => { ); return ( - + { } /> {contentView === ContentViews.CREATE_FROM_UPLOAD && ( - Create from Upload + setContentView(ContentViews.MY_CONTRACTS)} + /> )} {contentView === ContentViews.CREATE_FROM_CODE_ID && ( Create from Code ID diff --git a/examples/chain-template/components/contract/MyContractsTable.tsx b/examples/chain-template/components/contract/MyContractsTable.tsx index 23d21d60e..8d9138422 100644 --- a/examples/chain-template/components/contract/MyContractsTable.tsx +++ b/examples/chain-template/components/contract/MyContractsTable.tsx @@ -27,7 +27,7 @@ export const MyContractsTable = ({ const { data: myContracts = [], isLoading } = useMyContracts(); return ( - + {title} @@ -41,7 +41,7 @@ export const MyContractsTable = ({ minHeight="300px" > {!address ? ( - + ) : isLoading ? ( ) : myContracts.length === 0 ? ( @@ -73,7 +73,7 @@ export const MyContractsTable = ({ {Number(contractInfo?.codeId)} - + + + ); +}; diff --git a/examples/chain-template/components/contract/WasmFileUploader.tsx b/examples/chain-template/components/contract/WasmFileUploader.tsx new file mode 100644 index 000000000..709506ca7 --- /dev/null +++ b/examples/chain-template/components/contract/WasmFileUploader.tsx @@ -0,0 +1,129 @@ +import Image from 'next/image'; +import { useCallback, useMemo } from 'react'; +import { Box, Text } from '@interchain-ui/react'; +import { HiOutlineTrash } from 'react-icons/hi'; +import { useDropzone } from 'react-dropzone'; + +import { bytesToKb } from '@/utils'; + +const MAX_FILE_SIZE = 800_000; + +const defaultFileInfo = { + image: { + src: '/images/upload.svg', + alt: 'upload', + width: 80, + height: 48, + }, + title: 'Upload or drag .wasm file here', + description: `Max file size: ${bytesToKb(MAX_FILE_SIZE)}KB`, +}; + +type WasmFileUploaderProps = { + file: File | null; + setFile: (file: File | null) => void; +}; + +export const WasmFileUploader = ({ file, setFile }: WasmFileUploaderProps) => { + const onDrop = useCallback( + (files: File[]) => { + setFile(files[0]); + }, + [setFile], + ); + + const fileInfo = useMemo(() => { + if (!file) return defaultFileInfo; + + return { + image: { + src: '/images/contract-file.svg', + alt: 'contract-file', + width: 40, + height: 54, + }, + title: file.name, + description: `File size: ${bytesToKb(file.size)}KB`, + }; + }, [file]); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + multiple: false, + accept: { 'application/octet-stream': ['.wasm'] }, + maxSize: MAX_FILE_SIZE, + }); + + return ( +
+ + {!file && } + + {fileInfo.image.alt} + + + {fileInfo.title} + + + {fileInfo.description} + + + + {file && ( + setFile(null) }} + > + + + Remove + + + )} + +
+ ); +}; diff --git a/examples/chain-template/config/theme.ts b/examples/chain-template/config/theme.ts index 2550afa48..c3dc41228 100644 --- a/examples/chain-template/config/theme.ts +++ b/examples/chain-template/config/theme.ts @@ -10,6 +10,7 @@ export const lightColors: ThemeDef['vars']['colors'] = { purple400: '#AB6FFF', purple200: '#E5D4FB', purple100: '#F9F4FF', + purple50: '#FCFAFF', blackAlpha600: '#2C3137', blackAlpha500: '#6D7987', blackAlpha400: '#697584', @@ -44,6 +45,7 @@ export const darkColors: ThemeDef['vars']['colors'] = { purple400: '#AB6FFF', purple200: '#4D198F', purple100: '#14004D', + purple50: '#FCFAFF', blackAlpha600: '#FFFFFF', blackAlpha500: '#9EACBD', blackAlpha400: '#807C86', diff --git a/examples/chain-template/hooks/contract/useStoreCodeTx.tsx b/examples/chain-template/hooks/contract/useStoreCodeTx.tsx index 6aa793035..750066f37 100644 --- a/examples/chain-template/hooks/contract/useStoreCodeTx.tsx +++ b/examples/chain-template/hooks/contract/useStoreCodeTx.tsx @@ -6,7 +6,7 @@ import { StdFee } from '@cosmjs/amino'; import { Box } from '@interchain-ui/react'; import { useToast } from '../common'; -import { CodeInfo, prettyStoreCodeTxResult, PrettyTxResult } from '@/utils'; +import { prettyStoreCodeTxResult } from '@/utils'; const { storeCode } = cosmwasm.wasm.v1.MessageComposer.fromPartial; @@ -14,8 +14,7 @@ type StoreCodeTxParams = { wasmFile: File; permission: AccessType; addresses: string[]; - codeName: string; - onTxSucceed?: (txResult: PrettyTxResult, codeInfo: CodeInfo) => void; + onTxSucceed?: (codeId: string) => void; onTxFailed?: () => void; }; @@ -27,7 +26,6 @@ export const useStoreCodeTx = (chainName: string) => { wasmFile, permission, addresses, - codeName, onTxSucceed = () => {}, onTxFailed = () => {}, }: StoreCodeTxParams) => { @@ -56,14 +54,7 @@ export const useStoreCodeTx = (chainName: string) => { try { const client = await getSigningCosmWasmClient(); const result = await client.signAndBroadcast(address, [message], fee); - const txResult = prettyStoreCodeTxResult(result, codeName, wasmFile.name); - onTxSucceed(txResult, { - id: Number(txResult.codeId), - name: codeName, - uploader: address, - permission, - addresses, - }); + onTxSucceed(prettyStoreCodeTxResult(result).codeId); toast.close(toastId); toast({ title: 'Contract uploaded successfully', diff --git a/examples/chain-template/pages/_app.tsx b/examples/chain-template/pages/_app.tsx index 55bbbc262..e5f79bd46 100644 --- a/examples/chain-template/pages/_app.tsx +++ b/examples/chain-template/pages/_app.tsx @@ -2,16 +2,15 @@ import '../styles/globals.css'; import '@interchain-ui/react/styles'; import type { AppProps } from 'next/app'; -import { SignerOptions } from 'cosmos-kit'; import { ChainProvider } from '@cosmos-kit/react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; // import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { Box, Toaster, useTheme } from '@interchain-ui/react'; import { chains, assets } from 'chain-registry'; -import { GasPrice } from '@cosmjs/stargate'; import { CustomThemeProvider, Layout } from '@/components'; import { wallets } from '@/config'; +import { getSignerOptions } from '@/utils'; const queryClient = new QueryClient({ defaultOptions: { @@ -26,24 +25,6 @@ const queryClient = new QueryClient({ function CreateCosmosApp({ Component, pageProps }: AppProps) { const { themeClass } = useTheme(); - const signerOptions: SignerOptions = { - // TODO fix type error - // @ts-ignore - signingStargate: (chain) => { - let gasPrice; - try { - // TODO fix type error - // @ts-ignore - const feeToken = chain.fees?.fee_tokens[0]; - const fee = `${feeToken?.average_gas_price || 0.025}${feeToken?.denom}`; - gasPrice = GasPrice.fromString(fee); - } catch (error) { - gasPrice = GasPrice.fromString('0.025uosmo'); - } - return { gasPrice }; - }, - }; - return ( + + + + + + + + + + + + + diff --git a/examples/chain-template/public/images/upload.svg b/examples/chain-template/public/images/upload.svg new file mode 100644 index 000000000..388c40354 --- /dev/null +++ b/examples/chain-template/public/images/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/chain-template/utils/common.ts b/examples/chain-template/utils/common.ts index 33f0f4d1e..45f856b71 100644 --- a/examples/chain-template/utils/common.ts +++ b/examples/chain-template/utils/common.ts @@ -1,6 +1,7 @@ import { assets } from 'chain-registry'; import { Asset, AssetList } from '@chain-registry/types'; -import { Wallet } from '@cosmos-kit/core'; +import { GasPrice } from '@cosmjs/stargate'; +import { SignerOptions, Wallet } from '@cosmos-kit/core'; export const getChainAssets = (chainName: string) => { return assets.find((chain) => chain.chain_name === chainName) as AssetList; @@ -13,7 +14,7 @@ export const getCoin = (chainName: string) => { export const getExponent = (chainName: string) => { return getCoin(chainName).denom_units.find( - (unit) => unit.denom === getCoin(chainName).display + (unit) => unit.denom === getCoin(chainName).display, )?.exponent as number; }; @@ -28,3 +29,26 @@ export const getWalletLogo = (wallet: Wallet) => { ? wallet.logo : wallet.logo.major || wallet.logo.minor; }; + +export const getSignerOptions = (): SignerOptions => { + const defaultGasPrice = GasPrice.fromString('0.025uosmo'); + + return { + // @ts-ignore + signingStargate: (chain) => { + if (typeof chain === 'string') { + return { gasPrice: defaultGasPrice }; + } + let gasPrice; + try { + const feeToken = chain.fees?.fee_tokens[0]; + const fee = `${feeToken?.average_gas_price || 0.025}${feeToken?.denom}`; + gasPrice = GasPrice.fromString(fee); + } catch (error) { + gasPrice = defaultGasPrice; + } + return { gasPrice }; + }, + preferredSignType: () => 'direct', + }; +}; diff --git a/examples/chain-template/utils/contract.ts b/examples/chain-template/utils/contract.ts index 58a9dbf6f..06e74951f 100644 --- a/examples/chain-template/utils/contract.ts +++ b/examples/chain-template/utils/contract.ts @@ -103,13 +103,10 @@ export type PrettyTxResult = { codeHash: string; txHash: string; txFee: string; - codeDisplayName: string; }; export const prettyStoreCodeTxResult = ( txResponse: DeliverTxResponse, - codeName: string, - wasmFileName: string, ): PrettyTxResult => { const events = txResponse.events; const codeId = findAttr(events, 'store_code', 'code_id') ?? '0'; @@ -117,14 +114,12 @@ export const prettyStoreCodeTxResult = ( const txHash = txResponse.transactionHash; const txFee = txResponse.events.find((e) => e.type === 'tx')?.attributes[0].value ?? ''; - const codeDisplayName = codeName || `${wasmFileName}(${codeId})`; return { codeId, codeHash, txHash, txFee, - codeDisplayName, }; };