Conviction voting delegation (#8721)

* Conviction voting delegation

* Extract option creation

* TrackDropdown (& component consolidation)

* Adjust option extraction

* Adjust

* "a" modal

* Retrieve voting activity for persons of interest

* Debug logs ... all is ok

* Adjust check ordering

* Really make identity optional

* Extract voting details

* Add delegation targets to hooks

* Use param types

* Apply master font changes

* WIP, start populating

* Some progress in the UI stuff

* Adjust

* The pieces are mostly in-place

* Seems to do the trick...

* Correct pallet for voting

* Use palletVote
This commit is contained in:
Jaco 2023-01-23 16:41:02 +02:00 committed by GitHub
parent cf9e406e31
commit 3a8ea22d4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 931 additions and 94 deletions

View File

@ -0,0 +1,79 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletReferenda, TrackDescription, TrackInfoExt } from '../../types';
import type { VoteResultItem } from './types';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { MarkWarning, Table } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import { useTranslation } from '../../translate';
import { getTrackInfo } from '../../util';
interface Props {
allowEmpty?: boolean;
className?: string;
palletReferenda: PalletReferenda;
trackId: number;
tracks: TrackDescription[];
value?: VoteResultItem[] | null | false;
}
function Activity ({ allowEmpty, className, palletReferenda, tracks, value }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api, specName } = useApi();
const infos = useMemo(
() => value && value.map((v): [VoteResultItem, TrackInfoExt | undefined] =>
[v, getTrackInfo(api, specName, palletReferenda, tracks, v.classId.toNumber())]
),
[api, palletReferenda, specName, tracks, value]
);
if (!infos) {
return null;
}
return (
<StyledDiv className={className}>
{infos.length
? (
<Table isInline>
{infos.map(([{ casting, classId, delegating }, info], index) => (
<tr key={index}>
<td className='all'>
{(info && info.trackName) || classId.toString()}
</td>
<td className='together'>
{
(delegating &&
t<string>('delegating')) ||
(casting &&
`${casting.length} ${casting.length === 1 ? t<string>('vote') : t<string>('votes')}`)
}
</td>
</tr>
))}
</Table>
)
: <MarkWarning content={t<string>('This account has no voting/delating activity in the chain state')} />
}
{!allowEmpty && infos.some(([{ delegating }]) => delegating) && (
<MarkWarning content={t<string>('This account has some delegations in itself')} />
)}
</StyledDiv>
);
}
const StyledDiv = styled.div`
.ui--Table {
font-size: var(--font-percent-small);
opacity: var(--opacity-light);
padding-left: 2rem;
}
`;
export default React.memo(Activity);

View File

