feat(governance): proposal list tile and summary enhancements (#4326)

This commit is contained in:
Sam Keen 2023-07-25 11:23:25 +01:00 committed by GitHub
parent ffcdfb6a6a
commit e01fb7f9ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1112 additions and 755 deletions

View File

@ -11,7 +11,7 @@ context('Home Page - verify elements on page', { tags: '@smoke' }, function () {
cy.getByTestId('home-proposals').within(() => { cy.getByTestId('home-proposals').within(() => {
cy.get('[href="/proposals"]') cy.get('[href="/proposals"]')
.should('exist') .should('exist')
.and('have.text', 'Browse, vote, and propose'); .and('have.text', 'See all proposals');
}); });
}); });

View File

@ -161,7 +161,7 @@ context(
); );
cy.getByTestId('protocol-upgrade-proposal-status').should( cy.getByTestId('protocol-upgrade-proposal-status').should(
'have.text', 'have.text',
'Approved by validators ' 'Approved by validators'
); );
}); });
}); });

View File

@ -166,6 +166,7 @@
"proposedEnactment": "Proposed enactment", "proposedEnactment": "Proposed enactment",
"Enacted": "Enacted", "Enacted": "Enacted",
"enactedOn": "Enacted on", "enactedOn": "Enacted on",
"enactedOn{{date}}": "Enacted on {{enactmentDate}}",
"status": "Status", "status": "Status",
"state": "State", "state": "State",
"shouldPass": "Should pass", "shouldPass": "Should pass",
@ -185,6 +186,7 @@
"proposedOn": "Proposed on", "proposedOn": "Proposed on",
"proposedBy": "Proposed by", "proposedBy": "Proposed by",
"toEnactOn": "Enacts on", "toEnactOn": "Enacts on",
"enactsOn{{date}}": "Enacts on {{enactmentDate}}",
"closesOn": "Closes on", "closesOn": "Closes on",
"closedOn": "Closed on", "closedOn": "Closed on",
"errorDetails": "Error details", "errorDetails": "Error details",
@ -217,6 +219,7 @@
"votingThresholdInfo": "If the token vote passes the participation threshold it will be the deciding vote. If not, the outcome will be determined by liquidity providers on this market.", "votingThresholdInfo": "If the token vote passes the participation threshold it will be the deciding vote. If not, the outcome will be determined by liquidity providers on this market.",
"noGovernanceTokens": "You need some VEGA tokens to participate in governance", "noGovernanceTokens": "You need some VEGA tokens to participate in governance",
"youVoted": "You voted", "youVoted": "You voted",
"voted": "Voted",
"changeVote": "Change vote", "changeVote": "Change vote",
"txRequested": "Confirm transaction in wallet", "txRequested": "Confirm transaction in wallet",
"votePending": "Casting vote", "votePending": "Casting vote",
@ -628,7 +631,6 @@
"pendingDescriptionLinkText": "set up and run a node on Vega", "pendingDescriptionLinkText": "set up and run a node on Vega",
"pendingDescription2": ". A node can move from being a candidate into standby based on how much nomination it attracts, assuming it has proven reliability by sending heartbeats to the network.", "pendingDescription2": ". A node can move from being a candidate into standby based on how much nomination it attracts, assuming it has proven reliability by sending heartbeats to the network.",
"n/a": "N/A", "n/a": "N/A",
"Set to": "Set to",
"pass": "pass", "pass": "pass",
"fail": "fail", "fail": "fail",
"New asset": "New asset", "New asset": "New asset",
@ -788,7 +790,7 @@
"overstakedPenalty": "Overstaked penalty", "overstakedPenalty": "Overstaked penalty",
"multisigPenalty": "Multisig penalty", "multisigPenalty": "Multisig penalty",
"homeProposalsIntro": "Decisions on the Vega network are on-chain, with tokenholders creating proposals that other tokenholders vote to approve or reject. Network upgrades are proposed and approved by validators.", "homeProposalsIntro": "Decisions on the Vega network are on-chain, with tokenholders creating proposals that other tokenholders vote to approve or reject. Network upgrades are proposed and approved by validators.",
"homeProposalsButtonText": "Browse, vote, and propose", "homeProposalsButtonText": "See all proposals",
"homeValidatorsIntro": "Vega runs on a delegated proof of stake blockchain, where validators earn fees for validating block transactions. Tokenholders can nominate validators by staking tokens to them.", "homeValidatorsIntro": "Vega runs on a delegated proof of stake blockchain, where validators earn fees for validating block transactions. Tokenholders can nominate validators by staking tokens to them.",
"homeValidatorsButtonText": "Browse, and stake", "homeValidatorsButtonText": "Browse, and stake",
"homeRewardsIntro": "Track rewards you've earned for trading, liquidity provision, market creation, and staking.", "homeRewardsIntro": "Track rewards you've earned for trading, liquidity provision, market creation, and staking.",
@ -838,6 +840,18 @@
"networkGovernance": "Network governance", "networkGovernance": "Network governance",
"networkUpgrades": "Network upgrades", "networkUpgrades": "Network upgrades",
"assetSpecification": "Asset specification", "assetSpecification": "Asset specification",
"viewDetails": "View details",
"vegaGovernance": "Vega Governance",
"latestProposals": "Latest proposals",
"vegaToken": "VEGA Token",
"majorityVotedForProposal": "majority voted for this proposal",
"majorityNotVotedForProposal": "majority not voted for this proposal",
"requiredMajorityVotedForProposal": "Required majority voted for this proposal",
"requiredMajorityNotVotedForProposal": "Required majority not voted for this proposal",
"minParticipationReached": "Min. participation reached",
"minParticipationNotReached": "Min. participation not reached",
"consensusNodes": "consensus nodes",
"activeNodes": "active nodes",
"Estimated time to upgrade": "Estimated time to upgrade", "Estimated time to upgrade": "Estimated time to upgrade",
"Upgraded at": "Upgraded at" "Upgraded at": "Upgraded at"
} }

View File

