Files
xrpl-hooks-ide/components/SetHookDialog.tsx
2022-07-29 14:33:28 +05:30

464 lines
16 KiB
TypeScript

import React, { useCallback, useEffect, useState } from "react";
import { Plus, Trash, X } from "phosphor-react";
import { Button, Box, Text } from ".";
import { Stack, Flex, Select } from ".";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogTrigger,
} from "./Dialog";
import { Input, Label } from "./Input";
import {
Controller,
SubmitHandler,
useFieldArray,
useForm,
} from "react-hook-form";
import { deployHook } from "../state/actions";
import { useSnapshot } from "valtio";
import state, { IFile, SelectOption } from "../state";
import toast from "react-hot-toast";
import { prepareDeployHookTx, sha256 } from "../state/actions/deployHook";
import estimateFee from "../utils/estimateFee";
import {
getParameters,
getInvokeOptions,
transactionOptions,
SetHookData,
} from "../utils/setHook";
import { capitalize } from "../utils/helpers";
export const SetHookDialog: React.FC<{ accountAddress: string }> = React.memo(
({ accountAddress }) => {
const snap = useSnapshot(state);
const [estimateLoading, setEstimateLoading] = useState(false);
const [isSetHookDialogOpen, setIsSetHookDialogOpen] = useState(false);
const compiledFiles = snap.files.filter(file => file.compiledContent);
const activeFile = compiledFiles[snap.activeWat] as IFile | undefined;
const accountOptions: SelectOption[] = snap.accounts.map(acc => ({
label: acc.name,
value: acc.address,
}));
const [selectedAccount, setSelectedAccount] = useState(
accountOptions.find(acc => acc.value === accountAddress)
);
const account = snap.accounts.find(
acc => acc.address === selectedAccount?.value
);
const getHookNamespace = useCallback(
() =>
(activeFile && snap.deployValues[activeFile.name]?.HookNamespace) ||
activeFile?.name.split(".")[0] ||
"",
[activeFile, snap.deployValues]
);
const getDefaultValues = useCallback((): Partial<SetHookData> => {
const content = activeFile?.compiledValueSnapshot;
return (
(activeFile && snap.deployValues[activeFile.name]) || {
HookNamespace: getHookNamespace(),
Invoke: getInvokeOptions(content),
HookParameters: getParameters(content),
}
);
}, [activeFile, getHookNamespace, snap.deployValues]);
const {
register,
handleSubmit,
control,
watch,
setValue,
getValues,
reset,
formState: { errors },
} = useForm<SetHookData>({
defaultValues: getDefaultValues(),
});
const { fields, append, remove } = useFieldArray({
control,
name: "HookParameters", // unique name for your Field Array
});
const watchedFee = watch("Fee");
// Reset form if activeFile changes
useEffect(() => {
if (!activeFile) return;
const defaultValues = getDefaultValues();
reset(defaultValues);
}, [activeFile, getDefaultValues, reset]);
useEffect(() => {
if (
watchedFee &&
(watchedFee.includes(".") || watchedFee.includes(","))
) {
setValue("Fee", watchedFee.replaceAll(".", "").replaceAll(",", ""));
}
}, [watchedFee, setValue]);
// const {
// fields: grantFields,
// append: grantAppend,
// remove: grantRemove,
// } = useFieldArray({
// control,
// name: "HookGrants", // unique name for your Field Array
// });
const [hashedNamespace, setHashedNamespace] = useState("");
const namespace = watch("HookNamespace", getHookNamespace());
const calculateHashedValue = useCallback(async () => {
const hashedVal = await sha256(namespace);
setHashedNamespace(hashedVal.toUpperCase());
}, [namespace]);
useEffect(() => {
calculateHashedValue();
}, [namespace, calculateHashedValue]);
const calculateFee = useCallback(async () => {
if (!account) return;
const formValues = getValues();
const tx = await prepareDeployHookTx(account, formValues);
if (!tx) {
return;
}
const res = await estimateFee(tx, account);
if (res && res.base_fee) {
setValue("Fee", Math.round(Number(res.base_fee || "")).toString());
}
}, [account, getValues, setValue]);
const tooLargeFile = () => {
return Boolean(
activeFile?.compiledContent?.byteLength &&
activeFile?.compiledContent?.byteLength >= 64000
);
};
const onSubmit: SubmitHandler<SetHookData> = async data => {
const currAccount = state.accounts.find(
acc => acc.address === account?.address
);
if (!account) return;
if (currAccount) currAccount.isLoading = true;
data.HookParameters.forEach(param => {
delete param.$metaData;
return param;
});
const res = await deployHook(account, data);
if (currAccount) currAccount.isLoading = false;
if (res && res.engine_result === "tesSUCCESS") {
toast.success("Transaction succeeded!");
return setIsSetHookDialogOpen(false);
}
toast.error(`Transaction failed! (${res?.engine_result_message})`);
};
const onOpenChange = useCallback((open: boolean) => {
setIsSetHookDialogOpen(open);
if (open) calculateFee();
}, [calculateFee]);
return (
<Dialog open={isSetHookDialogOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button
ghost
size="xs"
uppercase
variant={"secondary"}
disabled={
!account || account.isLoading || !activeFile || tooLargeFile()
}
>
Set Hook
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogTitle>Deploy configuration</DialogTitle>
<DialogDescription as="div">
<Stack css={{ width: "100%", flex: 1 }}>
<Box css={{ width: "100%" }}>
<Label>Account</Label>
<Select
instanceId="deploy-account"
placeholder="Select account"
options={accountOptions}
value={selectedAccount}
onChange={(acc: any) => setSelectedAccount(acc)}
/>
</Box>
<Box css={{ width: "100%" }}>
<Label>Invoke on transactions</Label>
<Controller
name="Invoke"
control={control}
render={({ field }) => (
<Select
{...field}
closeMenuOnSelect={false}
isMulti
menuPosition="fixed"
options={transactionOptions}
/>
)}
/>
</Box>
<Box css={{ width: "100%" }}>
<Label>Hook Namespace Seed</Label>
<Input
{...register("HookNamespace", { required: true })}
autoComplete={"off"}
/>
{errors.HookNamespace?.type === "required" && (
<Box css={{ display: "inline", color: "$red11" }}>
Namespace is required
</Box>
)}
<Box css={{ mt: "$3" }}>
<Label>Hook Namespace (sha256)</Label>
<Input readOnly value={hashedNamespace} />
</Box>
</Box>
<Box css={{ width: "100%" }}>
<Label style={{ marginBottom: "10px", display: "block" }}>
Hook parameters
</Label>
<Stack>
{fields.map((field, index) => (
<Stack key={field.id}>
<Flex column>
<Flex row>
<Input
// important to include key with field's id
placeholder="Parameter name"
readOnly={field.$metaData?.required}
{...register(
`HookParameters.${index}.HookParameter.HookParameterName`
)}
/>
<Input
css={{ mx: "$2" }}
placeholder="Value (hex-quoted)"
{...register(
`HookParameters.${index}.HookParameter.HookParameterValue`,
{ required: field.$metaData?.required }
)}
/>
<Button
onClick={() => remove(index)}
variant="destroy"
>
<Trash weight="regular" size="16px" />
</Button>
</Flex>
{errors.HookParameters?.[index]?.HookParameter
?.HookParameterValue?.type === "required" && (
<Text error>This field is required</Text>
)}
<Label css={{ fontSize: "$sm", mt: "$1" }}>
{capitalize(field.$metaData?.description)}
</Label>
</Flex>
</Stack>
))}
<Button
outline
fullWidth
type="button"
onClick={() =>
append({
HookParameter: {
HookParameterName: "",
HookParameterValue: "",
},
})
}
>
<Plus size="16px" />
Add Hook Parameter
</Button>
</Stack>
</Box>
<Box css={{ width: "100%", position: "relative" }}>
<Label>Fee</Label>
<Box css={{ display: "flex", alignItems: "center" }}>
<Input
type="number"
{...register("Fee", { required: true })}
autoComplete={"off"}
onKeyPress={e => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
}}
step="1"
defaultValue={10000}
css={{
"-moz-appearance": "textfield",
"&::-webkit-outer-spin-button": {
"-webkit-appearance": "none",
margin: 0,
},
"&::-webkit-inner-spin-button ": {
"-webkit-appearance": "none",
margin: 0,
},
}}
/>
<Button
size="xs"
variant="primary"
outline
isLoading={estimateLoading}
css={{
position: "absolute",
right: "$2",
fontSize: "$xs",
cursor: "pointer",
alignContent: "center",
display: "flex",
}}
onClick={async e => {
e.preventDefault();
if (!account) return;
setEstimateLoading(true);
const formValues = getValues();
try {
const tx = await prepareDeployHookTx(
account,
formValues
);
if (tx) {
const res = await estimateFee(tx, account);
if (res && res.base_fee) {
setValue(
"Fee",
Math.round(
Number(res.base_fee || "")
).toString()
);
}
}
} catch (err) {}
setEstimateLoading(false);
}}
>
Suggest
</Button>
</Box>
{errors.Fee?.type === "required" && (
<Box css={{ display: "inline", color: "$red11" }}>
Fee is required
</Box>
)}
</Box>
{/* <Box css={{ width: "100%" }}>
<label style={{ marginBottom: "10px", display: "block" }}>
Hook Grants
</label>
<Stack>
{grantFields.map((field, index) => (
<Stack key={field.id}>
<Input
// important to include key with field's id
placeholder="Authorize"
{...register(
`HookGrants.${index}.HookGrant.Authorize`,
{ minLength: 5 }
)}
/>
<Input
placeholder="HookHash"
{...register(`HookGrants.${index}.HookGrant.HookHash`, {
minLength: 64,
maxLength: 64,
})}
/>
<Button
onClick={() => grantRemove(index)}
variant="destroy"
>
<Trash weight="regular" size="16px" />
</Button>
</Stack>
))}
<Button
outline
fullWidth
type="button"
onClick={() =>
grantAppend({
HookGrant: {
Authorize: "",
HookHash: "",
},
})
}
>
<Plus size="16px" />
Add Hook Grant
</Button>
</Stack>
</Box> */}
</Stack>
</DialogDescription>
<Flex
css={{
marginTop: 25,
justifyContent: "flex-end",
gap: "$3",
}}
>
<DialogClose asChild>
<Button outline>Cancel</Button>
</DialogClose>
{/* <DialogClose asChild> */}
<Button
variant="primary"
type="submit"
isLoading={account?.isLoading}
>
Set Hook
</Button>
{/* </DialogClose> */}
</Flex>
<DialogClose asChild>
<Box css={{ position: "absolute", top: "$3", right: "$3" }}>
<X size="20px" />
</Box>
</DialogClose>
</form>
</DialogContent>
</Dialog>
);
}
);
SetHookDialog.displayName = "SetHookDialog";
export default SetHookDialog;