Skip to content

Commit

Permalink
feat: add AddressInput component (#41)
Browse files Browse the repository at this point in the history
* Add AddressInput component

* Add MockProvider

* Add debounce

* Add FormControl, Error handling and chakra props to AddressInput

* Use useWalletProvider in storybook

* refactor AddressInput

* Fix debouncedValue typo

* Pass AddressInput test

* Add test to check if value changes correctly

* Update test name

* Add label prop, remove hard defaults

Co-authored-by: Dhaiwat Pandya <dhaiwatpandya@gmail.com>
  • Loading branch information
naz3eh and Dhaiwat10 authored Dec 6, 2021
1 parent 3e62159 commit ed78267
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@web3-ui/hooks": "^0.1.0",
"babel-loader": "^8.2.1",
"classnames": "^2.2.6",
"ethers": "^5.5.1",
"husky": "^7.0.0",
"identity-obj-proxy": "^3.0.0",
"lint-staged": "^12.1.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useEffect } from 'react';
import { storiesOf } from '@storybook/react';
import { AddressInput } from '.';
import { ethers } from 'ethers';
import { Provider, useWallet } from '@web3-ui/hooks';
import { Text } from '@chakra-ui/layout';

const WithUseWallet = () => {
const { connectWallet, connection } = useWallet();
const [value, setValue] = React.useState('');

useEffect(() => {
connectWallet!();
}, []);

return (
<>
<AddressInput value={value} onChange={(e) => setValue(e)} provider={connection.signer!} />
<Text>value: {value}</Text>
</>
);
};

const Component = ({ ...props }) => {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const [value, setValue] = React.useState('');
return (
<>
<AddressInput
value={value}
onChange={(e) => setValue(e)}
provider={provider}
placeholder='Enter input address'
{...props}
/>
<Text>value: {value}</Text>
</>
);
};

storiesOf('AddressInput', module)
.add('Default', () => {
return <Component />;
})
.add('Using @web3-hook', () => {
return (
<Provider network='mainnet'>
<WithUseWallet />
</Provider>
);
})
.add('With label', () => {
return <Component label='Address' />;
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { AddressInput } from '.';
import { ethers } from 'ethers';
import 'regenerator-runtime/runtime';

const WALLET_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
const SIGNED_MESSAGE =
'0xa2162955fbfbac44ad895441a3501465861435d6615053a64fc9622d98061f1556e47c6655d0ea02df00ed6f6050298eea381b4c46f8148ecb617b32695bdc451c';

const WINDOW_ETHEREUM = {
isMetaMask: true,
request: async (request: { method: string; params?: Array<unknown> }) => {
if (['eth_accounts', 'eth_requestAccounts'].includes(request.method)) {
return [WALLET_ADDRESS];
} else if (['personal_sign'].includes(request.method)) {
return SIGNED_MESSAGE;
}

throw Error(`Unknown request: ${request.method}`);
},
};

jest.mock('ethers', () => {
const original = jest.requireActual('ethers');
return {
...original,
ethers: {
...original.ethers,
},
};
});

const Component = () => {
const provider = new ethers.providers.Web3Provider(WINDOW_ETHEREUM);
const [value, setValue] = React.useState('');

return (
<AddressInput
value={value}
onChange={(e) => setValue(e)}
provider={provider}
placeholder='Input address'
/>
);
};

describe('AddressInput', () => {
it('renders AddressInput correctly', () => {
const { container } = render(<Component />);
expect(container);
});

it('changes Input value correctly', () => {
const { getByPlaceholderText } = render(<Component />);
const input = getByPlaceholderText('Input address') as HTMLInputElement;

fireEvent.change(input, { target: { value: WALLET_ADDRESS } });
expect(input.value).toBe(WALLET_ADDRESS);
});
});
95 changes: 95 additions & 0 deletions packages/components/src/components/AddressInput/AddressInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { FormControl, FormLabel, Input, FormErrorMessage, InputProps } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { ethers } from 'ethers';
import { useDebounce } from './useDebounce';
import { JsonRpcSigner } from '@ethersproject/providers/src.ts/json-rpc-provider';

export interface AddressInputProps {
/**
* @dev The provider or signer to fetch the address from the ens
* @type JsonRpcSigner or ethers.providers.Web3Provider
*/
provider: ethers.providers.Web3Provider | JsonRpcSigner;
/**
* @dev The value for the input
* @type string
* @default ''
*/
value: string;
/**
* @dev The label for the input
* @type string
* @default null
*/
label?: string;
/**
* @dev Change handler for the text input
* @type (value: string) => void
*/
onChange: (value: string) => void;
}

/**
* @dev A text input component that is used to get the address of the user from the ens. You can also pass all the styling props of the Chakra UI Input component.
* @param provider The provider or signer to fetch the address from the ens
* @param value The value for the input
* @param onChange Change hanlder for the text input
* @returns JSX.Element
*/
export const AddressInput: React.FC<AddressInputProps & InputProps> = ({
provider,
value,
onChange,
label,
...props
}) => {
const [inputValue, setInputValue] = useState('');
const debouncedValue = useDebounce(inputValue, 700);
const [error, setError] = useState<null | string>(null);
const regex = /^0x[a-fA-F0-9]{40}$/;

const getAddressFromEns = async () => {
try {
let address = await provider.resolveName(debouncedValue);
if (!address) {
setError('Invalid Input');
}
return address;
} catch (error) {
setError(error as string);
return;
}
};

useEffect(() => {
if (debouncedValue) {
onChange('');
setError(null);
if (regex.test(debouncedValue)) {
onChange(debouncedValue);
} else if (debouncedValue.endsWith('.eth') || debouncedValue.endsWith('.xyz')) {
getAddressFromEns().then((address) => onChange(address ? address : ''));
}
}
}, [debouncedValue]);

useEffect(() => {
if (inputValue === '') {
onChange('');
setError(null);
}
}, [inputValue]);

return (
<FormControl isInvalid={!!error}>
{label && <FormLabel>Input address</FormLabel>}
<Input
isInvalid={!!error}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
{...props}
/>
<FormErrorMessage>{error ? ' ' + error : ''}</FormErrorMessage>
</FormControl>
);
};
1 change: 1 addition & 0 deletions packages/components/src/components/AddressInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './AddressInput';
22 changes: 22 additions & 0 deletions packages/components/src/components/AddressInput/useDebounce.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';

export const useDebounce = (value: string, delay: number) => {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay] // Only re-call effect if value or delay changes
);
return debouncedValue;
};

0 comments on commit ed78267

Please sign in to comment.