Show Multilocation for Foreign Assets <> Assets Page (#11306)

* feat: create hook for quering foreign assets locations

* feat: add foreign assets tab

* fix: useMapKeys used instead of useMapEntries

* chore: rename hook

* feat: add ForeignAssets component and Summary for displaying foreign asset counts

* chore: rename folder

* feat: implement useForeignAssetInfos hook for managing foreign asset information

* feat: add Asset and Assets components for displaying foreign asset information

* feat: enhance Asset component with CopyButton for location and adjust Assets table layout

* chore: add translations

* fix: CI error

* fix: CI build errors

* feat: added expander component for multilocation

* chore: align position change
This commit is contained in:
Arjun Porwal 2025-02-19 18:46:59 +05:30 committed by GitHub
parent 783206911a
commit 16826c9268
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 311 additions and 4 deletions

View File

@ -1,6 +1,7 @@
{
"Balances": "Balances",
"Create": "Create",
"Foreign assets": "Foreign assets",
"Mint": "Mint",
"Next": "Next",
"No": "No",
@ -44,10 +45,12 @@
"assets": "assets",
"create asset {{step}}/{{steps}}": "create asset {{step}}/{{steps}}",
"creator account": "creator account",
"foreign assets": "foreign assets",
"free balance": "free balance",
"freezer": "freezer",
"freezer account": "freezer account",
"frozen": "frozen",
"holders": "holders",
"issuer": "issuer",
"issuer account": "issuer account",
"minimum balance": "minimum balance",

View File

@ -293,6 +293,7 @@
"Folder": "",
"For approvals outstanding approvers will be shown, for hashes that should be cancelled the first approver is required.": "",
"For fund security, your session key should not match your stash key.": "",
"Foreign assets": "",
"Forget": "",
"Forget this account": "",
"Forget this address": "",
@ -1439,6 +1440,7 @@
"first": "",
"first - last": "",
"first period": "",
"foreign assets": "",
"forks": "",
"found signatories": "",
"founder": "",
@ -1466,6 +1468,7 @@
"hex-encoded call": "",
"hex-encoded storage key": "",
"historic results": "",
"holders": "",
"https://example.com": "",
"id": "",
"ideal staked": "",

View File

@ -0,0 +1,71 @@
// Copyright 2017-2025 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ForeignAssetInfo } from '../useForeignAssetInfos.js';
import React, { useMemo } from 'react';
import { AddressSmall, CopyButton, Expander, styled } from '@polkadot/react-components';
import { FormatBalance } from '@polkadot/react-query';
interface Props {
className?: string;
value: ForeignAssetInfo;
}
function Asset ({ className, value: { details, location, metadata } }: Props): React.ReactElement<Props> {
const format = useMemo(
(): [number, string] => metadata
? [metadata.decimals.toNumber(), metadata.symbol.toUtf8()]
: [0, '---'],
[metadata]
);
return (
<tr className={className}>
<Location>
<Expander
isLeft
summary={JSON.stringify(location?.toJSON()).substring(0, 40)}
>
<pre>
{JSON.stringify(location?.toJSON(), null, 2)}
</pre>
</Expander>
<CopyButton value={JSON.stringify(location?.toJSON(), null, 2)} />
</Location>
<td className='together'>{metadata?.name.toUtf8()}</td>
<td className='address media--1000'>{details && <AddressSmall value={details.owner} />}</td>
<td className='address media--1300'>{details && <AddressSmall value={details.admin} />}</td>
<td className='address media--1600'>{details && <AddressSmall value={details.issuer} />}</td>
<td className='address media--1900'>{details && <AddressSmall value={details.freezer} />}</td>
<td className='number media--800'>{details && (
<FormatBalance
format={format}
value={details.supply}
/>
)}</td>
<td className='number all'>{details?.accounts.toString() || '0'}</td>
</tr>
);
}
const Location = styled.td`
display: flex;
align-items: center;
gap: 0.6rem;
white-space: nowrap;
font-style: italic;
pre {
overflow: visible;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.ui--Expander-summary svg {
padding: 0.3rem 0.4rem;
border-radius: 0.25rem;
border: 1px solid var(--border-input);
}
`;
export default React.memo(Asset);

View File

@ -0,0 +1,48 @@
// Copyright 2017-2025 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ForeignAssetInfo } from '../useForeignAssetInfos.js';
import React, { useRef } from 'react';
import { Table } from '@polkadot/react-components';
import { useTranslation } from '../translate.js';
import Asset from './Asset.js';
interface Props {
className?: string;
infos?: ForeignAssetInfo[];
}
function Assets ({ className, infos }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('foreign assets'), 'start'],
[],
[t('owner'), 'address media--1000'],
[t('admin'), 'address media--1300'],
[t('issuer'), 'address media--1600'],
[t('freezer'), 'address media--1900'],
[t('supply'), 'media--800'],
[t('holders')]
]);
return (
<Table
className={className}
empty={infos && t('No assets found')}
header={headerRef.current}
>
{infos?.map((info) => (
<Asset
key={info.key}
value={info}
/>
))}
</Table>
);
}
export default React.memo(Assets);