@ -0,0 +1,285 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { PalletReferenda, PalletVote, TrackDescription } from '../../types';
import React, { useCallback, useMemo, useState } from 'react';
import { Button, ConvictionDropdown, InputAddress, Modal, Toggle, ToggleGroup, TxButton, VoteValue } from '@polkadot/react-components';
import { useAccounts, useApi, useStepper, useToggle } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
import { useTranslation } from '../../translate';
import TrackDropdown from '../Submit/TrackDropdown';
import Activity from './Activity';
import useActivityAccount from './useActivityAccount';
import useActivityFellows from './useActivityFellows';
import useActivityNominators from './useActivityNominators';
interface Props {
className?: string;
palletReferenda: PalletReferenda;
palletVote: PalletVote;
tracks: TrackDescription[];
}
interface Option {
key: string;
name: string;
value: string;
}
function Delegate ({ className, palletReferenda, palletVote, tracks }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const { hasAccounts } = useAccounts();
const [isOpen, toggleOpen] = useToggle();
const [isAllTracks, toggleAllTracks] = useToggle();
const [step, nextStep, prevStep] = useStepper();
const [accountId, setAccountId] = useState<string | null>(null);
const [toAccount, setToAccount] = useState<string | null>(null);
const [trackId, setTrackId] = useState<number>(0);
const [balance, setBalance] = useState<BN | undefined>();
const [conviction, setConviction] = useState(1);
const activityFell = useActivityFellows(palletVote);
const activityVals = useActivityNominators(palletVote);
const activityFrom = useActivityAccount(palletVote, accountId);
const activityTo = useActivityAccount(palletVote, toAccount);
const [accType, setAccType] = useState({ index: 0, type: 'address' });
const includeTracks = useMemo(
() => activityFrom && tracks
.filter((t) => !activityFrom.some((a) => t.id.eq(a.classId)))
.map(({ id }) => id),
[activityFrom, tracks]
);
const allFell = useMemo(
// We also filter the fellows by activity - since there are a number
// we just want to skip those that does not have any activity
() => activityFell &&
Object
.entries(activityFell)
.map(([key, act]) => (act.length > 0) && ({ key, name: key, value: key }))
.filter((a): a is Option => !!a),
[activityFell]
);
const allVals = useMemo(
() => activityVals &&
Object
.entries(activityVals)
.map(([key]) => ({ key, name: key, value: key }))
.filter((a): a is Option => !!a),
[activityVals]
);
const typeOpts = useMemo(
() => [
{ text: t<string>('Addresses'), value: 'address' },
isFunction(api.query.staking?.nominators) &&
{ isDisabled: !allVals || !allVals.length, text: t<string>('Validators'), value: 'validators' },
isFunction(api.query.fellowshipCollective?.members) &&
{ isDisabled: !allFell || !allFell.length, text: t<string>('Fellows'), value: 'fellows' }
],
[allFell, allVals, api, t]
);
const onChangeType = useCallback(
(index: number, type: string | number) =>
setAccType({ index, type: type.toString() }),
[]
);
const extrinsic = useMemo(
() => balance && conviction && toAccount && includeTracks
? isAllTracks
? api.tx.utility.forceBatch(includeTracks.map((trackId) =>
api.tx[palletVote as 'convictionVoting'].delegate(trackId, toAccount, conviction, balance)
))
: api.tx[palletVote as 'convictionVoting'].delegate(trackId, toAccount, conviction, balance)
: null,
[api, balance, conviction, includeTracks, isAllTracks, palletVote, toAccount, trackId]
);
const isStep1Valid = !!(accountId && activityFrom && includeTracks && (includeTracks.length > 0));
const isStep2Valid = !!(toAccount && activityTo);
return (
<>
{isOpen && (
<Modal
className={className}
header={t<string>('Delegate votes {{step}}/{{numSteps}}', { replace: { numSteps: 2, step } })}
onClose={toggleOpen}
size='large'
>
{(step === 1) && (
<Modal.Content>
<Modal.Columns hint={t<string>('Delegate from this account to another. All votes made on the target would count as a delegated vote for this account.')}>
<InputAddress
label={t<string>('delegate from account')}
onChange={setAccountId}
type='account'
withLabel
/>
<Activity
allowEmpty
palletReferenda={palletReferenda}
trackId={-1}
tracks={tracks}
value={activityFrom}
/>
</Modal.Columns>
<Modal.Columns
align='right'
hint={t<string>('Either delegate your votes for a single track as selected or delegate for all available tracks.')}
>
<Toggle
label={t<string>('apply delegation to all tracks')}
onChange={toggleAllTracks}
value={isAllTracks}
/>
{!isAllTracks && includeTracks && (includeTracks.length > 0) && (
<TrackDropdown
include={includeTracks}
onChange={setTrackId}
palletReferenda={palletReferenda}
tracks={tracks}
/>
)}
</Modal.Columns>
<Modal.Columns
hint={
<>
<p>{t<string>('The balance associated with the vote will be locked as per the conviction specified and will not be available for transfer during this period.')}</p>
<p>{t<string>('Conviction locks do overlap and are not additive, meaning that funds locked during a previous vote can be locked again.')}</p>
</>
}
>
<VoteValue
accountId={accountId}
autoFocus
label={t<string>('delegated vote value')}
onChange={setBalance}
/>
<ConvictionDropdown
label={t<string>('conviction')}
onChange={setConviction}
value={conviction}
voteLockingPeriod={api.consts[palletVote as 'convictionVoting'].voteLockingPeriod}
/>
</Modal.Columns>
</Modal.Content>
)}
{(step === 2) && (
<Modal.Content>
{(typeOpts.length > 1) && (
<Modal.Columns
align='center'
hint={t<string>('Select from a list of pre-propulated accounts (based on your account activity) or supply your own')}
>
<ToggleGroup
onChange={onChangeType}
options={typeOpts}
value={accType.index}
/>
</Modal.Columns>
)}
<Modal.Columns hint={t<string>('The account that you wish to delegate to')}>
{accType.type === 'address'
? (
<InputAddress
key='address'
label={t<string>('delegate to address')}
onChange={setToAccount}
type='allPlus'
/>
)
: accType.type === 'validators'
? (
<InputAddress
defaultValue={allVals?.[0].value}
key='validators'
label={t<string>('delegate to validator')}
onChange={setToAccount}
options={allVals}
type='allPlus'
/>
)
: accType.type === 'fellows'
? (
<InputAddress
defaultValue={allFell?.[0].value}
key='fellows'
label={t<string>('delegate to fellow')}
onChange={setToAccount}
options={allFell}
type='allPlus'
/>
)
: null
}
<Activity
palletReferenda={palletReferenda}
trackId={isAllTracks ? -1 : trackId}
tracks={tracks}
value={
accType.type === 'fellows'
? activityFell && !!toAccount && activityFell[toAccount]
: accType.type === 'validators'
? activityVals && !!toAccount && activityVals[toAccount]
: activityTo
}
/>
</Modal.Columns>
</Modal.Content>
)}
<Modal.Actions>
{(step !== 1) && (
<Button
icon='step-backward'
label={t<string>('Prev')}
onClick={prevStep}
/>
)}
{(step !== 2) && (
<Button
activeOnEnter
icon='step-forward'
isDisabled={
step === 1
? !isStep1Valid
: !isStep2Valid
}
label={t<string>('Next')}
onClick={nextStep}
/>
)}
<TxButton
accountId={accountId}
extrinsic={extrinsic}
icon='code-merge'
isDisabled={
!isStep1Valid ||
!isStep2Valid ||
step !== 2
}
label={t<string>('Delegate')}
onStart={toggleOpen}
/>
</Modal.Actions>
</Modal>
)}
<Button
icon='code-merge'
isDisabled={!hasAccounts}
label={t<string>('Delegate')}
onClick={toggleOpen}
/>
</>
);
}
export default React.memo(Delegate);

