diff --git a/packages/apps/public/locales/en/app-bounties.json b/packages/apps/public/locales/en/app-bounties.json index f95bdd1448..5ab38bef07 100644 --- a/packages/apps/public/locales/en/app-bounties.json +++ b/packages/apps/public/locales/en/app-bounties.json @@ -8,18 +8,22 @@ "Approval under voting": "Approval under voting", "Approve": "Approve", "Assign curator": "Assign curator", + "Award Beneficiary": "Award Beneficiary", + "Award bounty to a beneficiary account. The beneficiary will be able to claim the funds after a delay.": "Award bounty to a beneficiary account. The beneficiary will be able to claim the funds after a delay.", "Aye": "Aye", "Aye: {{count}}": "Aye: {{count}}", "Beneficiary": "Beneficiary", "Bond": "Bond", "Bounties": "Bounties", "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.", + "Choose the Beneficiary for this bounty.": "Choose the Beneficiary for this bounty.", "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 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 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", @@ -49,6 +53,7 @@ "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": "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", "The Curator that will be slashed.": "The Curator that will be slashed.", "The Curator that will be unassigned": "The Curator that will be unassigned", @@ -65,6 +70,7 @@ "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 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.", "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 motion to slash the Curator.": "This action will create a Council motion to slash the Curator.", @@ -83,6 +89,9 @@ "Unassign curator under voting": "Unassign curator under voting", "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", + "award bounty": "award bounty", + "award with account": "award with account", + "beneficiary account": "beneficiary account", "bounty bond": "bounty bond", "bounty remark": "bounty remark", "bounty requested allocation": "bounty requested allocation", diff --git a/packages/page-bounties/src/Bounties.spec.tsx b/packages/page-bounties/src/Bounties.spec.tsx index ddc1f603ce..4275b82c1f 100644 --- a/packages/page-bounties/src/Bounties.spec.tsx +++ b/packages/page-bounties/src/Bounties.spec.tsx @@ -22,7 +22,7 @@ import { QueueProps, QueueTxExtrinsicAdd } from '@polkadot/react-components/Stat import { createAugmentedApi } from '@polkadot/test-support/api'; import { balanceOf } from '@polkadot/test-support/creation/balance'; import { BountyFactory } from '@polkadot/test-support/creation/bounties/bountyFactory'; -import { aliceSigner, MemoryStore } from '@polkadot/test-support/keyring'; +import { MemoryStore } from '@polkadot/test-support/keyring'; import { TypeRegistry } from '@polkadot/types/create'; import { keyring } from '@polkadot/ui-keyring'; import { extractTime } from '@polkadot/util'; @@ -287,7 +287,6 @@ describe('Bounties', () => { const proposingAccountInput = comboboxes[0].children[0]; const proposingCuratorInput = comboboxes[1].children[0]; - const alice = aliceSigner().address; fireEvent.change(proposingAccountInput, { target: { value: alice } }); fireEvent.change(proposingCuratorInput, { target: { value: alice } }); @@ -376,6 +375,7 @@ describe('Bounties', () => { fireEvent.click(acceptButton); expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockUnassignExtrinsic' })); + expect(mockBountyApi.unassignCurator).toHaveBeenCalledWith(aBountyIndex(0)); }); it('for bounty in PendingPayout state', async () => { @@ -402,6 +402,31 @@ describe('Bounties', () => { }); }); + describe('award beneficiary action modal', () => { + it('awards the beneficiary ', async () => { + const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) }); + const { findByText, getAllByRole } = renderOneBounty(bounty); + + const awardBeneficiaryButton = await findByText('Award Beneficiary'); + + fireEvent.click(awardBeneficiaryButton); + expect(await findByText('This action will award the Beneficiary and close the bounty after a delay.')).toBeTruthy(); + const comboboxes = getAllByRole('combobox'); + + const beneficiaryAccountInput = comboboxes[1].children[0]; + + fireEvent.change(beneficiaryAccountInput, { target: { value: bob } }); + fireEvent.keyDown(beneficiaryAccountInput, { code: 'Enter', key: 'Enter' }); + + const acceptButton = await findByText('Approve'); + + fireEvent.click(acceptButton); + + expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockAwardExtrinsic' })); + expect(mockBountyApi.awardBounty).toHaveBeenCalledWith(aBountyIndex(0), bob); + }); + }); + describe('status is extended', () => { it('on proposed curator', async () => { const bounty = bountyWith({ status: 'Funded' }); diff --git a/packages/page-bounties/src/BountyActions/AwardBounty.tsx b/packages/page-bounties/src/BountyActions/AwardBounty.tsx new file mode 100644 index 0000000000..67fbed9603 --- /dev/null +++ b/packages/page-bounties/src/BountyActions/AwardBounty.tsx @@ -0,0 +1,92 @@ +// Copyright 2017-2021 @polkadot/app-treasury authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AccountId, BountyIndex } from '@polkadot/types/interfaces'; + +import React, { useMemo, useState } from 'react'; + +import { Button, InputAddress, Modal, TxButton } from '@polkadot/react-components'; +import { useAccounts, useToggle } from '@polkadot/react-hooks'; + +import { useBounties } from '../hooks'; +import { useTranslation } from '../translate'; + +interface Props { + curatorId: AccountId + index: BountyIndex; +} + +function AwardBounty ({ curatorId, index }: Props): React.ReactElement | null { + const { t } = useTranslation(); + const { awardBounty } = useBounties(); + const { allAccounts } = useAccounts(); + const [isOpen, toggleOpen] = useToggle(); + const [beneficiaryId, setBeneficiaryId] = useState(null); + const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]); + + return isCurator + ? ( + <> +