From cf4f277611bfbd16d1742b35fdef0e80d778e880 Mon Sep 17 00:00:00 2001 From: ByteZhang Date: Mon, 4 Nov 2024 19:31:49 +0800 Subject: [PATCH] chore: optimize evm example --- .../ApiActuator/ApiPayloadProvider.tsx | 2 +- .../example/components/ApiForm/ApiButton.tsx | 87 ++ .../components/ApiForm/ApiCheckbox.tsx | 52 + .../components/ApiForm/ApiCombobox.tsx | 159 +++ .../example/components/ApiForm/ApiField.tsx | 81 ++ .../example/components/ApiForm/ApiForm.tsx | 133 +++ .../components/ApiForm/ApiJsonEdit.tsx | 61 ++ .../components/ApiForm/ApiSelector.tsx | 118 +++ .../components/ApiForm/ApiSeparator.tsx | 8 + .../example/components/ApiForm/ApiSwitch.tsx | 52 + .../components/ApiForm/ApiTextArea.tsx | 62 ++ .../components/ApiForm/hooks/useValidation.ts | 67 ++ packages/example/components/ApiForm/index.ts | 43 + packages/example/components/ApiForm/store.ts | 59 ++ packages/example/components/ApiForm/types.ts | 19 + .../ethereum/case/contract/SampleContracts.ts | 40 + .../ethereum/case/contract/contract1155.json | 427 ++++++++ .../ethereum/case/contract/contract721.json | 365 +++++++ .../ethereum/case/transfer/malformed.ts | 270 +++++ .../ethereum/case/transfer/malicious.ts | 158 +++ .../components/chains/ethereum/example.tsx | 579 ++++++++++- .../components/chains/ethereum/utils.ts | 3 +- .../components/context/ContextFactory.tsx | 93 +- packages/example/components/ui/command.tsx | 153 +++ packages/example/components/ui/tabs.tsx | 53 + packages/example/package.json | 19 +- packages/example/yarn.lock | 927 +++++++++++++++--- 27 files changed, 3903 insertions(+), 187 deletions(-) create mode 100644 packages/example/components/ApiForm/ApiButton.tsx create mode 100644 packages/example/components/ApiForm/ApiCheckbox.tsx create mode 100644 packages/example/components/ApiForm/ApiCombobox.tsx create mode 100644 packages/example/components/ApiForm/ApiField.tsx create mode 100644 packages/example/components/ApiForm/ApiForm.tsx create mode 100644 packages/example/components/ApiForm/ApiJsonEdit.tsx create mode 100644 packages/example/components/ApiForm/ApiSelector.tsx create mode 100644 packages/example/components/ApiForm/ApiSeparator.tsx create mode 100644 packages/example/components/ApiForm/ApiSwitch.tsx create mode 100644 packages/example/components/ApiForm/ApiTextArea.tsx create mode 100644 packages/example/components/ApiForm/hooks/useValidation.ts create mode 100644 packages/example/components/ApiForm/index.ts create mode 100644 packages/example/components/ApiForm/store.ts create mode 100644 packages/example/components/ApiForm/types.ts create mode 100644 packages/example/components/chains/ethereum/case/contract/SampleContracts.ts create mode 100644 packages/example/components/chains/ethereum/case/contract/contract1155.json create mode 100644 packages/example/components/chains/ethereum/case/contract/contract721.json create mode 100644 packages/example/components/chains/ethereum/case/transfer/malformed.ts create mode 100644 packages/example/components/chains/ethereum/case/transfer/malicious.ts create mode 100644 packages/example/components/ui/command.tsx create mode 100644 packages/example/components/ui/tabs.tsx diff --git a/packages/example/components/ApiActuator/ApiPayloadProvider.tsx b/packages/example/components/ApiActuator/ApiPayloadProvider.tsx index 0a0669a15..d539243e8 100644 --- a/packages/example/components/ApiActuator/ApiPayloadProvider.tsx +++ b/packages/example/components/ApiActuator/ApiPayloadProvider.tsx @@ -18,7 +18,7 @@ type ApiPayloadAction = | { type: 'SET_PRESUPPOSE_PARAMS'; payload: IPresupposeParam[] }; // 优化 JSON 格式化函数 -const tryFormatJson = (json: string) => { +export const tryFormatJson = (json: string) => { try { return JSON.stringify(JSON.parse(json), null, 2); } catch { diff --git a/packages/example/components/ApiForm/ApiButton.tsx b/packages/example/components/ApiForm/ApiButton.tsx new file mode 100644 index 000000000..7686cb255 --- /dev/null +++ b/packages/example/components/ApiForm/ApiButton.tsx @@ -0,0 +1,87 @@ +import React, { memo, useContext, useCallback } from 'react'; +import { useAtom } from 'jotai'; +import { Button } from '../ui/button'; +import { ApiFormContext } from './ApiForm'; +import { useValidation } from './hooks/useValidation'; +import { get } from 'lodash'; +import { toast } from '../ui/use-toast'; + +export interface ApiButtonProps { + id: string; + label: string; + onClick: () => Promise; + validation?: { + fields: string[]; + validator?: (values: Record) => string | undefined; + }; +} + +export const ApiButton = memo(({ + id, + label, + onClick, + validation +}: ApiButtonProps) => { + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiButton must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + + const loading = field.extra?.loading ?? false; + const result = field.extra?.result; + + const setResult = (value: string) => { + setField({ ...field, extra: { ...field.extra, result: value } }); + }; + + const setLoading = (value: boolean) => { + setField({ ...field, extra: { ...field.extra, loading: value } }); + }; + + const { validate } = useValidation({ + store, + validation + }); + + const handleClick = useCallback(async () => { + setResult(undefined); + + const { isValid, error } = validate(); + if (!isValid) { + setResult(error || '验证失败'); + return; + } + + try { + setLoading(true); + await onClick(); + } catch (error) { + const errorMessage = get(error, 'message', 'error') ?? JSON.stringify(error); + toast({ + title: '执行失败', + description: errorMessage, + variant: 'destructive', + }); + setResult(errorMessage); + } finally { + setLoading(false); + } + }, [onClick, validate, setLoading, setResult]); + + return ( +
+ + {result &&
{result}
} +
+ ); +}); + +ApiButton.displayName = 'ApiButton'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiCheckbox.tsx b/packages/example/components/ApiForm/ApiCheckbox.tsx new file mode 100644 index 000000000..fa32528ae --- /dev/null +++ b/packages/example/components/ApiForm/ApiCheckbox.tsx @@ -0,0 +1,52 @@ +import React, { memo, useContext, useEffect } from 'react'; +import { useAtom } from 'jotai'; +import { ApiFormContext } from './ApiForm'; +import { Checkbox } from '../ui/checkbox'; + +export interface ApiCheckboxProps { + id: string; + label?: string; + defaultChecked?: boolean; +} + +export const ApiCheckbox = memo(({ + id, + label, + defaultChecked +}: ApiCheckboxProps) => { + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiField must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + + useEffect(() => { + if (defaultChecked) { + setField({ ...field, value: defaultChecked }); + } + }, []); + + return
+ setField({ ...field, value: e })} + disabled={field.disabled} + /> + + + + {field.error && ( +
{field.error}
+ )} +
+}); + +ApiCheckbox.displayName = 'ApiCheckbox'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiCombobox.tsx b/packages/example/components/ApiForm/ApiCombobox.tsx new file mode 100644 index 000000000..79f5fc497 --- /dev/null +++ b/packages/example/components/ApiForm/ApiCombobox.tsx @@ -0,0 +1,159 @@ +import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo } from 'react'; +import { useAtom } from 'jotai'; +import { ApiFormContext } from './ApiForm'; +import { Label } from '../ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { Button } from '../ui/button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command'; +import { ChevronDownIcon, CheckIcon } from 'lucide-react'; +import { cn } from '../../lib/utils'; + +interface IOption { + value: string; + label: string; + extra?: T; + remark?: string; +} + +export interface ApiComboboxProps { + id: string; + label?: string; + required?: boolean; + defaultValue?: string; + placeholder?: string; + onRequestOptions?: () => Promise[]> + onValueChange?: (value: string | null) => void; + onOptionChange?: (option: IOption | null) => void; +} + +export interface ApiComboboxRef { + getCurrentValue: () => string | undefined; + getCurrentOption: () => IOption | undefined; + getOptions: () => IOption[]; + setValue: (value: string) => void; + setOptions: (options: IOption[]) => void; +} + +export const ApiCombobox = forwardRef(function ApiCombobox( + { + id, + label, + required, + defaultValue, + placeholder, + onRequestOptions, + onValueChange, + onOptionChange + }: ApiComboboxProps, + ref: React.Ref>, +) { + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiField must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + const options = field.extra?.options as IOption[] | undefined; + const [open, setOpen] = React.useState(false) + + const getCurrentOption = useCallback(() => { + return options?.find(opt => opt.value === field.value); + }, [options, field.value]); + + const currentOption = useMemo(() => getCurrentOption(), [getCurrentOption]); + + const setOptions = useCallback((options: IOption[]) => { + setField({ + ...field, extra: { + options + } + }); + }, [setField]); + + useEffect(() => { + if (onRequestOptions) { + onRequestOptions().then((options) => { + setOptions(options); + }); + } + }, [onRequestOptions]); + + const setValue = useCallback((value: string | null) => { + setField({ ...field, value }); + setOpen(false) + onValueChange?.(value); + onOptionChange?.(options?.find(opt => opt.value === value) ?? null); + }, [setField, onValueChange, onOptionChange, options]); + + useEffect(() => { + if (defaultValue) { + setField({ ...field, value: defaultValue }); + } + }, []); + + useImperativeHandle(ref, () => ({ + setValue, + getCurrentValue: () => currentOption?.value, + getCurrentOption: () => currentOption, + getOptions: () => options, + setOptions, + }), [currentOption]); + + return
+ {label && ( + + )} + + + + + + + + + 没有找到选项 + + {options?.map((option) => ( + { + const currentOption = options?.find(opt => opt.label === currentLabel); + setValue(currentOption?.value ?? null); + }} + > + {option.label} + + + ))} + + + + + + {currentOption?.remark && ( + + {currentOption.remark} + + )} +
+}); + +ApiCombobox.displayName = 'ApiCombobox'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiField.tsx b/packages/example/components/ApiForm/ApiField.tsx new file mode 100644 index 000000000..feec92fbb --- /dev/null +++ b/packages/example/components/ApiForm/ApiField.tsx @@ -0,0 +1,81 @@ +import React, { memo, useContext, useEffect } from 'react'; +import { useAtom } from 'jotai'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { ApiFormContext } from './ApiForm'; + + +interface ApiInputProps { + id: string; + placeholder?: string; + type?: 'text' | 'number' | 'password'; + hidden?: boolean; + defaultValue?: string; +} + +const ApiInput = memo(({ + id, + placeholder, + type = 'text', + hidden = false, + defaultValue +}: ApiInputProps) => { + + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiField must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + + useEffect(() => { + if (defaultValue) { + setField({ ...field, value: defaultValue }); + } + }, []); + + return <> + setField({ ...field, value: e.target.value })} + placeholder={placeholder} + disabled={field.disabled} + type={type} + hidden={hidden} + /> + {field.error && !hidden && ( +
{field.error}
+ )} + +}); + +export interface ApiFieldProps extends ApiInputProps { + id: string; + label?: string; + required?: boolean; +} + +export const ApiField = memo(({ + id, + label, + placeholder, + required, + hidden = false, + defaultValue +}: ApiFieldProps) => { + + return ( +
+ {label && !hidden && ( + + )} +
+ ); +}); + +ApiField.displayName = 'ApiField'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiForm.tsx b/packages/example/components/ApiForm/ApiForm.tsx new file mode 100644 index 000000000..e53c742e3 --- /dev/null +++ b/packages/example/components/ApiForm/ApiForm.tsx @@ -0,0 +1,133 @@ +import React, { forwardRef, useEffect, useImperativeHandle, useMemo } from 'react'; +import { Provider } from 'jotai'; +import { Card, CardContent, CardDescription, CardHeader } from '../ui/card'; +import { createFormStore, deleteFormStore } from './store'; +import type { FormStore } from './store'; +import { Button } from '../ui/button'; +import { stringifyWithSpecialType } from '../../lib/jsonUtils'; +import { tryFormatJson } from '../ApiActuator/ApiPayloadProvider'; +import { get } from 'lodash'; +import { IFormField } from './types'; + +export interface ApiFormRef { + reset: () => void; + getField: (id: string) => IFormField; + setField: (id: string, field: Partial>) => void; + getValue: (id: string) => any; + setValue: (id: string, value: any) => void; + setJsonValue: (id: string, value: any) => void; +} + +export interface ApiFormProps { + id?: string; + title: string; + description?: string; + children: React.ReactNode; + className?: string; +} + +export interface ApiFormContextValue { + store: FormStore; +} + +export const ApiFormContext = React.createContext(null); + + +export const ApiForm = forwardRef(function ApiForm( + { + id, + title, + description, + children, + className + }: ApiFormProps, + ref: React.Ref +) { + const store = useMemo(() => createFormStore(id), [id]); + + useEffect(() => { + return () => { + deleteFormStore(store.id); + }; + }, [store.id]); + + const contextValue = useMemo(() => ({ + store + }), [store]); + + useImperativeHandle(ref, () => ({ + reset: () => { + store.reset(); + }, + getField: (id: string) => { + return store.scope.get(store.fieldsAtom(id)); + }, + setField: (id: string, field: any) => { + const oldField = store.scope.get(store.fieldsAtom(id)); + store.scope.set(store.fieldsAtom(id), { + ...oldField, + ...field + }); + }, + getValue: (id: string) => { + return store.scope.get(store.fieldsAtom(id)).value; + }, + setValue: (id: string, value: any) => { + store.scope.set(store.fieldsAtom(id), { + value: value + }); + }, + setJsonValue: (id: string, result: any) => { + let resultString: string; + let errorMessage = ''; + + try { + // normal types + if ( + typeof result === 'number' || + typeof result === 'boolean' || + typeof result === 'string' + ) { + resultString = result.toString(); + } else { + resultString = stringifyWithSpecialType(result); + } + } catch (error) { + console.log('execute error', error); + try { + errorMessage = JSON.stringify(error); + } catch (error) { + errorMessage = get(error, 'message', 'Execution error'); + } + } + + store.scope.set(store.fieldsAtom(id), { + value: tryFormatJson(resultString) + }); + } + }), [store]); + + return ( + + + +
+
+ {title} + {description && {description}} +
+ + +
+ +
+ {children} +
+
+
+
+
+ ); +}); \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiJsonEdit.tsx b/packages/example/components/ApiForm/ApiJsonEdit.tsx new file mode 100644 index 000000000..5a071e8f8 --- /dev/null +++ b/packages/example/components/ApiForm/ApiJsonEdit.tsx @@ -0,0 +1,61 @@ +import React, { memo, useContext } from 'react'; +import { useAtom } from 'jotai'; +import { Label } from '../ui/label'; +import { ApiFormContext } from './ApiForm'; +import JsonEditor from '../ui/jsonEditor'; + + +interface JsonEditProps { + id: string; + placeholder?: string; +} + +const JsonEdit = memo(({ + id, + placeholder +}: JsonEditProps) => { + + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiField must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + + return <> + setField({ ...field, value: e })} + placeholder={placeholder} + /> + {field.error && ( +
{field.error}
+ )} + +}); + +export interface ApiJsonEditProps extends JsonEditProps { + id: string; + label?: string; + required?: boolean; +} + +export const ApiJsonEdit = memo(({ + id, + label, + placeholder, + required +}: ApiJsonEditProps) => { + return ( +
+ {label && ( + + )} + +
+ ); +}); + +ApiJsonEdit.displayName = 'ApiJsonEdit'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiSelector.tsx b/packages/example/components/ApiForm/ApiSelector.tsx new file mode 100644 index 000000000..2b038be52 --- /dev/null +++ b/packages/example/components/ApiForm/ApiSelector.tsx @@ -0,0 +1,118 @@ +import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo } from 'react'; +import { useAtom } from 'jotai'; +import { ApiFormContext } from './ApiForm'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { Label } from '../ui/label'; + +interface IOption { + value: string; + label: string; + extra?: T; + remark?: string; +} + +export interface ApiSelectorProps { + id: string; + label?: string; + required?: boolean; + defaultValue?: string; + placeholder?: string; + onRequestOptions?: () => Promise[]> +} + +export interface ApiSelectorRef { + getCurrentValue: () => string | undefined; + getCurrentOption: () => IOption | undefined; + getOptions: () => IOption[]; + setValue: (value: string) => void; +} + +export const ApiSelector = forwardRef(function ApiSelector( + { + id, + label, + required, + defaultValue, + placeholder, + onRequestOptions + }: ApiSelectorProps, + ref: React.Ref>, +) { + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiField must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + const options = field.extra?.options as IOption[] | undefined; + + const getCurrentOption = useCallback(() => { + return options?.find(opt => opt.value === field.value); + }, [options, field.value]); + + const currentOption = useMemo(() => getCurrentOption(), [getCurrentOption]); + + const setOptions = useCallback((options: IOption[]) => { + setField({ + ...field, extra: { + options: [...options] + } + }); + }, [setField]); + + useEffect(() => { + if (defaultValue) { + setField({ ...field, value: defaultValue }); + } + }, []); + + useEffect(() => { + if (onRequestOptions) { + onRequestOptions().then((options) => { + setOptions(options); + }); + } + }, [onRequestOptions]); + + + useImperativeHandle(ref, () => ({ + setValue: (key: string | null) => setField({ ...field, value: key }), + getCurrentValue: () => currentOption?.value, + getCurrentOption: () => currentOption, + getOptions: () => options, + setOptions, + }), [currentOption]); + + return
+ {label && ( + + )} + + {currentOption?.remark && ( + + {currentOption.remark} + + )} +
+}); + +ApiSelector.displayName = 'ApiSelector'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiSeparator.tsx b/packages/example/components/ApiForm/ApiSeparator.tsx new file mode 100644 index 000000000..1e0208f0a --- /dev/null +++ b/packages/example/components/ApiForm/ApiSeparator.tsx @@ -0,0 +1,8 @@ +import React, { memo } from 'react'; +import { Separator } from '../ui/separator'; + +export const ApiSeparator = memo(() => { + return +}); + +ApiSeparator.displayName = 'ApiSeparator'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiSwitch.tsx b/packages/example/components/ApiForm/ApiSwitch.tsx new file mode 100644 index 000000000..90bcc2d66 --- /dev/null +++ b/packages/example/components/ApiForm/ApiSwitch.tsx @@ -0,0 +1,52 @@ +import React, { memo, useContext, useEffect } from 'react'; +import { useAtom } from 'jotai'; +import { ApiFormContext } from './ApiForm'; +import { Switch } from '../ui/switch'; + +export interface ApiSwitchProps { + id: string; + label?: string; + defaultChecked?: boolean; +} + +export const ApiSwitch = memo(({ + id, + label, + defaultChecked +}: ApiSwitchProps) => { + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiField must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + + useEffect(() => { + if (defaultChecked) { + setField({ ...field, value: defaultChecked }); + } + }, []); + + return
+ setField({ ...field, value: e })} + disabled={field.disabled} + /> + + + + {field.error && ( +
{field.error}
+ )} +
+}); + +ApiSwitch.displayName = 'ApiSwitch'; \ No newline at end of file diff --git a/packages/example/components/ApiForm/ApiTextArea.tsx b/packages/example/components/ApiForm/ApiTextArea.tsx new file mode 100644 index 000000000..b2e5390d8 --- /dev/null +++ b/packages/example/components/ApiForm/ApiTextArea.tsx @@ -0,0 +1,62 @@ +import React, { memo, useContext } from 'react'; +import { useAtom } from 'jotai'; +import { Label } from '../ui/label'; +import { ApiFormContext } from './ApiForm'; +import { AutoHeightTextarea } from '../ui/textarea'; + + +interface TextAreaProps { + id: string; + placeholder?: string; +} + +const TextArea = memo(({ + id, + placeholder +}: TextAreaProps) => { + const context = useContext(ApiFormContext); + if (!context) throw new Error('ApiField must be used within ApiForm'); + + const { store } = context; + const [field, setField] = useAtom(store.fieldsAtom(id)); + + return <> + setField({ ...field, value: e.target.value })} + placeholder={placeholder} + disabled={field.disabled} + /> + {field.error && ( +
{field.error}
+ )} + +}); + +export interface ApiTextAreaProps extends TextAreaProps { + id: string; + label?: string; + required?: boolean; +} + +export const ApiTextArea = memo(({ + id, + label, + placeholder, + required +}: ApiTextAreaProps) => { + return ( +
+ {label && ( + + )} +