View File

@ -0,0 +1,27 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletConvictionVotingVoteVoting } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
export interface LockResultItem {
classId: BN;
}
export type LockResult = Record<string, LockResultItem[]>;
export interface VoteResultCasting {
refId: BN;
}
export interface VoteResultDelegating {
conviction: PalletConvictionVotingVoteVoting['asDelegating']['conviction']['type'];
targetId: string;
}
export interface VoteResultItem extends LockResultItem {
casting?: VoteResultCasting[];
delegating?: VoteResultDelegating;
}
export type VoteResult = Record<string, VoteResultItem[]>;

View File

@ -0,0 +1,18 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types';
import type { VoteResult } from './types';
import { createNamedHook } from '@polkadot/react-hooks';
import useSuperIds from './useSuperIds';
import useVotingFor from './useVotingFor';
function useActivityImpl (palletVote: PalletVote, accountIds?: string[] | null): VoteResult | null | undefined {
const identities = useSuperIds(accountIds);
return useVotingFor(palletVote, identities);
}
export default createNamedHook('useActivity', useActivityImpl);

View File

@ -0,0 +1,27 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types';
import type { VoteResultItem } from './types';
import { useMemo } from 'react';
import { createNamedHook } from '@polkadot/react-hooks';
import useVotingFor from './useVotingFor';
function useActivityAccountImpl (palletVote: PalletVote, accountId?: string | null): VoteResultItem[] | null | undefined {
const params = useMemo(
() => (accountId && [accountId]) || null,
[accountId]
);
const votingFor = useVotingFor(palletVote, params);
// for a single account (which we assume is user-specified), we
// do not lookup the actual parent identity, use as-is
return votingFor && accountId
? votingFor[accountId] || []
: null;
}
export default createNamedHook('useActivityAccount', useActivityAccountImpl);

View File

@ -0,0 +1,18 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types';
import type { VoteResult } from './types';
import { createNamedHook } from '@polkadot/react-hooks';
import useActivity from './useActivity';
import useFellows from './useFellows';
function useActivityFellowsImpl (palletVote: PalletVote): VoteResult | null | undefined {
const fellows = useFellows();
return useActivity(palletVote, fellows);
}
export default createNamedHook('useActivityFellows', useActivityFellowsImpl);

View File

@ -0,0 +1,18 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletVote } from '../../types';
import type { VoteResult } from './types';
import { createNamedHook } from '@polkadot/react-hooks';
import useActivity from './useActivity';
import useNominators from './useNominators';
function useActivityNominatorsImpl (palletVote: PalletVote): VoteResult | null | undefined {
const nominators = useNominators();
return useActivity(palletVote, nominators);
}
export default createNamedHook('useActivityNominators', useActivityNominatorsImpl);

View File

@ -0,0 +1,29 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StorageKey } from '@polkadot/types';
import type { AccountId } from '@polkadot/types/interfaces';
import { useMemo } from 'react';
import { createNamedHook, useApi, useMapKeys } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
const MEMBERS_OPT = {
transform: (keys: StorageKey<[AccountId]>[]): string[] =>
keys.map(({ args: [id] }) => id.toString())
};
function useFellowsImpl (): string[] | null | undefined {
const { api } = useApi();
const members = useMapKeys(api.query.fellowshipCollective?.members, [], MEMBERS_OPT);
return useMemo(
() => isFunction(api.query.fellowshipCollective?.members)
? members
: [],
[api, members]
);
}
export default createNamedHook('useFellows', useFellowsImpl);

View File

