Compare commits

...

24 Commits

Author SHA1 Message Date
muzam1l
53f2a71b08 minor changes 2022-05-05 21:02:33 +05:30
muzam1l
866f6257f1 json schema 2022-05-05 20:00:10 +05:30
muzam1l
386619619b support object Amount 2022-05-04 16:57:32 +05:30
muzam1l
d8bf10d0b8 fix handling Destination field 2022-05-04 16:12:50 +05:30
muzam1l
d18c893025 Replace native alerts 2022-05-04 14:38:59 +05:30
muzam1l
5e997044ed alert dialog component 2022-05-02 21:05:18 +05:30
muzam1l
e88720327e Allow using only imported accounts 2022-04-28 18:51:50 +05:30
muzam1l
bf568c3f46 Update button text 2022-04-28 18:33:20 +05:30
muzam1l
1d3bd128f8 Implement auto save 2022-04-27 23:18:00 +05:30
muzam1l
ab1f45febd Editable json and unified state. 2022-04-27 18:34:28 +05:30
muzam1l
56a9806b70 add path to tx editor and refactor its value providing 2022-04-25 15:47:58 +05:30
muzam1l
b3f2d0fb6d Readonly tx json view 2022-04-22 18:46:35 +05:30
muzamil
587f09ec00 Merge pull request #168 from eqlabs/feat/improved-logs
Background logs.
2022-04-20 16:45:14 +05:30
muzam1l
9296ea1acc remove a console.log 2022-04-20 16:44:49 +05:30
muzam1l
582fb17c94 Link ledger index to explorer 2022-04-20 13:45:17 +05:30
muzam1l
aff0142870 Debug history log only after clear timestamp 2022-04-19 20:51:11 +05:30
muzamil
df51d87cb2 Merge branch 'main' into feat/improved-logs 2022-04-19 20:43:05 +05:30
muzam1l
6a46f5f173 Merge branch 'main' into feat/improved-logs 2022-04-19 18:52:13 +05:30
muzamil
9e25cefef9 Merge pull request #162 from eqlabs/feat/transaction-persistence
Persisted transactions and debug stream state.
2022-04-19 18:42:46 +05:30
muzam1l
dfe5589074 fix 2022-04-14 17:05:36 +05:30
muzam1l
cdc50da840 don't show legacy logs in debug stream 2022-04-14 16:31:19 +05:30
muzam1l
4893b41936 revert to browser time parsing only 2022-04-14 15:02:00 +05:30
muzam1l
bf21fe36c3 timestamp and log fixes 2022-04-13 21:44:24 +05:30
muzam1l
a33a3eb6e2 Background logs 2022-04-13 16:59:02 +05:30
22 changed files with 13865 additions and 1114 deletions

View File

@@ -0,0 +1,75 @@
import { FC, ReactNode } from "react";
import { proxy, useSnapshot } from "valtio";
import Button from "../Button";
import Flex from "../Flex";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from "./primitive";
export interface AlertState {
isOpen: boolean;
title?: string;
body?: ReactNode;
cancelText?: string;
confirmText?: string;
confirmPrefix?: ReactNode;
onConfirm?: () => any;
onCancel?: () => any;
}
export const alertState = proxy<AlertState>({
isOpen: false,
});
const Alert: FC = () => {
const {
title = "Are you sure?",
isOpen,
body,
cancelText,
confirmText = "Ok",
confirmPrefix,
onCancel,
onConfirm,
} = useSnapshot(alertState);
return (
<AlertDialog
open={isOpen}
onOpenChange={value => (alertState.isOpen = value)}
>
<AlertDialogContent>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
<Flex css={{ justifyContent: "flex-end", gap: "$3" }}>
{(cancelText || onCancel) && (
<AlertDialogCancel asChild>
<Button css={{ minWidth: "$16" }} outline onClick={onCancel}>
{cancelText || "Cancel"}
</Button>
</AlertDialogCancel>
)}
<AlertDialogAction asChild>
<Button
css={{ minWidth: "$16" }}
variant="primary"
onClick={async () => {
await onConfirm?.();
alertState.isOpen = false;
}}
>
{confirmPrefix}
{confirmText}
</Button>
</AlertDialogAction>
</Flex>
</AlertDialogContent>
</AlertDialog>
);
};
export default Alert;

View File

