From d9fb262df7964d890e01a5d8c494221472a4b0d4 Mon Sep 17 00:00:00 2001 From: sohrab <4444588+sohrab-@users.noreply.github.com> Date: Thu, 4 Aug 2022 15:21:48 +1000 Subject: [PATCH] feat: import using url query params --- src/App.tsx | 2 + src/components/ImportModal.tsx | 90 +++++++++++++ src/components/palette/ImportTransaction.tsx | 1 - .../palette/preview/InstructionPreview.tsx | 39 +++--- .../palette/preview/TransactionPreview.tsx | 42 +++--- src/hooks/useImport.ts | 126 ++++++++++++++++++ src/index.tsx | 5 +- src/models/preview-mappers.ts | 18 ++- src/models/state-default.ts | 6 + src/models/state-types.ts | 9 ++ 10 files changed, 291 insertions(+), 47 deletions(-) create mode 100644 src/components/ImportModal.tsx create mode 100644 src/hooks/useImport.ts diff --git a/src/App.tsx b/src/App.tsx index 404cb03..52a5947 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { Transaction } from "./components/client/Transaction"; import { Web3Provider } from "./components/common/Web3Provider"; import { Footer } from "./components/Footer"; import { Header } from "./components/header/Header"; +import { ImportModal } from "./components/ImportModal"; import { Options } from "./components/options/Options"; import { Palette } from "./components/palette/Palette"; import { useSessionStoreWithoutUndo } from "./hooks/useSessionStore"; @@ -69,6 +70,7 @@ export const App: React.FC = () => { + ); diff --git a/src/components/ImportModal.tsx b/src/components/ImportModal.tsx new file mode 100644 index 0000000..3e1037f --- /dev/null +++ b/src/components/ImportModal.tsx @@ -0,0 +1,90 @@ +import { + Alert, + AlertIcon, + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Skeleton, + Text, +} from "@chakra-ui/react"; +import { useImport } from "../hooks/useImport"; +import { + useSessionStoreWithoutUndo, + useSessionStoreWithUndo, +} from "../hooks/useSessionStore"; +import { mapITransactionPreviewToITransaction } from "../models/preview-mappers"; +import { DEFAULT_IMPORT } from "../models/state-default"; +import { TransactionPreview } from "./palette/preview/TransactionPreview"; + +export const ImportModal: React.FC = () => { + const [isLoading, transaction, setImport] = useSessionStoreWithoutUndo( + (state) => [state.import.isLoading, state.import.transaction, state.set] + ); + const setTransaction = useSessionStoreWithUndo((state) => state.set); + + useImport(); // reads the query params + + const onConfirm = () => { + setTransaction((state) => { + state.transaction = mapITransactionPreviewToITransaction(transaction!); + }); + onClose(); + }; + + const onClose = () => { + setImport((state) => { + state.import = DEFAULT_IMPORT; + }); + }; + + return ( + + + + Import Transaction + + + + + + + Only import and run transactions from trusted sources! + + + You are about to import the following transaction: + + {transaction === undefined ? ( + + ) : ( + + )} + + + + + + + + + ); +}; diff --git a/src/components/palette/ImportTransaction.tsx b/src/components/palette/ImportTransaction.tsx index aea4958..ec9399d 100644 --- a/src/components/palette/ImportTransaction.tsx +++ b/src/components/palette/ImportTransaction.tsx @@ -25,7 +25,6 @@ const IMPORT_TYPES: Record = { export const ImportTransaction: React.FC = () => { const [importType, setImportType] = useState("tx"); - const [importValue, setImportValue] = useState(""); const [transaction, setTransaction] = useState(); const [error, setError] = useState(""); diff --git a/src/components/palette/preview/InstructionPreview.tsx b/src/components/palette/preview/InstructionPreview.tsx index 8d87d07..dc86624 100644 --- a/src/components/palette/preview/InstructionPreview.tsx +++ b/src/components/palette/preview/InstructionPreview.tsx @@ -22,7 +22,8 @@ import { AccountSummary } from "./AccountSummary"; export const InstructionPreview: React.FC<{ index: number; instruction: IInstructionPreview; -}> = ({ instruction, index }) => { + interactive?: boolean; +}> = ({ instruction, index, interactive = true }) => { const set = useSessionStoreWithUndo((state) => state.set); const { name, programId, accountSummary, innerInstructions } = instruction; @@ -50,23 +51,25 @@ export const InstructionPreview: React.FC<{ - - } - onClick={() => { - set((state) => { - addTo( - state.transaction.instructions, - mapIInstructionPreviewToIInstruction(instruction) - ); - }); - }} - /> - + {interactive && ( + + } + onClick={() => { + set((state) => { + addTo( + state.transaction.instructions, + mapIInstructionPreviewToIInstruction(instruction) + ); + }); + }} + /> + + )} diff --git a/src/components/palette/preview/TransactionPreview.tsx b/src/components/palette/preview/TransactionPreview.tsx index 0869b67..539ca76 100644 --- a/src/components/palette/preview/TransactionPreview.tsx +++ b/src/components/palette/preview/TransactionPreview.tsx @@ -17,9 +17,8 @@ import { useColorModeValue, } from "@chakra-ui/react"; import { useSessionStoreWithUndo } from "../../../hooks/useSessionStore"; -import { mapIInstructionPreviewToIInstruction } from "../../../models/preview-mappers"; +import { mapITransactionPreviewToITransaction } from "../../../models/preview-mappers"; import { ITransactionPreview } from "../../../models/preview-types"; -import { addTo } from "../../../models/sortable"; import { short } from "../../../models/web3js-mappers"; import { CopyButton } from "../../common/CopyButton"; import { AccountSummary } from "./AccountSummary"; @@ -27,8 +26,9 @@ import { InstructionPreview } from "./InstructionPreview"; export const TransactionPreview: React.FC<{ transaction: ITransactionPreview; -}> = ({ - transaction: { + interactive?: boolean; +}> = ({ transaction, interactive = true }) => { + const { source, sourceValue, name, @@ -36,17 +36,12 @@ export const TransactionPreview: React.FC<{ instructions, accountSummary, error, - }, -}) => { - const addInstructions = useSessionStoreWithUndo( + } = transaction; + + const setTransaction = useSessionStoreWithUndo( (state) => () => state.set((state) => { - instructions.forEach((instruction) => { - addTo( - state.transaction.instructions, - mapIInstructionPreviewToIInstruction(instruction) - ); - }); + state.transaction = mapITransactionPreviewToITransaction(transaction); }) ); @@ -108,15 +103,17 @@ export const TransactionPreview: React.FC<{ - - } - onClick={addInstructions} - /> - + {interactive && ( + + } + onClick={setTransaction} + /> + + )} {accountSummary && ( @@ -128,6 +125,7 @@ export const TransactionPreview: React.FC<{ key={index} index={index} instruction={instruction} + interactive={interactive} /> ))} diff --git a/src/hooks/useImport.ts b/src/hooks/useImport.ts new file mode 100644 index 0000000..f5f5e0b --- /dev/null +++ b/src/hooks/useImport.ts @@ -0,0 +1,126 @@ +import { useToast } from "@chakra-ui/react"; +import Ajv from "ajv"; +import axios from "axios"; +import { useEffect, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import { JSON_SCHEMA } from "../models/external-types"; +import { + mapITransactionExtToITransactionPreview, + mapTransactionResponseToITransactionPreview, +} from "../models/preview-mappers"; +import { ITransactionPreview } from "../models/preview-types"; +import { useGetWeb3Transaction } from "./useGetWeb3Transaction"; +import { usePersistentStore } from "./usePersistentStore"; +import { useSessionStoreWithoutUndo } from "./useSessionStore"; + +export const useImport = () => { + const [set, setIsLoading] = useSessionStoreWithoutUndo((state) => [ + state.set, + (value: boolean) => { + state.set((state) => { + state.import.isLoading = value; + }); + }, + ]); + const [searchParams, setSearchParams] = useSearchParams(); + + const toast = useToast(); + + // determine network + const network = searchParams.get("network"); + const rpcEndpoints = usePersistentStore( + (state) => state.appOptions.rpcEndpoints + ); + const rpcEndpoint = Object.values(rpcEndpoints.map).find( + (endpoint) => endpoint.network === (network || "mainnet-beta") + )!; + + const { start } = useGetWeb3Transaction({ + rpcEndpointUrl: rpcEndpoint.url, + onFinalised: (response) => { + set((state) => { + state.import = { + isLoading: false, + transaction: mapTransactionResponseToITransactionPreview( + response, + rpcEndpoint + ), + }; + }); + }, + onError: (error) => { + setIsLoading(false); + toast({ + title: "Transaction import failed", + description: `Failed to fetch transcation: ${error.message}`, + status: "error", + duration: 15000, + isClosable: true, + }); + }, + }); + + const validate = useMemo(() => new Ajv().compile(JSON_SCHEMA), []); + + // TODO load the transaction in the preview sidebar + // const preview = searchParams.has("preview"); + + const tx = searchParams.get("tx"); + const share = searchParams.get("share"); + + // import from transaction ID + useEffect(() => { + if (!tx) return; + + setIsLoading(true); + setSearchParams({}); + + start(tx, true); + }, [tx, setIsLoading, start, setSearchParams]); + + // import from share URL + useEffect(() => { + if (!share) return; + + setIsLoading(true); + setSearchParams({}); + + axios + .get(share) + .then((response) => { + if (!validate(response.data)) { + setIsLoading(false); + // TODO why is this triggering 3 times? + toast({ + title: "Transaction import failed", + description: `Invalid JSON from URL`, + status: "error", + duration: 15000, + isClosable: true, + }); + return; + } + + set((state) => { + state.import = { + isLoading: false, + transaction: mapITransactionExtToITransactionPreview( + response.data as ITransactionPreview, + "shareUrl", + share + ), + }; + }); + }) + .catch((err) => { + setIsLoading(false); + toast({ + title: "Transaction import failed", + description: `Cannot fetch tranasction from URL: ${err}`, + status: "error", + duration: 15000, + isClosable: true, + }); + }); + }, [share, set, setIsLoading, setSearchParams, toast, validate]); +}; diff --git a/src/index.tsx b/src/index.tsx index f85e6c6..3fc1a3f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ import { ColorModeScript } from "@chakra-ui/react"; import * as React from "react"; import * as ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import reportWebVitals from "./reportWebVitals"; import * as serviceWorker from "./serviceWorker"; @@ -19,7 +20,9 @@ root.render( } - + + + ); diff --git a/src/models/preview-mappers.ts b/src/models/preview-mappers.ts index 0fe1ba3..933a0cf 100644 --- a/src/models/preview-mappers.ts +++ b/src/models/preview-mappers.ts @@ -1,13 +1,14 @@ import { CompiledInstruction, TransactionResponse } from "@solana/web3.js"; import { mapIInstructionExtToIInstruction } from "./external-mappers"; import { IAccountExt, ITransactionExt } from "./external-types"; -import { IInstruction, IRpcEndpoint } from "./internal-types"; +import { IInstruction, IRpcEndpoint, ITransaction } from "./internal-types"; import { IAccountSummary, IInstructionPreview, ITransactionPreview, PreviewSource, } from "./preview-types"; +import { toSortableCollection } from "./sortable"; // TODO getParsedTransaction has some more info for specific instructions // { @@ -94,10 +95,17 @@ const accountSummary = (accounts: IAccountExt[]): IAccountSummary => ({ readonlyUsigned: accounts.filter((x) => !x.isSigner && !x.isWritable).length, }); +export const mapITransactionPreviewToITransaction = ({ + name, + instructions, +}: ITransactionPreview): ITransaction => ({ + name, + instructions: toSortableCollection( + instructions.map(mapIInstructionPreviewToIInstruction) + ), +}); + /** Imports a preview instruction into the current transaction */ export const mapIInstructionPreviewToIInstruction = ( instruction: IInstructionPreview -): IInstruction => ({ - ...mapIInstructionExtToIInstruction(instruction), - name: "Imported Instruction", -}); +): IInstruction => mapIInstructionExtToIInstruction(instruction); diff --git a/src/models/state-default.ts b/src/models/state-default.ts index 236b44b..d0acfbf 100644 --- a/src/models/state-default.ts +++ b/src/models/state-default.ts @@ -10,6 +10,7 @@ import { import { toSortableCollection } from "./sortable"; import { AppOptions, + ImportState, PersistentState, SessionStateWithoutUndo, SessionStateWithUndo, @@ -89,6 +90,10 @@ export const DEFAULT_TRANSACTION_RUN: ITransactionRun = { error: "", }; +export const DEFAULT_IMPORT: ImportState = { + isLoading: false, +}; + export const DEFAULT_SESSION_STATE_WITH_UNDO: SessionStateWithUndo = { transaction: DEFAULT_TRANSACTION, rpcEndpoint: DEFAULT_RPC_ENDPOINTS[0], @@ -102,6 +107,7 @@ export const DEFAULT_SESSION_STATE_WITHOUT_UNDO: SessionStateWithoutUndo = { paletteOpen: false, optionsOpen: false, }, + import: DEFAULT_IMPORT, set: () => {}, // set by the hook }; diff --git a/src/models/state-types.ts b/src/models/state-types.ts index a5f2616..9372db3 100644 --- a/src/models/state-types.ts +++ b/src/models/state-types.ts @@ -8,6 +8,7 @@ import { ITransactionOptions, ITransactionRun, } from "./internal-types"; +import { ITransactionPreview } from "./preview-types"; import { SortableCollection } from "./sortable"; export type Explorer = @@ -32,6 +33,13 @@ export interface UIState { readonly optionsOpen: boolean; } +export interface ImportState { + readonly isLoading: boolean; + readonly transaction?: ITransactionPreview; +} + +////// State Stores ////// + export interface PersistentState { readonly transactionOptions: ITransactionOptions; readonly appOptions: AppOptions; @@ -49,5 +57,6 @@ export interface SessionStateWithUndo { export interface SessionStateWithoutUndo { readonly transactionRun: ITransactionRun; readonly uiState: UIState; + readonly import: ImportState; set: (fn: (state: Draft) => void) => void; }