Bounties - Add "Accept curator role" modal (#4566)

This commit is contained in:
Ewa Kowalska 2021-02-02 17:17:33 +01:00 committed by GitHub
parent 5487db69b4
commit a790447add
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 171 additions and 54 deletions

View File

@ -1,6 +1,7 @@
{
"A reward for a curator, this amount is included in the total value of the bounty.": "A reward for a curator, this amount is included in the total value of the bounty.",
"Accept": "Accept",
"Accept Curator Role": "Accept Curator Role",
"Account does not have enough funds.": "Account does not have enough funds.",
"Add Bounty": "Add Bounty",
"After a bounty was proposed the council decides whether to fund it or not.": "After a bounty was proposed the council decides whether to fund it or not.",
@ -26,6 +27,7 @@
"Curator under voting": "Curator under voting",
"Curator's account that will award a bounty to the Beneficiary.": "Curator's account that will award a bounty to the Beneficiary.",
"Curator's deposit": "Curator's deposit",
"Curator's deposit depends on bounty fee.": "Curator's deposit depends on bounty fee.",
"Curator's deposit will be slashed and curator will be unassigned. Bounty will return to the Funded state.": "Curator's deposit will be slashed and curator will be unassigned. Bounty will return to the Funded state.",
"Curator's fee": "Curator's fee",
"Curator's fee can't be higher than bounty value.": "Curator's fee can't be higher than bounty value.",
@ -43,6 +45,7 @@
"Nay: {{count}}": "Nay: {{count}}",
"No open bounties": "No open bounties",
"Only curator can extend the bounty time.": "Only curator can extend the bounty time.",
"Only the account proposed as curator by the council can create the assign curator transaction ": "Only the account proposed as curator by the council can create the assign curator transaction ",
"Only the account proposed as curator by the council can create the unassign curator transaction ": "Only the account proposed as curator by the council can create the unassign curator transaction ",
"Part of the bounty value that will go to the Curator as a reward for their work": "Part of the bounty value that will go to the Curator as a reward for their work",
"Propose Curator": "Propose Curator",
@ -74,9 +77,11 @@
"The note that will be added to the transaction. It won't be stored on chain": "The note that will be added to the transaction. It won't be stored on chain",
"The total payment amount of this bounty, curator's fee included.": "The total payment amount of this bounty, curator's fee included.",
"This account will be used to create an extend bounty expire transaction.": "This account will be used to create an extend bounty expire transaction.",
"This account will be used to create the accept curator transaction.": "This account will be used to create the accept curator transaction.",
"This account will be used to create the unassign curator transaction.": "This account will be used to create the unassign curator transaction.",
"This account will propose the bounty. Bond amount will be reserved on its balance.": "This account will propose the bounty. Bond amount will be reserved on its balance.",
"This action will Slash the Curator.": "This action will Slash the Curator.",
"This action will accept your candidacy for the curator of the bounty.": "This action will accept your candidacy for the curator of the bounty.",
"This action will award the Beneficiary and close the bounty after a delay.": "This action will award the Beneficiary and close the bounty after a delay.",
"This action will create a Council motion to assign a Curator.": "This action will create a Council motion to assign a Curator.",
"This action will create a Council motion to either approve or reject the Bounty.": "This action will create a Council motion to either approve or reject the Bounty.",
@ -87,6 +92,8 @@
"This action will reject your candidacy for the curator of the bounty.": "This action will reject your candidacy for the curator of the bounty.",
"This action will unassign you from a curator role.": "This action will unassign you from a curator role.",
"This amount will be reserved from origin account and returned on approval or slashed upon rejection.": "This amount will be reserved from origin account and returned on approval or slashed upon rejection.",
"This amount will be reserved from your account and returned on bounty claim or if you give up, unless you are slashed earlier.": "This amount will be reserved from your account and returned on bounty claim or if you give up, unless you are slashed earlier.",
"This amount will be sent to your account on bounty claim.": "This amount will be sent to your account on bounty claim.",
"This bounty has received approval and is currently being implemented.": "This bounty has received approval and is currently being implemented.",
"This bounty is approved and funded.": "This bounty is approved and funded.",
"This bounty was approved by the council. When next treasury spend period comes, it will get funded.": "This bounty was approved by the council. When next treasury spend period comes, it will get funded.",
@ -100,6 +107,7 @@
"Waiting for Curator's acceptance": "Waiting for Curator's acceptance",
"Warning": "Warning",
"You are giving up your curator role, the bounty will return to the Funded state. You will get your deposit back.": "You are giving up your curator role, the bounty will return to the Funded state. You will get your deposit back.",
"accept curator role": "accept curator role",
"active": "active",
"award bounty": "award bounty",
"award with account": "award with account",
@ -111,6 +119,7 @@
"close bounty": "close bounty",
"curator": "curator",
"curator account": "curator account",
"curator's deposit": "curator's deposit",
"curator's fee": "curator's fee",
"current curator": "current curator",
"next action": "next action",

View File

@ -34,8 +34,7 @@ import { alice,
defaultBountyApi,
defaultBountyUpdatePeriod,
defaultMembers,
defaultTreasury,
ferdie } from '../test/hooks/defaults';
defaultTreasury } from '../test/hooks/defaults';
import { clickButtonWithName } from '../test/utils/clickButtonWithName';
import { clickElementWithTestId } from '../test/utils/clickElementWithTestId';
import { clickElementWithText } from '../test/utils/clickElementWithText';
@ -93,7 +92,7 @@ let augmentedApi: ApiPromise;
let queueExtrinsic: QueueTxExtrinsicAdd;
let aBountyStatus: (status: string) => BountyStatus;
let aBountyIndex: (index?:number) => BountyIndex;
let aBounty: ({ status, value }?: Partial<Bounty>) => Bounty;
let aBounty: ({ fee, status, value }?: Partial<Bounty>) => Bounty;
let bountyWith: ({ status, value }: { status?: string, value?: number }) => Bounty;
let bountyStatusWith: ({ curator, status, updateDue }: { curator?: string, status?: string, updateDue?: number}) => BountyStatus;
@ -315,7 +314,7 @@ describe('Bounties', () => {
describe('close bounty modal', () => {
it('creates closeBounty proposal', async () => {
const bounty = bountyWith({ status: 'Funded' });
const { findByTestId, findByText, getByRole } = renderOneBounty(bounty);
const { findByRole, findByTestId, findByText, getByRole } = renderOneBounty(bounty);
await clickElementWithTestId('extra-actions', findByTestId);
@ -326,12 +325,13 @@ describe('Bounties', () => {
const combobox = getByRole('combobox');
const proposingAccountInput = combobox.children[0];
fireEvent.change(proposingAccountInput, { target: { value: ferdie } });
fireEvent.change(proposingAccountInput, { target: { value: alice } });
fireEvent.keyDown(proposingAccountInput, { code: 'Enter', key: 'Enter' });
const closeBountyButton = getByRole('button', { name: 'Close Bounty' });
await clickButtonWithName('Close Bounty', findByRole);
fireEvent.click(closeBountyButton);
expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: ferdie, extrinsic: 'mockProposeExtrinsic' }));
expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockProposeExtrinsic' }));
expect(mockBountyApi.closeBounty).toHaveBeenCalledWith(aBountyIndex(0));
});
it('Not available when close bounty motion already exists', async () => {
@ -371,6 +371,31 @@ describe('Bounties', () => {
});
});
describe('Accept curator modal', () => {
it('creates extrinsic', async () => {
const bounty = aBounty({ fee: balanceOf(20), status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) });
const { findByRole, findByTestId, findByText } = renderOneBounty(bounty);
await clickButtonWithName('Accept', findByRole);
expect(await findByText('This action will accept your candidacy for the curator of the bounty.')).toBeTruthy();
const fee = (await findByTestId("curator's fee")).getAttribute('value');
expect(fee).toEqual('20.0000');
const deposit = (await findByTestId("curator's deposit")).getAttribute('value');
expect(deposit).toEqual('10.0000');
await clickButtonWithName('Accept Curator Role', findByRole);
expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: bob }));
expect(mockBountyApi.acceptCurator).toHaveBeenCalledWith(aBountyIndex(0));
});
});
describe('extend bounty expiry action modal', () => {
it('queues extend bounty expiry extrinsic on submit', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });

View File

@ -122,6 +122,7 @@ function Bounty ({ bestNumber, bounty, className = '', description, index, propo
<BountyActions
bestNumber={bestNumber}
description={description}
fee={fee}
index={index}
proposals={proposals}
status={status}

View File

@ -1,41 +0,0 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@polkadot/types/interfaces';
import React, { useMemo } from 'react';
import { TxButton } from '@polkadot/react-components';
import { useAccounts } from '@polkadot/react-hooks';
import { useBounties } from './hooks';
import { useTranslation } from './translate';
interface Props {
curatorId: AccountId;
index: BountyIndex;
}
function BountyAcceptCurator ({ curatorId, index }: Props) {
const { t } = useTranslation();
const { acceptCurator } = useBounties();
const { allAccounts } = useAccounts();
const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]);
return (
<>
{isCurator &&
<TxButton
accountId={curatorId}
icon='check'
label={t<string>('Accept')}
params={[index]}
tx={acceptCurator}
/>
}
</>
);
}
export default React.memo(BountyAcceptCurator);

View File

@ -0,0 +1,107 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@polkadot/types/interfaces';
import BN from 'bn.js';
import React, { useMemo } from 'react';
import { Button, InputAddress, InputBalance, Modal, TxButton } from '@polkadot/react-components';
import { useToggle } from '@polkadot/react-hooks';
import { permillOf } from '../helpers';
import { useBounties, useUserRole } from '../hooks';
import { useTranslation } from '../translate';
interface Props {
curatorId: AccountId;
fee: BN;
index: BountyIndex;
}
function BountyAcceptCurator ({ curatorId, fee, index }: Props) {
const { t } = useTranslation();
const { acceptCurator } = useBounties();
const { isCurator } = useUserRole(curatorId);
const { bountyCuratorDeposit } = useBounties();
const [isOpen, toggleOpen] = useToggle();
const deposit = useMemo(() => permillOf(fee, bountyCuratorDeposit), [fee, bountyCuratorDeposit]);
return isCurator
? (
<>
<Button
icon='check'
isDisabled={false}
label={t<string>('Accept')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={t<string>('accept curator role')}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{t<string>('This action will accept your candidacy for the curator of the bounty.')}</p>
</Modal.Column>
<Modal.Columns>
<Modal.Column>
<InputAddress
help={t<string>('This account will be used to create the accept curator transaction.')}
isDisabled
label={t<string>('curator account')}
type='account'
value={curatorId.toString()}
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('Only the account proposed as curator by the council can create the assign curator transaction ')}</p>
</Modal.Column>
</Modal.Columns>
<Modal.Columns>
<Modal.Column>
<InputBalance
defaultValue={fee.toString()}
isDisabled
label={t<string>("curator's fee")}
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('This amount will be sent to your account on bounty claim.')}</p>
</Modal.Column>
</Modal.Columns>
<Modal.Columns>
<Modal.Column>
<InputBalance
defaultValue={deposit.toString()}
help={t<string>("Curator's deposit depends on bounty fee.")}
isDisabled
label={t<string>("curator's deposit")}
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('This amount will be reserved from your account and returned on bounty claim or if you give up, unless you are slashed earlier.')}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={curatorId}
icon='check'
label={t<string>('Accept Curator Role')}
onStart={toggleOpen}
params={[index]}
tx={acceptCurator}
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
}
export default React.memo(BountyAcceptCurator);