@@ -1,7 +1,7 @@
import React from "react";
import { blackA } from "@radix-ui/colors";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { styled, keyframes } from "../stitches.config";
import { styled, keyframes } from "../../stitches.config";
const overlayShow = keyframes({
"0%": { opacity: 0 },
@@ -75,7 +75,7 @@ const StyledDescription = styled(AlertDialogPrimitive.Description, {
marginBottom: 20,
color: "$mauve11",
lineHeight: 1.5,
fontSize: "$sm",
fontSize: "$md",
});
// Exports

View File

@@ -10,10 +10,18 @@ interface ISelect<T = string> {
value: T;
}
export const streamState = proxy({
export interface IStreamState {
selectedAccount: ISelect | null;
status: "idle" | "opened" | "closed";
statusChangeTimestamp?: number;
logs: ILog[];
socket?: WebSocket;
}
export const streamState = proxy<IStreamState>({
selectedAccount: null as ISelect | null,
status: "idle",
logs: [] as ILog[],
socket: undefined as WebSocket | undefined,
});
const DebugStream = () => {
@@ -40,33 +48,6 @@ const DebugStream = () => {
</>
);
const prepareLog = useCallback((str: any): ILog => {
if (typeof str !== "string") throw Error("Unrecognized debug log stream!");
const match = str.match(/([\s\S]+(?:UTC|ISO|GMT[+|-]\d+))\ ?([\s\S]*)/m);
const [_, tm, msg] = match || [];
const extracted = extractJSON(msg);
const timestamp = isNaN(Date.parse(tm || ""))
? tm
: new Date(tm).toLocaleTimeString();
const message = !extracted
? msg
: msg.slice(0, extracted.start) + msg.slice(extracted.end + 1);
const jsonData = extracted
? JSON.stringify(extracted.result, null, 2)
: undefined;
return {
type: "log",
message,
timestamp,
jsonData,
defaultCollapsed: true,
};
}, []);
useEffect(() => {
const account = selectedAccount?.value;
if (account && (!socket || !socket.url.endsWith(account))) {
@@ -82,6 +63,50 @@ const DebugStream = () => {
}
}, [selectedAccount?.value, socket]);
const onMount = useCallback(async () => {
// deliberately using `proxy` values and not the `useSnapshot` ones to have no dep list
const acc = streamState.selectedAccount;
const status = streamState.status;
if (status === "opened" && acc) {
// fetch the missing ones
try {
const url = `https://${process.env.NEXT_PUBLIC_DEBUG_STREAM_URL}/recent/${acc?.value}`;
// TODO Remove after api sets cors properly
const res = await fetch("/api/proxy", {
method: "POST",
body: JSON.stringify({ url }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) return;
const body = await res.json();
if (!body?.logs) return;
const start = streamState.statusChangeTimestamp || 0;
streamState.logs = [];
pushLog(`Debug stream opened for account ${acc.value}`, {
type: "success",
});
const logs = Object.entries(body.logs).filter(([tm]) => +tm >= start);
logs.forEach(([tm, log]) => pushLog(log));
} catch (error) {
console.error(error);
}
}
}, []);
useEffect(() => {
onMount();
}, [onMount]);
useEffect(() => {
const account = selectedAccount?.value;
const socket = streamState.socket;
@@ -89,37 +114,27 @@ const DebugStream = () => {
const onOpen = () => {
streamState.logs = [];
streamState.logs.push({
streamState.status = "opened";
streamState.statusChangeTimestamp = Date.now();
pushLog(`Debug stream opened for account ${account}`, {
type: "success",
message: `Debug stream opened for account ${account}`,
});
};
const onError = () => {
streamState.logs.push({
pushLog("Something went wrong! Check your connection and try again.", {
type: "error",
message: "Something went wrong! Check your connection and try again.",
});
};
const onClose = (e: CloseEvent) => {
streamState.logs.push({
pushLog(`Connection was closed. [code: ${e.code}]`, {
type: "error",
message: `Connection was closed. [code: ${e.code}]`,
});
streamState.selectedAccount = null;
streamState.status = "closed";
streamState.statusChangeTimestamp = Date.now();
};
const onMessage = (event: any) => {
if (!event.data) return;
const log = prepareLog(event.data);
// Filter out account_info and account_objects requests
try {
const parsed = JSON.parse(log.jsonData);
if (parsed?.id?._Request?.includes("hooks-builder-req")) {
return;
}
} catch (err) {
// Lets just skip if we cannot parse the message
}
return streamState.logs.push(log);
pushLog(event.data);
};
socket.addEventListener("open", onOpen);
@@ -133,7 +148,7 @@ const DebugStream = () => {
socket.removeEventListener("message", onMessage);
socket.removeEventListener("error", onError);
};
}, [prepareLog, selectedAccount?.value, socket]);
}, [selectedAccount?.value, socket]);
useEffect(() => {
const account = transactionsState.transactions.find(
@@ -144,15 +159,59 @@ const DebugStream = () => {
streamState.selectedAccount = account;
}, [activeTxTab]);
const clearLog = () => {
streamState.logs = [];
streamState.statusChangeTimestamp = Date.now();
};
return (
<LogBox
enhanced
renderNav={renderNav}
title="Debug stream"
logs={logs}
clearLog={() => (streamState.logs = [])}
clearLog={clearLog}
/>
);
};
export default DebugStream;
export const pushLog = (
str: any,
opts: Partial<Pick<ILog, "type">> = {}
): ILog | undefined => {
if (!str) return;
if (typeof str !== "string") throw Error("Unrecognized debug log stream!");
const match = str.match(/([\s\S]+(?:UTC|ISO|GMT[+|-]\d+))?\ ?([\s\S]*)/m);
const [_, tm, msg] = match || [];
const timestamp = Date.parse(tm || "") || undefined;
const timestring = !timestamp ? tm : new Date(timestamp).toLocaleTimeString();
const extracted = extractJSON(msg);
const message = !extracted
? msg
: msg.slice(0, extracted.start) + msg.slice(extracted.end + 1);
const jsonData = extracted
? JSON.stringify(extracted.result, null, 2)
: undefined;
if (extracted?.result?.id?._Request?.includes("hooks-builder-req")) {
return;
}
const { type = "log" } = opts;
const log: ILog = {
type,
message,
timestring,
jsonData,
defaultCollapsed: true,
};
if (log) streamState.logs.push(log);
return log;
};

View File

@@ -50,15 +50,8 @@ import Stack from "./Stack";
import { Input, Label } from "./Input";
import Text from "./Text";
import Tooltip from "./Tooltip";
import {
AlertDialog,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
} from "./AlertDialog";
import { styled } from "../stitches.config";
import { showAlert } from "../state/actions/showAlert";
const ErrorText = styled(Text, {
color: "$error",
@@ -68,7 +61,6 @@ const ErrorText = styled(Text, {
const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
const snap = useSnapshot(state);
const [createNewAlertOpen, setCreateNewAlertOpen] = useState(false);
const [editorSettingsOpen, setEditorSettingsOpen] = useState(false);
const [isNewfileDialogOpen, setIsNewfileDialogOpen] = useState(false);
const [newfileError, setNewfileError] = useState<string | null>(null);
@@ -87,13 +79,29 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
setNewfileError(null);
}, [filename, setNewfileError]);
const showNewGistAlert = () => {
showAlert("Are you sure?", {
body: (
<>
This action will create new <strong>public</strong> Github Gist from
your current saved files. You can delete gist anytime from your GitHub
Gists page.
</>
),
cancelText: "Cancel",
confirmText: "Create new Gist",
confirmPrefix: <FilePlus size="15px" />,
onConfirm: () => syncToGist(session, true),
});
};
const validateFilename = useCallback(
(filename: string): { error: string | null } => {
// check if filename already exists
if (!filename) {
return { error: "You need to add filename" };
}
if (snap.files.find((file) => file.name === filename)) {
if (snap.files.find(file => file.name === filename)) {
return { error: "Filename already exists." };
}
@@ -225,8 +233,8 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
<Label>Filename</Label>
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyPress={(e) => {
onChange={e => setFilename(e.target.value)}
onKeyPress={e => {
if (e.key === "Enter") {
handleConfirm();
}
@@ -416,7 +424,7 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
if (snap.gistOwner === session?.user.username) {
syncToGist(session);
} else {
setCreateNewAlertOpen(true);
showNewGistAlert();
}
}}
>
@@ -466,7 +474,7 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
<DropdownMenuItem
disabled={status !== "authenticated"}
onClick={() => {
setCreateNewAlertOpen(true);
showNewGistAlert();
}}
>
<FilePlus size="16px" /> Create as a new Gist
@@ -486,34 +494,6 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
) : null}
</Container>
</Flex>
<AlertDialog
open={createNewAlertOpen}
onOpenChange={(value) => setCreateNewAlertOpen(value)}
>
<AlertDialogContent>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action will create new <strong>public</strong> Github Gist from
your current saved files. You can delete gist anytime from your
GitHub Gists page.
</AlertDialogDescription>
<Flex css={{ justifyContent: "flex-end", gap: "$3" }}>
<AlertDialogCancel asChild>
<Button outline>Cancel</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="primary"
onClick={() => {
syncToGist(session, true);
}}
>
<FilePlus size="15px" /> Create new Gist
</Button>
</AlertDialogAction>
</Flex>
</AlertDialogContent>
</AlertDialog>
<Dialog open={editorSettingsOpen} onOpenChange={setEditorSettingsOpen}>
<DialogTrigger asChild>
@@ -529,8 +509,8 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
type="number"
min="1"
value={editorSettings.tabSize}
onChange={(e) =>
setEditorSettings((curr) => ({
onChange={e =>
setEditorSettings(curr => ({
...curr,
tabSize: Number(e.target.value),
}))

View File

@@ -147,7 +147,7 @@ const LogBox: FC<ILogBox> = ({
export const Log: FC<ILog> = ({
type,
timestamp: timestamp,
timestring,
message: _message,
link,
linkText,
@@ -186,8 +186,17 @@ export const Log: FC<ILog> = ({
},
[accounts]
);
_message = _message.trim().replace(/\n /gi, "\n");
const message = enrichAccounts(_message);
let message: ReactNode;
if (typeof _message === 'string') {
_message = _message.trim().replace(/\n /gi, "\n");
message = enrichAccounts(_message)
}
else {
message = _message
}
const jsonData = enrichAccounts(_jsonData);
return (
@@ -197,9 +206,9 @@ export const Log: FC<ILog> = ({
activeAccountAddress={dialogAccount}
/>
<LogText variant={type}>
{timestamp && (
{timestring && (
<Text muted monospace>
{timestamp}{" "}
{timestring}{" "}
</Text>
)}
<Pre>{message} </Pre>

View File

@@ -1,376 +0,0 @@
import { Play } from "phosphor-react";
import { FC, useCallback, useEffect } from "react";
import { useSnapshot } from "valtio";
import transactionsData from "../content/transactions.json";
import state, { modifyTransaction } from "../state";
import { sendTransaction } from "../state/actions";
import Box from "./Box";
import Button from "./Button";
import Container from "./Container";
import { streamState } from "./DebugStream";
import Flex from "./Flex";
import Input from "./Input";
import Text from "./Text";
import Select from "./Select";
type TxFields = Omit<
typeof transactionsData[0],
"Account" | "Sequence" | "TransactionType"
>;
type OtherFields = (keyof Omit<TxFields, "Destination">)[];
type SelectOption = {
value: string;
label: string;
};
export interface TransactionState {
selectedTransaction: SelectOption | null;
selectedAccount: SelectOption | null;
selectedDestAccount: SelectOption | null;
txIsLoading: boolean;
txIsDisabled: boolean;
txFields: TxFields;
}
export interface TransactionProps {
header: string;
state: TransactionState;
}
const Transaction: FC<TransactionProps> = ({
header,
state: {
selectedAccount,
selectedDestAccount,
selectedTransaction,
txFields,
txIsDisabled,
txIsLoading,
},
...props
}) => {
const { accounts } = useSnapshot(state);
const setState = useCallback(
(pTx?: Partial<TransactionState>) => {
modifyTransaction(header, pTx);
},
[header]
);
const transactionsOptions = transactionsData.map(tx => ({
value: tx.TransactionType,
label: tx.TransactionType,
}));
const accountOptions: SelectOption[] = accounts.map(acc => ({
label: acc.name,
value: acc.address,
}));
const destAccountOptions: SelectOption[] = accounts
.map(acc => ({
label: acc.name,
value: acc.address,
}))
.filter(acc => acc.value !== selectedAccount?.value);
useEffect(() => {
const transactionType = selectedTransaction?.value;
const account = accounts.find(
acc => acc.address === selectedAccount?.value
);
if (!account || !transactionType || txIsLoading) {
setState({ txIsDisabled: true });
} else {
setState({ txIsDisabled: false });
}
}, [txIsLoading, selectedTransaction, selectedAccount, accounts, setState]);
useEffect(() => {
let _txFields: TxFields | undefined = transactionsData.find(
tx => tx.TransactionType === selectedTransaction?.value
);
if (!_txFields) return setState({ txFields: {} });
_txFields = { ..._txFields } as TxFields;
if (!_txFields.Destination) setState({ selectedDestAccount: null });
// @ts-ignore
delete _txFields.TransactionType;
// @ts-ignore
delete _txFields.Account;
// @ts-ignore
delete _txFields.Sequence;
setState({ txFields: _txFields });
}, [setState, selectedTransaction]);
const submitTest = useCallback(async () => {
const account = accounts.find(
acc => acc.address === selectedAccount?.value
);
const TransactionType = selectedTransaction?.value;
if (!account || !TransactionType || txIsDisabled) return;
setState({ txIsLoading: true });
// setTxIsError(null)
try {
let options = { ...txFields };
options.Destination = selectedDestAccount?.value;
(Object.keys(options) as (keyof TxFields)[]).forEach(field => {
let _value = options[field];
// convert currency
if (typeof _value === "object" && _value.type === "currency") {
if (+_value.value) {
options[field] = (+_value.value * 1000000 + "") as any;
} else {
options[field] = undefined; // 👇 💀
}
}
// handle type: `json`
if (typeof _value === "object" && _value.type === "json") {
if (typeof _value.value === "object") {
options[field] = _value.value as any;
} else {
try {
options[field] = JSON.parse(_value.value);
} catch (error) {
const message = `Input error for json field '${field}': ${
error instanceof Error ? error.message : ""
}`;
throw Error(message);
}
}
}
// delete unneccesary fields
if (!options[field]) {
delete options[field];
}
});
const logPrefix = header ? `${header.split(".")[0]}: ` : undefined;
await sendTransaction(
account,
{
TransactionType,
...options,
},
{ logPrefix }
);
} catch (error) {
console.error(error);
if (error instanceof Error) {
state.transactionLogs.push({ type: "error", message: error.message });
}
}
setState({ txIsLoading: false });
}, [
header,
setState,
selectedAccount?.value,
selectedDestAccount?.value,
selectedTransaction?.value,
accounts,
txFields,
txIsDisabled,
]);
const resetState = useCallback(() => {
setState({});
}, [setState]);
const handleSetAccount = (acc: SelectOption) => {
setState({ selectedAccount: acc });
streamState.selectedAccount = acc;
};
const usualFields = ["TransactionType", "Amount", "Account", "Destination"];
const otherFields = Object.keys(txFields).filter(
k => !usualFields.includes(k)
) as OtherFields;
return (
<Box css={{ position: "relative", height: "calc(100% - 28px)" }} {...props}>
<Container
css={{
p: "$3 01",
fontSize: "$sm",
height: "calc(100% - 45px)",
}}
>
<Flex column fluid css={{ height: "100%", overflowY: "auto" }}>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
mt: "1px",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Transaction type:{" "}
</Text>
<Select
instanceId="transactionsType"
placeholder="Select transaction type"
options={transactionsOptions}
hideSelectedOptions
css={{ width: "70%" }}
value={selectedTransaction}
onChange={(tx: any) => setState({ selectedTransaction: tx })}
/>
</Flex>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Account:{" "}
</Text>
<Select
instanceId="from-account"
placeholder="Select your account"
css={{ width: "70%" }}
options={accountOptions}
value={selectedAccount}
onChange={(acc: any) => handleSetAccount(acc)} // TODO make react-select have correct types for acc
/>
</Flex>
{txFields.Amount !== undefined && (
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Amount (XRP):{" "}
</Text>
<Input
value={txFields.Amount.value}
onChange={e =>
setState({
txFields: {
...txFields,
Amount: { type: "currency", value: e.target.value },
},
})
}
css={{ width: "70%", flex: "inherit" }}
/>
</Flex>
)}
{txFields.Destination !== undefined && (
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Destination account:{" "}
</Text>
<Select
instanceId="to-account"
placeholder="Select the destination account"
css={{ width: "70%" }}
options={destAccountOptions}
value={selectedDestAccount}
isClearable
onChange={(acc: any) => setState({ selectedDestAccount: acc })}
/>
</Flex>
)}
{otherFields.map(field => {
let _value = txFields[field];
let value = typeof _value === "object" ? _value.value : _value;
value =
typeof value === "object"
? JSON.stringify(value)
: value?.toLocaleString();
let isCurrency =
typeof _value === "object" && _value.type === "currency";
return (
<Flex
key={field}
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
{field + (isCurrency ? " (XRP)" : "")}:{" "}
</Text>
<Input
value={value}
onChange={e =>
setState({
txFields: {
...txFields,
[field]:
typeof _value === "object"
? { ..._value, value: e.target.value }
: e.target.value,
},
})
}
css={{ width: "70%", flex: "inherit" }}
/>
</Flex>
);
})}
</Flex>
</Container>
<Flex
row
css={{
justifyContent: "space-between",
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
}}
>
<Button outline>VIEW AS JSON</Button>
<Flex row>
<Button onClick={resetState} outline css={{ mr: "$3" }}>
RESET
</Button>
<Button
variant="primary"
onClick={submitTest}
isLoading={txIsLoading}
disabled={txIsDisabled}
>
<Play weight="bold" size="16px" />
RUN TEST
</Button>
</Flex>
</Flex>
</Box>
);
};
export default Transaction;

View File

@@ -0,0 +1,184 @@
import { Play } from "phosphor-react";
import { FC, useCallback, useEffect, useMemo } from "react";
import { useSnapshot } from "valtio";
import state from "../../state";
import {
modifyTransaction,
prepareState,
prepareTransaction,
TransactionState,
} from "../../state/transactions";
import { sendTransaction } from "../../state/actions";
import Box from "../Box";
import Button from "../Button";
import Flex from "../Flex";
import { TxJson } from "./json";
import { TxUI } from "./ui";
export interface TransactionProps {
header: string;
state: TransactionState;
}
const Transaction: FC<TransactionProps> = ({
header,
state: txState,
...props
}) => {
const { accounts, editorSettings } = useSnapshot(state);
const {
selectedAccount,
selectedTransaction,
txIsDisabled,
txIsLoading,
viewType,
editorSavedValue,
editorValue,
} = txState;
const setState = useCallback(
(pTx?: Partial<TransactionState>) => {
return modifyTransaction(header, pTx);
},
[header]
);
const prepareOptions = useCallback(
(state: TransactionState = txState) => {
const {
selectedTransaction,
selectedDestAccount,
selectedAccount,
txFields,
} = state;
const TransactionType = selectedTransaction?.value || null;
const Destination =
selectedDestAccount?.value ||
("Destination" in txFields ? null : undefined);
const Account = selectedAccount?.value || null;
return prepareTransaction({
...txFields,
TransactionType,
Destination,
Account,
});
},
[txState]
);
useEffect(() => {
const transactionType = selectedTransaction?.value;
const account = selectedAccount?.value;
if (!account || !transactionType || txIsLoading) {
setState({ txIsDisabled: true });
} else {
setState({ txIsDisabled: false });
}
}, [selectedAccount?.value, selectedTransaction?.value, setState, txIsLoading]);
const submitTest = useCallback(async () => {
let st: TransactionState | undefined;
if (viewType === "json") {
// save the editor state first
const pst = prepareState(editorValue || '', txState);
if (!pst) return;
st = setState(pst);
}
const account = accounts.find(
acc => acc.address === selectedAccount?.value
);
if (txIsDisabled) return;
setState({ txIsLoading: true });
const logPrefix = header ? `${header.split(".")[0]}: ` : undefined;
try {
if (!account) {
throw Error("Account must be selected from imported accounts!");
}
const options = prepareOptions(st);
if (options.Destination === null) {
throw Error("Destination account cannot be null")
}
await sendTransaction(account, options, { logPrefix });
} catch (error) {
console.error(error);
if (error instanceof Error) {
state.transactionLogs.push({
type: "error",
message: `${logPrefix}${error.message}`,
});
}
}
setState({ txIsLoading: false });
}, [viewType, accounts, txIsDisabled, setState, header, editorValue, txState, selectedAccount?.value, prepareOptions]);
const resetState = useCallback(() => {
modifyTransaction(header, { viewType }, { replaceState: true });
}, [header, viewType]);
const jsonValue = useMemo(
() =>
editorSavedValue ||
JSON.stringify(prepareOptions?.() || {}, null, editorSettings.tabSize),
[editorSavedValue, editorSettings.tabSize, prepareOptions]
);
return (
<Box css={{ position: "relative", height: "calc(100% - 28px)" }} {...props}>
{viewType === "json" ? (
<TxJson
value={jsonValue}
header={header}
state={txState}
setState={setState}
/>
) : (
<TxUI state={txState} setState={setState} />
)}
<Flex
row
css={{
justifyContent: "space-between",
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
mb: "$1",
}}
>
<Button
onClick={() => {
if (viewType === "ui") {
setState({ editorSavedValue: null, viewType: "json" });
} else setState({ viewType: "ui" });
}}
outline
>
{viewType === "ui" ? "EDIT AS JSON" : "EXIT JSON MODE"}
</Button>
<Flex row>
<Button onClick={resetState} outline css={{ mr: "$3" }}>
RESET
</Button>
<Button
variant="primary"
onClick={submitTest}
isLoading={txIsLoading}
disabled={txIsDisabled}
>
<Play weight="bold" size="16px" />
RUN TEST
</Button>
</Flex>
</Flex>
</Box>
);
};
export default Transaction;

View File

@@ -0,0 +1,205 @@
import Editor, { loader, useMonaco } from "@monaco-editor/react";
import { FC, useCallback, useEffect, useState } from "react";
import { useTheme } from "next-themes";
import dark from "../../theme/editor/amy.json";
import light from "../../theme/editor/xcode_default.json";
import { useSnapshot } from "valtio";
import state, {
prepareState,
transactionsData,
TransactionState,
} from "../../state";
import Text from "../Text";
import Flex from "../Flex";
import { Link } from "..";
import { showAlert } from "../../state/actions/showAlert";
import { parseJSON } from "../../utils/json";
import { extractSchemaProps } from "../../utils/schema";
import amountSchema from "../../content/amount-schema.json";
loader.config({
paths: {
vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.30.1/min/vs",
},
});
interface JsonProps {
value?: string;
header?: string;
setState: (pTx?: Partial<TransactionState> | undefined) => void;
state: TransactionState;
}
export const TxJson: FC<JsonProps> = ({
value = "",
state: txState,
header,
setState,
}) => {
const { editorSettings, accounts } = useSnapshot(state);
const { editorValue = value, selectedTransaction } = txState;
const { theme } = useTheme();
const [hasUnsaved, setHasUnsaved] = useState(false);
useEffect(() => {
setState({ editorValue: value });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => {
if (editorValue === value) setHasUnsaved(false);
else setHasUnsaved(true);
}, [editorValue, value]);
const saveState = (value: string, txState: TransactionState) => {
const tx = prepareState(value, txState);
if (tx) setState(tx);
};
const discardChanges = () => {
showAlert("Confirm", {
body: "Are you sure to discard these changes?",
confirmText: "Yes",
onConfirm: () => setState({ editorValue: value }),
});
};
const onExit = (value: string) => {
const options = parseJSON(value);
if (options) {
saveState(value, txState);
return;
}
showAlert("Error!", {
body: `Malformed Transaction in ${header}, would you like to discard these changes?`,
confirmText: "Discard",
onConfirm: () => setState({ editorValue: value }),
onCancel: () => setState({ viewType: "json", editorSavedValue: value }),
});
};
const path = `file:///${header}`;
const monaco = useMonaco();
const getSchemas = useCallback((): any[] => {
const tt = selectedTransaction?.value;
const txObj = transactionsData.find(td => td.TransactionType === tt);
let genericSchemaProps: any;
if (txObj) {
genericSchemaProps = extractSchemaProps(txObj);
} else {
genericSchemaProps = transactionsData.reduce(
(cumm, td) => ({
...cumm,
...extractSchemaProps(td),
}),
{}
);
}
return [
{
uri: "file:///main-schema.json", // id of the first schema
fileMatch: ["**.json"], // associate with our model
schema: {
title: header,
type: "object",
required: ["TransactionType", "Account"],
properties: {
...genericSchemaProps,
TransactionType: {
title: "Transaction Type",
enum: transactionsData.map(td => td.TransactionType),
},
Account: {
$ref: "file:///account-schema.json",
},
Destination: {
anyOf: [
{
$ref: "file:///account-schema.json",
},
{
type: "string",
title: "Destination Account",
},
],
},
Amount: {
$ref: "file:///amount-schema.json",
},
},
},
},
{
uri: "file:///account-schema.json",
schema: {
type: "string",
title: "Account type",
enum: accounts.map(acc => acc.address),
},
},
{
...amountSchema,
},
];
}, [accounts, header, selectedTransaction?.value]);
useEffect(() => {
if (!monaco) return;
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: getSchemas(),
});
}, [getSchemas, monaco]);
return (
<Flex
fluid
column
css={{ height: "calc(100% - 45px)", position: "relative" }}
>
<Editor
className="hooks-editor"
language={"json"}
path={path}
height="100%"
beforeMount={monaco => {
monaco.editor.defineTheme("dark", dark as any);
monaco.editor.defineTheme("light", light as any);
}}
value={editorValue}
onChange={val => setState({ editorValue: val })}
onMount={(editor, monaco) => {
editor.updateOptions({
minimap: { enabled: false },
glyphMargin: true,
tabSize: editorSettings.tabSize,
dragAndDrop: true,
fontSize: 14,
});
// register onExit cb
const model = editor.getModel();
model?.onWillDispose(() => onExit(model.getValue()));
// set json defaults
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: getSchemas(),
});
}}
theme={theme === "dark" ? "dark" : "light"}
/>
{hasUnsaved && (
<Text muted small css={{ position: "absolute", bottom: 0, right: 0 }}>
This file has unsaved changes.{" "}
<Link onClick={() => saveState(editorValue, txState)}>save</Link>{" "}
<Link onClick={discardChanges}>discard</Link>
</Text>
)}
</Flex>
);
};

View File

@@ -0,0 +1,213 @@
import { FC } from "react";
import Container from "../Container";
import Flex from "../Flex";
import Input from "../Input";
import Select from "../Select";
import Text from "../Text";
import {
SelectOption,
TransactionState,
transactionsData,
TxFields,
} from "../../state/transactions";
import { useSnapshot } from "valtio";
import state from "../../state";
import { streamState } from "../DebugStream";
interface UIProps {
setState: (pTx?: Partial<TransactionState> | undefined) => void;
state: TransactionState;
}
export const TxUI: FC<UIProps> = ({ state: txState, setState }) => {
const { accounts } = useSnapshot(state);
const {
selectedAccount,
selectedDestAccount,
selectedTransaction,
txFields,
} = txState;
const transactionsOptions = transactionsData.map(tx => ({
value: tx.TransactionType,
label: tx.TransactionType,
}));
const accountOptions: SelectOption[] = accounts.map(acc => ({
label: acc.name,
value: acc.address,
}));
const destAccountOptions: SelectOption[] = accounts
.map(acc => ({
label: acc.name,
value: acc.address,
}))
.filter(acc => acc.value !== selectedAccount?.value);
const resetOptions = (tt: string) => {
const txFields: TxFields | undefined = transactionsData.find(
tx => tx.TransactionType === tt
);
if (!txFields) return setState({ txFields: {} });
const _txFields = Object.keys(txFields)
.filter(key => !["TransactionType", "Account", "Sequence"].includes(key))
.reduce<TxFields>(
(tf, key) => ((tf[key as keyof TxFields] = (txFields as any)[key]), tf),
{}
);
if (!_txFields.Destination) setState({ selectedDestAccount: null });
setState({ txFields: _txFields });
};
const handleSetAccount = (acc: SelectOption) => {
setState({ selectedAccount: acc });
streamState.selectedAccount = acc;
};
const handleChangeTxType = (tt: SelectOption) => {
setState({ selectedTransaction: tt });
resetOptions(tt.value);
};
const specialFields = ["TransactionType", "Account", "Destination"];
const otherFields = Object.keys(txFields).filter(
k => !specialFields.includes(k)
) as [keyof TxFields];
return (
<Container
css={{
p: "$3 01",
fontSize: "$sm",
height: "calc(100% - 45px)",
}}
>
<Flex column fluid css={{ height: "100%", overflowY: "auto" }}>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
mt: "1px",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Transaction type:{" "}
</Text>
<Select
instanceId="transactionsType"
placeholder="Select transaction type"
options={transactionsOptions}
hideSelectedOptions
css={{ width: "70%" }}
value={selectedTransaction}
onChange={(tt: any) => handleChangeTxType(tt)}
/>
</Flex>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Account:{" "}
</Text>
<Select
instanceId="from-account"
placeholder="Select your account"
css={{ width: "70%" }}
options={accountOptions}
value={selectedAccount}
onChange={(acc: any) => handleSetAccount(acc)} // TODO make react-select have correct types for acc
/>
</Flex>
{txFields.Destination !== undefined && (
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Destination account:{" "}
</Text>
<Select
instanceId="to-account"
placeholder="Select the destination account"
css={{ width: "70%" }}
options={destAccountOptions}
value={selectedDestAccount}
isClearable
onChange={(acc: any) => setState({ selectedDestAccount: acc })}
/>
</Flex>
)}
{otherFields.map(field => {
let _value = txFields[field];
let value: string | undefined;
if (typeof _value === "object") {
if (_value.$type === "json" && typeof _value.$value === "object") {
value = JSON.stringify(_value.$value);
} else {
value = _value.$value.toString();
}
} else {
value = _value?.toString();
}
let isXrp = typeof _value === "object" && _value.$type === "xrp";
return (
<Flex
key={field}
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
{field + (isXrp ? " (XRP)" : "")}:{" "}
</Text>
<Input
value={value}
onChange={e => {
setState({
txFields: {
...txFields,
[field]:
typeof _value === "object"
? { ..._value, $value: e.target.value }
: e.target.value,
},
});
}}
css={{ width: "70%", flex: "inherit" }}
/>
</Flex>
);
})}
</Flex>
</Container>
);
};

View File

@@ -7,7 +7,7 @@ export { default as Text } from "./Text";
export { default as Input, Label } from "./Input";
export { default as Select } from "./Select";
export * from "./Tabs";
export * from "./AlertDialog";
export * from "./AlertDialog/primitive";
export { default as Box } from "./Box";
export { default as Button } from "./Button";
export { default as Pre } from "./Pre";

View File

@@ -0,0 +1,50 @@
{
"uri": "file:///amount-schema.json",
"title": "Amount",
"description": "Specify xrp in drops and tokens as objects.",
"schema": {
"anyOf": [
{
"type": [
"number",
"string"
],
"exclusiveMinimum": 0,
"maximum": "100000000000000000"
},
{
"type": "object",
"properties": {
"currency": {
"description": "Arbitrary currency code for the token. Cannot be XRP."
},
"value": {
"type": [
"string",
"number"
],
"description": "Quoted decimal representation of the amount of the token."
},
"issuer": {
"type": "string",
"description": "Generally, the account that issues this token. In special cases, this can refer to the account that holds the token instead."
}
}
}
],
"defaultSnippets": [
{
"label": "Xrp",
"body": "1000000"
},
{
"label": "Token",
"body": {
"currency": "${1:13.1}",
"value": "${2:FOO}",
"description": "${3:rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpns}"
}
}
]
}
}

View File

@@ -27,8 +27,8 @@
"Account": "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy",
"TransactionType": "CheckCash",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"CheckID": "838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334",
"Fee": "12"
@@ -61,8 +61,8 @@
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"TransactionType": "EscrowCreate",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Destination": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW",
"CancelAfter": 533257958,
@@ -99,8 +99,8 @@
"Account": "rs8jBmmfpwgmrSPgwMsh7CvKRmRt1JTVSX",
"TokenID": "000100001E962F495F07A990F4ED55ACCFEEF365DBAA76B6A048C0A200000007",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Flags": 1
},
@@ -122,8 +122,8 @@
"Sequence": 8,
"TakerGets": "6000000",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
}
},
{
@@ -131,8 +131,8 @@
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"Destination": "ra5nK24KXen9AHvsdFTKHSANinZseWnPcX",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Fee": "12",
"Flags": 2147483648,
@@ -142,8 +142,8 @@
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"TransactionType": "PaymentChannelCreate",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Destination": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW",
"SettleDelay": 86400,
@@ -157,8 +157,8 @@
"TransactionType": "PaymentChannelFund",
"Channel": "C1AE6DDDEEC05CF2978C0BAD6FE302948E9533691DC749DCDD3B9E5992CA6198",
"Amount": {
"value": "200",
"type": "currency"
"$value": "200",
"$type": "xrp"
},
"Expiration": 543171558
},
@@ -176,8 +176,8 @@
"Fee": "12",
"SignerQuorum": 3,
"SignerEntries": {
"type": "json",
"value": [
"$type": "json",
"$value": [
{
"SignerEntry": {
"Account": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW",
@@ -213,8 +213,8 @@
"Flags": 262144,
"LastLedgerSequence": 8007750,
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Sequence": 12
}

12057
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import state from "../state";
import TimeAgo from "javascript-time-ago";
import en from "javascript-time-ago/locale/en.json";
import { useSnapshot } from "valtio";
import Alert from '../components/AlertDialog';
TimeAgo.setDefaultLocale(en.locale);
TimeAgo.addLocale(en);
@@ -140,6 +141,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
})(),
}}
/>
<Alert />
</ThemeProvider>
</SessionProvider>
</IdProvider>

18
pages/api/proxy.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { url, opts } = req.body
const r = await fetch(url, opts);
if (!r.ok) throw (r.statusText)
const data = await r.json()
return res.json(data)
} catch (error) {
console.warn(error)
return res.status(500).json({ message: "Something went wrong!" })
}
}

View File

@@ -4,19 +4,21 @@ import toast from "react-hot-toast";
import state, { IAccount } from "../index";
import calculateHookOn, { TTS } from "../../utils/hookOnCalculator";
import { SetHookData } from "../../components/SetHookDialog";
import { Link } from "../../components";
import { ref } from "valtio";
export const sha256 = async (string: string) => {
const utf8 = new TextEncoder().encode(string);
const hashBuffer = await crypto.subtle.digest('SHA-256', utf8);
const hashBuffer = await crypto.subtle.digest("SHA-256", utf8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((bytes) => bytes.toString(16).padStart(2, '0'))
.join('');
.map(bytes => bytes.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}
};
function toHex(str: string) {
var result = '';
var result = "";
for (var i = 0; i < str.length; i++) {
result += str.charCodeAt(i).toString(16);
}
@@ -51,7 +53,10 @@ function arrayBufferToHex(arrayBuffer?: ArrayBuffer | null) {
* hex string, signs the transaction and deploys it to
* Hooks testnet.
*/
export const deployHook = async (account: IAccount & { name?: string }, data: SetHookData) => {
export const deployHook = async (
account: IAccount & { name?: string },
data: SetHookData
) => {
if (
!state.files ||
state.files.length === 0 ||
@@ -69,7 +74,15 @@ export const deployHook = async (account: IAccount & { name?: string }, data: Se
const HookNamespace = (await sha256(data.HookNamespace)).toUpperCase();
const hookOnValues: (keyof TTS)[] = data.Invoke.map(tt => tt.value);
const { HookParameters } = data;
const filteredHookParameters = HookParameters.filter(hp => hp.HookParameter.HookParameterName && hp.HookParameter.HookParameterValue)?.map(aa => ({ HookParameter: { HookParameterName: toHex(aa.HookParameter.HookParameterName || ''), HookParameterValue: toHex(aa.HookParameter.HookParameterValue || '') } }));
const filteredHookParameters = HookParameters.filter(
hp =>
hp.HookParameter.HookParameterName && hp.HookParameter.HookParameterValue
)?.map(aa => ({
HookParameter: {
HookParameterName: toHex(aa.HookParameter.HookParameterName || ""),
HookParameterValue: toHex(aa.HookParameter.HookParameterValue || ""),
},
}));
// const filteredHookGrants = HookGrants.filter(hg => hg.HookGrant.Authorize || hg.HookGrant.HookHash).map(hg => {
// return {
// HookGrant: {
@@ -97,16 +110,18 @@ export const deployHook = async (account: IAccount & { name?: string }, data: Se
HookApiVersion: 0,
Flags: 1,
// ...(filteredHookGrants.length > 0 && { HookGrants: filteredHookGrants }),
...(filteredHookParameters.length > 0 && { HookParameters: filteredHookParameters }),
}
}
]
...(filteredHookParameters.length > 0 && {
HookParameters: filteredHookParameters,
}),
},
},
],
};
const keypair = derive.familySeed(account.secret);
const { signedTransaction } = sign(tx, keypair);
const currentAccount = state.accounts.find(
(acc) => acc.address === account.address
acc => acc.address === account.address
);
if (currentAccount) {
currentAccount.isLoading = true;
@@ -125,12 +140,28 @@ export const deployHook = async (account: IAccount & { name?: string }, data: Se
});
state.deployLogs.push({
type: "success",
message: `[${submitRes.engine_result}] ${submitRes.engine_result_message} Validated ledger index: ${submitRes.validated_ledger_index}`,
message: ref(
<>
[{submitRes.engine_result}] {submitRes.engine_result_message}{" "}
Validated ledger index:{" "}
<Link
as="a"
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${submitRes.validated_ledger_index}`}
target="_blank"
rel="noopener noreferrer"
>
{submitRes.validated_ledger_index}
</Link>
</>
),
// message: `[${submitRes.engine_result}] ${submitRes.engine_result_message} Validated ledger index: ${submitRes.validated_ledger_index}`,
});
} else {
state.deployLogs.push({
type: "error",
message: `[${submitRes.engine_result || submitRes.error}] ${submitRes.engine_result_message || submitRes.error_exception}`,
message: `[${submitRes.engine_result || submitRes.error}] ${
submitRes.engine_result_message || submitRes.error_exception
}`,
});
}
} catch (err) {
@@ -152,10 +183,10 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
return;
}
const currentAccount = state.accounts.find(
(acc) => acc.address === account.address
acc => acc.address === account.address
);
if (currentAccount?.isLoading || !currentAccount?.hooks.length) {
return
return;
}
if (typeof window !== "undefined") {
const tx = {
@@ -168,9 +199,9 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
Hook: {
CreateCode: "",
Flags: 1,
}
}
]
},
},
],
};
const keypair = derive.familySeed(account.secret);
@@ -188,7 +219,7 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
});
if (submitRes.engine_result === "tesSUCCESS") {
toast.success('Hook deleted successfully ✅', { id: toastId })
toast.success("Hook deleted successfully ✅", { id: toastId });
state.deployLogs.push({
type: "success",
message: "Hook deleted successfully ✅",
@@ -199,15 +230,20 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
});
currentAccount.hooks = [];
} else {
toast.error(`${submitRes.engine_result_message || submitRes.error_exception}`, { id: toastId })
toast.error(
`${submitRes.engine_result_message || submitRes.error_exception}`,
{ id: toastId }
);
state.deployLogs.push({
type: "error",
message: `[${submitRes.engine_result || submitRes.error}] ${submitRes.engine_result_message || submitRes.error_exception}`,
message: `[${submitRes.engine_result || submitRes.error}] ${
submitRes.engine_result_message || submitRes.error_exception
}`,
});
}
} catch (err) {
console.log(err);
toast.error('Error occured while deleting hoook', { id: toastId })
toast.error("Error occured while deleting hoook", { id: toastId });
state.deployLogs.push({
type: "error",
message: "Error occured while deleting hook",
@@ -218,4 +254,4 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
}
return submitRes;
}
};
};

View File

@@ -0,0 +1,23 @@
import { ref } from 'valtio';
import { AlertState, alertState } from "../../components/AlertDialog";
export const showAlert = (title: string, opts: Omit<Partial<AlertState>, 'title' | 'isOpen'> = {}) => {
const { body: _body, confirmPrefix: _confirmPrefix, ...rest } = opts
const body = (_body && typeof _body === 'object') ? ref(_body) : _body
const confirmPrefix = (_confirmPrefix && typeof _confirmPrefix === 'object') ? ref(_confirmPrefix) : _confirmPrefix
const nwState: AlertState = {
isOpen: true,
title,
body,
confirmPrefix,
cancelText: undefined,
confirmText: undefined,
onCancel: undefined,
onConfirm: undefined,
...rest,
}
Object.entries(nwState).forEach(([key, value]) => {
(alertState as any)[key] = value
})
}

View File

@@ -39,9 +39,10 @@ export interface IAccount {
export interface ILog {
type: "error" | "warning" | "log" | "success";
message: string;
message: string | JSX.Element;
key?: string;
jsonData?: any,
timestamp?: string;
timestring?: string;
link?: string;
linkText?: string;
defaultCollapsed?: boolean

View File

@@ -1,6 +1,32 @@
import { proxy } from 'valtio';
import { TransactionState } from '../components/Transaction';
import { deepEqual } from '../utils/object';
import transactionsData from "../content/transactions.json";
import state from '.';
import { showAlert } from "../state/actions/showAlert";
import { parseJSON } from '../utils/json';
export type SelectOption = {
value: string;
label: string;
};
export interface TransactionState {
selectedTransaction: SelectOption | null;
selectedAccount: SelectOption | null;
selectedDestAccount: SelectOption | null;
txIsLoading: boolean;
txIsDisabled: boolean;
txFields: TxFields;
viewType: 'json' | 'ui',
editorSavedValue: null | string,
editorValue?: string
}
export type TxFields = Omit<
typeof transactionsData[0],
"Account" | "Sequence" | "TransactionType"
>;
export const defaultTransaction: TransactionState = {
selectedTransaction: null,
@@ -9,6 +35,8 @@ export const defaultTransaction: TransactionState = {
txIsLoading: false,
txIsDisabled: false,
txFields: {},
viewType: 'ui',
editorSavedValue: null
};
export const transactionsState = proxy({
@@ -24,11 +52,13 @@ export const transactionsState = proxy({
/**
* Simple transaction state changer
* @param header Unique key and tab name for the transaction tab
* @param partialTx partial transaction state, `{}` resets the state and `undefined` deletes the transaction
* @param partialTx partial transaction state, `undefined` deletes the transaction
*
*/
export const modifyTransaction = (
header: string,
partialTx?: Partial<TransactionState>
partialTx?: Partial<TransactionState>,
opts: { replaceState?: boolean } = {}
) => {
const tx = transactionsState.transactions.find(tx => tx.header === header);
@@ -40,14 +70,24 @@ export const modifyTransaction = (
}
if (!tx) {
const state = {
...defaultTransaction,
...partialTx,
}
transactionsState.transactions.push({
header,
state: {
...defaultTransaction,
...partialTx,
},
state,
});
return;
return state;
}
if (opts.replaceState) {
const repTx: TransactionState = {
...defaultTransaction,
...partialTx,
}
tx.state = repTx
return repTx
}
Object.keys(partialTx).forEach(k => {
@@ -56,4 +96,130 @@ export const modifyTransaction = (
const p = partialTx as any;
if (!deepEqual(s[k], p[k])) s[k] = p[k];
});
};
return tx.state
};
// state to tx options
export const prepareTransaction = (data: any) => {
let options = { ...data };
(Object.keys(options)).forEach(field => {
let _value = options[field];
// convert xrp
if (_value && typeof _value === "object" && _value.$type === "xrp") {
if (+_value.$value) {
options[field] = (+_value.$value * 1000000 + "") as any;
} else {
options[field] = undefined; // 👇 💀
}
}
// handle type: `json`
if (_value && typeof _value === "object" && _value.$type === "json") {
if (typeof _value.$value === "object") {
options[field] = _value.$value as any;
} else {
try {
options[field] = JSON.parse(_value.$value);
} catch (error) {
const message = `Input error for json field '${field}': ${error instanceof Error ? error.message : ""
}`;
console.error(message)
options[field] = _value.$value
}
}
}
// delete unneccesary fields
if (options[field] === undefined) {
delete options[field];
}
});
return options
}
// editor value to state
export const prepareState = (value: string, txState: TransactionState) => {
const options = parseJSON(value);
if (!options) {
showAlert("Error!", {
body: "Cannot save editor with malformed transaction."
})
return
};
const { Account, TransactionType, Destination, ...rest } = options;
let tx: Partial<TransactionState> = {};
const { txFields } = txState
if (Account) {
const acc = state.accounts.find(acc => acc.address === Account);
if (acc) {
tx.selectedAccount = {
label: acc.name,
value: acc.address,
};
} else {
tx.selectedAccount = {
label: Account,
value: Account,
};
}
} else {
tx.selectedAccount = null;
}
if (TransactionType) {
tx.selectedTransaction = {
label: TransactionType,
value: TransactionType,
};
} else {
tx.selectedTransaction = null;
}
if (txFields.Destination !== undefined) {
const dest = state.accounts.find(acc => acc.address === Destination);
rest.Destination = null
if (dest) {
tx.selectedDestAccount = {
label: dest.name,
value: dest.address,
};
}
else if (Destination) {
tx.selectedDestAccount = {
label: Destination,
value: Destination,
};
}
else {
tx.selectedDestAccount = null
}
}
Object.keys(rest).forEach(field => {
const value = rest[field];
const origValue = txFields[field as keyof TxFields]
const isXrp = typeof value !== 'object' && origValue && typeof origValue === 'object' && origValue.$type === 'xrp'
if (isXrp) {
rest[field] = {
$type: "xrp",
$value: +value / 1000000, // TODO maybe use bigint?
};
} else if (typeof value === "object") {
rest[field] = {
$type: "json",
$value: value,
};
}
});
tx.txFields = rest;
tx.editorSavedValue = null;
return tx
}
export { transactionsData }

View File

@@ -18,4 +18,14 @@ export const extractJSON = (str?: string) => {
} while (firstClose > firstOpen);
firstOpen = str.indexOf('{', firstOpen + 1);
} while (firstOpen != -1);
}
export const parseJSON = (str?: string | null): any | undefined => {
if (!str) return undefined
try {
const parsed = JSON.parse(str);
return typeof parsed === "object" ? parsed : undefined;
} catch (error) {
return undefined;
}
}

39
utils/schema.ts Normal file
View File

@@ -0,0 +1,39 @@
export const extractSchemaProps = <O extends object>(obj: O) =>
Object.entries(obj).reduce((prev, [key, val]) => {
const typeOf = <T>(arg: T) =>
arg instanceof Array
? "array"
: arg === null
? "undefined"
: typeof arg;
const value = (typeOf(val) === "object" && '$type' in val && '$value' in val) ? val?.$value : val;
const type = typeOf(value);
let schema: any = {
title: key,
type,
default: value,
}
if (typeOf(value) === 'array') {
const item = value[0] // TODO merge other item schema's into one
if (typeOf(item) !== 'object') {
schema.items = {
type: 'object',
properties: extractSchemaProps(item),
default: item
}
}
// TODO support primitive-value arrays
}
if (typeOf(value) === "object") {
schema.properties = extractSchemaProps(value)
}
return {
...prev,
[key]: schema,
};
}, {} as any);

1168
yarn.lock

File diff suppressed because it is too large Load Diff