Refactor Assets Page <> Improve Non-Native Asset Balance Display (#11290)

* feat: shown assets in table format

* feat: refactor Account and Asset components for improved rendering and styling

* feat: add search functionality to asset balances display

* feat: remove emptySpinner prop from Balances table
This commit is contained in:
Arjun Porwal 2025-02-12 18:47:07 +05:30 committed by GitHub
parent 91e352848e
commit 11024f141a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 120 additions and 76 deletions

View File

@ -32,11 +32,11 @@ interface Props {
siFormat: [number, string];
}
function Account ({ account: { balance, isFrozen, reason, sufficient }, accountId, assetId, className, minBalance, siFormat }: Props): React.ReactElement<Props> {
function Account ({ account: { balance, isFrozen, reason, sufficient }, accountId, assetId, minBalance, siFormat }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<tr className={className}>
<>
<td className='address'>
<AddressSmall value={accountId} />
</td>
@ -62,7 +62,7 @@ function Account ({ account: { balance, isFrozen, reason, sufficient }, accountI
siFormat={siFormat}
/>
</td>
</tr>
</>
);
}

View File

@ -0,0 +1,83 @@
// Copyright 2017-2025 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetInfoComplete } from '@polkadot/react-hooks/types';
import React, { useMemo } from 'react';
import { styled } from '@polkadot/react-components';
import { formatNumber } from '@polkadot/util';
import Account from './Account.js';
import useBalances from './useBalances.js';
interface Props {
asset: AssetInfoComplete,
className?: string;
searchValue: string;
}
const Asset = ({ asset: { details, id, metadata }, className, searchValue }: Props) => {
const balances = useBalances(id);
const siFormat = useMemo(
(): [number, string] => metadata
? [metadata.decimals.toNumber(), metadata.symbol.toUtf8().toUpperCase()]
: [0, 'NONE'],
[metadata]
);
const shouldShowAsset = useMemo(() => metadata.name.toUtf8().toLowerCase().includes(searchValue) ||
formatNumber(id).toString().replaceAll(',', '').includes(searchValue), [id, metadata.name, searchValue]);
if (!balances?.length || !shouldShowAsset) {
return <></>;
}
return balances.map(({ account, accountId }, index) => {
return (
<StyledTr
className={`isExpanded ${className}`}
isFirstItem={index === 0}
isLastItem={index === balances.length - 1}
key={accountId}
>
<td className='all'>
{index === 0 && <>{metadata.name.toUtf8()} ({formatNumber(id)})</>}
</td>
<Account
account={account}
accountId={accountId}
assetId={id}
key={accountId}
minBalance={details.minBalance}
siFormat={siFormat}
/>
</StyledTr>
);
});
};
const BASE_BORDER = 0.125;
const BORDER_TOP = `${BASE_BORDER * 3}rem solid var(--bg-page)`;
const BORDER_RADIUS = `${BASE_BORDER * 4}rem`;
const StyledTr = styled.tr<{isFirstItem: boolean; isLastItem: boolean}>`
td {
border-top: ${(props) => props.isFirstItem && BORDER_TOP};
border-radius: 0rem !important;
&:first-child {
padding-block: 1rem !important;
border-top-left-radius: ${(props) => props.isFirstItem ? BORDER_RADIUS : '0rem'}!important;
border-bottom-left-radius: ${(props) => props.isLastItem ? BORDER_RADIUS : '0rem'}!important;
}
&:last-child {
border-top-right-radius: ${(props) => props.isFirstItem ? BORDER_RADIUS : '0rem'}!important;
border-bottom-right-radius: ${(props) => props.isLastItem ? BORDER_RADIUS : '0rem'}!important;
}
}
`;
export default Asset;

View File

@ -1,17 +1,14 @@
// Copyright 2017-2025 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DropdownItemProps } from 'semantic-ui-react';
import type { AssetInfo, AssetInfoComplete } from '@polkadot/react-hooks/types';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Dropdown, styled, Table } from '@polkadot/react-components';
import { formatNumber } from '@polkadot/util';
import { Input, styled, Table } from '@polkadot/react-components';
import { useTranslation } from '../translate.js';
import Account from './Account.js';
import useBalances from './useBalances.js';
import Asset from './Asset.js';
interface Props {
className?: string;
@ -20,97 +17,61 @@ interface Props {
function Balances ({ className, infos = [] }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [selectedAssetValue, setSelectedAssetValue] = useState('0');
const [info, setInfo] = useState<AssetInfoComplete | null>(null);
const balances = useBalances(info?.id);
const [{ searchValue }, onApplySearch] = useState({ searchValue: '' });
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('asset'), 'start'],
[t('accounts'), 'start'],
[t('frozen'), 'start'],
[t('sufficient'), 'start'],
[],
[t('free balance'), 'start'],
[]
]);
const completeInfos = useMemo(
const onChangeInput = useCallback((e: string) => {
onApplySearch({ searchValue: e });
}, []);
const completeAssets = useMemo(
() => infos
.filter((i): i is AssetInfoComplete => !!(i.details && i.metadata) && !i.details.supply.isZero())
.sort((a, b) => a.id.cmp(b.id)),
[infos]
);
const assetOptions = useMemo(
() => completeInfos.map(({ id, metadata }) => ({
text: `${metadata.name.toUtf8()} (${formatNumber(id)})`,
value: id.toString()
})),
[completeInfos]
);
const siFormat = useMemo(
(): [number, string] => info
? [info.metadata.decimals.toNumber(), info.metadata.symbol.toUtf8().toUpperCase()]
: [0, 'NONE'],
[info]
);
const onSearch = useCallback(
(options: DropdownItemProps[], value: string): DropdownItemProps[] =>
options.filter((options) => {
const { text: optText, value: optValue } = options as { text: string, value: number };
return parseInt(value) === optValue || optText.includes(value);
}),
[]
);
useEffect((): void => {
const info = completeInfos.find(({ id }) => id.toString() === selectedAssetValue);
// if no info found (usually happens on first load), select the first one automatically
if (!info) {
setInfo(completeInfos.at(0) ?? null);
setSelectedAssetValue(completeInfos.at(0)?.id?.toString() ?? '0');
} else {
setInfo(info);
}
}, [completeInfos, selectedAssetValue]);
return (
<StyledDiv className={className}>
<Input
aria-label={t('Search by asset id or name')}
className='full isSmall'
label={t('Search')}
onChange={onChangeInput}
placeholder={t('Search by asset id or name')}
value={searchValue}
/>
<Table
empty={info && balances && t('No accounts with balances found for the asset')}
filter={assetOptions.length
? (
<Dropdown
isFull
label={t('the asset to query for balances')}
onChange={setSelectedAssetValue}
onSearch={onSearch}
options={assetOptions}
value={selectedAssetValue}
/>
)
: undefined
}
empty={t('No accounts with balances found for the asset')}
header={headerRef.current}
>
{info && balances?.map(({ account, accountId }) => (
<Account
account={account}
accountId={accountId}
assetId={info.id}
key={accountId}
minBalance={info.details.minBalance}
siFormat={siFormat}
/>
))}
{completeAssets.map((asset) => {
return (
<Asset
asset={asset}
key={asset.id.toString()}
searchValue={searchValue.toLowerCase()}
/>
);
})}
</Table>
</StyledDiv>
);
}
const StyledDiv = styled.div`
input {
max-width: 250px !important;
}
table {
overflow: auto;
}

View File

@ -52,7 +52,7 @@ function useAssetInfosImpl (ids?: BN[]): AssetInfo[] | undefined {
const { api } = useApi();
const { allAccounts } = useAccounts();
const isReady = useMemo(() => !!api.tx.assets?.setMetadata && !!api.tx.assets?.transferKeepAlive, [api.tx.assets?.setMetadata, api.tx.assets?.transferKeepAlive]);
const isReady = useMemo(() => !!ids?.length && !!api.tx.assets?.setMetadata && !!api.tx.assets?.transferKeepAlive, [api.tx.assets?.setMetadata, api.tx.assets?.transferKeepAlive, ids?.length]);
const metadata = useCall<[[BN[]], PalletAssetsAssetMetadata[]]>(isReady && api.query.assets.metadata.multi, [ids], QUERY_OPTS);
const details = useCall<[[BN[]], Option<PalletAssetsAssetDetails>[]]>(isReady && api.query.assets.asset.multi, [ids], QUERY_OPTS);