@ -0,0 +1,46 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { PalletStakingNominations } from '@polkadot/types/lookup';
import { useMemo } from 'react';
import { createNamedHook, useAccounts, useApi, useCall } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
const NOMINATORS_OPT = {
transform: (optNominators: Option<PalletStakingNominations>[]): string[] =>
optNominators.reduce<string[]>((all, o) =>
o.isSome
? all.concat(
o.unwrap().targets
.map((w) => w.toString())
.filter((w) => !all.includes(w))
)
: all, []
)
};
// A list of all validators that any of our accounts nominate
// (deduped accross accounts)
function useNominatorsImpl (): string[] | null | undefined {
const { api } = useApi();
const { allAccounts } = useAccounts();
const nomineesParam = useMemo(
() => [allAccounts],
[allAccounts]
);
const nominees = useCall(!!allAccounts.length && nomineesParam && api.query.staking?.nominators?.multi, nomineesParam, NOMINATORS_OPT);
return useMemo(
() => isFunction(api.query.staking?.nominators) && allAccounts.length
? nominees
: [],
[allAccounts, api, nominees]
);
}
export default createNamedHook('useNominators', useNominatorsImpl);

View File

@ -0,0 +1,57 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { AccountId } from '@polkadot/types/interfaces';
import type { ITuple } from '@polkadot/types/types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
const SUPEROF_OPT = {
transform: ([[ids], optSupers]: [[string[]], Option<ITuple<[AccountId]>>[]]): string[] =>
optSupers
.map((opt, index) =>
// if we have a super, use that, otherwise we default to
// the actual passed-in identity (which is top-level)
opt.isSome
? opt.unwrap()[0].toString()
: ids[index]
)
.reduce((all: string[], who): string[] => {
// deupe all entries since we may have multiple nominees
if (!all.includes(who)) {
all.push(who);
}
return all;
}, []),
withParamsTransform: true
};
function useSuperIdsImpl (accountIds?: string[] | null): string[] | null | undefined {
const { api } = useApi();
// for the supplied accounts, retrieve the de-dupes parent identity
const identityParam = useMemo(
() => accountIds && [accountIds],
[accountIds]
);
const identities = useCall(identityParam && !!identityParam[0].length && api.query.identity?.superOf?.multi, identityParam, SUPEROF_OPT);
return useMemo(
() => identityParam
? identityParam[0].length
? isFunction(api.query.identity?.superOf)
? identities
: accountIds
: []
: null,
[api, accountIds, identities, identityParam]
);
}
export default createNamedHook('useSuperIds', useSuperIdsImpl);

View File

@ -0,0 +1,91 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PalletConvictionVotingVoteVoting } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import type { PalletVote } from '../../types';
import type { LockResult, VoteResult, VoteResultItem } from './types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
import useVotingLocks from './useVotingLocks';
type ForParam = [accountId: string, classId: BN];
const FOR_OPT = {
transform: ([[ids], votes]: [[ForParam[]], PalletConvictionVotingVoteVoting[]]): VoteResult =>
ids.sort((a, b) => a[1].cmp(b[1])).reduce<VoteResult>((all, [accountId, classId], index) => {
if (!all[accountId]) {
all[accountId] = [];
}
let casting: VoteResultItem['casting'] | undefined;
let delegating: VoteResultItem['delegating'] | undefined;
if (votes[index].isCasting) {
casting = votes[index].asCasting.votes.map(([refId]) => ({ refId }));
} else if (votes[index].isDelegating) {
const { conviction, target } = votes[index].asDelegating;
delegating = { conviction: conviction.type, targetId: target.toString() };
} else {
// failsafe log... just in-case
console.error(`Unable to handle PalletConvictionVotingVoteVoting type ${votes[index].type}`);
}
all[accountId].push({ casting, classId, delegating });
return all;
}, {}),
withParamsTransform: true
};
function getParams (locks?: LockResult | null): ForParam[] | null | undefined {
return locks && Object.entries(locks).reduce<ForParam[]>((all, [accountId, items]) => {
return items.reduce<ForParam[]>((all, { classId }) => {
all.push(([accountId, classId]));
return all;
}, all);
}, []);
}
function combineResult (locks: LockResult, votes: VoteResult): VoteResult {
return Object.keys(locks).reduce<VoteResult>((all, accountId) => {
if (!all[accountId]) {
// when it appears in the original keys, but not here
// we just add an empty value to keep track of this one
all[accountId] = [];
}
return all;
}, votes);
}
function useVotingForImpl (palletVote: PalletVote, accountIds?: string[] | null): VoteResult | null | undefined {
const { api } = useApi();
const locks = useVotingLocks(palletVote, accountIds);
const forParam = useMemo(
() => [getParams(locks)],
[locks]
);
const votes = useCall(forParam && forParam[0] && api.query[palletVote]?.votingFor?.multi, forParam, FOR_OPT);
return useMemo(
() => locks && forParam
? forParam[0]
? isFunction(api.query[palletVote]?.votingFor)
? votes && combineResult(locks, votes)
: locks
: {}
: null,
[api, locks, forParam, palletVote, votes]
);
}
export default createNamedHook('useVotingFor', useVotingForImpl);