View File

@ -0,0 +1,28 @@
// Copyright 2017-2025 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { CardSummary, SummaryBox } from '@polkadot/react-components';
import { formatNumber } from '@polkadot/util';
import { useTranslation } from '../translate.js';
interface Props {
className?: string;
numAssets?: number;
}
function Summary ({ className, numAssets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
<CardSummary label={t('foreign assets')}>
{formatNumber(numAssets)}
</CardSummary>
</SummaryBox>
);
}
export default React.memo(Summary);

View File

@ -0,0 +1,27 @@
// Copyright 2017-2025 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StagingXcmV3MultiLocation } from '@polkadot/types/lookup';
import type { ForeignAssetInfo } from '../useForeignAssetInfos.js';
import React from 'react';
import Assets from './Assets.js';
import Summary from './Summary.js';
interface Props {
className?: string;
foreignAssetInfos?: ForeignAssetInfo[]
locations?: StagingXcmV3MultiLocation[];
}
function ForeignAssets ({ className, foreignAssetInfos, locations }: Props): React.ReactElement<Props> {
return (
<div className={className}>
<Summary numAssets={locations?.length} />
<Assets infos={foreignAssetInfos} />
</div>
);
}
export default React.memo(ForeignAssets);

View File

@ -16,8 +16,11 @@ import { useAccounts, useApi, useAssetIds, useAssetInfos } from '@polkadot/react
import { BN_ONE } from '@polkadot/util';
import Balances from './Balances/index.js';
import ForeignAssets from './foreignAssets/index.js';
import Overview from './Overview/index.js';
import { useTranslation } from './translate.js';
import { useForeignAssetInfos } from './useForeignAssetInfos.js';
import { useForeignAssetLocations } from './useForeignAssetLocations.js';
interface Props {
basePath: string;
@ -54,23 +57,32 @@ function AssetApp ({ basePath, className }: Props): React.ReactElement<Props> {
const ids = useAssetIds();
const infos = useAssetInfos(ids);
const foreignAssetLocations = useForeignAssetLocations();
const foreignAssetInfos = useForeignAssetInfos(foreignAssetLocations);
const tabsRef = useRef([
{
isRoot: true,
name: 'overview',
text: t('Overview')
},
{
name: 'foreignAssets',
text: t('Foreign assets')
},
{
name: 'balances',
text: t('Balances')
}
]);
const showForeignAssetsTab = useMemo(() => !!foreignAssetLocations.length, [foreignAssetLocations.length]);
const showBalancesTab = useMemo(() => hasAccounts && infos && infos.some(({ details, metadata }) => !!(details && metadata)), [hasAccounts, infos]);
const hidden = useMemo(
() => (hasAccounts && infos && infos.some(({ details, metadata }) => !!(details && metadata)))
? []
: ['balances'],
[hasAccounts, infos]
() =>
[!showForeignAssetsTab && 'foreignAssets', !showBalancesTab && 'balances'].filter((a) => typeof a === 'string'),
[showBalancesTab, showForeignAssetsTab]
);
const openId = useMemo(
@ -93,6 +105,15 @@ function AssetApp ({ basePath, className }: Props): React.ReactElement<Props> {
}
path='balances'
/>
<Route
element={
<ForeignAssets
foreignAssetInfos={foreignAssetInfos}
locations={foreignAssetLocations}
/>
}
path='foreignAssets'
/>
<Route
element={
<Overview

View File

@ -0,0 +1,82 @@
// Copyright 2017-2025 @polkadot/react-hooks authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { AccountId } from '@polkadot/types/interfaces';
import type { PalletAssetsAssetDetails, PalletAssetsAssetMetadata, StagingXcmV3MultiLocation } from '@polkadot/types/lookup';
import { useEffect, useMemo, useState } from 'react';
import { createNamedHook, useAccounts, useApi, useCall } from '@polkadot/react-hooks';
const EMPTY_FLAGS = {
isAdminMe: false,
isFreezerMe: false,
isIssuerMe: false,
isOwnerMe: false
};
export interface ForeignAssetInfo {
details: PalletAssetsAssetDetails | null;
location: StagingXcmV3MultiLocation;
isAdminMe: boolean;
isIssuerMe: boolean;
isFreezerMe: boolean;
isOwnerMe: boolean;
key: string;
metadata: PalletAssetsAssetMetadata | null;
}
const QUERY_OPTS = { withParams: true };
function isAccount (allAccounts: string[], accountId: AccountId): boolean {
const address = accountId.toString();
return allAccounts.some((a) => a === address);
}
function extractInfo (allAccounts: string[], location: StagingXcmV3MultiLocation, optDetails: Option<PalletAssetsAssetDetails>, metadata: PalletAssetsAssetMetadata): ForeignAssetInfo {
const details = optDetails.unwrapOr(null);
return {
...(details
? {
isAdminMe: isAccount(allAccounts, details.admin),
isFreezerMe: isAccount(allAccounts, details.freezer),
isIssuerMe: isAccount(allAccounts, details.issuer),
isOwnerMe: isAccount(allAccounts, details.owner)
}
: EMPTY_FLAGS
),
details,
key: String(location),
location,
metadata: metadata.isEmpty
? null
: metadata
};
}
function useForeignAssetInfosImpl (locations?: StagingXcmV3MultiLocation[]): ForeignAssetInfo[] | undefined {
const { api } = useApi();
const { allAccounts } = useAccounts();
const isReady = useMemo(() => !!locations?.length && !!api.tx.foreignAssets?.setMetadata && !!api.tx.foreignAssets?.transferKeepAlive, [api.tx.foreignAssets?.setMetadata, api.tx.foreignAssets?.transferKeepAlive, locations?.length]);
const metadata = useCall<[[StagingXcmV3MultiLocation[]], PalletAssetsAssetMetadata[]]>(isReady && api.query.foreignAssets.metadata.multi, [locations], QUERY_OPTS);
const details = useCall<[[StagingXcmV3MultiLocation[]], Option<PalletAssetsAssetDetails>[]]>(isReady && api.query.foreignAssets.asset.multi, [locations], QUERY_OPTS);
const [state, setState] = useState<ForeignAssetInfo[] | undefined>();
useEffect((): void => {
details && metadata && (details[0][0].length === metadata[0][0].length) &&
setState(
details[0][0].map((location, index) =>
extractInfo(allAccounts, location, details[1][index], metadata[1][index])
)
);
}, [allAccounts, details, locations, metadata]);
return state;
}
export const useForeignAssetInfos = createNamedHook('useForeignAssetInfos', useForeignAssetInfosImpl);

View File

@ -0,0 +1,24 @@
// Copyright 2017-2025 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StorageKey } from '@polkadot/types';
import type { StagingXcmV3MultiLocation } from '@polkadot/types/lookup';
import { createNamedHook, useApi, useMapKeys } from '@polkadot/react-hooks';
const EMPTY_PARAMS: unknown[] = [];
const OPT_KEY = {
transform: (keys: StorageKey<[StagingXcmV3MultiLocation]>[]): StagingXcmV3MultiLocation[] =>
keys.flatMap(({ args }) => args)
};
function useForeignAssetLocationsImpl () {
const { api, isApiReady } = useApi();
const values = useMapKeys(isApiReady && api.query.foreignAssets?.asset, EMPTY_PARAMS, OPT_KEY) || [];
return values;
}
export const useForeignAssetLocations = createNamedHook('useForeignAssetLocations', useForeignAssetLocationsImpl);