Skip to content

Commit

Permalink
feat: address field component (#497)
Browse files Browse the repository at this point in the history
  • Loading branch information
Melisa Anabella Rossi authored Jan 23, 2024
1 parent ba0c6a0 commit ee0a2f2
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 1 deletion.
23 changes: 23 additions & 0 deletions src/components/AddressField/AddressField.css
Original file line number Diff line number Diff line change
@@ -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;
}
95 changes: 95 additions & 0 deletions src/components/AddressField/AddressField.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = {}) {
return render(<AddressField resolveName={jest.fn()} {...props} />)
}

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()
)
})
})
})
17 changes: 17 additions & 0 deletions src/components/AddressField/AddressField.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AddressField>

const Template: ComponentStory<typeof AddressField> = (args) => (
<AddressField {...args} />
)

export const Basic = Template.bind({})
Basic.args = {
resolveName: () => '0xtestaddresstestaddresstestaddresstestadd'
}
132 changes: 132 additions & 0 deletions src/components/AddressField/AddressField.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Timeout>()
const [valid, setValid] = useState<boolean>()
const [loading, setLoading] = useState<boolean>()

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: (
<Icon
color="green"
data-testid="check-icon"
size="large"
name="check circle"
/>
)
}
: {}

return (
<div className={classNames('dui-address-field', className)}>
{address && (
<Popup
position="top center"
className="dui-address-field__address-popup"
on="hover"
content={address}
trigger={
<span
data-testid="resolved-address"
className="dui-address-field__address"
>
{shorten(address)}
</span>
}
/>
)}
<Field
{...otherProps}
type="text"
placeholder={props.placeholder ?? 'Address or name'}
value={inputValue || ''}
message={
valid === false
? i18n?.errorMessage || 'This is not a valid name or address'
: undefined
}
error={valid === false}
loading={loading}
disabled={loading}
input={{ autoComplete: 'off', name: 'address', id: 'address' }}
onChange={handleChange}
className={classNames(fieldClassName, {
'dui-address-field__input--with-address': !!address
})}
{...additionalProps}
></Field>
</div>
)
}
7 changes: 7 additions & 0 deletions src/components/AddressField/AddressField.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { FieldProps } from '../Field/Field'

export type Props = FieldProps & {
fieldClassName?: string
i18n?: { errorMessage: string }
resolveName: (address: string) => string | undefined
}
2 changes: 2 additions & 0 deletions src/components/AddressField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import AddressField from './AddressField'
export default AddressField
27 changes: 27 additions & 0 deletions src/components/AddressField/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
7 changes: 7 additions & 0 deletions src/components/AddressField/utils.ts
Original file line number Diff line number Diff line change
@@ -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) : ''
}
3 changes: 2 additions & 1 deletion src/components/v2/Table/TableContent/TableContent.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})

Expand All @@ -111,6 +111,7 @@ describe('Table content', () => {
empty={() => <div>empty table</div>}
total={data.length}
totalPages={2}
activePage={1}
/>
)

Expand Down

1 comment on commit ee0a2f2

@vercel
Copy link

@vercel vercel bot commented on ee0a2f2 Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

decentraland-ui – ./

decentraland-ui-decentraland1.vercel.app
decentraland-ui-git-master-decentraland1.vercel.app

Please sign in to comment.