Skip to content

Commit

Permalink
Refactor <AssetOutSelector /> and <SelectTokenModal /> (#970)
Browse files Browse the repository at this point in the history
* Refactor <AssetOutSelector /> and <SelectTokenModal />

* Add comment
  • Loading branch information
jessepinho authored Apr 23, 2024
1 parent 1f9d75c commit eb5c717
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 137 deletions.
126 changes: 126 additions & 0 deletions apps/minifront/src/components/shared/asset-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTrigger,
} from '@penumbra-zone/ui/components/ui/dialog';
import { AssetIcon } from '@penumbra-zone/ui/components/ui/tx/view/asset-icon';
import {
Metadata,
ValueView,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { localAssets } from '@penumbra-zone/constants/src/assets';
import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value';
import { useEffect, useMemo, useState } from 'react';
import { IconInput } from '@penumbra-zone/ui/components/ui/icon-input';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';

interface AssetSelectorProps {
value?: Metadata;
onChange: (metadata: Metadata) => void;
/**
* If passed, this function will be called for every asset that
* `AssetSelector` plans to display. It should return `true` or `false`
* depending on whether that asset should be displayed.
*/
filter?: (metadata: Metadata) => boolean;
}

const sortedAssets = [...localAssets].sort((a, b) =>
a.symbol.toLocaleLowerCase() < b.symbol.toLocaleLowerCase() ? -1 : 1,
);

/**
* If the `filter` rejects the currently selected `asset`, switch to a different
* `asset`.
*/
const switchAssetIfNecessary = ({
value,
onChange,
filter,
assets,
}: AssetSelectorProps & { assets: Metadata[] }) => {
if (!filter || !value) return;

if (!filter(value)) {
const firstAssetThatPassesTheFilter = assets.find(filter);
if (firstAssetThatPassesTheFilter) onChange(firstAssetThatPassesTheFilter);
}
};

const useFilteredAssets = ({ value, onChange, filter }: AssetSelectorProps) => {
const [search, setSearch] = useState('');

let assets = filter ? sortedAssets.filter(filter) : sortedAssets;
assets = search ? assets.filter(bySearch(search)) : assets;

useEffect(
() => switchAssetIfNecessary({ value, onChange, filter, assets }),
[filter, value, assets, onChange],
);

return { assets, search, setSearch };
};

const bySearch = (search: string) => (asset: Metadata) =>
asset.display.toLocaleLowerCase().includes(search.toLocaleLowerCase()) ||
asset.symbol.toLocaleLowerCase().includes(search.toLocaleLowerCase());

/**
* Allows the user to select any asset known to Penumbra, optionally filtered by
* a filter function.
*
* For an asset selector that picks from the user's balances, use
* `<BalanceSelector />`.
*/
export const AssetSelector = ({ onChange, value, filter }: AssetSelectorProps) => {
const { assets, search, setSearch } = useFilteredAssets({ value, onChange, filter });

/**
* @todo: Refactor to not use `ValueViewComponent`, since it's not intended to
* just display an asset icon/symbol without a value.
*/
const valueView = useMemo(
() => new ValueView({ valueView: { case: 'knownAssetId', value: { metadata: value } } }),
[value],
);

return (
<Dialog>
<DialogTrigger className='block'>
<div className='flex h-9 min-w-[100px] max-w-[150px] items-center justify-center gap-2 rounded-lg bg-light-brown px-2'>
<ValueViewComponent view={valueView} showValue={false} />
</div>
</DialogTrigger>
<DialogContent>
<div className='flex max-h-screen flex-col'>
<DialogHeader>Select asset</DialogHeader>
<div className='flex flex-col gap-2 overflow-auto p-4'>
<IconInput
icon={<MagnifyingGlassIcon className='size-5 text-muted-foreground' />}
value={search}
onChange={setSearch}
placeholder='Search assets...'
/>
{assets.map(metadata => (
<div key={metadata.display} className='flex flex-col'>
<DialogClose>
<div
className={
'flex cursor-pointer justify-start gap-[6px] overflow-hidden py-[10px] font-bold text-muted-foreground hover:-mx-4 hover:bg-light-brown hover:px-4'
}
onClick={() => onChange(metadata)}
>
<AssetIcon metadata={metadata} />
<p className='truncate'>{metadata.symbol}</p>
</div>
</DialogClose>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
import { Input } from '@penumbra-zone/ui/components/ui/input';
import { IconInput } from '@penumbra-zone/ui/components/ui/icon-input';
import {
Dialog,
DialogClose,
Expand All @@ -12,50 +12,60 @@ import { cn } from '@penumbra-zone/ui/lib/utils';
import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { getAddressIndex } from '@penumbra-zone/getters/src/address-view';
import {
getDisplayDenomFromView,
getSymbolFromValueView,
} from '@penumbra-zone/getters/src/value-view';

const bySearch = (search: string) => (balancesResponse: BalancesResponse) =>
getDisplayDenomFromView(balancesResponse.balanceView)
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase()) ||
getSymbolFromValueView(balancesResponse.balanceView)
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase());

interface SelectTokenModalProps {
selection: BalancesResponse | undefined;
setSelection: (selection: BalancesResponse) => void;
interface BalanceSelectorProps {
value: BalancesResponse | undefined;
onChange: (selection: BalancesResponse) => void;
balances: BalancesResponse[];
}

export default function SelectTokenModal({
selection,
balances,
setSelection,
}: SelectTokenModalProps) {
/**
* Renders balances the user holds, and allows the user to select one. This is
* useful for a form where the user wants to send/sell/swap an asset that they
* already hold.
*
* Use `<AssetSelector />` if you want to render assets that aren't tied to any
* balance.
*/
export default function BalanceSelector({ value, balances, onChange }: BalanceSelectorProps) {
const [search, setSearch] = useState('');
const filteredBalances = search ? balances.filter(bySearch(search)) : balances;

return (
<Dialog>
<DialogTrigger disabled={!balances.length}>
<div className='flex h-9 min-w-[100px] max-w-[200px] items-center justify-center gap-2 rounded-lg bg-light-brown px-2'>
<ValueViewComponent view={selection?.balanceView} showValue={false} />
<ValueViewComponent view={value?.balanceView} showValue={false} />
</div>
</DialogTrigger>
<DialogContent>
<div className='relative z-10 flex max-h-screen flex-col gap-4 pb-5'>
<div className='flex max-h-screen flex-col'>
<DialogHeader>Select asset</DialogHeader>
<div className='px-[30px]'>
<div className='relative flex w-full items-center justify-center gap-4'>
<div className='absolute inset-y-0 left-3 flex items-center'>
<MagnifyingGlassIcon className='size-5 text-muted-foreground' />
</div>
<Input
className='pl-10'
value={search}
onChange={e => setSearch(e.target.value)}
placeholder='Search asset...'
/>
</div>
</div>
<div className='flex shrink flex-col gap-4 overflow-auto px-[30px]'>
<div className='flex shrink flex-col gap-4 overflow-auto p-4'>
<IconInput
icon={<MagnifyingGlassIcon className='size-5 text-muted-foreground' />}
value={search}
onChange={setSearch}
placeholder='Search assets...'
/>
<div className='mt-2 grid grid-cols-4 font-headline text-base font-semibold'>
<p className='flex justify-start'>Account</p>
<p className='col-span-3 flex justify-start'>Asset</p>
</div>
<div className='flex flex-col gap-2'>
{balances.map((b, i) => {
{filteredBalances.map((b, i) => {
const index = getAddressIndex(b.accountAddress).account;

return (
Expand All @@ -64,11 +74,11 @@ export default function SelectTokenModal({
<div
className={cn(
'grid grid-cols-4 py-[10px] cursor-pointer hover:bg-light-brown hover:px-4 hover:-mx-4 font-bold text-muted-foreground',
selection?.balanceView?.equals(b.balanceView) &&
selection.accountAddress?.equals(b.accountAddress) &&
value?.balanceView?.equals(b.balanceView) &&
value.accountAddress?.equals(b.accountAddress) &&
'bg-light-brown px-4 -mx-4',
)}
onClick={() => setSelection(b)}
onClick={() => onChange(b)}
>
<p className='flex justify-start'>{index}</p>
<div className='col-span-3 flex justify-start'>
Expand All @@ -82,7 +92,6 @@ export default function SelectTokenModal({
</div>
</div>
</div>
<div className='absolute inset-0 z-0 bg-card-radial opacity-20' />
</DialogContent>
</Dialog>
);
Expand Down
13 changes: 7 additions & 6 deletions apps/minifront/src/components/shared/input-token.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Input, InputProps } from '@penumbra-zone/ui/components/ui/input';
import { Input } from '@penumbra-zone/ui/components/ui/input';
import { cn } from '@penumbra-zone/ui/lib/utils';
import SelectTokenModal from './select-token-modal';
import BalanceSelector from './balance-selector';
import { Validation } from './validation-result';
import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value';
import { InputBlock } from './input-block';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { WalletIcon } from '@penumbra-zone/ui/components/ui/icons/wallet';

interface InputTokenProps extends InputProps {
interface InputTokenProps {
label: string;
selection: BalancesResponse | undefined;
placeholder: string;
Expand All @@ -17,6 +17,7 @@ interface InputTokenProps extends InputProps {
setSelection: (selection: BalancesResponse) => void;
validations?: Validation[];
balances: BalancesResponse[];
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

export default function InputToken({
Expand All @@ -29,7 +30,7 @@ export default function InputToken({
inputClassName,
setSelection,
balances,
...props
onChange,
}: InputTokenProps) {
return (
<InputBlock label={label} value={value} validations={validations} className={className}>
Expand All @@ -43,9 +44,9 @@ export default function InputToken({
inputClassName,
)}
value={value}
{...props}
onChange={onChange}
/>
<SelectTokenModal selection={selection} setSelection={setSelection} balances={balances} />
<BalanceSelector value={selection} onChange={setSelection} balances={balances} />
</div>

<div className='mt-[6px] flex items-center justify-between gap-2'>
Expand Down
8 changes: 2 additions & 6 deletions apps/minifront/src/components/swap/asset-out-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
TooltipTrigger,
} from '@penumbra-zone/ui/components/ui/tooltip';
import { buttonVariants } from '@penumbra-zone/ui/components/ui/button';
import { AssetOutSelector } from './asset-out-selector';
import { AssetSelector } from '../shared/asset-selector';
import {
Metadata,
ValueView,
Expand Down Expand Up @@ -72,11 +72,7 @@ export const AssetOutBox = ({ balances }: AssetOutBoxProps) => {
</div>
<div className='flex flex-col'>
<div className='ml-auto w-auto shrink-0'>
<AssetOutSelector
assetOut={matchingBalance}
setAssetOut={setAssetOut}
filter={filter}
/>
<AssetSelector value={assetOut} onChange={setAssetOut} filter={filter} />
</div>
<div className='mt-[6px] flex items-start justify-between'>
<div />
Expand Down
Loading

0 comments on commit eb5c717

Please sign in to comment.