Bounties - Hide bounty unhappy path actions (#4500)

This commit is contained in:
Ewa Kowalska 2021-01-28 10:18:57 +01:00 committed by GitHub
parent 2a26df1419
commit ccf44f650b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 614 additions and 373 deletions

View File

@ -40,18 +40,21 @@
"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 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",
"Proposed Curator": "Proposed Curator",
"Proposer": "Proposer",
"Proposer bond depends on bounty title length.": "Proposer bond depends on bounty title length.",
"Reject": "Reject",
"Reject Curator": "Reject Curator",
"Rejection under voting": "Rejection under voting",
"Select an account which (after a successful vote) will act as a curator.": "Select an account which (after a successful vote) will act as a curator.",
"Select the account you wish to propose the bounty from.": "Select the account you wish to propose the bounty from.",
"Select the council account you wish to use to create a motion for the Bounty.": "Select the council account you wish to use to create a motion for the Bounty.",
"Select the council account you wish to use to create a proposal for closing bounty.": "Select the council account you wish to use to create a proposal for closing bounty.",
"Slash Curator": "Slash Curator",
"Slash Curator (Council)": "Slash Curator (Council)",
"Slash curator": "Slash curator",
"The Curator account that will be used to send this transaction. Any applicable fees will be paid by this account.": "The Curator account that will be used to send this transaction. Any applicable fees will be paid by this account.",
"The Curator account that will give up on it's role": "The Curator account that will give up on it's role",
@ -68,6 +71,7 @@
"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 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 award the Beneficiary and close the bounty after a delay.": "This action will award the Beneficiary and close the bounty after a delay.",
@ -77,6 +81,7 @@
"This action will create a Council motion to unassign the Curator.": "This action will create a Council motion to unassign the Curator.",
"This action will create a Council proposal to close the Bounty.": "This action will create a Council proposal to close the Bounty.",
"This action will extend expiry time of the selected bounty.": "This action will extend expiry time of the selected bounty.",
"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 bounty has received approval and is currently being implemented.": "This bounty has received approval and is currently being implemented.",
@ -87,8 +92,11 @@
"Unassign Curator": "Unassign Curator",
"Unassign curator": "Unassign curator",
"Unassign curator under voting": "Unassign curator under voting",
"Value": "Value",
"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.",
"active": "active",
"beneficiary": "beneficiary",
"bounties": "bounties",
"award bounty": "award bounty",
"award with account": "award with account",
"beneficiary account": "beneficiary account",
@ -105,14 +113,17 @@
"next bounty funding in": "next bounty funding in",
"next burn": "next burn",
"past": "past",
"payout due": "payout due",
"propose with account": "propose with account",
"proposing account": "proposing account",
"reject curator": "reject curator",
"select curator": "select curator",
"spend period": "spend period",
"status": "status",
"submit with account": "submit with account",
"title": "title",
"treasury": "treasury",
"update due": "update due",
"value": "value",
"vote with account": "vote with account",
"{{blocks}} blocks": "{{blocks}} blocks"

View File

@ -95,6 +95,7 @@
"Settings": "",
"Show address on hardware device": "",
"Slash Curator": "",
"Slash Curator (Council)": "",
"Slash curator": "",
"Slashed": "",
"Specified payment account": "",

View File

@ -27,7 +27,16 @@ import { TypeRegistry } from '@polkadot/types/create';
import { keyring } from '@polkadot/ui-keyring';
import { extractTime } from '@polkadot/util';
import { alice, bob, defaultBalance, defaultBountyApi, defaultMembers, defaultTreasury, ferdie } from '../test/hooks/defaults';
import { alice,
bob,
defaultBalance,
defaultBountyApi,
defaultMembers,
defaultTreasury,
ferdie } from '../test/hooks/defaults';
import { clickButtonWithName } from '../test/utils/clickButtonWithName';
import { clickElementWithTestId } from '../test/utils/clickElementWithTestId';
import { clickElementWithText } from '../test/utils/clickElementWithText';
import Bounties from './Bounties';
import { BountyApi } from './hooks';
@ -35,10 +44,14 @@ const mockMembers = defaultMembers;
const mockTreasury = defaultTreasury;
let mockBountyApi = defaultBountyApi;
let mockBalance = defaultBalance;
const mockBlockTime = [50, '', extractTime(1)];
jest.mock('./hooks', () => ({
useBalance: () => mockBalance,
jest.mock('./hooks/useBalance', () => ({
useBalance: () => mockBalance
}));
jest.mock('./hooks/useBounties', () => ({
useBounties: () => mockBountyApi
}));
@ -80,7 +93,7 @@ let aBountyStatus: (status: string) => BountyStatus;
let aBountyIndex: (index?:number) => BountyIndex;
let aBounty: ({ status, value }?: Partial<Bounty>) => Bounty;
let bountyWith: ({ status, value }: { status?: string, value?: number }) => Bounty;
let bountyStatusWith: ({ curator, status }: { curator?: string, status?: string, }) => BountyStatus;
let bountyStatusWith: ({ curator, status, updateDue }: { curator?: string, status?: string, updateDue?: number}) => BountyStatus;
describe('Bounties', () => {
beforeAll(async () => {
@ -215,14 +228,13 @@ describe('Bounties', () => {
});
it('validates balance is enough for bond', async () => {
const { findByTestId, findByText, queryByText } = renderBounties(
const { findByRole, findByTestId, findByText, queryByText } = renderBounties(
{ bountyDepositBase: new BN(10), dataDepositPerByte: new BN(1) },
{ balance: 10 }
);
const addBountyButton = await findByText('Add Bounty');
await clickButtonWithName('Add Bounty', findByRole);
fireEvent.click(addBountyButton);
expect(await findByText('Description of the Bounty (to be stored on-chain)')).toBeTruthy(); // wait for load
expect(queryByText('Account does not have enough funds.')).toBeFalsy();
@ -301,11 +313,12 @@ describe('Bounties', () => {
describe('close bounty modal', () => {
it('creates closeBounty proposal', async () => {
const bounty = bountyWith({ status: 'Funded' });
const { findByText, getByRole } = renderOneBounty(bounty);
const { findByTestId, findByText, getByRole } = renderOneBounty(bounty);
const closeButton = await findByText('Close');
await clickElementWithTestId('extra-actions', findByTestId);
await clickElementWithText('Close', findByText);
fireEvent.click(closeButton);
expect(await findByText('This action will create a Council proposal to close the Bounty.')).toBeTruthy();
const combobox = getByRole('combobox');
@ -323,9 +336,36 @@ describe('Bounties', () => {
const bounty = bountyWith({ status: 'Funded' });
const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))];
const { findByText } = renderOneBounty(bounty, proposals);
const { findByTestId } = renderOneBounty(bounty, proposals);
await expect(findByText('Close')).rejects.toThrow();
await expect(findByTestId('extra-actions')).rejects.toThrow();
});
});
describe('Reject curator modal', () => {
it('creates extrinsic', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) });
const { findByRole, findByTestId, findByText } = renderOneBounty(bounty);
await clickElementWithTestId('extra-actions', findByTestId);
await clickElementWithText('Reject Curator', findByText);
await clickButtonWithName('Reject', findByRole);
expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: bob }));
});
it('shows options for all roles', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'Active' }) });
const { findByTestId, findByText } = renderOneBounty(bounty);
await clickElementWithTestId('extra-actions', findByTestId);
expect(await findByText('Give Up')).toBeTruthy();
expect(await findByText('Slash Curator (Council)')).toBeTruthy();
});
});
@ -333,11 +373,11 @@ describe('Bounties', () => {
it('queues extend bounty expiry extrinsic on submit', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
const { findByTestId, findByText } = renderOneBounty(bounty);
const { findByRole, findByTestId, findByText } = renderOneBounty(bounty);
const extendBountyExpiryButton = await findByText('Extend Expiry');
await clickElementWithTestId('extra-actions', findByTestId);
fireEvent.click(extendBountyExpiryButton);
await clickElementWithText('Extend Expiry', findByText);
expect(await findByText('This action will extend expiry time of the selected bounty.')).toBeTruthy();
@ -345,9 +385,8 @@ describe('Bounties', () => {
fireEvent.change(remarkInput, { target: { value: 'The bounty extend expiry remark' } });
const acceptButton = await findByText('Accept');
await clickButtonWithName('Accept', findByRole);
fireEvent.click(acceptButton);
expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockExtendExtrinsic' }));
expect(mockBountyApi.extendBountyExpiry).toHaveBeenCalledWith(aBountyIndex(0), 'The bounty extend expiry remark');
});
@ -356,11 +395,11 @@ describe('Bounties', () => {
describe('slash curator action modal', () => {
it('give up on a Curator function in Active state bounty', async () => {
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
const { findByText, getAllByRole } = renderOneBounty(bounty);
const { findByRole, findByTestId, findByText, getAllByRole } = renderOneBounty(bounty);
const slashCuratorButton = await findByText('Give Up');
await clickElementWithTestId('extra-actions', findByTestId);
fireEvent.click(slashCuratorButton);
await clickElementWithText('Give Up', findByText);
expect(await findByText('This action will unassign you from a curator role.')).toBeTruthy();
@ -370,9 +409,7 @@ describe('Bounties', () => {
fireEvent.change(proposingAccountInput, { target: { value: alice } });
const acceptButton = await findByText('Approve');
fireEvent.click(acceptButton);
await clickButtonWithName('Approve', findByRole);
expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockUnassignExtrinsic' }));
expect(mockBountyApi.unassignCurator).toHaveBeenCalledWith(aBountyIndex(0));
@ -380,11 +417,12 @@ describe('Bounties', () => {
it('for bounty in PendingPayout state', async () => {
const bounty = bountyWith({ status: 'PendingPayout' });
const { findByText, getAllByRole } = renderOneBounty(bounty);
const slashCuratorButton = await findByText('Slash Curator');
const { findByRole, findByTestId, findByText, getAllByRole } = renderOneBounty(bounty);
fireEvent.click(slashCuratorButton);
await clickElementWithTestId('extra-actions', findByTestId);
await clickElementWithText('Slash Curator (Council)', findByText);
expect(await findByText('This action will create a Council motion to slash the Curator.')).toBeTruthy();
@ -393,10 +431,9 @@ describe('Bounties', () => {
const proposingAccountInput = comboboxes[0].children[0];
fireEvent.change(proposingAccountInput, { target: { value: alice } });
fireEvent.keyDown(proposingAccountInput, { code: 'Enter', key: 'Enter' });
const acceptButton = await findByText('Approve');
fireEvent.click(acceptButton);
await clickButtonWithName('Approve', findByRole);
expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockProposeExtrinsic' }));
});
@ -581,7 +618,7 @@ describe('Bounties', () => {
describe('Beneficiary description', () => {
it('PendingPayout status', async () => {
const bounty = bountyWith({ status: 'PendingPayout' });
const proposals = [aProposal(augmentedApi.tx.bounties.awardBounty(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z'))];
const proposals = [aProposal(augmentedApi.tx.bounties.awardBounty(0, alice))];
const { findByText } = renderOneBounty(bounty, proposals);

View File

@ -16,10 +16,11 @@ import { formatNumber } from '@polkadot/util';
import { getProposalToDisplay } from './helpers/extendedStatuses';
import VotingResultsColumn from './Voting/VotersColumn';
import { BountyActions } from './BountyActions';
import BountyExtraActions from './BountyExtraActions';
import BountyInfos from './BountyInfos';
import BountyStatusView from './BountyStatusView';
import Curator from './Curator';
import { getBountyStatus } from './helpers';
import { useBountyStatus } from './hooks';
import { useTranslation } from './translate';
interface Props {
@ -43,10 +44,7 @@ function Bounty ({ bestNumber, bounty, className = '', description, index, propo
const [isExpanded, setIsExpanded] = useState(false);
const { bond, curatorDeposit, fee, proposer, status, value } = bounty;
const updateStatus = useCallback(() => getBountyStatus(status), [status]);
const { beneficiary, bountyStatus, curator, unlockAt, updateDue } = updateStatus();
const { beneficiary, bountyStatus, curator, unlockAt, updateDue } = useBountyStatus(status);
const blocksUntilUpdate = useMemo(() => updateDue?.sub(bestNumber), [bestNumber, updateDue]);
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
@ -137,6 +135,13 @@ function Bounty ({ bestNumber, bounty, className = '', description, index, propo
isLogo
type='bounty'
/>
<BountyExtraActions
bestNumber={bestNumber}
description={description}
index={index}
proposals={proposals}
status={status}
/>
<div className='table-column-icon'
onClick={handleOnIconClick}>
<Icon icon={
@ -249,28 +254,6 @@ export default React.memo(styled(Bounty)(({ theme }: ThemeProps) => `
border-radius: 4px;
cursor: pointer;
}
.settings-button {
width: 24px;
height: 24px;
padding: 0;
border-radius: 4px;
svg {
padding: 0;
margin: 0;
color: #000 !important;
}
&:hover {
background: #fff;
}
&:focus {
background: #fff;
border: 1px solid #616161;
}
}
}
& .inline-balance {

View File

@ -8,24 +8,24 @@ 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';
import { useBounties } from './hooks';
import { useTranslation } from './translate';
interface Props {
curatorId: AccountId;
index: BountyIndex;
}
function BountyCuratorProposedActions ({ curatorId, index }: Props) {
function BountyAcceptCurator ({ curatorId, index }: Props) {
const { t } = useTranslation();
const { acceptCurator, unassignCurator } = useBounties();
const { acceptCurator } = useBounties();
const { allAccounts } = useAccounts();
const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]);
return isCurator
? (
<>
return (
<>
{isCurator &&
<TxButton
accountId={curatorId}
icon='check'
@ -33,16 +33,9 @@ function BountyCuratorProposedActions ({ curatorId, index }: Props) {
params={[index]}
tx={acceptCurator}
/>
<TxButton
accountId={curatorId}
icon='times'
label={t<string>('Reject')}
params={[index]}
tx={unassignCurator}
/>
</>
)
: null;
}
</>
);
}
export default React.memo(BountyCuratorProposedActions);
export default React.memo(BountyAcceptCurator);

View File

@ -1,30 +1,28 @@
// Copyright 2017-2021 @polkadot/app-treasury authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@polkadot/api-derive/types';
import type { BountyIndex } from '@polkadot/types/interfaces';
import BN from 'bn.js';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { getTreasuryProposalThreshold } from '@polkadot/apps-config';
import { Button, InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useApi, useMembers, useToggle } from '@polkadot/react-hooks';
import { InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useApi, useMembers } from '@polkadot/react-hooks';
import { useBounties } from '../hooks';
import { useTranslation } from '../translate';
interface Props {
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
toggleOpen: () => void;
}
function CloseBounty ({ index, proposals }: Props): React.ReactElement<Props> | null {
function CloseBounty ({ index, toggleOpen }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { isMember, members } = useMembers();
const { members } = useMembers();
const { closeBounty } = useBounties();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [threshold, setThreshold] = useState<BN>();
@ -36,58 +34,44 @@ function CloseBounty ({ index, proposals }: Props): React.ReactElement<Props> |
const closeBountyProposal = useRef(closeBounty(index));
const existingCloseBountyProposal = useMemo(() => proposals?.find(({ proposal }) => proposal.method === 'closeBounty'), [proposals]);
return isMember && !existingCloseBountyProposal
? (
<>
<Button
icon='step-forward'
return (
<Modal
header={t<string>('close bounty')}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{t<string>('This action will create a Council proposal to close the Bounty.')}</p>
</Modal.Column>
<Modal.Columns>
<Modal.Column>
<InputAddress
filter={members}
help={t<string>('Select the council account you wish to use to create a proposal for closing bounty.')}
label={t<string>('propose with account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('The council member that will create the close bounty proposal, submission equates to an "aye" vote.')}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={accountId}
icon='ban'
isDisabled={false}
label={t<string>('Close')}
onClick={toggleOpen}
label={t<string>('Close Bounty')}
onStart={toggleOpen}
params={[threshold, closeBountyProposal.current, closeBountyProposal.current.length]}
tx={api.tx.council.propose}
/>
{isOpen && (
<Modal
header={t<string>('close bounty')}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{t<string>('This action will create a Council proposal to close the Bounty.')}</p>
</Modal.Column>
<Modal.Columns>
<Modal.Column>
<InputAddress
filter={members}
help={t<string>('Select the council account you wish to use to create a proposal for closing bounty.')}
label={t<string>('propose with account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('The council member that will create the close bounty proposal, submission equates to an "aye" vote.')}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={accountId}
icon='ban'
isDisabled={false}
label={t<string>('Close Bounty')}
onStart={toggleOpen}
params={[threshold, closeBountyProposal.current, closeBountyProposal.current.length]}
tx={api.tx.council.propose}
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
</Modal.Actions>
</Modal>
);
}
export default React.memo(CloseBounty);

View File

@ -3,97 +3,84 @@
import type { AccountId, BountyIndex } from '@polkadot/types/interfaces';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Button, Input, InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useAccounts, useToggle } from '@polkadot/react-hooks';
import { Input, InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { truncateTitle } from '../helpers';
import { useBounties } from '../hooks';
import { useTranslation } from '../translate';
interface Props {
curatorId: AccountId;
description: string
index: BountyIndex;
curatorId: AccountId;
toggleOpen: () => void;
}
function ExtendBountyExpiryAction ({ curatorId, description, index }: Props): React.ReactElement<Props> | null {
function ExtendBountyExpiryAction ({ curatorId, description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { extendBountyExpiry } = useBounties();
const [isOpen, toggleOpen] = useToggle();
const { allAccounts } = useAccounts();
const [remark, setRemark] = useState('');
const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]);
const onRemarkChange = useCallback((value: string) => {
setRemark(value);
}, []);
return isCurator
? (
<>
<Button
icon='calendar-plus'
isDisabled={false}
label={t<string>('Extend Expiry')}
onClick={toggleOpen}
/>
{isOpen && (
<Modal
header={t<string>(`Extend Expiry of "${truncateTitle(description, 30)}"`)}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{t<string>('This action will extend expiry time of the selected bounty.')}</p>
</Modal.Column>
<Modal.Columns>
<Modal.Column>
<InputAddress
defaultValue={curatorId.toString()}
help={t<string>('This account will be used to create an extend bounty expire transaction.')}
isDisabled
label={t<string>('curator account')}
type='account'
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>('Only curator can extend the bounty time.')}</p>
</Modal.Column>
</Modal.Columns>
<Modal.Columns>
<Modal.Column>
<Input
autoFocus
defaultValue={''}
help={t<string>('The note on the extension.')}
label={t<string>('bounty remark')}
onChange={onRemarkChange}
value={remark}
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>("The note that will be added to the transaction. It won't be stored on chain")}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={curatorId}
icon='check'
label={t<string>('Accept')}
onStart={toggleOpen}
params={[index, remark]}
tx={extendBountyExpiry}
return (
<>
<Modal
header={t<string>(`Extend Expiry of "${truncateTitle(description, 30)}"`)}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{t<string>('This action will extend expiry time of the selected bounty.')}</p>
</Modal.Column>
<Modal.Columns>
<Modal.Column>
<InputAddress
help={t<string>('This account will be used to create an extend bounty expire transaction.')}
isDisabled
label={t<string>('curator account')}
type='account'
value={curatorId.toString()}
withLabel
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
</Modal.Column>
<Modal.Column>
<p>{t<string>('Only curator can extend the bounty time.')}</p>
</Modal.Column>
</Modal.Columns>
<Modal.Columns>
<Modal.Column>
<Input
autoFocus
defaultValue={''}
help={t<string>('The note on the extension.')}
label={t<string>('bounty remark')}
onChange={onRemarkChange}
value={remark}
/>
</Modal.Column>
<Modal.Column>
<p>{t<string>("The note that will be added to the transaction. It won't be stored on chain")}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={curatorId}
icon='check'
label={t<string>('Accept')}
onStart={toggleOpen}
params={[index, remark]}
tx={extendBountyExpiry}
/>
</Modal.Actions>
</Modal>
</>
);
}
export default React.memo(ExtendBountyExpiryAction);

View File

@ -1,34 +1,30 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveCollectiveProposal } from '@polkadot/api-derive/types';
import type { AccountId, BountyIndex, BountyStatus } from '@polkadot/types/interfaces';
import type { AccountId, BountyIndex } from '@polkadot/types/interfaces';
import BN from 'bn.js';
import React, { useEffect, useMemo, useState } from 'react';
import { SubmittableExtrinsic } from '@polkadot/api/types';
import { getTreasuryProposalThreshold } from '@polkadot/apps-config';
import { Button, InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useAccounts, useApi, useMembers, useToggle } from '@polkadot/react-hooks';
import { InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useAccounts, useApi, useMembers } from '@polkadot/react-hooks';
import { determineUnassignCuratorAction, truncateTitle } from '../helpers';
import { truncateTitle } from '../helpers';
import { useBounties } from '../hooks';
import { useUserRole } from '../hooks/useUserRole';
import { useTranslation } from '../translate';
import { ValidUnassignCuratorAction } from '../types';
interface Props {
blocksUntilUpdate?: BN;
action: ValidUnassignCuratorAction;
curatorId: AccountId;
description: string;
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
status: BountyStatus;
toggleOpen: () => void;
}
interface ActionProperties {
buttonName: string;
filter: string[];
header: string;
helpMessage: string;
@ -39,14 +35,11 @@ interface ActionProperties {
tx: ((...args: any[]) => SubmittableExtrinsic<'promise'>);
}
const BOUNTY_METHODS = ['unassignCurator'];
function SlashCurator ({ blocksUntilUpdate, curatorId, description, index, proposals, status }: Props): React.ReactElement<Props> | null {
function SlashCurator ({ action, curatorId, description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const { api } = useApi();
const { members } = useMembers();
const { unassignCurator } = useBounties();
const [isOpen, toggleOpen] = useToggle();
const [accountId, setAccountId] = useState<string | null>(null);
const [threshold, setThreshold] = useState<BN>();
const { allAccounts } = useAccounts();
@ -61,7 +54,6 @@ function SlashCurator ({ blocksUntilUpdate, curatorId, description, index, propo
const actionProperties = useMemo<Record<ValidUnassignCuratorAction, ActionProperties>>(() => ({
GiveUp: {
buttonName: t('Give Up'),
filter: [curatorId.toString()],
header: t('This action will unassign you from a curator role.'),
helpMessage: t("The Curator account that will give up on it's role"),
@ -72,7 +64,6 @@ function SlashCurator ({ blocksUntilUpdate, curatorId, description, index, propo
tx: unassignCurator
},
SlashCuratorAction: {
buttonName: t('Slash Curator'),
filter: allAccounts,
header: t('This action will Slash the Curator.'),
helpMessage: t('The Curator that will be slashed.'),
@ -83,7 +74,6 @@ function SlashCurator ({ blocksUntilUpdate, curatorId, description, index, propo
tx: unassignCurator
},
SlashCuratorMotion: {
buttonName: t('Slash Curator'),
filter: members,
header: t('This action will create a Council motion to slash the Curator.'),
helpMessage: t('The Curator that will be slashed.'),
@ -94,7 +84,6 @@ function SlashCurator ({ blocksUntilUpdate, curatorId, description, index, propo
tx: api.tx.council.propose
},
UnassignCurator: {
buttonName: t('Unassign Curator'),
filter: members,
header: t('This action will create a Council motion to unassign the Curator.'),
helpMessage: t('The Curator that will be unassigned'),
@ -106,81 +95,60 @@ function SlashCurator ({ blocksUntilUpdate, curatorId, description, index, propo
}
}), [t, curatorId, index, unassignCurator, api.tx.council.propose, allAccounts, members, threshold, unassignCuratorProposal]);
const isVotingInitiated = useMemo(() => proposals?.filter(({ proposal }) => BOUNTY_METHODS.includes(proposal.method)).length !== 0, [proposals]);
const { filter, header, helpMessage, params, proposingAccountTip, tip, title, tx } = actionProperties[action];
const userRole = useUserRole(curatorId);
const action = determineUnassignCuratorAction(userRole, status, blocksUntilUpdate);
if (action === 'None') {
return null;
}
const { buttonName, filter, header, helpMessage, params, proposingAccountTip, tip, title, tx } = actionProperties[action];
return !isVotingInitiated
? (
<>
<Button
icon='user-slash'
label={buttonName}
onClick={toggleOpen}
return (
<Modal
header={`${title} - "${truncateTitle(description, 30)}"`}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{header}</p>
</Modal.Column>
<Modal.Columns>
<Modal.Column>
<InputAddress
filter={filter}
help={t<string>('The account that will sign the transaction.')}
label={t<string>('proposing account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{proposingAccountTip}</p>
</Modal.Column>
</Modal.Columns>
<Modal.Columns>
<Modal.Column>
<InputAddress
defaultValue={curatorId}
help={helpMessage}
isDisabled
label={t<string>('current curator')}
type='account'
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{tip}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={accountId}
icon='check'
label='Approve'
onStart={toggleOpen}
params={params}
tx={tx}
/>
{isOpen && (
<Modal
header={`${title} - "${truncateTitle(description, 30)}"`}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{header}</p>
</Modal.Column>
<Modal.Columns>
<Modal.Column>
<InputAddress
filter={filter}
help={t<string>('The account that will sign the transaction.')}
label={t<string>('proposing account')}
onChange={setAccountId}
type='account'
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{proposingAccountTip}</p>
</Modal.Column>
</Modal.Columns>
<Modal.Columns>
<Modal.Column>
<InputAddress
defaultValue={curatorId}
help={helpMessage}
isDisabled
label={t<string>('current curator')}
type='account'
withLabel
/>
</Modal.Column>
<Modal.Column>
<p>{tip}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={accountId}
icon='check'
label='Approve'
onStart={toggleOpen}
params={params}
tx={tx}
/>
</Modal.Actions>
</Modal>
)}
</>
)
: null;
</Modal.Actions>
</Modal>
);
}
export default React.memo(SlashCurator);

View File

@ -4,17 +4,14 @@
import type { DeriveCollectiveProposal } from '@polkadot/api-derive/types';
import type { Balance, BlockNumber, BountyIndex, BountyStatus } from '@polkadot/types/interfaces';
import React, { useCallback, useMemo } from 'react';
import React, { useMemo } from 'react';
import { getBountyStatus } from '../helpers';
import BountyAcceptCurator from '../BountyAcceptCurator';
import { useBountyStatus } from '../hooks';
import AwardBounty from './AwardBounty';
import BountyClaimAction from './BountyClaimAction';
import BountyCuratorProposedActions from './BountyCuratorProposedActions';
import BountyInitiateVoting from './BountyInitiateVoting';
import CloseBounty from './CloseBounty';
import ExtendBountyExpiryAction from './ExtendBountyExpiryAction';
import ProposeCuratorAction from './ProposeCuratorAction';
import SlashCurator from './SlashCurator';
interface Props {
bestNumber: BlockNumber;
@ -26,11 +23,8 @@ interface Props {
}
export function BountyActions ({ bestNumber, description, index, proposals, status, value }: Props): JSX.Element {
const updateStatus = useCallback(() => getBountyStatus(status), [status]);
const { beneficiary, curator, unlockAt } = useBountyStatus(status);
const { beneficiary, curator, unlockAt, updateDue } = updateStatus();
const blocksUntilUpdate = useMemo(() => updateDue?.sub(bestNumber), [bestNumber, updateDue]);
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
return (
@ -50,24 +44,11 @@ export function BountyActions ({ bestNumber, description, index, proposals, stat
/>
}
{status.isCuratorProposed && curator &&
<BountyCuratorProposedActions
<BountyAcceptCurator
curatorId={curator}
index={index}
/>
}
{status.isActive && curator &&
<ExtendBountyExpiryAction
curatorId={curator}
description={description}
index={index}
/>
}
{(status.isFunded || status.isActive || status.isCuratorProposed) &&
<CloseBounty
index={index}
proposals={proposals}
/>
}
{status.isPendingPayout && beneficiary && blocksUntilPayout &&
<BountyClaimAction
beneficiaryId={beneficiary}
@ -75,21 +56,11 @@ export function BountyActions ({ bestNumber, description, index, proposals, stat
payoutDue={blocksUntilPayout}
/>
}
{(status.isCuratorProposed || status.isActive || status.isPendingPayout) && curator && (
<SlashCurator
blocksUntilUpdate={blocksUntilUpdate}
curatorId={curator}
description={description}
index={index}
proposals={proposals}
status={status}
/>
)}
{status.isActive && curator &&
<AwardBounty
curatorId={curator}
index={index}
/>
<AwardBounty
curatorId={curator}
index={index}
/>
}
</>
);

View File

@ -0,0 +1,176 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import { DeriveCollectiveProposal } from '@polkadot/api-derive/types';
import { determineUnassignCuratorAction } from '@polkadot/app-bounties/helpers';
import { useBountyStatus, useUserRole } from '@polkadot/app-bounties/hooks';
import { ValidUnassignCuratorAction } from '@polkadot/app-bounties/types';
import { Button, Menu, Popup } from '@polkadot/react-components';
import { ThemeProps } from '@polkadot/react-components/types';
import { useMembers, useToggle } from '@polkadot/react-hooks';
import { BlockNumber, BountyIndex, BountyStatus } from '@polkadot/types/interfaces';
import CloseBounty from './BountyActions/CloseBounty';
import ExtendBountyExpiryAction from './BountyActions/ExtendBountyExpiryAction';
import SlashCurator from './BountyActions/SlashCurator';
import BountyRejectCurator from './BountyRejectCurator';
import { useTranslation } from './translate';
interface Props {
bestNumber: BlockNumber;
className?: string;
description: string;
index: BountyIndex;
proposals?: DeriveCollectiveProposal[];
status: BountyStatus;
}
function BountyExtraActions ({ bestNumber, className, description, index, proposals, status }: Props): React.ReactElement<Props> | null {
const [isPopupOpen, togglePopupOpen] = useToggle();
const [isCloseBountyOpen, toggleCloseBounty] = useToggle();
const [isRejectCuratorOpen, toggleRejectCurator] = useToggle();
const [isSlashCuratorOpen, toggleSlashCurator] = useToggle();
const [isExtendExpiryOpen, toggleExtendExpiry] = useToggle();
const [selectedAction, setSlashAction] = useState<ValidUnassignCuratorAction>();
const { t } = useTranslation();
const { isMember } = useMembers();
const { curator, updateDue } = useBountyStatus(status);
const blocksUntilUpdate = useMemo(() => updateDue?.sub(bestNumber), [bestNumber, updateDue]);
const { isCurator, roles } = useUserRole(curator);
const availableSlashActions = determineUnassignCuratorAction(roles, status, blocksUntilUpdate);
const slashCuratorActionNames = useRef<Record<ValidUnassignCuratorAction, string>>({
GiveUp: t('Give Up'),
SlashCuratorAction: t('Slash Curator'),
SlashCuratorMotion: t('Slash Curator (Council)'),
UnassignCurator: t('Unassign Curator')
});
const existingCloseBountyProposal = useMemo(() => proposals?.find(({ proposal }) => proposal.method === 'closeBounty'), [proposals]);
const existingUnassignCuratorProposal = useMemo(() => proposals?.find(({ proposal }) => proposal.method === 'unassignCurator'), [proposals]);
const showCloseBounty = (status.isFunded || status.isActive || status.isCuratorProposed) && isMember && !existingCloseBountyProposal;
const showRejectCurator = status.isCuratorProposed && isCurator;
const showExtendExpiry = status.isActive && isCurator;
const showSlashCurator = (status.isCuratorProposed || status.isActive || status.isPendingPayout) && !existingUnassignCuratorProposal && availableSlashActions.length !== 0;
const hasNoItems = !(showCloseBounty || showRejectCurator || showExtendExpiry || showSlashCurator);
function slashCuratorClicked (action: ValidUnassignCuratorAction) {
setSlashAction(action);
toggleSlashCurator();
}
return !hasNoItems
? (
<div className={className}>
{isCloseBountyOpen &&
<CloseBounty
index={index}
toggleOpen={toggleCloseBounty}
/>
}
{isRejectCuratorOpen && curator &&
<BountyRejectCurator
curatorId={curator}
index={index}
toggleOpen={toggleRejectCurator}
/>
}
{isExtendExpiryOpen && curator &&
<ExtendBountyExpiryAction
curatorId={curator}
description={description}
index={index}
toggleOpen={toggleExtendExpiry}
/>
}
{isSlashCuratorOpen && curator && selectedAction &&
<SlashCurator
action={selectedAction}
curatorId={curator}
description={description}
index={index}
toggleOpen={toggleSlashCurator}
/>
}
<Popup
isOpen={isPopupOpen}
onClose={togglePopupOpen}
trigger={
<Button
className='settings-button'
dataTestId='extra-actions'
icon='ellipsis-v'
onClick={togglePopupOpen}
/>
}
>
<Menu
className='settings-menu'
onClick={togglePopupOpen}
text
vertical
>
{showCloseBounty &&
<Menu.Item
key='closeBounty'
onClick={toggleCloseBounty}
>
{t<string>('Close')}
</Menu.Item>
}
{showRejectCurator &&
<Menu.Item
key='rejectCurator'
onClick={toggleRejectCurator}
>
{t<string>('Reject Curator')}
</Menu.Item>
}
{showExtendExpiry &&
<Menu.Item
key='extendExpiry'
onClick={toggleExtendExpiry}
>
{t<string>('Extend Expiry')}
</Menu.Item>
}
{showSlashCurator && availableSlashActions.map((actionName) =>
<Menu.Item
key={actionName}
onClick={() => slashCuratorClicked(actionName)}
>
{slashCuratorActionNames.current[actionName]}
</Menu.Item>
)}
</Menu>
</Popup>
</div>
)
: null;
}
export default React.memo(styled(BountyExtraActions)(({ theme }: ThemeProps) => `
.settings-button {
width: 24px;
height: 24px;
padding: 0;
border-radius: 4px;
svg {
padding: 0;
margin: 0;
color: ${theme.theme === 'dark' ? 'rgba(244,242,240,0.9)' : '#000 !important'};
}
&:focus {
border: 1px solid #616161;
}
}
`));

View File

@ -0,0 +1,62 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountId, BountyIndex } from '@polkadot/types/interfaces';
import React from 'react';
import { useBounties } from '@polkadot/app-bounties/hooks';
import { InputAddress, Modal, TxButton } from '@polkadot/react-components';
import { useTranslation } from './translate';
interface Props {
curatorId: AccountId;
index: BountyIndex;
toggleOpen: () => void;
}
function BountyRejectCurator ({ curatorId, index, toggleOpen }: Props) {
const { t } = useTranslation();
const { unassignCurator } = useBounties();
return (
<Modal
header={t<string>('reject curator')}
size='large'
>
<Modal.Content>
<Modal.Column>
<p>{t<string>('This action will reject 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 unassign 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 unassign curator transaction ')}</p>
</Modal.Column>
</Modal.Columns>
</Modal.Content>
<Modal.Actions onCancel={toggleOpen}>
<TxButton
accountId={curatorId}
icon='times'
label={t<string>('Reject')}
onStart={toggleOpen}
params={[index]}
tx={unassignCurator}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(BountyRejectCurator);

View File

@ -47,8 +47,8 @@ function VotersColumn ({ className, option, proposals, status }: Props): React.R
<div className='vote-numbers'>
<span className='vote-numbers-icon'><Icon icon={icons[option]} /></span>
<span className='vote-numbers-label'>
{option === 'ayes' && t('Aye: {{count}}', { replace: { count: votes && votes.length } })}
{option === 'nays' && t('Nay: {{count}}', { replace: { count: votes && votes.length } })}
{option === 'ayes' && t('Aye: {{count}}', { replace: { count: votes ? votes.length : 0 } })}
{option === 'nays' && t('Nay: {{count}}', { replace: { count: votes ? votes.length : 0 } })}
</span>
</div>
{voters}

View File

@ -12,50 +12,56 @@ describe('adjust slash curator component for', () => {
const { aBountyStatus } = new BountyFactory(augmentedApi);
it('Member in Active state', () => {
const displayAs = determineUnassignCuratorAction('Member', aBountyStatus('Active'));
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('Active'));
expect(displayAs).toEqual('SlashCuratorMotion');
expect(displayAs).toEqual(['SlashCuratorMotion']);
});
it('Member in CuratorProposed state', () => {
const displayAs = determineUnassignCuratorAction('Member', aBountyStatus('CuratorProposed'));
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('CuratorProposed'));
expect(displayAs).toEqual('UnassignCurator');
expect(displayAs).toEqual(['UnassignCurator']);
});
it('Member in PendingPayout state', () => {
const displayAs = determineUnassignCuratorAction('Member', aBountyStatus('PendingPayout'));
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('PendingPayout'));
expect(displayAs).toEqual('SlashCuratorMotion');
expect(displayAs).toEqual(['SlashCuratorMotion']);
});
it('Curator in Active state', () => {
const displayAs = determineUnassignCuratorAction('Curator', aBountyStatus('Active'));
const displayAs = determineUnassignCuratorAction(['Curator'], aBountyStatus('Active'));
expect(displayAs).toEqual('GiveUp');
expect(displayAs).toEqual(['GiveUp']);
});
it('User in Active state with update due blocks remaining', () => {
const displayAs = determineUnassignCuratorAction('User', aBountyStatus('Active'), new BN('1'));
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('Active'), new BN('1'));
expect(displayAs).toEqual('None');
expect(displayAs).toEqual([]);
});
it('User in Active state with no updated state', () => {
const displayAs = determineUnassignCuratorAction('User', aBountyStatus('Active'), new BN('-1'));
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('Active'), new BN('-1'));
expect(displayAs).toEqual('SlashCuratorAction');
expect(displayAs).toEqual(['SlashCuratorAction']);
});
it('Member and User in Active state with no updated state', () => {
const displayAs = determineUnassignCuratorAction(['User', 'Member'], aBountyStatus('Active'), new BN('-1'));
expect(displayAs).toEqual(expect.arrayContaining(['SlashCuratorAction', 'SlashCuratorMotion']));
});
it('Curator in Active state', () => {
const displayAs = determineUnassignCuratorAction('Curator', aBountyStatus('PendingPayout'));
const displayAs = determineUnassignCuratorAction(['Curator'], aBountyStatus('PendingPayout'));
expect(displayAs).toEqual('None');
expect(displayAs).toEqual([]);
});
it('User in Active state', () => {
const displayAs = determineUnassignCuratorAction('User', aBountyStatus('PendingPayout'));
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('PendingPayout'));
expect(displayAs).toEqual('None');
expect(displayAs).toEqual([]);
});
});

View File

@ -7,30 +7,32 @@ import BN from 'bn.js';
import { BN_ZERO } from '@polkadot/util';
import { UnassignCuratorAction, UserRole } from '../types';
import { UserRole, ValidUnassignCuratorAction } from '../types';
export function determineUnassignCuratorAction (role: UserRole, status: BountyStatus, blocksUntilUpdate?: BN): UnassignCuratorAction {
if (status.isCuratorProposed && role === 'Member') {
return 'UnassignCurator';
export function determineUnassignCuratorAction (roles: UserRole[], status: BountyStatus, blocksUntilUpdate?: BN): ValidUnassignCuratorAction[] {
const actions: ValidUnassignCuratorAction[] = [];
if (status.isCuratorProposed && roles.includes('Member')) {
actions.push('UnassignCurator');
}
if (status.isActive) {
if (role === 'Curator') {
return 'GiveUp';
if (roles.includes('Curator')) {
actions.push('GiveUp');
}
if (role === 'Member') {
return 'SlashCuratorMotion';
if (roles.includes('Member')) {
actions.push('SlashCuratorMotion');
}
if (role === 'User' && blocksUntilUpdate && blocksUntilUpdate.lt(BN_ZERO)) {
return 'SlashCuratorAction';
if (roles.includes('User') && blocksUntilUpdate && blocksUntilUpdate.lt(BN_ZERO)) {
actions.push('SlashCuratorAction');
}
}
if (status.isPendingPayout && role === 'Member') {
return 'SlashCuratorMotion';
if (status.isPendingPayout && roles.includes('Member')) {
actions.push('SlashCuratorMotion');
}
return 'None';
return actions;
}

View File

@ -4,3 +4,4 @@
export * from './useBounties';
export * from './useBalance';
export * from './useUserRole';
export * from './useBountyStatus';

View File

@ -0,0 +1,14 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useCallback } from 'react';
import { getBountyStatus } from '@polkadot/app-bounties/helpers';
import { BountyStatusType } from '@polkadot/app-bounties/types';
import { BountyStatus } from '@polkadot/types/interfaces';
export function useBountyStatus (status: BountyStatus): BountyStatusType {
const updateStatus = useCallback(() => getBountyStatus(status), [status]);
return updateStatus();
}

View File

@ -9,24 +9,28 @@ import { useAccounts, useMembers } from '@polkadot/react-hooks';
import { UserRole } from '../types';
export function useUserRole (curatorId: AccountId): UserRole {
export type UserRolesInfo = { roles: UserRole[], isCurator: boolean };
export function useUserRole (curatorId?: AccountId): UserRolesInfo {
const { allAccounts, hasAccounts } = useAccounts();
const { isMember } = useMembers();
const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]);
const isCurator = useMemo(() => curatorId && allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]);
const roles: UserRole[] = [];
if (isCurator) {
return 'Curator';
roles.push('Curator');
}
if (isMember) {
return 'Member';
roles.push('Member');
}
if (hasAccounts) {
return 'User';
roles.push('User');
}
return 'None';
return { isCurator: !!isCurator, roles };
}

View File

@ -0,0 +1,10 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { fireEvent } from '@testing-library/react';
export const clickButtonWithName = async (buttonName: string, findByRole: (...args : any[]) => Promise<HTMLElement>): Promise<void> => {
const button = await findByRole('button', { name: buttonName });
fireEvent.click(button);
};

View File

@ -0,0 +1,10 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { fireEvent } from '@testing-library/react';
export const clickElementWithTestId = async (testId: string, findByTestId: (testId: string) => Promise<HTMLElement>): Promise<void> => {
const element = await findByTestId(testId);
fireEvent.click(element);
};

View File

@ -0,0 +1,10 @@
// Copyright 2017-2021 @polkadot/app-bounties authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { fireEvent } from '@testing-library/react';
export const clickElementWithText = async (text: string, findByText: (name: string) => Promise<HTMLElement>): Promise<void> => {
const element = await findByText(text);
fireEvent.click(element);
};

View File

@ -10,7 +10,7 @@ import styled from 'styled-components';
import Icon from '../Icon';
import Spinner from '../Spinner';
function Button ({ children, className = '', icon, isBasic, isBusy, isCircular, isDisabled, isFull, isIcon, isSelected, isToplevel, label, onClick, onMouseEnter, onMouseLeave, tabIndex, withoutLink }: ButtonProps): React.ReactElement<ButtonProps> {
function Button ({ children, className = '', dataTestId = '', icon, isBasic, isBusy, isCircular, isDisabled, isFull, isIcon, isSelected, isToplevel, label, onClick, onMouseEnter, onMouseLeave, tabIndex, withoutLink }: ButtonProps): React.ReactElement<ButtonProps> {
const _onClick = useCallback(
() => !(isBusy || isDisabled) && onClick && onClick(),
[isBusy, isDisabled, onClick]
@ -19,6 +19,7 @@ function Button ({ children, className = '', icon, isBasic, isBusy, isCircular,
return (
<button
className={`ui--Button${label ? ' hasLabel' : ''}${isBasic ? ' isBasic' : ''}${isCircular ? ' isCircular' : ''}${isFull ? ' isFull' : ''}${isIcon ? ' isIcon' : ''}${(isBusy || isDisabled) ? ' isDisabled' : ''}${isBusy ? ' isBusy' : ''}${!onClick ? ' isReadOnly' : ''}${isSelected ? ' isSelected' : ''}${isToplevel ? ' isToplevel' : ''}${withoutLink ? ' withoutLink' : ''} ${className}`}
data-testid={dataTestId}
onClick={_onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}

View File

@ -9,6 +9,7 @@ export type Button$Callback = () => void | Promise<void>;
export interface ButtonProps {
children?: React.ReactNode;
className?: string;
dataTestId?: string;
icon: IconName;
isBasic?: boolean;
isBusy?: boolean;

View File

@ -24,8 +24,17 @@ export class BountyFactory {
public aBountyStatus = (status: string): BountyStatus =>
this.#registry.createType('BountyStatus', status);
public bountyStatusWith = ({ curator = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', status = 'Active', updateDue = 100000 } = {}): BountyStatus =>
this.#registry.createType('BountyStatus', { active: { curator, updateDue }, status });
public bountyStatusWith = ({ curator = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', status = 'Active', updateDue = 100000 } = {}): BountyStatus => {
if (status === 'Active') {
return this.#registry.createType('BountyStatus', { active: { curator, updateDue }, status });
}
if (status === 'CuratorProposed') {
return this.#registry.createType('BountyStatus', { curatorProposed: { curator }, status });
}
throw new Error('Unsupported status');
};
public bountyWith = ({ status = 'Proposed', value = 1 } = {}): Bounty =>
this.aBounty({ status: this.aBountyStatus(status), value: balanceOf(value) })