diff --git a/packages/apps/public/locales/en/app-bounties.json b/packages/apps/public/locales/en/app-bounties.json index bc3ba61cbb..195ccff4b9 100644 --- a/packages/apps/public/locales/en/app-bounties.json +++ b/packages/apps/public/locales/en/app-bounties.json @@ -16,6 +16,8 @@ "Choose a curator whose background and expertise is such that they are capable of determining when the task is complete.": "Choose a curator whose background and expertise is such that they are capable of determining when the task is complete.", "Claim": "Claim", "Claimable": "Claimable", + "Close": "Close", + "Close Bounty": "Close Bounty", "Curator has been proposed by council. The bounty is waiting for curator to accept the role.": "Curator has been proposed by council. The bounty is waiting for curator to accept the role.", "Curator under voting": "Curator under voting", "Curator's deposit": "Curator's deposit", @@ -31,6 +33,7 @@ "Only curator can extend the bounty time.": "Only curator can extend the bounty time.", "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", @@ -38,7 +41,9 @@ "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.", "The council member that will create a motion, submission equates to an \"aye\" vote for chosen option.": "The council member that will create a motion, submission equates to an \"aye\" vote for chosen option.", + "The council member that will create the close bounty proposal, submission equates to an \"aye\" vote.": "The council member that will create the close bounty proposal, submission equates to an \"aye\" vote.", "The council member that will create the motion.": "The council member that will create the motion.", "The description of this bounty": "The description of this bounty", "The note on the extension.": "The note on the extension.", @@ -48,6 +53,7 @@ "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 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.", + "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 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.", @@ -64,6 +70,7 @@ "bounty remark": "bounty remark", "bounty requested allocation": "bounty requested allocation", "bounty title": "bounty title", + "close bounty": "close bounty", "curator": "curator", "curator account": "curator account", "curator's fee": "curator's fee", @@ -72,6 +79,7 @@ "next burn": "next burn", "past": "past", "payout due": "payout due", + "propose with account": "propose with account", "proposing account": "proposing account", "select curator": "select curator", "spend period": "spend period", diff --git a/packages/page-bounties/src/Bounties.spec.tsx b/packages/page-bounties/src/Bounties.spec.tsx index 4538613165..228ff4b7fa 100644 --- a/packages/page-bounties/src/Bounties.spec.tsx +++ b/packages/page-bounties/src/Bounties.spec.tsx @@ -26,17 +26,17 @@ import { aliceSigner, MemoryStore } from '@polkadot/test-support/keyring'; import { TypeRegistry } from '@polkadot/types/create'; import { keyring } from '@polkadot/ui-keyring'; -import { defaultAccounts, +import { alice, + bob, + defaultAccounts, defaultBalance, defaultBountyApi, defaultMembers, - defaultTreasury } from '../test/hooks/defaults'; + defaultTreasury, + ferdie } from '../test/hooks/defaults'; import Bounties from './Bounties'; import { BountyApi } from './hooks'; -const alice = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; -const bob = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'; - const mockMembers = defaultMembers; const mockAccounts = defaultAccounts; const mockTreasury = defaultTreasury; @@ -302,6 +302,37 @@ describe('Bounties', () => { }); }); + describe('close bounty modal', () => { + it('creates closeBounty proposal', async () => { + const bounty = bountyWith({ status: 'Funded' }); + const { findByText, getByRole } = renderOneBounty(bounty); + + const closeButton = await findByText('Close'); + + fireEvent.click(closeButton); + expect(await findByText('This action will create a Council proposal to close the Bounty.')).toBeTruthy(); + + const combobox = getByRole('combobox'); + const proposingAccountInput = combobox.children[0]; + + fireEvent.change(proposingAccountInput, { target: { value: ferdie } }); + + const closeBountyButton = getByRole('button', { name: 'Close Bounty' }); + + fireEvent.click(closeBountyButton); + expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: ferdie, extrinsic: 'mockProposeExtrinsic' })); + }); + + it('Not available when close bounty motion already exists', async () => { + const bounty = bountyWith({ status: 'Funded' }); + const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))]; + + const { findByText } = renderOneBounty(bounty, proposals); + + await expect(findByText('Close')).rejects.toThrow(); + }); + }); + describe('extend bounty expiry action modal', () => { it('queues extend bounty expiry extrinsic on submit', async () => { const bounty = bountyWith({ status: 'Active' }); @@ -392,6 +423,33 @@ describe('Bounties', () => { }); }); + describe('Proposed Curator extended status', () => { + it('Funded and propose curator motion', async () => { + const bounty = bountyWith({ status: 'Funded' }); + const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, alice, 1))]; + + const { findByText } = renderOneBounty(bounty, proposals); + + expect(await findByText('Proposed Curator')).toBeTruthy(); + }); + + it('Funded and no propose curator motion', async () => { + const bounty = bountyWith({ status: 'Funded' }); + + const { findByText } = renderOneBounty(bounty); + + await expect(findByText('Proposed Curator')).rejects.toThrow(); + }); + + it('CuratorProposed', async () => { + const bounty = bountyWith({ status: 'CuratorProposed' }); + + const { findByText } = renderOneBounty(bounty); + + await expect(findByText('Proposed Curator')).rejects.toThrow(); + }); + }); + describe('Voters', () => { it('aye and nay', async () => { const bounty = bountyWith({ status: 'Proposed' }); @@ -450,8 +508,8 @@ describe('Bounties', () => { }); }); - describe('Description', () => { - it('Beneficiary', async () => { + describe('Beneficiary description', () => { + it('PendingPayout status', async () => { const bounty = bountyWith({ status: 'PendingPayout' }); const proposals = [aProposal(augmentedApi.tx.bounties.awardBounty(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z'))]; @@ -459,5 +517,13 @@ describe('Bounties', () => { expect(await findByText('Beneficiary')).toBeTruthy(); }); + + it('Other status', async () => { + const bounty = bountyWith({ status: 'Active' }); + + const { findByText } = renderOneBounty(bounty); + + await expect(findByText('Beneficiary')).rejects.toThrow(); + }); }); }); diff --git a/packages/page-bounties/src/Bounty.tsx b/packages/page-bounties/src/Bounty.tsx index 8dbd26d5e0..2fbd102285 100644 --- a/packages/page-bounties/src/Bounty.tsx +++ b/packages/page-bounties/src/Bounty.tsx @@ -13,10 +13,12 @@ import { ThemeProps } from '@polkadot/react-components/types'; import { BlockToTime, FormatBalance } from '@polkadot/react-query'; import { formatNumber } from '@polkadot/util'; +import { getProposalToDisplay } from './helpers/extendedStatuses'; import VotingResultsColumn from './Voting/VotersColumn'; import { BountyActions } from './BountyActions'; import BountyInfos from './BountyInfos'; import BountyStatusView from './BountyStatusView'; +import Curator from './Curator'; import { getBountyStatus } from './helpers'; import { useTranslation } from './translate'; @@ -49,6 +51,18 @@ function Bounty ({ bestNumber, bounty, className = '', description, index, propo const blocksUntilUpdate = useMemo(() => updateDue?.sub(bestNumber), [bestNumber, updateDue]); const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]); + const curatorToRender = useMemo(() => { + if (curator) { + return { curator, isFromProposal: false }; + } + + const proposalToDisplay = proposals && getProposalToDisplay(proposals, status); + + return (proposalToDisplay?.proposal.method === 'proposeCurator') + ? { curator: proposalToDisplay.proposal.args[1], isFromProposal: true } + : null; + }, [curator, proposals, status]); + const handleOnIconClick = useCallback( () => setIsExpanded((isExpanded) => !isExpanded), [] @@ -75,7 +89,14 @@ function Bounty ({ bestNumber, bounty, className = '', description, index, propo - {curator && } + + {curatorToRender && ( + + )} + {blocksUntilPayout ? } - {status.isFunded && ( + /> + } + {status.isFunded && - )} + } {status.isCuratorProposed && curator && } + /> + } {status.isActive && curator && } + {(status.isFunded || status.isActive || status.isCuratorProposed) && + + } {status.isPendingPayout && beneficiary && blocksUntilPayout && } + /> + } ); } diff --git a/packages/page-bounties/src/BountyCuratorProposedActions.tsx b/packages/page-bounties/src/BountyCuratorProposedActions.tsx index 438ff06420..0420e4383d 100644 --- a/packages/page-bounties/src/BountyCuratorProposedActions.tsx +++ b/packages/page-bounties/src/BountyCuratorProposedActions.tsx @@ -5,8 +5,9 @@ import type { AccountId, BountyIndex } from '@polkadot/types/interfaces'; import React, { useMemo } from 'react'; +import { useBounties } from '@polkadot/app-bounties/hooks'; import { TxButton } from '@polkadot/react-components'; -import { useAccounts, useApi } from '@polkadot/react-hooks'; +import { useAccounts } from '@polkadot/react-hooks'; import { useTranslation } from './translate'; @@ -17,7 +18,7 @@ interface Props { function BountyCuratorProposedActions ({ curatorId, index }: Props) { const { t } = useTranslation(); - const { api } = useApi(); + const { acceptCurator, unassignCurator } = useBounties(); const { allAccounts } = useAccounts(); const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]); @@ -30,14 +31,14 @@ function BountyCuratorProposedActions ({ curatorId, index }: Props) { icon='check' label={t('Accept')} params={[index]} - tx={(api.tx.bounties || api.tx.treasury).acceptCurator} + tx={acceptCurator} /> ('Reject')} params={[index]} - tx={(api.tx.bounties || api.tx.treasury).unassignCurator} + tx={unassignCurator} /> ) diff --git a/packages/page-bounties/src/CloseBounty.tsx b/packages/page-bounties/src/CloseBounty.tsx new file mode 100644 index 0000000000..c399184244 --- /dev/null +++ b/packages/page-bounties/src/CloseBounty.tsx @@ -0,0 +1,97 @@ +// 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 { getTreasuryProposalThreshold } from '@polkadot/apps-config'; +import { Button, InputAddress, Modal, TxButton } from '@polkadot/react-components'; +import { useApi, useMembers, useToggle } from '@polkadot/react-hooks'; + +import { useBounties } from './hooks'; +import { useTranslation } from './translate'; + +interface Props { + index: BountyIndex; + proposals?: DeriveCollectiveProposal[]; +} + +function CloseBounty ({ index, proposals }: Props): React.ReactElement | null { + const { t } = useTranslation(); + const { api } = useApi(); + const { isMember, members } = useMembers(); + const { closeBounty } = useBounties(); + const [isOpen, toggleOpen] = useToggle(); + const [accountId, setAccountId] = useState(null); + const [threshold, setThreshold] = useState(); + + useEffect((): void => { + members && setThreshold( + new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api))) + ); + }, [api, members]); + + const closeBountyProposal = useRef(closeBounty(index)); + + const existingCloseBountyProposal = useMemo(() => proposals?.find(({ proposal }) => proposal.method === 'closeBounty'), [proposals]); + + return isMember && !existingCloseBountyProposal + ? ( + <> +