426 lines
15 KiB
TypeScript
426 lines
15 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'
|
|
import AccountSequence from './Sequence'
|
|
|
|
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%', position: 'relative' }}>
|
|
<Label>Sequence</Label>
|
|
<AccountSequence address={selectedAccount?.value} />
|
|
</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
|