Adds delegations overview and actions (#3122)

* add tab and query

* display accounts amount and conviction

* delegation modal without react-component

* add conviction dropdown

* number not string

* use string rather than accountId and KeyringAddress

* all changing

* use ConvictionDropdown from react-components

* delegate column in accounts

* with expander

* with modals

* ordering

* Apply suggestions from code review

Co-authored-by: Jaco Greeff <jacogr@gmail.com>

* ordering

* implement suggestion and fix lint

* Small layout adjustments with help text

* Only delegate to owned accounts

* Use AddressMini for delegation layout

* Remove ununsed imports

Co-authored-by: Jaco Greeff <jacogr@gmail.com>
This commit is contained in:
Thibaut Sardan 2020-07-09 10:30:38 +02:00 committed by GitHub
parent e1510d4209
commit e76ac8395b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 418 additions and 80 deletions

View File

@ -7,6 +7,7 @@ import { DeriveBalancesAll, DeriveDemocracyLock } from '@polkadot/api-derive/typ
import { ActionStatus } from '@polkadot/react-components/Status/types';
import { RecoveryConfig } from '@polkadot/types/interfaces';
import { KeyringAddress } from '@polkadot/ui-keyring/types';
import { Delegation } from '../types';
import BN from 'bn.js';
import React, { useCallback, useContext, useState, useEffect } from 'react';
@ -15,6 +16,7 @@ import { ApiPromise } from '@polkadot/api';
import { getLedger } from '@polkadot/react-api';
import { AddressInfo, AddressMini, AddressSmall, Badge, Button, ChainLock, CryptoType, Forget, Icon, IdentityIcon, LinkExternal, Menu, Popup, StatusContext, Tags } from '@polkadot/react-components';
import { useAccountInfo, useApi, useCall, useToggle } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { Option } from '@polkadot/types';
import keyring from '@polkadot/ui-keyring';
import { BN_ZERO, formatBalance, formatNumber } from '@polkadot/util';
@ -23,6 +25,7 @@ import { useTranslation } from '../translate';
import { createMenuGroup } from '../util';
import Backup from './modals/Backup';
import ChangePass from './modals/ChangePass';
import DelegateModal from './modals/Delegate';
import Derive from './modals/Derive';
import IdentityMain from './modals/IdentityMain';
import IdentitySub from './modals/IdentitySub';
@ -30,12 +33,14 @@ import MultisigApprove from './modals/MultisigApprove';
import RecoverAccount from './modals/RecoverAccount';
import RecoverSetup from './modals/RecoverSetup';
import Transfer from './modals/Transfer';
import UndelegateModal from './modals/Undelegate';
import useMultisigApprovals from './useMultisigApprovals';
import useProxies from './useProxies';
interface Props {
account: KeyringAddress;
className?: string;
delegation?: Delegation;
filter: string;
isFavorite: boolean;
setBalance: (address: string, value: BN) => void;
@ -67,7 +72,7 @@ function createClearDemocracyTx (api: ApiPromise, address: string, unlockableIds
);
}
function Account ({ account: { address, meta }, className = '', filter, isFavorite, setBalance, toggleFavorite }: Props): React.ReactElement<Props> | null {
function Account ({ account: { address, meta }, className = '', delegation, filter, isFavorite, setBalance, toggleFavorite }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { queueExtrinsic } = useContext(StatusContext);
const api = useApi();
@ -94,6 +99,8 @@ function Account ({ account: { address, meta }, className = '', filter, isFavori
const [isRecoverSetupOpen, toggleRecoverSetup] = useToggle();
const [isSettingsOpen, toggleSettings] = useToggle();
const [isTransferOpen, toggleTransfer] = useToggle();
const [isDelegateOpen, toggleDelegate] = useToggle();
const [isUndelegateOpen, toggleUndelegate] = useToggle();
useEffect((): void => {
if (balancesAll) {
@ -340,6 +347,36 @@ function Account ({ account: { address, meta }, className = '', filter, isFavori
<AddressMini value={meta.parentAddress} />
)}
</td>
<td className='address ui--media-1500'>
{isDelegateOpen && (
<DelegateModal
amount={delegation?.amount}
conviction={delegation?.conviction}
delegatedAccount={delegation?.accountDelegated}
delegatingAccount={address}
key='modal-delegate'
onClose={toggleDelegate}
/>
)}
{isUndelegateOpen && (
<UndelegateModal
accountDelegating={address}
key='modal-delegate'
onClose={toggleUndelegate}
/>
)}
{delegation && (
<AddressMini
summary={
<div>
<FormatBalance value={delegation.amount} />
<div>{delegation.conviction.toString()}</div>
</div>
}
value={delegation.accountDelegated}
/>
)}
</td>
<td className='number'>
<CryptoType accountId={address} />
</td>
@ -484,6 +521,28 @@ function Account ({ account: { address, meta }, className = '', filter, isFavori
{t('Multisig approvals')}
</Menu.Item>
])}
{delegation?.accountDelegated && createMenuGroup([
(<Menu.Item
key='changeDelegate'
onClick={toggleDelegate}
>
{t('Change democracy delegation')}
</Menu.Item>),
(<Menu.Item
key='undelegate'
onClick={toggleUndelegate}
>
{t('Undelegate')}
</Menu.Item>)
])}
{!delegation?.accountDelegated && createMenuGroup([
(<Menu.Item
key='delegate'
onClick={toggleDelegate}
>
{t('Delegate democracy votes')}
</Menu.Item>)
])}
<ChainLock
className='accounts--network-toggle'
genesisHash={genesisHash}

View File

@ -3,15 +3,15 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import { ActionStatus } from '@polkadot/react-components/Status/types';
import { KeyringAddress } from '@polkadot/ui-keyring/types';
import { SortedAccount } from './types';
import { Voting } from '@polkadot/types/interfaces';
import { Delegation, SortedAccount } from '../types';
import BN from 'bn.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import keyring from '@polkadot/ui-keyring';
import { getLedger, isLedger } from '@polkadot/react-api';
import { useApi, useAccounts, useFavorites, useIpfs, useToggle } from '@polkadot/react-hooks';
import { useApi, useAccounts, useCall, useFavorites, useIpfs, useToggle } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { Button, Input, Table } from '@polkadot/react-components';
import { BN_ZERO } from '@polkadot/util';
@ -25,12 +25,18 @@ import Qr from './modals/Qr';
import Account from './Account';
import BannerClaims from './BannerClaims';
import BannerExtension from './BannerExtension';
import { sortAccounts } from '../util';
interface Balances {
accounts: Record<string, BN>;
balanceTotal?: BN;
}
interface Sorted {
sortedAccounts: SortedAccount[];
sortedAddresses: string[];
}
interface Props {
className?: string;
onStatusChange: (status: ActionStatus) => void;
@ -51,53 +57,6 @@ async function queryLedger (): Promise<void> {
}
}
function expandList (mapped: SortedAccount[], entry: SortedAccount): SortedAccount[] {
mapped.push(entry);
entry.children.forEach((entry): void => {
expandList(mapped, entry);
});
return mapped;
}
function sortAccounts (addresses: string[], favorites: string[]): SortedAccount[] {
const mapped = addresses
.map((address) => keyring.getAccount(address))
.filter((account): account is KeyringAddress => !!account)
.map((account): SortedAccount => ({
account,
children: [],
isFavorite: favorites.includes(account.address)
}))
.sort((a, b) => (a.account.meta.whenCreated || 0) - (b.account.meta.whenCreated || 0));
return mapped
.filter((entry): boolean => {
const parentAddress = entry.account.meta.parentAddress;
if (parentAddress) {
const parent = mapped.find(({ account: { address } }) => address === parentAddress);
if (parent) {
parent.children.push(entry);
return false;
}
}
return true;
})
.reduce(expandList, [])
.sort((a, b): number =>
a.isFavorite === b.isFavorite
? 0
: b.isFavorite
? 1
: -1
);
}
function Overview ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
@ -110,15 +69,45 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
const [isQrOpen, toggleQr] = useToggle();
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
const [{ balanceTotal }, setBalances] = useState<Balances>({ accounts: {} });
const [sortedAccounts, setSortedAccounts] = useState<SortedAccount[]>([]);
const [filterOn, setFilter] = useState<string>('');
const [sortedAccountsWithDelegation, setSortedAccountsWithDelegation] = useState<SortedAccount[]>([]);
const [{ sortedAccounts, sortedAddresses }, setSorted] = useState<Sorted>({ sortedAccounts: [], sortedAddresses: [] });
const delegations = useCall<Voting[]>(api.query.democracy.votingOf.multi, [sortedAddresses]);
useEffect((): void => {
setSortedAccounts(
sortAccounts(allAccounts, favorites)
);
const sortedAccounts = sortAccounts(allAccounts, favorites);
const sortedAddresses = sortedAccounts.map((a) => a.account.address);
setSorted({ sortedAccounts, sortedAddresses });
}, [allAccounts, favorites]);
useEffect(() => {
if (!delegations?.length) {
return;
}
const sortedAccountsWithDelegation = sortedAccounts?.map((account, index) => {
let delegation: Delegation | undefined;
if (delegations[index].isDelegating) {
const _delegation = delegations[index].asDelegating;
delegation = {
accountDelegated: _delegation.target.toString(),
amount: _delegation.balance,
conviction: _delegation.conviction
};
}
return ({
...account,
delegation
});
});
setSortedAccountsWithDelegation(sortedAccountsWithDelegation);
}, [delegations, sortedAccounts]);
const _setBalance = useCallback(
(account: string, balance: BN) =>
setBalances(({ accounts }: Balances): Balances => {
@ -135,6 +124,7 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
const header = useMemo(() => [
[t('accounts'), 'start', 3],
[t('parent'), 'address'],
[t('delegation'), 'address ui--media-1500'],
[t('type')],
[t('tags'), 'start'],
[t('transactions'), 'ui--media-1500'],
@ -245,9 +235,10 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
footer={footer}
header={header}
>
{sortedAccounts.map(({ account, isFavorite }): React.ReactNode => (
{sortedAccountsWithDelegation.map(({ account, delegation, isFavorite }): React.ReactNode => (
<Account
account={account}
delegation={delegation}
filter={filterOn}
isFavorite={isFavorite}
key={account.address}

View File

@ -0,0 +1,110 @@
// Copyright 2017-2020 @polkadot/app-staking authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { AmountValidateState } from '../types';
import BN from 'bn.js';
import React, { useState } from 'react';
import { ConvictionDropdown, InputAddress, InputBalance, Modal, TxButton } from '@polkadot/react-components';
import { BalanceFree } from '@polkadot/react-query';
import { Conviction } from '@polkadot/types/interfaces';
import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate';
import ValidateAmount from './InputValidateAmount';
interface Props {
amount?: BN;
conviction?: Conviction;
delegatedAccount?: string;
delegatingAccount?: string;
onClose: () => void;
}
function Delegate ({ amount: _amount, conviction: _conviction, delegatedAccount: _delegatedAccount, delegatingAccount: _delegatingAccount, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [amountError, setAmountError] = useState<AmountValidateState | null>(null);
const [maxBalance] = useState<BN | undefined>();
const [amount, setAmount] = useState<BN | undefined>(_amount);
const [delegatingAccount, setDelegatingAccount] = useState<string | null>(_delegatingAccount || null);
const [delegatedAccount, setDelegatedAccount] = useState<string | null>(_delegatedAccount || null);
const [conviction, setConviction] = useState(_conviction?.toNumber() || 1);
return (
<Modal
className='staking--Delegate'
header= {t<string>('Delegate democracy vote')}
size='large'
>
<Modal.Content className='ui--signer-Signer-Content'>
<Modal.Columns>
<Modal.Column>
<InputAddress
label={t<string>('delegating account')}
onChange={setDelegatingAccount}
type='account'
value={delegatingAccount}
/>
<InputAddress
label={t<string>('delegated account')}
onChange={setDelegatedAccount}
type='account'
value={delegatedAccount}
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('Any democracy vote performed by the delegated account will result in an additional vote from the delegating account')}</p>
<p>{t<string>('If the delegated account is currently voting in a referendum, the delegating vote and conviction will be added.')}</p>
</Modal.Column>
</Modal.Columns>
<Modal.Columns>
<Modal.Column>
<InputBalance
help={t<string>('Amount to delegate for any democracy vote. This is adjusted using the available funds on the account.')}
isError={!!amountError?.error}
label={t<string>('delegating amount')}
labelExtra={
<BalanceFree
label={<span className='label'>{t<string>('balance')}</span>}
params={delegatingAccount}
/>
}
maxValue={maxBalance}
onChange={setAmount}
value={amount}
/>
<ValidateAmount
amount={amount}
delegatingAccount={delegatingAccount}
onError={setAmountError}
/>
<ConvictionDropdown
help={t<string>('The conviction that will be used for each delegated vote.')}
label={t<string>('conviction')}
onChange={setConviction}
value={conviction}
/>
</Modal.Column>
<Modal.Column>
<p>{t('The amount to allocate and the conviction that will be applied to all votes made on a referendum.')}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={onClose}>
<TxButton
accountId={delegatingAccount}
icon='sign-in-alt'
isDisabled={!amount?.gt(BN_ZERO) || !!amountError?.error}
isPrimary
label={t<string>('Delegate')}
onStart={onClose}
params={[delegatedAccount, conviction, amount]}
tx='democracy.delegate'
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Delegate);

View File

@ -0,0 +1,58 @@
// Copyright 2017-2020 @polkadot/app-staking authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { DeriveBalancesAll } from '@polkadot/api-derive/types';
import { AmountValidateState } from '../types';
import BN from 'bn.js';
import React, { useEffect, useState } from 'react';
import { Icon } from '@polkadot/react-components';
import { useApi, useCall } from '@polkadot/react-hooks';
import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../../translate';
interface Props {
amount?: BN | null;
delegatingAccount: string | null;
onError: (state: AmountValidateState | null) => void;
}
function ValidateAmount ({ amount, delegatingAccount, onError }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const delegatingAccountBalance = useCall<DeriveBalancesAll>(api.derive.balances.all, [delegatingAccount]);
const [{ error, warning }, setResult] = useState<AmountValidateState>({ error: null, warning: null });
useEffect((): void => {
if (delegatingAccountBalance?.freeBalance && amount?.gt(BN_ZERO)) {
let newError: string | null = null;
if (amount.gte(delegatingAccountBalance.freeBalance)) {
newError = t('The maximum amount you can delegate is the amount of funds available on the delegating account.');
}
setResult((state): AmountValidateState => {
const error = state.error !== newError ? newError : state.error;
const warning = state.warning;
onError((error || warning) ? { error, warning } : null);
return { error, warning };
});
}
}, [api, onError, amount, t, delegatingAccountBalance]);
if (error || warning) {
return (
<article className={error ? 'error' : 'warning'}>
<div><Icon icon='exclamation-triangle' />{error || warning}</div>
</article>
);
}
return null;
}
export default React.memo(ValidateAmount);

View File

@ -0,0 +1,53 @@
// Copyright 2017-2020 @polkadot/app-staking authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React from 'react';
import { InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useTranslation } from '../../translate';
interface Props {
accountDelegating: string | null;
onClose: () => void;
}
function Undelegate ({ accountDelegating, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<Modal
className='staking--Undelegate'
header= {t<string>('Undelegate')}
size='large'
>
<Modal.Content className='ui--signer-Signer-Content'>
<Modal.Columns>
<Modal.Column>
<InputAddress
defaultValue={accountDelegating}
isDisabled
label={t<string>('delegating account')}
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('You will remove any delegation made by this acccount')}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={onClose}>
<TxButton
accountId={accountDelegating}
icon='sign-in-alt'
isPrimary
label={t<string>('Undelegate')}
onStart={onClose}
params={[]}
tx='democracy.undelegate'
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Undelegate);

View File

@ -2,10 +2,31 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { ActionStatus } from '@polkadot/react-components/Status/types';
import { KeyringAddress } from '@polkadot/ui-keyring/types';
import { WithTranslation } from 'react-i18next';
export { AppProps as ComponentProps } from '@polkadot/react-components/types';
export interface BareProps {
className?: string;
}
export interface I18nProps extends BareProps, WithTranslation {}
export interface ModalProps {
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
export interface SortedAccount {
account: KeyringAddress;
children: SortedAccount[];
isFavorite: boolean;
}
export interface AmountValidateState {
error: string | null;
warning: string | null;
}

View File

@ -3,6 +3,8 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import { ActionStatus } from '@polkadot/react-components/Status/types';
import { Balance, Conviction } from '@polkadot/types/interfaces';
import { KeyringAddress } from '@polkadot/ui-keyring/types';
import { WithTranslation } from 'react-i18next';
@ -18,3 +20,16 @@ export interface ModalProps {
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
export interface Delegation {
accountDelegated: string
amount: Balance
conviction: Conviction
}
export interface SortedAccount {
account: KeyringAddress;
children: SortedAccount[];
delegation?: Delegation;
isFavorite: boolean;
}

View File

@ -2,8 +2,12 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { SortedAccount } from './types';
import React from 'react';
import { Menu } from '@polkadot/react-components';
import keyring from '@polkadot/ui-keyring';
import { KeyringAddress } from '@polkadot/ui-keyring/types';
export function createMenuGroup (items: (React.ReactNode | false | undefined | null)[]): React.ReactNode | null {
const filtered = items.filter((item): item is React.ReactNode => !!item);
@ -12,3 +16,50 @@ export function createMenuGroup (items: (React.ReactNode | false | undefined | n
? <>{filtered}<Menu.Divider /></>
: null;
}
function expandList (mapped: SortedAccount[], entry: SortedAccount): SortedAccount[] {
mapped.push(entry);
entry.children.forEach((entry): void => {
expandList(mapped, entry);
});
return mapped;
}
export function sortAccounts (addresses: string[], favorites: string[]): SortedAccount[] {
const mapped = addresses
.map((address) => keyring.getAccount(address))
.filter((account): account is KeyringAddress => !!account)
.map((account): SortedAccount => ({
account,
children: [],
isFavorite: favorites.includes(account.address)
}))
.sort((a, b) => (a.account.meta.whenCreated || 0) - (b.account.meta.whenCreated || 0));
return mapped
.filter((entry): boolean => {
const parentAddress = entry.account.meta.parentAddress;
if (parentAddress) {
const parent = mapped.find(({ account: { address } }) => address === parentAddress);
if (parent) {
parent.children.push(entry);
return false;
}
}
return true;
})
.reduce(expandList, [])
.sort((a, b): number =>
a.isFavorite === b.isFavorite
? 0
: b.isFavorite
? 1
: -1
);
}

View File

@ -6,7 +6,7 @@ import { PropIndex, Proposal } from '@polkadot/types/interfaces';
import BN from 'bn.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Dropdown, Modal, ProposedAction, VoteAccount, VoteActions, VoteToggle, VoteValue } from '@polkadot/react-components';
import { Button, ConvictionDropdown, Modal, ProposedAction, VoteAccount, VoteActions, VoteToggle, VoteValue } from '@polkadot/react-components';
import { useAccounts, useApi, useToggle } from '@polkadot/react-hooks';
import { isBoolean } from '@polkadot/util';
@ -17,8 +17,6 @@ interface Props {
referendumId: PropIndex;
}
const CONVICTIONS: [number, number][] = [1, 2, 4, 8, 16, 32].map((lock, index) => [index + 1, lock]);
function Voting ({ proposal, referendumId }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
@ -38,24 +36,6 @@ function Voting ({ proposal, referendumId }: Props): React.ReactElement<Props> |
[api]
);
const [enact] = useState(
(api.consts.democracy.enactmentPeriod.toNumber() * api.consts.timestamp.minimumPeriod.toNumber() / 1000 * 2) / 60 / 60 / 24
);
const convictionOpts = useMemo(() => [
{ text: t<string>('0.1x voting balance, no lockup period'), value: 0 },
...CONVICTIONS.map(([value, lock]): { text: string; value: number } => ({
text: t<string>('{{value}}x voting balance, locked for {{lock}}x enactment ({{period}} days)', {
replace: {
lock,
period: (enact * lock).toFixed(2),
value
}
}),
value
}))
], [t, enact]);
const _onChangeVote = useCallback(
(vote?: boolean): void => setVoteValue(isBoolean(vote) ? vote : true),
[]
@ -101,11 +81,10 @@ function Voting ({ proposal, referendumId }: Props): React.ReactElement<Props> |
onChange={setBalance}
/>
)}
<Dropdown
<ConvictionDropdown
help={t<string>('The conviction to use for this vote, with an appropriate lock period.')}
label={t<string>('conviction')}
onChange={setConviction}
options={convictionOpts}
value={conviction}
/>
</Modal.Column>

View File

@ -213,8 +213,9 @@ export default React.memo(styled(AddressMini)`
.ui--AddressMini-summary {
font-size: 0.75rem;
line-height: 1.2;
margin-left: 2.25rem;
margin-top: -0.5rem;
margin-top: -0.2rem;
text-align: left;
}
`);