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