diff --git a/frontend/public/expand-icon.svg b/frontend/public/expand-icon.svg new file mode 100644 index 000000000..0627f069d --- /dev/null +++ b/frontend/public/expand-icon.svg @@ -0,0 +1,5 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="open_in_full_FILL0_wght200_GRAD0_opsz24 1"> +<path id="Vector" d="M4 20V13H5V18.3115L18.3115 5H13V4H20V11H19V5.68848L5.68848 19H11V20H4Z" fill="white"/> +</g> +</svg> diff --git a/frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx index ba4047fb9..7d3f07676 100644 --- a/frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx +++ b/frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx @@ -23,6 +23,8 @@ const DialogSearchContract = (props: DialogSearchContractI) => { const dispatch = useAppDispatch(); const handleClose = () => { onClose(); + setSearchTerm(''); + setSearchResult(null); }; const [isEnterManually, setIsEnterManually] = useState(true); const [searchResult, setSearchResult] = useState<ContractInfoResponse | null>( @@ -34,7 +36,8 @@ const DialogSearchContract = (props: DialogSearchContractI) => { const [searchTerm, setSearchTerm] = useState(''); const { getContractInfo, contractLoading, contractError } = useContracts(); - const onSearchContract = async () => { + const onSearchContract = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); const { data } = await getContractInfo({ address: searchTerm, baseURLs: restURLs, @@ -56,6 +59,8 @@ const DialogSearchContract = (props: DialogSearchContractI) => { searchResult?.contract_info?.label ); onClose(); + setSearchTerm(''); + setSearchResult(null); } }; @@ -102,18 +107,18 @@ const DialogSearchContract = (props: DialogSearchContractI) => { </div> {isEnterManually ? ( <> - <div className="w-full flex justify-between gap-4"> + <form + onSubmit={(e) => onSearchContract(e)} + className="w-full flex justify-between gap-4" + > <SearchInputField searchTerm={searchTerm} setSearchTerm={(value: string) => setSearchTerm(value)} /> - <button - onClick={() => onSearchContract()} - className="primary-gradient search-btn" - > + <button type="submit" className="primary-gradient search-btn"> Search </button> - </div> + </form> <div className="w-full space-y-6 h-10"> {contractLoading ? ( <div className="flex-center-center gap-2"> @@ -127,7 +132,7 @@ const DialogSearchContract = (props: DialogSearchContractI) => { <> {searchResult ? ( <div className="space-y-2"> - <div className="font-semibold">Contract found:</div> + <div className="font-semibold">Search Result:</div> <ContractItem key={searchResult?.address} name={searchResult?.contract_info?.label} diff --git a/frontend/src/app/(routes)/cosmwasm/components/MessageInputFields.tsx b/frontend/src/app/(routes)/cosmwasm/components/MessageInputFields.tsx new file mode 100644 index 000000000..0504fde8a --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/MessageInputFields.tsx @@ -0,0 +1,68 @@ +import { TxStatus } from '@/types/enums'; +import { CircularProgress } from '@mui/material'; +import Image from 'next/image'; +import React from 'react'; + +const MessageInputFields = ({ + fields, + handleChange, + onQuery, + expandField, + queryLoading, +}: { + fields: MessageInputField[]; + handleChange: (e: React.ChangeEvent<HTMLInputElement>, index: number) => void; + onQuery: (index: number) => void; + expandField: (index: number) => void; + queryLoading: TxStatus; +}) => { + return ( + <div className="w-full flex flex-col gap-4"> + {fields.map((field, index) => ( + <div + key={field.name} + className="bg-[#ffffff14] rounded-2xl p-6 space-y-6" + > + <div className="flex justify-between items-center"> + <div className="text-[14px]">{field.name}</div> + <Image + onClick={() => expandField(index)} + className="cursor-pointer" + src={'/expand-icon.svg'} + height={24} + width={24} + alt="Expand" + /> + </div> + {field?.open ? ( + <div className="space-y-6"> + <div className="message-input-wrapper"> + <input + className="message-input-field" + type="text" + placeholder={`Enter ${field.name}`} + value={field.value} + onChange={(e) => handleChange(e, index)} + autoFocus={true} + /> + </div> + <button + type="button" + onClick={() => onQuery(index)} + className="primary-gradient text-[12px] font-medium py-[6px] px-6 leading-[20px] rounded-lg h-10 w-20 flex-center-center" + > + {queryLoading === TxStatus.PENDING ? ( + <CircularProgress size={18} sx={{ color: 'white' }} /> + ) : ( + 'Query' + )} + </button> + </div> + ) : null} + </div> + ))} + </div> + ); +}; + +export default MessageInputFields; diff --git a/frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx index dbbbd4412..627db21db 100644 --- a/frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx +++ b/frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx @@ -1,11 +1,9 @@ import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; import useContracts from '@/custom-hooks/useContracts'; import { queryContractInfo } from '@/store/features/cosmwasm/cosmwasmSlice'; -import { TxStatus } from '@/types/enums'; -import { CircularProgress, TextField } from '@mui/material'; import React, { useEffect, useState } from 'react'; -import { queryInputStyles } from '../styles'; import { setError } from '@/store/features/common/commonSlice'; +import QueryContractInputs from './QueryContractInputs'; interface QueryContractI { address: string; @@ -20,13 +18,25 @@ const QueryContract = (props: QueryContractI) => { // ---------------DEPENDENCIES---------------// // ------------------------------------------// const dispatch = useAppDispatch(); - const { getContractMessages, getQueryContract } = useContracts(); + const { + getContractMessages, + getQueryContract, + getContractMessageInputs, + messagesLoading, + messageInputsLoading, + messageInputsError, + messagesError, + } = useContracts(); // ------------------------------------------// // ------------------STATES------------------// // ------------------------------------------// const [queryText, setQueryText] = useState(''); const [contractMessages, setContractMessages] = useState<string[]>([]); + const [contractMessageInputs, setContractMessageInputs] = useState<string[]>( + [] + ); + const [selectedMessage, setSelectedMessage] = useState(''); const queryOutput = useAppSelector( (state) => state.cosmwasm.chains?.[chainID]?.query.queryOutput @@ -44,8 +54,29 @@ const QueryContract = (props: QueryContractI) => { setQueryText(e.target.value); }; - const handleSelectMessage = (msg: string) => { + const handleSelectMessage = async (msg: string) => { setQueryText(`{\n\t"${msg}": {}\n}`); + setSelectedMessage(msg); + const { messages } = await getContractMessageInputs({ + address, + baseURLs, + queryMsg: { [msg]: {} }, + }); + setContractMessageInputs(messages); + }; + + const handleSelectedMessageInputChange = (value: string) => { + setQueryText( + JSON.stringify( + { + [selectedMessage]: { + [value]: '', + }, + }, + undefined, + 2 + ) + ); }; const formatJSON = () => { @@ -69,8 +100,8 @@ const QueryContract = (props: QueryContractI) => { // --------------------------------------// // -----------------QUERY----------------// // --------------------------------------// - const onQuery = () => { - if (!queryText?.length) { + const onQuery = (queryInput: string) => { + if (!queryInput?.length) { dispatch( setError({ type: 'error', @@ -87,7 +118,7 @@ const QueryContract = (props: QueryContractI) => { queryContractInfo({ address, baseURLs, - queryData: queryText, + queryData: queryInput, chainID, getQueryContract, }) @@ -106,62 +137,23 @@ const QueryContract = (props: QueryContractI) => { }, [address]); return ( - <div className="flex gap-10"> - <div className="query-input-wrapper"> - <div className="space-y-4"> - <div className="font-medium">Suggested Messages:</div> - <div className="flex gap-4 flex-wrap"> - {contractMessages?.map((msg) => ( - <div - onClick={() => handleSelectMessage(msg)} - key={msg} - className="query-shortcut-msg" - > - {msg} - </div> - ))} - </div> - </div> - <div className="query-input"> - <TextField - value={queryText} - name="queryField" - placeholder={JSON.stringify({ test_query: {} }, undefined, 2)} - onChange={handleQueryChange} - fullWidth - multiline - rows={7} - InputProps={{ - sx: { - input: { - color: 'white', - fontSize: '14px', - padding: 2, - }, - }, - }} - sx={queryInputStyles} - /> - <button - onClick={onQuery} - disabled={queryLoading === TxStatus.PENDING} - className="primary-gradient query-btn" - > - {queryLoading === TxStatus.PENDING ? ( - <CircularProgress size={18} sx={{ color: 'white' }} /> - ) : ( - 'Query' - )} - </button> - <button - type="button" - onClick={formatJSON} - className="format-json-btn" - > - Format JSON - </button> - </div> - </div> + <div className="grid grid-cols-2 gap-10"> + <QueryContractInputs + contractMessageInputs={contractMessageInputs} + contractMessages={contractMessages} + formatJSON={formatJSON} + handleQueryChange={handleQueryChange} + handleSelectMessage={handleSelectMessage} + handleSelectedMessageInputChange={handleSelectedMessageInputChange} + messagesLoading={messagesLoading} + onQuery={onQuery} + queryLoading={queryLoading} + queryText={queryText} + selectedMessage={selectedMessage} + messageInputsLoading={messageInputsLoading} + messageInputsError={messageInputsError} + messagesError={messagesError} + /> <div className="query-output-box overflow-y-scroll"> <div className="qeury-output"> <pre>{JSON.stringify(queryOutput, undefined, 2)}</pre> diff --git a/frontend/src/app/(routes)/cosmwasm/components/QueryContractInputs.tsx b/frontend/src/app/(routes)/cosmwasm/components/QueryContractInputs.tsx new file mode 100644 index 000000000..b1414c9c8 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/QueryContractInputs.tsx @@ -0,0 +1,241 @@ +import { CircularProgress, TextField } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { queryInputStyles } from '../styles'; +import { TxStatus } from '@/types/enums'; +import MessageInputFields from './MessageInputFields'; + +const QueryContractInputs = (props: QueryContractInputsI) => { + const { + contractMessageInputs, + contractMessages, + handleQueryChange, + handleSelectMessage, + handleSelectedMessageInputChange, + messagesLoading, + queryText, + selectedMessage, + onQuery, + formatJSON, + queryLoading, + messageInputsLoading, + messageInputsError, + messagesError, + } = props; + + // ------------------------------------------// + // ------------------STATES------------------// + // ------------------------------------------// + const [isJSONInput, setIsJSONInput] = useState(false); + const [messageInputFields, setMessageInputFields] = useState< + MessageInputField[] + >([]); + + // ------------------------------------------------// + // -----------------CHANGE HANDLERS----------------// + // ------------------------------------------------// + const handleInputMessageChange = ( + e: React.ChangeEvent<HTMLInputElement>, + index: number + ) => { + const input = e.target.value; + const updatedFields = messageInputFields.map((value, key) => { + if (index === key) { + value.value = input; + } + return value; + }); + setMessageInputFields(updatedFields); + }; + + const expandField = (index: number) => { + const updatedFields = messageInputFields.map((field, i) => { + if (i === index) { + return { ...field, open: !field.open }; + } else { + return { ...field, open: false }; + } + }); + + setMessageInputFields(updatedFields); + }; + + const queryContract = (index: number) => { + const queryInput = JSON.stringify( + { + [selectedMessage]: { + [messageInputFields[index].name]: messageInputFields[index].value, + }, + }, + undefined, + 2 + ); + onQuery(queryInput); + }; + + // ------------------------------------------// + // ---------------SIDE EFFECT----------------// + // ------------------------------------------// + useEffect(() => { + const inputFields: MessageInputField[] = []; + contractMessageInputs.forEach((messageInput) => { + inputFields.push({ name: messageInput, open: false, value: '' }); + }); + setMessageInputFields(inputFields); + }, [contractMessageInputs]); + + return ( + <div className="query-input-wrapper"> + <div className="space-y-4"> + <div className="font-light"> + Suggested Messages: + {messagesLoading ? ( + <span className="italic "> + Fetching messages<span className="dots-flashing"></span>{' '} + </span> + ) : contractMessages?.length ? null : ( + <span className=" italic">{' '}No messages found</span> + )} + </div> + <div className="flex gap-4 flex-wrap"> + {contractMessages?.map((msg) => ( + <div + onClick={() => handleSelectMessage(msg)} + key={msg} + className={`query-shortcut-msg ${!isJSONInput && selectedMessage === msg ? 'primary-gradient' : ''}`} + > + {msg} + </div> + ))} + </div> + </div> + {contractMessageInputs?.length ? ( + <div className="space-y-4"> + <div className="font-light"> + Suggested Inputs for{' '} + <span className="font-bold">{selectedMessage}</span>: + </div> + <div className="flex gap-4 flex-wrap"> + {contractMessageInputs?.map((msg) => ( + <div + onClick={() => handleSelectedMessageInputChange(msg)} + key={msg} + className="query-shortcut-msg" + > + {msg} + </div> + ))} + </div> + </div> + ) : null} + <div className="flex justify-between items-center font-extralight"> + <div> + {isJSONInput + ? 'Enter query in JSON format:' + : messageInputFields.length + ? 'Enter field value to query:' + : 'Query:'} + </div> + <div className="change-input-type-btn-wrapper"> + <button + className="change-input-type-btn w-[104px]" + onClick={() => setIsJSONInput((prev) => !prev)} + > + {isJSONInput ? 'Enter Fields' : 'JSON Format'} + </button> + </div> + </div> + {isJSONInput ? ( + <div className="query-input"> + <TextField + value={queryText} + name="queryField" + placeholder={JSON.stringify({ test_query: {} }, undefined, 2)} + onChange={handleQueryChange} + fullWidth + multiline + rows={10} + InputProps={{ + sx: { + input: { + color: 'white', + fontSize: '14px', + padding: 2, + }, + }, + }} + sx={queryInputStyles} + /> + <button + onClick={() => onQuery(queryText)} + disabled={queryLoading === TxStatus.PENDING} + className="primary-gradient query-btn" + > + {queryLoading === TxStatus.PENDING ? ( + <CircularProgress size={18} sx={{ color: 'white' }} /> + ) : ( + 'Query' + )} + </button> + <button + type="button" + onClick={formatJSON} + className="format-json-btn" + > + Format JSON + </button> + </div> + ) : messageInputsLoading ? ( + <div className="flex-center-center gap-4 py-6 italic font-light"> + <CircularProgress size={16} sx={{ color: 'white' }} /> + <span>Fetching message inputs</span> + <span className="dots-flashing"></span> + </div> + ) : messageInputsError ? ( + <div className="flex-center-center py-6 text-red-400"> + Couldn't fetch message inputs, Please switch to JSON format + </div> + ) : !messageInputFields.length ? ( + <div> + {selectedMessage?.length ? ( + <div className="bg-[#ffffff14] rounded-2xl p-6 space-y-6"> + <div className="flex justify-between items-center"> + <div className="text-[14px]">{selectedMessage}</div> + </div> + <button + type="button" + onClick={() => onQuery(queryText)} + className="primary-gradient text-[12px] font-medium py-[6px] px-6 leading-[20px] rounded-lg h-10 w-20 flex-center-center" + > + {queryLoading === TxStatus.PENDING ? ( + <CircularProgress size={18} sx={{ color: 'white' }} /> + ) : ( + 'Query' + )} + </button> + </div> + ) : ( + <div className="flex-center-center py-6"> + {messagesError ? ( + <div className="text-red-400"> + Couldn't fetch messages, Please switch to JSON format + </div> + ) : ( + <div className="text-center">- Select a message to query -</div> + )} + </div> + )} + </div> + ) : ( + <MessageInputFields + fields={messageInputFields} + handleChange={handleInputMessageChange} + onQuery={queryContract} + expandField={expandField} + queryLoading={queryLoading} + /> + )} + </div> + ); +}; + +export default QueryContractInputs; diff --git a/frontend/src/app/(routes)/cosmwasm/cosmwasm.css b/frontend/src/app/(routes)/cosmwasm/cosmwasm.css index c14f0df5d..3a7d752ef 100644 --- a/frontend/src/app/(routes)/cosmwasm/cosmwasm.css +++ b/frontend/src/app/(routes)/cosmwasm/cosmwasm.css @@ -47,7 +47,7 @@ .query-input-wrapper, .query-output-box { - @apply w-[50%] bg-[#FFFFFF0D] rounded-2xl min-h-[380px] p-4 flex flex-col gap-4; + @apply bg-[#FFFFFF0D] rounded-2xl min-h-[380px] p-4 flex flex-col gap-4; } .execute-field-wrapper, @@ -144,3 +144,28 @@ .upload-btn { @apply flex justify-center items-center text-[12px] font-medium px-3 py-[6px] rounded-lg h-10 w-full; } + +.message-input-field { + @apply w-full border-none bg-transparent placeholder:text-[16px] placeholder:text-[#FFFFFFBF] focus:border-[1px] focus:outline-none; +} + +.message-input-wrapper { + @apply h-12 flex bg-transparent items-center px-6 py-2 rounded-lg flex-1 border-[1px] border-[#ffffff1e] hover:border-[#ffffff50]; +} + +.search-contract-wrapper:focus-within { + border: 1px solid #ffffff4a; +} + +.change-input-type-btn-wrapper { + @apply rounded-lg p-[1px]; + background: linear-gradient(to right, #4aa29c, #8b3da7); +} + +.change-input-type-btn-wrapper:hover { + background: linear-gradient(to right, #8b3da7, #4aa29c); +} + +.change-input-type-btn { + @apply bg-[#232034] text-[12px] font-semibold px-3 py-[10px] flex justify-center items-center rounded-lg; +} diff --git a/frontend/src/custom-hooks/useContracts.ts b/frontend/src/custom-hooks/useContracts.ts index 2597b41e1..397c38cda 100644 --- a/frontend/src/custom-hooks/useContracts.ts +++ b/frontend/src/custom-hooks/useContracts.ts @@ -44,13 +44,21 @@ const getCodeId = (txData: any) => { }; const useContracts = () => { + // ------------------------------------------// + // ---------------DEPENDENCIES---------------// + // ------------------------------------------// + const { getDummyWallet } = useDummyWallet(); + const { getChainInfo } = useGetChainInfo(); + + // ------------------------------------------// + // ------------------STATES------------------// + // ------------------------------------------// const [contractLoading, setContractLoading] = useState(false); const [contractError, setContractError] = useState(''); - const [messagesLoading, setMessagesLoading] = useState(false); - - const { getDummyWallet } = useDummyWallet(); - const { getChainInfo } = useGetChainInfo(); + const [messagesError, setMessagesError] = useState(''); + const [messageInputsLoading, setMessageInputsLoading] = useState(false); + const [messageInputsError, setMessageInputsError] = useState(''); const getContractInfo = async ({ address, @@ -81,21 +89,32 @@ const useContracts = () => { const getContractMessages = async ({ address, baseURLs, + queryMsg = dummyQuery, }: { address: string; baseURLs: string[]; + queryMsg?: any; }) => { - let messages = []; + let messages: string[] = []; try { setMessagesLoading(true); - setContractError(''); - await queryContract(baseURLs, address, btoa(JSON.stringify(dummyQuery))); + setMessagesError(''); + await queryContract(baseURLs, address, btoa(JSON.stringify(queryMsg))); return { messages: [], }; /* eslint-disable @typescript-eslint/no-explicit-any */ } catch (error: any) { - messages = extractContractMessages(error.message); + const errMsg = error.message; + if ( + errMsg?.includes('expected one of') || + errMsg?.includes('missing field') + ) { + messages = extractContractMessages(error.message); + } else { + messages = []; + setMessagesError('Failed to fetch messages'); + } } finally { setMessagesLoading(false); } @@ -104,6 +123,43 @@ const useContracts = () => { }; }; + const getContractMessageInputs = async ({ + address, + baseURLs, + queryMsg, + }: { + address: string; + baseURLs: string[]; + queryMsg: any; + }) => { + let messages: string[] = []; + try { + setMessageInputsLoading(true); + setMessageInputsError(''); + await queryContract(baseURLs, address, btoa(JSON.stringify(queryMsg))); + return { + messages: [], + }; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + const errMsg = error.message; + if ( + errMsg?.includes('expected one of') || + errMsg?.includes('missing field') + ) { + messages = extractContractMessages(error.message); + } else { + messages = []; + setMessageInputsError('Failed to fetch message inputs'); + } + } finally { + setMessageInputsLoading(false); + } + return { + messages, + }; + }; + const getQueryContract = async ({ address, baseURLs, @@ -324,6 +380,10 @@ const useContracts = () => { getChainAssets, uploadContract, instantiateContract, + getContractMessageInputs, + messageInputsLoading, + messageInputsError, + messagesError, }; }; diff --git a/frontend/src/types/cosmwasm.d.ts b/frontend/src/types/cosmwasm.d.ts index 68fe3a635..ac54364b7 100644 --- a/frontend/src/types/cosmwasm.d.ts +++ b/frontend/src/types/cosmwasm.d.ts @@ -154,3 +154,28 @@ interface InstantiateContractInputs { txHash: string; }>; } + +interface QueryContractInputsI { + messagesLoading: boolean; + contractMessages: string[]; + handleSelectMessage: (msg: string) => Promise<void>; + contractMessageInputs: string[]; + selectedMessage: string; + handleSelectedMessageInputChange: (value: string) => void; + queryText: string; + handleQueryChange: ( + e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> + ) => void; + onQuery: (queryInput: string) => void; + queryLoading: TxStatus; + formatJSON: () => boolean; + messageInputsLoading: boolean; + messageInputsError: string; + messagesError: string; +} + +interface MessageInputField { + name: string; + value: string; + open: boolean; +}