421 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			421 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'
 | 
						|
 | 
						|
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
 |