mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-06-17 05:31:05 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Hash, Proposal, ProposalIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCollectiveInstance, useToggle, useWeight } from '@pezkuwi/react-hooks';
|
||||
import { ProposedAction } from '@pezkuwi/react-params';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
hasFailed: boolean;
|
||||
hash: Hash;
|
||||
idNumber: ProposalIndex;
|
||||
proposal: Proposal | null;
|
||||
}
|
||||
|
||||
function Close ({ hasFailed, hash, idNumber, proposal }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const { encodedCallLength, weight } = useWeight(proposal);
|
||||
const modLocation = useCollectiveInstance('council');
|
||||
|
||||
// protect against older versions
|
||||
if (!modLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
header={t('Close proposal')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The proposal that will be affected. Once closed for the current voting round, it would need to be re-submitted to council for a subsequent voting round.')}>
|
||||
<ProposedAction
|
||||
idNumber={idNumber}
|
||||
proposal={proposal}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The council account that will apply the close for the current round.')}>
|
||||
<InputAddress
|
||||
label={t('close from account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
isDisabled={!hasFailed && !encodedCallLength}
|
||||
onStart={toggleOpen}
|
||||
params={
|
||||
api.tx[modLocation].close.meta.args.length === 4
|
||||
? hasFailed
|
||||
? [hash, idNumber, 0, 0]
|
||||
: [hash, idNumber, weight, encodedCallLength]
|
||||
: [hash, idNumber]
|
||||
}
|
||||
tx={api.tx[modLocation].closeOperational || api.tx[modLocation].close}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
<Button
|
||||
icon='times'
|
||||
label={t('Close')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Close);
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import ProposalCell from '@pezkuwi/app-democracy/Overview/ProposalCell';
|
||||
import { Icon, LinkExternal, Table } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useCollectiveInstance, useVotingStatus } from '@pezkuwi/react-hooks';
|
||||
import { BlockToTime } from '@pezkuwi/react-query';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import Close from './Close.js';
|
||||
import Voters from './Voters.js';
|
||||
import Voting from './Voting.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isMember: boolean;
|
||||
members: string[];
|
||||
motion: DeriveCollectiveProposal;
|
||||
prime?: AccountId | null;
|
||||
}
|
||||
|
||||
interface VoterState {
|
||||
hasVoted: boolean;
|
||||
hasVotedAye: boolean;
|
||||
hasVotedNay: boolean;
|
||||
}
|
||||
|
||||
function Motion ({ className = '', isMember, members, motion: { hash, proposal, votes }, prime }: Props): React.ReactElement<Props> | null {
|
||||
const { allAccounts } = useAccounts();
|
||||
const { hasFailed, isCloseable, isVoteable, remainingBlocks } = useVotingStatus(votes, members.length, 'council');
|
||||
const modLocation = useCollectiveInstance('council');
|
||||
|
||||
const { hasVoted, hasVotedAye } = useMemo(
|
||||
(): VoterState => {
|
||||
if (votes) {
|
||||
const hasVotedAye = allAccounts.some((a) => votes.ayes.some((accountId) => accountId.eq(a)));
|
||||
const hasVotedNay = allAccounts.some((a) => votes.nays.some((accountId) => accountId.eq(a)));
|
||||
|
||||
return {
|
||||
hasVoted: hasVotedAye || hasVotedNay,
|
||||
hasVotedAye,
|
||||
hasVotedNay
|
||||
};
|
||||
}
|
||||
|
||||
return { hasVoted: false, hasVotedAye: false, hasVotedNay: false };
|
||||
},
|
||||
[allAccounts, votes]
|
||||
);
|
||||
|
||||
if (!votes || !modLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ayes, end, index, nays, threshold } = votes;
|
||||
|
||||
return (
|
||||
<tr className={className}>
|
||||
<Table.Column.Id value={index} />
|
||||
<ProposalCell
|
||||
imageHash={hash}
|
||||
isCollective
|
||||
proposal={proposal}
|
||||
/>
|
||||
<td className='number together'>
|
||||
{formatNumber(threshold)}
|
||||
</td>
|
||||
<td className='number together'>
|
||||
{remainingBlocks && end && (
|
||||
<>
|
||||
<BlockToTime value={remainingBlocks} />
|
||||
#{formatNumber(end)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className='expand'>
|
||||
<Voters
|
||||
isAye
|
||||
members={members}
|
||||
threshold={threshold}
|
||||
votes={ayes}
|
||||
/>
|
||||
<Voters
|
||||
members={members}
|
||||
threshold={threshold}
|
||||
votes={nays}
|
||||
/>
|
||||
</td>
|
||||
<td className='button'>
|
||||
{isVoteable && !isCloseable && (
|
||||
<Voting
|
||||
hash={hash}
|
||||
idNumber={index}
|
||||
isDisabled={!isMember}
|
||||
members={members}
|
||||
prime={prime}
|
||||
proposal={proposal}
|
||||
/>
|
||||
)}
|
||||
{isCloseable && (
|
||||
<Close
|
||||
hasFailed={hasFailed}
|
||||
hash={hash}
|
||||
idNumber={index}
|
||||
proposal={proposal}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className='badge'>
|
||||
{isMember && (
|
||||
<Icon
|
||||
color={hasVoted ? (hasVotedAye ? 'green' : 'red') : 'gray'}
|
||||
icon='asterisk'
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className='links'>
|
||||
<LinkExternal
|
||||
data={index}
|
||||
hash={hash.toString()}
|
||||
type='council'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Motion);
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { Button, Input, InputAddress, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCollectiveInstance, usePreimage, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO, isFunction, isHex } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isMember: boolean;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
interface HashState {
|
||||
hash?: HexString | null;
|
||||
isHashValid: boolean;
|
||||
}
|
||||
|
||||
interface ImageState {
|
||||
imageLen: BN;
|
||||
imageLenDefault?: BN;
|
||||
isImageLenValid: boolean;
|
||||
}
|
||||
|
||||
interface ProposalState {
|
||||
proposal?: SubmittableExtrinsic<'promise'> | null;
|
||||
proposalLength: number;
|
||||
}
|
||||
|
||||
function ProposeExternal ({ className = '', isMember, members }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isVisible, toggleVisible] = useToggle();
|
||||
const [accountId, setAcountId] = useState<string | null>(null);
|
||||
const [{ proposal, proposalLength }, setProposal] = useState<ProposalState>({ proposalLength: 0 });
|
||||
const [{ hash, isHashValid }, setHash] = useState<HashState>({ isHashValid: false });
|
||||
const [{ imageLen, imageLenDefault, isImageLenValid }, setImageLen] = useState<ImageState>({ imageLen: BN_ZERO, isImageLenValid: false });
|
||||
const modLocation = useCollectiveInstance('council');
|
||||
const preimage = usePreimage(hash);
|
||||
|
||||
const threshold = Math.min(members.length, Math.ceil((members.length || 0) * getProposalThreshold(api)));
|
||||
|
||||
const isCurrentPreimage = useMemo(
|
||||
() => isFunction(api.tx.preimage?.notePreimage) && !isFunction(api.tx.democracy?.notePreimage),
|
||||
[api]
|
||||
);
|
||||
|
||||
const _onChangeHash = useCallback(
|
||||
(hash?: string): void =>
|
||||
setHash({ hash: hash as HexString, isHashValid: isHex(hash, 256) }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeImageLen = useCallback(
|
||||
(value?: BN): void => {
|
||||
value && setImageLen((prev) => ({
|
||||
imageLen: value,
|
||||
imageLenDefault: prev.imageLenDefault,
|
||||
isImageLenValid: !value.isZero()
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
preimage?.proposalLength && setImageLen((prev) => ({
|
||||
imageLen: prev.imageLen,
|
||||
imageLenDefault: preimage.proposalLength,
|
||||
isImageLenValid: prev.isImageLenValid
|
||||
}));
|
||||
}, [preimage]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (isHashValid && hash) {
|
||||
const proposal = isCurrentPreimage
|
||||
? preimage && api.tx.democracy.externalProposeMajority({
|
||||
Lookup: {
|
||||
hash: preimage.proposalHash,
|
||||
len: preimage.proposalLength || imageLen
|
||||
}
|
||||
})
|
||||
: api.tx.democracy.externalProposeMajority(hash);
|
||||
|
||||
if (proposal) {
|
||||
return setProposal({
|
||||
proposal,
|
||||
proposalLength: proposal.encodedLength || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setProposal({
|
||||
proposal: null,
|
||||
proposalLength: 0
|
||||
});
|
||||
}, [api, hash, isCurrentPreimage, isHashValid, imageLen, preimage]);
|
||||
|
||||
if (!modLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!isMember}
|
||||
label={t('Propose external')}
|
||||
onClick={toggleVisible}
|
||||
/>
|
||||
{isVisible && (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Propose external (majority)')}
|
||||
onClose={toggleVisible}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The council account for the proposal. The selection is filtered by the current members.')}>
|
||||
<InputAddress
|
||||
filter={members}
|
||||
label={t('propose from account')}
|
||||
onChange={setAcountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The hash of the proposal image, either already submitted or valid for the specific call.')}>
|
||||
<Input
|
||||
autoFocus
|
||||
isError={!isHashValid}
|
||||
label={t('preimage hash')}
|
||||
onChange={_onChangeHash}
|
||||
value={hash}
|
||||
/>
|
||||
{isCurrentPreimage && (
|
||||
<InputNumber
|
||||
defaultValue={imageLenDefault}
|
||||
isDisabled={!!preimage?.proposalLength && !preimage?.proposalLength.isZero() && isHashValid && isImageLenValid}
|
||||
isError={!isImageLenValid}
|
||||
key='inputLength'
|
||||
label={t('preimage length')}
|
||||
onChange={_onChangeImageLen}
|
||||
value={imageLen}
|
||||
/>
|
||||
)}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='plus'
|
||||
isDisabled={!threshold || !members.includes(accountId || '') || !proposal || (isCurrentPreimage && !isImageLenValid)}
|
||||
label={t('Propose')}
|
||||
onStart={toggleVisible}
|
||||
params={
|
||||
api.tx[modLocation].propose.meta.args.length === 3
|
||||
? [threshold, proposal, proposalLength]
|
||||
: [threshold, proposal]
|
||||
}
|
||||
tx={api.tx[modLocation].propose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ProposeExternal);
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { Button, InputAddress, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCollectiveInstance, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { Extrinsic } from '@pezkuwi/react-params';
|
||||
import { BN, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
isMember: boolean;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
interface Threshold {
|
||||
isThresholdValid: boolean;
|
||||
threshold?: BN;
|
||||
}
|
||||
|
||||
interface ProposalState {
|
||||
proposal?: SubmittableExtrinsic<'promise'> | null;
|
||||
proposalLength: number;
|
||||
}
|
||||
|
||||
function Propose ({ isMember, members }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api, apiDefaultTxSudo } = useApi();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAcountId] = useState<string | null>(null);
|
||||
const [{ proposal, proposalLength }, setProposal] = useState<ProposalState>({ proposalLength: 0 });
|
||||
const [{ isThresholdValid, threshold }, setThreshold] = useState<Threshold>({ isThresholdValid: false });
|
||||
const modLocation = useCollectiveInstance('council');
|
||||
|
||||
useEffect((): void => {
|
||||
members && setThreshold({
|
||||
isThresholdValid: members.length !== 0,
|
||||
threshold: new BN(Math.min(members.length, Math.ceil(members.length * getProposalThreshold(api))))
|
||||
});
|
||||
}, [api, members]);
|
||||
|
||||
const _setMethod = useCallback(
|
||||
(proposal?: SubmittableExtrinsic<'promise'> | null) => setProposal({
|
||||
proposal,
|
||||
proposalLength: proposal?.encodedLength || 0
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const _setThreshold = useCallback(
|
||||
(threshold?: BN) => setThreshold({
|
||||
isThresholdValid: !!threshold?.gtn(0),
|
||||
threshold
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!modLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!isMember}
|
||||
label={t('Propose motion')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
header={t('Propose a council motion')}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The council account for the proposal. The selection is filtered by the current members.')}>
|
||||
<InputAddress
|
||||
filter={members}
|
||||
label={t('propose from account')}
|
||||
onChange={setAcountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The desired threshold. Here set to a default of 50%+1, as applicable for general proposals.')}>
|
||||
<InputNumber
|
||||
className='medium'
|
||||
isError={!threshold || threshold.eqn(0) || threshold.gtn(members.length)}
|
||||
label={t('threshold')}
|
||||
onChange={_setThreshold}
|
||||
placeholder={t('Positive number between 1 and {{memberCount}}', { replace: { memberCount: members.length } })}
|
||||
value={threshold || BN_ZERO}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The actual proposal to make, based on the selected call and parameters thereof.')}>
|
||||
<Extrinsic
|
||||
defaultValue={apiDefaultTxSudo}
|
||||
label={t('proposal')}
|
||||
onChange={_setMethod}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
isDisabled={!proposal || !isThresholdValid}
|
||||
label={t('Propose')}
|
||||
onStart={toggleOpen}
|
||||
params={
|
||||
api.tx[modLocation].propose.meta.args.length === 3
|
||||
? [threshold, proposal, proposalLength]
|
||||
: [threshold, proposal]
|
||||
}
|
||||
tx={api.tx[modLocation].propose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Propose);
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getSlashProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { Button, Dropdown, Input, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useAvailableSlashes, useCollectiveInstance, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isMember: boolean;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
interface Option {
|
||||
text: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ProposalState {
|
||||
proposal?: SubmittableExtrinsic<'promise'> | null;
|
||||
proposalLength: number;
|
||||
}
|
||||
|
||||
function Slashing ({ className = '', isMember, members }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const slashes = useAvailableSlashes();
|
||||
const [isVisible, toggleVisible] = useToggle();
|
||||
const [accountId, setAcountId] = useState<string | null>(null);
|
||||
const [{ proposal, proposalLength }, setProposal] = useState<ProposalState>({ proposal: null, proposalLength: 0 });
|
||||
const [selectedEra, setSelectedEra] = useState(0);
|
||||
const modLocation = useCollectiveInstance('council');
|
||||
|
||||
const threshold = Math.ceil((members.length || 0) * getSlashProposalThreshold(api));
|
||||
|
||||
const eras = useMemo(
|
||||
() => (slashes || []).map(([era, slashes]): Option => ({
|
||||
text: t('era {{era}}, {{count}} slashes', {
|
||||
replace: {
|
||||
count: slashes.length,
|
||||
era: era.toNumber()
|
||||
}
|
||||
}),
|
||||
value: era.toNumber()
|
||||
})),
|
||||
[slashes, t]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
const actioned = selectedEra && slashes?.find(([era]) => era.eqn(selectedEra));
|
||||
const proposal = actioned
|
||||
? api.tx.staking.cancelDeferredSlash(actioned[0], actioned[1].map((_, index) => index))
|
||||
: null;
|
||||
|
||||
setProposal({
|
||||
proposal,
|
||||
proposalLength: proposal?.encodedLength || 0
|
||||
});
|
||||
}, [api, selectedEra, slashes]);
|
||||
|
||||
if (!modLocation || !api.tx.staking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='sync'
|
||||
isDisabled={!isMember || !slashes.length}
|
||||
label={t('Cancel slashes')}
|
||||
onClick={toggleVisible}
|
||||
/>
|
||||
{isVisible && (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Revert pending slashes')}
|
||||
onClose={toggleVisible}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The council account for the proposal. The selection is filtered by the current members.')}>
|
||||
<InputAddress
|
||||
filter={members}
|
||||
label={t('propose from account')}
|
||||
onChange={setAcountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The specific eras on which there are unapplied slashes. For each era a separate proposal is to be made.')}>
|
||||
{eras.length
|
||||
? (
|
||||
<Dropdown
|
||||
defaultValue={eras[0].value}
|
||||
label={t('the era to cancel for')}
|
||||
onChange={setSelectedEra}
|
||||
options={eras}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Input
|
||||
isDisabled
|
||||
label={t('the era to cancel for')}
|
||||
value={t('no unapplied slashes found')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='sync'
|
||||
isDisabled={!threshold || !members.includes(accountId || '') || !proposal}
|
||||
label={t('Revert')}
|
||||
onStart={toggleVisible}
|
||||
params={
|
||||
api.tx[modLocation].propose.meta.args.length === 3
|
||||
? [threshold, proposal, proposalLength]
|
||||
: [threshold, proposal]
|
||||
}
|
||||
tx={api.tx[modLocation].propose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Slashing);
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, MemberCount } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { AddressMini, ExpanderScroll } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
isAye?: boolean;
|
||||
members: string[];
|
||||
votes: AccountId[];
|
||||
threshold: MemberCount;
|
||||
}
|
||||
|
||||
function Voters ({ isAye, members, threshold, votes }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const count = useMemo(
|
||||
(): string => {
|
||||
const num = threshold.toNumber();
|
||||
const max = isAye
|
||||
? num
|
||||
: members?.length
|
||||
? (members.length - num) + 1
|
||||
: 0;
|
||||
|
||||
return `${votes.length}${max ? `/${max}` : ''}`;
|
||||
},
|
||||
[isAye, members, threshold, votes]
|
||||
);
|
||||
|
||||
const renderVotes = useCallback(
|
||||
() => votes?.map((address): React.ReactNode => (
|
||||
<AddressMini
|
||||
key={address.toString()}
|
||||
value={address}
|
||||
withBalance={false}
|
||||
/>
|
||||
)),
|
||||
[votes]
|
||||
);
|
||||
|
||||
if (!count || !votes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpanderScroll
|
||||
renderChildren={renderVotes}
|
||||
summary={
|
||||
isAye
|
||||
? t('Aye {{count}}', { replace: { count } })
|
||||
: t('Nay {{count}}', { replace: { count } })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Voters);
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, Hash, Proposal, ProposalIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, MarkWarning, Modal, TxButton, VoteAccount } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useCollectiveInstance, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { ProposedAction } from '@pezkuwi/react-params';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
hash: Hash;
|
||||
idNumber: ProposalIndex;
|
||||
isDisabled: boolean;
|
||||
members: string[];
|
||||
prime?: AccountId | null;
|
||||
proposal: Proposal | null;
|
||||
}
|
||||
|
||||
function Voting ({ hash, idNumber, isDisabled, members, prime, proposal }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { hasAccounts } = useAccounts();
|
||||
const [isVotingOpen, toggleVoting] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const modLocation = useCollectiveInstance('council');
|
||||
|
||||
if (!hasAccounts || !modLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPrime = prime?.toString() === accountId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVotingOpen && (
|
||||
<Modal
|
||||
header={t('Vote on proposal')}
|
||||
onClose={toggleVoting}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The proposal that is being voted on. It will pass when the threshold is reached.')}>
|
||||
<ProposedAction
|
||||
idNumber={idNumber}
|
||||
proposal={proposal}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The council account for this vote. The selection is filtered by the current members.')}>
|
||||
<VoteAccount
|
||||
filter={members}
|
||||
onChange={setAccountId}
|
||||
/>
|
||||
{isPrime && (
|
||||
<MarkWarning content={t('You are voting with this collective\'s prime account. The vote will be the default outcome in case of any abstentions.')} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='ban'
|
||||
isDisabled={isDisabled}
|
||||
label={t('Vote Nay')}
|
||||
onStart={toggleVoting}
|
||||
params={[hash, idNumber, false]}
|
||||
tx={api.tx[modLocation].vote}
|
||||
/>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='check-to-slot'
|
||||
isDisabled={isDisabled}
|
||||
label={t('Vote Aye')}
|
||||
onStart={toggleVoting}
|
||||
params={[hash, idNumber, true]}
|
||||
tx={api.tx[modLocation].vote}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
<Button
|
||||
icon='check-to-slot'
|
||||
isDisabled={isDisabled}
|
||||
label={t('Vote')}
|
||||
onClick={toggleVoting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Voting);
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Button, Table } from '@pezkuwi/react-components';
|
||||
import { useCollectiveMembers } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Motion from './Motion.js';
|
||||
import ProposeExternal from './ProposeExternal.js';
|
||||
import ProposeMotion from './ProposeMotion.js';
|
||||
import Slashing from './Slashing.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
motions?: DeriveCollectiveProposal[];
|
||||
prime?: AccountId | null;
|
||||
}
|
||||
|
||||
function Proposals ({ className = '', motions, prime }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { isMember, members } = useCollectiveMembers('council');
|
||||
|
||||
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('motions'), 'start', 2],
|
||||
[t('threshold')],
|
||||
[t('voting end')],
|
||||
[t('votes'), 'expand'],
|
||||
[],
|
||||
[undefined, 'badge'],
|
||||
[]
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button.Group>
|
||||
<ProposeMotion
|
||||
isMember={isMember}
|
||||
members={members}
|
||||
/>
|
||||
<ProposeExternal
|
||||
isMember={isMember}
|
||||
members={members}
|
||||
/>
|
||||
<Slashing
|
||||
isMember={isMember}
|
||||
members={members}
|
||||
/>
|
||||
</Button.Group>
|
||||
<Table
|
||||
empty={motions && t('No council motions')}
|
||||
header={headerRef.current}
|
||||
>
|
||||
{motions?.map((motion: DeriveCollectiveProposal): React.ReactNode => (
|
||||
<Motion
|
||||
isMember={isMember}
|
||||
key={motion.hash.toHex()}
|
||||
members={members}
|
||||
motion={motion}
|
||||
prime={prime}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Proposals);
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, Balance } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AddressSmall, Tag } from '@pezkuwi/react-components';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Voters from './Voters.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
address: AccountId;
|
||||
balance?: Balance;
|
||||
hasElections: boolean;
|
||||
isPrime?: boolean;
|
||||
voters?: AccountId[];
|
||||
}
|
||||
|
||||
function Candidate ({ address, balance, className = '', hasElections, isPrime, voters }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={`${className} isExpanded isFirst ${hasElections ? '' : 'isLast'}`}>
|
||||
<td className='address all relative'>
|
||||
<AddressSmall value={address} />
|
||||
{isPrime && (
|
||||
<Tag
|
||||
className='absolute'
|
||||
color='green'
|
||||
label={t('prime')}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className='number'>
|
||||
{voters && formatNumber(voters.length)}
|
||||
</td>
|
||||
</tr>
|
||||
{hasElections && (
|
||||
<Voters
|
||||
balance={balance}
|
||||
voters={voters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Candidate);
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Table } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Candidate from './Candidate.js';
|
||||
|
||||
interface Props {
|
||||
allVotes?: Record<string, AccountId[]>;
|
||||
className?: string;
|
||||
electionsInfo?: DeriveElectionsInfo;
|
||||
hasElections: boolean;
|
||||
}
|
||||
|
||||
function Candidates ({ allVotes = {}, electionsInfo, hasElections }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const headerCandidatesRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('candidates'), 'start', 2]
|
||||
]);
|
||||
|
||||
const headerRunnersRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('runners up'), 'start', 2]
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
empty={electionsInfo && t('No runners up found')}
|
||||
header={headerRunnersRef.current}
|
||||
isSplit
|
||||
>
|
||||
{electionsInfo?.runnersUp.map(([accountId, balance]): React.ReactNode => (
|
||||
<Candidate
|
||||
address={accountId}
|
||||
balance={balance}
|
||||
hasElections={hasElections}
|
||||
key={accountId.toString()}
|
||||
voters={allVotes[accountId.toString()]}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
<Table
|
||||
empty={electionsInfo && t('No candidates found')}
|
||||
header={headerCandidatesRef.current}
|
||||
isSplit
|
||||
>
|
||||
{electionsInfo?.candidates.map((accountId): React.ReactNode => (
|
||||
<Candidate
|
||||
address={accountId}
|
||||
hasElections={false}
|
||||
key={accountId.toString()}
|
||||
voters={allVotes[accountId.toString()]}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Candidates);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Table } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Candidate from './Candidate.js';
|
||||
|
||||
interface Props {
|
||||
allVotes?: Record<string, AccountId[]>;
|
||||
className?: string;
|
||||
electionsInfo?: DeriveElectionsInfo;
|
||||
hasElections: boolean;
|
||||
prime?: AccountId | null;
|
||||
}
|
||||
|
||||
function Members ({ allVotes = {}, className = '', electionsInfo, hasElections, prime }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('members'), 'start', 2]
|
||||
]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={className}
|
||||
empty={electionsInfo && t('No members found')}
|
||||
header={headerRef.current}
|
||||
isSplit
|
||||
>
|
||||
{electionsInfo?.members.map(([accountId, balance]): React.ReactNode => (
|
||||
<Candidate
|
||||
address={accountId}
|
||||
balance={balance}
|
||||
hasElections={hasElections}
|
||||
isPrime={prime?.eq(accountId)}
|
||||
key={accountId.toString()}
|
||||
voters={allVotes[accountId.toString()]}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Members);
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { u128 } from '@pezkuwi/types';
|
||||
import type { ComponentProps as Props } from './types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useModal } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { useModuleElections } from '../useModuleElections.js';
|
||||
|
||||
function SubmitCandidacy ({ electionsInfo }: Props): React.ReactElement<Props> | null {
|
||||
const { api } = useApi();
|
||||
const { t } = useTranslation();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const { isOpen, onClose, onOpen } = useModal();
|
||||
const modLocation = useModuleElections();
|
||||
|
||||
if (!modLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
header={t('Submit your council candidacy')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('This account will appear in the list of candidates. With enough votes in an election, it will become either a runner-up or a council member.')}>
|
||||
<InputAddress
|
||||
label={t('candidate account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{api.consts[modLocation] && (
|
||||
<Modal.Columns hint={t('The bond will be reserved for the duration of your candidacy and membership.')}>
|
||||
<InputBalance
|
||||
defaultValue={api.consts[modLocation as 'council']?.candidacyBond as u128}
|
||||
isDisabled
|
||||
label={t('candidacy bond')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
isDisabled={!electionsInfo}
|
||||
onStart={onClose}
|
||||
params={
|
||||
api.tx[modLocation].submitCandidacy.meta.args.length === 1
|
||||
? [electionsInfo?.candidates.length]
|
||||
: []
|
||||
}
|
||||
tx={api.tx[modLocation].submitCandidacy}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!electionsInfo}
|
||||
label={t('Submit candidacy')}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SubmitCandidacy);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { BlockNumber } from '@pezkuwi/types/interfaces';
|
||||
import type { ComponentProps } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { BN_THREE, BN_TWO, BN_ZERO, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props extends ComponentProps {
|
||||
bestNumber?: BlockNumber;
|
||||
className?: string;
|
||||
electionsInfo?: DeriveElectionsInfo;
|
||||
hasElections: boolean;
|
||||
}
|
||||
|
||||
function Summary ({ bestNumber, className = '', electionsInfo, hasElections }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SummaryBox className={className}>
|
||||
<section>
|
||||
<CardSummary label={t('seats')}>
|
||||
{electionsInfo
|
||||
? <>{formatNumber(electionsInfo.members.length)}{electionsInfo.desiredSeats && <> / {formatNumber(electionsInfo.desiredSeats)}</>}</>
|
||||
: <span className='--tmp'>99</span>}
|
||||
</CardSummary>
|
||||
{hasElections && (
|
||||
<>
|
||||
<CardSummary label={t('runners up')}>
|
||||
{electionsInfo
|
||||
? <>{formatNumber(electionsInfo.runnersUp.length)}{electionsInfo.desiredRunnersUp && <> / {formatNumber(electionsInfo.desiredRunnersUp)}</>}</>
|
||||
: <span className='--tmp'>99 / 99</span>}
|
||||
</CardSummary>
|
||||
<CardSummary label={t('candidates')}>
|
||||
{electionsInfo
|
||||
? formatNumber(electionsInfo.candidateCount)
|
||||
: <span className='--tmp'>99</span>}
|
||||
</CardSummary>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{electionsInfo?.voteCount && (
|
||||
<section>
|
||||
<CardSummary label={t('voting round')}>
|
||||
#{formatNumber(electionsInfo.voteCount)}
|
||||
</CardSummary>
|
||||
</section>
|
||||
)}
|
||||
{electionsInfo && bestNumber && electionsInfo.termDuration && electionsInfo.termDuration.gt(BN_ZERO) && (
|
||||
<section>
|
||||
<CardSummary
|
||||
label={t('term progress')}
|
||||
progress={{
|
||||
total: (electionsInfo && bestNumber) ? electionsInfo.termDuration : BN_THREE,
|
||||
value: (electionsInfo && bestNumber) ? bestNumber.mod(electionsInfo.termDuration) : BN_TWO,
|
||||
withTime: true
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</SummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, InputAddressMulti, InputBalance, Modal, TxButton, VoteValue } from '@pezkuwi/react-components';
|
||||
import { useApi, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { useModuleElections } from '../useModuleElections.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
electionsInfo?: DeriveElectionsInfo;
|
||||
}
|
||||
|
||||
const MAX_VOTES = 16;
|
||||
|
||||
function Vote ({ electionsInfo }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [isVisible, toggleVisible] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [available, setAvailable] = useState<string[]>([]);
|
||||
const [defaultVotes, setDefaultVotes] = useState<string[]>([]);
|
||||
const [votes, setVotes] = useState<string[]>([]);
|
||||
const [voteValue, setVoteValue] = useState(BN_ZERO);
|
||||
const modLocation = useModuleElections();
|
||||
|
||||
useEffect((): void => {
|
||||
if (electionsInfo) {
|
||||
const { candidates, members, runnersUp } = electionsInfo;
|
||||
|
||||
setAvailable(
|
||||
members
|
||||
.map(([accountId]) => accountId.toString())
|
||||
.concat(runnersUp.map(([accountId]) => accountId.toString()))
|
||||
.concat(candidates.map((accountId) => accountId.toString()))
|
||||
);
|
||||
}
|
||||
}, [electionsInfo]);
|
||||
|
||||
useEffect((): void => {
|
||||
accountId && api.derive.council
|
||||
.votesOf(accountId)
|
||||
.then(({ votes }): void => {
|
||||
setDefaultVotes(
|
||||
votes
|
||||
.map((a) => a.toString())
|
||||
.filter((a) => available.includes(a))
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [api, accountId, available]);
|
||||
|
||||
const bondValue = useMemo(
|
||||
(): BN | undefined => {
|
||||
const location = api.consts.elections || api.consts.phragmenElection || api.consts.electionsPhragmen;
|
||||
|
||||
return location &&
|
||||
location.votingBondBase &&
|
||||
location.votingBondBase.add(location.votingBondFactor.muln(votes.length));
|
||||
},
|
||||
[api, votes]
|
||||
);
|
||||
|
||||
if (!modLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='check-to-slot'
|
||||
isDisabled={available.length === 0}
|
||||
label={t('Vote')}
|
||||
onClick={toggleVisible}
|
||||
/>
|
||||
{isVisible && (
|
||||
<Modal
|
||||
header={t('Vote for current candidates')}
|
||||
onClose={toggleVisible}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The vote will be recorded for the selected account.')}>
|
||||
<InputAddress
|
||||
label={t('voting account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The value associated with this vote. The amount will be locked (not available for transfer) and used in all subsequent elections.')}>
|
||||
<VoteValue
|
||||
accountId={accountId}
|
||||
onChange={setVoteValue}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('The votes for the members, runner-ups and candidates. These should be ordered based on your priority.')}</p>
|
||||
<p>{t('In calculating the election outcome, this prioritized vote ordering will be used to determine the final score for the candidates.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputAddressMulti
|
||||
available={available}
|
||||
availableLabel={t('council candidates')}
|
||||
defaultValue={defaultVotes}
|
||||
maxCount={MAX_VOTES}
|
||||
onChange={setVotes}
|
||||
valueLabel={t('my ordered votes')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{bondValue && (
|
||||
<Modal.Columns hint={t('The amount will be reserved for the duration of your vote')}>
|
||||
<InputBalance
|
||||
defaultValue={bondValue}
|
||||
isDisabled
|
||||
label={t('voting bond')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='trash-alt'
|
||||
isDisabled={!defaultVotes.length}
|
||||
label={t('Unvote all')}
|
||||
onStart={toggleVisible}
|
||||
tx={api.tx[modLocation].removeVoter}
|
||||
/>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
isDisabled={!accountId || votes.length === 0 || voteValue.lten(0)}
|
||||
label={t('Vote')}
|
||||
onStart={toggleVisible}
|
||||
params={[votes, voteValue]}
|
||||
tx={api.tx[modLocation].vote}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Vote);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, Balance } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { AddressMini, ExpanderScroll } from '@pezkuwi/react-components';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
|
||||
interface Props {
|
||||
balance?: Balance;
|
||||
voters?: AccountId[];
|
||||
}
|
||||
|
||||
function Voters ({ balance, voters }: Props): React.ReactElement<Props> {
|
||||
const renderVoters = useCallback(
|
||||
() => voters?.map((who): React.ReactNode =>
|
||||
<AddressMini
|
||||
key={who.toString()}
|
||||
value={who}
|
||||
withLockedVote
|
||||
/>
|
||||
),
|
||||
[voters]
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className='isExpanded isLast packedTop'>
|
||||
<td
|
||||
className='expand all'
|
||||
colSpan={2}
|
||||
>
|
||||
<ExpanderScroll
|
||||
renderChildren={renderVoters}
|
||||
summary={
|
||||
<FormatBalance
|
||||
className={balance && voters ? '' : '--tmp'}
|
||||
value={balance}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Voters);
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCouncilVotes, DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@pezkuwi/react-components';
|
||||
import { useApi, useBestNumber, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useModuleElections } from '../useModuleElections.js';
|
||||
import Candidates from './Candidates.js';
|
||||
import Members from './Members.js';
|
||||
import SubmitCandidacy from './SubmitCandidacy.js';
|
||||
import Summary from './Summary.js';
|
||||
import Vote from './Vote.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
prime?: AccountId | null;
|
||||
}
|
||||
|
||||
const transformVotes = {
|
||||
transform: (entries: DeriveCouncilVotes): Record<string, AccountId[]> =>
|
||||
entries.reduce<Record<string, AccountId[]>>((result, [voter, { votes }]) => {
|
||||
votes.forEach((candidate): void => {
|
||||
const address = candidate.toString();
|
||||
|
||||
if (!result[address]) {
|
||||
result[address] = [];
|
||||
}
|
||||
|
||||
result[address].push(voter);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, {})
|
||||
};
|
||||
|
||||
function Overview ({ className = '', prime }: Props): React.ReactElement<Props> {
|
||||
const { api } = useApi();
|
||||
const bestNumber = useBestNumber();
|
||||
const electionsInfo = useCall<DeriveElectionsInfo>(api.derive.elections.info);
|
||||
const allVotes = useCall<Record<string, AccountId[]>>(api.derive.council.votes, undefined, transformVotes);
|
||||
const modElections = useModuleElections();
|
||||
const hasElections = !!modElections;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Summary
|
||||
bestNumber={bestNumber}
|
||||
electionsInfo={electionsInfo}
|
||||
hasElections={!!modElections}
|
||||
/>
|
||||
{hasElections && (
|
||||
<Button.Group>
|
||||
<Vote electionsInfo={electionsInfo} />
|
||||
<SubmitCandidacy electionsInfo={electionsInfo} />
|
||||
</Button.Group>
|
||||
)}
|
||||
<Members
|
||||
allVotes={allVotes}
|
||||
electionsInfo={electionsInfo}
|
||||
hasElections={hasElections}
|
||||
prime={prime}
|
||||
/>
|
||||
{hasElections && (
|
||||
<Candidates
|
||||
allVotes={allVotes}
|
||||
electionsInfo={electionsInfo}
|
||||
hasElections={hasElections}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Overview);
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveElectionsInfo } from '@pezkuwi/api-derive/types';
|
||||
import type { SetIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export interface ComponentProps {
|
||||
electionsInfo?: DeriveElectionsInfo;
|
||||
}
|
||||
|
||||
export interface VoterPosition {
|
||||
setIndex: SetIndex;
|
||||
globalIndex: BN;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Motions from './Motions/index.js';
|
||||
import Overview from './Overview/index.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import useCounter from './useCounter.js';
|
||||
|
||||
export { useCounter };
|
||||
|
||||
interface Props {
|
||||
basePath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CouncilApp ({ basePath, className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { pathname } = useLocation();
|
||||
const numMotions = useCounter();
|
||||
const prime = useCall<AccountId | null>(api.derive.council.prime);
|
||||
const motions = useCall<DeriveCollectiveProposal[]>(api.derive.council.proposals);
|
||||
|
||||
const items = useMemo(() => [
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'overview',
|
||||
text: t('Overview')
|
||||
},
|
||||
{
|
||||
count: numMotions,
|
||||
name: 'motions',
|
||||
text: t('Motions')
|
||||
}
|
||||
], [numMotions, t]);
|
||||
|
||||
return (
|
||||
<main className={className}>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={items}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path={basePath}>
|
||||
<Route
|
||||
element={
|
||||
<Motions
|
||||
motions={motions}
|
||||
prime={prime}
|
||||
/>
|
||||
}
|
||||
path='motions'
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
<Overview
|
||||
className={[basePath, `${basePath}/candidates`].includes(pathname) ? '' : '--hidden'}
|
||||
prime={prime}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CouncilApp);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-council');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
|
||||
import { createNamedHook, useAccounts, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
const transformCounter = {
|
||||
transform: (motions: DeriveCollectiveProposal[]) => motions.filter(({ votes }) => !!votes).length
|
||||
};
|
||||
|
||||
function useCounterImpl (): number {
|
||||
const { hasAccounts } = useAccounts();
|
||||
const { api, isApiReady } = useApi();
|
||||
const counter = useCall<number>(isApiReady && hasAccounts && api.derive.council?.proposals, undefined, transformCounter) || 0;
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
export default createNamedHook('useCounter', useCounterImpl);
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-council authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
function useModuleElectionsImpl (): string | null {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMemo(
|
||||
() => api.tx.phragmenElection
|
||||
? 'phragmenElection'
|
||||
: api.tx.electionsPhragmen
|
||||
? 'electionsPhragmen'
|
||||
: api.tx.elections
|
||||
? 'elections'
|
||||
: null,
|
||||
[api]
|
||||
);
|
||||
}
|
||||
|
||||
export const useModuleElections = createNamedHook('useModuleElections', useModuleElectionsImpl);
|
||||
Reference in New Issue
Block a user