View File

@ -0,0 +1,51 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { INumber } from '@polkadot/types/types';
import type { PalletVote } from '../../types';
import type { LockResult } from './types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
import { isFunction } from '@polkadot/util';
const LOCKS_OPT = {
transform: ([[ids], locks]: [[string[]], [INumber, INumber][][]]): LockResult =>
ids.reduce<LockResult>((all, accountId, index) => ({
...all,
[accountId]: locks[index].map(([classId, value]) => ({
balance: value.toBn(),
classId: classId.toBn()
}))
}), {}),
withParamsTransform: true
};
function useVotingLocksImpl (palletVote: PalletVote, accountIds?: string[] | null): LockResult | null | undefined {
const { api } = useApi();
const locksParam = useMemo(
() => accountIds
? accountIds.length
? [accountIds]
: []
: undefined,
[accountIds]
);
const locks = useCall(locksParam && locksParam[0] && api.query[palletVote]?.classLocksFor?.multi, locksParam, LOCKS_OPT);
return useMemo(
() => locksParam
? locksParam[0]
? isFunction(api.query[palletVote]?.classLocksFor)
? locks
: locksParam[0].reduce<LockResult>((all, accountId) => ({ ...all, [accountId]: [] }), {})
: {}
: null,
[api, locks, locksParam, palletVote]
);
}
export default createNamedHook('useVotingLocks', useVotingLocksImpl);

View File

@ -0,0 +1,48 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { PalletReferenda, TrackDescription } from '../../types';
import React from 'react';
import styled from 'styled-components';
import { Dropdown } from '@polkadot/react-components';
import { useTranslation } from '../../translate';
import useTrackOptions from './useTrackOptions';
interface Props {
className?: string;
exclude?: (BN | number)[];
include?: (BN | number)[];
onChange: (trackId: number) => void;
palletReferenda: PalletReferenda;
tracks: TrackDescription[];
}
function TrackDropdown ({ className, exclude, include, onChange, palletReferenda, tracks }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const trackOpts = useTrackOptions(palletReferenda, tracks, include, exclude);
return (
<Dropdown
className={className}
defaultValue={trackOpts[0].value}
label={t<string>('submission track')}
onChange={onChange}
options={trackOpts}
/>
);
}
export default React.memo(styled(TrackDropdown)`
.trackOption {
.faded {
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
margin-top: 0.125rem;
opacity: 0.6;
}
}
`);

View File