@ -20,7 +20,7 @@ import {
getNotRejectedProposals, getNotRejectedProposals,
getNotRejectedProtocolUpgradeProposals, getNotRejectedProtocolUpgradeProposals,
} from '../proposals/proposals/proposals-container'; } from '../proposals/proposals/proposals-container';
import { Heading } from '../../components/heading'; import { Heading, SubHeading } from '../../components/heading';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import type { RouteChildProps } from '..'; import type { RouteChildProps } from '..';
import type { ProposalFieldsFragment } from '../proposals/proposals/__generated__/Proposals'; import type { ProposalFieldsFragment } from '../proposals/proposals/__generated__/Proposals';
@ -31,6 +31,10 @@ import {
orderByDate, orderByDate,
orderByUpgradeBlockHeight, orderByUpgradeBlockHeight,
} from '../proposals/components/proposals-list/proposals-list'; } from '../proposals/components/proposals-list/proposals-list';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
import { BigNumber } from '../../lib/bignumber'; import { BigNumber } from '../../lib/bignumber';
const nodesToShow = 6; const nodesToShow = 6;
@ -43,33 +47,57 @@ const HomeProposals = ({
protocolUpgradeProposals: ProtocolUpgradeProposalFieldsFragment[]; protocolUpgradeProposals: ProtocolUpgradeProposalFieldsFragment[];
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const {
params: networkParams,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredMajority,
]);
return ( return (
<section className="mb-16" data-testid="home-proposals"> <AsyncRenderer
<Heading title={t('Proposals')} /> loading={networkParamsLoading}
<h3 className="mb-6">{t('homeProposalsIntro')}</h3> error={networkParamsError}
<div className="flex items-center mb-8 gap-8"> data={networkParams}
<Link to={`${Routes.PROPOSALS}`}> >
<Button size="md">{t('homeProposalsButtonText')}</Button> <section className="mb-16" data-testid="home-proposals">
</Link> <Heading title={t('vegaGovernance')} />
<h3 className="mb-6">{t('homeProposalsIntro')}</h3>
<div className="mb-8">
<ExternalLink href={ExternalLinks.GOVERNANCE_PAGE}>
{t(`readMoreGovernance`)}
</ExternalLink>
</div>
<ExternalLink href={ExternalLinks.GOVERNANCE_PAGE}> <SubHeading title={t('latestProposals')} />
{t(`readMoreGovernance`)} <ul data-testid="home-proposal-list" className="grid gap-6">
</ExternalLink> {protocolUpgradeProposals.map((proposal, index) => (
</div> <ProtocolUpgradeProposalsListItem key={index} proposal={proposal} />
<ul ))}
data-testid="home-proposal-list"
className="grid md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-6"
>
{protocolUpgradeProposals.map((proposal, index) => (
<ProtocolUpgradeProposalsListItem key={index} proposal={proposal} />
))}
{proposals.map((proposal) => ( {proposals.map((proposal) => (
<ProposalsListItem key={proposal.id} proposal={proposal} /> <ProposalsListItem
))} key={proposal.id}
</ul> proposal={proposal}
</section> networkParams={networkParams}
/>
))}
</ul>
<div className="mt-6">
<Link to={`${Routes.PROPOSALS}`}>
<Button size="md">{t('homeProposalsButtonText')}</Button>
</Link>
</div>
</section>
</AsyncRenderer>
); );
}; };
@ -87,8 +115,8 @@ const HomeNodes = ({
const { t } = useTranslation(); const { t } = useTranslation();
const highlightedNodeData = [ const highlightedNodeData = [
{ title: t('active nodes'), length: activeNodes.length }, { title: t('activeNodes'), length: activeNodes.length },
{ title: t('consensus nodes'), length: consensusNodes.length }, { title: t('consensusNodes'), length: consensusNodes.length },
]; ];
return ( return (
@ -234,7 +262,7 @@ const GovernanceHome = ({ name }: RouteChildProps) => {
[protocolUpgradeProposals] [protocolUpgradeProposals]
); );
const totalProposalsDesired = 4; const totalProposalsDesired = 3;
const protocolUpgradeProposalsToShow = sortedProtocolUpgradeProposals.slice( const protocolUpgradeProposalsToShow = sortedProtocolUpgradeProposals.slice(
0, 0,
totalProposalsDesired totalProposalsDesired
@ -290,7 +318,7 @@ const GovernanceHome = ({ name }: RouteChildProps) => {
</div> </div>
<div data-testid="home-vega-token"> <div data-testid="home-vega-token">
<Heading title={t('VEGA Token')} marginTop={false} /> <Heading title={t('vegaToken')} marginTop={false} />
<h3 className="mb-6">{t('homeVegaTokenIntro')}</h3> <h3 className="mb-6">{t('homeVegaTokenIntro')}</h3>
<div className="flex items-center mb-8 gap-4"> <div className="flex items-center mb-8 gap-4">
<Link to={Routes.WITHDRAWALS}> <Link to={Routes.WITHDRAWALS}>

View File

@ -1,5 +1,4 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Icon } from '@vegaprotocol/ui-toolkit';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals'; import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { ProposalState } from '@vegaprotocol/types'; import { ProposalState } from '@vegaprotocol/types';
@ -18,53 +17,32 @@ export const CurrentProposalState = ({
switch (proposal?.state) { switch (proposal?.state) {
case ProposalState.STATE_ENACTED: { case ProposalState.STATE_ENACTED: {
proposalStatus = ( proposalStatus = t('voteState_Enacted');
<>
<span className="mr-2">{t('voteState_Enacted')}</span>
<Icon name={'tick'} />
</>
);
break; break;
} }
case ProposalState.STATE_PASSED: { case ProposalState.STATE_PASSED: {
proposalStatus = ( proposalStatus = t('voteState_Passed');
<>
<span className="mr-2">{t('voteState_Passed')}</span>
<Icon name={'tick'} />
</>
);
break; break;
} }
case ProposalState.STATE_WAITING_FOR_NODE_VOTE: { case ProposalState.STATE_WAITING_FOR_NODE_VOTE: {
proposalStatus = ( proposalStatus = t('voteState_WaitingForNodeVote');
<>
<span className="mr-2">{t('voteState_WaitingForNodeVote')}</span>
<Icon name={'time'} />
</>
);
break; break;
} }
case ProposalState.STATE_OPEN: { case ProposalState.STATE_OPEN: {
variant = 'primary' as ProposalInfoLabelVariant; variant = 'primary' as ProposalInfoLabelVariant;
proposalStatus = <>{t('voteState_Open')}</>; proposalStatus = t('voteState_Open');
break; break;
} }
case ProposalState.STATE_DECLINED: { case ProposalState.STATE_DECLINED: {
proposalStatus = ( proposalStatus = t('voteState_Declined');
<>
<span className="mr-2">{t('voteState_Declined')}</span>
<Icon name={'cross'} />
</>
);
break; break;
} }
case ProposalState.STATE_REJECTED: { case ProposalState.STATE_REJECTED: {
proposalStatus = ( proposalStatus = t('voteState_Rejected');
<> break;
<span className="mr-2">{t('voteState_Rejected')}</span> }
<Icon name={'warning-sign'} /> case ProposalState.STATE_FAILED: {
</> proposalStatus = t('voteState_Failed');
);
break; break;
} }
} }

View File

@ -1,18 +1,46 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import {
ProposalRejectionReason,
ProposalState,
VoteValue,
} from '@vegaprotocol/types';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { import {
generateNoVotes, generateNoVotes,
generateProposal, generateProposal,
generateYesVotes, generateYesVotes,
} from '../../test-helpers/generate-proposals'; } from '../../test-helpers/generate-proposals';
import { ProposalHeader } from './proposal-header'; import { ProposalHeader } from './proposal-header';
import {
lastWeek,
nextWeek,
mockNetworkParams,
mockWalletContext,
createUserVoteQueryMock,
} from '../../test-helpers/mocks';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { ProposalRejectionReason, ProposalState } from '@vegaprotocol/types'; import type { MockedResponse } from '@apollo/client/testing';
import { lastWeek, nextWeek } from '../../test-helpers/mocks';
const renderComponent = ( const renderComponent = (
proposal: ProposalQuery['proposal'], proposal: ProposalQuery['proposal'],
isListItem = true isListItem = true,
) => render(<ProposalHeader proposal={proposal} isListItem={isListItem} />); mocks: MockedResponse[] = []
) =>
render(
<AppStateProvider>
<MockedProvider mocks={mocks}>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalHeader
proposal={proposal}
isListItem={isListItem}
networkParams={mockNetworkParams}
/>
</VegaWalletContext.Provider>
</MockedProvider>
</AppStateProvider>
);
describe('Proposal header', () => { describe('Proposal header', () => {
it('Renders New market proposal', () => { it('Renders New market proposal', () => {
@ -317,22 +345,6 @@ describe('Proposal header', () => {
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open'); expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
}); });
it('Renders proposal state: Declined - majority not reached', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_DECLINED,
terms: {
enactmentDatetime: lastWeek.toString(),
},
votes: {
no: generateNoVotes(1, 1000000000000000000),
yes: generateYesVotes(1, 1000000000000000000),
},
})
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Declined');
});
it('Renders proposal state: Rejected', () => { it('Renders proposal state: Rejected', () => {
renderComponent( renderComponent(
generateProposal({ generateProposal({
@ -346,4 +358,32 @@ describe('Proposal header', () => {
); );
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Rejected'); expect(screen.getByTestId('proposal-status')).toHaveTextContent('Rejected');
}); });
it('Renders proposal state: Open - user voted against', async () => {
const proposal = generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
closingDatetime: nextWeek.toString(),
},
});
renderComponent(proposal, true, [
// @ts-ignore generateProposal always creates an id
createUserVoteQueryMock(proposal.id, VoteValue.VALUE_NO),
]);
expect(await screen.findByTestId('user-voted-no')).toBeInTheDocument();
});
it('Renders proposal state: Open - user voted for', async () => {
const proposal = generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
closingDatetime: nextWeek.toString(),
},
});
renderComponent(proposal, true, [
// @ts-ignore generateProposal always creates an id
createUserVoteQueryMock(proposal.id, VoteValue.VALUE_YES),
]);
expect(await screen.findByTestId('user-voted-yes')).toBeInTheDocument();
});
}); });

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Lozenge } from '@vegaprotocol/ui-toolkit'; import { Lozenge, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import { shorten } from '@vegaprotocol/utils'; import { shorten } from '@vegaprotocol/utils';
import { Heading, SubHeading } from '../../../../components/heading'; import { Heading, SubHeading } from '../../../../components/heading';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -8,15 +8,21 @@ import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { truncateMiddle } from '../../../../lib/truncate-middle'; import { truncateMiddle } from '../../../../lib/truncate-middle';
import { CurrentProposalState } from '../current-proposal-state'; import { CurrentProposalState } from '../current-proposal-state';
import { ProposalInfoLabel } from '../proposal-info-label'; import { ProposalInfoLabel } from '../proposal-info-label';
import { useUserVote } from '../vote-details/use-user-vote';
import { ProposalVotingStatus } from '../proposal-voting-status';
import type { NetworkParamsResult } from '@vegaprotocol/network-parameters';
export const ProposalHeader = ({ export const ProposalHeader = ({
proposal, proposal,
networkParams,
isListItem = true, isListItem = true,
}: { }: {
proposal: ProposalFieldsFragment | ProposalQuery['proposal']; proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
networkParams: Partial<NetworkParamsResult>;
isListItem?: boolean; isListItem?: boolean;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { voteState } = useUserVote(proposal?.id);
const change = proposal?.terms.change; const change = proposal?.terms.change;
let details: ReactNode; let details: ReactNode;
@ -119,6 +125,35 @@ export const ProposalHeader = ({
return ( return (
<> <>
<div className="flex items-center justify-between gap-4 mb-6 text-sm">
<div data-testid="proposal-type">
<ProposalInfoLabel variant="secondary">
{t(`${proposalType}`)}
</ProposalInfoLabel>
</div>
<div className="flex items-center gap-6">
{(voteState === 'Yes' || voteState === 'No') && (
<div
className="flex items-center gap-2"
data-testid={`user-voted-${voteState.toLowerCase()}`}
>
<div className="text-vega-green">
<VegaIcon name={VegaIconNames.VOTE} size={24} />
</div>
<div>
{t('voted')}{' '}
<span className="uppercase">{t(`voteState_${voteState}`)}</span>
</div>
</div>
)}
<div data-testid="proposal-status">
<CurrentProposalState proposal={proposal} />
</div>
</div>
</div>
<div data-testid="proposal-title"> <div data-testid="proposal-title">
{isListItem ? ( {isListItem ? (
<header> <header>
@ -133,23 +168,16 @@ export const ProposalHeader = ({
)} )}
</div> </div>
<div className="flex items-center gap-2 mb-4">
<div data-testid="proposal-type">
<ProposalInfoLabel variant="secondary">
{t(`${proposalType}`)}
</ProposalInfoLabel>
</div>
<div data-testid="proposal-status">
<CurrentProposalState proposal={proposal} />
</div>
</div>
{details && ( {details && (
<div data-testid="proposal-details" className="break-words my-10"> <div
data-testid="proposal-details"
className="break-words mb-6 text-vega-light-200"
>
{details} {details}
</div> </div>
)} )}
<ProposalVotingStatus proposal={proposal} networkParams={networkParams} />
</> </>
); );
}; };

View File

@ -7,10 +7,10 @@ export type ProposalInfoLabelVariant =
| 'tertiary' | 'tertiary'
| 'highlight'; | 'highlight';
const base = 'rounded-md px-2 py-1 font-alpha'; const base = 'rounded-full px-3 py-1 font-alpha';
const primary = 'bg-vega-light-150 text-black'; const primary = 'bg-vega-green text-black';
const secondary = 'bg-vega-dark-200 text-white'; const secondary = 'bg-vega-dark-200 text-vega-light-200';
const tertiary = 'bg-vega-dark-150 text-white'; const tertiary = 'bg-vega-dark-150 text-vega-light-200';
const highlight = 'bg-vega-yellow text-black'; const highlight = 'bg-vega-yellow text-black';
const getClassname = (variant: ProposalInfoLabelVariant) => { const getClassname = (variant: ProposalInfoLabelVariant) => {

View File

@ -0,0 +1 @@
export * from './proposal-voting-status';

View File

@ -0,0 +1,153 @@
import { render, screen } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';
import { MockedProvider } from '@apollo/client/testing';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import {
lastWeek,
mockWalletContext,
networkParamsQueryMock,
nextWeek,
mockNetworkParams,
} from '../../test-helpers/mocks';
import { ProposalVotingStatus } from './proposal-voting-status';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import type { MockedResponse } from '@apollo/client/testing';
import {
generateNoVotes,
generateProposal,
generateYesVotes,
} from '../../test-helpers/generate-proposals';
import { ProposalState } from '@vegaprotocol/types';
import { BigNumber } from '../../../../lib/bignumber';
import type { AppState } from '../../../../contexts/app-state/app-state-context';
const mockTotalSupply = new BigNumber(100);
// Note - giving a fixedTokenValue of 1 means a ratio of 1:1 votes to tokens, making sums easier :)
const fixedTokenValue = 1000000000000000000;
const mockAppState: AppState = {
totalAssociated: new BigNumber('50063005'),
decimals: 18,
totalSupply: mockTotalSupply,
vegaWalletManageOverlay: false,
transactionOverlay: false,
bannerMessage: '',
disconnectNotice: false,
};
jest.mock('../../../../contexts/app-state/app-state-context', () => ({
useAppState: () => ({
appState: mockAppState,
}),
}));
const renderComponent = (
proposal: ProposalQuery['proposal'],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mocks: MockedResponse<any>[] = [networkParamsQueryMock]
) =>
render(
<Router>
<MockedProvider mocks={mocks}>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalVotingStatus
proposal={proposal}
networkParams={mockNetworkParams}
/>
</VegaWalletContext.Provider>
</MockedProvider>
</Router>
);
describe('ProposalVotingStatus', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => {
jest.useRealTimers();
});
it('Renders majority reached', () => {
const yesVotes = 100;
const noVotes = 0;
renderComponent(
generateProposal({
state: ProposalState.STATE_PASSED,
terms: {
closingDatetime: lastWeek.toString(),
enactmentDatetime: nextWeek.toString(),
},
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(yesVotes, fixedTokenValue),
no: generateNoVotes(noVotes, fixedTokenValue),
},
})
);
expect(screen.getByTestId('majority-reached')).toBeInTheDocument();
});
it('Renders majority not reached', () => {
const yesVotes = 20;
const noVotes = 80;
renderComponent(
generateProposal({
state: ProposalState.STATE_PASSED,
terms: {
closingDatetime: lastWeek.toString(),
enactmentDatetime: nextWeek.toString(),
},
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(yesVotes, fixedTokenValue),
no: generateNoVotes(noVotes, fixedTokenValue),
},
})
);
expect(screen.getByTestId('majority-not-reached')).toBeInTheDocument();
});
it('Renders participation reached', () => {
const yesVotes = 1000;
const noVotes = 0;
renderComponent(
generateProposal({
state: ProposalState.STATE_PASSED,
terms: {
closingDatetime: lastWeek.toString(),
enactmentDatetime: nextWeek.toString(),
},
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(yesVotes, fixedTokenValue),
no: generateNoVotes(noVotes, fixedTokenValue),
},
})
);
expect(screen.getByTestId('participation-reached')).toBeInTheDocument();
});
it('Renders participation not reached', () => {
const yesVotes = 0;
const noVotes = 0;
renderComponent(
generateProposal({
terms: {
closingDatetime: lastWeek.toString(),
enactmentDatetime: nextWeek.toString(),
},
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(yesVotes, fixedTokenValue),
no: generateNoVotes(noVotes, fixedTokenValue),
},
})
);
expect(screen.getByTestId('participation-not-reached')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,174 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Icon } from '@vegaprotocol/ui-toolkit';
import { useVoteInformation } from '../../hooks';
import { BigNumber } from '../../../../lib/bignumber';
import type { NetworkParamsResult } from '@vegaprotocol/network-parameters';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
const statusClasses = (reached: boolean) =>
classNames('flex items-center gap-2 px-4 py-2 rounded-md', {
'bg-vega-green-700': reached,
'bg-vega-red-700': !reached,
});
const MajorityStatus = ({
reached,
requiredMajority,
}: {
reached: boolean;
requiredMajority: string | null | undefined;
}) => {
const { t } = useTranslation();
return (
<div
className={statusClasses(reached)}
data-testid="proposal-majority-status"
>
{reached ? <Icon name="tick" /> : <Icon name="cross" />}
{reached ? (
<div data-testid="majority-reached">
{requiredMajority ? (
<>
{new BigNumber(requiredMajority).times(100).toString()}%{' '}
{t('majorityVotedForProposal')}
</>
) : (
t('requiredMajorityVotedForProposal')
)}
</div>
) : (
<div data-testid="majority-not-reached">
{requiredMajority ? (
<>
{new BigNumber(requiredMajority).times(100).toString()}%{' '}
{t('majorityNotVotedForProposal')}
</>
) : (
t('requiredMajorityNotVotedForProposal')
)}
</div>
)}
</div>
);
};
const ParticipationStatus = ({ reached }: { reached: boolean }) => {
const { t } = useTranslation();
return (
<div className={statusClasses(reached)}>
{reached ? (
<>
<Icon name="tick" />
<div data-testid="participation-reached">
{t('minParticipationReached')}
</div>
</>
) : (
<>
<Icon name="cross" />
<div data-testid="participation-not-reached">
{t('minParticipationNotReached')}
</div>
</>
)}
</div>
);
};
export const ProposalVotingStatus = ({
proposal,
networkParams,
}: {
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
networkParams: Partial<NetworkParamsResult>;
}) => {
const { t } = useTranslation();
const { majorityMet, majorityLPMet, participationMet, participationLPMet } =
useVoteInformation({
proposal,
});
if (!proposal) {
return null;
}
const isUpdateMarket = proposal?.terms.change.__typename === 'UpdateMarket';
let requiredVotingMajority = null;
let requiredVotingMajorityLP = null;
if (networkParams) {
switch (proposal.terms.change.__typename) {
case 'NewMarket':
requiredVotingMajority =
networkParams.governance_proposal_market_requiredMajority;
break;
case 'UpdateMarket':
requiredVotingMajority =
networkParams.governance_proposal_updateMarket_requiredMajority;
requiredVotingMajorityLP =
networkParams.governance_proposal_updateMarket_requiredMajorityLP;
break;
case 'NewAsset':
requiredVotingMajority =
networkParams.governance_proposal_asset_requiredMajority;
break;
case 'UpdateAsset':
requiredVotingMajority =
networkParams.governance_proposal_updateAsset_requiredMajority;
break;
case 'UpdateNetworkParameter':
requiredVotingMajority =
networkParams.governance_proposal_updateNetParam_requiredMajority;
break;
case 'NewFreeform':
requiredVotingMajority =
networkParams.governance_proposal_freeform_requiredMajority;
break;
}
}
if (isUpdateMarket) {
return (
<div className="mb-6">
<p>{t('Token vote')}</p>
<div
className="grid grid-cols-2 gap-4 items-center"
data-testid="token-vote-statuses"
>
<MajorityStatus
reached={majorityMet}
requiredMajority={requiredVotingMajority}
/>{' '}
<ParticipationStatus reached={participationMet} />
</div>
<p className="mt-4">{t('Liquidity provider vote')}</p>
<div
className="grid grid-cols-2 gap-4 items-center"
data-testid="lp-vote-statuses"
>
<MajorityStatus
reached={majorityLPMet}
requiredMajority={requiredVotingMajorityLP}
/>{' '}
<ParticipationStatus reached={participationLPMet} />
</div>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-4 items-center mb-6">
<MajorityStatus
reached={majorityMet}
requiredMajority={requiredVotingMajority}
/>{' '}
<ParticipationStatus reached={participationMet} />
</div>
);
};

View File

@ -4,6 +4,7 @@ import { generateProposal } from '../../test-helpers/generate-proposals';
import { Proposal } from './proposal'; import { Proposal } from './proposal';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { ProposalState } from '@vegaprotocol/types'; import { ProposalState } from '@vegaprotocol/types';
import { mockNetworkParams } from '../../test-helpers/mocks';
jest.mock('@vegaprotocol/network-parameters', () => ({ jest.mock('@vegaprotocol/network-parameters', () => ({
...jest.requireActual('@vegaprotocol/network-parameters'), ...jest.requireActual('@vegaprotocol/network-parameters'),
@ -46,6 +47,7 @@ const renderComponent = (proposal: ProposalQuery['proposal']) => {
<Proposal <Proposal
restData={{}} restData={{}}
proposal={proposal as ProposalQuery['proposal']} proposal={proposal as ProposalQuery['proposal']}
networkParams={mockNetworkParams}
/> />
</MemoryRouter> </MemoryRouter>
); );

View File

@ -1,10 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import { Icon, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
import { AsyncRenderer, Icon, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
import { ProposalHeader } from '../proposal-detail-header/proposal-header'; import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import { ProposalDescription } from '../proposal-description'; import { ProposalDescription } from '../proposal-description';
import { ProposalChangeTable } from '../proposal-change-table'; import { ProposalChangeTable } from '../proposal-change-table';
@ -21,6 +17,7 @@ import type { MarketInfoWithData } from '@vegaprotocol/markets';
import type { AssetQuery } from '@vegaprotocol/assets'; import type { AssetQuery } from '@vegaprotocol/assets';
import { removePaginationWrapper } from '@vegaprotocol/utils'; import { removePaginationWrapper } from '@vegaprotocol/utils';
import { ProposalState } from '@vegaprotocol/types'; import { ProposalState } from '@vegaprotocol/types';
import type { NetworkParamsResult } from '@vegaprotocol/network-parameters';
export enum ProposalType { export enum ProposalType {
PROPOSAL_NEW_MARKET = 'PROPOSAL_NEW_MARKET', PROPOSAL_NEW_MARKET = 'PROPOSAL_NEW_MARKET',
@ -32,6 +29,7 @@ export enum ProposalType {
} }
export interface ProposalProps { export interface ProposalProps {
proposal: ProposalFieldsFragment | ProposalQuery['proposal']; proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
networkParams: Partial<NetworkParamsResult>;
newMarketData?: MarketInfoWithData | null; newMarketData?: MarketInfoWithData | null;
assetData?: AssetQuery | null; assetData?: AssetQuery | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -40,20 +38,12 @@ export interface ProposalProps {
export const Proposal = ({ export const Proposal = ({
proposal, proposal,
networkParams,
restData, restData,
newMarketData, newMarketData,
assetData, assetData,
}: ProposalProps) => { }: ProposalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { params, loading, error } = useNetworkParams([
NetworkParams.governance_proposal_market_minVoterBalance,
NetworkParams.governance_proposal_updateMarket_minVoterBalance,
NetworkParams.governance_proposal_asset_minVoterBalance,
NetworkParams.governance_proposal_updateAsset_minVoterBalance,
NetworkParams.governance_proposal_updateNetParam_minVoterBalance,
NetworkParams.governance_proposal_freeform_minVoterBalance,
NetworkParams.spam_protection_voting_min_tokens,
]);
if (!proposal) { if (!proposal) {
return null; return null;
@ -79,122 +69,122 @@ export const Proposal = ({
let minVoterBalance = null; let minVoterBalance = null;
let proposalType = null; let proposalType = null;
if (params) { if (networkParams) {
switch (proposal.terms.change.__typename) { switch (proposal.terms.change.__typename) {
case 'NewMarket': case 'NewMarket':
minVoterBalance = params.governance_proposal_market_minVoterBalance; minVoterBalance =
networkParams.governance_proposal_market_minVoterBalance;
proposalType = ProposalType.PROPOSAL_NEW_MARKET; proposalType = ProposalType.PROPOSAL_NEW_MARKET;
break; break;
case 'UpdateMarket': case 'UpdateMarket':
minVoterBalance = minVoterBalance =
params.governance_proposal_updateMarket_minVoterBalance; networkParams.governance_proposal_updateMarket_minVoterBalance;
proposalType = ProposalType.PROPOSAL_UPDATE_MARKET; proposalType = ProposalType.PROPOSAL_UPDATE_MARKET;
break; break;
case 'NewAsset': case 'NewAsset':
minVoterBalance = params.governance_proposal_asset_minVoterBalance; minVoterBalance =
networkParams.governance_proposal_asset_minVoterBalance;
proposalType = ProposalType.PROPOSAL_NEW_ASSET; proposalType = ProposalType.PROPOSAL_NEW_ASSET;
break; break;
case 'UpdateAsset': case 'UpdateAsset':
minVoterBalance = minVoterBalance =
params.governance_proposal_updateAsset_minVoterBalance; networkParams.governance_proposal_updateAsset_minVoterBalance;
proposalType = ProposalType.PROPOSAL_UPDATE_ASSET; proposalType = ProposalType.PROPOSAL_UPDATE_ASSET;
break; break;
case 'UpdateNetworkParameter': case 'UpdateNetworkParameter':
minVoterBalance = minVoterBalance =
params.governance_proposal_updateNetParam_minVoterBalance; networkParams.governance_proposal_updateNetParam_minVoterBalance;
proposalType = ProposalType.PROPOSAL_NETWORK_PARAMETER; proposalType = ProposalType.PROPOSAL_NETWORK_PARAMETER;
break; break;
case 'NewFreeform': case 'NewFreeform':
minVoterBalance = params.governance_proposal_freeform_minVoterBalance; minVoterBalance =
networkParams.governance_proposal_freeform_minVoterBalance;
proposalType = ProposalType.PROPOSAL_FREEFORM; proposalType = ProposalType.PROPOSAL_FREEFORM;
break; break;
} }
} }
return ( return (
<AsyncRenderer data={params} loading={loading} error={error}> <section data-testid="proposal">
<section data-testid="proposal"> <div className="flex items-center gap-1 mb-6">
<div className="flex items-center gap-1"> <Icon name={'chevron-left'} />
<Icon name={'chevron-left'} />
{proposal.state === ProposalState.STATE_REJECTED ? ( {proposal.state === ProposalState.STATE_REJECTED ? (
<div data-testid="rejected-proposals-link"> <div data-testid="rejected-proposals-link">
<Link className="underline" to={Routes.PROPOSALS_REJECTED}> <Link className="underline" to={Routes.PROPOSALS_REJECTED}>
{t('RejectedProposals')} {t('RejectedProposals')}
</Link> </Link>
</div> </div>
) : ( ) : (
<div data-testid="all-proposals-link"> <div data-testid="all-proposals-link">
<Link className="underline" to={Routes.PROPOSALS}> <Link className="underline" to={Routes.PROPOSALS}>
{t('AllProposals')} {t('AllProposals')}
</Link> </Link>
</div> </div>
)} )}
</div>
<ProposalHeader
proposal={proposal}
isListItem={false}
networkParams={networkParams}
/>
<div id="details">
<div className="my-10">
<ProposalChangeTable proposal={proposal} />
</div> </div>
<ProposalHeader proposal={proposal} isListItem={false} />
<div id="details"> {proposal.terms.change.__typename === 'NewAsset' &&
<div className="my-10"> proposal.terms.change.source.__typename === 'ERC20' &&
<ProposalChangeTable proposal={proposal} /> proposal.id ? (
</div> <ListAsset
assetId={proposal.id}
withdrawalThreshold={proposal.terms.change.source.withdrawThreshold}
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
/>
) : null}
{proposal.terms.change.__typename === 'NewAsset' && <div className="mb-4">
proposal.terms.change.source.__typename === 'ERC20' && <ProposalDescription description={proposal.rationale.description} />
proposal.id ? ( </div>
<ListAsset
assetId={proposal.id}
withdrawalThreshold={
proposal.terms.change.source.withdrawThreshold
}
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
/>
) : null}
{newMarketData && (
<div className="mb-4"> <div className="mb-4">
<ProposalDescription description={proposal.rationale.description} /> <ProposalMarketData marketData={newMarketData} />
</div> </div>
)}
{newMarketData && ( {(proposal.terms.change.__typename === 'NewAsset' ||
proposal.terms.change.__typename === 'UpdateAsset') &&
asset && (
<div className="mb-4"> <div className="mb-4">
<ProposalMarketData marketData={newMarketData} /> <ProposalAssetDetails asset={asset} />
</div> </div>
)} )}
{(proposal.terms.change.__typename === 'NewAsset' || <div className="mb-6">
proposal.terms.change.__typename === 'UpdateAsset') && <ProposalJson proposal={restData?.data?.proposal} />
asset && (
<div className="mb-4">
<ProposalAssetDetails asset={asset} />
</div>
)}
<div className="mb-6">
<ProposalJson proposal={restData?.data?.proposal} />
</div>
</div> </div>
</div>
<div id="voting"> <div id="voting">
<div className="mb-10"> <div className="mb-10">
<RoundedWrapper paddingBottom={true}> <RoundedWrapper paddingBottom={true}>
<VoteDetails <VoteDetails
proposal={proposal}
proposalType={proposalType}
minVoterBalance={minVoterBalance}
spamProtectionMinTokens={
params?.spam_protection_voting_min_tokens
}
/>
</RoundedWrapper>
</div>
<div className="mb-4">
<ProposalVotesTable
proposal={proposal} proposal={proposal}
proposalType={proposalType} proposalType={proposalType}
minVoterBalance={minVoterBalance}
spamProtectionMinTokens={
networkParams?.spam_protection_voting_min_tokens
}
/> />
</div> </RoundedWrapper>
</div> </div>
</section>
</AsyncRenderer> <div className="mb-4">
<ProposalVotesTable proposal={proposal} proposalType={proposalType} />
</div>
</div>
</section>
); );
}; };

View File

@ -5,11 +5,7 @@ import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { import { ProposalRejectionReason, ProposalState } from '@vegaprotocol/types';
ProposalRejectionReason,
ProposalState,
VoteValue,
} from '@vegaprotocol/types';
import { import {
generateNoVotes, generateNoVotes,
generateProposal, generateProposal,
@ -18,7 +14,6 @@ import {
import { ProposalsListItemDetails } from './proposals-list-item-details'; import { ProposalsListItemDetails } from './proposals-list-item-details';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats'; import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import { import {
mockPubkey,
mockWalletContext, mockWalletContext,
networkParamsQueryMock, networkParamsQueryMock,
fiveMinutes, fiveMinutes,
@ -28,39 +23,6 @@ import {
nextWeek, nextWeek,
} from '../../test-helpers/mocks'; } from '../../test-helpers/mocks';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { UserVoteDocument } from '../vote-details/__generated__/Vote';
import faker from 'faker';
const createUserVoteQueryMock = (
proposalId: string | undefined | null,
value: VoteValue
) => ({
request: {
query: UserVoteDocument,
variables: {
partyId: mockPubkey.publicKey,
},
},
result: {
data: {
party: {
votesConnection: {
edges: [
{
node: {
proposalId,
vote: {
value,
datetime: faker.date.past().toISOString(),
},
},
},
],
},
},
},
},
});
const renderComponent = ( const renderComponent = (
proposal: ProposalQuery['proposal'], proposal: ProposalQuery['proposal'],
@ -131,7 +93,7 @@ describe('Proposals list item details', () => {
); );
}); });
it('Renders proposal state: Update market proposal - set to pass by LP vote', () => { it('Renders proposal state: Update market proposal - Currently expected to pass by LP vote', () => {
renderComponent( renderComponent(
generateProposal({ generateProposal({
state: ProposalState.STATE_OPEN, state: ProposalState.STATE_OPEN,
@ -153,11 +115,11 @@ describe('Proposals list item details', () => {
}) })
); );
expect(screen.getByTestId('vote-status')).toHaveTextContent( expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Set to pass by LP vote' 'Currently expected to pass by LP vote'
); );
}); });
it('Renders proposal state: Update market proposal - set to pass by token vote', () => { it('Renders proposal state: Update market proposal - Currently expected to pass by token vote', () => {
renderComponent( renderComponent(
generateProposal({ generateProposal({
state: ProposalState.STATE_OPEN, state: ProposalState.STATE_OPEN,
@ -179,11 +141,11 @@ describe('Proposals list item details', () => {
}) })
); );
expect(screen.getByTestId('vote-status')).toHaveTextContent( expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Set to pass by token vote' 'Currently expected to pass by token vote'
); );
}); });
it('Renders proposal state: Update market proposal - set to fail', () => { it('Renders proposal state: Update market proposal - Currently expected to fail', () => {
renderComponent( renderComponent(
generateProposal({ generateProposal({
state: ProposalState.STATE_OPEN, state: ProposalState.STATE_OPEN,
@ -204,7 +166,9 @@ describe('Proposals list item details', () => {
}, },
}) })
); );
expect(screen.getByTestId('vote-status')).toHaveTextContent('Set to fail'); expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Currently expected to fail'
);
}); });
it('Renders proposal state: Open - 5 minutes left to vote', () => { it('Renders proposal state: Open - 5 minutes left to vote', () => {
@ -249,52 +213,6 @@ describe('Proposals list item details', () => {
); );
}); });
it('Renders proposal state: Open - user voted for', async () => {
const proposal = generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
closingDatetime: nextWeek.toString(),
},
});
renderComponent(proposal, [
networkParamsQueryMock,
createUserVoteQueryMock(proposal?.id, VoteValue.VALUE_YES),
]);
expect(await screen.findByText('You voted For')).toBeInTheDocument();
});
it('Renders proposal state: Open - user voted against', async () => {
const proposal = generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
closingDatetime: nextWeek.toString(),
},
});
renderComponent(proposal, [
networkParamsQueryMock,
createUserVoteQueryMock(proposal?.id, VoteValue.VALUE_NO),
]);
expect(await screen.findByText('You voted Against')).toBeInTheDocument();
});
it('Renders proposal state: Open - participation not reached', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_OPEN,
terms: {
enactmentDatetime: nextWeek.toString(),
},
votes: {
no: generateNoVotes(0),
yes: generateYesVotes(0),
},
})
);
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Participation not reached'
);
});
it('Renders proposal state: Open - majority not reached', () => { it('Renders proposal state: Open - majority not reached', () => {
renderComponent( renderComponent(
generateProposal({ generateProposal({
@ -309,7 +227,7 @@ describe('Proposals list item details', () => {
}) })
); );
expect(screen.getByTestId('vote-status')).toHaveTextContent( expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Majority not reached' 'Currently expected to fail'
); );
}); });
@ -327,42 +245,8 @@ describe('Proposals list item details', () => {
}, },
}) })
); );
expect(screen.getByTestId('vote-status')).toHaveTextContent('Set to pass');
});
it('Renders proposal state: Declined - participation not reached', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_DECLINED,
terms: {
enactmentDatetime: lastWeek.toString(),
},
votes: {
no: generateNoVotes(0),
yes: generateYesVotes(0),
},
})
);
expect(screen.getByTestId('vote-status')).toHaveTextContent( expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Participation not reached' 'Currently expected to pass'
);
});
it('Renders proposal state: Declined - majority not reached', () => {
renderComponent(
generateProposal({
state: ProposalState.STATE_DECLINED,
terms: {
enactmentDatetime: lastWeek.toString(),
},
votes: {
no: generateNoVotes(1, 1000000000000000000),
yes: generateYesVotes(1, 1000000000000000000),
},
})
);
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Majority not reached'
); );
}); });

View File

@ -1,9 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from '@vegaprotocol/ui-toolkit'; import { Button, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import { useVoteInformation } from '../../hooks'; import { differenceInHours, format, formatDistanceToNowStrict } from 'date-fns';
import { useUserVote } from '../vote-details/use-user-vote';
import { StatusPass } from '../current-proposal-status/current-proposal-status';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats'; import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -14,138 +11,100 @@ import {
import Routes from '../../../routes'; import Routes from '../../../routes';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals'; import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { useVoteInformation } from '../../hooks';
const MajorityNotReached = () => {
const { t } = useTranslation();
return (
<>
{t('Majority')} {t('not reached')}
</>
);
};
const ParticipationNotReached = () => {
const { t } = useTranslation();
return (
<>
{t('Participation')} {t('not reached')}
</>
);
};
export const ProposalsListItemDetails = ({ export const ProposalsListItemDetails = ({
proposal, proposal,
}: { }: {
proposal: ProposalFieldsFragment | ProposalQuery['proposal']; proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
}) => { }) => {
const { t } = useTranslation();
const state = proposal?.state; const state = proposal?.state;
const { const { willPassByTokenVote, willPassByLPVote } = useVoteInformation({
willPassByTokenVote,
willPassByLPVote,
majorityMet,
participationMet,
} = useVoteInformation({
proposal, proposal,
}); });
const { t } = useTranslation();
const { voteState } = useUserVote(proposal?.id);
const isUpdateMarket = proposal?.terms.change.__typename === 'UpdateMarket';
const updateMarketWillPass = willPassByTokenVote || willPassByLPVote; const updateMarketWillPass = willPassByTokenVote || willPassByLPVote;
const updateMarketVotePassMethod = willPassByTokenVote const updateMarketVotePassMethod = willPassByTokenVote
? t('byTokenVote') ? t('byTokenVote')
: t('byLPVote'); : t('byLPVote');
const nowToEnactmentInHours = differenceInHours(
new Date(proposal?.terms.closingDatetime),
new Date()
);
const isUpdateMarket = proposal?.terms.change.__typename === 'UpdateMarket';
let voteDetails: ReactNode; let voteDetails: ReactNode;
let voteStatus: ReactNode; let voteStatus: ReactNode;
switch (state) { switch (state) {
case ProposalState.STATE_ENACTED: { case ProposalState.STATE_ENACTED: {
voteDetails = proposal?.terms.enactmentDatetime && ( voteDetails =
<> proposal?.terms.enactmentDatetime &&
{format( t('enactedOn{{date}}', {
new Date(proposal?.terms.enactmentDatetime), enactmentDate:
DATE_FORMAT_DETAILED proposal?.terms.enactmentDatetime &&
)} format(
</> new Date(proposal?.terms.enactmentDatetime),
); DATE_FORMAT_DETAILED
),
});
break; break;
} }
case ProposalState.STATE_PASSED: { case ProposalState.STATE_PASSED: {
voteDetails = proposal?.terms.change.__typename !== 'NewFreeform' && ( voteDetails =
<> proposal?.terms.change.__typename !== 'NewFreeform' &&
{t('toEnactOn')}{' '} t('enactsOn{{date}}', {
{proposal?.terms.enactmentDatetime && enactmentDate:
proposal?.terms.enactmentDatetime &&
format( format(
new Date(proposal.terms.enactmentDatetime), new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED DATE_FORMAT_DETAILED
)} ),
</> });
);
break; break;
} }
case ProposalState.STATE_WAITING_FOR_NODE_VOTE: { case ProposalState.STATE_WAITING_FOR_NODE_VOTE: {
voteDetails = proposal?.terms.change.__typename !== 'NewFreeform' && ( voteDetails =
<> proposal?.terms.change.__typename !== 'NewFreeform' &&
{t('toEnactOn')}{' '} t('enactsOn{{date}}', {
{proposal?.terms.enactmentDatetime && enactmentDate:
proposal?.terms.enactmentDatetime &&
format( format(
new Date(proposal.terms.enactmentDatetime), new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED DATE_FORMAT_DETAILED
)} ),
</> });
);
break; break;
} }
case ProposalState.STATE_OPEN: { case ProposalState.STATE_OPEN: {
voteDetails = (voteState === 'Yes' && ( voteDetails = (
<> <span className={nowToEnactmentInHours < 6 ? 'text-vega-pink' : ''}>
{t('youVoted')} {t('voteState_Yes')} {formatDistanceToNowStrict(new Date(proposal?.terms.closingDatetime))}{' '}
</> {t('left to vote')}
)) || </span>
(voteState === 'No' && ( );
<>
{t('youVoted')} {t('voteState_No')}
</>
)) || (
<>
{formatDistanceToNowStrict(
new Date(proposal?.terms.closingDatetime)
)}{' '}
{t('left to vote')}
</>
);
voteStatus = voteStatus =
(isUpdateMarket && (isUpdateMarket &&
(updateMarketWillPass ? ( (updateMarketWillPass ? (
<> <>
{t('Set to')}{' '} {t('currentlySetTo')} {t('pass')} {updateMarketVotePassMethod}
<StatusPass>
{t('pass')} {updateMarketVotePassMethod}
</StatusPass>
</> </>
) : ( ) : (
<> <>
{t('Set to')} {t('fail')} {t('currentlySetTo')} {t('fail')}
</> </>
))) || ))) ||
(!participationMet && <ParticipationNotReached />) ||
(!majorityMet && <MajorityNotReached />) ||
(willPassByTokenVote ? ( (willPassByTokenVote ? (
<> <>
{t('Set to')} {t('pass')} {t('currentlySetTo')} {t('pass')}
</> </>
) : ( ) : (
<> <>
{t('Set to')} {t('fail')} {t('currentlySetTo')} {t('fail')}
</> </>
)); ));
break; break;
} }
case ProposalState.STATE_DECLINED: {
voteStatus =
(!participationMet && <ParticipationNotReached />) ||
(!majorityMet && <MajorityNotReached />);
break;
}
case ProposalState.STATE_REJECTED: { case ProposalState.STATE_REJECTED: {
voteStatus = proposal?.rejectionReason && ( voteStatus = proposal?.rejectionReason && (
<>{t(ProposalRejectionReasonMapping[proposal.rejectionReason])}</> <>{t(ProposalRejectionReasonMapping[proposal.rejectionReason])}</>
@ -155,29 +114,21 @@ export const ProposalsListItemDetails = ({
} }
return ( return (
<div className="grid grid-cols-[1fr_auto] mt-4 items-start gap-2 text-sm"> <div className="mt-4 items-start text-sm">
{voteDetails && ( <div className="text-vega-green">
<div <VegaIcon size={16} name={VegaIconNames.VOTE} />
className="col-start-1 row-start-2 text-vega-light-300" <VegaIcon size={16} name={VegaIconNames.TICKET} />
data-testid="vote-details" </div>
> <div className="flex items-center gap-2 text-vega-light-300 mb-2">
{voteDetails} {voteDetails && <span data-testid="vote-details">{voteDetails}</span>}
</div> {voteDetails && voteStatus && <span>&middot;</span>}
)} {voteStatus && <span data-testid="vote-status">{voteStatus}</span>}
{voteStatus && ( </div>
<div
className="col-start-2 row-start-1 justify-self-end"
data-testid="vote-status"
>
{voteStatus}
</div>
)}
{proposal?.id && ( {proposal?.id && (
<div className="col-start-2 row-start-2 justify-self-end"> <Link to={`${Routes.PROPOSALS}/${proposal.id}`}>
<Link to={`${Routes.PROPOSALS}/${proposal.id}`}> <Button data-testid="view-proposal-btn">{t('viewDetails')}</Button>
<Button data-testid="view-proposal-btn">{t('View')}</Button> </Link>
</Link>
</div>
)} )}
</div> </div>
); );

View File

@ -3,18 +3,23 @@ import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import { ProposalsListItemDetails } from './proposals-list-item-details'; import { ProposalsListItemDetails } from './proposals-list-item-details';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals'; import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import type { NetworkParamsResult } from '@vegaprotocol/network-parameters';
interface ProposalsListItemProps { interface ProposalsListItemProps {
proposal?: ProposalFieldsFragment | ProposalQuery['proposal'] | null; proposal?: ProposalFieldsFragment | ProposalQuery['proposal'] | null;
networkParams: Partial<NetworkParamsResult> | null;
} }
export const ProposalsListItem = ({ proposal }: ProposalsListItemProps) => { export const ProposalsListItem = ({
if (!proposal || !proposal.id) return null; proposal,
networkParams,
}: ProposalsListItemProps) => {
if (!proposal || !proposal.id || !networkParams) return null;
return ( return (
<li id={proposal.id} data-testid="proposals-list-item"> <li id={proposal.id} data-testid="proposals-list-item">
<RoundedWrapper paddingBottom={true} heightFull={true}> <RoundedWrapper paddingBottom={true} heightFull={true}>
<ProposalHeader proposal={proposal} /> <ProposalHeader proposal={proposal} networkParams={networkParams} />
<ProposalsListItemDetails proposal={proposal} /> <ProposalsListItemDetails proposal={proposal} />
</RoundedWrapper> </RoundedWrapper>
</li> </li>

View File

@ -88,31 +88,39 @@ afterAll(() => {
jest.useRealTimers(); jest.useRealTimers();
}); });
jest.mock('../vote-details/use-user-vote', () => ({
useUserVote: jest.fn().mockImplementation(() => ({ voteState: 'NotCast' })),
}));
describe('Proposals list', () => { describe('Proposals list', () => {
it('Render a page title and link to the make proposal form', () => { it('Render a page title and link to the make proposal form', async () => {
render(renderComponent([])); render(renderComponent([]));
await screen.findByTestId('proposals-list');
expect(screen.getByText('Proposals')).toBeInTheDocument(); expect(screen.getByText('Proposals')).toBeInTheDocument();
expect(screen.getByTestId('new-proposal-link')).toBeInTheDocument(); expect(screen.getByTestId('new-proposal-link')).toBeInTheDocument();
}); });
it('Will hide filter if no proposals', () => { it('Will hide filter if no proposals', async () => {
render(renderComponent([])); render(renderComponent([]));
await screen.findByTestId('proposals-list');
expect( expect(
screen.queryByTestId('proposals-list-filter') screen.queryByTestId('proposals-list-filter')
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it('Will show filter if there are proposals', () => { it('Will show filter if there are proposals', async () => {
render(renderComponent([enactedProposalClosedLastWeek])); render(renderComponent([enactedProposalClosedLastWeek]));
await screen.findByTestId('proposals-list');
expect(screen.queryByTestId('proposals-list-filter')).toBeInTheDocument(); expect(screen.queryByTestId('proposals-list-filter')).toBeInTheDocument();
}); });
it('Will render a link to rejected proposals', () => { it('Will render a link to rejected proposals', async () => {
render(renderComponent([])); render(renderComponent([]));
await screen.findByTestId('proposals-list');
expect(screen.getByText('See rejected proposals')).toBeInTheDocument(); expect(screen.getByText('See rejected proposals')).toBeInTheDocument();
}); });
it('Places proposals correctly in open or closed lists', () => { it('Places proposals correctly in open or closed lists', async () => {
render( render(
renderComponent([ renderComponent([
openProposalClosesNextWeek, openProposalClosesNextWeek,
@ -121,6 +129,7 @@ describe('Proposals list', () => {
failedProposalClosedLastMonth, failedProposalClosedLastMonth,
]) ])
); );
await screen.findByTestId('proposals-list');
const openProposals = within(screen.getByTestId('open-proposals')); const openProposals = within(screen.getByTestId('open-proposals'));
const closedProposals = within(screen.getByTestId('closed-proposals')); const closedProposals = within(screen.getByTestId('closed-proposals'));
expect(openProposals.getAllByTestId('proposals-list-item').length).toBe(2); expect(openProposals.getAllByTestId('proposals-list-item').length).toBe(2);
@ -129,42 +138,47 @@ describe('Proposals list', () => {
); );
}); });
it('Displays info on no proposals', () => { it('Displays info on no proposals', async () => {
render(renderComponent([])); render(renderComponent([]));
await screen.findByTestId('proposals-list');
expect(screen.queryByTestId('open-proposals')).not.toBeInTheDocument(); expect(screen.queryByTestId('open-proposals')).not.toBeInTheDocument();
expect(screen.getByTestId('no-open-proposals')).toBeInTheDocument(); expect(screen.getByTestId('no-open-proposals')).toBeInTheDocument();
expect(screen.queryByTestId('closed-proposals')).not.toBeInTheDocument(); expect(screen.queryByTestId('closed-proposals')).not.toBeInTheDocument();
expect(screen.getByTestId('no-closed-proposals')).toBeInTheDocument(); expect(screen.getByTestId('no-closed-proposals')).toBeInTheDocument();
}); });
it('Displays info on no open proposals if only closed are present', () => { it('Displays info on no open proposals if only closed are present', async () => {
render(renderComponent([enactedProposalClosedLastWeek])); render(renderComponent([enactedProposalClosedLastWeek]));
await screen.findByTestId('proposals-list');
expect(screen.queryByTestId('open-proposals')).not.toBeInTheDocument(); expect(screen.queryByTestId('open-proposals')).not.toBeInTheDocument();
expect(screen.getByTestId('no-open-proposals')).toBeInTheDocument(); expect(screen.getByTestId('no-open-proposals')).toBeInTheDocument();
expect(screen.getByTestId('closed-proposals')).toBeInTheDocument(); expect(screen.getByTestId('closed-proposals')).toBeInTheDocument();
expect(screen.queryByTestId('no-closed-proposals')).not.toBeInTheDocument(); expect(screen.queryByTestId('no-closed-proposals')).not.toBeInTheDocument();
}); });
it('Displays info on no closed proposals if only open are present', () => { it('Displays info on no closed proposals if only open are present', async () => {
render(renderComponent([openProposalClosesNextWeek])); render(renderComponent([openProposalClosesNextWeek]));
await screen.findByTestId('proposals-list');
expect(screen.getByTestId('open-proposals')).toBeInTheDocument(); expect(screen.getByTestId('open-proposals')).toBeInTheDocument();
expect(screen.queryByTestId('no-open-proposals')).not.toBeInTheDocument(); expect(screen.queryByTestId('no-open-proposals')).not.toBeInTheDocument();
expect(screen.queryByTestId('closed-proposals')).not.toBeInTheDocument(); expect(screen.queryByTestId('closed-proposals')).not.toBeInTheDocument();
expect(screen.getByTestId('no-closed-proposals')).toBeInTheDocument(); expect(screen.getByTestId('no-closed-proposals')).toBeInTheDocument();
}); });
it('Opens filter form when button is clicked', () => { it('Opens filter form when button is clicked', async () => {
render( render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek]) renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
); );
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle')); fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
expect(screen.getByTestId('proposals-list-filter')).toBeInTheDocument(); expect(screen.getByTestId('proposals-list-filter')).toBeInTheDocument();
}); });
it('Filters list by text - party id', () => { it('Filters list by text - party id', async () => {
render( render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek]) renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
); );
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle')); fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), { fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'bvcx' }, target: { value: 'bvcx' },
@ -174,10 +188,11 @@ describe('Proposals list', () => {
expect(container.querySelector('#proposal1')).not.toBeInTheDocument(); expect(container.querySelector('#proposal1')).not.toBeInTheDocument();
}); });
it('Filters list by text - proposal id', () => { it('Filters list by text - proposal id', async () => {
render( render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek]) renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
); );
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle')); fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), { fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'proposal1' }, target: { value: 'proposal1' },
@ -187,10 +202,11 @@ describe('Proposals list', () => {
expect(container.querySelector('#proposal2')).not.toBeInTheDocument(); expect(container.querySelector('#proposal2')).not.toBeInTheDocument();
}); });
it('Filters list by text - check for substring matching', () => { it('Filters list by text - check for substring matching', async () => {
render( render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek]) renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
); );
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle')); fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), { fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'osal1' }, target: { value: 'osal1' },
@ -200,10 +216,11 @@ describe('Proposals list', () => {
expect(container.querySelector('#proposal2')).not.toBeInTheDocument(); expect(container.querySelector('#proposal2')).not.toBeInTheDocument();
}); });
it('When filter is used, clear button is visible', () => { it('When filter is used, clear button is visible', async () => {
render( render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek]) renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
); );
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle')); fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), { fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'test' }, target: { value: 'test' },
@ -211,10 +228,11 @@ describe('Proposals list', () => {
expect(screen.getByTestId('clear-filter')).toBeInTheDocument(); expect(screen.getByTestId('clear-filter')).toBeInTheDocument();
}); });
it('When clear filter button is used, input is cleared', () => { it('When clear filter button is used, input is cleared', async () => {
render( render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek]) renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
); );
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle')); fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), { fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'test' }, target: { value: 'test' },
@ -225,37 +243,41 @@ describe('Proposals list', () => {
); );
}); });
it('Displays a toggle for closed proposals if there are both closed governance proposals and closed upgrade proposals', () => { it('Displays a toggle for closed proposals if there are both closed governance proposals and closed upgrade proposals', async () => {
render( render(
renderComponent( renderComponent(
[enactedProposalClosedLastWeek], [enactedProposalClosedLastWeek],
[closedProtocolUpgradeProposal] [closedProtocolUpgradeProposal]
) )
); );
await screen.findByTestId('proposals-list');
expect(screen.getByTestId('toggle-closed-proposals')).toBeInTheDocument(); expect(screen.getByTestId('toggle-closed-proposals')).toBeInTheDocument();
}); });
it('Does not display a toggle for closed proposals if there are only closed upgrade proposals', () => { it('Does not display a toggle for closed proposals if there are only closed upgrade proposals', async () => {
render(renderComponent([], [closedProtocolUpgradeProposal])); render(renderComponent([], [closedProtocolUpgradeProposal]));
await screen.findByTestId('proposals-list');
expect( expect(
screen.queryByTestId('toggle-closed-proposals') screen.queryByTestId('toggle-closed-proposals')
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it('Does not display a toggle for closed proposals if there are only closed governance proposals', () => { it('Does not display a toggle for closed proposals if there are only closed governance proposals', async () => {
render(renderComponent([enactedProposalClosedLastWeek])); render(renderComponent([enactedProposalClosedLastWeek]));
await screen.findByTestId('proposals-list');
expect( expect(
screen.queryByTestId('toggle-closed-proposals') screen.queryByTestId('toggle-closed-proposals')
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it('Does not display a toggle for closed proposals if the proposal filter is engaged', () => { it('Does not display a toggle for closed proposals if the proposal filter is engaged', async () => {
render( render(
renderComponent( renderComponent(
[enactedProposalClosedLastWeek], [enactedProposalClosedLastWeek],
[closedProtocolUpgradeProposal] [closedProtocolUpgradeProposal]
) )
); );
await screen.findByTestId('proposal-filter-toggle');
fireEvent.click(screen.getByTestId('proposal-filter-toggle')); fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), { fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'test' }, target: { value: 'test' },
@ -265,7 +287,7 @@ describe('Proposals list', () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it('Displays closed governance proposals by default due to default for the toggle', () => { it('Displays closed governance proposals by default due to default for the toggle', async () => {
render( render(
renderComponent( renderComponent(
[enactedProposalClosedLastWeek], [enactedProposalClosedLastWeek],
@ -273,17 +295,19 @@ describe('Proposals list', () => {
) )
); );
expect( expect(
screen.getByTestId('closed-governance-proposals') await screen.findByTestId('closed-governance-proposals')
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('Displays closed upgrade proposals when the toggle is clicked', () => { it('Displays closed upgrade proposals when the toggle is clicked', async () => {
render( render(
renderComponent( renderComponent(
[enactedProposalClosedLastWeek], [enactedProposalClosedLastWeek],
[closedProtocolUpgradeProposal] [closedProtocolUpgradeProposal]
) )
); );
await screen.findByTestId('toggle-closed-proposals');
fireEvent.click(screen.getByText('Network upgrades')); fireEvent.click(screen.getByText('Network upgrades'));
expect(screen.getByTestId('closed-upgrade-proposals')).toBeInTheDocument(); expect(screen.getByTestId('closed-upgrade-proposals')).toBeInTheDocument();
}); });

View File

@ -8,6 +8,7 @@ import { ProtocolUpgradeProposalsListItem } from '../protocol-upgrade-proposals-
import { ProposalsListFilter } from '../proposals-list-filter'; import { ProposalsListFilter } from '../proposals-list-filter';
import Routes from '../../../routes'; import Routes from '../../../routes';
import { import {
AsyncRenderer,
Button, Button,
Toggle, Toggle,
VegaIcon, VegaIcon,
@ -19,6 +20,10 @@ import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals'; import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals'; import type { ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
import { DocsLinks, ExternalLinks } from '@vegaprotocol/environment'; import { DocsLinks, ExternalLinks } from '@vegaprotocol/environment';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
interface ProposalsListProps { interface ProposalsListProps {
proposals: Array<ProposalFieldsFragment | ProposalQuery['proposal']>; proposals: Array<ProposalFieldsFragment | ProposalQuery['proposal']>;
@ -70,6 +75,20 @@ export const ProposalsList = ({
lastBlockHeight, lastBlockHeight,
}: ProposalsListProps) => { }: ProposalsListProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const {
params: networkParams,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredMajority,
]);
const [filterString, setFilterString] = useState(''); const [filterString, setFilterString] = useState('');
const [closedProposalsView, setClosedProposalsView] = const [closedProposalsView, setClosedProposalsView] =
useState<ClosedProposalsViewOptions>( useState<ClosedProposalsViewOptions>(
@ -134,166 +153,181 @@ export const ProposalsList = ({
p?.party?.id?.toString().includes(filterString); p?.party?.id?.toString().includes(filterString);
return ( return (
<> <AsyncRenderer
<div className="grid xs:grid-cols-2 items-center"> loading={networkParamsLoading}
<Heading error={networkParamsError}
centerContent={false} data={networkParams}
marginBottom={false} >
title={t('pageTitleProposals')} <div data-testid="proposals-list">
/> <div className="grid xs:grid-cols-2 items-center">
<Heading
centerContent={false}
marginBottom={false}
title={t('pageTitleProposals')}
/>
{DocsLinks && ( {DocsLinks && (
<div className="xs:justify-self-end" data-testid="new-proposal-link"> <div
<ExternalLink href={DocsLinks.PROPOSALS_GUIDE}> className="xs:justify-self-end"
<Button variant="primary" size="sm"> data-testid="new-proposal-link"
<div className="flex items-center gap-1"> >
{t('NewProposal')} <ExternalLink href={DocsLinks.PROPOSALS_GUIDE}>
<VegaIcon name={VegaIconNames.OPEN_EXTERNAL} size={13} /> <Button variant="primary" size="sm">
</div> <div className="flex items-center gap-1">
</Button> {t('NewProposal')}
</ExternalLink> <VegaIcon name={VegaIconNames.OPEN_EXTERNAL} size={13} />
</div> </div>
</Button>
</ExternalLink>
</div>
)}
</div>
<p className="mb-8">
{t(
`The Vega network is governed by the community. View active proposals, vote on them or propose changes to the network. Network upgrades are proposed and approved by validators.`
)}{' '}
<ExternalLink
data-testid="proposal-documentation-link"
href={ExternalLinks.GOVERNANCE_PAGE}
className="text-white"
>
{t(`Find out more about Vega governance`)}
</ExternalLink>
</p>
{proposals.length > 0 && (
<ProposalsListFilter
filterString={filterString}
setFilterString={(value) => {
setFilterString(value);
if (value.length > 0) {
// If the filter is engaged, ensure the user is viewing governance proposals,
// as network upgrades do not have IDs to filter by and will be excluded.
setClosedProposalsView(
ClosedProposalsViewOptions.NetworkGovernance
);
}
}}
/>
)} )}
</div>
<p className="mb-8"> <section className="-mx-4 p-4 mb-8 bg-vega-dark-100">
{t( <SubHeading title={t('openProposals')} />
`The Vega network is governed by the community. View active proposals, vote on them or propose changes to the network. Network upgrades are proposed and approved by validators.`
)}{' '}
<ExternalLink
data-testid="proposal-documentation-link"
href={ExternalLinks.GOVERNANCE_PAGE}
className="text-white"
>
{t(`Find out more about Vega governance`)}
</ExternalLink>
</p>
{proposals.length > 0 && ( {sortedProposals.open.length > 0 ||
<ProposalsListFilter sortedProtocolUpgradeProposals.open.length > 0 ? (
filterString={filterString} <ul data-testid="open-proposals">
setFilterString={(value) => { {filterString.length < 1 &&
setFilterString(value); sortedProtocolUpgradeProposals.open.map((proposal) => (
if (value.length > 0) { <ProtocolUpgradeProposalsListItem
// If the filter is engaged, ensure the user is viewing governance proposals, key={proposal.upgradeBlockHeight}
// as network upgrades do not have IDs to filter by and will be excluded. proposal={proposal}
setClosedProposalsView( />
ClosedProposalsViewOptions.NetworkGovernance ))}
);
}
}}
/>
)}
<section className="-mx-4 p-4 mb-8 bg-vega-dark-100"> {sortedProposals.open.filter(filterPredicate).map((proposal) => (
<SubHeading title={t('openProposals')} /> <ProposalsListItem
key={proposal?.id}
{sortedProposals.open.length > 0 ||
sortedProtocolUpgradeProposals.open.length > 0 ? (
<ul data-testid="open-proposals">
{filterString.length < 1 &&
sortedProtocolUpgradeProposals.open.map((proposal) => (
<ProtocolUpgradeProposalsListItem
key={proposal.upgradeBlockHeight}
proposal={proposal} proposal={proposal}
networkParams={networkParams}
/> />
))} ))}
</ul>
) : (
<p className="mb-0" data-testid="no-open-proposals">
{t('noOpenProposals')}
</p>
)}
</section>
{sortedProposals.open.filter(filterPredicate).map((proposal) => ( <section className="relative">
<ProposalsListItem key={proposal?.id} proposal={proposal} /> <SubHeading title={t('closedProposals')} />
))} {sortedProposals.closed.length > 0 ||
</ul> sortedProtocolUpgradeProposals.closed.length > 0 ? (
) : ( <>
<p className="mb-0" data-testid="no-open-proposals"> {
{t('noOpenProposals')} // We need both the closed proposals and closed protocol upgrade
</p> // proposals to be present for there to be a toggle. It also gets
)} // hidden if the user has filtered the list, as the upgrade proposals
</section> // do not have the necessary fields for filtering.
sortedProposals.closed.length > 0 &&
<section className="relative"> sortedProtocolUpgradeProposals.closed.length > 0 &&
<SubHeading title={t('closedProposals')} /> filterString.length < 1 && (
{sortedProposals.closed.length > 0 || <div
sortedProtocolUpgradeProposals.closed.length > 0 ? ( className="grid w-full justify-end xl:-mt-12 pb-6"
<> data-testid="toggle-closed-proposals"
{ >
// We need both the closed proposals and closed protocol upgrade <div className="w-[440px]">
// proposals to be present for there to be a toggle. It also gets <Toggle
// hidden if the user has filtered the list, as the upgrade proposals name="closed-proposals-toggle"
// do not have the necessary fields for filtering. toggles={[
sortedProposals.closed.length > 0 && {
sortedProtocolUpgradeProposals.closed.length > 0 && label: t(
filterString.length < 1 && ( ClosedProposalsViewOptions.NetworkGovernance
<div ),
className="grid w-full justify-end xl:-mt-12 pb-6" value:
data-testid="toggle-closed-proposals" ClosedProposalsViewOptions.NetworkGovernance,
> },
<div className="w-[440px]"> {
<Toggle label: t(
name="closed-proposals-toggle" ClosedProposalsViewOptions.NetworkUpgrades
toggles={[ ),
{ value: ClosedProposalsViewOptions.NetworkUpgrades,
label: t( },
ClosedProposalsViewOptions.NetworkGovernance ]}
), checkedValue={closedProposalsView}
value: ClosedProposalsViewOptions.NetworkGovernance, onChange={(e) =>
}, setClosedProposalsView(
{ e.target.value as ClosedProposalsViewOptions
label: t( )
ClosedProposalsViewOptions.NetworkUpgrades }
), />
value: ClosedProposalsViewOptions.NetworkUpgrades, </div>
},
]}
checkedValue={closedProposalsView}
onChange={(e) =>
setClosedProposalsView(
e.target.value as ClosedProposalsViewOptions
)
}
/>
</div> </div>
</div> )
) }
}
<ul data-testid="closed-proposals"> <ul data-testid="closed-proposals">
{closedProposalsView === {closedProposalsView ===
ClosedProposalsViewOptions.NetworkUpgrades && ( ClosedProposalsViewOptions.NetworkUpgrades && (
<div data-testid="closed-upgrade-proposals"> <div data-testid="closed-upgrade-proposals">
{sortedProtocolUpgradeProposals.closed.map((proposal) => ( {sortedProtocolUpgradeProposals.closed.map((proposal) => (
<ProtocolUpgradeProposalsListItem <ProtocolUpgradeProposalsListItem
key={proposal.upgradeBlockHeight} key={proposal.upgradeBlockHeight}
proposal={proposal}
/>
))}
</div>
)}
{closedProposalsView ===
ClosedProposalsViewOptions.NetworkGovernance && (
<div data-testid="closed-governance-proposals">
{sortedProposals.closed
.filter(filterPredicate)
.map((proposal) => (
<ProposalsListItem
key={proposal?.id}
proposal={proposal} proposal={proposal}
/> />
))} ))}
</div> </div>
)} )}
</ul>
</>
) : (
<p className="mb-0" data-testid="no-closed-proposals">
{t('noClosedProposals')}
</p>
)}
</section>
<Link className="underline" to={Routes.PROPOSALS_REJECTED}> {closedProposalsView ===
{t('seeRejectedProposals')} ClosedProposalsViewOptions.NetworkGovernance && (
</Link> <div data-testid="closed-governance-proposals">
</> {sortedProposals.closed
.filter(filterPredicate)
.map((proposal) => (
<ProposalsListItem
key={proposal?.id}
proposal={proposal}
networkParams={networkParams}
/>
))}
</div>
)}
</ul>
</>
) : (
<p className="mb-0" data-testid="no-closed-proposals">
{t('noClosedProposals')}
</p>
)}
</section>
<Link className="underline" to={Routes.PROPOSALS_REJECTED}>
{t('seeRejectedProposals')}
</Link>
</div>
</AsyncRenderer>
); );
}; };

View File

@ -5,7 +5,7 @@ import { BrowserRouter as Router } from 'react-router-dom';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider'; import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { RejectedProposalsList } from './rejected-proposals-list'; import { RejectedProposalsList } from './rejected-proposals-list';
import { ProposalState } from '@vegaprotocol/types'; import { ProposalState } from '@vegaprotocol/types';
import { render, screen, within } from '@testing-library/react'; import { render, screen, waitFor, within } from '@testing-library/react';
import { import {
mockWalletContext, mockWalletContext,
networkParamsQueryMock, networkParamsQueryMock,
@ -55,24 +55,38 @@ afterAll(() => {
jest.useRealTimers(); jest.useRealTimers();
}); });
jest.mock('../vote-details/use-user-vote', () => ({
useUserVote: jest.fn().mockImplementation(() => ({ voteState: 'NotCast' })),
}));
describe('Rejected proposals list', () => { describe('Rejected proposals list', () => {
it('Renders a list of proposals', () => { it('Renders a list of proposals', async () => {
render( render(
renderComponent([ renderComponent([
rejectedProposalClosedLastMonth, rejectedProposalClosedLastMonth,
rejectedProposalClosesNextWeek, rejectedProposalClosesNextWeek,
]) ])
); );
const rejectedProposals = within(screen.getByTestId('rejected-proposals'));
const rejectedProposalsItems = rejectedProposals.getAllByTestId( await waitFor(() => {
'proposals-list-item' const rejectedProposals = within(
); screen.getByTestId('rejected-proposals')
expect(rejectedProposalsItems).toHaveLength(2); );
const rejectedProposalsItems = rejectedProposals.getAllByTestId(
'proposals-list-item'
);
expect(rejectedProposalsItems).toHaveLength(2);
});
}); });
it('Displays text when there are no proposals', () => { it('Displays text when there are no proposals', async () => {
render(renderComponent([])); render(renderComponent([]));
expect(screen.queryByTestId('rejected-proposals')).not.toBeInTheDocument();
expect(screen.getByTestId('no-rejected-proposals')).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByTestId('no-rejected-proposals')).toBeInTheDocument();
expect(
screen.queryByTestId('rejected-proposals')
).not.toBeInTheDocument();
});
}); });
}); });

View File

@ -1,10 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../../components/heading'; import { Heading } from '../../../../components/heading';
import { ProposalsListItem } from '../proposals-list-item'; import { ProposalsListItem } from '../proposals-list-item';
import { ProposalsListFilter } from '../proposals-list-filter'; import { ProposalsListFilter } from '../proposals-list-filter';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals'; import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
interface ProposalsListProps { interface ProposalsListProps {
proposals: Array<ProposalQuery['proposal'] | ProposalFieldsFragment>; proposals: Array<ProposalQuery['proposal'] | ProposalFieldsFragment>;
@ -12,6 +17,19 @@ interface ProposalsListProps {
export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => { export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const {
params: networkParams,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredMajority,
]);
const [filterString, setFilterString] = useState(''); const [filterString, setFilterString] = useState('');
const filterPredicate = ( const filterPredicate = (
@ -21,7 +39,11 @@ export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
p?.party?.id?.toString().includes(filterString); p?.party?.id?.toString().includes(filterString);
return ( return (
<> <AsyncRenderer
loading={networkParamsLoading}
error={networkParamsError}
data={networkParams}
>
<Heading title={t('pageTitleRejectedProposals')} /> <Heading title={t('pageTitleRejectedProposals')} />
<ProposalsListFilter <ProposalsListFilter
filterString={filterString} filterString={filterString}
@ -31,7 +53,11 @@ export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
{proposals.length > 0 ? ( {proposals.length > 0 ? (
<ul data-testid="rejected-proposals"> <ul data-testid="rejected-proposals">
{proposals.filter(filterPredicate).map((proposal) => ( {proposals.filter(filterPredicate).map((proposal) => (
<ProposalsListItem key={proposal?.id} proposal={proposal} /> <ProposalsListItem
key={proposal?.id}
proposal={proposal}
networkParams={networkParams}
/>
))} ))}
</ul> </ul>
) : ( ) : (
@ -40,6 +66,6 @@ export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
</p> </p>
)} )}
</section> </section>
</> </AsyncRenderer>
); );
}; };

View File

@ -20,43 +20,6 @@ const renderComponent = (proposal: ProtocolUpgradeProposalFieldsFragment) =>
); );
describe('ProtocolUpgradeProposalsListItem', () => { describe('ProtocolUpgradeProposalsListItem', () => {
it('renders the correct status icon for each proposal status', () => {
const statuses = [
{
status:
ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_REJECTED,
icon: 'protocol-upgrade-proposal-status-icon-rejected',
text: 'Declined by validators',
},
{
status:
ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_PENDING,
icon: 'protocol-upgrade-proposal-status-icon-pending',
text: 'Waiting for validator votes',
},
{
status:
ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_APPROVED,
icon: 'protocol-upgrade-proposal-status-icon-approved',
text: 'Approved by validators',
},
{
status:
ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_UNSPECIFIED,
icon: 'protocol-upgrade-proposal-status-icon-unspecified',
text: 'Unspecified',
},
];
statuses.forEach(({ status, icon, text }) => {
renderComponent({ ...proposal, status });
const statusIcon = screen.getByTestId(icon);
const textContent = screen.getByText(text);
expect(statusIcon).toBeInTheDocument();
expect(textContent).toBeInTheDocument();
});
});
it('renders the correct Vega release tag', () => { it('renders the correct Vega release tag', () => {
renderComponent(proposal); renderComponent(proposal);
const releaseTag = screen.getByTestId( const releaseTag = screen.getByTestId(

View File

@ -1,17 +1,10 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import { Button, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
Button,
Icon,
Lozenge,
RoundedWrapper,
} from '@vegaprotocol/ui-toolkit';
import { stripFullStops } from '@vegaprotocol/utils'; import { stripFullStops } from '@vegaprotocol/utils';
import { ProtocolUpgradeProposalStatus } from '@vegaprotocol/types';
import { SubHeading } from '../../../../components/heading'; import { SubHeading } from '../../../../components/heading';
import { ProposalInfoLabel } from '../proposal-info-label'; import { ProposalInfoLabel } from '../proposal-info-label';
import Routes from '../../../routes'; import Routes from '../../../routes';
import type { ReactNode } from 'react';
import type { ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals'; import type { ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
interface ProtocolProposalsListItemProps { interface ProtocolProposalsListItemProps {
@ -24,45 +17,21 @@ export const ProtocolUpgradeProposalsListItem = ({
const { t } = useTranslation(); const { t } = useTranslation();
if (!proposal || !proposal.upgradeBlockHeight) return null; if (!proposal || !proposal.upgradeBlockHeight) return null;
let proposalStatusIcon: ReactNode;
switch (proposal.status) {
case ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_REJECTED:
proposalStatusIcon = (
<span data-testid="protocol-upgrade-proposal-status-icon-rejected">
<Icon name={'cross'} />
</span>
);
break;
case ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_PENDING:
proposalStatusIcon = (
<span data-testid="protocol-upgrade-proposal-status-icon-pending">
<Icon name={'time'} />
</span>
);
break;
case ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_APPROVED:
proposalStatusIcon = (
<span data-testid="protocol-upgrade-proposal-status-icon-approved">
<Icon name={'tick'} />
</span>
);
break;
case ProtocolUpgradeProposalStatus.PROTOCOL_UPGRADE_PROPOSAL_STATUS_UNSPECIFIED:
proposalStatusIcon = (
<span data-testid="protocol-upgrade-proposal-status-icon-unspecified">
<Icon name={'disable'} />
</span>
);
break;
}
return ( return (
<li <li
id={proposal.upgradeBlockHeight} id={proposal.upgradeBlockHeight}
data-testid="protocol-upgrade-proposals-list-item" data-testid="protocol-upgrade-proposals-list-item"
> >
<RoundedWrapper paddingBottom={true} heightFull={true}> <RoundedWrapper paddingBottom={true} heightFull={true}>
<div
data-testid="protocol-upgrade-proposal-type"
className="flex items-center gap-2 mb-4 text-sm"
>
<ProposalInfoLabel variant="highlight">
{t('networkUpgrade')}
</ProposalInfoLabel>
</div>
<div <div
data-testid="protocol-upgrade-proposal-title" data-testid="protocol-upgrade-proposal-title"
className="text-sm mb-2" className="text-sm mb-2"
@ -70,30 +39,13 @@ export const ProtocolUpgradeProposalsListItem = ({
<SubHeading title={`Vega release ${proposal.vegaReleaseTag}`} /> <SubHeading title={`Vega release ${proposal.vegaReleaseTag}`} />
</div> </div>
<div className="text-sm"> <div className="flex items-center gap-4 text-vega-light-200">
<div className="flex gap-2">
<div
data-testid="protocol-upgrade-proposal-type"
className="flex items-center gap-2 mb-4"
>
<ProposalInfoLabel variant="highlight">
{t('networkUpgrade')}
</ProposalInfoLabel>
</div>
<div data-testid="protocol-upgrade-proposal-status">
<ProposalInfoLabel>
{t(`${proposal.status}`)} {proposalStatusIcon}
</ProposalInfoLabel>
</div>
</div>
<div <div
data-testid="protocol-upgrade-proposal-release-tag" data-testid="protocol-upgrade-proposal-release-tag"
className="mb-2" className="mb-2"
> >
<span>{t('vegaReleaseTag')}:</span>{' '} <span>{t('vegaReleaseTag')}:</span>{' '}
<Lozenge>{proposal.vegaReleaseTag}</Lozenge> <span>{proposal.vegaReleaseTag}</span>
</div> </div>
<div <div
@ -101,21 +53,24 @@ export const ProtocolUpgradeProposalsListItem = ({
className="mb-2" className="mb-2"
> >
<span>{t('upgradeBlockHeight')}:</span>{' '} <span>{t('upgradeBlockHeight')}:</span>{' '}
<Lozenge>{proposal.upgradeBlockHeight}</Lozenge> <span>{proposal.upgradeBlockHeight}</span>
</div>
<div className="grid grid-cols-1 mt-3">
<div className="justify-self-end">
<Link
to={`${Routes.PROTOCOL_UPGRADES}/${stripFullStops(
proposal.vegaReleaseTag
)}`}
>
<Button data-testid="view-proposal-btn">{t('View')}</Button>
</Link>
</div>
</div> </div>
</div> </div>
<div
className="text-sm text-vega-light-300 mb-4"
data-testid="protocol-upgrade-proposal-status"
>
{t(`${proposal.status}`)}
</div>
<Link
to={`${Routes.PROTOCOL_UPGRADES}/${stripFullStops(
proposal.vegaReleaseTag
)}`}
>
<Button data-testid="view-proposal-btn">{t('viewDetails')}</Button>
</Link>
</RoundedWrapper> </RoundedWrapper>
</li> </li>
); );

View File

@ -24,8 +24,8 @@ interface VoteButtonsContainerProps {
voteDatetime: Date | null; voteDatetime: Date | null;
proposalId: string | null; proposalId: string | null;
proposalState: ProposalState; proposalState: ProposalState;
minVoterBalance: string | null; minVoterBalance: string | null | undefined;
spamProtectionMinTokens: string | null; spamProtectionMinTokens: string | null | undefined;
submit: (voteValue: VoteValue, proposalId: string | null) => Promise<void>; submit: (voteValue: VoteValue, proposalId: string | null) => Promise<void>;
dialog: (props: DialogProps) => JSX.Element; dialog: (props: DialogProps) => JSX.Element;
className?: string; className?: string;

View File

@ -17,8 +17,8 @@ import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
interface VoteDetailsProps { interface VoteDetailsProps {
proposal: ProposalFieldsFragment | ProposalQuery['proposal']; proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
minVoterBalance: string | null; minVoterBalance: string | null | undefined;
spamProtectionMinTokens: string | null; spamProtectionMinTokens: string | null | undefined;
proposalType: ProposalType | null; proposalType: ProposalType | null;
} }

View File

@ -10,9 +10,33 @@ import { ENV } from '../../../config';
import { useDataProvider } from '@vegaprotocol/data-provider'; import { useDataProvider } from '@vegaprotocol/data-provider';
import { marketInfoWithDataProvider } from '@vegaprotocol/markets'; import { marketInfoWithDataProvider } from '@vegaprotocol/markets';
import { useAssetQuery } from '@vegaprotocol/assets'; import { useAssetQuery } from '@vegaprotocol/assets';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
export const ProposalContainer = () => { export const ProposalContainer = () => {
const params = useParams<{ proposalId: string }>(); const params = useParams<{ proposalId: string }>();
const {
params: networkParams,
loading: networkParamsLoading,
error: networkParamsError,
} = useNetworkParams([
NetworkParams.governance_proposal_market_minVoterBalance,
NetworkParams.governance_proposal_updateMarket_minVoterBalance,
NetworkParams.governance_proposal_asset_minVoterBalance,
NetworkParams.governance_proposal_updateAsset_minVoterBalance,
NetworkParams.governance_proposal_updateNetParam_minVoterBalance,
NetworkParams.governance_proposal_freeform_minVoterBalance,
NetworkParams.spam_protection_voting_min_tokens,
NetworkParams.governance_proposal_market_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajority,
NetworkParams.governance_proposal_updateMarket_requiredMajorityLP,
NetworkParams.governance_proposal_asset_requiredMajority,
NetworkParams.governance_proposal_updateAsset_requiredMajority,
NetworkParams.governance_proposal_updateNetParam_requiredMajority,
NetworkParams.governance_proposal_freeform_requiredMajority,
]);
const { const {
state: { data: restData }, state: { data: restData },
} = useFetch(`${ENV.rest}governance?proposalId=${params.proposalId}`); } = useFetch(`${ENV.rest}governance?proposalId=${params.proposalId}`);
@ -62,10 +86,13 @@ export const ProposalContainer = () => {
return ( return (
<AsyncRenderer <AsyncRenderer
loading={loading || newMarketLoading || assetLoading} loading={
error={error || newMarketError || assetError} loading || newMarketLoading || assetLoading || networkParamsLoading
}
error={error || newMarketError || assetError || networkParamsError}
data={{ data={{
...data, ...data,
...networkParams,
...(newMarketData ? { newMarketData } : {}), ...(newMarketData ? { newMarketData } : {}),
...(assetData ? { assetData } : {}), ...(assetData ? { assetData } : {}),
}} }}
@ -73,6 +100,7 @@ export const ProposalContainer = () => {
{data?.proposal ? ( {data?.proposal ? (
<Proposal <Proposal
proposal={data.proposal} proposal={data.proposal}
networkParams={networkParams}
restData={restData} restData={restData}
newMarketData={newMarketData} newMarketData={newMarketData}
assetData={assetData} assetData={assetData}

View File

@ -60,6 +60,11 @@ const mockConsensusValidators: NodesFragmentFragment[] = [
}, },
]; ];
jest.mock('@vegaprotocol/environment', () => ({
useVegaRelease: jest.fn(),
useVegaReleases: jest.fn(),
}));
const renderComponent = () => const renderComponent = () =>
render( render(
<ProtocolUpgradeProposal proposal={mockProposal} consensusValidators={[]} /> <ProtocolUpgradeProposal proposal={mockProposal} consensusValidators={[]} />

View File

@ -2,6 +2,10 @@ import { NetworkParamsDocument } from '@vegaprotocol/network-parameters';
import type { MockedResponse } from '@apollo/client/testing'; import type { MockedResponse } from '@apollo/client/testing';
import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters'; import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters';
import type { PubKey } from '@vegaprotocol/wallet'; import type { PubKey } from '@vegaprotocol/wallet';
import type { VoteValue } from '@vegaprotocol/types';
import type { UserVoteQuery } from '../components/vote-details/__generated__/Vote';
import { UserVoteDocument } from '../components/vote-details/__generated__/Vote';
import faker from 'faker';
export const mockPubkey: PubKey = { export const mockPubkey: PubKey = {
publicKey: '0x123', publicKey: '0x123',
@ -49,6 +53,16 @@ export const networkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
}, },
}; };
export const mockNetworkParams = {
governance_proposal_asset_requiredMajority: '0.66',
governance_proposal_freeform_requiredMajority: '0.66',
governance_proposal_market_requiredMajority: '0.66',
governance_proposal_updateAsset_requiredMajority: '0.66',
governance_proposal_updateMarket_requiredMajority: '0.66',
governance_proposal_updateMarket_requiredMajorityLP: '0.66',
governance_proposal_updateNetParam_requiredMajority: '0.5',
};
const oneMinute = 1000 * 60; const oneMinute = 1000 * 60;
const oneHour = oneMinute * 60; const oneHour = oneMinute * 60;
const oneDay = oneHour * 24; const oneDay = oneHour * 24;
@ -62,3 +76,34 @@ export const lastWeek = new Date(-oneWeek);
export const nextWeek = new Date(oneWeek); export const nextWeek = new Date(oneWeek);
export const lastMonth = new Date(-oneMonth); export const lastMonth = new Date(-oneMonth);
export const nextMonth = new Date(oneMonth); export const nextMonth = new Date(oneMonth);
export const createUserVoteQueryMock = (
proposalId: string,
value: VoteValue
): MockedResponse<UserVoteQuery> => ({
request: {
query: UserVoteDocument,
variables: {
partyId: mockPubkey.publicKey,
},
},
result: {
data: {
party: {
votesConnection: {
edges: [
{
node: {
proposalId,
vote: {
value,
datetime: faker.date.past().toISOString(),
},
},
},
],
},
},
},
},
});

View File

@ -16,6 +16,12 @@ export const NetworkParams = {
governance_proposal_market_maxClose: 'governance_proposal_market_maxClose', governance_proposal_market_maxClose: 'governance_proposal_market_maxClose',
governance_proposal_market_minEnact: 'governance_proposal_market_minEnact', governance_proposal_market_minEnact: 'governance_proposal_market_minEnact',
governance_proposal_market_maxEnact: 'governance_proposal_market_maxEnact', governance_proposal_market_maxEnact: 'governance_proposal_market_maxEnact',
governance_proposal_market_requiredMajority:
'governance_proposal_market_requiredMajority',
governance_proposal_market_requiredParticipation:
'governance_proposal_market_requiredParticipation',
governance_proposal_market_minProposerBalance:
'governance_proposal_market_minProposerBalance',
governance_proposal_updateMarket_minVoterBalance: governance_proposal_updateMarket_minVoterBalance:
'governance_proposal_updateMarket_minVoterBalance', 'governance_proposal_updateMarket_minVoterBalance',
governance_proposal_updateMarket_requiredMajority: governance_proposal_updateMarket_requiredMajority:
@ -30,12 +36,24 @@ export const NetworkParams = {
'governance_proposal_updateMarket_minEnact', 'governance_proposal_updateMarket_minEnact',
governance_proposal_updateMarket_maxEnact: governance_proposal_updateMarket_maxEnact:
'governance_proposal_updateMarket_maxEnact', 'governance_proposal_updateMarket_maxEnact',
governance_proposal_updateMarket_requiredParticipation:
'governance_proposal_updateMarket_requiredParticipation',
governance_proposal_updateMarket_requiredParticipationLP:
'governance_proposal_updateMarket_requiredParticipationLP',
governance_proposal_updateMarket_minProposerBalance:
'governance_proposal_updateMarket_minProposerBalance',
governance_proposal_asset_minVoterBalance: governance_proposal_asset_minVoterBalance:
'governance_proposal_asset_minVoterBalance', 'governance_proposal_asset_minVoterBalance',
governance_proposal_asset_minClose: 'governance_proposal_asset_minClose', governance_proposal_asset_minClose: 'governance_proposal_asset_minClose',
governance_proposal_asset_maxClose: 'governance_proposal_asset_maxClose', governance_proposal_asset_maxClose: 'governance_proposal_asset_maxClose',
governance_proposal_asset_minEnact: 'governance_proposal_asset_minEnact', governance_proposal_asset_minEnact: 'governance_proposal_asset_minEnact',
governance_proposal_asset_maxEnact: 'governance_proposal_asset_maxEnact', governance_proposal_asset_maxEnact: 'governance_proposal_asset_maxEnact',
governance_proposal_asset_requiredMajority:
'governance_proposal_asset_requiredMajority',
governance_proposal_asset_requiredParticipation:
'governance_proposal_asset_requiredParticipation',
governance_proposal_asset_minProposerBalance:
'governance_proposal_asset_minProposerBalance',
governance_proposal_updateAsset_minVoterBalance: governance_proposal_updateAsset_minVoterBalance:
'governance_proposal_updateAsset_minVoterBalance', 'governance_proposal_updateAsset_minVoterBalance',
governance_proposal_updateAsset_minClose: governance_proposal_updateAsset_minClose:
@ -46,6 +64,12 @@ export const NetworkParams = {
'governance_proposal_updateAsset_minEnact', 'governance_proposal_updateAsset_minEnact',
governance_proposal_updateAsset_maxEnact: governance_proposal_updateAsset_maxEnact:
'governance_proposal_updateAsset_maxEnact', 'governance_proposal_updateAsset_maxEnact',
governance_proposal_updateAsset_requiredMajority:
'governance_proposal_updateAsset_requiredMajority',
governance_proposal_updateAsset_requiredParticipation:
'governance_proposal_updateAsset_requiredParticipation',
governance_proposal_updateAsset_minProposerBalance:
'governance_proposal_updateAsset_minProposerBalance',
governance_proposal_updateNetParam_minClose: governance_proposal_updateNetParam_minClose:
'governance_proposal_updateNetParam_minClose', 'governance_proposal_updateNetParam_minClose',
governance_proposal_updateNetParam_minVoterBalance: governance_proposal_updateNetParam_minVoterBalance:
@ -56,42 +80,18 @@ export const NetworkParams = {
'governance_proposal_updateNetParam_minEnact', 'governance_proposal_updateNetParam_minEnact',
governance_proposal_updateNetParam_maxEnact: governance_proposal_updateNetParam_maxEnact:
'governance_proposal_updateNetParam_maxEnact', 'governance_proposal_updateNetParam_maxEnact',
governance_proposal_freeform_minVoterBalance:
'governance_proposal_freeform_minVoterBalance',
governance_proposal_freeform_minClose:
'governance_proposal_freeform_minClose',
governance_proposal_freeform_maxClose:
'governance_proposal_freeform_maxClose',
governance_proposal_updateMarket_requiredParticipation:
'governance_proposal_updateMarket_requiredParticipation',
governance_proposal_updateMarket_requiredParticipationLP:
'governance_proposal_updateMarket_requiredParticipationLP',
governance_proposal_updateMarket_minProposerBalance:
'governance_proposal_updateMarket_minProposerBalance',
governance_proposal_market_requiredMajority:
'governance_proposal_market_requiredMajority',
governance_proposal_market_requiredParticipation:
'governance_proposal_market_requiredParticipation',
governance_proposal_market_minProposerBalance:
'governance_proposal_market_minProposerBalance',
governance_proposal_asset_requiredMajority:
'governance_proposal_asset_requiredMajority',
governance_proposal_asset_requiredParticipation:
'governance_proposal_asset_requiredParticipation',
governance_proposal_updateAsset_requiredMajority:
'governance_proposal_updateAsset_requiredMajority',
governance_proposal_updateAsset_requiredParticipation:
'governance_proposal_updateAsset_requiredParticipation',
governance_proposal_asset_minProposerBalance:
'governance_proposal_asset_minProposerBalance',
governance_proposal_updateAsset_minProposerBalance:
'governance_proposal_updateAsset_minProposerBalance',
governance_proposal_updateNetParam_requiredMajority: governance_proposal_updateNetParam_requiredMajority:
'governance_proposal_updateNetParam_requiredMajority', 'governance_proposal_updateNetParam_requiredMajority',
governance_proposal_updateNetParam_requiredParticipation: governance_proposal_updateNetParam_requiredParticipation:
'governance_proposal_updateNetParam_requiredParticipation', 'governance_proposal_updateNetParam_requiredParticipation',
governance_proposal_updateNetParam_minProposerBalance: governance_proposal_updateNetParam_minProposerBalance:
'governance_proposal_updateNetParam_minProposerBalance', 'governance_proposal_updateNetParam_minProposerBalance',
governance_proposal_freeform_minVoterBalance:
'governance_proposal_freeform_minVoterBalance',
governance_proposal_freeform_minClose:
'governance_proposal_freeform_minClose',
governance_proposal_freeform_maxClose:
'governance_proposal_freeform_maxClose',
governance_proposal_freeform_requiredParticipation: governance_proposal_freeform_requiredParticipation:
'governance_proposal_freeform_requiredParticipation', 'governance_proposal_freeform_requiredParticipation',
governance_proposal_freeform_requiredMajority: governance_proposal_freeform_requiredMajority:
@ -111,7 +111,7 @@ export const NetworkParams = {
type Params = typeof NetworkParams; type Params = typeof NetworkParams;
export type NetworkParamsKey = keyof Params; export type NetworkParamsKey = keyof Params;
type Result = { export type NetworkParamsResult = {
[key in keyof Params]: string; [key in keyof Params]: string;
}; };
@ -137,7 +137,7 @@ export const useNetworkParams = <T extends NetworkParamsKey[]>(params?: T) => {
}, [data, params]); }, [data, params]);
return { return {
params: paramsObj as Pick<Result, T[number]>, params: paramsObj as Pick<NetworkParamsResult, T[number]>,
loading, loading,
error, error,
}; };

View File

@ -0,0 +1,12 @@
export const IconVote = ({ size = 16 }: { size: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 16 16">
<path d="M1.667 9.667V12.333H14.333V9.667H13.333V10.333H13.667V11.667H2.333V10.333H2.667V9.667H1.667Z" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 3.667H12V10.333H4V3.667ZM7.25 8.083L9.667 5.667L10.1666 6.166L7.25 9.083L5.667 7.5L6.166 7L7.25 8.083Z"
/>
</svg>
);
};

View File

@ -28,6 +28,7 @@ import { IconTransfer } from './svg-icons/icon-transfer';
import { IconTrendUp } from './svg-icons/icon-trend-up'; import { IconTrendUp } from './svg-icons/icon-trend-up';
import { IconTrendDown } from './svg-icons/icon-trend-down'; import { IconTrendDown } from './svg-icons/icon-trend-down';
import { IconTwitter } from './svg-icons/icon-twitter'; import { IconTwitter } from './svg-icons/icon-twitter';
import { IconVote } from './svg-icons/icon-vote';
import { IconWithdraw } from './svg-icons/icon-withdraw'; import { IconWithdraw } from './svg-icons/icon-withdraw';
import { IconSearch } from './svg-icons/icon-search'; import { IconSearch } from './svg-icons/icon-search';
@ -63,6 +64,7 @@ export enum VegaIconNames {
TREND_UP = 'trend-up', TREND_UP = 'trend-up',
TREND_DOWN = 'trend-down', TREND_DOWN = 'trend-down',
TWITTER = 'twitter', TWITTER = 'twitter',
VOTE = 'vote',
WITHDRAW = 'withdraw', WITHDRAW = 'withdraw',
} }
@ -96,10 +98,11 @@ export const VegaIconNameMap: Record<
search: IconSearch, search: IconSearch,
star: IconStar, star: IconStar,
tick: IconTick, tick: IconTick,
ticket: IconTicket,
transfer: IconTransfer, transfer: IconTransfer,
'trend-up': IconTrendUp, 'trend-up': IconTrendUp,
'trend-down': IconTrendDown, 'trend-down': IconTrendDown,
twitter: IconTwitter, twitter: IconTwitter,
ticket: IconTicket, vote: IconVote,
withdraw: IconWithdraw, withdraw: IconWithdraw,
}; };