From ee0a2f231360a09fbcd20c2447e02eac2dd79649 Mon Sep 17 00:00:00 2001 From: Melisa Anabella Rossi Date: Tue, 23 Jan 2024 10:04:59 -0300 Subject: [PATCH] feat: address field component (#497) --- src/components/AddressField/AddressField.css | 23 +++ .../AddressField/AddressField.spec.tsx | 95 +++++++++++++ .../AddressField/AddressField.stories.tsx | 17 +++ src/components/AddressField/AddressField.tsx | 132 ++++++++++++++++++ .../AddressField/AddressField.types.ts | 7 + src/components/AddressField/index.ts | 2 + src/components/AddressField/utils.spec.ts | 27 ++++ src/components/AddressField/utils.ts | 7 + .../Table/TableContent/TableContent.spec.tsx | 3 +- 9 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/components/AddressField/AddressField.css create mode 100644 src/components/AddressField/AddressField.spec.tsx create mode 100644 src/components/AddressField/AddressField.stories.tsx create mode 100644 src/components/AddressField/AddressField.tsx create mode 100644 src/components/AddressField/AddressField.types.ts create mode 100644 src/components/AddressField/index.ts create mode 100644 src/components/AddressField/utils.spec.ts create mode 100644 src/components/AddressField/utils.ts diff --git a/src/components/AddressField/AddressField.css b/src/components/AddressField/AddressField.css new file mode 100644 index 00000000..cc9ea42c --- /dev/null +++ b/src/components/AddressField/AddressField.css @@ -0,0 +1,23 @@ +.dui-address-field { + display: flex; + flex-direction: column; + flex: 1; + position: relative; +} + +.dui-address-field__address { + color: var(--secondary-text); + position: absolute; + top: 10px; + right: 48px; + font-size: 20px; + z-index: 1; +} + +.dui-address-field__input--with-address.ui.icon.input input { + padding-right: 200px !important; +} + +.dui-address-field__address-popup.ui.popup { + z-index: 9999; +} diff --git a/src/components/AddressField/AddressField.spec.tsx b/src/components/AddressField/AddressField.spec.tsx new file mode 100644 index 00000000..b45f1dd8 --- /dev/null +++ b/src/components/AddressField/AddressField.spec.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import { RenderResult, render, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Props } from './AddressField.types' +import AddressField from './AddressField' +import { shorten } from './utils' + +function renderAddressField(props: Partial = {}) { + return render() +} + +const address = '0x89805E5f0698Cb4dB57f0E389f2a75259f78CC22' +const name = 'test.dcl.eth' +let screen: RenderResult +let resolveNameMock: jest.Mock + +describe('when user inputs an address', () => { + beforeEach(async () => { + resolveNameMock = jest.fn() + screen = renderAddressField({ + resolveName: resolveNameMock, + placeholder: 'test address' + }) + const addressInput = screen.getByPlaceholderText('test address') + userEvent.type(addressInput, address) + await waitFor(() => + expect(screen.getByTestId('check-icon')).toBeInTheDocument() + ) + }) + + it('should not call resolveName function', () => { + expect(resolveNameMock).not.toHaveBeenCalled() + }) + + it('should show the address as the input value', () => { + expect(screen.getByPlaceholderText('test address')).toHaveValue(address) + }) + + it('should not show the resolved address', () => { + expect(screen.queryByTestId('resolved-address')).not.toBeInTheDocument() + }) +}) + +describe('when user inputs a name', () => { + describe('and the name resolves correctly to an address', () => { + beforeEach(async () => { + resolveNameMock = jest.fn().mockResolvedValue(address) + screen = renderAddressField({ + resolveName: resolveNameMock, + placeholder: 'test address' + }) + const addressInput = screen.getByPlaceholderText('test address') + userEvent.type(addressInput, name) + await waitFor(() => + expect(screen.getByTestId('check-icon')).toBeInTheDocument() + ) + }) + + it('should keep the name as the input value', () => { + expect(screen.getByPlaceholderText('test address')).toHaveValue(name) + }) + + it('should show the resolved address', () => { + expect(screen.queryByTestId('resolved-address')).toBeInTheDocument() + }) + + it('should show the cropped address', () => { + expect(screen.queryByText(shorten(address))).toBeInTheDocument() + }) + }) + + describe("and the name doesn't resolve to an address", () => { + beforeEach(async () => { + resolveNameMock = jest.fn().mockResolvedValue(undefined) + screen = renderAddressField({ + resolveName: resolveNameMock, + placeholder: 'test address' + }) + const addressInput = screen.getByPlaceholderText('test address') + userEvent.type(addressInput, name) + }) + + it('should keep the name as the input value', () => { + expect(screen.getByPlaceholderText('test address')).toHaveValue(name) + }) + + it('should show an error', async () => { + await waitFor(() => + expect( + screen.getByText('This is not a valid name or address') + ).toBeInTheDocument() + ) + }) + }) +}) diff --git a/src/components/AddressField/AddressField.stories.tsx b/src/components/AddressField/AddressField.stories.tsx new file mode 100644 index 00000000..b73dc42b --- /dev/null +++ b/src/components/AddressField/AddressField.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { ComponentMeta, ComponentStory } from '@storybook/react' +import AddressField from './AddressField' + +export default { + title: 'AddressField', + component: AddressField +} as ComponentMeta + +const Template: ComponentStory = (args) => ( + +) + +export const Basic = Template.bind({}) +Basic.args = { + resolveName: () => '0xtestaddresstestaddresstestaddresstestadd' +} diff --git a/src/components/AddressField/AddressField.tsx b/src/components/AddressField/AddressField.tsx new file mode 100644 index 00000000..e647a6e8 --- /dev/null +++ b/src/components/AddressField/AddressField.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import classNames from 'classnames' +import Icon from 'semantic-ui-react/dist/commonjs/elements/Icon' +import { InputOnChangeData } from 'semantic-ui-react/dist/commonjs/elements/Input' +import Popup from 'semantic-ui-react/dist/commonjs/modules/Popup' +import { Field } from '../Field/Field' +import { shorten, isValid } from './utils' +import { Props } from './AddressField.types' +import './AddressField.css' + +export default function AddressField(props: Props) { + const { + className, + fieldClassName, + i18n, + resolveName, + onChange, + ...otherProps + } = props + const [inputValue, setInputValue] = useState('') + const [address, setAddress] = useState('') + const timeout = useRef() + const [valid, setValid] = useState() + const [loading, setLoading] = useState() + + useEffect(() => { + if (props.value && props.value !== address) { + setInputValue(props.value) + } + }, [props.value, address]) + + useEffect(() => { + return () => { + clearTimeout(timeout.current) + } + }, []) + + const handleChange = useCallback( + (evt, data: InputOnChangeData) => { + setInputValue(data.value) + setValid(undefined) + setAddress('') + if (timeout.current) { + clearTimeout(timeout.current) + } + + timeout.current = setTimeout(async () => { + if (isValid(data.value)) { + setValid(true) + if (onChange) { + onChange(evt, data) + } + return + } + + setLoading(true) + try { + const resolvedAddress = await resolveName(data.value) + if (resolvedAddress) { + setValid(true) + setAddress(resolvedAddress) + if (onChange) { + onChange(evt, { value: resolvedAddress }) + } + } else { + setValid(false) + } + } catch (e) { + console.error('Error resolving address', e) + setValid(false) + } + setLoading(false) + }, 800) + }, + [onChange] + ) + + const additionalProps = valid + ? { + icon: ( + + ) + } + : {} + + return ( +
+ {address && ( + + {shorten(address)} + + } + /> + )} + +
+ ) +} diff --git a/src/components/AddressField/AddressField.types.ts b/src/components/AddressField/AddressField.types.ts new file mode 100644 index 00000000..57cf5133 --- /dev/null +++ b/src/components/AddressField/AddressField.types.ts @@ -0,0 +1,7 @@ +import { FieldProps } from '../Field/Field' + +export type Props = FieldProps & { + fieldClassName?: string + i18n?: { errorMessage: string } + resolveName: (address: string) => string | undefined +} diff --git a/src/components/AddressField/index.ts b/src/components/AddressField/index.ts new file mode 100644 index 00000000..58a52fe4 --- /dev/null +++ b/src/components/AddressField/index.ts @@ -0,0 +1,2 @@ +import AddressField from './AddressField' +export default AddressField diff --git a/src/components/AddressField/utils.spec.ts b/src/components/AddressField/utils.spec.ts new file mode 100644 index 00000000..33a68df1 --- /dev/null +++ b/src/components/AddressField/utils.spec.ts @@ -0,0 +1,27 @@ +import { isValid, shorten } from './utils' + +describe('isValid', () => { + it('should return false when address is undefined', () => { + expect(isValid(undefined)).toBe(false) + }) + + it("should return false when address doesn't match regex", () => { + expect(isValid('0x')).toBe(false) + }) + + it('should return true when address matches regex', () => { + expect(isValid('0x89805E5f0698Cb4dB57f0E389f2a75259f78CC22')).toBe(true) + }) +}) + +describe('shorten', () => { + it('should return empty string when address is not defined', () => { + expect(shorten(undefined)).toBe('') + }) + + it('should return first and last 5 items', () => { + expect(shorten('0x89805E5f0698Cb4dB57f0E389f2a75259f78CC22')).toBe( + '0x8980...8CC22' + ) + }) +}) diff --git a/src/components/AddressField/utils.ts b/src/components/AddressField/utils.ts new file mode 100644 index 00000000..265749c5 --- /dev/null +++ b/src/components/AddressField/utils.ts @@ -0,0 +1,7 @@ +export function isValid(addr: string) { + return /^0x[a-fA-F0-9]{40}$/g.test(addr) +} + +export function shorten(address: string) { + return address ? address.slice(0, 6) + '...' + address.slice(42 - 5) : '' +} diff --git a/src/components/v2/Table/TableContent/TableContent.spec.tsx b/src/components/v2/Table/TableContent/TableContent.spec.tsx index 72032210..47b71a6a 100644 --- a/src/components/v2/Table/TableContent/TableContent.spec.tsx +++ b/src/components/v2/Table/TableContent/TableContent.spec.tsx @@ -100,7 +100,7 @@ describe('Table content', () => { describe('Should have pagination', () => { it('should render the pagination correctly', async () => { data = Array(ROWS_PER_PAGE).fill({ - first_header: 'contetnt 1', + first_header: 'content 1', second_header: 'content 2' }) @@ -111,6 +111,7 @@ describe('Table content', () => { empty={() =>
empty table
} total={data.length} totalPages={2} + activePage={1} /> )