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;
}