View File

@ -4,11 +4,12 @@
import type { DeriveCollectiveProposal } from '@polkadot/api-derive/types';
import type { Balance, BlockNumber, BountyIndex, BountyStatus } from '@polkadot/types/interfaces';
import BN from 'bn.js';
import React, { useMemo } from 'react';
import BountyAcceptCurator from '../BountyAcceptCurator';
import { useBountyStatus } from '../hooks';
import AwardBounty from './AwardBounty';
import BountyAcceptCurator from './BountyAcceptCurator';
import BountyClaimAction from './BountyClaimAction';
import BountyInitiateVoting from './BountyInitiateVoting';
import ProposeCuratorAction from './ProposeCuratorAction';
@ -16,13 +17,14 @@ import ProposeCuratorAction from './ProposeCuratorAction';
interface Props {
bestNumber: BlockNumber;
description: string;
fee?: BN;
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
status: BountyStatus;
value: Balance;
}
export function BountyActions ({ bestNumber, description, index, proposals, status, value }: Props): JSX.Element {
export function BountyActions ({ bestNumber, description, fee, index, proposals, status, value }: Props): JSX.Element {
const { beneficiary, curator, unlockAt } = useBountyStatus(status);
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
@ -43,9 +45,10 @@ export function BountyActions ({ bestNumber, description, index, proposals, stat
value={value}
/>
}
{status.isCuratorProposed && curator &&
{status.isCuratorProposed && curator && fee &&
<BountyAcceptCurator
curatorId={curator}
fee={fee}
index={index}
/>
}

View File

@ -6,3 +6,4 @@ export * from './getBountyStatus';
export * from './isClaimable';
export * from './stringHelpers';
export * from './determineUnassignCuratorAction';
export * from './permillOf';

View File

@ -0,0 +1,8 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import BN from 'bn.js';
export function permillOf (value: BN, perMill: BN): BN {
return value.mul(perMill).div(new BN(1_000_000));
}

View File

@ -15,6 +15,7 @@ export type BountyApi = {
awardBounty: ((...args: any[]) => SubmittableExtrinsic<'promise'>);
bestNumber?: BlockNumber,
bounties?: DeriveBounties,
bountyCuratorDeposit: BN,
bountyDepositBase: BN,
bountyIndex?: BN,
bountyUpdatePeriod?: BN,
@ -35,6 +36,7 @@ export function useBounties (): BountyApi {
const bountyIndex = useCall<BountyIndex>((api.query.bounties || api.query.treasury).bountyCount);
const bestNumber = useCall<BlockNumber>(api.derive.chain.bestNumber);
const constsBase = api.consts.bounties || api.consts.treasury;
const bountyCuratorDeposit = (constsBase.bountyCuratorDeposit as BalanceOf).toBn();
const bountyDepositBase = (constsBase.bountyDepositBase as BalanceOf).toBn();
const bountyValueMinimum = (constsBase.bountyValueMinimum as BalanceOf).toBn();
const maximumReasonLength = constsBase.maximumReasonLength.toNumber();
@ -56,6 +58,7 @@ export function useBounties (): BountyApi {
awardBounty,
bestNumber,
bounties,
bountyCuratorDeposit,
bountyDepositBase,
bountyIndex,
bountyUpdatePeriod,

View File

@ -17,6 +17,7 @@ export const defaultBountyApi: BountyApi = {
awardBounty: jest.fn().mockReturnValue('mockAwardExtrinsic'),
bestNumber: new BN(1) as BlockNumber,
bounties: [] as DeriveBounties,
bountyCuratorDeposit: balanceOf(500000),
bountyDepositBase: new BN(1),
bountyUpdatePeriod: defaultBountyUpdatePeriod,
bountyValueMinimum: new BN(1),

View File

@ -39,6 +39,6 @@ export class BountyFactory {
public bountyWith = ({ status = 'Proposed', value = 1 } = {}): Bounty =>
this.aBounty({ status: this.aBountyStatus(status), value: balanceOf(value) })
public aBounty = ({ status = this.aBountyStatus('Proposed'), value = balanceOf(1) }: Partial<Bounty> = {}): Bounty =>
this.#registry.createType('Bounty', { status, value });
public aBounty = ({ fee = balanceOf(10), status = this.aBountyStatus('Proposed'), value = balanceOf(500) }: Partial<Bounty> = {}): Bounty =>
this.#registry.createType('Bounty', { fee, status, value });
}