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.get('[href="/proposals"]')
.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(
'have.text',
'Approved by validators '
'Approved by validators'
);
});
});

View File

@ -166,6 +166,7 @@
"proposedEnactment": "Proposed enactment",
"Enacted": "Enacted",
"enactedOn": "Enacted on",
"enactedOn{{date}}": "Enacted on {{enactmentDate}}",
"status": "Status",
"state": "State",
"shouldPass": "Should pass",
@ -185,6 +186,7 @@
"proposedOn": "Proposed on",
"proposedBy": "Proposed by",
"toEnactOn": "Enacts on",
"enactsOn{{date}}": "Enacts on {{enactmentDate}}",
"closesOn": "Closes on",
"closedOn": "Closed on",
"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.",
"noGovernanceTokens": "You need some VEGA tokens to participate in governance",
"youVoted": "You voted",
"voted": "Voted",
"changeVote": "Change vote",
"txRequested": "Confirm transaction in wallet",
"votePending": "Casting vote",
@ -628,7 +631,6 @@
"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.",
"n/a": "N/A",
"Set to": "Set to",
"pass": "pass",
"fail": "fail",
"New asset": "New asset",
@ -788,7 +790,7 @@
"overstakedPenalty": "Overstaked 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.",
"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.",
"homeValidatorsButtonText": "Browse, and stake",
"homeRewardsIntro": "Track rewards you've earned for trading, liquidity provision, market creation, and staking.",
@ -838,6 +840,18 @@
"networkGovernance": "Network governance",
"networkUpgrades": "Network upgrades",
"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",
"Upgraded at": "Upgraded at"
}

View File

