Asset create & list display (#4911)

* Asset create & list display

* metadata WIP

* ... inputs

* Steps

* min balance

* with balance

* Expand display
This commit is contained in:
Jaco Greeff 2021-03-23 23:03:32 +01:00 committed by GitHub
parent 32fc8b0257
commit 3afc055b3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 590 additions and 4 deletions

View File

@ -10,12 +10,11 @@ export default function create (t: TFunction): Route {
return {
Component,
display: {
isHidden: true,
needsApi: [
'tx.assets.create'
'tx.assets.setMetadata'
]
},
group: 'accounts',
group: 'network',
icon: 'shopping-basket',
name: 'assets',
text: t('nav.assets', 'Assets', { ns: 'apps-routing' })

View File

@ -6,6 +6,7 @@ import type { Routes } from './types';
import accounts from './accounts';
import addresses from './addresses';
import assets from './assets';
import bounties from './bounties';
import calendar from './calendar';
import claims from './claims';
@ -45,6 +46,7 @@ export default function create (t: TFunction): Routes {
techcomm(t),
parachains(t),
gilt(t),
assets(t),
society(t),
calendar(t),
contracts(t),

View File

@ -0,0 +1,31 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetInfo } from './types';
import React from 'react';
import { AddressSmall } from '@polkadot/react-components';
import { formatNumber } from '@polkadot/util';
interface Props {
className?: string;
value: AssetInfo;
}
function Asset ({ className, value: { details, id, metadata } }: Props): React.ReactElement<Props> {
return (
<tr className={className}>
<td className='number'><h1>{formatNumber(id)}</h1></td>
<td className='together'>{metadata?.name.toUtf8()}</td>
<td className='address'>{details && <AddressSmall value={details.admin} />}</td>
<td className='address'>{details && <AddressSmall value={details.issuer} />}</td>
<td className='address'>{details && <AddressSmall value={details.freezer} />}</td>
<td className='number all'>{formatNumber(metadata?.decimals)}</td>
<td className='upper'>{metadata?.symbol.toUtf8()}</td>
<td className='button' />
</tr>
);
}
export default React.memo(Asset);

View File

@ -0,0 +1,49 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetId } from '@polkadot/types/interfaces';
import React, { useRef } from 'react';
import { Table } from '@polkadot/react-components';
import { useTranslation } from '../translate';
import Asset from './Asset';
import useAssetInfos from './useAssetInfos';
interface Props {
assetIds?: AssetId[];
className?: string;
}
function Assets ({ assetIds, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const assetInfos = useAssetInfos(assetIds);
const headerRef = useRef([
[t('assets'), 'start', 2],
[t('admin'), 'start'],
[t('issuer'), 'start'],
[t('freezer'), 'start'],
[t('decimals')],
[t('symbol'), 'start'],
[]
]);
return (
<Table
className={className}
empty={assetInfos && t<string>('No assets found')}
header={headerRef.current}
>
{assetInfos?.map((info) => (
<Asset
key={info.key}
value={info}
/>
))}
</Table>
);
}
export default React.memo(Assets);

View File

@ -0,0 +1,75 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type BN from 'bn.js';
import type { AssetId } from '@polkadot/types/interfaces';
import type { InfoState } from './types';
import React, { useEffect, useMemo, useState } from 'react';
import { InputAddress, InputNumber, Modal } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate';
interface Props {
assetIds: AssetId[];
className?: string;
onChange: (info: InfoState | null) => void;
}
function Info ({ assetIds, className = '', onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [accountId, setAccountId] = useState<string | null>(null);
const [assetId, setAssetId] = useState<BN | null>(null);
const [minBalance, setMinBalance] = useState<BN | null>(null);
const isIdValid = useMemo(
() => !!assetId && assetId.gt(BN_ZERO) && !assetIds.some((a) => a.eq(assetId)),
[assetId, assetIds]
);
useEffect((): void => {
onChange(
assetId && accountId && minBalance && isIdValid && !minBalance.isZero()
? {
accountId,
assetId,
createTx: api.tx.assets.create(assetId, accountId, minBalance)
}
: null
);
}, [api, accountId, assetId, assetIds, isIdValid, minBalance, onChange]);
return (
<Modal.Content className={className}>
<Modal.Columns hint={t<string>('The account that is to be used to create this asset and setup the initial metadata.')}>
<InputAddress
label={t<string>('creator account')}
onChange={setAccountId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The selected id for the asset. Should not match an already-existing id.')}>
<InputNumber
autoFocus
isError={!isIdValid}
isZeroable={false}
label={t<string>('asset id')}
onChange={setAssetId}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The minimum balance for the asset.')}>
<InputNumber
isZeroable={false}
label={t<string>('minimum balance')}
onChange={setMinBalance}
/>
</Modal.Columns>
</Modal.Content>
);
}
export default React.memo(Info);

View File

@ -0,0 +1,78 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type BN from 'bn.js';
import type { MetadataState } from './types';
import React, { useEffect, useMemo, useState } from 'react';
import { Input, InputNumber, Modal } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate';
interface Props {
assetId?: BN | null;
className?: string;
onChange: (info: MetadataState | null) => void;
}
function Metadata ({ assetId, className = '', onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [assetDecimals, setAssetDecimals] = useState<BN | null>(null);
const [assetName, setAssetName] = useState<string | null>(null);
const [assetSymbol, setAssetSymbol] = useState<string | null>(null);
const isValidDecimals = useMemo(
() => !!assetDecimals && assetDecimals.lten(20),
[assetDecimals]
);
const isValidName = useMemo(
() => !!assetName && assetName.length >= 3 && assetName.length <= 32,
[assetName]
);
const isValidSymbol = useMemo(
() => !!assetSymbol && assetSymbol.length >= 3,
[assetSymbol]
);
useEffect((): void => {
onChange(
assetId && assetName && assetSymbol && assetDecimals && isValidName && isValidSymbol && isValidDecimals
? { metadataTx: api.tx.assets.setMetadata(assetId, assetName, assetSymbol, assetDecimals) }
: null
);
}, [api, assetDecimals, assetId, assetName, assetSymbol, isValidName, isValidSymbol, isValidDecimals, onChange]);
return (
<Modal.Content className={className}>
<Modal.Columns hint={t<string>('The descriptive name for this asset.')}>
<Input
autoFocus
isError={!isValidName}
label={t<string>('asset name')}
onChange={setAssetName}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The symbol that will represent this asset.')}>
<Input
isError={!isValidSymbol}
label={t<string>('asset symbol')}
onChange={setAssetSymbol}
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The number of decimals for this token. Max allowed via the UI is set to 20.')}>
<InputNumber
isError={!isValidDecimals}
label={t<string>('asset decimals')}
onChange={setAssetDecimals}
/>
</Modal.Columns>
</Modal.Content>
);
}
export default React.memo(Metadata);

View File

@ -0,0 +1,68 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type BN from 'bn.js';
import type { TeamState } from './types';
import React, { useEffect, useState } from 'react';
import { InputAddress, Modal } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate';
interface Props {
accountId?: string;
assetId?: BN;
className?: string;
onChange: (info: TeamState | null) => void;
}
function Team ({ accountId, assetId, className = '', onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [adminId, setAdminId] = useState<string | null>(null);
const [freezerId, setFreezerId] = useState<string | null>(null);
const [issuerId, setIssuerId] = useState<string | null>(null);
useEffect((): void => {
onChange(
assetId && accountId && adminId && freezerId && issuerId
? adminId !== accountId || freezerId !== accountId || issuerId !== accountId
? { teamTx: api.tx.assets.setTeam(assetId, issuerId, adminId, freezerId) }
: { teamTx: null }
: null
);
}, [api, assetId, accountId, adminId, freezerId, issuerId, onChange]);
return (
<Modal.Content className={className}>
<Modal.Columns hint={t<string>('The account that is to be used for ongoing admin on the token.')}>
<InputAddress
defaultValue={accountId}
label={t<string>('admin account')}
onChange={setAdminId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The account that is to be used for issuing for this token.')}>
<InputAddress
defaultValue={accountId}
label={t<string>('issuer account')}
onChange={setIssuerId}
type='account'
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The account that is to be used for performing freezing.')}>
<InputAddress
defaultValue={accountId}
label={t<string>('freezer account')}
onChange={setFreezerId}
type='account'
/>
</Modal.Columns>
</Modal.Content>
);
}
export default React.memo(Team);

View File

@ -0,0 +1,130 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SubmittableExtrinsic } from '@polkadot/api/types';
import type { AssetId } from '@polkadot/types/interfaces';
import type { InfoState, MetadataState, TeamState } from './types';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { Button, Modal, TxButton } from '@polkadot/react-components';
import { useAccounts, useApi, useStepper, useToggle } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
import { useTranslation } from '../../translate';
import Info from './Info';
import Metadata from './Metadata';
import Team from './Team';
interface Props {
assetIds?: AssetId[];
className?: string;
}
function Create ({ assetIds, className }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { hasAccounts } = useAccounts();
const [step, nextStep, prevStep] = useStepper();
const [isOpen, toggleOpen] = useToggle();
const [asset, setAsset] = useState<InfoState | null>(null);
const [metadata, setMetadata] = useState<MetadataState | null>(null);
const [team, setTeam] = useState<TeamState | null>(null);
const [extrinsic, setExtrinsic] = useState<SubmittableExtrinsic<'promise'> | null>(null);
useEffect((): void => {
setExtrinsic(
() => asset && metadata && team
? team.teamTx
? api.tx.utility.batchAll([asset.createTx, metadata.metadataTx, team.teamTx])
: api.tx.utility.batchAll([asset.createTx, metadata.metadataTx])
: null
);
}, [api, asset, metadata, team]);
return (
<>
<Button
icon='plus'
isDisabled={!assetIds || !hasAccounts || !isFunction(api.tx.utility.batchAll)}
label={t<string>('Create')}
onClick={toggleOpen}
/>
{isOpen && assetIds && (
<Modal
className={className}
header={t<string>('create asset {{step}}/{{steps}}', { replace: { step, steps: 3 } })}
size='large'
>
{step === 1 && (
<Info
assetIds={assetIds}
onChange={setAsset}
/>
)}
{step === 2 && (
<Metadata
assetId={asset?.assetId}
onChange={setMetadata}
/>
)}
{step === 3 && (
<Team
accountId={asset?.accountId}
assetId={asset?.assetId}
onChange={setTeam}
/>
)}
<Modal.Actions onCancel={toggleOpen}>
{step === 1 &&
<Button
icon='step-forward'
isDisabled={!asset}
label={t<string>('Next')}
onClick={nextStep}
/>
}
{step === 2 && (
<>
<Button
icon='step-backward'
label={t<string>('Prev')}
onClick={prevStep}
/>
<Button
icon='step-forward'
isDisabled={!metadata}
label={t<string>('Next')}
onClick={nextStep}
/>
</>
)}
{step === 3 && (
<>
<Button
icon='step-backward'
label={t<string>('Prev')}
onClick={prevStep}
/>
<TxButton
accountId={asset?.accountId}
extrinsic={extrinsic}
icon='plus'
label={t<string>('Create')}
onStart={toggleOpen}
/>
</>
)}
</Modal.Actions>
</Modal>
)}
</>
);
}
export default React.memo(styled(Create)`
.stepHidden.content {
display: none !important;
}
`);

View File

@ -0,0 +1,19 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type BN from 'bn.js';
import type { SubmittableExtrinsic } from '@polkadot/api/types';
export interface InfoState {
accountId: string;
assetId: BN;
createTx: SubmittableExtrinsic<'promise'>;
}
export interface MetadataState {
metadataTx: SubmittableExtrinsic<'promise'>;
}
export interface TeamState {
teamTx: SubmittableExtrinsic<'promise'> | null;
}

View File

@ -0,0 +1,28 @@
// Copyright 2017-2021 @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';
interface Props {
className?: string;
numAssets?: number;
}
function Summary ({ className, numAssets }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<SummaryBox className={className}>
<CardSummary label={t<string>('assets')}>
{formatNumber(numAssets)}
</CardSummary>
</SummaryBox>
);
}
export default React.memo(Summary);

View File

@ -3,14 +3,27 @@
import React from 'react';
import { Button } from '@polkadot/react-components';
import Assets from './Assets';
import Create from './Create';
import Summary from './Summary';
import useAssetIds from './useAssetIds';
interface Props {
className?: string;
}
function Overview ({ className }: Props): React.ReactElement<Props> {
const assetIds = useAssetIds();
return (
<div className={className}>
TODO
<Summary numAssets={assetIds?.length} />
<Button.Group>
<Create assetIds={assetIds} />
</Button.Group>
<Assets assetIds={assetIds} />
</div>
);
}

View File

@ -0,0 +1,15 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetDetails, AssetId, AssetMetadata } from '@polkadot/types/interfaces';
export interface AssetInfo {
details: AssetDetails | null;
id: AssetId;
isAdmin: boolean;
isOwner: boolean;
isIssuer: boolean;
isFreezer: boolean;
key: string;
metadata: AssetMetadata | null;
}

View File

@ -0,0 +1,28 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AssetId } from '@polkadot/types/interfaces';
import { useEffect, useState } from 'react';
import { useApi, useEventTrigger } from '@polkadot/react-hooks';
export default function useAssetIds (): AssetId[] | undefined {
const { api } = useApi();
const trigger = useEventTrigger([api.events.assets.Created, api.events.assets.Destroyed]);
const [state, setState] = useState<AssetId[] | undefined>();
useEffect((): void => {
trigger &&
api.query.assets.asset
.keys()
.then((keys) => setState(
keys
.map(({ args: [assetId] }) => assetId)
.sort((a, b) => a.cmp(b))
))
.catch(console.error);
}, [api, trigger]);
return state;
}

View File

@ -0,0 +1,47 @@
// Copyright 2017-2021 @polkadot/app-assets authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { AccountId, AssetDetails, AssetId, AssetMetadata } from '@polkadot/types/interfaces';
import type { AssetInfo } from './types';
import { useMemo } from 'react';
import { useAccounts, useApi, useCall } from '@polkadot/react-hooks';
function isAccount (allAccounts: string[], accountId: AccountId): boolean {
const address = accountId.toString();
return allAccounts.some((a) => a === address);
}
function extractInfo (allAccounts: string[], id: AssetId, optDetails: Option<AssetDetails>, metadata: AssetMetadata): AssetInfo {
const details = optDetails.unwrapOr(null);
return {
details,
id,
isAdmin: !!details && isAccount(allAccounts, details.admin),
isFreezer: !!details && isAccount(allAccounts, details.freezer),
isIssuer: !!details && isAccount(allAccounts, details.issuer),
isOwner: !!details && isAccount(allAccounts, details.owner),
key: id.toString(),
metadata: metadata.isEmpty
? null
: metadata
};
}
export default function useAssetInfos (ids?: AssetId[]): AssetInfo[] | undefined {
const { api } = useApi();
const { allAccounts } = useAccounts();
const allMetadata = useCall<AssetMetadata[]>(api.query.assets.metadata.multi, [ids]);
const allDetails = useCall<Option<AssetDetails>[]>(api.query.assets.asset.multi, [ids]);
return useMemo(
() => allDetails && allMetadata && ids && (allDetails.length === ids.length) && (allMetadata.length === ids.length)
? ids.map((id, index) => extractInfo(allAccounts, id, allDetails[index], allMetadata[index]))
: undefined,
[allAccounts, allDetails, allMetadata, ids]
);
}

View File

@ -166,6 +166,10 @@ export default React.memo(styled(Body)`
}
}
&.upper {
text-transform: uppercase;
}
&.favorite .ui--Icon.isSelected {
color: darkorange;
}