diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f853571..4145ee83a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Implement `DaoDataListItem`, `ProposalDataListItem.Structure`, and `MemberDataListItem.Structure` module components +- Implement `DaoDataListItem.Structure`, `ProposalDataListItem.Structure`, `MemberDataListItem.Structure` and + `AddressInput` module components - Implement `StatePingAnimation` core component +- Implement `addressUtils` and `ensUtils` module utilities +- Implement `useDebouncedValue` core hook and `clipboardUtils` core utility ### Changed - Update `Tag` component primary variant styling - Update Eslint rules to align usage of boolean properties +- Update default query-client options to set a stale time greater than 0 ### Fixed diff --git a/jest.config.js b/jest.config.js index 7a1ba3323..5d16d9d90 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,7 +10,7 @@ const config = { '^.+\\.svg$': '/src/core/test/svgTransform.js', '^.+\\.m?[tj]sx?$': 'ts-jest', }, - transformIgnorePatterns: ['node_modules/(?!(.*\\.mjs$|react-merge-refs))'], + transformIgnorePatterns: ['node_modules/(?!(.*\\.mjs$|react-merge-refs|wagmi|@wagmi))'], }; module.exports = config; diff --git a/src/core/components/input/index.ts b/src/core/components/input/index.ts index 63f8e3e07..d3ec46bc8 100644 --- a/src/core/components/input/index.ts +++ b/src/core/components/input/index.ts @@ -1,3 +1,4 @@ +export * from './hooks'; export * from './inputContainer'; export * from './inputDate'; export * from './inputFileAvatar'; diff --git a/src/core/components/input/inputContainer/inputContainer.api.ts b/src/core/components/input/inputContainer/inputContainer.api.ts index bf88e5074..28aa67cf5 100644 --- a/src/core/components/input/inputContainer/inputContainer.api.ts +++ b/src/core/components/input/inputContainer/inputContainer.api.ts @@ -65,7 +65,8 @@ export interface IInputContainerBaseProps { */ wrapperClassName?: string; /** - * Shortcircuits all the input wrapper classes to pass control to the child component. + * Does not render the default input wrapper when set to true, to be used for using the base input container + * properties (label, helpText, ..) for components without a input wrapper (e.g. file inputs). */ useCustomWrapper?: boolean; } @@ -75,7 +76,7 @@ export interface IInputContainerProps extends IInputContainerBaseProps, Omit - extends Omit, + extends Omit, Omit, 'type'> { /** * Classes for the input element. diff --git a/src/core/components/textAreas/textArea/textArea.tsx b/src/core/components/textAreas/textArea/textArea.tsx index a73fe57de..e0c88a0bd 100644 --- a/src/core/components/textAreas/textArea/textArea.tsx +++ b/src/core/components/textAreas/textArea/textArea.tsx @@ -1,7 +1,6 @@ import classNames from 'classnames'; import { forwardRef } from 'react'; -import { InputContainer, type IInputComponentProps } from '../../input'; -import { useInputProps } from '../../input/hooks'; +import { InputContainer, useInputProps, type IInputComponentProps } from '../../input'; export interface ITextAreaProps extends IInputComponentProps {} diff --git a/src/core/hooks/index.ts b/src/core/hooks/index.ts new file mode 100644 index 000000000..0f52b345c --- /dev/null +++ b/src/core/hooks/index.ts @@ -0,0 +1 @@ +export * from './useDebouncedValue'; diff --git a/src/core/hooks/useDebouncedValue/index.ts b/src/core/hooks/useDebouncedValue/index.ts new file mode 100644 index 000000000..44bafc971 --- /dev/null +++ b/src/core/hooks/useDebouncedValue/index.ts @@ -0,0 +1 @@ +export { useDebouncedValue, type IUseDebouncedValueParams, type IUseDebouncedValueResult } from './useDebouncedValue'; diff --git a/src/core/hooks/useDebouncedValue/useDebouncedValue.test.ts b/src/core/hooks/useDebouncedValue/useDebouncedValue.test.ts new file mode 100644 index 000000000..590414dc6 --- /dev/null +++ b/src/core/hooks/useDebouncedValue/useDebouncedValue.test.ts @@ -0,0 +1,34 @@ +import { act, renderHook } from '@testing-library/react'; +import { useDebouncedValue } from './useDebouncedValue'; + +describe('useDebouncedValue hook', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + it('returns the value initialised to the value property and a function to update the debounced value', () => { + const value = 'test-value'; + const { result } = renderHook(() => useDebouncedValue(value)); + expect(result.current).toEqual([value, expect.any(Function)]); + }); + + it('debounces the value updates', () => { + const newValue = 'test'; + const { result, rerender } = renderHook((value) => useDebouncedValue(value)); + expect(result.current[0]).toBeUndefined(); + + rerender(newValue); + expect(result.current[0]).toBeUndefined(); + + act(() => jest.runAllTimers()); + expect(result.current[0]).toEqual(newValue); + }); + + it('the returned setter updates the debounced value', () => { + const newValue = 'my-value'; + const { result } = renderHook((value) => useDebouncedValue(value)); + expect(result.current[0]).toBeUndefined(); + act(() => result.current[1](newValue)); + expect(result.current[0]).toEqual(newValue); + }); +}); diff --git a/src/core/hooks/useDebouncedValue/useDebouncedValue.ts b/src/core/hooks/useDebouncedValue/useDebouncedValue.ts new file mode 100644 index 000000000..354dfb39b --- /dev/null +++ b/src/core/hooks/useDebouncedValue/useDebouncedValue.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface IUseDebouncedValueParams { + /** + * Debounce time period in milliseconds. + * @default 500 + */ + delay?: number; +} + +export type IUseDebouncedValueResult = [ + /** + * Debounced value. + */ + TValue, + /** + * Setter for the debounced value. + */ + (value: TValue) => void, +]; + +export const useDebouncedValue = ( + value: TValue, + params: IUseDebouncedValueParams = {}, +): IUseDebouncedValueResult => { + const { delay } = params; + + const [debouncedValue, setDebouncedValue] = useState(value); + + const timeoutRef = useRef(); + + useEffect(() => { + timeoutRef.current = setTimeout(() => setDebouncedValue(value), delay); + + return () => clearTimeout(timeoutRef.current); + }, [value, delay]); + + return [debouncedValue, setDebouncedValue]; +}; diff --git a/src/core/index.ts b/src/core/index.ts index 974d976bb..14b0c36c8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,4 @@ export * from './components'; +export * from './hooks'; export * from './types'; export * from './utils'; diff --git a/src/core/test/setup.ts b/src/core/test/setup.ts index 7ff1a6b29..a81b553c6 100644 --- a/src/core/test/setup.ts +++ b/src/core/test/setup.ts @@ -3,7 +3,11 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; +import { TextDecoder, TextEncoder } from 'util'; import { testLogger } from './utils'; // Setup test logger testLogger.setup(); + +// Globally setup TextEncoder/TextDecoder needed by viem +Object.assign(global, { TextDecoder, TextEncoder }); diff --git a/src/core/utils/clipboardUtils/clipboardUtils.test.ts b/src/core/utils/clipboardUtils/clipboardUtils.test.ts new file mode 100644 index 000000000..cd7c7dde1 --- /dev/null +++ b/src/core/utils/clipboardUtils/clipboardUtils.test.ts @@ -0,0 +1,61 @@ +import { testLogger } from '../../test'; +import { clipboardUtils } from './clipboardUtils'; + +// Navigator.clipboard object is not defined on Jest by default +Object.defineProperty(navigator, 'clipboard', { + value: { writeText: jest.fn(), readText: jest.fn() }, +}); + +describe('clipboard utils', () => { + describe('copy', () => { + const writeTextMock = jest.spyOn(navigator.clipboard, 'writeText'); + + afterEach(() => { + writeTextMock.mockReset(); + }); + + it('copies the specified value on the user clipboard', async () => { + const copyValue = 'copy-value'; + await clipboardUtils.copy(copyValue); + expect(writeTextMock).toHaveBeenCalledWith(copyValue); + }); + + it('calls the onError callback on copy error', async () => { + testLogger.suppressErrors(); + const onError = jest.fn(); + const error = new Error('test-error'); + writeTextMock.mockImplementation(() => { + throw error; + }); + await clipboardUtils.copy('test', { onError }); + expect(onError).toHaveBeenCalledWith(error); + }); + }); + + describe('paste', () => { + const readTextMock = jest.spyOn(navigator.clipboard, 'readText'); + + afterEach(() => { + readTextMock.mockReset(); + }); + + it('reads and returns the user clipboard', async () => { + const clipboardValue = 'test-value'; + readTextMock.mockResolvedValue(clipboardValue); + const result = await clipboardUtils.paste(); + expect(result).toEqual(clipboardValue); + }); + + it('calls the onError callback on paste error', async () => { + testLogger.suppressErrors(); + const onError = jest.fn(); + const error = new Error('test-error'); + readTextMock.mockImplementation(() => { + throw error; + }); + const result = await clipboardUtils.paste({ onError }); + expect(result).toEqual(''); + expect(onError).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/src/core/utils/clipboardUtils/clipboardUtils.ts b/src/core/utils/clipboardUtils/clipboardUtils.ts new file mode 100644 index 000000000..21f11c53b --- /dev/null +++ b/src/core/utils/clipboardUtils/clipboardUtils.ts @@ -0,0 +1,33 @@ +export interface IClipboardUtilsParams { + /** + * Callback called on paste error. + */ + onError?: (error: unknown) => void; +} + +class ClipboardUtils { + copy = async (value: string, params: IClipboardUtilsParams = {}): Promise => { + const { onError } = params; + + try { + await navigator.clipboard.writeText(value); + } catch (error: unknown) { + onError?.(error); + } + }; + + paste = async (params: IClipboardUtilsParams = {}): Promise => { + const { onError } = params; + let clipboardText = ''; + + try { + clipboardText = await navigator.clipboard.readText(); + } catch (error: unknown) { + onError?.(error); + } + + return clipboardText; + }; +} + +export const clipboardUtils = new ClipboardUtils(); diff --git a/src/core/utils/clipboardUtils/index.ts b/src/core/utils/clipboardUtils/index.ts new file mode 100644 index 000000000..4d779f5c7 --- /dev/null +++ b/src/core/utils/clipboardUtils/index.ts @@ -0,0 +1 @@ +export { clipboardUtils, type IClipboardUtilsParams } from './clipboardUtils'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 07cc26fd2..2cf1e2951 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -1,3 +1,4 @@ +export * from './clipboardUtils'; export * from './formatterUtils'; export * from './mergeRefs'; export * from './responsiveUtils'; diff --git a/src/modules/components/address/addressInput/addressInput.stories.tsx b/src/modules/components/address/addressInput/addressInput.stories.tsx new file mode 100644 index 000000000..ed9874b52 --- /dev/null +++ b/src/modules/components/address/addressInput/addressInput.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { AddressInput, type IAddressInputProps, type IAddressInputResolvedValue } from './addressInput'; + +const meta: Meta = { + title: 'Modules/Components/Address/AddressInput', + component: AddressInput, + tags: ['autodocs'], + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/P0GeJKqILL7UXvaqu5Jj7V/v1.1.0?type=design&node-id=8192-18146&mode=design&t=VfR81DAQucRS3iGm-4', + }, + }, +}; + +type Story = StoryObj; + +const ControlledComponent = (props: IAddressInputProps) => { + const [value, setValue] = useState(); + const [addressValue, setAddressValue] = useState(); + + const stringAddressValue = JSON.stringify(addressValue, null, 2) ?? 'undefined'; + + return ( +
+ + Address value: {stringAddressValue} +
+ ); +}; + +/** + * Default usage of the AddressInput component. + */ +export const Default: Story = { + args: { + placeholder: 'ENS or 0x …', + }, + render: ({ onChange, onAccept, ...props }) => , +}; + +export default meta; diff --git a/src/modules/components/address/addressInput/addressInput.test.tsx b/src/modules/components/address/addressInput/addressInput.test.tsx new file mode 100644 index 000000000..a7e066309 --- /dev/null +++ b/src/modules/components/address/addressInput/addressInput.test.tsx @@ -0,0 +1,258 @@ +import { QueryClient } from '@tanstack/react-query'; +import { act, render, screen, within } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import type { Address } from 'viem'; +import type { UseEnsAddressReturnType, UseEnsNameReturnType } from 'wagmi'; +import * as wagmi from 'wagmi'; +import { IconType, clipboardUtils } from '../../../../core'; +import { addressUtils } from '../../../utils'; +import { OdsModulesProvider } from '../../odsModulesProvider'; +import { AddressInput, type IAddressInputProps } from './addressInput'; + +jest.mock('../../member', () => ({ + MemberAvatar: () =>
, +})); + +describe(' component', () => { + const pasteMock = jest.spyOn(clipboardUtils, 'paste'); + const copyMock = jest.spyOn(clipboardUtils, 'copy'); + + const getChecksumMock = jest.spyOn(addressUtils, 'getChecksum'); + + const useEnsAddressMock = jest.spyOn(wagmi, 'useEnsAddress'); + const useEnsNameMock = jest.spyOn(wagmi, 'useEnsName'); + + beforeEach(() => { + getChecksumMock.mockImplementation((value) => value as Address); + useEnsAddressMock.mockReturnValue({ + data: undefined, + isFetching: false, + queryKey: ['', {}], + } as unknown as UseEnsAddressReturnType); + useEnsNameMock.mockReturnValue({ + data: undefined, + isFetching: false, + queryKey: ['', {}], + } as unknown as UseEnsNameReturnType); + }); + + afterEach(() => { + pasteMock.mockReset(); + copyMock.mockReset(); + + useEnsAddressMock.mockReset(); + useEnsNameMock.mockReset(); + }); + + const createTestComponent = (props?: Partial, queryClient?: QueryClient) => { + const completeProps = { + ...props, + }; + + return ( + + + + ); + }; + + it('renders an input field', () => { + render(createTestComponent()); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('initialises the input field using the value property', () => { + const value = 'test.eth'; + render(createTestComponent({ value })); + expect(screen.getByDisplayValue(value)).toBeInTheDocument(); + }); + + it('calls the onChange property on input field change', async () => { + const input = '0'; + const onChange = jest.fn(); + render(createTestComponent({ onChange })); + await userEvent.type(screen.getByRole('textbox'), input); + expect(onChange).toHaveBeenCalledWith(input); + }); + + it('renders a paste button to read and paste the user clipboard into the input field', async () => { + const userClipboard = 'vitalik.eth'; + pasteMock.mockResolvedValue(userClipboard); + const onChange = jest.fn(); + render(createTestComponent({ onChange })); + + const pasteButton = screen.getByRole('button', { name: 'Paste' }); + expect(pasteButton).toBeInTheDocument(); + + await userEvent.click(pasteButton); + expect(onChange).toHaveBeenCalledWith(userClipboard); + }); + + it('hides the paste button when input field is not empty', () => { + const value = 'test'; + render(createTestComponent({ value })); + expect(screen.queryByRole('button', { name: 'Paste' })).not.toBeInTheDocument(); + }); + + it('renders a clear button to clear current input value when input is focused', async () => { + const value = 'test-value'; + const onChange = jest.fn(); + render(createTestComponent({ value, onChange })); + + act(() => screen.getByRole('textbox').focus()); + const clearButton = screen.getByRole('button', { name: 'Clear' }); + expect(clearButton).toBeInTheDocument(); + + await userEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it('renders a copy button to copy current input value when current value is a valid address', async () => { + const value = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + render(createTestComponent({ value })); + const copyButton = screen.getAllByRole('button').find((button) => within(button).findByTestId(IconType.COPY)); + expect(copyButton).toBeInTheDocument(); + await userEvent.click(copyButton!); + expect(copyMock).toHaveBeenCalledWith(value); + }); + + it('renders the external link button when input value is a valid address', () => { + const value = '0xeefB13C7D42eFCc655E528dA6d6F7bBcf9A2251d'; + render(createTestComponent({ value })); + const linkButton = screen.getByRole('link'); + expect(linkButton).toBeInTheDocument(); + expect(linkButton.href).toEqual(`https://etherscan.io/address/${value}`); + }); + + it('renders a loader as avatar when loading the user address', () => { + useEnsAddressMock.mockReturnValue({ isFetching: true } as UseEnsAddressReturnType); + render(createTestComponent()); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders the avatar for the current address', () => { + render(createTestComponent()); + expect(screen.getByTestId('member-avatar-mock')).toBeInTheDocument(); + }); + + it('displays a button to display the ENS value linked to the address input when address has ENS linked', async () => { + const ensValue = 'vitalik.eth'; + const value = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + const onChange = jest.fn(); + useEnsNameMock.mockReturnValue({ data: ensValue, isFetching: false } as UseEnsNameReturnType); + + render(createTestComponent({ value, onChange })); + const ensButton = screen.getByRole('button', { name: 'ENS' }); + expect(ensButton).toBeInTheDocument(); + + await userEvent.click(ensButton); + expect(onChange).toHaveBeenCalledWith(ensValue); + }); + + it('displays a button to display the address value linked to the ENS input when ENS is linked to an address', async () => { + const addressValue: Address = '0xeefB13C7D42eFCc655E528dA6d6F7bBcf9A2251d'; + const value = 'cdixon.eth'; + const onChange = jest.fn(); + useEnsAddressMock.mockReturnValue({ data: addressValue, isFetching: false } as UseEnsAddressReturnType); + + render(createTestComponent({ value, onChange })); + const addressButton = screen.getByRole('button', { name: '0x …' }); + expect(addressButton).toBeInTheDocument(); + + await userEvent.click(addressButton); + expect(onChange).toHaveBeenCalledWith(addressValue); + }); + + it('displays a truncated address when address is valid and input is not focused', async () => { + const value = '0xeefB13C7D42eFCc655E528dA6d6F7bBcf9A2251d'; + render(createTestComponent({ value })); + expect(screen.getByDisplayValue('0xee…251d')).toBeInTheDocument(); + act(() => screen.getByRole('textbox').focus()); + expect(screen.getByDisplayValue(value)).toBeInTheDocument(); + }); + + it('displays a truncated ENS name when ENS is valid and input is not focused', async () => { + const value = 'longensname.eth'; + render(createTestComponent({ value })); + expect(screen.getByDisplayValue('longe…eth')).toBeInTheDocument(); + act(() => screen.getByRole('textbox').focus()); + expect(screen.getByDisplayValue(value)).toBeInTheDocument(); + }); + + it('triggers the onAccept property with the normalised ENS when input value is a valid ENS and has an address linked to it', () => { + const value = 'ViTaLiK.eth'; + const acceptedValue = { name: 'vitalik.eth', address: '0xeefB13C7D42eFCc655E528dA6d6F7bBcf9A2251d' }; + const onAccept = jest.fn(); + useEnsAddressMock.mockReturnValue({ + data: acceptedValue.address, + isFetching: false, + } as UseEnsAddressReturnType); + render(createTestComponent({ value, onAccept })); + expect(onAccept).toHaveBeenCalledWith(acceptedValue); + }); + + it('triggers the onAccept property with the address is checksum format when input value is a valid address', () => { + const value = '0xeefb13c7d42efcc655e528da6d6f7bbcf9a2251d'; + const acceptedValue = { name: 'vitalik.eth', address: '0xeefB13C7D42eFCc655E528dA6d6F7bBcf9A2251d' }; + const onAccept = jest.fn(); + getChecksumMock.mockImplementation(() => acceptedValue.address as Address); + useEnsNameMock.mockReturnValue({ + data: acceptedValue.name, + isFetching: false, + } as UseEnsNameReturnType); + render(createTestComponent({ value, onAccept })); + expect(onAccept).toHaveBeenCalledWith(acceptedValue); + }); + + it('triggers the onAccept property with undefined ENS name when input value is a valid address', () => { + const value = '0xeefb13c7d42efcc655e528da6d6f7bbcf9a2251d'; + const onAccept = jest.fn(); + useEnsNameMock.mockReturnValue({ data: undefined, isFetching: false } as UseEnsNameReturnType); + render(createTestComponent({ value, onAccept })); + expect(onAccept).toHaveBeenCalledWith({ address: value, name: undefined }); + }); + + it('triggers the onAccept property with undefined when input is not a valid address nor ENS', () => { + const value = 'test'; + const onAccept = jest.fn(); + useEnsAddressMock.mockReturnValue({ data: undefined, isFetching: false } as UseEnsAddressReturnType); + render(createTestComponent({ value, onAccept })); + expect(onAccept).toHaveBeenCalledWith(undefined); + }); + + it('does not try to resolve address when value is ENS name but current chain-id does not support ens names', () => { + const value = 'vitalik.eth'; + const chainId = 137; + render(createTestComponent({ value, chainId })); + const queryObject = { query: { enabled: false } }; + expect(useEnsAddressMock).toHaveBeenCalledWith(expect.objectContaining(queryObject)); + }); + + it('does not try to resolve ens when value is a valid address but current chain-id does not support ens names', () => { + const value = '0xeefb13c7d42efcc655e528da6d6f7bbcf9a2251d'; + const chainId = 137; + render(createTestComponent({ value, chainId })); + const queryObject = { query: { enabled: false } }; + expect(useEnsNameMock).toHaveBeenCalledWith(expect.objectContaining(queryObject)); + }); + + it('updates the query cache with the current resolved ens/address when input address is linked to an ENS name', () => { + const queryClient = new QueryClient(); + queryClient.setQueryData = jest.fn(); + const value = '0xeefb13c7d42efcc655e528da6d6f7bbcf9a2251d'; + const resolvedEns = 'test.eth'; + useEnsNameMock.mockReturnValue({ data: resolvedEns, isFetching: false } as UseEnsNameReturnType); + render(createTestComponent({ value }, queryClient)); + expect(queryClient.setQueryData).toHaveBeenCalledWith(['', { name: resolvedEns }], value); + }); + + it('updates the query cache with the current resolved ens/address when input ENS is linked to an address', () => { + const queryClient = new QueryClient(); + queryClient.setQueryData = jest.fn(); + const value = 'abc.eth'; + const resolvedAddress: Address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + useEnsAddressMock.mockReturnValue({ data: resolvedAddress, isFetching: false } as UseEnsAddressReturnType); + render(createTestComponent({ value }, queryClient)); + expect(queryClient.setQueryData).toHaveBeenCalledWith(['', { address: resolvedAddress }], value); + }); +}); diff --git a/src/modules/components/address/addressInput/addressInput.tsx b/src/modules/components/address/addressInput/addressInput.tsx new file mode 100644 index 000000000..cb3f3fac5 --- /dev/null +++ b/src/modules/components/address/addressInput/addressInput.tsx @@ -0,0 +1,253 @@ +import { useQueryClient } from '@tanstack/react-query'; +import classNames from 'classnames'; +import { forwardRef, useEffect, useRef, useState, type ChangeEvent, type FocusEvent } from 'react'; +import { type Address } from 'viem'; +import { normalize } from 'viem/ens'; +import { useConfig, useEnsAddress, useEnsName, type UseEnsAddressParameters, type UseEnsNameParameters } from 'wagmi'; +import { + Button, + IconType, + InputContainer, + Spinner, + clipboardUtils, + mergeRefs, + useDebouncedValue, + useInputProps, + type IInputComponentProps, +} from '../../../../core'; +import type { IWeb3ComponentProps } from '../../../types'; +import { addressUtils, ensUtils } from '../../../utils'; +import { MemberAvatar } from '../../member'; + +export interface IAddressInputResolvedValue { + /** + * Address value. + */ + address?: string; + /** + * ENS name linked to the given address. + */ + name?: string; +} + +export interface IAddressInputProps + extends Omit, 'maxLength' | 'value' | 'onChange'>, + IWeb3ComponentProps { + /** + * Current value of the address input. + */ + value?: string; + /** + * Callback called whenever the current input value (address or ens) changes. + */ + onChange?: (value?: string) => void; + /** + * Callback called with the address value object when the user input is valid. The component will output the address + * in checksum format and the ENS name normalised. The value will be set to undefined when the user input is not a + * valid address nor a valid ens name. + */ + onAccept?: (value?: IAddressInputResolvedValue) => void; +} + +export const AddressInput = forwardRef((props, ref) => { + const { value = '', onChange, onAccept, wagmiConfig: wagmiConfigProps, chainId, ...otherProps } = props; + + const { containerProps, inputProps } = useInputProps(otherProps); + const { onFocus, onBlur, className: inputClassName, ...otherInputProps } = inputProps; + + const queryClient = useQueryClient(); + const wagmiConfigProvider = useConfig(); + + const wagmiConfig = wagmiConfigProps ?? wagmiConfigProvider; + const processedChainId = chainId ?? wagmiConfig.chains[0].id; + + const currentChain = wagmiConfig.chains.find(({ id }) => id === processedChainId); + const blockExplorerUrl = `${currentChain?.blockExplorers?.default.url}/address/${value}`; + + const supportEnsNames = currentChain?.contracts?.ensRegistry != null; + + const inputRef = useRef(null); + + const [debouncedValue, setDebouncedValue] = useDebouncedValue(value, { delay: 300 }); + const [isFocused, setIsFocused] = useState(false); + + const isDebouncedValueValidEns = ensUtils.isEnsName(debouncedValue); + const isDebouncedValueValidAddress = addressUtils.isAddress(debouncedValue); + + const { + data: ensAddress, + isFetching: isEnsAddressLoading, + queryKey: ensAddressQueryKey, + } = useEnsAddress({ + name: isDebouncedValueValidEns ? normalize(debouncedValue) : undefined, + config: wagmiConfig, + chainId, + query: { enabled: supportEnsNames && isDebouncedValueValidEns }, + }); + + const { + data: ensName, + isFetching: isEnsNameLoading, + queryKey: ensNameQueryKey, + } = useEnsName({ + address: debouncedValue as Address, + config: wagmiConfig, + chainId, + query: { enabled: supportEnsNames && isDebouncedValueValidAddress }, + }); + + const displayMode = ensUtils.isEnsName(value) ? 'ens' : 'address'; + + const isLoading = isEnsAddressLoading || isEnsNameLoading; + + const handleInputChange = (event: ChangeEvent) => onChange?.(event.target.value); + + const toggleDisplayMode = () => { + const newInputValue = displayMode === 'address' ? ensName : ensAddress; + onChange?.(newInputValue ?? ''); + + // Update the debounced value without waiting for the debounce timeout to avoid delays on displaying the + // ENS/Address buttons because of delayed queries + setDebouncedValue(newInputValue ?? ''); + }; + + const handlePasteClick = async () => { + const text = await clipboardUtils.paste(); + onChange?.(text); + }; + + const handleClearClick = () => onChange?.(undefined); + + const handleInputFocus = (event: FocusEvent) => { + setIsFocused(true); + onFocus?.(event); + }; + + const handleInputBlur = (event: FocusEvent) => { + setIsFocused(false); + onBlur?.(event); + }; + + // Trigger onChange property when value is a valid address or ENS + useEffect(() => { + if (isLoading) { + return; + } + + if (ensAddress) { + // User input is a valid ENS name + const normalizedEns = normalize(debouncedValue); + onAccept?.({ address: ensAddress, name: normalizedEns }); + } else if (isDebouncedValueValidAddress) { + // User input is a valid address with or without a ENS name linked to it + const checksumAddress = addressUtils.getChecksum(debouncedValue); + onAccept?.({ address: checksumAddress, name: ensName ?? undefined }); + } else { + // User input is not a valid address nor ENS name + onAccept?.(undefined); + } + }, [ensAddress, ensName, debouncedValue, isDebouncedValueValidAddress, isLoading, onAccept]); + + // Update react-query cache to avoid fetching the ENS address when the ENS name has been successfully resolved. + // E.g. user types 0x..123 which is resolved into test.eth, therefore set test.eth as resolved ENS name of 0x..123 + useEffect(() => { + if (ensName) { + const queryKey = [...ensAddressQueryKey]; + (queryKey[1] as UseEnsAddressParameters).name = ensName; + queryClient.setQueryData(queryKey, debouncedValue); + } + }, [queryClient, ensName, debouncedValue, ensAddressQueryKey]); + + // Update react-query cache to avoid fetching the ENS name when the ENS address has been successfully resolved. + // E.g. user types test.eth which is resolved into 0x..123, therefore set 0x..123 as resolved ENS address of test.eth + useEffect(() => { + if (ensAddress) { + const queryKey = [...ensNameQueryKey]; + (queryKey[1] as UseEnsNameParameters).address = ensAddress; + queryClient.setQueryData(queryKey, debouncedValue); + } + }, [queryClient, ensAddress, debouncedValue, ensNameQueryKey]); + + // Resize textarea element on user input depending on the focus state of the textarea + useEffect(() => { + if (inputRef.current) { + // Needed to trigger a calculation for the new scrollHeight of the textarea + inputRef.current.style.height = 'auto'; + + const newHeight = `${inputRef.current.scrollHeight}px`; + inputRef.current.style.height = newHeight; + } + }, [value, isFocused]); + + // Display the address or ENS as truncated when the value is a valid address or ENS and input is not focused + const displayTruncatedAddress = addressUtils.isAddress(value) && !isFocused; + const displayTruncatedEns = ensUtils.isEnsName(value) && !isFocused; + + const addressValue = ensAddress ?? (addressUtils.isAddress(value) ? value : undefined); + + const processedValue = displayTruncatedAddress + ? addressUtils.truncateAddress(value) + : displayTruncatedEns + ? ensUtils.truncateEns(value) + : value; + + return ( + +
+ {isLoading && } + {!isLoading && } +
+