Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add asset select modal #27

Merged
merged 6 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@shapeshiftoss/caip": "^8.15.0",
"@shapeshiftoss/types": "^8.6.0",
"axios": "^1.6.5",
Copy link
Contributor

Choose a reason for hiding this comment

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

We're not using this, but might as well keep it since we will

"framer-motion": "^10.17.9",
"match-sorter": "^6.3.1",
"mixpanel-browser": "^2.48.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.21.1"
"react-router-dom": "^6.21.1",
"react-virtuoso": "^4.6.2"
},
"devDependencies": {
"@types/inquirer": "^9.0.7",
Expand Down
34 changes: 34 additions & 0 deletions src/components/AssetSelectModal/AssetList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ListProps } from '@chakra-ui/react'
import type { FC } from 'react'
import { useCallback } from 'react'
import { Virtuoso } from 'react-virtuoso'
import type { Asset } from 'types/Asset'

import { AssetRow } from './AssetRow'

const style = { height: '400px' }

export type AssetData = {
assets: Asset[]
handleClick: (asset: Asset) => void
}

type AssetListProps = AssetData & ListProps

export const AssetList: FC<AssetListProps> = ({ assets, handleClick }) => {
const renderItemContent = useCallback(
(index: number, asset: Asset) => {
return <AssetRow {...asset} key={index} onClick={handleClick} />
},
[handleClick],
)

return (
<Virtuoso
style={style}
data={assets}
totalCount={assets.length}
itemContent={renderItemContent}
/>
)
}
69 changes: 69 additions & 0 deletions src/components/AssetSelectModal/AssetRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Avatar, Box, Button, Flex, SkeletonCircle, Text } from '@chakra-ui/react'
import type { FC } from 'react'
import { memo, useCallback, useState } from 'react'
import { middleEllipsis } from 'lib/utils'
import type { Asset } from 'types/Asset'

const focus = {
shadow: 'outline-inset',
}

type AssetRowProps = {
onClick: (asset: Asset) => void
} & Asset

export const AssetRow: FC<AssetRowProps> = memo(({ onClick, ...asset }) => {
const [imgLoaded, setImgLoaded] = useState(false)
const { name, icon, symbol, id } = asset
const handleOnClick = useCallback(() => {
reallybeard marked this conversation as resolved.
Show resolved Hide resolved
onClick(asset)
}, [asset, onClick])

const handleImgLoad = useCallback(() => {
reallybeard marked this conversation as resolved.
Show resolved Hide resolved
setImgLoaded(true)
}, [])

if (!asset) return null

return (
<Button
width='full'
variant='ghost'
onClick={handleOnClick}
justifyContent='space-between'
_focus={focus}
height='auto'
py={4}
>
<Flex gap={4} alignItems='center'>
<SkeletonCircle isLoaded={imgLoaded}>
<Avatar src={icon} size='sm' onLoad={handleImgLoad} />
</SkeletonCircle>
<Box textAlign='left'>
<Text
lineHeight={1}
textOverflow='ellipsis'
whiteSpace='nowrap'
maxWidth='200px'
overflow='hidden'
color='text.base'
fontSize='sm'
mb={1}
>
{name}
</Text>
<Flex alignItems='center' gap={2}>
<Text fontWeight='normal' fontSize='xs' color='text.subtle'>
{symbol}
</Text>
{id && (
<Text fontWeight='normal' fontSize='xs' color='text.subtle'>
{middleEllipsis(id)}
</Text>
)}
</Flex>
</Box>
</Flex>
</Button>
)
})
159 changes: 159 additions & 0 deletions src/components/AssetSelectModal/AssetSelectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
Button,
CloseButton,
Flex,
Input,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
} from '@chakra-ui/react'
import type { ChainId } from '@shapeshiftoss/caip'
import type { ChangeEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import AssetData from 'lib/generatedAssetData.json'
import { isNft } from 'lib/utils'
import type { Asset } from 'types/Asset'

import { AssetList } from './AssetList'
import type { ChainRow } from './ChainButton'
import { ChainButton } from './ChainButton'
import { filterAssetsBySearchTerm } from './helpers/filterAssetsBySearchTerm'

type AssetSelectModalProps = {
isOpen: boolean
onClose: () => void
onClick: (asset: Asset) => void
}

export const AssetSelectModal: React.FC<AssetSelectModalProps> = ({ isOpen, onClose, onClick }) => {
const [searchQuery, setSearchQuery] = useState('')
const assets = Object.values(AssetData)
reallybeard marked this conversation as resolved.
Show resolved Hide resolved
const [activeChain, setActiveChain] = useState<ChainId | 'All'>('All')
const [searchTermAssets, setSearchTermAssets] = useState<Asset[]>([])
const iniitalRef = useRef(null)

const filteredAssets = useMemo(
() =>
activeChain === 'All'
? assets.filter(a => !isNft(a.assetId))
: assets.filter(a => a.chainId === activeChain && !isNft(a.assetId)),
[activeChain, assets],
)

const searching = useMemo(() => searchQuery.length > 0, [searchQuery])

const handleSearchQuery = useCallback((value: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(value.target.value)
}, [])

const handleChainClick = useCallback((chainId: ChainId | 'All') => {
setActiveChain(chainId)
}, [])

const handleAllChain = useCallback(() => {
reallybeard marked this conversation as resolved.
Show resolved Hide resolved
setActiveChain('All')
}, [])

const handleClose = useCallback(() => {
// Reset state on close
setActiveChain('All')
setSearchQuery('')
onClose()
}, [onClose])

const handleClick = useCallback(
(asset: Asset) => {
onClick(asset)
handleClose()
},
[handleClose, onClick],
)

useEffect(() => {
if (filteredAssets && searching) {
setSearchTermAssets(
searching ? filterAssetsBySearchTerm(searchQuery, filteredAssets) : filteredAssets,
)
}
}, [searchQuery, searching, filteredAssets])

const listAssets = searching ? searchTermAssets : filteredAssets

const uniqueChainIds: ChainRow[] = assets.reduce((accumulator, currentAsset: Asset) => {
const existingEntry = accumulator.find(
(entry: ChainRow) => entry.chainId === currentAsset.chainId,
)

if (!existingEntry) {
accumulator.push({
chainId: currentAsset.chainId,
icon: currentAsset.networkIcon ?? currentAsset.icon,
name: currentAsset.networkName ?? currentAsset.name,
})
}

return accumulator
}, [])

const renderRows = useMemo(() => {
return <AssetList assets={listAssets} handleClick={handleClick} />
}, [handleClick, listAssets])

const renderChains = useMemo(() => {
return uniqueChainIds.map(chain => (
<ChainButton
key={chain.chainId}
isActive={chain.chainId === activeChain}
onClick={handleChainClick}
{...chain}
/>
))
}, [activeChain, handleChainClick, uniqueChainIds])

return (
<Modal isOpen={isOpen} onClose={handleClose} isCentered initialFocusRef={iniitalRef}>
<ModalOverlay />
<ModalContent>
<ModalHeader
display='flex'
flexDir='column'
gap={2}
borderBottomWidth={1}
borderColor='border.base'
>
<Flex alignItems='center' justifyContent='space-between'>
<Text fontWeight='bold' fontSize='md'>
Select asset
</Text>
<CloseButton position='relative' />
</Flex>
<Input
size='lg'
placeholder='Search name or paste address'
ref={iniitalRef}
onChange={handleSearchQuery}
/>
<Flex mt={4} flexWrap='wrap' gap={2} justifyContent='space-between'>
<Button
size='lg'
isActive={activeChain === 'All'}
variant='outline'
fontSize='sm'
px={2}
onClick={handleAllChain}
>
All
</Button>
{renderChains}
</Flex>
</ModalHeader>
<ModalBody px={2} py={0}>
{renderRows}
</ModalBody>
</ModalContent>
</Modal>
)
}
40 changes: 40 additions & 0 deletions src/components/AssetSelectModal/ChainButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Avatar, IconButton } from '@chakra-ui/react'
import type { ChainId } from '@shapeshiftoss/caip'
import { useCallback, useMemo } from 'react'
export type ChainRow = {
chainId: ChainId
icon: string
name: string
}

