mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-08-08 22:05:43 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import '@pezkuwi/react-components/i18n';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import React, { Suspense } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import { ApiCtxRoot } from '@pezkuwi/react-api';
|
||||
import { lightTheme } from '@pezkuwi/react-components';
|
||||
import { createApi } from '@pezkuwi/test-support/api';
|
||||
import { aliceSigner, MemoryStore } from '@pezkuwi/test-support/keyring';
|
||||
import { WaitForApi } from '@pezkuwi/test-support/react';
|
||||
import { execute } from '@pezkuwi/test-support/transaction';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import BountiesApp from './index.js';
|
||||
|
||||
const BIZINIKIWI_PORT = Number.parseInt(process.env.TEST_BIZINIKIWI_PORT || '30333');
|
||||
|
||||
const renderBounties = () => {
|
||||
const memoryStore = new MemoryStore();
|
||||
|
||||
return render(
|
||||
<Suspense fallback='...'>
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiCtxRoot
|
||||
apiUrl={`ws://127.0.0.1:${BIZINIKIWI_PORT}`}
|
||||
isElectron={false}
|
||||
store={memoryStore}
|
||||
>
|
||||
<WaitForApi>
|
||||
<div>
|
||||
<BountiesApp basePath='/bounties' />
|
||||
</div>
|
||||
</WaitForApi>
|
||||
</ApiCtxRoot>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('--SLOW--: Bounties', () => {
|
||||
it('list shows an existing bounty', async () => {
|
||||
const api = await createApi();
|
||||
|
||||
await execute(api.tx.bounties.proposeBounty(new BN(500_000_000_000_000), 'a short bounty title'), aliceSigner());
|
||||
|
||||
const { findByText } = renderBounties();
|
||||
|
||||
expect(await findByText('a short bounty title', {}, { timeout: 20_000 })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
/* eslint-disable jest/expect-expect */
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBountiesBounty, PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
import i18next from '@pezkuwi/react-components/i18n';
|
||||
import { createAugmentedApi } from '@pezkuwi/test-support/api';
|
||||
import { balanceOf } from '@pezkuwi/test-support/creation/balance';
|
||||
import { BountyFactory } from '@pezkuwi/test-support/creation/bounties';
|
||||
import { proposalFactory } from '@pezkuwi/test-support/creation/treasury';
|
||||
import { mockHooks } from '@pezkuwi/test-support/hooks';
|
||||
import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { defaultBountyUpdatePeriod, mockBountyHooks } from '../test/hooks/defaults.js';
|
||||
import { BountiesPage } from '../test/pages/bountiesPage.js';
|
||||
import { BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING } from './BountyNextActionInfo/BountyActionMessage.js';
|
||||
|
||||
jest.mock('@pezkuwi/react-hooks/useTreasury', () => ({
|
||||
useTreasury: () => mockHooks.treasury
|
||||
}));
|
||||
|
||||
jest.mock('@pezkuwi/react-hooks/useCollectiveInstance', () => ({
|
||||
useCollectiveInstance: () => 'council'
|
||||
}));
|
||||
|
||||
jest.mock('@pezkuwi/react-hooks/useCollectiveMembers', () => ({
|
||||
useCollectiveMembers: () => mockHooks.members
|
||||
}));
|
||||
|
||||
jest.mock('@pezkuwi/react-hooks/useBlockTime', () => ({
|
||||
useBlockTime: () => mockHooks.blockTime
|
||||
}));
|
||||
|
||||
jest.mock('./hooks/useBalance', () => ({
|
||||
useBalance: () => mockBountyHooks.balance
|
||||
}));
|
||||
|
||||
jest.mock('./hooks/useBounties', () => ({
|
||||
useBounties: () => mockBountyHooks.bountyApi
|
||||
}));
|
||||
|
||||
let aProposal: (extrinsic: SubmittableExtrinsic<'promise'>, ayes?: string[], nays?: string[]) => DeriveCollectiveProposal;
|
||||
let augmentedApi: ApiPromise;
|
||||
let aBounty: ({ status, value }?: Partial<PalletBountiesBounty>) => PalletBountiesBounty;
|
||||
let aBountyIndex: (index?: number) => BountyIndex;
|
||||
let bountyStatusWith: ({ curator, status, updateDue }: { curator?: string, status?: string, updateDue?: number}) => PalletBountiesBountyStatus;
|
||||
let bountyWith: ({ status, value }: { status?: string, value?: number }) => PalletBountiesBounty;
|
||||
|
||||
describe('Bounties', () => {
|
||||
let bountiesPage: BountiesPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
|
||||
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
|
||||
|
||||
augmentedApi = createAugmentedApi();
|
||||
({ aBounty, aBountyIndex, bountyStatusWith, bountyWith } = new BountyFactory(augmentedApi));
|
||||
({ aProposal } = proposalFactory(augmentedApi));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
bountiesPage = new BountiesPage(augmentedApi);
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('shows message when no bounties', async () => {
|
||||
bountiesPage.renderMany();
|
||||
|
||||
await bountiesPage.expectText('No open bounties');
|
||||
});
|
||||
|
||||
it('renders a bounty', async () => {
|
||||
bountiesPage.renderOne(aBounty(), [], 'dicle comic book');
|
||||
|
||||
await bountiesPage.expectText('dicle comic book');
|
||||
bountiesPage.expectTextAbsent('No open bounties');
|
||||
});
|
||||
|
||||
it('renders bounties in order from newest to oldest', async () => {
|
||||
bountiesPage.renderMany({
|
||||
bounties: [
|
||||
{ bounty: aBounty(), description: 'bounty 2', index: aBountyIndex(2), proposals: [] },
|
||||
{ bounty: aBounty(), description: 'bounty 1', index: aBountyIndex(1), proposals: [] },
|
||||
{ bounty: aBounty(), description: 'bounty 3', index: aBountyIndex(3), proposals: [] }
|
||||
]
|
||||
});
|
||||
|
||||
expect(await bountiesPage.findAllDescriptions()).toEqual(['bounty 3', 'bounty 2', 'bounty 1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bounty in a list', () => {
|
||||
describe('has extended status', () => {
|
||||
it('when voting on proposed curator', async () => {
|
||||
const bounty = bountyWith({ status: 'Funded' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z', 1))];
|
||||
|
||||
bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await bountiesPage.expectVotingDescription('Curator proposal under voting');
|
||||
});
|
||||
|
||||
it('when voting on bounty approval', async () => {
|
||||
const bounty = bountyWith({ status: 'Proposed' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))];
|
||||
|
||||
bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await bountiesPage.expectVotingDescription('Bounty approval under voting');
|
||||
});
|
||||
|
||||
it('when simultaneous close and approve motions exist, show approved', async () => {
|
||||
const bounty = bountyWith({ status: 'Proposed' });
|
||||
const proposals = [
|
||||
aProposal(augmentedApi.tx.bounties.closeBounty(0)),
|
||||
aProposal(augmentedApi.tx.bounties.approveBounty(0))
|
||||
];
|
||||
|
||||
bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await bountiesPage.expectVotingDescription('Bounty approval under voting');
|
||||
});
|
||||
|
||||
it('when voting on close bounty', async () => {
|
||||
const bounty = bountyWith({ status: 'Active' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))];
|
||||
|
||||
bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await bountiesPage.expectVotingDescription('Bounty rejection under voting');
|
||||
});
|
||||
|
||||
it('when voting on unassign curator', async () => {
|
||||
const bounty = bountyWith({ status: 'Active' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.unassignCurator(0))];
|
||||
|
||||
bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await bountiesPage.expectVotingDescription('Curator slash under voting');
|
||||
});
|
||||
|
||||
it('when a motion exists that would fail on execution, show nothing', async () => {
|
||||
const bounty = bountyWith({ status: 'Active' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))];
|
||||
|
||||
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await expect(findByTestId('voting-description')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('has extended description for Curator', () => {
|
||||
it('when propose curator motion is voted and bounty is in Funded state', async () => {
|
||||
const bounty = bountyWith({ status: 'Funded' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, alice, 1))];
|
||||
|
||||
bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await bountiesPage.expectText('Proposed Curator');
|
||||
});
|
||||
|
||||
it('when bounty is in Funded status, but there is no motion, show nothing', async () => {
|
||||
const bounty = bountyWith({ status: 'Funded' });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.rendered();
|
||||
bountiesPage.expectTextAbsent('Proposed Curator');
|
||||
});
|
||||
|
||||
it('when status is different, show nothing', async () => {
|
||||
const bounty = bountyWith({ status: 'CuratorProposed' });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.rendered();
|
||||
bountiesPage.expectTextAbsent('Proposed Curator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('has Beneficiary description', () => {
|
||||
it('in PendingPayout status', async () => {
|
||||
const bounty = bountyWith({ status: 'PendingPayout' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.awardBounty(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z'))];
|
||||
|
||||
bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await bountiesPage.expectText('Beneficiary');
|
||||
});
|
||||
|
||||
it('not in other status', async () => {
|
||||
const bounty = bountyWith({ status: 'Active' });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.rendered();
|
||||
bountiesPage.expectTextAbsent('Beneficiary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('has voting summary', () => {
|
||||
it('is displayed when voting', async () => {
|
||||
const bounty = bountyWith({ status: 'Proposed' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])];
|
||||
|
||||
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
expect((await findByTestId('voting-summary')).textContent).toEqual('Aye 2/4Nay 0/0Voting');
|
||||
});
|
||||
|
||||
it('is not displayed when not voting', async () => {
|
||||
const bounty = bountyWith({ status: 'Proposed' });
|
||||
const proposals: DeriveCollectiveProposal[] = [];
|
||||
|
||||
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await expect(findByTestId('voting-summary')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('has voters', () => {
|
||||
it('aye and nay', async () => {
|
||||
const bounty = bountyWith({ status: 'Proposed' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))];
|
||||
const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals);
|
||||
const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes'));
|
||||
const nayVoters = await findAllByTestId((testId) => testId.startsWith('voters_nays'));
|
||||
|
||||
expect(ayeVoters).toHaveLength(1);
|
||||
expect(nayVoters).toHaveLength(1);
|
||||
expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice);
|
||||
expect(nayVoters[0].getAttribute('data-testid')).toContain(bob);
|
||||
});
|
||||
|
||||
it('multiple ayes and no nay', async () => {
|
||||
const bounty = bountyWith({ status: 'Proposed' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])];
|
||||
|
||||
const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals);
|
||||
const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes'));
|
||||
|
||||
expect(ayeVoters).toHaveLength(2);
|
||||
await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow();
|
||||
expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice);
|
||||
expect(ayeVoters[1].getAttribute('data-testid')).toContain(bob);
|
||||
});
|
||||
|
||||
it('no voters when no voting', async () => {
|
||||
const bounty = bountyWith({ status: 'Proposed' });
|
||||
const proposals: DeriveCollectiveProposal[] = [];
|
||||
|
||||
const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await expect(findAllByTestId((testId) => testId.startsWith('voters_ayes'))).rejects.toThrow();
|
||||
await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create bounty modal', () => {
|
||||
it('validates bounty length', async () => {
|
||||
bountiesPage.renderMany({ maximumReasonLength: 5 });
|
||||
|
||||
await bountiesPage.openAddBounty();
|
||||
await bountiesPage.enterBountyTitle('longer than 5');
|
||||
|
||||
await bountiesPage.expectText('Title too long');
|
||||
});
|
||||
|
||||
it('validates balance is enough for bond', async () => {
|
||||
bountiesPage.renderMany(
|
||||
{ bountyDepositBase: new BN(10), dataDepositPerByte: new BN(1) },
|
||||
{ balance: 10 }
|
||||
);
|
||||
|
||||
await bountiesPage.openAddBounty();
|
||||
bountiesPage.expectTextAbsent('Account does not have enough funds.');
|
||||
await bountiesPage.enterBountyTitle('add bytes');
|
||||
|
||||
await bountiesPage.expectText('Account does not have enough funds.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('propose curator modal', () => {
|
||||
beforeEach(async () => {
|
||||
bountiesPage.renderOne(bountyWith({ status: 'Funded', value: 5 }));
|
||||
|
||||
await bountiesPage.openProposeCurator();
|
||||
});
|
||||
|
||||
it('shows an error if fee is greater than bounty value', async () => {
|
||||
await bountiesPage.enterCuratorsFee('6');
|
||||
|
||||
await bountiesPage.expectText("Curator's fee can't be higher than bounty value.");
|
||||
});
|
||||
|
||||
it('disables Assign Curator button if validation fails', async () => {
|
||||
await bountiesPage.enterCuratorsFee('6');
|
||||
|
||||
expect(await bountiesPage.assignCuratorButton()).toHaveClass('isDisabled');
|
||||
});
|
||||
|
||||
it('queues propose extrinsic on submit', async () => {
|
||||
await bountiesPage.enterCuratorsFee('0');
|
||||
bountiesPage.enterProposingAccount(alice);
|
||||
bountiesPage.enterProposedCurator(alice);
|
||||
|
||||
fireEvent.click(await bountiesPage.assignCuratorButton());
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('close bounty modal', () => {
|
||||
it('creates closeBounty proposal', async () => {
|
||||
bountiesPage.renderOne(bountyWith({ status: 'Funded' }));
|
||||
await bountiesPage.openCloseBounty();
|
||||
|
||||
bountiesPage.enterProposingAccount(alice);
|
||||
await bountiesPage.clickButton('Close Bounty');
|
||||
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' });
|
||||
expect(mockBountyHooks.bountyApi.closeBounty).toHaveBeenCalledWith(aBountyIndex(0));
|
||||
});
|
||||
|
||||
it('is not available when close bounty motion already exists', async () => {
|
||||
const bounty = bountyWith({ status: 'Funded' });
|
||||
const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))];
|
||||
|
||||
const { findByTestId } = bountiesPage.renderOne(bounty, proposals);
|
||||
|
||||
await expect(findByTestId('extra-actions')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reject curator modal', () => {
|
||||
it('creates extrinsic', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.openRejectCuratorRole();
|
||||
await bountiesPage.clickButton('Reject');
|
||||
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: bob });
|
||||
});
|
||||
|
||||
it('shows options for all roles', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'Active' }) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.openExtraActions();
|
||||
await bountiesPage.expectText('Give up');
|
||||
await bountiesPage.expectText('Slash curator (Council)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accept curator modal', () => {
|
||||
it('creates extrinsic', async () => {
|
||||
const bounty = aBounty({
|
||||
fee: balanceOf(20),
|
||||
status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' })
|
||||
});
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
await bountiesPage.openAcceptCuratorRole();
|
||||
|
||||
expect(await bountiesPage.findCuratorsFee()).toEqual('20.0000');
|
||||
expect(await bountiesPage.findCuratorsDeposit()).toEqual('10.0000');
|
||||
|
||||
await bountiesPage.clickButton('Accept Curator Role');
|
||||
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: bob });
|
||||
expect(mockBountyHooks.bountyApi.acceptCurator).toHaveBeenCalledWith(aBountyIndex(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('extend bounty expiry action modal', () => {
|
||||
it('queues extend bounty expiry extrinsic on submit', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
await bountiesPage.openExtendExpiry();
|
||||
|
||||
await bountiesPage.enterExpiryRemark('The bounty extend expiry remark');
|
||||
await bountiesPage.clickButton('Accept');
|
||||
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockExtendExtrinsic' });
|
||||
expect(mockBountyHooks.bountyApi.extendBountyExpiry).toHaveBeenCalledWith(aBountyIndex(0), 'The bounty extend expiry remark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('give up curator modal', () => {
|
||||
it('gives up on the Curator role of an Active bounty', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
await bountiesPage.openGiveUpCuratorsRole();
|
||||
|
||||
bountiesPage.enterProposingAccount(alice);
|
||||
await bountiesPage.clickButton('Give up');
|
||||
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockUnassignExtrinsic' });
|
||||
expect(mockBountyHooks.bountyApi.unassignCurator).toHaveBeenCalledWith(aBountyIndex(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('slash curator modal', () => {
|
||||
it('creates a motion when slashing a PendingPayout bounty', async () => {
|
||||
bountiesPage.renderOne(bountyWith({ status: 'PendingPayout' }));
|
||||
|
||||
await bountiesPage.openSlashCuratorByCouncil();
|
||||
|
||||
bountiesPage.enterProposingAccount(alice);
|
||||
|
||||
await bountiesPage.clickButton('Approve');
|
||||
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('award beneficiary action modal', () => {
|
||||
it('awards the beneficiary', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.openAwardBeneficiary();
|
||||
bountiesPage.enterBeneficiary(bob);
|
||||
await bountiesPage.clickButton('Approve');
|
||||
|
||||
bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockAwardExtrinsic' });
|
||||
expect(mockBountyHooks.bountyApi.awardBounty).toHaveBeenCalledWith(aBountyIndex(0), bob);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Show', () => {
|
||||
it('warning when update time is close', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith(
|
||||
{
|
||||
curator: alice,
|
||||
status: 'Active',
|
||||
updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() - 1
|
||||
}) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.expectText('Close deadline');
|
||||
});
|
||||
|
||||
it('warning when update time is overdue', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith(
|
||||
{
|
||||
curator: alice,
|
||||
status: 'Active',
|
||||
updateDue: mockBountyHooks.bountyApi.bestNumber?.toNumber()
|
||||
}) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.expectText('Update overdue');
|
||||
});
|
||||
|
||||
it('info when waiting for bounty funding', async () => {
|
||||
const bounty = bountyWith({ status: 'Approved' });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.expectText('Waiting for Bounty Funding');
|
||||
});
|
||||
|
||||
it('info when waiting for curator acceptance', async () => {
|
||||
const bounty = bountyWith({ status: 'CuratorProposed' });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.expectText('Waiting for Curator\'s acceptance');
|
||||
});
|
||||
|
||||
it('info when bounty is claimable', async () => {
|
||||
const bounty = bountyWith({ status: 'PendingPayout' });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
|
||||
await bountiesPage.expectText('Waiting for implementer to claim');
|
||||
});
|
||||
|
||||
it('no warning or info when requirements are not met', async () => {
|
||||
const bounty = aBounty({ status: bountyStatusWith({
|
||||
curator: alice,
|
||||
status: 'Active',
|
||||
updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() + 1
|
||||
}) });
|
||||
|
||||
bountiesPage.renderOne(bounty);
|
||||
await bountiesPage.rendered();
|
||||
|
||||
bountiesPage.expectTextAbsent('Close deadline');
|
||||
bountiesPage.expectTextAbsent('Update overdue');
|
||||
bountiesPage.expectTextAbsent('Waiting for Bounty Funding');
|
||||
bountiesPage.expectTextAbsent("Waiting for Curator's acceptance");
|
||||
bountiesPage.expectTextAbsent('Waiting for implementer to claim');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import { Button, styled, Table } from '@pezkuwi/react-components';
|
||||
|
||||
import { useBounties } from './hooks/index.js';
|
||||
import Bounty from './Bounty.js';
|
||||
import BountyCreate from './BountyCreate.js';
|
||||
import Summary from './Summary.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Bounties ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const info = useBounties();
|
||||
|
||||
const sorted = useMemo(
|
||||
() => info?.bounties && [...info.bounties].sort((a, b) => b.index.cmp(a.index)),
|
||||
[info]
|
||||
);
|
||||
|
||||
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
|
||||
[t('bounties'), 'start', 3],
|
||||
[t('value')],
|
||||
[t('curator'), 'start'],
|
||||
[t('next action'), 'start', 3]
|
||||
]);
|
||||
const bestNumber = info.bestNumber;
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<Summary info={info} />
|
||||
<Button.Group>
|
||||
<BountyCreate />
|
||||
</Button.Group>
|
||||
<Table
|
||||
className='bounties-table-wrapper'
|
||||
empty={sorted && t('No open bounties')}
|
||||
header={headerRef.current}
|
||||
>
|
||||
{sorted && bestNumber && sorted.map(({ bounty, description, index, proposals }) => (
|
||||
<Bounty
|
||||
bestNumber={bestNumber}
|
||||
bounty={bounty}
|
||||
description={description}
|
||||
index={index}
|
||||
key={index.toNumber()}
|
||||
proposals={proposals}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.bounties-table-wrapper table {
|
||||
tr {
|
||||
td, &:not(.filter) th {
|
||||
&:last-child {
|
||||
padding-right: 1.14rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui--IdentityIcon {
|
||||
margin-right: 0.42rem;
|
||||
}
|
||||
|
||||
.via-identity .name {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.7rem;
|
||||
text-transform: initial;
|
||||
filter: initial;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Bounties);
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBountiesBounty } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressSmall, Columar, ExpandButton, LinkExternal, styled, Table } from '@pezkuwi/react-components';
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
|
||||
import { BountyActions } from './BountyActions/index.js';
|
||||
import BountyExtraActions from './BountyExtraActions/index.js';
|
||||
import BountyInfos from './BountyInfos/index.js';
|
||||
import BountyActionMessage from './BountyNextActionInfo/BountyActionMessage.js';
|
||||
import { getProposalToDisplay } from './helpers/extendedStatuses.js';
|
||||
import { useBountyStatus } from './hooks/index.js';
|
||||
import BountyStatusView from './BountyStatusView.js';
|
||||
import Curator from './Curator.js';
|
||||
import DueBlocks from './DueBlocks.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import VotersColumn from './VotersColumn.js';
|
||||
|
||||
interface Props {
|
||||
bestNumber: BN;
|
||||
bounty: PalletBountiesBounty;
|
||||
className?: string;
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
proposals?: DeriveCollectiveProposal[];
|
||||
}
|
||||
|
||||
function Bounty ({ bestNumber, bounty, className = '', description, index, proposals }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, toggleExpanded] = useToggle(false);
|
||||
|
||||
const { bond, curatorDeposit, fee, proposer, status, value } = bounty;
|
||||
const { beneficiary, bountyStatus, curator, unlockAt, updateDue } = useBountyStatus(status);
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTr className={`${className} isExpanded isFirst ${isExpanded ? '' : 'isLast'}`}>
|
||||
<Table.Column.Id value={index} />
|
||||
<td
|
||||
className='description-column'
|
||||
data-testid='description'
|
||||
>
|
||||
<div title={description}>
|
||||
{description}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<BountyStatusView bountyStatus={bountyStatus} />
|
||||
</td>
|
||||
<Table.Column.Balance value={value} />
|
||||
<td>
|
||||
{curatorToRender && (
|
||||
<Curator
|
||||
curator={curatorToRender.curator}
|
||||
isFromProposal={curatorToRender.isFromProposal}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{blocksUntilPayout && unlockAt && (
|
||||
<DueBlocks
|
||||
dueBlocks={blocksUntilPayout}
|
||||
endBlock={unlockAt}
|
||||
label={t('payout')}
|
||||
/>
|
||||
)}
|
||||
{blocksUntilUpdate && updateDue && (
|
||||
<DueBlocks
|
||||
dueBlocks={blocksUntilUpdate}
|
||||
endBlock={updateDue}
|
||||
label={t('update')}
|
||||
/>
|
||||
)}
|
||||
<BountyActionMessage
|
||||
bestNumber={bestNumber}
|
||||
blocksUntilUpdate={blocksUntilUpdate}
|
||||
status={status}
|
||||
/>
|
||||
<BountyActions
|
||||
bestNumber={bestNumber}
|
||||
description={description}
|
||||
fee={fee}
|
||||
index={index}
|
||||
proposals={proposals}
|
||||
status={status}
|
||||
value={value}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<BountyInfos
|
||||
beneficiary={beneficiary}
|
||||
proposals={proposals}
|
||||
status={status}
|
||||
/>
|
||||
</td>
|
||||
<td className='actions'>
|
||||
<div>
|
||||
<BountyExtraActions
|
||||
bestNumber={bestNumber}
|
||||
description={description}
|
||||
index={index}
|
||||
proposals={proposals}
|
||||
status={status}
|
||||
/>
|
||||
<ExpandButton
|
||||
expanded={isExpanded}
|
||||
onClick={toggleExpanded}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</StyledTr>
|
||||
<StyledTr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'}`}>
|
||||
<td />
|
||||
<td
|
||||
className='columar'
|
||||
colSpan={3}
|
||||
>
|
||||
<Columar>
|
||||
<Columar.Column>
|
||||
<LinkExternal
|
||||
data={index}
|
||||
type='bounty'
|
||||
withTitle
|
||||
/>
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
<div className='column'>
|
||||
<h5>{t('Proposer')}</h5>
|
||||
<AddressSmall value={proposer} />
|
||||
</div>
|
||||
<div className='column'>
|
||||
<h5>{t('Bond')}</h5>
|
||||
<div className='inline-balance'><FormatBalance value={bond} /></div>
|
||||
</div>
|
||||
{curator && (
|
||||
<div className='column'>
|
||||
<h5>{t("Curator's fee")}</h5>
|
||||
<div className='inline-balance'>{<FormatBalance value={fee} />}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='column'>
|
||||
{curator && !curatorDeposit.isZero() && (
|
||||
<>
|
||||
<h5>{t("Curator's deposit")}</h5>
|
||||
<div className='inline-balance'>
|
||||
<FormatBalance value={curatorDeposit} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
</td>
|
||||
<td />
|
||||
<td />
|
||||
<td>
|
||||
{proposals && (
|
||||
<div className='votes-table'>
|
||||
<VotersColumn
|
||||
option={'ayes'}
|
||||
proposals={proposals}
|
||||
status={status}
|
||||
/>
|
||||
<VotersColumn
|
||||
option={'nays'}
|
||||
proposals={proposals}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td />
|
||||
</StyledTr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTr = styled.tr`
|
||||
.description-column {
|
||||
max-width: 200px;
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
& .links {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
& .inline-balance {
|
||||
width: 50%;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.column {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 0 0.5rem;
|
||||
|
||||
h5 {
|
||||
text-align: right;
|
||||
padding: 0 1.7rem 0 0;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
& .td-info-action-row {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.td-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
& :only-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bounty-action-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
& > * + * {
|
||||
margin-left: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.block-to-time {
|
||||
font-size: var(--font-size-tiny);
|
||||
line-height: 1.5rem;
|
||||
color: var(--color-label);
|
||||
}
|
||||
|
||||
& .votes-table {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Bounty);
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { Button, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
curatorId: AccountId;
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
}
|
||||
|
||||
function AwardBounty ({ curatorId, description, index }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { awardBounty } = useBounties();
|
||||
const { allAccounts } = useAccounts();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [beneficiaryId, setBeneficiaryId] = useState<string | null>(null);
|
||||
const isCurator = useMemo(() => allAccounts.includes(curatorId.toString()), [allAccounts, curatorId]);
|
||||
|
||||
return isCurator
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
icon='award'
|
||||
isDisabled={false}
|
||||
label={t('Reward implementer')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
header={`${t('award bounty')} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The Curator account that will be used to send this transaction. Any applicable fees will be paid by this account.')}>
|
||||
<InputAddress
|
||||
defaultValue={curatorId}
|
||||
isDisabled={true}
|
||||
label={t('award with account')}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t("Reward the bounty to an implementer's account. The implementer will be able to claim the funds after a delay period.")}>
|
||||
<InputAddress
|
||||
label={t('implementer account')}
|
||||
onChange={setBeneficiaryId}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={curatorId}
|
||||
icon='check'
|
||||
label={t('Approve')}
|
||||
onStart={toggleOpen}
|
||||
params={[index, beneficiaryId]}
|
||||
tx={awardBounty}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default React.memo(AwardBounty);
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Button, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { permillOf, truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties, useUserRole } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
curatorId: AccountId;
|
||||
description: string;
|
||||
fee: BN;
|
||||
index: BountyIndex;
|
||||
}
|
||||
|
||||
function BountyAcceptCurator ({ curatorId, description, fee, index }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { acceptCurator } = useBounties();
|
||||
const { isCurator } = useUserRole(curatorId);
|
||||
const { bountyCuratorDeposit } = useBounties();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
|
||||
const deposit = useMemo(() => permillOf(fee, bountyCuratorDeposit), [fee, bountyCuratorDeposit]);
|
||||
|
||||
return isCurator
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
icon='check'
|
||||
isDisabled={false}
|
||||
label={t('Accept')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
header={`${t('accept curator role')} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Only the account proposed as curator by the council can create the assign curator transaction')}>
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('curator account')}
|
||||
type='account'
|
||||
value={curatorId.toString()}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t("This amount will be sent to your account after bounty is rewarded and you claim curator's fee.")}>
|
||||
<InputBalance
|
||||
defaultValue={fee.toString()}
|
||||
isDisabled
|
||||
label={t("curator's fee")}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('This amount will be reserved from your account and returned after bounty claim is confirmed or if you give up, unless you are slashed earlier.')}>
|
||||
<InputBalance
|
||||
defaultValue={deposit.toString()}
|
||||
isDisabled
|
||||
label={t("curator's deposit")}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={curatorId}
|
||||
icon='check'
|
||||
label={t('Accept Curator Role')}
|
||||
onStart={toggleOpen}
|
||||
params={[index]}
|
||||
tx={acceptCurator}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default React.memo(BountyAcceptCurator);
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { TxButton } from '@pezkuwi/react-components';
|
||||
import { useAccounts } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { isClaimable } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
beneficiaryId: AccountId;
|
||||
index: BountyIndex;
|
||||
payoutDue: BN;
|
||||
}
|
||||
|
||||
function BountyClaimAction ({ beneficiaryId, index, payoutDue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { claimBounty } = useBounties();
|
||||
const { allAccounts } = useAccounts();
|
||||
|
||||
const isBountyClaimable = useMemo(
|
||||
() => isClaimable(allAccounts, beneficiaryId, payoutDue),
|
||||
[allAccounts, beneficiaryId, payoutDue]
|
||||
);
|
||||
|
||||
return isBountyClaimable
|
||||
? (
|
||||
<TxButton
|
||||
accountId={beneficiaryId}
|
||||
icon='plus'
|
||||
label={t('Claim')}
|
||||
params={[index]}
|
||||
tx={claimBounty}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default React.memo(BountyClaimAction);
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { Button, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCollectiveInstance, useCollectiveMembers, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
proposals?: DeriveCollectiveProposal[];
|
||||
}
|
||||
|
||||
const BOUNTY_METHODS = ['approveBounty', 'closeBounty'];
|
||||
|
||||
function BountyInitiateVoting ({ description, index, proposals }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { isMember, members } = useCollectiveMembers('council');
|
||||
const councilMod = useCollectiveInstance('council');
|
||||
const { approveBounty, closeBounty } = useBounties();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [threshold, setThreshold] = useState<BN>();
|
||||
|
||||
useEffect((): void => {
|
||||
members && setThreshold(
|
||||
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
|
||||
);
|
||||
}, [api, members]);
|
||||
|
||||
const approveBountyProposal = useRef(approveBounty(index));
|
||||
const closeBountyProposal = useRef(closeBounty(index));
|
||||
|
||||
const isVotingInitiated = useMemo(
|
||||
() => proposals?.filter(({ proposal }) =>
|
||||
proposal && BOUNTY_METHODS.includes(proposal.method)
|
||||
).length !== 0,
|
||||
[proposals]
|
||||
);
|
||||
|
||||
return isMember && !isVotingInitiated && councilMod
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
icon='step-forward'
|
||||
isDisabled={false}
|
||||
label={t('Initiate voting')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
header={`${t('Initiate voting')} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The council member that will create a motion, submission equates to an "aye" vote for chosen option.')}>
|
||||
<InputAddress
|
||||
filter={members}
|
||||
label={t('vote with account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='check'
|
||||
isDisabled={false}
|
||||
label={t('Approve')}
|
||||
onStart={toggleOpen}
|
||||
params={[threshold, approveBountyProposal.current, approveBountyProposal.current.length]}
|
||||
tx={api.tx[councilMod].propose}
|
||||
/>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='ban'
|
||||
isDisabled={false}
|
||||
label={t('Reject')}
|
||||
onStart={toggleOpen}
|
||||
params={[threshold, closeBountyProposal.current, closeBountyProposal.current.length]}
|
||||
tx={api.tx[councilMod].propose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default React.memo(BountyInitiateVoting);
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { Balance, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { Button, InputAddress, InputBalance, MarkError, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCollectiveInstance, useCollectiveMembers, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
description: string
|
||||
index: BountyIndex;
|
||||
proposals?: DeriveCollectiveProposal[];
|
||||
value: Balance;
|
||||
}
|
||||
|
||||
const BOUNTY_METHODS = ['proposeCurator'];
|
||||
|
||||
function ProposeCuratorAction ({ description, index, proposals, value }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { isMember, members } = useCollectiveMembers('council');
|
||||
const councilMod = useCollectiveInstance('council');
|
||||
const { proposeCurator } = useBounties();
|
||||
const [isOpen, toggleOpen] = useToggle();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [curatorId, setCuratorId] = useState<string | null>(null);
|
||||
const [threshold, setThreshold] = useState<BN>();
|
||||
const [fee, setFee] = useState<BN | null>();
|
||||
const [isFeeValid, setIsFeeValid] = useState(false);
|
||||
|
||||
useEffect((): void => {
|
||||
members && setThreshold(
|
||||
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
|
||||
);
|
||||
}, [api, members]);
|
||||
|
||||
const proposeCuratorProposal = useMemo(
|
||||
() => curatorId && proposeCurator(index, curatorId, fee),
|
||||
[curatorId, fee, index, proposeCurator]
|
||||
);
|
||||
|
||||
const isVotingInitiated = useMemo(
|
||||
() => proposals?.filter(({ proposal }) =>
|
||||
proposal && BOUNTY_METHODS.includes(proposal.method)
|
||||
).length !== 0,
|
||||
[proposals]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFeeValid(!!fee && !!value?.gt(fee));
|
||||
}, [value, fee]);
|
||||
|
||||
return isMember && !isVotingInitiated && councilMod
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
icon='step-forward'
|
||||
isDisabled={false}
|
||||
label={t('Propose curator')}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
header={`${t('Propose curator')} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
testId='propose-curator-modal'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The council member that will create the motion.')}>
|
||||
<InputAddress
|
||||
filter={members}
|
||||
label={t('proposing account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('Choose a curator whose background and expertise is such that they are capable of determining when the task is complete.')}>
|
||||
<InputAddress
|
||||
label={t('select curator')}
|
||||
onChange={setCuratorId}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('Part of the bounty value that will go to the Curator as a reward for their work')}>
|
||||
<InputBalance
|
||||
isError={!isFeeValid}
|
||||
isZeroable
|
||||
label={t("curator's fee")}
|
||||
onChange={setFee}
|
||||
value={fee}
|
||||
/>
|
||||
{!isFeeValid && (
|
||||
<MarkError content={t("Curator's fee can't be higher than bounty value.")} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='check'
|
||||
isDisabled={!isFeeValid}
|
||||
label={t('Propose curator')}
|
||||
onStart={toggleOpen}
|
||||
params={[threshold, proposeCuratorProposal, proposeCuratorProposal?.length]}
|
||||
tx={api.tx[councilMod].propose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default React.memo(ProposeCuratorAction);
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { Balance, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useBountyStatus } from '../hooks/index.js';
|
||||
import AwardBounty from './AwardBounty.js';
|
||||
import BountyAcceptCurator from './BountyAcceptCurator.js';
|
||||
import BountyClaimAction from './BountyClaimAction.js';
|
||||
import BountyInitiateVoting from './BountyInitiateVoting.js';
|
||||
import ProposeCuratorAction from './ProposeCuratorAction.js';
|
||||
|
||||
interface Props {
|
||||
bestNumber: BN;
|
||||
description: string;
|
||||
fee?: BN;
|
||||
index: BountyIndex;
|
||||
proposals?: DeriveCollectiveProposal[];
|
||||
status: PalletBountiesBountyStatus;
|
||||
value: Balance;
|
||||
}
|
||||
|
||||
export function BountyActions ({ bestNumber, description, fee, index, proposals, status, value }: Props): React.ReactElement<Props> {
|
||||
const { beneficiary, curator, unlockAt } = useBountyStatus(status);
|
||||
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{status.isProposed &&
|
||||
<BountyInitiateVoting
|
||||
description={description}
|
||||
index={index}
|
||||
proposals={proposals}
|
||||
/>
|
||||
}
|
||||
{status.isFunded &&
|
||||
<ProposeCuratorAction
|
||||
description={description}
|
||||
index={index}
|
||||
proposals={proposals}
|
||||
value={value}
|
||||
/>
|
||||
}
|
||||
{status.isCuratorProposed && curator && fee &&
|
||||
<BountyAcceptCurator
|
||||
curatorId={curator}
|
||||
description={description}
|
||||
fee={fee}
|
||||
index={index}
|
||||
/>
|
||||
}
|
||||
{status.isPendingPayout && beneficiary && blocksUntilPayout &&
|
||||
<BountyClaimAction
|
||||
beneficiaryId={beneficiary}
|
||||
index={index}
|
||||
payoutDue={blocksUntilPayout}
|
||||
/>
|
||||
}
|
||||
{status.isActive && curator &&
|
||||
<AwardBounty
|
||||
curatorId={curator}
|
||||
description={description}
|
||||
index={index}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Button, Input, InputAddress, InputBalance, MarkError, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { calculateBountyBond, countUtf8Bytes } from './helpers/index.js';
|
||||
import { useBalance, useBounties } from './hooks/index.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
const MIN_TITLE_LEN = 1;
|
||||
const TITLE_DEFAULT_VALUE = '';
|
||||
const BOUNTY_DEFAULT_VALUE = BN_ZERO;
|
||||
|
||||
function BountyCreate () {
|
||||
const { t } = useTranslation();
|
||||
const { bountyDepositBase, bountyValueMinimum, dataDepositPerByte, maximumReasonLength, proposeBounty } = useBounties();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const balance = useBalance(accountId);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [bond, setBond] = useState(bountyDepositBase);
|
||||
const [value, setValue] = useState<BN | undefined>(BOUNTY_DEFAULT_VALUE);
|
||||
const [isOpen, toggleIsOpen] = useToggle();
|
||||
const [isTitleValid, setIsTitleValid] = useState(false);
|
||||
const [isValueValid, setIsValueValid] = useState(false);
|
||||
const [hasFunds, setHasFunds] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTitleValid(title?.length >= MIN_TITLE_LEN && countUtf8Bytes(title) <= maximumReasonLength);
|
||||
}, [maximumReasonLength, title]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsValueValid(!!value?.gte(bountyValueMinimum));
|
||||
}, [bountyValueMinimum, value]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasFunds(!!balance?.gte(bond));
|
||||
}, [balance, bond]);
|
||||
|
||||
const isValid = hasFunds && isTitleValid && isValueValid;
|
||||
|
||||
const onTitleChange = useCallback((value: string) => {
|
||||
setTitle(value);
|
||||
setBond(calculateBountyBond(value, bountyDepositBase, dataDepositPerByte));
|
||||
}, [bountyDepositBase, dataDepositPerByte]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={false}
|
||||
label={t('Add Bounty')}
|
||||
onClick={toggleIsOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
className='ui--AddBountyModal'
|
||||
header={t('Add Bounty')}
|
||||
onClose={toggleIsOpen}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Description of the Bounty (to be stored on-chain)')}>
|
||||
<Input
|
||||
autoFocus
|
||||
defaultValue={TITLE_DEFAULT_VALUE}
|
||||
isError={!isTitleValid}
|
||||
label={t('bounty title')}
|
||||
onChange={onTitleChange}
|
||||
value={title}
|
||||
/>
|
||||
{!isTitleValid && (title !== TITLE_DEFAULT_VALUE) && (
|
||||
<MarkError content={t('Title too long')} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('How much should be paid out for completed Bounty. Upon funding, the amount will be reserved in treasury.')}>
|
||||
<InputBalance
|
||||
isError={!isValueValid}
|
||||
isZeroable
|
||||
label={t('bounty requested allocation')}
|
||||
onChange={setValue}
|
||||
value={value}
|
||||
/>
|
||||
{!isValueValid && !value?.eq(BOUNTY_DEFAULT_VALUE) && (
|
||||
<MarkError content={t('Allocation value is smaller than the minimum bounty value.')} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('Proposer bond depends on bounty title length.')}>
|
||||
<InputBalance
|
||||
defaultValue={bond.toString()}
|
||||
isDisabled
|
||||
label={t('bounty bond')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('This account will propose the bounty. Bond amount will be reserved on its balance.')}>
|
||||
<InputAddress
|
||||
isError={!hasFunds}
|
||||
label={t('submit with account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
{!hasFunds && (
|
||||
<MarkError content={t('Account does not have enough funds.')} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='plus'
|
||||
isDisabled={!accountId || !isValid}
|
||||
label={t('Add Bounty')}
|
||||
onStart={toggleIsOpen}
|
||||
params={[value, title]}
|
||||
tx={proposeBounty}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BountyCreate);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
curatorId: AccountId;
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
function BountyRejectCurator ({ curatorId, description, index, toggleOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { unassignCurator } = useBounties();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={`${t('reject curator')} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Only the account proposed as curator by the council can create the unassign curator transaction ')}>
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('curator account')}
|
||||
type='account'
|
||||
value={curatorId.toString()}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={curatorId}
|
||||
icon='times'
|
||||
label={t('Reject')}
|
||||
onStart={toggleOpen}
|
||||
params={[index]}
|
||||
tx={unassignCurator}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BountyRejectCurator);
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useCollectiveInstance, useCollectiveMembers } from '@pezkuwi/react-hooks';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
function CloseBounty ({ description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { members } = useCollectiveMembers('council');
|
||||
const councilMod = useCollectiveInstance('council');
|
||||
const { closeBounty } = useBounties();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [threshold, setThreshold] = useState<BN>();
|
||||
|
||||
useEffect((): void => {
|
||||
members && setThreshold(
|
||||
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
|
||||
);
|
||||
}, [api, members]);
|
||||
|
||||
const closeBountyProposal = useRef(closeBounty(index));
|
||||
|
||||
if (!councilMod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={`${t('close bounty')} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The council member that will create the close bounty proposal, submission equates to an "aye" vote.')}>
|
||||
<InputAddress
|
||||
filter={members}
|
||||
label={t('propose with account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='ban'
|
||||
isDisabled={false}
|
||||
label={t('Close Bounty')}
|
||||
onStart={toggleOpen}
|
||||
params={[threshold, closeBountyProposal.current, closeBountyProposal.current.length]}
|
||||
tx={api.tx[councilMod].propose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CloseBounty);
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Input, InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useBlockTime } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { increaseDateByBlocks } from '../helpers/increaseDateByBlocks.js';
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
curatorId: AccountId;
|
||||
description: string
|
||||
index: BountyIndex;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
function ExtendBountyExpiryAction ({ curatorId, description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { bountyUpdatePeriod, extendBountyExpiry } = useBounties();
|
||||
const [remark, setRemark] = useState('');
|
||||
const [blockTime, timeAsText] = useBlockTime(bountyUpdatePeriod);
|
||||
|
||||
const onRemarkChange = useCallback((value: string) => {
|
||||
setRemark(value);
|
||||
}, []);
|
||||
|
||||
const expiryDate = useMemo(() => bountyUpdatePeriod && increaseDateByBlocks(bountyUpdatePeriod, blockTime), [bountyUpdatePeriod, blockTime]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
header={`${t('extend expiry')} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Only curator can extend the bounty time.')}>
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('curator account')}
|
||||
type='account'
|
||||
value={curatorId.toString()}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{expiryDate &&
|
||||
<Modal.Columns hint={t(`Bounty expiry time will be set to ${timeAsText} from now.`)}>
|
||||
<Input
|
||||
isDisabled
|
||||
label={t('new expiry date and time')}
|
||||
value={`${expiryDate.toLocaleDateString()} ${expiryDate.toLocaleTimeString()}`}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
}
|
||||
<Modal.Columns hint={t("The note that will be added to the transaction. It won't be stored on chain")}>
|
||||
<Input
|
||||
autoFocus
|
||||
defaultValue={''}
|
||||
label={t('bounty remark')}
|
||||
onChange={onRemarkChange}
|
||||
value={remark}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={curatorId}
|
||||
icon='check'
|
||||
label={t('Accept')}
|
||||
onStart={toggleOpen}
|
||||
params={[index, remark]}
|
||||
tx={extendBountyExpiry}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ExtendBountyExpiryAction);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
curatorId: AccountId;
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
function BountyGiveUpCurator ({ curatorId, description, index, toggleOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { unassignCurator } = useBounties();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={`${t("give up curator's role")} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('You are giving up your curator role, the bounty will return to the Funded state. You will get your deposit back.')}>
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('curator account')}
|
||||
type='account'
|
||||
value={curatorId.toString()}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={curatorId}
|
||||
icon='check'
|
||||
label={t('Give up')}
|
||||
onStart={toggleOpen}
|
||||
params={[index]}
|
||||
tx={unassignCurator}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BountyGiveUpCurator);
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsicFunction } from '@pezkuwi/api/types';
|
||||
import type { AccountId, BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { ValidUnassignCuratorAction } from '../types.js';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getTreasuryProposalThreshold } from '@pezkuwi/apps-config';
|
||||
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useCollectiveInstance, useCollectiveMembers } from '@pezkuwi/react-hooks';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { truncateTitle } from '../helpers/index.js';
|
||||
import { useBounties } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
action: ValidUnassignCuratorAction;
|
||||
curatorId: AccountId;
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
toggleOpen: () => void;
|
||||
}
|
||||
|
||||
interface ActionProperties {
|
||||
filter: string[];
|
||||
header: string;
|
||||
params: unknown[] | (() => unknown[]) | undefined;
|
||||
proposingAccountTip: string;
|
||||
tip: string;
|
||||
title: string;
|
||||
tx: null | SubmittableExtrinsicFunction<'promise'>;
|
||||
}
|
||||
|
||||
function SlashCurator ({ action, curatorId, description, index, toggleOpen }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { members } = useCollectiveMembers('council');
|
||||
const councilMod = useCollectiveInstance('council');
|
||||
const { unassignCurator } = useBounties();
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [threshold, setThreshold] = useState<BN>();
|
||||
const { allAccounts } = useAccounts();
|
||||
|
||||
useEffect((): void => {
|
||||
members && setThreshold(
|
||||
new BN(Math.ceil(members.length * getTreasuryProposalThreshold(api)))
|
||||
);
|
||||
}, [api, members]);
|
||||
|
||||
const unassignCuratorProposal = useMemo(() => unassignCurator(index), [index, unassignCurator]);
|
||||
|
||||
const actionProperties = useMemo<Record<ValidUnassignCuratorAction, ActionProperties>>(() => ({
|
||||
SlashCuratorAction: {
|
||||
filter: allAccounts,
|
||||
header: t('This action will Slash the Curator.'),
|
||||
params: [index],
|
||||
proposingAccountTip: t('The account that will create the transaction.'),
|
||||
tip: t("Curator's deposit will be slashed and curator will be unassigned. Bounty will return to the Funded state."),
|
||||
title: t('Slash curator'),
|
||||
tx: unassignCurator
|
||||
},
|
||||
SlashCuratorMotion: {
|
||||
filter: members,
|
||||
header: t('This action will create a Council motion to slash the Curator.'),
|
||||
params: [threshold, unassignCuratorProposal, unassignCuratorProposal?.length],
|
||||
proposingAccountTip: t('The council member that will create the motion, submission equates to an "aye" vote.'),
|
||||
tip: t("If the motion is approved, Curator's deposit will be slashed and Curator will be unassigned. Bounty will return to the Funded state."),
|
||||
title: t('Slash curator'),
|
||||
tx: councilMod && api.tx[councilMod].propose
|
||||
},
|
||||
UnassignCurator: {
|
||||
filter: members,
|
||||
header: t('This action will create a Council motion to unassign the Curator.'),
|
||||
params: [threshold, unassignCuratorProposal, unassignCuratorProposal?.length],
|
||||
proposingAccountTip: t('The council member that will create the motion, submission equates to an "aye" vote.'),
|
||||
tip: t('If the motion is approved, the current Curator will be unassigned and the Bounty will return to the Funded state.'),
|
||||
title: t('Unassign curator'),
|
||||
tx: councilMod && api.tx[councilMod].propose
|
||||
}
|
||||
}), [t, index, unassignCurator, api, allAccounts, councilMod, members, threshold, unassignCuratorProposal]);
|
||||
|
||||
const { filter, params, proposingAccountTip, tip, title, tx } = actionProperties[action];
|
||||
|
||||
if (!tx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={`${title} - "${truncateTitle(description, 30)}"`}
|
||||
onClose={toggleOpen}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={proposingAccountTip}>
|
||||
<InputAddress
|
||||
filter={filter}
|
||||
label={t('proposing account')}
|
||||
onChange={setAccountId}
|
||||
type='account'
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={tip}>
|
||||
<InputAddress
|
||||
defaultValue={curatorId}
|
||||
isDisabled
|
||||
label={t('current curator')}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountId}
|
||||
icon='check'
|
||||
label='Approve'
|
||||
onStart={toggleOpen}
|
||||
params={params}
|
||||
tx={tx}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(SlashCurator);
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { BountyIndex } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { ValidUnassignCuratorAction } from '../types.js';
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Menu, Popup } from '@pezkuwi/react-components';
|
||||
import { useCollectiveMembers, useToggle } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { determineUnassignCuratorAction } from '../helpers/index.js';
|
||||
import { useBountyStatus, useUserRole } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import BountyRejectCurator from './BountyRejectCurator.js';
|
||||
import CloseBounty from './CloseBounty.js';
|
||||
import ExtendBountyExpiryAction from './ExtendBountyExpiryAction.js';
|
||||
import GiveUp from './GiveUp.js';
|
||||
import SlashCurator from './SlashCurator.js';
|
||||
|
||||
interface Props {
|
||||
bestNumber: BN;
|
||||
className?: string;
|
||||
description: string;
|
||||
index: BountyIndex;
|
||||
proposals?: DeriveCollectiveProposal[];
|
||||
status: PalletBountiesBountyStatus;
|
||||
}
|
||||
|
||||
function Index ({ bestNumber, className, description, index, proposals, status }: Props): React.ReactElement<Props> | null {
|
||||
const [isCloseBountyOpen, toggleCloseBounty] = useToggle();
|
||||
const [isRejectCuratorOpen, toggleRejectCurator] = useToggle();
|
||||
const [isSlashCuratorOpen, toggleSlashCurator] = useToggle();
|
||||
const [isExtendExpiryOpen, toggleExtendExpiry] = useToggle();
|
||||
const [isGiveUpCuratorOpen, toggleGiveUpCurator] = useToggle();
|
||||
const [selectedAction, setSlashAction] = useState<ValidUnassignCuratorAction>();
|
||||
const { t } = useTranslation();
|
||||
const { isMember } = useCollectiveMembers('council');
|
||||
const { curator, updateDue } = useBountyStatus(status);
|
||||
const { isCurator, roles } = useUserRole(curator);
|
||||
|
||||
const blocksUntilUpdate = useMemo(() => updateDue?.sub(bestNumber), [bestNumber, updateDue]);
|
||||
const availableSlashActions = determineUnassignCuratorAction(roles, status, blocksUntilUpdate);
|
||||
|
||||
const slashCuratorActionNames = useRef<Record<ValidUnassignCuratorAction, string>>({
|
||||
SlashCuratorAction: t('Slash curator'),
|
||||
SlashCuratorMotion: t('Slash curator (Council)'),
|
||||
UnassignCurator: t('Unassign curator')
|
||||
});
|
||||
|
||||
const existingCloseBountyProposal = useMemo(
|
||||
() => proposals?.find(({ proposal }) =>
|
||||
proposal && proposal.method === 'closeBounty'
|
||||
),
|
||||
[proposals]
|
||||
);
|
||||
|
||||
const existingUnassignCuratorProposal = useMemo(
|
||||
() => proposals?.find(({ proposal }) =>
|
||||
proposal && proposal.method === 'unassignCurator'
|
||||
),
|
||||
[proposals]
|
||||
);
|
||||
|
||||
const showCloseBounty = (status.isFunded || status.isActive || status.isCuratorProposed) && isMember && !existingCloseBountyProposal;
|
||||
const showRejectCurator = status.isCuratorProposed && isCurator;
|
||||
const showGiveUpCurator = status.isActive && isCurator;
|
||||
const showExtendExpiry = status.isActive && isCurator;
|
||||
const showSlashCurator = (status.isCuratorProposed || status.isActive || status.isPendingPayout) && !existingUnassignCuratorProposal && availableSlashActions.length !== 0;
|
||||
|
||||
const hasNoItems = !(showCloseBounty || showRejectCurator || showExtendExpiry || showSlashCurator || showGiveUpCurator);
|
||||
|
||||
const slashCurator = useCallback(
|
||||
(actionName: ValidUnassignCuratorAction) =>
|
||||
(): void => {
|
||||
setSlashAction(actionName);
|
||||
toggleSlashCurator();
|
||||
},
|
||||
[toggleSlashCurator]
|
||||
);
|
||||
|
||||
return !hasNoItems
|
||||
? (
|
||||
<div className={className}>
|
||||
{isCloseBountyOpen &&
|
||||
<CloseBounty
|
||||
description={description}
|
||||
index={index}
|
||||
toggleOpen={toggleCloseBounty}
|
||||
/>
|
||||
}
|
||||
{isRejectCuratorOpen && curator &&
|
||||
<BountyRejectCurator
|
||||
curatorId={curator}
|
||||
description={description}
|
||||
index={index}
|
||||
toggleOpen={toggleRejectCurator}
|
||||
/>
|
||||
}
|
||||
{isExtendExpiryOpen && curator &&
|
||||
<ExtendBountyExpiryAction
|
||||
curatorId={curator}
|
||||
description={description}
|
||||
index={index}
|
||||
toggleOpen={toggleExtendExpiry}
|
||||
/>
|
||||
}
|
||||
{isGiveUpCuratorOpen && curator &&
|
||||
<GiveUp
|
||||
curatorId={curator}
|
||||
description={description}
|
||||
index={index}
|
||||
toggleOpen={toggleGiveUpCurator}
|
||||
/>
|
||||
}
|
||||
{isSlashCuratorOpen && curator && selectedAction &&
|
||||
<SlashCurator
|
||||
action={selectedAction}
|
||||
curatorId={curator}
|
||||
description={description}
|
||||
index={index}
|
||||
toggleOpen={toggleSlashCurator}
|
||||
/>
|
||||
}
|
||||
<Popup
|
||||
value={
|
||||
<Menu className='settings-menu'>
|
||||
{showCloseBounty && (
|
||||
<Menu.Item
|
||||
key='closeBounty'
|
||||
label={t('Close')}
|
||||
onClick={toggleCloseBounty}
|
||||
/>
|
||||
)}
|
||||
{showRejectCurator && (
|
||||
<Menu.Item
|
||||
key='rejectCurator'
|
||||
label={t('Reject curator')}
|
||||
onClick={toggleRejectCurator}
|
||||
/>
|
||||
)}
|
||||
{showExtendExpiry && (
|
||||
<Menu.Item
|
||||
key='extendExpiry'
|
||||
label={t('Extend expiry')}
|
||||
onClick={toggleExtendExpiry}
|
||||
/>
|
||||
)}
|
||||
{showGiveUpCurator && (
|
||||
<Menu.Item
|
||||
key='giveUpCurator'
|
||||
label={t('Give up')}
|
||||
onClick={toggleGiveUpCurator}
|
||||
/>
|
||||
)}
|
||||
{showSlashCurator && availableSlashActions.map((actionName) => (
|
||||
<Menu.Item
|
||||
key={actionName}
|
||||
label={slashCuratorActionNames.current[actionName]}
|
||||
onClick={slashCurator(actionName)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export default React.memo(Index);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { LabelHelp, styled } from '@pezkuwi/react-components';
|
||||
|
||||
import { proposalNameToDisplay } from '../helpers/extendedStatuses.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
proposal: DeriveCollectiveProposal;
|
||||
status: PalletBountiesBountyStatus;
|
||||
}
|
||||
|
||||
function VotingDescriptionInfo ({ className, proposal, status }: Props): React.ReactElement<Props> {
|
||||
const bestProposalName = proposalNameToDisplay(proposal, status);
|
||||
const { t } = useTranslation();
|
||||
const votingDescriptions = useRef<Record<string, string>>({
|
||||
approveBounty: t('Bounty approval under voting'),
|
||||
closeBounty: t('Bounty rejection under voting'),
|
||||
proposeCurator: t('Curator proposal under voting'),
|
||||
slashCurator: t('Curator slash under voting'),
|
||||
unassignCurator: t('Unassign curator under voting')
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledDiv
|
||||
className={className}
|
||||
data-testid='voting-description'
|
||||
>
|
||||
{bestProposalName && votingDescriptions.current[bestProposalName] &&
|
||||
<LabelHelp help={votingDescriptions.current[bestProposalName]} />
|
||||
}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
margin-left: 0.2rem;
|
||||
`;
|
||||
|
||||
export default React.memo(VotingDescriptionInfo);
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function VotingLink ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StyledA
|
||||
className={className}
|
||||
href='#/council/motions'
|
||||
>
|
||||
{t('Voting')}
|
||||
</StyledA>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledA = styled.a`
|
||||
line-height: 0.85rem;
|
||||
font-size: var(--font-size-tiny);
|
||||
text-decoration: underline;
|
||||
`;
|
||||
|
||||
export default React.memo(VotingLink);
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
import { useCollectiveMembers } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import VotingDescriptionInfo from './VotingDescriptionInfo.js';
|
||||
import VotingLink from './VotingLink.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
proposal: DeriveCollectiveProposal;
|
||||
status: PalletBountiesBountyStatus;
|
||||
}
|
||||
|
||||
function VotingSummary ({ className, proposal, status }: Props): React.ReactElement<Props> {
|
||||
const { members } = useCollectiveMembers('council');
|
||||
const { t } = useTranslation();
|
||||
const ayes = useMemo(() => proposal?.votes?.ayes?.length, [proposal]);
|
||||
const nays = useMemo(() => proposal?.votes?.nays?.length, [proposal]);
|
||||
const threshold = useMemo(() => proposal?.votes?.threshold.toNumber(), [proposal]);
|
||||
const nayThreshold = useMemo(() => members?.length && threshold ? (members.length - threshold + 1) : 0, [members, threshold]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{proposal && (
|
||||
<StyledDiv
|
||||
className={className}
|
||||
data-testid='voting-summary'
|
||||
>
|
||||
<div className='voting-summary-text'><span>{t('Aye')}</span> <b>{ayes}/{threshold}</b></div>
|
||||
<div className='voting-summary-text'><span>{t('Nay')}</span> <b>{nays}/{nayThreshold}</b></div>
|
||||
<div className='link-info'>
|
||||
<VotingLink />
|
||||
<VotingDescriptionInfo
|
||||
proposal={proposal}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</StyledDiv>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.voting-summary-text {
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.5rem;
|
||||
color: var(--color-label);
|
||||
|
||||
span {
|
||||
min-width: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.link-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(VotingSummary);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressSmall } from '@pezkuwi/react-components';
|
||||
|
||||
import Description from '../Description.js';
|
||||
import { getProposalToDisplay } from '../helpers/extendedStatuses.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import VotingSummary from './VotingSummary.js';
|
||||
|
||||
interface Props {
|
||||
beneficiary?: AccountId;
|
||||
proposals?: DeriveCollectiveProposal[];
|
||||
status: PalletBountiesBountyStatus;
|
||||
}
|
||||
|
||||
function BountyInfos ({ beneficiary, proposals, status }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const proposalToDisplay = useMemo(() => proposals && getProposalToDisplay(proposals, status), [proposals, status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{proposalToDisplay &&
|
||||
<VotingSummary
|
||||
proposal={proposalToDisplay}
|
||||
status={status}
|
||||
/>
|
||||
}
|
||||
{beneficiary && (
|
||||
<div>
|
||||
<AddressSmall value={beneficiary} />
|
||||
<Description description={t('Beneficiary')} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BountyInfos);
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { BN, BN_HUNDRED, BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useBounties, useBountyStatus } from '../hooks/index.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import BountyInfo from './BountyInfo.js';
|
||||
|
||||
interface Props {
|
||||
bestNumber: BN;
|
||||
blocksUntilUpdate?: BN;
|
||||
status: PalletBountiesBountyStatus;
|
||||
}
|
||||
|
||||
export const BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING = 10;
|
||||
const BLOCKS_LEFT_TO_SHOW_WARNING = new BN('10000');
|
||||
|
||||
function BountyActionMessage ({ bestNumber, blocksUntilUpdate, status }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { unlockAt } = useBountyStatus(status);
|
||||
const { bountyUpdatePeriod } = useBounties();
|
||||
|
||||
const blocksUntilPayout = useMemo(() => unlockAt?.sub(bestNumber), [bestNumber, unlockAt]);
|
||||
|
||||
const blocksPercentageLeftToShowWarning = bountyUpdatePeriod?.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).div(BN_HUNDRED);
|
||||
const blocksToShowWarning = blocksPercentageLeftToShowWarning ?? BLOCKS_LEFT_TO_SHOW_WARNING;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{blocksUntilUpdate?.lte(BN_ZERO) && (
|
||||
<BountyInfo
|
||||
description={t('Update overdue')}
|
||||
type='warning'
|
||||
/>
|
||||
)}
|
||||
{blocksUntilUpdate?.lt(blocksToShowWarning) && blocksUntilUpdate?.gt(BN_ZERO) && (
|
||||
<BountyInfo
|
||||
description={t('Close deadline')}
|
||||
type='warning'
|
||||
/>
|
||||
)}
|
||||
{status.isApproved && (
|
||||
<BountyInfo
|
||||
description={t('Waiting for Bounty Funding')}
|
||||
type='info'
|
||||
/>
|
||||
)}
|
||||
{status.isCuratorProposed && (
|
||||
<BountyInfo
|
||||
description={t("Waiting for Curator's acceptance")}
|
||||
type='info'
|
||||
/>
|
||||
)}
|
||||
{blocksUntilPayout?.lt(BN_ZERO) &&
|
||||
<BountyInfo
|
||||
description={t('Waiting for implementer to claim')}
|
||||
type='info'
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BountyActionMessage);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, styled } from '@pezkuwi/react-components';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
description: string;
|
||||
type?: 'info' | 'warning';
|
||||
}
|
||||
|
||||
function BountyInfo ({ className = '', description, type = 'info' }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{type === 'warning' && (
|
||||
<div className='info-icon'>
|
||||
<Icon icon={'exclamation-triangle'} />
|
||||
</div>
|
||||
)}
|
||||
<div className='description'>
|
||||
{description}
|
||||
</div>
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1.5rem;
|
||||
|
||||
.info-icon {
|
||||
margin-right: 0.2rem;
|
||||
|
||||
svg {
|
||||
color: var(--color-bounty-info);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-weight: var(--font-weight-normal);
|
||||
var(font-size: var(--font-size-tiny);)
|
||||
line-height: 0.864rem;
|
||||
color: var(--color-label);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(BountyInfo);
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { StatusName } from './types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
|
||||
import { insertSpaceBeforeCapitalLetter } from './helpers/index.js';
|
||||
|
||||
interface Props {
|
||||
bountyStatus: StatusName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function BountyStatusView ({ bountyStatus, className = '' }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv
|
||||
className={className}
|
||||
data-testid={'bountyStatus'}
|
||||
>
|
||||
{insertSpaceBeforeCapitalLetter(bountyStatus)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export default React.memo(BountyStatusView);
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Codec } from '@pezkuwi/types/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AddressSmall } from '@pezkuwi/react-components';
|
||||
|
||||
import Description from './Description.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
curator: Codec;
|
||||
isFromProposal: boolean;
|
||||
}
|
||||
|
||||
function Curator ({ curator, isFromProposal }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AddressSmall value={curator.toString()} />
|
||||
{isFromProposal && <Description description={t('Proposed Curator')} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Curator);
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
dataTestId?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function Description ({ className = '', dataTestId = '', description }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<StyledDiv
|
||||
className={className}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{description}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
margin-top: 0.28rem;
|
||||
font-size: var(--font-size-tiny);
|
||||
line-height: 0.85rem;
|
||||
color: var(--color-label);
|
||||
`;
|
||||
|
||||
export default React.memo(Description);
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { BlockToTime } from '@pezkuwi/react-query';
|
||||
import { formatNumber } from '@pezkuwi/util';
|
||||
|
||||
interface Props {
|
||||
dueBlocks: BN;
|
||||
endBlock: BN;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function DueBlocks ({ dueBlocks, endBlock, label }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<>
|
||||
{dueBlocks.gtn(0) && (
|
||||
<>
|
||||
<BlockToTime value={dueBlocks}>
|
||||
({label})
|
||||
</BlockToTime>
|
||||
#{formatNumber(endBlock)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(DueBlocks);
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BountyApi } from './hooks/useBounties.js';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { useTreasury } from '@pezkuwi/react-hooks';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
import { BN, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
info: BountyApi;
|
||||
}
|
||||
|
||||
function Summary ({ className = '', info: { bestNumber, bounties, bountyCount, childCount } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { spendPeriod } = useTreasury();
|
||||
|
||||
const totalValue = useMemo(
|
||||
() => (bounties || []).reduce((total, { bounty: { value } }) => total.iadd(value), new BN(0)),
|
||||
[bounties]
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryBox className={`${className} ui--BountySummary`}>
|
||||
<section>
|
||||
{bounties && (
|
||||
<CardSummary label={t('active')}>
|
||||
{formatNumber(bounties.length)}
|
||||
</CardSummary>
|
||||
)}
|
||||
{bountyCount && bounties && (
|
||||
<CardSummary label={t('past')}>
|
||||
{formatNumber(bountyCount.subn(bounties.length))}
|
||||
</CardSummary>
|
||||
)}
|
||||
{childCount && (
|
||||
<CardSummary label={t('children')}>
|
||||
{formatNumber(childCount)}
|
||||
</CardSummary>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<CardSummary label={t('active total')}>
|
||||
<FormatBalance
|
||||
value={totalValue}
|
||||
withSi
|
||||
/>
|
||||
</CardSummary>
|
||||
</section>
|
||||
<section>
|
||||
{bestNumber && !spendPeriod.isZero() && (
|
||||
<CardSummary
|
||||
label={t('funding period')}
|
||||
progress={{
|
||||
total: spendPeriod,
|
||||
value: bestNumber.mod(spendPeriod),
|
||||
withTime: true
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</SummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { AddressSmall, Icon, styled } from '@pezkuwi/react-components';
|
||||
|
||||
import { getProposalToDisplay } from './helpers/extendedStatuses.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
option: 'ayes' | 'nays';
|
||||
proposals: DeriveCollectiveProposal[];
|
||||
status: PalletBountiesBountyStatus;
|
||||
}
|
||||
|
||||
const icons: Record<string, IconName> = {
|
||||
ayes: 'check',
|
||||
nays: 'times'
|
||||
} as const;
|
||||
|
||||
function VotersColumn ({ className, option, proposals, status }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const proposal = useMemo(() => getProposalToDisplay(proposals, status), [proposals, status]);
|
||||
const votes = useMemo(() => option === 'ayes' ? proposal?.votes?.ayes : proposal?.votes?.nays, [proposal, option]);
|
||||
|
||||
const voters = useMemo(() => votes?.map((accountId) =>
|
||||
<div
|
||||
className='voter'
|
||||
data-testid={`voters_${option}_${accountId.toString()}`}
|
||||
key={accountId.toString()}
|
||||
>
|
||||
<AddressSmall value={accountId} />
|
||||
</div>),
|
||||
[option, votes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{proposal &&
|
||||
<StyledDiv className={className}>
|
||||
<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 : 0 } })}
|
||||
{option === 'nays' && t('Nay: {{count}}', { replace: { count: votes ? votes.length : 0 } })}
|
||||
</span>
|
||||
</div>
|
||||
{voters}
|
||||
</StyledDiv>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
width: 50%;
|
||||
|
||||
.vote-numbers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.vote-numbers-icon svg {
|
||||
max-width: 10px;
|
||||
color: var(--color-label);
|
||||
}
|
||||
|
||||
.vote-numbers-label {
|
||||
margin-left: 0.75rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-tiny);
|
||||
line-height: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-label);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(VotersColumn);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import { TypeRegistry } from '@pezkuwi/types/create';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { calculateBountyBond } from './calculateBountyBond.js';
|
||||
|
||||
describe('Calculate bounty bond', () => {
|
||||
it('sums deposit base and deposit for each byte of description', () => {
|
||||
const registry = new TypeRegistry();
|
||||
const depositBase = registry.createType('BalanceOf', new BN(166666666666));
|
||||
const depositPerByte = registry.createType('BalanceOf', new BN(1666666666));
|
||||
|
||||
expect(calculateBountyBond('Dicle network UI Bounty', depositBase, depositPerByte)).toEqual(new BN(206666666650));
|
||||
});
|
||||
|
||||
it('handles utf-8 chars', () => {
|
||||
const registry = new TypeRegistry();
|
||||
const depositBase = registry.createType('BalanceOf', new BN(100));
|
||||
const depositPerByte = registry.createType('BalanceOf', new BN(10));
|
||||
|
||||
expect(calculateBountyBond('óy😅€', depositBase, depositPerByte)).toEqual(new BN(200));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export function calculateBountyBond (description: string, depositBase: BN, depositPerByte: BN): BN {
|
||||
return depositBase.add(depositPerByte.muln(countUtf8Bytes(description)));
|
||||
}
|
||||
|
||||
export function countUtf8Bytes (str: string): number {
|
||||
return new Blob([str]).size;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import { createAugmentedApi } from '@pezkuwi/test-support/api';
|
||||
import { BountyFactory } from '@pezkuwi/test-support/creation/bounties';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { determineUnassignCuratorAction } from './determineUnassignCuratorAction.js';
|
||||
|
||||
describe('adjust slash curator component for', () => {
|
||||
const augmentedApi = createAugmentedApi();
|
||||
const { aBountyStatus } = new BountyFactory(augmentedApi);
|
||||
|
||||
it('Member in Active state', () => {
|
||||
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('Active'));
|
||||
|
||||
expect(displayAs).toEqual(['SlashCuratorMotion']);
|
||||
});
|
||||
|
||||
it('Member in CuratorProposed state', () => {
|
||||
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('CuratorProposed'));
|
||||
|
||||
expect(displayAs).toEqual(['UnassignCurator']);
|
||||
});
|
||||
|
||||
it('Member in PendingPayout state', () => {
|
||||
const displayAs = determineUnassignCuratorAction(['Member'], aBountyStatus('PendingPayout'));
|
||||
|
||||
expect(displayAs).toEqual(['SlashCuratorMotion']);
|
||||
});
|
||||
|
||||
it('User in Active state with update due blocks remaining', () => {
|
||||
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('Active'), new BN('1'));
|
||||
|
||||
expect(displayAs).toEqual([]);
|
||||
});
|
||||
|
||||
it('User in Active state with no updated state', () => {
|
||||
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('Active'), new BN('-1'));
|
||||
|
||||
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'));
|
||||
|
||||
expect(displayAs).toEqual([]);
|
||||
});
|
||||
|
||||
it('User in Active state', () => {
|
||||
const displayAs = determineUnassignCuratorAction(['User'], aBountyStatus('PendingPayout'));
|
||||
|
||||
expect(displayAs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { UserRole, ValidUnassignCuratorAction } from '../types.js';
|
||||
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
export function determineUnassignCuratorAction (roles: UserRole[], status: PalletBountiesBountyStatus, blocksUntilUpdate?: BN): ValidUnassignCuratorAction[] {
|
||||
const actions: ValidUnassignCuratorAction[] = [];
|
||||
|
||||
if (status.isCuratorProposed && roles.includes('Member')) {
|
||||
actions.push('UnassignCurator');
|
||||
}
|
||||
|
||||
if (status.isActive) {
|
||||
if (roles.includes('Member')) {
|
||||
actions.push('SlashCuratorMotion');
|
||||
}
|
||||
|
||||
if (roles.includes('User') && blocksUntilUpdate && blocksUntilUpdate.lt(BN_ZERO)) {
|
||||
actions.push('SlashCuratorAction');
|
||||
}
|
||||
}
|
||||
|
||||
if (status.isPendingPayout && roles.includes('Member')) {
|
||||
actions.push('SlashCuratorMotion');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types';
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
import type { BountyVotingStatuses, StatusName } from '../types.js';
|
||||
|
||||
const validProposalNames: BountyVotingStatuses = {
|
||||
Active: ['closeBounty', 'unassignCurator'],
|
||||
Approved: [],
|
||||
CuratorProposed: ['closeBounty', 'unassignCurator'],
|
||||
Funded: ['proposeCurator', 'closeBounty'],
|
||||
PendingPayout: ['unassignCurator'],
|
||||
Proposed: ['approveBounty', 'closeBounty']
|
||||
};
|
||||
|
||||
function validMethods (status: PalletBountiesBountyStatus): string[] {
|
||||
return validProposalNames[status.type as StatusName];
|
||||
}
|
||||
|
||||
function getProposalByMethod (bountyProposals: DeriveCollectiveProposal[], method: string | undefined): DeriveCollectiveProposal | undefined {
|
||||
return bountyProposals.find(({ proposal }) => proposal && proposal.method === method);
|
||||
}
|
||||
|
||||
function bestValidProposalName (bountyProposals: DeriveCollectiveProposal[], status: PalletBountiesBountyStatus): string | undefined {
|
||||
const methods = bountyProposals.map(({ proposal }) => proposal?.method);
|
||||
|
||||
return validMethods(status).find((method) => methods.includes(method));
|
||||
}
|
||||
|
||||
export function proposalNameToDisplay (bountyProposal: DeriveCollectiveProposal, status: PalletBountiesBountyStatus): string | undefined {
|
||||
if (bountyProposal.proposal && bountyProposal.proposal.method !== 'unassignCurator') {
|
||||
return bountyProposal.proposal.method;
|
||||
}
|
||||
|
||||
return status.isCuratorProposed ? 'unassignCurator' : 'slashCurator';
|
||||
}
|
||||
|
||||
export function getProposalToDisplay (bountyProposals: DeriveCollectiveProposal[], status: PalletBountiesBountyStatus): DeriveCollectiveProposal | null {
|
||||
const method = bestValidProposalName(bountyProposals, status);
|
||||
|
||||
return getProposalByMethod(bountyProposals, method) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import { TypeRegistry } from '@pezkuwi/types/create';
|
||||
|
||||
import { getBountyStatus } from './getBountyStatus.js';
|
||||
|
||||
describe('get bounty status', () => {
|
||||
let registry: TypeRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new TypeRegistry();
|
||||
});
|
||||
|
||||
it('for CuratorProposed state it has curator defined', () => {
|
||||
const bountyStatus = getBountyStatus(registry.createType('PalletBountiesBountyStatus', 'CuratorProposed'));
|
||||
|
||||
expect(bountyStatus.bountyStatus).toEqual('CuratorProposed');
|
||||
expect(bountyStatus.curator).toBeDefined();
|
||||
});
|
||||
|
||||
it('for Active state it has curator and update due defined', () => {
|
||||
const bountyStatus = getBountyStatus(registry.createType('PalletBountiesBountyStatus', 'Active'));
|
||||
|
||||
expect(bountyStatus.bountyStatus).toEqual('Active');
|
||||
expect(bountyStatus.curator).toBeDefined();
|
||||
expect(bountyStatus.updateDue).toBeDefined();
|
||||
});
|
||||
|
||||
it('for PendingPayout state it has curator, beneficiary and unlock_at defined', () => {
|
||||
const bountyStatus = getBountyStatus(registry.createType('PalletBountiesBountyStatus', 'PendingPayout'));
|
||||
|
||||
expect(bountyStatus.bountyStatus).toEqual('PendingPayout');
|
||||
expect(bountyStatus.curator).toBeDefined();
|
||||
expect(bountyStatus.beneficiary).toBeDefined();
|
||||
expect(bountyStatus.unlockAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
import type { BountyStatusType, StatusName } from '../types.js';
|
||||
|
||||
export const getBountyStatus = (status: PalletBountiesBountyStatus): BountyStatusType => {
|
||||
const statusAsString = status.type as StatusName;
|
||||
|
||||
let result: BountyStatusType = {
|
||||
beneficiary: undefined,
|
||||
bountyStatus: statusAsString,
|
||||
curator: undefined,
|
||||
unlockAt: undefined,
|
||||
updateDue: undefined
|
||||
};
|
||||
|
||||
if (status.isCuratorProposed) {
|
||||
result = {
|
||||
...result,
|
||||
bountyStatus: 'CuratorProposed',
|
||||
curator: status.asCuratorProposed.curator
|
||||
};
|
||||
}
|
||||
|
||||
if (status.isActive) {
|
||||
result = {
|
||||
...result,
|
||||
curator: status.asActive.curator,
|
||||
updateDue: status.asActive.updateDue
|
||||
};
|
||||
}
|
||||
|
||||
if (status.isPendingPayout) {
|
||||
result = {
|
||||
...result,
|
||||
beneficiary: status.asPendingPayout.beneficiary,
|
||||
bountyStatus: 'PendingPayout',
|
||||
curator: status.asPendingPayout.curator,
|
||||
unlockAt: status.asPendingPayout.unlockAt
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-treasury authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export function increaseDateByBlocks (blocks: BN, blockTime: number): Date {
|
||||
return new Date(Date.now() + blocks.muln(blockTime).toNumber());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export * from './calculateBountyBond.js';
|
||||
export * from './determineUnassignCuratorAction.js';
|
||||
export * from './getBountyStatus.js';
|
||||
export * from './isClaimable.js';
|
||||
export * from './permillOf.js';
|
||||
export * from './stringHelpers.js';
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import { TypeRegistry } from '@pezkuwi/types/create';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { isClaimable } from './isClaimable.js';
|
||||
|
||||
describe('Is claimable', () => {
|
||||
const registry = new TypeRegistry();
|
||||
const accountAddress = '5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM';
|
||||
const beneficiaryId = registry.createType('AccountId', accountAddress);
|
||||
|
||||
it('returns false, when payout due is greater than 0', () => {
|
||||
const accounts = [accountAddress];
|
||||
const payoutDue = new BN('1');
|
||||
|
||||
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false, when payout due is equal 0', () => {
|
||||
const accounts = [accountAddress];
|
||||
const payoutDue = new BN('0');
|
||||
|
||||
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true, when payout due is lesser than 0 and beneficiary is among accounts', () => {
|
||||
const accounts = [accountAddress];
|
||||
const payoutDue = new BN('-1');
|
||||
|
||||
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false, when beneficiary is not among accounts', () => {
|
||||
const accounts = ['This_is_not_the_treasury_address_27Tt8tkntv6Q7JVPhFsTB'];
|
||||
const payoutDue = new BN('-1');
|
||||
|
||||
expect(isClaimable(accounts, beneficiaryId, payoutDue)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
export function isClaimable (accounts: string[], beneficiary: AccountId, payoutDue: BN): boolean {
|
||||
return payoutDue.ltn(0) && accounts.includes(beneficiary.toString());
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { BN_MILLION } from '@pezkuwi/util';
|
||||
|
||||
export function permillOf (value: BN, perMill: BN): BN {
|
||||
return value.mul(perMill).div(BN_MILLION);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export function insertSpaceBeforeCapitalLetter (str: string): string {
|
||||
return str.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
export function truncateTitle (str: string, maxLength: number): string {
|
||||
return (str.length > maxLength)
|
||||
// ellipsis
|
||||
? (str.substring(0, maxLength - 1) + String.fromCharCode(8230))
|
||||
: str;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import { truncateTitle } from './stringHelpers.js';
|
||||
|
||||
describe('Truncate title', () => {
|
||||
it('does not truncate short title', () => {
|
||||
expect(truncateTitle('a short one', 30)).toEqual('a short one');
|
||||
});
|
||||
|
||||
it('truncates a long title', () => {
|
||||
const ellipsis = String.fromCharCode(8230);
|
||||
|
||||
expect(truncateTitle('A long title that should got truncated', 30)).toEqual(`A long title that should got ${ellipsis}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export * from './useBalance.js';
|
||||
export * from './useBounties.js';
|
||||
export * from './useBountyStatus.js';
|
||||
export * from './useUserRole.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
|
||||
import type { Balance } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
function useBalanceImpl (accountId: string | null): Balance | undefined {
|
||||
const { api } = useApi();
|
||||
const balancesAll = useCall<DeriveBalancesAll>(api.derive.balances?.all, [accountId]);
|
||||
|
||||
return balancesAll?.transferable || balancesAll?.availableBalance;
|
||||
}
|
||||
|
||||
export const useBalance = createNamedHook('useBalance', useBalanceImpl);
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { SubmittableExtrinsicFunction } from '@pezkuwi/api/types';
|
||||
import type { DeriveBounties } from '@pezkuwi/api-derive/types';
|
||||
import type { Codec } from '@pezkuwi/types/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useBestNumber, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
interface BountyApiTxs {
|
||||
acceptCurator: SubmittableExtrinsicFunction<'promise'>;
|
||||
approveBounty: SubmittableExtrinsicFunction<'promise'>;
|
||||
awardBounty: SubmittableExtrinsicFunction<'promise'>;
|
||||
claimBounty: SubmittableExtrinsicFunction<'promise'>;
|
||||
closeBounty: SubmittableExtrinsicFunction<'promise'>;
|
||||
extendBountyExpiry: SubmittableExtrinsicFunction<'promise'>;
|
||||
proposeBounty: SubmittableExtrinsicFunction<'promise'>;
|
||||
proposeCurator: SubmittableExtrinsicFunction<'promise'>;
|
||||
unassignCurator: SubmittableExtrinsicFunction<'promise'>;
|
||||
}
|
||||
|
||||
interface BountyApiConstants {
|
||||
bountyCuratorDeposit: BN;
|
||||
bountyDepositBase: BN;
|
||||
bountyUpdatePeriod?: BN;
|
||||
bountyValueMinimum: BN;
|
||||
dataDepositPerByte: BN;
|
||||
maximumReasonLength: number;
|
||||
}
|
||||
|
||||
interface BountyApiStatics extends BountyApiConstants, BountyApiTxs {
|
||||
// nothing additional
|
||||
}
|
||||
|
||||
export interface BountyApi extends BountyApiStatics {
|
||||
bestNumber?: BN;
|
||||
bounties?: DeriveBounties;
|
||||
bountyCount?: BN;
|
||||
childCount?: BN;
|
||||
}
|
||||
|
||||
function getStatics (api: ApiPromise): BountyApiStatics {
|
||||
const constsBase = api.consts.bounties || api.consts.treasury;
|
||||
const txBase = api.tx.bounties || api.tx.treasury;
|
||||
|
||||
return {
|
||||
// constants
|
||||
bountyCuratorDeposit: (constsBase.bountyCuratorDeposit as (BN & Codec)) || BN_ZERO,
|
||||
bountyDepositBase: constsBase.bountyDepositBase,
|
||||
bountyUpdatePeriod: constsBase.bountyUpdatePeriod,
|
||||
bountyValueMinimum: constsBase.bountyValueMinimum,
|
||||
dataDepositPerByte: constsBase.dataDepositPerByte,
|
||||
maximumReasonLength: constsBase.maximumReasonLength.toNumber(),
|
||||
|
||||
// extrinsics
|
||||
// eslint-disable-next-line sort-keys
|
||||
acceptCurator: txBase.acceptCurator,
|
||||
approveBounty: txBase.approveBounty,
|
||||
awardBounty: txBase.awardBounty,
|
||||
claimBounty: txBase.claimBounty,
|
||||
closeBounty: txBase.closeBounty,
|
||||
extendBountyExpiry: txBase.extendBountyExpiry,
|
||||
proposeBounty: txBase.proposeBounty,
|
||||
proposeCurator: txBase.proposeCurator,
|
||||
unassignCurator: txBase.unassignCurator
|
||||
};
|
||||
}
|
||||
|
||||
function useBountiesImpl (): BountyApi {
|
||||
const { api } = useApi();
|
||||
const bounties = useCall<DeriveBounties>(api.derive.bounties.bounties);
|
||||
const bountyCount = useCall<BN>((api.query.bounties || api.query.treasury).bountyCount);
|
||||
const childCount = useCall<BN>(api.query.childBounties?.childBountyCount);
|
||||
const bestNumber = useBestNumber();
|
||||
|
||||
const statics = useMemo(
|
||||
() => getStatics(api),
|
||||
[api]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
(): BountyApi => ({
|
||||
...statics,
|
||||
bestNumber,
|
||||
bounties,
|
||||
bountyCount,
|
||||
childCount
|
||||
}),
|
||||
[bestNumber, bounties, bountyCount, childCount, statics]
|
||||
);
|
||||
}
|
||||
|
||||
export const useBounties = createNamedHook('useBounties', useBountiesImpl);
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PalletBountiesBountyStatus } from '@pezkuwi/types/lookup';
|
||||
import type { BountyStatusType } from '../types.js';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { createNamedHook } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { getBountyStatus } from '../helpers/index.js';
|
||||
|
||||
function useBountyStatusImpl (status: PalletBountiesBountyStatus): BountyStatusType {
|
||||
const updateStatus = useCallback(() => getBountyStatus(status), [status]);
|
||||
|
||||
return updateStatus();
|
||||
}
|
||||
|
||||
export const useBountyStatus = createNamedHook('useBountyStatus', useBountyStatusImpl);
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { UserRole } from '../types.js';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useAccounts, useCollectiveMembers } from '@pezkuwi/react-hooks';
|
||||
|
||||
export interface UserRolesInfo { roles: UserRole[], isCurator: boolean }
|
||||
|
||||
function useUserRoleImpl (curatorId?: AccountId): UserRolesInfo {
|
||||
const { allAccounts, hasAccounts } = useAccounts();
|
||||
const { isMember } = useCollectiveMembers('council');
|
||||
|
||||
return useMemo((): UserRolesInfo => {
|
||||
const isCurator = !!curatorId && allAccounts.includes(curatorId.toString());
|
||||
const roles: UserRole[] = [];
|
||||
|
||||
if (isCurator) {
|
||||
roles.push('Curator');
|
||||
}
|
||||
|
||||
if (isMember) {
|
||||
roles.push('Member');
|
||||
}
|
||||
|
||||
if (hasAccounts) {
|
||||
roles.push('User');
|
||||
}
|
||||
|
||||
return { isCurator, roles };
|
||||
}, [allAccounts, curatorId, hasAccounts, isMember]);
|
||||
}
|
||||
|
||||
export const useUserRole = createNamedHook('useUserRole', useUserRoleImpl);
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
|
||||
import Bounties from './Bounties.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
|
||||
export { default as useCounter } from './useCounter.js';
|
||||
|
||||
interface Props {
|
||||
basePath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function BountiesApp ({ basePath, className = '' }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemsRef = useRef([
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'index',
|
||||
text: t('Overview')
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className={`${className} bounties--App`}>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
items={itemsRef.current}
|
||||
/>
|
||||
<Bounties />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BountiesApp);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-bounties');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountId, BlockNumber } from '@pezkuwi/types/interfaces/runtime';
|
||||
|
||||
export interface BountyStatusType {
|
||||
beneficiary: AccountId | undefined;
|
||||
bountyStatus: StatusName;
|
||||
curator: AccountId | undefined;
|
||||
unlockAt: BlockNumber | undefined;
|
||||
updateDue: BlockNumber | undefined;
|
||||
}
|
||||
|
||||
export type HelpMessages = Record<StatusName, string>;
|
||||
|
||||
export type StatusName = 'Active' | 'Approved' | 'CuratorProposed' | 'Funded' | 'PendingPayout' | 'Proposed';
|
||||
export type BountyVotingStatuses = { [status in StatusName]: string[] };
|
||||
export type ValidUnassignCuratorAction = 'UnassignCurator' | 'SlashCuratorMotion' | 'SlashCuratorAction';
|
||||
export type UnassignCuratorAction = ValidUnassignCuratorAction | 'None';
|
||||
export type UserRole = 'User' | 'Member' | 'Curator' | 'None';
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-bounties authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBounties } from '@pezkuwi/api-derive/types';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
|
||||
function useCounterImpl (): number {
|
||||
const { api, isApiReady } = useApi();
|
||||
const bounties = useCall<DeriveBounties>(isApiReady && api.derive.bounties?.bounties);
|
||||
|
||||
return useMemo(
|
||||
() => bounties?.length || 0,
|
||||
[bounties]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useCounter', useCounterImpl);
|
||||
Reference in New Issue
Block a user