@ -20,7 +20,7 @@ import {
getNotRejectedProposals,
getNotRejectedProtocolUpgradeProposals,
} from '../proposals/proposals/proposals-container';
import { Heading } from '../../components/heading';
import { Heading, SubHeading } from '../../components/heading';
import * as Schema from '@vegaprotocol/types';
import type { RouteChildProps } from '..';
import type { ProposalFieldsFragment } from '../proposals/proposals/__generated__/Proposals';
@ -31,6 +31,10 @@ import {
orderByDate,
orderByUpgradeBlockHeight,
} from '../proposals/components/proposals-list/proposals-list';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
import { BigNumber } from '../../lib/bignumber';
const nodesToShow = 6;
@ -43,33 +47,57 @@ const HomeProposals = ({
protocolUpgradeProposals: ProtocolUpgradeProposalFieldsFragment[];
}) => {
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 (
<section className="mb-16" data-testid="home-proposals">
<Heading title={t('Proposals')} />
<h3 className="mb-6">{t('homeProposalsIntro')}</h3>
<div className="flex items-center mb-8 gap-8">
<Link to={`${Routes.PROPOSALS}`}>
<Button size="md">{t('homeProposalsButtonText')}</Button>
</Link>
<AsyncRenderer
loading={networkParamsLoading}
error={networkParamsError}
data={networkParams}
>
<section className="mb-16" data-testid="home-proposals">
<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}>
{t(`readMoreGovernance`)}
</ExternalLink>
</div>
<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} />
))}
<SubHeading title={t('latestProposals')} />
<ul data-testid="home-proposal-list" className="grid gap-6">
{protocolUpgradeProposals.map((proposal, index) => (
<ProtocolUpgradeProposalsListItem key={index} proposal={proposal} />
))}
{proposals.map((proposal) => (
<ProposalsListItem key={proposal.id} proposal={proposal} />
))}
</ul>
</section>
{proposals.map((proposal) => (
<ProposalsListItem
key={proposal.id}
proposal={proposal}
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 highlightedNodeData = [
{ title: t('active nodes'), length: activeNodes.length },
{ title: t('consensus nodes'), length: consensusNodes.length },
{ title: t('activeNodes'), length: activeNodes.length },
{ title: t('consensusNodes'), length: consensusNodes.length },
];
return (
@ -234,7 +262,7 @@ const GovernanceHome = ({ name }: RouteChildProps) => {
[protocolUpgradeProposals]
);
const totalProposalsDesired = 4;
const totalProposalsDesired = 3;
const protocolUpgradeProposalsToShow = sortedProtocolUpgradeProposals.slice(
0,
totalProposalsDesired
@ -290,7 +318,7 @@ const GovernanceHome = ({ name }: RouteChildProps) => {
</div>
<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>
<div className="flex items-center mb-8 gap-4">
<Link to={Routes.WITHDRAWALS}>

View File

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

View File

@ -1,18 +1,46 @@
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 {
generateNoVotes,
generateProposal,
generateYesVotes,
} from '../../test-helpers/generate-proposals';
import { ProposalHeader } from './proposal-header';
import {
lastWeek,
nextWeek,
mockNetworkParams,
mockWalletContext,
createUserVoteQueryMock,
} from '../../test-helpers/mocks';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { ProposalRejectionReason, ProposalState } from '@vegaprotocol/types';
import { lastWeek, nextWeek } from '../../test-helpers/mocks';
import type { MockedResponse } from '@apollo/client/testing';
const renderComponent = (
proposal: ProposalQuery['proposal'],
isListItem = true
) => render(<ProposalHeader proposal={proposal} isListItem={isListItem} />);
isListItem = true,
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', () => {
it('Renders New market proposal', () => {
@ -317,22 +345,6 @@ describe('Proposal header', () => {
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', () => {
renderComponent(
generateProposal({
@ -346,4 +358,32 @@ describe('Proposal header', () => {
);
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 { Lozenge } from '@vegaprotocol/ui-toolkit';
import { Lozenge, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import { shorten } from '@vegaprotocol/utils';
import { Heading, SubHeading } from '../../../../components/heading';
import type { ReactNode } from 'react';
@ -8,15 +8,21 @@ import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { truncateMiddle } from '../../../../lib/truncate-middle';
import { CurrentProposalState } from '../current-proposal-state';
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 = ({
proposal,
networkParams,
isListItem = true,
}: {
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
networkParams: Partial<NetworkParamsResult>;
isListItem?: boolean;
}) => {
const { t } = useTranslation();
const { voteState } = useUserVote(proposal?.id);
const change = proposal?.terms.change;
let details: ReactNode;
@ -119,6 +125,35 @@ export const ProposalHeader = ({
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">
{isListItem ? (
<header>
@ -133,23 +168,16 @@ export const ProposalHeader = ({
)}
</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 && (
<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}
</div>
)}
<ProposalVotingStatus proposal={proposal} networkParams={networkParams} />
</>
);
};

View File

@ -7,10 +7,10 @@ export type ProposalInfoLabelVariant =
| 'tertiary'
| 'highlight';
const base = 'rounded-md px-2 py-1 font-alpha';
const primary = 'bg-vega-light-150 text-black';
const secondary = 'bg-vega-dark-200 text-white';
const tertiary = 'bg-vega-dark-150 text-white';
const base = 'rounded-full px-3 py-1 font-alpha';
const primary = 'bg-vega-green text-black';
const secondary = 'bg-vega-dark-200 text-vega-light-200';
const tertiary = 'bg-vega-dark-150 text-vega-light-200';
const highlight = 'bg-vega-yellow text-black';
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 type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import { ProposalState } from '@vegaprotocol/types';
import { mockNetworkParams } from '../../test-helpers/mocks';
jest.mock('@vegaprotocol/network-parameters', () => ({
...jest.requireActual('@vegaprotocol/network-parameters'),
@ -46,6 +47,7 @@ const renderComponent = (proposal: ProposalQuery['proposal']) => {
<Proposal
restData={{}}
proposal={proposal as ProposalQuery['proposal']}
networkParams={mockNetworkParams}
/>
</MemoryRouter>
);

View File

@ -1,10 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
import { AsyncRenderer, Icon, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
import { Icon, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import { ProposalDescription } from '../proposal-description';
import { ProposalChangeTable } from '../proposal-change-table';
@ -21,6 +17,7 @@ import type { MarketInfoWithData } from '@vegaprotocol/markets';
import type { AssetQuery } from '@vegaprotocol/assets';
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { ProposalState } from '@vegaprotocol/types';
import type { NetworkParamsResult } from '@vegaprotocol/network-parameters';
export enum ProposalType {
PROPOSAL_NEW_MARKET = 'PROPOSAL_NEW_MARKET',
@ -32,6 +29,7 @@ export enum ProposalType {
}
export interface ProposalProps {
proposal: ProposalFieldsFragment | ProposalQuery['proposal'];
networkParams: Partial<NetworkParamsResult>;
newMarketData?: MarketInfoWithData | null;
assetData?: AssetQuery | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -40,20 +38,12 @@ export interface ProposalProps {
export const Proposal = ({
proposal,
networkParams,
restData,
newMarketData,
assetData,
}: ProposalProps) => {
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) {
return null;
@ -79,122 +69,122 @@ export const Proposal = ({
let minVoterBalance = null;
let proposalType = null;
if (params) {
if (networkParams) {
switch (proposal.terms.change.__typename) {
case 'NewMarket':
minVoterBalance = params.governance_proposal_market_minVoterBalance;
minVoterBalance =
networkParams.governance_proposal_market_minVoterBalance;
proposalType = ProposalType.PROPOSAL_NEW_MARKET;
break;
case 'UpdateMarket':
minVoterBalance =
params.governance_proposal_updateMarket_minVoterBalance;
networkParams.governance_proposal_updateMarket_minVoterBalance;
proposalType = ProposalType.PROPOSAL_UPDATE_MARKET;
break;
case 'NewAsset':
minVoterBalance = params.governance_proposal_asset_minVoterBalance;
minVoterBalance =
networkParams.governance_proposal_asset_minVoterBalance;
proposalType = ProposalType.PROPOSAL_NEW_ASSET;
break;
case 'UpdateAsset':
minVoterBalance =
params.governance_proposal_updateAsset_minVoterBalance;
networkParams.governance_proposal_updateAsset_minVoterBalance;
proposalType = ProposalType.PROPOSAL_UPDATE_ASSET;
break;
case 'UpdateNetworkParameter':
minVoterBalance =
params.governance_proposal_updateNetParam_minVoterBalance;
networkParams.governance_proposal_updateNetParam_minVoterBalance;
proposalType = ProposalType.PROPOSAL_NETWORK_PARAMETER;
break;
case 'NewFreeform':
minVoterBalance = params.governance_proposal_freeform_minVoterBalance;
minVoterBalance =
networkParams.governance_proposal_freeform_minVoterBalance;
proposalType = ProposalType.PROPOSAL_FREEFORM;
break;
}
}
return (
<AsyncRenderer data={params} loading={loading} error={error}>
<section data-testid="proposal">
<div className="flex items-center gap-1">
<Icon name={'chevron-left'} />
<section data-testid="proposal">
<div className="flex items-center gap-1 mb-6">
<Icon name={'chevron-left'} />
{proposal.state === ProposalState.STATE_REJECTED ? (
<div data-testid="rejected-proposals-link">
<Link className="underline" to={Routes.PROPOSALS_REJECTED}>
{t('RejectedProposals')}
</Link>
</div>
) : (
<div data-testid="all-proposals-link">
<Link className="underline" to={Routes.PROPOSALS}>
{t('AllProposals')}
</Link>
</div>
)}
{proposal.state === ProposalState.STATE_REJECTED ? (
<div data-testid="rejected-proposals-link">
<Link className="underline" to={Routes.PROPOSALS_REJECTED}>
{t('RejectedProposals')}
</Link>
</div>
) : (
<div data-testid="all-proposals-link">
<Link className="underline" to={Routes.PROPOSALS}>
{t('AllProposals')}
</Link>
</div>
)}
</div>
<ProposalHeader
proposal={proposal}
isListItem={false}
networkParams={networkParams}
/>
<div id="details">
<div className="my-10">
<ProposalChangeTable proposal={proposal} />
</div>
<ProposalHeader proposal={proposal} isListItem={false} />
<div id="details">
<div className="my-10">
<ProposalChangeTable proposal={proposal} />
</div>
{proposal.terms.change.__typename === 'NewAsset' &&
proposal.terms.change.source.__typename === 'ERC20' &&
proposal.id ? (
<ListAsset
assetId={proposal.id}
withdrawalThreshold={proposal.terms.change.source.withdrawThreshold}
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
/>
) : null}
{proposal.terms.change.__typename === 'NewAsset' &&
proposal.terms.change.source.__typename === 'ERC20' &&
proposal.id ? (
<ListAsset
assetId={proposal.id}
withdrawalThreshold={
proposal.terms.change.source.withdrawThreshold
}
lifetimeLimit={proposal.terms.change.source.lifetimeLimit}
/>
) : null}
<div className="mb-4">
<ProposalDescription description={proposal.rationale.description} />
</div>
{newMarketData && (
<div className="mb-4">
<ProposalDescription description={proposal.rationale.description} />
<ProposalMarketData marketData={newMarketData} />
</div>
)}
{newMarketData && (
{(proposal.terms.change.__typename === 'NewAsset' ||
proposal.terms.change.__typename === 'UpdateAsset') &&
asset && (
<div className="mb-4">
<ProposalMarketData marketData={newMarketData} />
<ProposalAssetDetails asset={asset} />
</div>
)}
{(proposal.terms.change.__typename === 'NewAsset' ||
proposal.terms.change.__typename === 'UpdateAsset') &&
asset && (
<div className="mb-4">
<ProposalAssetDetails asset={asset} />
</div>
)}
<div className="mb-6">
<ProposalJson proposal={restData?.data?.proposal} />
</div>
<div className="mb-6">
<ProposalJson proposal={restData?.data?.proposal} />
</div>
</div>
<div id="voting">
<div className="mb-10">
<RoundedWrapper paddingBottom={true}>
<VoteDetails
proposal={proposal}
proposalType={proposalType}
minVoterBalance={minVoterBalance}
spamProtectionMinTokens={
params?.spam_protection_voting_min_tokens
}
/>
</RoundedWrapper>
</div>
<div className="mb-4">
<ProposalVotesTable
<div id="voting">
<div className="mb-10">
<RoundedWrapper paddingBottom={true}>
<VoteDetails
proposal={proposal}
proposalType={proposalType}
minVoterBalance={minVoterBalance}
spamProtectionMinTokens={
networkParams?.spam_protection_voting_min_tokens
}
/>
</div>
</RoundedWrapper>
</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 { render, screen } from '@testing-library/react';
import { format } from 'date-fns';
import {
ProposalRejectionReason,
ProposalState,
VoteValue,
} from '@vegaprotocol/types';
import { ProposalRejectionReason, ProposalState } from '@vegaprotocol/types';
import {
generateNoVotes,
generateProposal,
@ -18,7 +14,6 @@ import {
import { ProposalsListItemDetails } from './proposals-list-item-details';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import {
mockPubkey,
mockWalletContext,
networkParamsQueryMock,
fiveMinutes,
@ -28,39 +23,6 @@ import {
nextWeek,
} from '../../test-helpers/mocks';
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 = (
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(
generateProposal({
state: ProposalState.STATE_OPEN,
@ -153,11 +115,11 @@ describe('Proposals list item details', () => {
})
);
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(
generateProposal({
state: ProposalState.STATE_OPEN,
@ -179,11 +141,11 @@ describe('Proposals list item details', () => {
})
);
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(
generateProposal({
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', () => {
@ -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', () => {
renderComponent(
generateProposal({
@ -309,7 +227,7 @@ describe('Proposals list item details', () => {
})
);
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(
'Participation not reached'
);
});
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'
'Currently expected to pass'
);
});

View File

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

View File

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

View File

@ -88,31 +88,39 @@ afterAll(() => {
jest.useRealTimers();
});
jest.mock('../vote-details/use-user-vote', () => ({
useUserVote: jest.fn().mockImplementation(() => ({ voteState: 'NotCast' })),
}));
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([]));
await screen.findByTestId('proposals-list');
expect(screen.getByText('Proposals')).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([]));
await screen.findByTestId('proposals-list');
expect(
screen.queryByTestId('proposals-list-filter')
).not.toBeInTheDocument();
});
it('Will show filter if there are proposals', () => {
it('Will show filter if there are proposals', async () => {
render(renderComponent([enactedProposalClosedLastWeek]));
await screen.findByTestId('proposals-list');
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([]));
await screen.findByTestId('proposals-list');
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(
renderComponent([
openProposalClosesNextWeek,
@ -121,6 +129,7 @@ describe('Proposals list', () => {
failedProposalClosedLastMonth,
])
);
await screen.findByTestId('proposals-list');
const openProposals = within(screen.getByTestId('open-proposals'));
const closedProposals = within(screen.getByTestId('closed-proposals'));
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([]));
await screen.findByTestId('proposals-list');
expect(screen.queryByTestId('open-proposals')).not.toBeInTheDocument();
expect(screen.getByTestId('no-open-proposals')).toBeInTheDocument();
expect(screen.queryByTestId('closed-proposals')).not.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]));
await screen.findByTestId('proposals-list');
expect(screen.queryByTestId('open-proposals')).not.toBeInTheDocument();
expect(screen.getByTestId('no-open-proposals')).toBeInTheDocument();
expect(screen.getByTestId('closed-proposals')).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]));
await screen.findByTestId('proposals-list');
expect(screen.getByTestId('open-proposals')).toBeInTheDocument();
expect(screen.queryByTestId('no-open-proposals')).not.toBeInTheDocument();
expect(screen.queryByTestId('closed-proposals')).not.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(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
expect(screen.getByTestId('proposals-list-filter')).toBeInTheDocument();
});
it('Filters list by text - party id', () => {
it('Filters list by text - party id', async () => {
render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'bvcx' },
@ -174,10 +188,11 @@ describe('Proposals list', () => {
expect(container.querySelector('#proposal1')).not.toBeInTheDocument();
});
it('Filters list by text - proposal id', () => {
it('Filters list by text - proposal id', async () => {
render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'proposal1' },
@ -187,10 +202,11 @@ describe('Proposals list', () => {
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(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'osal1' },
@ -200,10 +216,11 @@ describe('Proposals list', () => {
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(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'test' },
@ -211,10 +228,11 @@ describe('Proposals list', () => {
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(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
await screen.findByTestId('proposals-list');
fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), {
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(
renderComponent(
[enactedProposalClosedLastWeek],
[closedProtocolUpgradeProposal]
)
);
await screen.findByTestId('proposals-list');
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]));
await screen.findByTestId('proposals-list');
expect(
screen.queryByTestId('toggle-closed-proposals')
).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]));
await screen.findByTestId('proposals-list');
expect(
screen.queryByTestId('toggle-closed-proposals')
).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(
renderComponent(
[enactedProposalClosedLastWeek],
[closedProtocolUpgradeProposal]
)
);
await screen.findByTestId('proposal-filter-toggle');
fireEvent.click(screen.getByTestId('proposal-filter-toggle'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'test' },
@ -265,7 +287,7 @@ describe('Proposals list', () => {
).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(
renderComponent(
[enactedProposalClosedLastWeek],
@ -273,17 +295,19 @@ describe('Proposals list', () => {
)
);
expect(
screen.getByTestId('closed-governance-proposals')
await screen.findByTestId('closed-governance-proposals')
).toBeInTheDocument();
});
it('Displays closed upgrade proposals when the toggle is clicked', () => {
it('Displays closed upgrade proposals when the toggle is clicked', async () => {
render(
renderComponent(
[enactedProposalClosedLastWeek],
[closedProtocolUpgradeProposal]
)
);
await screen.findByTestId('toggle-closed-proposals');
fireEvent.click(screen.getByText('Network upgrades'));
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 Routes from '../../../routes';
import {
AsyncRenderer,
Button,
Toggle,
VegaIcon,
@ -19,6 +20,10 @@ import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
import { DocsLinks, ExternalLinks } from '@vegaprotocol/environment';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
interface ProposalsListProps {
proposals: Array<ProposalFieldsFragment | ProposalQuery['proposal']>;
@ -70,6 +75,20 @@ export const ProposalsList = ({
lastBlockHeight,
}: ProposalsListProps) => {
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 [closedProposalsView, setClosedProposalsView] =
useState<ClosedProposalsViewOptions>(
@ -134,166 +153,181 @@ export const ProposalsList = ({
p?.party?.id?.toString().includes(filterString);
return (
<>
<div className="grid xs:grid-cols-2 items-center">
<Heading
centerContent={false}
marginBottom={false}
title={t('pageTitleProposals')}
/>
<AsyncRenderer
loading={networkParamsLoading}
error={networkParamsError}
data={networkParams}
>
<div data-testid="proposals-list">
<div className="grid xs:grid-cols-2 items-center">
<Heading
centerContent={false}
marginBottom={false}
title={t('pageTitleProposals')}
/>
{DocsLinks && (
<div className="xs:justify-self-end" data-testid="new-proposal-link">
<ExternalLink href={DocsLinks.PROPOSALS_GUIDE}>
<Button variant="primary" size="sm">
<div className="flex items-center gap-1">
{t('NewProposal')}
<VegaIcon name={VegaIconNames.OPEN_EXTERNAL} size={13} />
</div>
</Button>
</ExternalLink>
</div>
{DocsLinks && (
<div
className="xs:justify-self-end"
data-testid="new-proposal-link"
>
<ExternalLink href={DocsLinks.PROPOSALS_GUIDE}>
<Button variant="primary" size="sm">
<div className="flex items-center gap-1">
{t('NewProposal')}
<VegaIcon name={VegaIconNames.OPEN_EXTERNAL} size={13} />
</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">
{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>
<section className="-mx-4 p-4 mb-8 bg-vega-dark-100">
<SubHeading title={t('openProposals')} />
{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
);
}
}}
/>
)}
{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}
/>
))}
<section className="-mx-4 p-4 mb-8 bg-vega-dark-100">
<SubHeading title={t('openProposals')} />
{sortedProposals.open.length > 0 ||
sortedProtocolUpgradeProposals.open.length > 0 ? (
<ul data-testid="open-proposals">
{filterString.length < 1 &&
sortedProtocolUpgradeProposals.open.map((proposal) => (
<ProtocolUpgradeProposalsListItem
key={proposal.upgradeBlockHeight}
{sortedProposals.open.filter(filterPredicate).map((proposal) => (
<ProposalsListItem
key={proposal?.id}
proposal={proposal}
networkParams={networkParams}
/>
))}
</ul>
) : (
<p className="mb-0" data-testid="no-open-proposals">
{t('noOpenProposals')}
</p>
)}
</section>
{sortedProposals.open.filter(filterPredicate).map((proposal) => (
<ProposalsListItem key={proposal?.id} proposal={proposal} />
))}
</ul>
) : (
<p className="mb-0" data-testid="no-open-proposals">
{t('noOpenProposals')}
</p>
)}
</section>
<section className="relative">
<SubHeading title={t('closedProposals')} />
{sortedProposals.closed.length > 0 ||
sortedProtocolUpgradeProposals.closed.length > 0 ? (
<>
{
// We need both the closed proposals and closed protocol upgrade
// 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
// do not have the necessary fields for filtering.
sortedProposals.closed.length > 0 &&
sortedProtocolUpgradeProposals.closed.length > 0 &&
filterString.length < 1 && (
<div
className="grid w-full justify-end xl:-mt-12 pb-6"
data-testid="toggle-closed-proposals"
>
<div className="w-[440px]">
<Toggle
name="closed-proposals-toggle"
toggles={[
{
label: t(
ClosedProposalsViewOptions.NetworkGovernance
),
value: ClosedProposalsViewOptions.NetworkGovernance,
},
{
label: t(
ClosedProposalsViewOptions.NetworkUpgrades
),
value: ClosedProposalsViewOptions.NetworkUpgrades,
},
]}
checkedValue={closedProposalsView}
onChange={(e) =>
setClosedProposalsView(
e.target.value as ClosedProposalsViewOptions
)
}
/>
<section className="relative">
<SubHeading title={t('closedProposals')} />
{sortedProposals.closed.length > 0 ||
sortedProtocolUpgradeProposals.closed.length > 0 ? (
<>
{
// We need both the closed proposals and closed protocol upgrade
// 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
// do not have the necessary fields for filtering.
sortedProposals.closed.length > 0 &&
sortedProtocolUpgradeProposals.closed.length > 0 &&
filterString.length < 1 && (
<div
className="grid w-full justify-end xl:-mt-12 pb-6"
data-testid="toggle-closed-proposals"
>
<div className="w-[440px]">
<Toggle
name="closed-proposals-toggle"
toggles={[
{
label: t(
ClosedProposalsViewOptions.NetworkGovernance
),
value:
ClosedProposalsViewOptions.NetworkGovernance,
},
{
label: t(
ClosedProposalsViewOptions.NetworkUpgrades
),
value: ClosedProposalsViewOptions.NetworkUpgrades,
},
]}
checkedValue={closedProposalsView}
onChange={(e) =>
setClosedProposalsView(
e.target.value as ClosedProposalsViewOptions
)
}
/>
</div>
</div>
</div>
)
}
)
}
<ul data-testid="closed-proposals">
{closedProposalsView ===
ClosedProposalsViewOptions.NetworkUpgrades && (
<div data-testid="closed-upgrade-proposals">
{sortedProtocolUpgradeProposals.closed.map((proposal) => (
<ProtocolUpgradeProposalsListItem
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}
<ul data-testid="closed-proposals">
{closedProposalsView ===
ClosedProposalsViewOptions.NetworkUpgrades && (
<div data-testid="closed-upgrade-proposals">
{sortedProtocolUpgradeProposals.closed.map((proposal) => (
<ProtocolUpgradeProposalsListItem
key={proposal.upgradeBlockHeight}
proposal={proposal}
/>
))}
</div>
)}
</ul>
</>
) : (
<p className="mb-0" data-testid="no-closed-proposals">
{t('noClosedProposals')}
</p>
)}
</section>
</div>
)}
<Link className="underline" to={Routes.PROPOSALS_REJECTED}>
{t('seeRejectedProposals')}
</Link>
</>
{closedProposalsView ===
ClosedProposalsViewOptions.NetworkGovernance && (
<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 { RejectedProposalsList } from './rejected-proposals-list';
import { ProposalState } from '@vegaprotocol/types';
import { render, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import {
mockWalletContext,
networkParamsQueryMock,
@ -55,24 +55,38 @@ afterAll(() => {
jest.useRealTimers();
});
jest.mock('../vote-details/use-user-vote', () => ({
useUserVote: jest.fn().mockImplementation(() => ({ voteState: 'NotCast' })),
}));
describe('Rejected proposals list', () => {
it('Renders a list of proposals', () => {
it('Renders a list of proposals', async () => {
render(
renderComponent([
rejectedProposalClosedLastMonth,
rejectedProposalClosesNextWeek,
])
);
const rejectedProposals = within(screen.getByTestId('rejected-proposals'));
const rejectedProposalsItems = rejectedProposals.getAllByTestId(
'proposals-list-item'
);
expect(rejectedProposalsItems).toHaveLength(2);
await waitFor(() => {
const rejectedProposals = within(
screen.getByTestId('rejected-proposals')
);
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([]));
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 { useTranslation } from 'react-i18next';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../../components/heading';
import { ProposalsListItem } from '../proposals-list-item';
import { ProposalsListFilter } from '../proposals-list-filter';
import type { ProposalFieldsFragment } from '../../proposals/__generated__/Proposals';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
interface ProposalsListProps {
proposals: Array<ProposalQuery['proposal'] | ProposalFieldsFragment>;
@ -12,6 +17,19 @@ interface ProposalsListProps {
export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
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 filterPredicate = (
@ -21,7 +39,11 @@ export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
p?.party?.id?.toString().includes(filterString);
return (
<>
<AsyncRenderer
loading={networkParamsLoading}
error={networkParamsError}
data={networkParams}
>
<Heading title={t('pageTitleRejectedProposals')} />
<ProposalsListFilter
filterString={filterString}
@ -31,7 +53,11 @@ export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
{proposals.length > 0 ? (
<ul data-testid="rejected-proposals">
{proposals.filter(filterPredicate).map((proposal) => (
<ProposalsListItem key={proposal?.id} proposal={proposal} />
<ProposalsListItem
key={proposal?.id}
proposal={proposal}
networkParams={networkParams}
/>
))}
</ul>
) : (
@ -40,6 +66,6 @@ export const RejectedProposalsList = ({ proposals }: ProposalsListProps) => {
</p>
)}
</section>
</>
</AsyncRenderer>
);
};

View File

@ -20,43 +20,6 @@ const renderComponent = (proposal: ProtocolUpgradeProposalFieldsFragment) =>
);
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', () => {
renderComponent(proposal);
const releaseTag = screen.getByTestId(

View File

@ -1,17 +1,10 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
Button,
Icon,
Lozenge,
RoundedWrapper,
} from '@vegaprotocol/ui-toolkit';
import { Button, RoundedWrapper } from '@vegaprotocol/ui-toolkit';
import { stripFullStops } from '@vegaprotocol/utils';
import { ProtocolUpgradeProposalStatus } from '@vegaprotocol/types';
import { SubHeading } from '../../../../components/heading';
import { ProposalInfoLabel } from '../proposal-info-label';
import Routes from '../../../routes';
import type { ReactNode } from 'react';
import type { ProtocolUpgradeProposalFieldsFragment } from '@vegaprotocol/proposals';
interface ProtocolProposalsListItemProps {
@ -24,45 +17,21 @@ export const ProtocolUpgradeProposalsListItem = ({
const { t } = useTranslation();
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 (
<li
id={proposal.upgradeBlockHeight}
data-testid="protocol-upgrade-proposals-list-item"
>
<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
data-testid="protocol-upgrade-proposal-title"
className="text-sm mb-2"
@ -70,30 +39,13 @@ export const ProtocolUpgradeProposalsListItem = ({
<SubHeading title={`Vega release ${proposal.vegaReleaseTag}`} />
</div>
<div className="text-sm">
<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 className="flex items-center gap-4 text-vega-light-200">
<div
data-testid="protocol-upgrade-proposal-release-tag"
className="mb-2"
>
<span>{t('vegaReleaseTag')}:</span>{' '}
<Lozenge>{proposal.vegaReleaseTag}</Lozenge>
<span>{proposal.vegaReleaseTag}</span>
</div>
<div
@ -101,21 +53,24 @@ export const ProtocolUpgradeProposalsListItem = ({
className="mb-2"
>
<span>{t('upgradeBlockHeight')}:</span>{' '}
<Lozenge>{proposal.upgradeBlockHeight}</Lozenge>
</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>
<span>{proposal.upgradeBlockHeight}</span>
</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>
</li>
);

View File

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

View File

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

View File

@ -10,9 +10,33 @@ import { ENV } from '../../../config';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { marketInfoWithDataProvider } from '@vegaprotocol/markets';
import { useAssetQuery } from '@vegaprotocol/assets';
import {
NetworkParams,
useNetworkParams,
} from '@vegaprotocol/network-parameters';
export const ProposalContainer = () => {
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 {
state: { data: restData },
} = useFetch(`${ENV.rest}governance?proposalId=${params.proposalId}`);
@ -62,10 +86,13 @@ export const ProposalContainer = () => {
return (
<AsyncRenderer
loading={loading || newMarketLoading || assetLoading}
error={error || newMarketError || assetError}
loading={
loading || newMarketLoading || assetLoading || networkParamsLoading
}
error={error || newMarketError || assetError || networkParamsError}
data={{
...data,
...networkParams,
...(newMarketData ? { newMarketData } : {}),
...(assetData ? { assetData } : {}),
}}
@ -73,6 +100,7 @@ export const ProposalContainer = () => {
{data?.proposal ? (
<Proposal
proposal={data.proposal}
networkParams={networkParams}
restData={restData}
newMarketData={newMarketData}
assetData={assetData}

View File

@ -60,6 +60,11 @@ const mockConsensusValidators: NodesFragmentFragment[] = [
},
];
jest.mock('@vegaprotocol/environment', () => ({
useVegaRelease: jest.fn(),
useVegaReleases: jest.fn(),
}));
const renderComponent = () =>
render(
<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 { NetworkParamsQuery } from '@vegaprotocol/network-parameters';
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 = {
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 oneHour = oneMinute * 60;
const oneDay = oneHour * 24;
@ -62,3 +76,34 @@ export const lastWeek = new Date(-oneWeek);
export const nextWeek = new Date(oneWeek);
export const lastMonth = 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_minEnact: 'governance_proposal_market_minEnact',
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_requiredMajority:
@ -30,12 +36,24 @@ export const NetworkParams = {
'governance_proposal_updateMarket_minEnact',
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_minClose: 'governance_proposal_asset_minClose',
governance_proposal_asset_maxClose: 'governance_proposal_asset_maxClose',
governance_proposal_asset_minEnact: 'governance_proposal_asset_minEnact',
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_minClose:
@ -46,6 +64,12 @@ export const NetworkParams = {
'governance_proposal_updateAsset_minEnact',
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_minVoterBalance:
@ -56,42 +80,18 @@ export const NetworkParams = {
'governance_proposal_updateNetParam_minEnact',
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_requiredParticipation:
'governance_proposal_updateNetParam_requiredParticipation',
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_requiredMajority:
@ -111,7 +111,7 @@ export const NetworkParams = {
type Params = typeof NetworkParams;
export type NetworkParamsKey = keyof Params;
type Result = {
export type NetworkParamsResult = {
[key in keyof Params]: string;
};
@ -137,7 +137,7 @@ export const useNetworkParams = <T extends NetworkParamsKey[]>(params?: T) => {
}, [data, params]);
return {
params: paramsObj as Pick<Result, T[number]>,
params: paramsObj as Pick<NetworkParamsResult, T[number]>,
loading,
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 { IconTrendDown } from './svg-icons/icon-trend-down';
import { IconTwitter } from './svg-icons/icon-twitter';
import { IconVote } from './svg-icons/icon-vote';
import { IconWithdraw } from './svg-icons/icon-withdraw';
import { IconSearch } from './svg-icons/icon-search';
@ -63,6 +64,7 @@ export enum VegaIconNames {
TREND_UP = 'trend-up',
TREND_DOWN = 'trend-down',
TWITTER = 'twitter',
VOTE = 'vote',
WITHDRAW = 'withdraw',
}
@ -96,10 +98,11 @@ export const VegaIconNameMap: Record<
search: IconSearch,
star: IconStar,
tick: IconTick,
ticket: IconTicket,
transfer: IconTransfer,
'trend-up': IconTrendUp,
'trend-down': IconTrendDown,
twitter: IconTwitter,
ticket: IconTicket,
vote: IconVote,
withdraw: IconWithdraw,
};