type ChainButtonProps = {
onClick: (chainId: ChainId) => void
isActive?: boolean
} & ChainRow

export const ChainButton: React.FC<ChainButtonProps> = ({
name,
chainId,
icon,
onClick,
isActive,
}) => {
const chainIcon = useMemo(() => {
return <Avatar size='xs' src={icon} />
}, [icon])

const handleClick = useCallback(() => {
onClick(chainId)
}, [chainId, onClick])

return (
<IconButton
isActive={isActive}
variant='outline'
size='lg'
aria-label={name}
icon={chainIcon}
onClick={handleClick}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fromAssetId } from '@shapeshiftoss/caip'
import { matchSorter } from 'match-sorter'
import { isEthAddress } from 'lib/utils'
import type { Asset } from 'types/Asset'

export const filterAssetsBySearchTerm = (search: string, assets: Asset[]) => {
if (!assets) return []

const searchLower = search.toLowerCase()

if (isEthAddress(search)) {
return assets.filter(
asset => fromAssetId(asset?.assetId).assetReference.toLowerCase() === searchLower,
)
}

return matchSorter(assets, search, {
keys: ['name', 'symbol'],
threshold: matchSorter.rankings.CONTAINS,
})
}
4 changes: 4 additions & 0 deletions src/components/AssetSelectModal/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum AssetType {
BUY = 'buy',
SELL = 'sell',
}
17 changes: 7 additions & 10 deletions src/components/AssetSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import { Avatar, Button, Text } from '@chakra-ui/react'
import type { Asset } from 'types/Asset'

type AssetSelectionProps = {
onClick: () => void
label: string
assetIcon: string
assetName: string
asset?: Asset
}

export const AssetSelection: React.FC<AssetSelectionProps> = ({
label,
onClick,
assetIcon,
assetName,
}) => {
export const AssetSelection: React.FC<AssetSelectionProps> = ({ label, onClick, asset }) => {
return (
<Button flexDir='column' height='auto' py={4} gap={4} flex={1} onClick={onClick}>
<Text color='text.subtle'>{label}</Text>
<Avatar src={assetIcon} />
<Text>{assetName}</Text>
<Avatar src={asset ? asset.icon : ''} />
<Text textOverflow='ellipsis' overflow='hidden' width='full'>
{asset ? asset.name : 'Select Asset'}
</Text>
</Button>
)
}
Loading