diff --git a/packages/apps/public/locales/en/app-bounties.json b/packages/apps/public/locales/en/app-bounties.json index 5ab38bef07..52ceb04f92 100644 --- a/packages/apps/public/locales/en/app-bounties.json +++ b/packages/apps/public/locales/en/app-bounties.json @@ -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" diff --git a/packages/apps/public/locales/en/translation.json b/packages/apps/public/locales/en/translation.json index 9dc2dd4100..261f370caf 100644 --- a/packages/apps/public/locales/en/translation.json +++ b/packages/apps/public/locales/en/translation.json @@ -95,6 +95,7 @@ "Settings": "", "Show address on hardware device": "", "Slash Curator": "", + "Slash Curator (Council)": "", "Slash curator": "", "Slashed": "", "Specified payment account": "", diff --git a/packages/page-bounties/src/Bounties.spec.tsx b/packages/page-bounties/src/Bounties.spec.tsx index 4275b82c1f..51e6b61fbf 100644 --- a/packages/page-bounties/src/Bounties.spec.tsx +++ b/packages/page-bounties/src/Bounties.spec.tsx @@ -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; 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); diff --git a/packages/page-bounties/src/Bounty.tsx b/packages/page-bounties/src/Bounty.tsx index 2fbd102285..87e79e7b57 100644 --- a/packages/page-bounties/src/Bounty.tsx +++ b/packages/page-bounties/src/Bounty.tsx @@ -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' /> +
` 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 { diff --git a/packages/page-bounties/src/BountyActions/BountyCuratorProposedActions.tsx b/packages/page-bounties/src/BountyAcceptCurator.tsx similarity index 59% rename from packages/page-bounties/src/BountyActions/BountyCuratorProposedActions.tsx rename to packages/page-bounties/src/BountyAcceptCurator.tsx index 6360e14f40..4d0bd83a91 100644 --- a/packages/page-bounties/src/BountyActions/BountyCuratorProposedActions.tsx +++ b/packages/page-bounties/src/BountyAcceptCurator.tsx @@ -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 && - ('Reject')} - params={[index]} - tx={unassignCurator} - /> - - ) - : null; + } + + ); } -export default React.memo(BountyCuratorProposedActions); +export default React.memo(BountyAcceptCurator); diff --git a/packages/page-bounties/src/BountyActions/CloseBounty.tsx b/packages/page-bounties/src/BountyActions/CloseBounty.tsx index 5ee9d3c21e..7cb0c7d4a9 100644 --- a/packages/page-bounties/src/BountyActions/CloseBounty.tsx +++ b/packages/page-bounties/src/BountyActions/CloseBounty.tsx @@ -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 | null { +function CloseBounty ({ index, toggleOpen }: Props): React.ReactElement | 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(null); const [threshold, setThreshold] = useState(); @@ -36,58 +34,44 @@ function CloseBounty ({ index, proposals }: Props): React.ReactElement | const closeBountyProposal = useRef(closeBounty(index)); - const existingCloseBountyProposal = useMemo(() => proposals?.find(({ proposal }) => proposal.method === 'closeBounty'), [proposals]); - - return isMember && !existingCloseBountyProposal - ? ( - <> -