@ -1,11 +1,10 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { RawParam } from '@polkadot/react-params/types';
import type { BN } from '@polkadot/util';
import type { HexString } from '@polkadot/util/types';
import type { PalletReferenda, TrackDescription } from '../types';
import type { PalletReferenda, TrackDescription } from '../../types';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
@ -17,8 +16,9 @@ import { Available } from '@polkadot/react-query';
import { getTypeDef } from '@polkadot/types/create';
import { BN_HUNDRED, BN_ONE, BN_THOUSAND, BN_ZERO, isHex } from '@polkadot/util';
import { useTranslation } from '../translate';
import { getTrackInfo, getTrackName } from '../util';
import { useTranslation } from '../../translate';
import { getTrackInfo } from '../../util';
import TrackDropdown from './TrackDropdown';
interface Props {
className?: string;
@ -39,35 +39,11 @@ interface ImageState {
isImageLenValid: boolean;
}
interface TrackOpt {
text: React.ReactNode;
value: number;
}
function getTrackOptions (api: ApiPromise, specName: string, palletReferenda: string, tracks?: TrackDescription[]): undefined | TrackOpt[] {
return tracks && tracks.map(({ id, info }): TrackOpt => {
const trackInfo = getTrackInfo(api, specName, palletReferenda, tracks, id.toNumber());
const trackName = getTrackName(id, info);
return {
text: trackInfo?.text
? (
<div className='trackOption'>
<div className='normal'>{trackName}</div>
<div className='faded'>{trackInfo.text}</div>
</div>
)
: trackName,
value: id.toNumber()
};
});
}
function Submit ({ className = '', isMember, members, palletReferenda, tracks }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api, specName } = useApi();
const bestNumber = useBestNumber();
const [isSubmitOpen, toggleSubmit] = useToggle();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [trackId, setTrack] = useState<number | undefined>(undefined);
const [origin, setOrigin] = useState<RawParam['value'] | null>(null);
@ -132,11 +108,6 @@ function Submit ({ className = '', isMember, members, palletReferenda, tracks }:
[origin, trackInfo]
);
const trackOpts = useMemo(
() => getTrackOptions(api, specName, palletReferenda, tracks),
[api, palletReferenda, specName, tracks]
);
const enactOpts = useMemo(
() => [
{ text: t<string>('After delay'), value: 'after' },
@ -182,11 +153,11 @@ function Submit ({ className = '', isMember, members, palletReferenda, tracks }:
return (
<>
{trackOpts && isSubmitOpen && (
{isOpen && (
<StyledModal
className={className}
header={t<string>('Submit proposal')}
onClose={toggleSubmit}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
@ -205,19 +176,11 @@ function Submit ({ className = '', isMember, members, palletReferenda, tracks }:
/>
</Modal.Columns>
<Modal.Columns hint={t<string>('The origin (and by extension track) that you wish to submit for, each has a different period, different root and acceptance criteria.')}>
<Dropdown
defaultValue={trackOpts[0] && trackOpts[0].value}
label={t<string>('submission track')}
<TrackDropdown
onChange={setTrack}
options={trackOpts}
palletReferenda={palletReferenda}
tracks={tracks}
/>
{false && trackInfo?.text && (
<Input
isDisabled
label={t<string>('track overview')}
value={trackInfo?.text}
/>
)}
{!trackInfo?.origin && (
<Params
className='originSelect'
@ -260,7 +223,7 @@ function Submit ({ className = '', isMember, members, palletReferenda, tracks }:
/>
</Modal.Columns>
<Modal.Columns
className='centerEnactType'
align='center'
hint={t<string>('The moment of enactment, either at a specific block, or after a specific number of blocks.')}
>
<ToggleGroup
@ -307,7 +270,7 @@ function Submit ({ className = '', isMember, members, palletReferenda, tracks }:
icon='plus'
isDisabled={!selectedOrigin || !isImageHashValid || !isImageLenValid || !accountId || isInvalidAt || !preimage?.proposalHash}
label={t<string>('Submit proposal')}
onStart={toggleSubmit}
onStart={toggleOpen}
params={[
selectedOrigin,
{
@ -326,9 +289,9 @@ function Submit ({ className = '', isMember, members, palletReferenda, tracks }:
)}
<Button
icon='plus'
isDisabled={!isMember || !trackOpts}
isDisabled={!isMember}
label={t<string>('Submit proposal')}
onClick={toggleSubmit}
onClick={toggleOpen}
/>
</>
);
@ -340,19 +303,6 @@ const StyledModal = styled(Modal)`
padding-left: 0;
}
}
.trackOption {
.faded {
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
margin-top: 0.125rem;
opacity: var(--opacity-light);
}
}
.ui--Modal-Columns.centerEnactType > div:first-child {
text-align: center;
}
`;
export default React.memo(Submit);

View File

@ -0,0 +1,9 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type React from 'react';
export interface TrackOption {
text: React.ReactNode;
value: number;
}

View File

@ -0,0 +1,57 @@
// Copyright 2017-2023 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ApiPromise } from '@polkadot/api';
import type { BN } from '@polkadot/util';
import type { TrackDescription } from '../../types';
import type { TrackOption } from './types';
import React, { useMemo } from 'react';
import { createNamedHook, useApi } from '@polkadot/react-hooks';
import { bnToBn } from '@polkadot/util';
import { getTrackInfo, getTrackName } from '../../util';
function getTrackOptions (api: ApiPromise, specName: string, palletReferenda: string, tracks: TrackDescription[], include?: (BN | number)[], exclude?: (BN | number)[]): TrackOption[] {
const includeBn = include && include.map((v) => bnToBn(v));
const excludeBn = exclude && exclude.map((v) => bnToBn(v));
return tracks
.filter(({ id }) =>
(
!includeBn ||
includeBn.some((v) => v.eq(id))
) && (
!excludeBn ||
!excludeBn.some((v) => v.eq(id))
)
)
.map(({ id, info }): TrackOption => {
const trackInfo = getTrackInfo(api, specName, palletReferenda, tracks, id.toNumber());
const trackName = getTrackName(id, info);
return {
text: trackInfo?.text
? (
<div className='trackOption'>
<div className='normal'>{trackName}</div>
<div className='faded'>{trackInfo.text}</div>
</div>
)
: trackName,
value: id.toNumber()
};
});
}
function useTrackOptionsImpl (palletReferenda: string, tracks: TrackDescription[], include?: (BN | number)[], exclude?: (BN | number)[]): TrackOption[] {
const { api, specName } = useApi();
return useMemo(
() => getTrackOptions(api, specName, palletReferenda, tracks, include, exclude),
[api, exclude, include, palletReferenda, specName, tracks]
);
}
export default createNamedHook('useTrackOptions', useTrackOptionsImpl);

View File

@ -2,13 +2,13 @@
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { VoteTypeProps as Props } from './types';
import type { VoteTypeProps as Props } from '../types';
import React, { useEffect, useState } from 'react';
import { Modal, VoteValue } from '@polkadot/react-components';
import { useTranslation } from '../translate';
import { useTranslation } from '../../translate';
function VoteAbstain ({ accountId, id, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();

View File

@ -2,13 +2,13 @@
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { VoteTypeProps as Props } from './types';
import type { VoteTypeProps as Props } from '../types';
import React, { useEffect, useState } from 'react';
import { Modal, VoteValue } from '@polkadot/react-components';
import { useTranslation } from '../translate';
import { useTranslation } from '../../translate';
function VoteSplit ({ accountId, id, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();

View File

@ -2,13 +2,13 @@
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { VoteTypeProps } from './types';
import type { VoteTypeProps } from '../types';
import React, { useEffect, useState } from 'react';
import { ConvictionDropdown, Modal, VoteValue } from '@polkadot/react-components';
import { useTranslation } from '../translate';
import { useTranslation } from '../../translate';
interface Props extends VoteTypeProps {
voteLockingPeriod: BN;

View File

@ -5,7 +5,7 @@ import type { TFunction } from 'react-i18next';
import type { ApiPromise } from '@polkadot/api';
import type { Preimage } from '@polkadot/react-hooks/types';
import type { BN } from '@polkadot/util';
import type { PalletVote, TrackInfo } from '../types';
import type { PalletVote, TrackInfo } from '../../types';
import React, { useMemo, useState } from 'react';
import styled from 'styled-components';
@ -13,7 +13,7 @@ import styled from 'styled-components';
import { Button, Modal, ProposedAction, ToggleGroup, TxButton, VoteAccount } from '@polkadot/react-components';
import { useAccounts, useApi, useToggle } from '@polkadot/react-hooks';
import { useTranslation } from '../translate';
import { useTranslation } from '../../translate';
import VoteAbstain from './VoteAbstain';
import VoteSplit from './VoteSplit';
import VoteStandard from './VoteStandard';
@ -74,7 +74,7 @@ function Voting ({ className, id, isConvictionVote, isMember, members, palletVot
const { api } = useApi();
const { allAccounts, hasAccounts } = useAccounts();
const [accountId, setAccountId] = useState<string | null>(null);
const [isVotingOpen, toggleVoting] = useToggle();
const [isOpen, toggleOpen] = useToggle();
const [voteTypeIndex, setVoteTypeIndex] = useState(0);
const [params, setParams] = useState<unknown[] | undefined>();
@ -105,11 +105,11 @@ function Voting ({ className, id, isConvictionVote, isMember, members, palletVot
return (
<>
{isVotingOpen && (
{isOpen && (
<StyledModal
className={className}
header={t<string>('Vote on referendum')}
onClose={toggleVoting}
onClose={toggleOpen}
size='large'
>
<Modal.Content>
@ -184,7 +184,7 @@ function Voting ({ className, id, isConvictionVote, isMember, members, palletVot
accountId={accountId}
icon='check-to-slot'
label={t<string>('Vote')}
onStart={toggleVoting}
onStart={toggleOpen}
params={params}
tx={api.tx[palletVote].vote}
/>
@ -195,7 +195,7 @@ function Voting ({ className, id, isConvictionVote, isMember, members, palletVot
accountId={accountId}
icon='ban'
label={t<string>('Vote Nay')}
onStart={toggleVoting}
onStart={toggleOpen}
params={[id, false]}
tx={api.tx[palletVote].vote}
/>
@ -203,7 +203,7 @@ function Voting ({ className, id, isConvictionVote, isMember, members, palletVot
accountId={accountId}
icon='check'
label={t<string>('Vote Aye')}
onStart={toggleVoting}
onStart={toggleOpen}
params={[id, true]}
tx={api.tx[palletVote].vote}
/>
@ -217,7 +217,7 @@ function Voting ({ className, id, isConvictionVote, isMember, members, palletVot
icon='check-to-slot'
isDisabled={isDisabled}
label={t<string>('Vote')}
onClick={toggleVoting}
onClick={toggleOpen}
/>
</>
);

View File

@ -15,6 +15,7 @@ import { BN_ZERO } from '@polkadot/util';
import { useTranslation } from '../translate';
import useReferenda from '../useReferenda';
import useSummary from '../useSummary';
import Delegate from './Delegate';
import Group from './Group';
import Submit from './Submit';
import Summary from './Summary';
@ -88,6 +89,13 @@ function Referenda ({ className, isConvictionVote, members, palletReferenda, pal
options={trackOpts}
value={trackSelected}
/>
{isConvictionVote && (
<Delegate
palletReferenda={palletReferenda}
palletVote={palletVote}
tracks={tracks}
/>
)}
<AddPreimage />
<Submit
isMember={isMember}

View File

@ -71,6 +71,7 @@ export interface TrackInfo {
export interface TrackInfoExt extends TrackInfo {
track: TrackDescription;
trackName: string;
}
export interface Lock {

View File

@ -25,7 +25,7 @@ export function getTrackName (trackId: BN, { name }: PalletReferendaTrackInfo):
export function getTrackInfo (api: ApiPromise, specName: string, palletReferenda: string, tracks: TrackDescription[], trackId?: number): TrackInfoExt | undefined {
let info: TrackInfoExt | undefined;
if (tracks && trackId !== undefined) {
if (tracks && trackId !== undefined && trackId !== -1) {
const originMap = getGovernanceTracks(api, specName, palletReferenda);
const track = tracks.find(({ id }) => id.eqn(trackId));
@ -37,7 +37,10 @@ export function getTrackInfo (api: ApiPromise, specName: string, palletReferenda
);
if (base) {
info = objectSpread<TrackInfoExt>({ track }, base);
info = objectSpread<TrackInfoExt>({
track,
trackName: getTrackName(track.id, track.info)
}, base);
}
}
}

View File

@ -34,7 +34,7 @@ interface Props {
labelExtra?: React.ReactNode;
onChange?: (value: string | null) => void;
onChangeMulti?: (value: string[]) => void;
options?: KeyringSectionOption[];
options?: KeyringSectionOption[] | null;
optionsAll?: Record<string, Option[]>;
placeholder?: string;
type?: KeyringOption$Type;

View File

@ -5,17 +5,18 @@ import React from 'react';
import styled from 'styled-components';
interface Props {
align?: 'center' | 'left' | 'right';
children: React.ReactNode;
className?: string;
hint?: React.ReactNode;
}
function Columns ({ children, className = '', hint }: Props): React.ReactElement<Props> {
function Columns ({ align = 'left', children, className = '', hint }: Props): React.ReactElement<Props> {
return (
<StyledDiv className={`${className} ui--Modal-Columns`}>
<div>{children}</div>
<StyledDiv className={`${className} ui--Modal-Columns ${align}Align`}>
<div className='ui--Modal-Columns-content'>{children}</div>
{hint && (
<div>{hint}</div>
<div className='ui--Modal-Columns-hint'>{hint}</div>
)}
</StyledDiv>
);
@ -26,6 +27,14 @@ const StyledDiv = styled.div`
display: flex;
justify-content: space-between;
&.centerAlign > div.ui--Modal-Columns-content {
text-align: center;
}
&.rightAlign > div.ui--Modal-Columns-content {
text-align: right;
}
&+& {
margin-top: 0.25rem;
}

View File

@ -14,8 +14,8 @@ interface Option {
interface Props {
className?: string;
onChange: (index: number) => void;
options: (Option | null | false)[];
onChange: (index: number, value: string | number) => void;
options: (Option | null | undefined | false)[];
value: number;
}
@ -23,21 +23,26 @@ interface ToggleProps {
index: number;
isDisabled?: boolean;
isSelected: boolean;
onChange: (index: number) => void;
onChange: (index: number, value: string | number) => void;
text: string;
value: string | number;
}
function ToggleIndex ({ index, isDisabled, isSelected, onChange, text }: ToggleProps): React.ReactElement<ToggleProps> {
function ToggleIndex ({ index, isDisabled, isSelected, onChange, text, value }: ToggleProps): React.ReactElement<ToggleProps> {
const _onClick = useCallback(
(): void => {
!isDisabled && onChange(index);
!isDisabled && onChange(index, value);
},
[isDisabled, index, onChange]
[isDisabled, index, onChange, value]
);
return (
<Button
icon={isSelected ? 'check' : 'circle'}
icon={
isSelected
? 'check'
: 'circle'
}
isBasic
isDisabled={isDisabled}
isSelected={isSelected}
@ -62,7 +67,7 @@ function ToggleGroup ({ className = '', onChange, options, value }: Props): Reac
return (
<StyledDiv className={`${className} ui--ToggleGroup`}>
{available.map(({ isDisabled, text }, index): React.ReactNode => (
{available.map(({ isDisabled, text, value: optValue }, index): React.ReactNode => (
<ToggleIndexMemo
index={index}
isDisabled={isDisabled}
@ -70,6 +75,7 @@ function ToggleGroup ({ className = '', onChange, options, value }: Props): Reac
key={index}
onChange={onChange}
text={text}
value={optValue}
/>
))}
</StyledDiv>