Skip to content

Commit

Permalink
feat(swaps): add dropdown network filter (#5174)
Browse files Browse the repository at this point in the history
### Description

Dropdown for filtering swap tokens by network


[Figma](https://www.figma.com/file/gFuRy5HJrV34ehkY0dvWWb/Swap-guidance?type=design&node-id=2708-3698&mode=design&t=l0AoyI5xCPHiauyq-0)


[Android](https://github.com/valora-inc/wallet/assets/8432644/7aaf789f-904b-4a06-988a-a3a8fae23713)


[iOS](https://github.com/valora-inc/wallet/assets/8432644/98ff2060-f4fa-446f-98e3-b6b8de4d7496)

### Test plan

Updated unit tests

### Related issues

https://linear.app/valora/issue/ACT-1098/select-token-by-network

### Backwards compatibility

<!-- Brief explanation of why these changes are/are not backwards
compatible. -->

### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [x] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
jh2oman committed Mar 28, 2024
1 parent 836ba87 commit 69ce7a2
Show file tree
Hide file tree
Showing 14 changed files with 235 additions and 161 deletions.
3 changes: 2 additions & 1 deletion locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1928,7 +1928,8 @@
"myTokens": "My tokens",
"popular": "Popular",
"recentlySwapped": "Recently swapped",
"network": "{{networkName}} Network"
"network": "{{networkName}} Network",
"selectNetwork": "Network"
}
},
"homeActions": {
Expand Down
1 change: 1 addition & 0 deletions src/analytics/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ export enum TokenBottomSheetEvents {
search_token = 'search_token',
token_selected = 'token_selected',
toggle_tokens_filter = 'toggle_tokens_filter',
network_filter_updated = 'network_filter_updated',
}

export enum AssetsEvents {
Expand Down
4 changes: 4 additions & 0 deletions src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,10 @@ interface TokenBottomSheetEventsProperties {
isRemoving: boolean
isPreSelected: boolean
}
[TokenBottomSheetEvents.network_filter_updated]: {
selectedNetworkIds: NetworkId[]
origin: TokenPickerOrigin
}
[TokenBottomSheetEvents.token_selected]: {
origin: TokenPickerOrigin
tokenId: string
Expand Down
1 change: 1 addition & 0 deletions src/analytics/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ export const eventDocs: Record<AnalyticsEventType, string> = {
[TokenBottomSheetEvents.search_token]: `When a user searches a token using the token bottom sheet search box`,
[TokenBottomSheetEvents.token_selected]: `A token was selected in TokenBottomSheet.`,
[TokenBottomSheetEvents.toggle_tokens_filter]: `A filter was selected in the TokenBottomSheet.`,
[TokenBottomSheetEvents.network_filter_updated]: `The network filter was updated and the multiselect UI closed in the TokenBottomSheet.`,
[AssetsEvents.show_asset_balance_info]: `When a user taps on the info icon`,
[AssetsEvents.view_wallet_assets]: `When a user taps on the "Wallet Assets" segmented control or "Assets" tab`,
[AssetsEvents.view_collectibles]: `When a user taps on the "Collectibles" tab`,
Expand Down
51 changes: 41 additions & 10 deletions src/components/FilterChipsCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,32 @@ import React from 'react'
import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import Touchable from 'src/components/Touchable'
import DownArrowIcon from 'src/icons/DownArrowIcon'
import colors from 'src/styles/colors'
import { typeScale } from 'src/styles/fonts'
import { Spacing } from 'src/styles/styles'
import { NetworkId } from 'src/transactions/types'

export interface FilterChip<T> {
interface BaseFilterChip {
id: string
name: string
filterFn: (t: T) => boolean
isSelected: boolean
}
export interface BooleanFilterChip<T> extends BaseFilterChip {
filterFn: (t: T) => boolean
}

export interface NetworkFilterChip<T> extends BaseFilterChip {
filterFn: (t: T, n: NetworkId[]) => boolean
allNetworkIds: NetworkId[]
selectedNetworkIds: NetworkId[]
}

export function isNetworkChip<T>(chip: FilterChip<T>): chip is NetworkFilterChip<T> {
return 'allNetworkIds' in chip
}

export type FilterChip<T> = BooleanFilterChip<T> | NetworkFilterChip<T>

interface Props<T> {
chips: FilterChip<T>[]
Expand Down Expand Up @@ -58,14 +74,24 @@ function FilterChipsCarousel<T>({
}}
style={styles.filterChip}
>
<Text
style={[
styles.filterChipText,
chip.isSelected ? { color: secondaryColor } : { color: primaryColor },
]}
>
{chip.name}
</Text>
<View style={styles.filterChipTextWrapper}>
<Text
style={[
styles.filterChipText,
chip.isSelected ? { color: secondaryColor } : { color: primaryColor },
]}
>
{chip.name}
</Text>
{isNetworkChip(chip) && (
<DownArrowIcon
color={chip.isSelected ? secondaryColor : primaryColor}
strokeWidth={2}
height={Spacing.Regular16}
style={{ marginBottom: 2, marginLeft: 4 }}
/>
)}
</View>
</Touchable>
</View>
)
Expand Down Expand Up @@ -97,6 +123,11 @@ const styles = StyleSheet.create({
filterChipText: {
...typeScale.labelXSmall,
},
filterChipTextWrapper: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
})

export default FilterChipsCarousel
195 changes: 126 additions & 69 deletions src/components/TokenBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,23 @@ import { TokenBottomSheetEvents } from 'src/analytics/Events'
import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { BottomSheetRefType } from 'src/components/BottomSheet'
import BottomSheetBase from 'src/components/BottomSheetBase'
import FilterChipsCarousel, { FilterChip } from 'src/components/FilterChipsCarousel'
import FilterChipsCarousel, {
FilterChip,
NetworkFilterChip,
isNetworkChip,
} from 'src/components/FilterChipsCarousel'
import SearchInput from 'src/components/SearchInput'
import TokenDisplay from 'src/components/TokenDisplay'
import TokenIcon, { IconSize } from 'src/components/TokenIcon'
import Touchable from 'src/components/Touchable'
import NetworkMultiSelectBottomSheet from 'src/components/multiSelect/NetworkMultiSelectBottomSheet'
import InfoIcon from 'src/icons/InfoIcon'
import colors, { Colors } from 'src/styles/colors'
import { typeScale } from 'src/styles/fonts'
import { Spacing } from 'src/styles/styles'
import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem'
import { TokenBalance } from 'src/tokens/slice'
import { NetworkId } from 'src/transactions/types'

export enum TokenPickerOrigin {
Send = 'Send',
Expand Down Expand Up @@ -160,23 +166,52 @@ function TokenBottomSheet<T extends TokenBalance>({

const { t } = useTranslation()

const handleToggleFilterChip = (toggledChip: FilterChip<TokenBalance>) => {
ValoraAnalytics.track(TokenBottomSheetEvents.toggle_tokens_filter, {
filterId: toggledChip.id,
isRemoving: filters.find((chip) => chip.id === toggledChip.id)?.isSelected ?? false,
isPreSelected: filterChips.find((chip) => chip.id === toggledChip.id)?.isSelected ?? false,
})
const networkChipRef = useRef<BottomSheetRefType>(null)
const networkChip = useMemo(
() => filters.find((chip): chip is NetworkFilterChip<TokenBalance> => isNetworkChip(chip)),
[filters]
)

// These function params mimic the params of the setSelectedNetworkIds function in
// const [selectedNetworkIds, setSelectedNetworkIds] = useState<NetworkId[]>([])
// This custom function is used to keep the same shared state between the network filter and the other filters
// which made the rest of the code more readable and maintainable
const setSelectedNetworkIds = (arg: NetworkId[] | ((networkIds: NetworkId[]) => NetworkId[])) => {
setFilters((prev) => {
return prev.map((chip) => {
if (chip.id === toggledChip.id) {
return { ...chip, isSelected: !chip.isSelected }
if (isNetworkChip(chip)) {
const selectedNetworkIds = typeof arg === 'function' ? arg(chip.selectedNetworkIds) : arg
return {
...chip,
selectedNetworkIds,
isSelected: selectedNetworkIds.length !== chip.allNetworkIds.length,
}
}
return chip
})
})
}

const handleToggleFilterChip = (toggledChip: FilterChip<TokenBalance>) => {
if (isNetworkChip(toggledChip)) {
networkChipRef.current?.snapToIndex(0)
} else {
ValoraAnalytics.track(TokenBottomSheetEvents.toggle_tokens_filter, {
filterId: toggledChip.id,
isRemoving: filters.find((chip) => chip.id === toggledChip.id)?.isSelected ?? false,
isPreSelected: filterChips.find((chip) => chip.id === toggledChip.id)?.isSelected ?? false,
})
setFilters((prev) => {
return prev.map((chip) => {
if (chip.id === toggledChip.id) {
return { ...chip, isSelected: !chip.isSelected }
}
return chip
})
})
}
}

const onTokenPressed = (token: T, index: number) => () => {
ValoraAnalytics.track(TokenBottomSheetEvents.token_selected, {
origin,
Expand All @@ -203,12 +238,17 @@ function TokenBottomSheet<T extends TokenBalance>({

const tokenList = useMemo(() => {
const lowercasedSearchTerm = searchTerm.toLowerCase()
const activeFilterFns =
activeFilters.length > 0 ? activeFilters.map((filter) => filter.filterFn) : null

return tokens.filter((token) => {
// Exclude the token if it does not match the active filters
if (activeFilterFns && !activeFilterFns.every((filterFn) => filterFn(token))) {
if (
!activeFilters.every((filter) => {
if (isNetworkChip(filter)) {
return filter.filterFn(token, filter.selectedNetworkIds)
}
return filter.filterFn(token)
})
) {
return false
}

Expand Down Expand Up @@ -247,66 +287,83 @@ function TokenBottomSheet<T extends TokenBalance>({
// that the header would be stuck to the wrong position between sheet reopens.
// See https://valora-app.slack.com/archives/C04B61SJ6DS/p1707757919681089
return (
<BottomSheetBase forwardedRef={forwardedRef} snapPoints={snapPoints}>
<View style={styles.container} testID="TokenBottomSheet">
<BottomSheetFlatList
ref={tokenListRef}
data={tokenList}
keyExtractor={(item) => item.tokenId}
contentContainerStyle={[styles.tokenListContainer, { paddingBottom: insets.bottom }]}
scrollIndicatorInsets={{ top: headerHeight }}
renderItem={({ item, index }) => {
return (
<TokenOptionComponent
tokenInfo={item}
onPress={onTokenPressed(item, index)}
index={index}
showPriceUsdUnavailableWarning={showPriceUsdUnavailableWarning}
<>
<BottomSheetBase forwardedRef={forwardedRef} snapPoints={snapPoints}>
<View style={styles.container} testID="TokenBottomSheet">
<BottomSheetFlatList
ref={tokenListRef}
data={tokenList}
keyExtractor={(item) => item.tokenId}
contentContainerStyle={[styles.tokenListContainer, { paddingBottom: insets.bottom }]}
scrollIndicatorInsets={{ top: headerHeight }}
renderItem={({ item, index }) => {
return (
<TokenOptionComponent
tokenInfo={item}
onPress={onTokenPressed(item, index)}
index={index}
showPriceUsdUnavailableWarning={showPriceUsdUnavailableWarning}
/>
)
}}
ListHeaderComponent={<View style={{ height: headerHeight }} />}
ListEmptyComponent={() => {
if (searchEnabled || filterChips.length > 0) {
return <NoResults searchTerm={searchTerm} activeFilters={activeFilters} />
}
return null
}}
/>
<View style={styles.headerContainer} onLayout={handleMeasureHeader}>
<Text style={[styles.title, titleStyle]}>{title}</Text>
{searchEnabled && (
<SearchInput
placeholder={t('tokenBottomSheet.searchAssets') ?? undefined}
value={searchTerm}
onChangeText={(text) => {
setSearchTerm(text)
sendAnalytics(text)
}}
style={styles.searchInput}
returnKeyType={'search'}
// disable autoCorrect and spellCheck since the search terms here
// are token names which autoCorrect would get in the way of. This
// combination also hides the keyboard suggestions bar from the top
// of the iOS keyboard, preserving screen real estate.
autoCorrect={false}
spellCheck={false}
/>
)
}}
ListHeaderComponent={<View style={{ height: headerHeight }} />}
ListEmptyComponent={() => {
if (searchEnabled || filterChips.length > 0) {
return <NoResults searchTerm={searchTerm} activeFilters={activeFilters} />
}
return null
)}
{filterChips.length > 0 && (
<FilterChipsCarousel
chips={filters}
onSelectChip={handleToggleFilterChip}
primaryColor={colors.successDark}
secondaryColor={colors.successLight}
style={styles.filterChipsCarouselContainer}
forwardedRef={filterChipsCarouselRef}
scrollEnabled={false}
/>
)}
</View>
</View>
</BottomSheetBase>
{networkChip && (
<NetworkMultiSelectBottomSheet
allNetworkIds={networkChip.allNetworkIds}
setSelectedNetworkIds={setSelectedNetworkIds}
selectedNetworkIds={networkChip.selectedNetworkIds}
forwardedRef={networkChipRef}
onClose={() => {
ValoraAnalytics.track(TokenBottomSheetEvents.network_filter_updated, {
selectedNetworkIds: networkChip.selectedNetworkIds,
origin,
})
networkChipRef.current?.close()
}}
/>
<View style={styles.headerContainer} onLayout={handleMeasureHeader}>
<Text style={[styles.title, titleStyle]}>{title}</Text>
{searchEnabled && (
<SearchInput
placeholder={t('tokenBottomSheet.searchAssets') ?? undefined}
value={searchTerm}
onChangeText={(text) => {
setSearchTerm(text)
sendAnalytics(text)
}}
style={styles.searchInput}
returnKeyType={'search'}
// disable autoCorrect and spellCheck since the search terms here
// are token names which autoCorrect would get in the way of. This
// combination also hides the keyboard suggestions bar from the top
// of the iOS keyboard, preserving screen real estate.
autoCorrect={false}
spellCheck={false}
/>
)}
{filterChips.length > 0 && (
<FilterChipsCarousel
chips={filters}
onSelectChip={handleToggleFilterChip}
primaryColor={colors.successDark}
secondaryColor={colors.successLight}
style={styles.filterChipsCarouselContainer}
forwardedRef={filterChipsCarouselRef}
scrollEnabled={false}
/>
)}
</View>
</View>
</BottomSheetBase>
)}
</>
)
}

Expand Down
Loading

0 comments on commit 69ce7a2

Please sign in to comment.