Feat/671 Proposal listings page (#733)

* frontend-monorepo-671: Removed old proposal list intro text

* frontend-monorepo-671: Proposals sorted into open and closed state

* frontend-monorepo-671: Proposals also sorted by date, and sorting functions memoized

* frontend-monorepo-671: Updated proposal header for new text and i18n support, updated test

* frontend-monorepo-671: Eth wallet connect button full width

* frontend-monorepo-671: Proposal tests for primary text fixed

* Frontend-monorepo-671: Updated proposal description and tests. Included translations

* Frontend-monorepo-671: Small structural refactor

* frontend-monorepo-715: Added required translations

* frontend-monorepo-715: Proposals list item details

* frontend-monorepo-715: Proposals list item styling

* frontend-monorepo-671: Tests for proposals-list-item-details.tsx

* frontend-monorepo-671: Tests and tweaks for proposals-list.tsx

* frontend-monorepo-671: Reusable test components pulled into test-helpers

* frontend-monorepo-671: Proposals list text filter and tests (partially working tests)

* frontend-monorepo-671: Refactoring generateProposal to clobber rather than merge old arrays

* frontend-monorepo-671: Readded commented out tests

* frontend-monorepo-671: Removed empty files

* frontend-monorepo-671: Made more use of generateProposal overrides

* frontend-monorepo-671: Run prettier

* frontend-monorepo-671: Fixed linting errors

* frontend-monorepo-671: PR suggestions

* frontend-monorepo-671: Used 'describe' and improved test descriptions

* frontend-monorepo-646: PR improvement

* frontend-monorepo-671: Tweak to basic cypress tests

* Frontend-monorepo-45: Adjusted proposal filter and tests to remove rationale

* Frontend-monorepo-45: Removed accidentally duplicated test

* frontend-monorepo-671: More clarity for freeform proposal header

* frontend-monorepo-671: resolve master

* frontend-monorepo-671: Added issue number in comment for proposal rationale

* frontend-monorepo-671: Added issue number in another comment for proposal rationale

* frontend-monorepo-671: Mock timers added for proposals-list-item-details.spec.tsx

* frontend-monorepo-671: Mock timers added for proposals-list.spec.tsx

* frontend-monorepo-671: Improved styling to differentiate open vs closed proposals

* Fixed previous incorrect resolution of master
This commit is contained in:
Sam Keen 2022-07-26 21:10:49 +01:00 committed by GitHub
parent 6f374264c0
commit aa0be2b3e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1608 additions and 239 deletions

View File

@ -69,8 +69,7 @@ Run `nx serve my-app` for a dev server. Navigate to the port specified in `app/<
### Using Apollo GraphQL and Generate Types
In order to generate the schemas for your GraphQL queries, you can run `nx run types:generate`.
If it is the first time you are running the command, make sure you are setting up the environment variable from `apollo.config.js`.
In order to generate the schemas for your GraphQL queries, you can run `NX_VEGA_URL=[YOUR URL HERE] nx run types:generate`.
```bash
export NX_VEGA_URL=https://lb.testnet.vega.xyz/query

View File

@ -1,4 +1,5 @@
const noProposals = '[data-testid="no-proposals"]';
const noOpenProposals = '[data-testid="no-open-proposals"]';
const noClosedProposals = '[data-testid="no-closed-proposals"]';
context('Governance Page - verify elements on page', function () {
before('navigate to governance page', function () {
@ -14,10 +15,13 @@ context('Governance Page - verify elements on page', function () {
cy.verify_page_header('Governance');
});
it('should have information box visible', function () {
cy.get(noProposals)
it('should have information visible', function () {
cy.get(noOpenProposals)
.should('be.visible')
.and('have.text', 'There are no active network change proposals');
.and('have.text', 'There are no open or yet to enact proposals');
cy.get(noClosedProposals)
.should('be.visible')
.and('have.text', 'There are no enacted or rejected proposals');
});
});
});

View File

@ -202,9 +202,9 @@ export const EthWallet = () => {
<WalletCard dark={true}>
<section data-testid="ethereum-wallet">
<WalletCardHeader>
<h1 className="text-h3 uppercase">{t('ethereumKey')}</h1>
<h1 className="m-0 text-h3 uppercase">{t('ethereumKey')}</h1>
{account && (
<div className="px-4 text-right">
<div className="place-self-end font-mono px-4 pb-2">
<div
className="font-mono"
data-testid="ethereum-account-truncated"
@ -237,7 +237,7 @@ export const EthWallet = () => {
) : (
<Button
variant={'secondary'}
className="w-full px-28 border h-28"
className="w-full"
onClick={() =>
appDispatch({
type: AppStateActionType.SET_ETH_WALLET_OVERLAY,

View File

@ -156,15 +156,20 @@
"The vesting contract holds VEGA tokens until they have become unlocked.": "The vesting contract holds $VEGA tokens until they have become unlocked.",
"Once unlocked they can be redeemed from the contract so that you can transfer them between wallets.": "Once unlocked they can be redeemed from the contract so that you can transfer them between wallets.",
"Tokens are held in different <trancheLink>Tranches</trancheLink>. Each tranche has its own schedule for how the tokens are unlocked.": "Tokens are held in different <trancheLink>Tranches</trancheLink>. Each tranche has its own schedule for how the tokens are unlocked.",
"proposedChangesToVegaNetwork": "This page lists proposed changes to the Vega network.",
"vegaTokenHoldersCanVote": "$VEGA token holders can vote for or against proposals as well as make their own.",
"requiredMajorityDescription": "Each proposal needs both a required majority of votes (e.g 66% but this differs by proposal type) and to meet a minimum threshold of votes.",
"proposals": "Proposals",
"proposedEnactment": "Proposed enactment",
"Enacted": "Enacted",
"enactedOn": "Enacted on",
"status": "Status",
"state": "State",
"shouldPass": "Should pass",
"Participation": "Participation",
"Majority": "Majority",
"not reached": "not reached",
"openProposals": "Open proposals",
"closedProposals": "Closed proposals",
"noOpenProposals": "There are no open or yet to enact proposals",
"noClosedProposals": "There are no enacted or rejected proposals",
"participationNotMet": "Participation not met",
"majorityNotMet": "Majority not met",
"noProposals": "There are no active network change proposals",
@ -201,7 +206,13 @@
"voteState_Yes": "For",
"voteState_No": "Against",
"voteState_NotCast": "Not cast",
"voteState_Enacted": "Enacted",
"voteState_Passed": "Passed",
"voteState_WaitingForNodeVote": "Waiting for node vote",
"voteState_Open": "Open",
"voteState_Declined": "Declined",
"voteState_Failed": "Failed",
"voteState_Rejected": "Rejected",
"Token address": "Token address",
"Vesting contract": "Vesting contract",
"Total supply": "Total supply",
@ -484,6 +495,7 @@
"invalidAddress": "Looks like that address isn't a valid Ethereum address, please check and try again",
"Signature": "Signature",
"voteFailedReason": "Vote closed. Failed due to: ",
"Passed": "Passed",
"votePassed": "Vote passed.",
"subjectToFurtherActions": "Vote passed {{daysAgo}} subject to further actions.",
"transactionHashPrompt": "Transaction hash will appear here once the transaction is approved in your Ethereum wallet",
@ -544,8 +556,6 @@
"noPercentage": "No percentage",
"proposalTerms": "Proposal terms",
"currentlySetTo": "Currently set to ",
"pass": "Pass",
"fail": "Fail",
"rankingScore": "Ranking score",
"stakeScore": "Stake score",
"performanceScore": "Performance",
@ -559,5 +569,56 @@
"status-tendermint": "Consensus",
"status-ersatz": "Ersatz",
"status-pending": "Pending",
"status-unspecified": "Unspecified"
"status-unspecified": "Unspecified",
"Set to": "Set to",
"pass": "pass",
"fail": "fail",
"New asset": "New asset",
"Asset change": "Asset change",
"New market": "New market",
"Market change": "Market change",
"Network parameter": "Network parameter",
"Unknown proposal": "Unknown proposal",
"Code": "Code",
"settled future": "settled future",
"Symbol": "Symbol",
"Max faucet amount mint": "Max faucet amount mint",
"left to vote": "left to vote",
"View": "View",
"CloseTimeTooSoon": "Close time too soon",
"CloseTimeTooLate": "Close time too late",
"EnactTimeTooSoon": "Enact time too soon",
"EnactTimeTooLate": "Enact time too late",
"InsufficientTokens": "Insufficient tokens",
"InvalidInstrumentSecurity": "Invalid instrument security",
"NoProduct": "No product",
"UnsupportedProduct": "Unsupported product",
"InvalidFutureMaturityTimestamp": "Invalid future maturity timestamp",
"ProductMaturityIsPassed": "Product maturity is passed",
"NoTradingMode": "No trading mode",
"UnsupportedTradingMode": "Unsupported trading mode",
"NodeValidationFailed": "Node validation failed",
"MissingBuiltinAssetField": "Missing builtin asset field",
"MissingERC20ContractAddress": "Missing ERC20 contract address",
"InvalidAsset": "Invalid asset",
"IncompatibleTimestamps": "Incompatible timestamps",
"NoRiskParameters": "No risk parameters",
"NetworkParameterInvalidKey": "Network parameter invalid key",
"NetworkParameterInvalidValue": "Network parameter invalid value",
"NetworkParameterValidationFailed": "Network parameter validation failed",
"OpeningAuctionDurationTooSmall": "Opening auction duration too small",
"OpeningAuctionDurationTooLarge": "Opening auction duration too large",
"MarketMissingLiquidityCommitment": "Market missing liquidity commitment",
"CouldNotInstantiateMarket": "Could not instantiate market",
"InvalidFutureProduct": "Invalid future product",
"MissingCommitmentAmount": "Missing commitment amount",
"InvalidFeeAmount": "Invalid fee amount",
"InvalidShape": "Invalid shape",
"InvalidRiskParameter": "Invalid risk parameter",
"MajorityThresholdNotReached": "Majority threshold not reached",
"ParticipationThresholdNotReached": "Participation threshold not reached",
"InvalidAssetDetails": "Invalid asset details",
"FilterProposals": "Filter proposals",
"FilterProposalsDescription": "Filter by proposal ID or proposer ID",
"Freeform proposal": "Freeform proposal"
}

View File

@ -1,101 +0,0 @@
import { generateProposal } from '../../routes/governance/test-helpers/generate-proposals';
import { getProposalName } from './proposal';
const proposal = generateProposal();
it('New market', () => {
const name = getProposalName({
...proposal,
terms: {
...proposal.terms,
change: {
__typename: 'NewMarket',
decimalPlaces: 1,
instrument: {
__typename: 'InstrumentConfiguration',
name: 'Some market',
},
metadata: [],
},
},
});
expect(name).toEqual('New Market: Some market');
});
it('New asset', () => {
const name = getProposalName({
...proposal,
terms: {
...proposal.terms,
change: {
__typename: 'NewAsset',
symbol: 'FAKE',
source: {
__typename: 'ERC20',
contractAddress: '0x0',
},
},
},
});
expect(name).toEqual('New Asset: FAKE');
});
it('Update market', () => {
const name = getProposalName({
...proposal,
terms: {
...proposal.terms,
change: {
__typename: 'UpdateMarket',
marketId: 'MarketId',
},
},
});
expect(name).toEqual('Update Market: MarketId');
});
it('Update network', () => {
const name = getProposalName({
...proposal,
terms: {
...proposal.terms,
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
__typename: 'NetworkParameter',
key: 'key',
value: 'value',
},
},
},
});
expect(name).toEqual('Update Network: key');
});
it('Freeform network', () => {
const name = getProposalName({
...proposal,
id: 'test-id',
terms: {
...proposal.terms,
change: {
__typename: 'NewFreeform',
},
},
});
expect(name).toEqual('Freeform: test-id');
});
it("Renders unknown proposal if it's a different proposal type", () => {
const name = getProposalName({
...proposal,
terms: {
...proposal.terms,
change: {
// @ts-ignore unknown proposal
__typename: 'Foo',
},
},
});
expect(name).toEqual('Unknown Proposal');
});

View File

@ -1,19 +0,0 @@
import type { Proposals_proposals } from '../../routes/governance/proposals/__generated__/Proposals';
export function getProposalName(proposal: Proposals_proposals) {
const { change } = proposal.terms;
if (change.__typename === 'NewAsset') {
return `New Asset: ${change.symbol}`;
} else if (change.__typename === 'NewMarket') {
return `New Market: ${change.instrument.name}`;
} else if (change.__typename === 'UpdateMarket') {
return `Update Market: ${change.marketId}`;
} else if (change.__typename === 'UpdateNetworkParameter') {
return `Update Network: ${change.networkParameter.key}`;
} else if (change.__typename === 'NewFreeform') {
return `Freeform: ${proposal.id}`;
}
return 'Unknown Proposal';
}

View File

@ -21,12 +21,36 @@ export interface ProposalFields_terms_change_NewFreeform {
__typename: "NewFreeform";
}
export interface ProposalFields_terms_change_NewMarket_instrument_futureProduct_settlementAsset {
__typename: "Asset";
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
}
export interface ProposalFields_terms_change_NewMarket_instrument_futureProduct {
__typename: "FutureProduct";
/**
* Product asset ID
*/
settlementAsset: ProposalFields_terms_change_NewMarket_instrument_futureProduct_settlementAsset;
}
export interface ProposalFields_terms_change_NewMarket_instrument {
__typename: "InstrumentConfiguration";
/**
* Full and fairly descriptive name for the instrument
*/
name: string;
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18)
*/
code: string;
/**
* Future product specification
*/
futureProduct: ProposalFields_terms_change_NewMarket_instrument_futureProduct | null;
}
export interface ProposalFields_terms_change_NewMarket {
@ -70,6 +94,10 @@ export type ProposalFields_terms_change_NewAsset_source = ProposalFields_terms_c
export interface ProposalFields_terms_change_NewAsset {
__typename: "NewAsset";
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The symbol of the asset (e.g: GBP)
*/

View File

@ -6,11 +6,11 @@ import { ProposalState } from '../../../../__generated__/globalTypes';
import { useVoteInformation } from '../../hooks';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
const StatusPass = ({ children }: { children: React.ReactNode }) => (
export const StatusPass = ({ children }: { children: React.ReactNode }) => (
<span className="text-vega-green">{children}</span>
);
const StatusFail = ({ children }: { children: React.ReactNode }) => (
export const StatusFail = ({ children }: { children: React.ReactNode }) => (
<span className="text-danger">{children}</span>
);

View File

@ -0,0 +1,277 @@
import { render, screen } from '@testing-library/react';
import { generateProposal } from '../../test-helpers/generate-proposals';
import { ProposalHeader } from './proposal-header';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
const renderComponent = (proposal: Proposals_proposals) => (
<ProposalHeader proposal={proposal} />
);
describe('Proposal header', () => {
it('Renders New market proposal', () => {
render(
renderComponent(
generateProposal({
terms: {
change: {
__typename: 'NewMarket',
decimalPlaces: 1,
instrument: {
__typename: 'InstrumentConfiguration',
name: 'Some market',
code: 'FX:BTCUSD/DEC99',
futureProduct: {
__typename: 'FutureProduct',
settlementAsset: {
__typename: 'Asset',
symbol: 'tGBP',
},
},
},
metadata: [],
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'New market: Some market'
);
expect(screen.getByTestId('proposal-details-one')).toHaveTextContent(
'tGBP settled future.'
);
});
it('Renders Update market proposal', () => {
render(
renderComponent(
generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
marketId: 'MarketId',
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'Market change: MarketId'
);
});
it('Renders New asset proposal - ERC20', () => {
render(
renderComponent(
generateProposal({
terms: {
change: {
__typename: 'NewAsset',
name: 'Fake currency',
symbol: 'FAKE',
source: {
__typename: 'ERC20',
contractAddress: '0x0',
},
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'New asset: Fake currency'
);
expect(screen.getByTestId('proposal-details-one')).toHaveTextContent(
'Symbol: FAKE. ERC20 0x0'
);
});
it('Renders New asset proposal - BuiltInAsset', () => {
render(
renderComponent(
generateProposal({
terms: {
change: {
__typename: 'NewAsset',
name: 'Fake currency',
symbol: 'BIA',
source: {
__typename: 'BuiltinAsset',
maxFaucetAmountMint: '300',
},
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'New asset: Fake currency'
);
expect(screen.getByTestId('proposal-details-one')).toHaveTextContent(
'Symbol: BIA. Max faucet amount mint: 300'
);
});
it('Renders Update network', () => {
render(
renderComponent(
generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
__typename: 'NetworkParameter',
key: 'Network key',
value: 'Network value',
},
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'Network parameter'
);
expect(screen.getByTestId('proposal-details-one')).toHaveTextContent(
'Network key to Network value'
);
});
// Skipped until proposals have rationale - https://github.com/vegaprotocol/frontend-monorepo/issues/824
// eslint-disable-next-line jest/no-disabled-tests
it.skip('Renders Freeform network - short rationale', () => {
render(
renderComponent(
generateProposal({
id: 'short',
// rationale: {
// hash: '0x0',
// description: 'freeform description',
// },
terms: {
change: {
__typename: 'NewFreeform',
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'freeform description'
);
expect(screen.getByTestId('proposal-details-one')).toBeEmptyDOMElement();
expect(screen.getByTestId('proposal-details-two')).toHaveTextContent(
'short'
);
});
// Skipped until proposals have rationale
// eslint-disable-next-line jest/no-disabled-tests
it.skip('Renders Freeform proposal - long rationale (105 chars)', () => {
render(
renderComponent(
generateProposal({
id: 'long',
// rationale: {
// hash: '0x0',
// description:
// 'Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean dolor.',
// },
terms: {
change: {
__typename: 'NewFreeform',
},
},
})
)
);
// For a rationale over 100 chars, we expect the header to be truncated at
// 100 chars with ellipsis and the details-one element to contain the rest.
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean…'
);
expect(screen.getByTestId('proposal-details-one')).toHaveTextContent(
'dolor'
);
expect(screen.getByTestId('proposal-details-two')).toHaveTextContent(
'long'
);
});
// Skipped until proposals have rationale
// eslint-disable-next-line jest/no-disabled-tests
it.skip('Renders Freeform proposal - extra long rationale (165 chars)', () => {
render(
renderComponent(
generateProposal({
id: 'extraLong',
// rationale: {
// hash: '0x0',
// description:
// 'Aenean sem odio, eleifend non sodales vitae, porttitor eu ex. Aliquam erat volutpat. Fusce pharetra libero quis risus lobortis, sed ornare leo efficitur turpis duis.',
// },
terms: {
change: {
__typename: 'NewFreeform',
},
},
})
)
);
// For a rationale over 160 chars, we expect the header to be truncated at 100
// chars with ellipsis and the details-one element to contain 60 chars and also
// be truncated with an ellipsis.
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'Aenean sem odio, eleifend non sodales vitae, porttitor eu ex. Aliquam erat volutpat. Fusce pharetra…'
);
expect(screen.getByTestId('proposal-details-one')).toHaveTextContent(
'libero quis risus lobortis, sed ornare leo efficitur turpis…'
);
expect(screen.getByTestId('proposal-details-two')).toHaveTextContent(
'extraLong'
);
});
// Remove once proposals have rationale and re-enable above tests
it('Renders Freeform proposal - id for title', () => {
render(
renderComponent(
generateProposal({
id: 'freeform id',
terms: {
change: {
__typename: 'NewFreeform',
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'Freeform proposal: freeform id'
);
expect(
screen.queryByTestId('proposal-details-one')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('proposal-details-two')
).not.toBeInTheDocument();
});
it("Renders unknown proposal if it's a different proposal type", () => {
render(
renderComponent(
generateProposal({
terms: {
change: {
// @ts-ignore unknown proposal
__typename: 'Foo',
},
},
})
)
);
expect(screen.getByTestId('proposal-header')).toHaveTextContent(
'Unknown proposal'
);
});
});

View File

@ -0,0 +1,109 @@
import { useTranslation } from 'react-i18next';
import { Lozenge } from '@vegaprotocol/ui-toolkit';
import type { ReactNode } from 'react';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
export const ProposalHeader = ({
proposal,
}: {
proposal: Proposals_proposals;
}) => {
const { t } = useTranslation();
const { change } = proposal.terms;
let headerText: string;
let detailsOne: ReactNode;
let detailsTwo: ReactNode;
switch (change.__typename) {
case 'NewMarket': {
headerText = `${t('New market')}: ${change.instrument.name}`;
detailsOne = (
<>
{t('Code')}: {change.instrument.code}.{' '}
{change.instrument.futureProduct?.settlementAsset.symbol ? (
<>
<span className="font-semibold">
{change.instrument.futureProduct.settlementAsset.symbol}
</span>{' '}
{t('settled future')}.
</>
) : (
''
)}
</>
);
break;
}
case 'UpdateMarket': {
headerText = `${t('Market change')}: ${change.marketId}`;
break;
}
case 'NewAsset': {
headerText = `${t('New asset')}: ${change.name}`;
detailsOne = (
<>
{t('Symbol')}: {change.symbol}.{' '}
<Lozenge>
{change.source.__typename === 'ERC20'
? `ERC20 ${change.source.contractAddress}`
: `${t('Max faucet amount mint')}: ${
change.source.maxFaucetAmountMint
}`}
</Lozenge>
</>
);
break;
}
case 'UpdateNetworkParameter': {
headerText = `${t('Network parameter')}`;
detailsOne = (
<>
<Lozenge>{change.networkParameter.key}</Lozenge> {t('to')}{' '}
<Lozenge>{change.networkParameter.value}</Lozenge>
</>
);
break;
}
case 'NewFreeform': {
// When rationale exists (https://github.com/vegaprotocol/frontend-monorepo/issues/824):
// const description = proposal.rationale.description.trim();
// const headerMaxLength = 100;
// const descriptionOneMaxLength = 60;
// const headerOverflow = description.length > headerMaxLength;
// const descriptionOneOverflow =
// description.length > headerMaxLength + descriptionOneMaxLength;
//
// headerText = `${description.substring(0, headerMaxLength - 1).trim()}${
// headerOverflow ? '…' : ''
// }`;
// detailsOne = headerOverflow
// ? `${description
// .substring(
// headerMaxLength - 1,
// headerMaxLength + descriptionOneMaxLength - 1
// )
// .trim()}${descriptionOneOverflow ? '…' : ''}`
// : '';
// detailsTwo = `${proposal.id}`;
headerText = proposal.id
? `${t('Freeform proposal')}: ${proposal.id.trim()}`
: `${t('Unknown proposal')}`;
break;
}
default: {
headerText = `${t('Unknown proposal')}`;
}
}
return (
<div className="text-ui text-white">
<header data-testid="proposal-header">
<h2 className="text-h5 font-semibold mb-4">{headerText}</h2>
</header>
{detailsOne && <div data-testid="proposal-details-one">{detailsOne}</div>}
{detailsTwo && <div data-testid="proposal-details-two">{detailsTwo}</div>}
</div>
);
};

View File

@ -1,5 +1,4 @@
import { Heading } from '../../../../components/heading';
import { getProposalName } from '../../../../lib/type-policies/proposal';
import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import type { Proposal_proposal } from '../../proposal/__generated__/Proposal';
import type { RestProposalResponse } from '../../proposal/proposal-container';
import { ProposalChangeTable } from '../proposal-change-table';
@ -19,7 +18,7 @@ export const Proposal = ({ proposal, terms }: ProposalProps) => {
return (
<>
<Heading title={getProposalName(proposal)} />
<ProposalHeader proposal={proposal} />
<ProposalChangeTable proposal={proposal} />
<VoteDetails proposal={proposal} />
<ProposalVotesTable proposal={proposal} />

View File

@ -0,0 +1 @@
export { ProposalsListFilter } from './proposals-list-filter';

View File

@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Button, FormGroup, Input } from '@vegaprotocol/ui-toolkit';
import type { Dispatch, SetStateAction } from 'react';
interface ProposalsListFilterProps {
setFilterString: Dispatch<SetStateAction<string>>;
}
export const ProposalsListFilter = ({
setFilterString,
}: ProposalsListFilterProps) => {
const { t } = useTranslation();
const [filterVisible, setFilterVisible] = useState(false);
return (
<div data-testid="proposals-list-filter">
{!filterVisible && (
<Button
onClick={() => setFilterVisible(true)}
variant="inline-link"
className="pl-0 pb-20"
data-testid="set-proposals-filter-visible"
>
{t('FilterProposals')}
</Button>
)}
{filterVisible && (
<div data-testid="open-proposals-list-filter">
<p>{t('FilterProposalsDescription')}</p>
<FormGroup
labelClassName="sr-only"
label="Filter text input"
labelFor="filter-input"
>
<Input
data-testid="filter-input"
id="filter-input"
onChange={(e) => setFilterString(e.target.value)}
/>
</FormGroup>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,2 @@
export { ProposalsListItem } from './proposals-list-item';
export { ProposalsListItemDetails } from './proposals-list-item-details';

View File

@ -0,0 +1,377 @@
import { BrowserRouter as Router } from 'react-router-dom';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { VegaWalletContext } from '@vegaprotocol/wallet';
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 {
generateNoVotes,
generateProposal,
generateYesVotes,
} from '../../test-helpers/generate-proposals';
import { ProposalsListItemDetails } from './proposals-list-item-details';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import {
mockPubkey,
mockWalletContext,
networkParamsQueryMock,
fiveMinutes,
fiveHours,
fiveDays,
lastWeek,
nextWeek,
} from '../../test-helpers/mocks';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
const renderComponent = (
proposal: Proposals_proposals,
mock = networkParamsQueryMock
) => (
<Router>
<MockedProvider mocks={[mock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalsListItemDetails proposal={proposal} />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
</Router>
);
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => {
jest.useRealTimers();
});
describe('Proposals list item details', () => {
it('Renders proposal state: Enacted', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Enacted,
terms: {
enactmentDatetime: lastWeek.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Enacted');
expect(screen.getByTestId('vote-details')).toHaveTextContent(
format(lastWeek, DATE_FORMAT_DETAILED)
);
});
it('Renders proposal state: Passed', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Passed,
terms: {
closingDatetime: lastWeek.toString(),
enactmentDatetime: nextWeek.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Passed');
expect(screen.getByTestId('vote-details')).toHaveTextContent(
`Enacts on ${format(nextWeek, DATE_FORMAT_DETAILED)}`
);
});
it('Renders proposal state: Waiting for node vote', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.WaitingForNodeVote,
terms: {
enactmentDatetime: nextWeek.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent(
'Waiting for node vote'
);
expect(screen.getByTestId('vote-details')).toHaveTextContent(
`Enacts on ${format(nextWeek, DATE_FORMAT_DETAILED)}`
);
});
it('Renders proposal state: Open - 5 minutes left to vote', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
terms: {
closingDatetime: fiveMinutes.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'5 minutes left to vote'
);
});
it('Renders proposal state: Open - 5 hours left to vote', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
terms: {
closingDatetime: fiveHours.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'5 hours left to vote'
);
});
it('Renders proposal state: Open - 5 days left to vote', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
terms: {
closingDatetime: fiveDays.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'5 days left to vote'
);
});
it('Renders proposal state: Open - user voted for', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
votes: {
__typename: 'ProposalVotes',
yes: {
votes: [
{
__typename: 'Vote',
value: VoteValue.Yes,
datetime: lastWeek.toString(),
party: {
__typename: 'Party',
id: mockPubkey,
stake: {
__typename: 'PartyStake',
currentStakeAvailable: '1000',
},
},
},
],
},
no: generateNoVotes(0),
},
terms: {
closingDatetime: nextWeek.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'You voted For'
);
});
it('Renders proposal state: Open - user voted against', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
votes: {
__typename: 'ProposalVotes',
no: {
votes: [
{
__typename: 'Vote',
value: VoteValue.No,
datetime: lastWeek.toString(),
party: {
__typename: 'Party',
id: mockPubkey,
stake: {
__typename: 'PartyStake',
currentStakeAvailable: '1000',
},
},
},
],
},
yes: generateYesVotes(0),
},
terms: {
closingDatetime: nextWeek.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-details')).toHaveTextContent(
'You voted Against'
);
});
it('Renders proposal state: Open - participation not reached', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
terms: {
enactmentDatetime: nextWeek.toString(),
},
votes: {
no: generateNoVotes(0),
yes: generateYesVotes(0),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Participation not reached'
);
});
it('Renders proposal state: Open - majority not reached', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
terms: {
enactmentDatetime: nextWeek.toString(),
},
votes: {
no: generateNoVotes(1, 1000000000000000000),
yes: generateYesVotes(1, 1000000000000000000),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Majority not reached'
);
});
it('Renders proposal state: Open - will pass', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(3000, 1000000000000000000),
no: generateNoVotes(0),
},
terms: {
closingDatetime: nextWeek.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-status')).toHaveTextContent('Set to pass');
});
it('Renders proposal state: Open - will fail', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Open,
votes: {
__typename: 'ProposalVotes',
yes: generateYesVotes(0),
no: generateNoVotes(3000, 1000000000000000000),
},
terms: {
closingDatetime: nextWeek.toString(),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Open');
expect(screen.getByTestId('vote-status')).toHaveTextContent('Set to fail');
});
it('Renders proposal state: Declined - participation not reached', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Declined,
terms: {
enactmentDatetime: lastWeek.toString(),
},
votes: {
no: generateNoVotes(0),
yes: generateYesVotes(0),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Declined');
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Participation not reached'
);
});
it('Renders proposal state: Declined - majority not reached', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Declined,
terms: {
enactmentDatetime: lastWeek.toString(),
},
votes: {
no: generateNoVotes(1, 1000000000000000000),
yes: generateYesVotes(1, 1000000000000000000),
},
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Declined');
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Majority not reached'
);
});
it('Renders proposal state: Rejected', () => {
render(
renderComponent(
generateProposal({
state: ProposalState.Rejected,
terms: {
enactmentDatetime: lastWeek.toString(),
},
rejectionReason: ProposalRejectionReason.InvalidFutureProduct,
})
)
);
expect(screen.getByTestId('proposal-status')).toHaveTextContent('Rejected');
expect(screen.getByTestId('vote-status')).toHaveTextContent(
'Invalid future product'
);
});
});

View File

@ -0,0 +1,207 @@
import classnames from 'classnames';
import { Link } from 'react-router-dom';
import { Button, Icon } from '@vegaprotocol/ui-toolkit';
import { useVoteInformation } from '../../hooks';
import { useUserVote } from '../vote-details/use-user-vote';
import {
StatusPass,
StatusFail,
} from '../current-proposal-status/current-proposal-status';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import { ProposalState } from '../../../../__generated__/globalTypes';
import type { ReactNode } from 'react';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
const MajorityNotReached = () => {
const { t } = useTranslation();
return (
<>
{t('Majority')} <StatusFail>{t('not reached')}</StatusFail>
</>
);
};
const ParticipationNotReached = () => {
const { t } = useTranslation();
return (
<>
{t('Participation')} <StatusFail>{t('not reached')}</StatusFail>
</>
);
};
export const ProposalsListItemDetails = ({
proposal,
}: {
proposal: Proposals_proposals;
}) => {
const { state } = proposal;
const { willPass, majorityMet, participationMet } = useVoteInformation({
proposal,
});
const { t } = useTranslation();
const { voteState } = useUserVote(
proposal.id,
proposal.votes.yes.votes,
proposal.votes.no.votes
);
let proposalStatus: ReactNode;
let voteDetails: ReactNode;
let voteStatus: ReactNode;
switch (state) {
case ProposalState.Enacted: {
proposalStatus = (
<>
{t('voteState_Enacted')} <Icon name={'tick'} />
</>
);
voteDetails = (
<>
{format(
new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED
)}
</>
);
break;
}
case ProposalState.Passed: {
proposalStatus = (
<>
{t('voteState_Passed')} <Icon name={'tick'} />
</>
);
voteDetails = proposal.terms.change.__typename !== 'NewFreeform' && (
<>
{t('toEnactOn')}{' '}
{format(
new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED
)}
</>
);
break;
}
case ProposalState.WaitingForNodeVote: {
proposalStatus = (
<>
{t('voteState_WaitingForNodeVote')} <Icon name={'time'} />
</>
);
voteDetails = proposal.terms.change.__typename !== 'NewFreeform' && (
<>
{t('toEnactOn')}{' '}
{format(
new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED
)}
</>
);
break;
}
case ProposalState.Open: {
proposalStatus = (
<>
{t('voteState_Open')} <Icon name={'hand'} />
</>
);
voteDetails = (voteState === 'Yes' && (
<>
{t('youVoted')} <StatusPass>{t('voteState_Yes')}</StatusPass>
</>
)) ||
(voteState === 'No' && (
<>
{t('youVoted')} <StatusFail>{t('voteState_No')}</StatusFail>
</>
)) || (
<>
{formatDistanceToNowStrict(
new Date(proposal.terms.closingDatetime)
)}{' '}
{t('left to vote')}
</>
);
voteStatus =
(!participationMet && <ParticipationNotReached />) ||
(!majorityMet && <MajorityNotReached />) ||
(willPass && (
<>
{t('Set to')} <StatusPass>{t('pass')}</StatusPass>
</>
)) ||
(!willPass && (
<>
{t('Set to')} <StatusFail>{t('fail')}</StatusFail>
</>
));
break;
}
case ProposalState.Declined: {
proposalStatus = (
<>
{t('voteState_Declined')} <Icon name={'cross'} />
</>
);
voteStatus =
(!participationMet && <ParticipationNotReached />) ||
(!majorityMet && <MajorityNotReached />);
break;
}
case ProposalState.Rejected: {
proposalStatus = (
<>
<StatusFail>{t('voteState_Rejected')}</StatusFail>{' '}
<Icon name={'warning-sign'} />
</>
);
voteStatus = proposal.rejectionReason && (
<>{t(proposal.rejectionReason)}</>
);
break;
}
}
return (
<div
className={classnames(
'grid grid-cols-[1fr_auto] items-start gap-4',
'mt-4',
'text-ui'
)}
>
<div
className="col-start-1 row-start-1 flex items-center gap-4 text-white"
data-testid="proposal-status"
>
{proposalStatus}
</div>
{voteDetails && (
<div className="col-start-1 row-start-2" data-testid="vote-details">
{voteDetails}
</div>
)}
{voteStatus && (
<div
className="col-start-2 row-start-1 justify-self-end"
data-testid="vote-status"
>
testing testing 123
{voteStatus}
</div>
)}
{proposal.id && (
<div className="col-start-2 row-start-2 justify-self-end">
<Link to={proposal.id}>
<Button variant="secondary" data-testid="view-proposal-btn">
{t('View')}
</Button>
</Link>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,22 @@
import { ProposalHeader } from '../proposal-detail-header/proposal-header';
import { ProposalsListItemDetails } from './proposals-list-item-details';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
interface ProposalsListItemProps {
proposal: Proposals_proposals;
}
export const ProposalsListItem = ({ proposal }: ProposalsListItemProps) => {
if (!proposal || !proposal.id) return null;
return (
<li
className="py-20 border-b border-white-40"
id={proposal.id}
data-testid="proposals-list-item"
>
<ProposalHeader proposal={proposal} />
<ProposalsListItemDetails proposal={proposal} />
</li>
);
};

View File

@ -0,0 +1,219 @@
import { generateProposal } from '../../test-helpers/generate-proposals';
import { MockedProvider } from '@apollo/client/testing';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { BrowserRouter as Router } from 'react-router-dom';
import { AppStateProvider } from '../../../../contexts/app-state/app-state-provider';
import { ProposalsList } from './proposals-list';
import { ProposalState } from '@vegaprotocol/types';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
mockWalletContext,
networkParamsQueryMock,
lastWeek,
nextWeek,
lastMonth,
nextMonth,
} from '../../test-helpers/mocks';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
const openProposalClosesNextMonth = generateProposal({
id: 'proposal1',
state: ProposalState.Open,
party: {
id: 'zxcv',
},
terms: {
closingDatetime: nextMonth.toString(),
enactmentDatetime: nextMonth.toString(),
},
});
const openProposalClosesNextWeek = generateProposal({
id: 'proposal2',
state: ProposalState.Open,
party: {
id: 'bvcx',
},
terms: {
closingDatetime: nextWeek.toString(),
enactmentDatetime: nextWeek.toString(),
},
});
const enactedProposalClosedLastWeek = generateProposal({
id: 'proposal3',
state: ProposalState.Enacted,
terms: {
closingDatetime: lastWeek.toString(),
enactmentDatetime: lastWeek.toString(),
},
});
const rejectedProposalClosedLastMonth = generateProposal({
id: 'proposal4',
state: ProposalState.Rejected,
terms: {
closingDatetime: lastMonth.toString(),
enactmentDatetime: lastMonth.toString(),
},
});
const failedProposal = generateProposal({
id: 'proposal5',
state: ProposalState.Failed,
});
const renderComponent = (proposals: Proposals_proposals[]) => (
<Router>
<MockedProvider mocks={[networkParamsQueryMock]}>
<AppStateProvider>
<VegaWalletContext.Provider value={mockWalletContext}>
<ProposalsList proposals={proposals} />
</VegaWalletContext.Provider>
</AppStateProvider>
</MockedProvider>
</Router>
);
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => {
jest.useRealTimers();
});
describe('Proposals list', () => {
it('Culls failed proposals', () => {
render(renderComponent([failedProposal]));
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('Will hide filter if no proposals', () => {
render(renderComponent([]));
expect(
screen.queryByTestId('proposals-list-filter')
).not.toBeInTheDocument();
});
it('Will show filter if there are proposals', () => {
render(renderComponent([enactedProposalClosedLastWeek]));
expect(screen.queryByTestId('proposals-list-filter')).toBeInTheDocument();
});
it('Places proposals correctly in open or closed lists', () => {
render(
renderComponent([
openProposalClosesNextWeek,
openProposalClosesNextMonth,
enactedProposalClosedLastWeek,
rejectedProposalClosedLastMonth,
])
);
const openProposals = within(screen.getByTestId('open-proposals'));
const closedProposals = within(screen.getByTestId('closed-proposals'));
expect(openProposals.getAllByTestId('proposals-list-item').length).toBe(2);
expect(closedProposals.getAllByTestId('proposals-list-item').length).toBe(
2
);
});
it('Orders proposals correctly by closingDateTime', () => {
render(
renderComponent([
rejectedProposalClosedLastMonth,
openProposalClosesNextMonth,
openProposalClosesNextWeek,
enactedProposalClosedLastWeek,
])
);
const openProposals = within(screen.getByTestId('open-proposals'));
const closedProposals = within(screen.getByTestId('closed-proposals'));
const openProposalsItems = openProposals.getAllByTestId(
'proposals-list-item'
);
const closedProposalsItems = closedProposals.getAllByTestId(
'proposals-list-item'
);
expect(openProposalsItems[0]).toHaveAttribute('id', 'proposal1');
expect(openProposalsItems[1]).toHaveAttribute('id', 'proposal2');
expect(closedProposalsItems[0]).toHaveAttribute('id', 'proposal4');
expect(closedProposalsItems[1]).toHaveAttribute('id', 'proposal3');
});
it('Displays info on no proposals', () => {
render(renderComponent([]));
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', () => {
render(renderComponent([enactedProposalClosedLastWeek]));
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', () => {
render(renderComponent([openProposalClosesNextWeek]));
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', () => {
render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
fireEvent.click(screen.getByTestId('set-proposals-filter-visible'));
expect(
screen.getByTestId('open-proposals-list-filter')
).toBeInTheDocument();
});
it('Filters list by text - party id', () => {
render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
fireEvent.click(screen.getByTestId('set-proposals-filter-visible'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'bvcx' },
});
const container = screen.getByTestId('open-proposals');
expect(container.querySelector('#proposal2')).toBeInTheDocument();
expect(container.querySelector('#proposal1')).not.toBeInTheDocument();
});
it('Filters list by text - proposal id', () => {
render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
fireEvent.click(screen.getByTestId('set-proposals-filter-visible'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'proposal1' },
});
const container = screen.getByTestId('open-proposals');
expect(container.querySelector('#proposal1')).toBeInTheDocument();
expect(container.querySelector('#proposal2')).not.toBeInTheDocument();
});
it('Filters list by text - check for substring matching', () => {
render(
renderComponent([openProposalClosesNextMonth, openProposalClosesNextWeek])
);
fireEvent.click(screen.getByTestId('set-proposals-filter-visible'));
fireEvent.change(screen.getByTestId('filter-input'), {
target: { value: 'osal1' },
});
const container = screen.getByTestId('open-proposals');
expect(container.querySelector('#proposal1')).toBeInTheDocument();
expect(container.querySelector('#proposal2')).not.toBeInTheDocument();
});
});

View File

@ -1,84 +1,83 @@
import { DATE_FORMAT_DETAILED } from '../../../../lib/date-formats';
import { format, isFuture } from 'date-fns';
import { isFuture } from 'date-fns';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { KeyValueTable, KeyValueTableRow } from '@vegaprotocol/ui-toolkit';
import { getProposalName } from '../../../../lib/type-policies/proposal';
import { Heading } from '../../../../components/heading';
import { ProposalsListItem } from '../proposals-list-item';
import { ProposalsListFilter } from '../proposals-list-filter';
import type { Proposals_proposals } from '../../proposals/__generated__/Proposals';
import { CurrentProposalState } from '../current-proposal-state';
interface ProposalsListProps {
proposals: Proposals_proposals[];
}
interface SortedProposalsProps {
open: Proposals_proposals[];
closed: Proposals_proposals[];
}
export const ProposalsList = ({ proposals }: ProposalsListProps) => {
const { t } = useTranslation();
const [filterString, setFilterString] = useState('');
if (proposals.length === 0) {
return <p data-testid="no-proposals">{t('noProposals')}</p>;
}
const failedProposalsCulled = proposals.filter(
({ state }) => state !== 'Failed'
);
const sortedProposals = failedProposalsCulled.reduce(
(acc: SortedProposalsProps, proposal) => {
if (isFuture(new Date(proposal.terms.closingDatetime))) {
acc.open.push(proposal);
} else {
acc.closed.push(proposal);
}
return acc;
},
{
open: [],
closed: [],
}
);
const filterPredicate = (p: Proposals_proposals) =>
p.id?.includes(filterString) ||
p.party?.id?.toString().includes(filterString);
return (
<>
<p>{t('proposedChangesToVegaNetwork')}</p>
<p>{t('vegaTokenHoldersCanVote')}</p>
<p>{t('requiredMajorityDescription')}</p>
<h2>{t('proposals')}</h2>
<ul>
{proposals.map((proposal) => (
<ProposalListItem proposal={proposal} />
))}
</ul>
<Heading title={t('pageTitleGovernance')} />
{failedProposalsCulled.length > 0 && (
<ProposalsListFilter setFilterString={setFilterString} />
)}
<section className="mx-[-20px] p-20 bg-white-10">
<h2 className="text-h4 mb-0">{t('openProposals')}</h2>
{sortedProposals.open.length > 0 ? (
<ul data-testid="open-proposals">
{sortedProposals.open.filter(filterPredicate).map((proposal) => (
<ProposalsListItem key={proposal.id} proposal={proposal} />
))}
</ul>
) : (
<p className="mt-12 mb-0" data-testid="no-open-proposals">
{t('noOpenProposals')}
</p>
)}
</section>
<section className="mx-[-20px] p-20">
<h2 className="text-h4 mb-0">{t('closedProposals')}</h2>
{sortedProposals.closed.length > 0 ? (
<ul data-testid="closed-proposals">
{sortedProposals.closed.filter(filterPredicate).map((proposal) => (
<ProposalsListItem key={proposal.id} proposal={proposal} />
))}
</ul>
) : (
<p className="mt-12 mb-0" data-testid="no-closed-proposals">
{t('noClosedProposals')}
</p>
)}
</section>
</>
);
};
interface ProposalListItemProps {
proposal: Proposals_proposals;
}
const ProposalListItem = ({ proposal }: ProposalListItemProps) => {
const { t } = useTranslation();
if (!proposal || !proposal.id) return null;
return (
<li className="last:mb-0 mb-24" key={proposal.id}>
<Link to={proposal.id} className="underline text-white">
<header>{getProposalName(proposal)}</header>
</Link>
<KeyValueTable muted={true}>
<KeyValueTableRow>
{t('state')}
<span data-testid="governance-proposal-state">
<CurrentProposalState proposal={proposal} />
</span>
</KeyValueTableRow>
<KeyValueTableRow>
{isFuture(new Date(proposal.terms.closingDatetime))
? t('closesOn')
: t('closedOn')}
<span data-testid="governance-proposal-closingDate">
{format(
new Date(proposal.terms.closingDatetime),
DATE_FORMAT_DETAILED
)}
</span>
</KeyValueTableRow>
<KeyValueTableRow>
{isFuture(new Date(proposal.terms.enactmentDatetime))
? t('proposedEnactment')
: t('enactedOn')}
<span data-testid="governance-proposal-enactmentDate">
{format(
new Date(proposal.terms.enactmentDatetime),
DATE_FORMAT_DETAILED
)}
</span>
</KeyValueTableRow>
</KeyValueTable>
</li>
);
};

View File

@ -85,9 +85,7 @@ export const useVoteInformation = ({
const requiredMajorityPercentage = React.useMemo(
() =>
requiredMajority
? new BigNumber(requiredMajority).multipliedBy(100)
: new BigNumber(100),
requiredMajority ? new BigNumber(requiredMajority) : new BigNumber(100),
[requiredMajority]
);
@ -155,7 +153,9 @@ export const useVoteInformation = ({
const willPass = React.useMemo(
() =>
participationMet &&
new BigNumber(yesPercentage).isGreaterThan(requiredMajorityPercentage),
new BigNumber(yesPercentage).isGreaterThanOrEqualTo(
requiredMajorityPercentage
),
[participationMet, requiredMajorityPercentage, yesPercentage]
);

View File

@ -20,6 +20,12 @@ export const PROPOSALS_FRAGMENT = gql`
metadata
instrument {
name
code
futureProduct {
settlementAsset {
symbol
}
}
}
}
... on UpdateMarket {
@ -27,12 +33,15 @@ export const PROPOSALS_FRAGMENT = gql`
}
... on NewAsset {
__typename
name
symbol
source {
... on BuiltinAsset {
__typename
maxFaucetAmountMint
}
... on ERC20 {
__typename
contractAddress
}
}

View File

@ -21,12 +21,36 @@ export interface Proposal_proposal_terms_change_NewFreeform {
__typename: "NewFreeform";
}
export interface Proposal_proposal_terms_change_NewMarket_instrument_futureProduct_settlementAsset {
__typename: "Asset";
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
}
export interface Proposal_proposal_terms_change_NewMarket_instrument_futureProduct {
__typename: "FutureProduct";
/**
* Product asset ID
*/
settlementAsset: Proposal_proposal_terms_change_NewMarket_instrument_futureProduct_settlementAsset;
}
export interface Proposal_proposal_terms_change_NewMarket_instrument {
__typename: "InstrumentConfiguration";
/**
* Full and fairly descriptive name for the instrument
*/
name: string;
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18)
*/
code: string;
/**
* Future product specification
*/
futureProduct: Proposal_proposal_terms_change_NewMarket_instrument_futureProduct | null;
}
export interface Proposal_proposal_terms_change_NewMarket {
@ -70,6 +94,10 @@ export type Proposal_proposal_terms_change_NewAsset_source = Proposal_proposal_t
export interface Proposal_proposal_terms_change_NewAsset {
__typename: "NewAsset";
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The symbol of the asset (e.g: GBP)
*/

View File

@ -21,12 +21,36 @@ export interface Proposals_proposals_terms_change_NewFreeform {
__typename: "NewFreeform";
}
export interface Proposals_proposals_terms_change_NewMarket_instrument_futureProduct_settlementAsset {
__typename: "Asset";
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
}
export interface Proposals_proposals_terms_change_NewMarket_instrument_futureProduct {
__typename: "FutureProduct";
/**
* Product asset ID
*/
settlementAsset: Proposals_proposals_terms_change_NewMarket_instrument_futureProduct_settlementAsset;
}
export interface Proposals_proposals_terms_change_NewMarket_instrument {
__typename: "InstrumentConfiguration";
/**
* Full and fairly descriptive name for the instrument
*/
name: string;
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18)
*/
code: string;
/**
* Future product specification
*/
futureProduct: Proposals_proposals_terms_change_NewMarket_instrument_futureProduct | null;
}
export interface Proposals_proposals_terms_change_NewMarket {
@ -70,6 +94,10 @@ export type Proposals_proposals_terms_change_NewAsset_source = Proposals_proposa
export interface Proposals_proposals_terms_change_NewAsset {
__typename: "NewAsset";
/**
* The full name of the asset (e.g: Great British Pound)
*/
name: string;
/**
* The symbol of the asset (e.g: GBP)
*/

View File

@ -1,6 +1,5 @@
import { gql, useQuery } from '@apollo/client';
import { Callout, Intent, Splash } from '@vegaprotocol/ui-toolkit';
import { Heading } from '../../../components/heading';
import compact from 'lodash/compact';
import flow from 'lodash/flow';
import orderBy from 'lodash/orderBy';
@ -64,10 +63,5 @@ export const ProposalsContainer = () => {
);
}
return (
<>
<Heading title={t('pageTitleGovernance')} />
<ProposalsList proposals={proposals} />
</>
);
return <ProposalsList proposals={proposals} />;
};

View File

@ -1,5 +1,6 @@
import * as faker from 'faker';
import merge from 'lodash/merge';
import isArray from 'lodash/isArray';
import mergeWith from 'lodash/mergeWith';
import { ProposalState, VoteValue } from '../../../__generated__/globalTypes';
import type { DeepPartial } from '../../../lib/type-helpers';
@ -54,19 +55,28 @@ export function generateProposal(
},
};
return merge<ProposalFields, DeepPartial<ProposalFields>>(
return mergeWith<ProposalFields, DeepPartial<ProposalFields>>(
defaultProposal,
override
override,
(objValue, srcValue) => {
if (!isArray(objValue)) {
return;
}
return srcValue;
}
);
}
export const generateYesVotes = (
numberOfVotes = 5
numberOfVotes = 5,
fixedTokenValue?: number
): ProposalFields_votes_yes => {
return {
__typename: 'ProposalVoteSide',
totalNumber: faker.datatype.number({ min: 0, max: 100 }).toString(),
totalTokens: faker.datatype.number({ min: 1, max: 10000 }).toString(),
totalTokens: faker.datatype
.number({ min: 1, max: 10000000000000000000000 })
.toString(),
votes: Array.from(Array(numberOfVotes)).map(() => {
return {
__typename: 'Vote',
@ -76,12 +86,14 @@ export const generateYesVotes = (
__typename: 'Party',
stake: {
__typename: 'PartyStake',
currentStakeAvailable: faker.datatype
.number({
min: 1,
max: 10000,
})
.toString(),
currentStakeAvailable: fixedTokenValue
? fixedTokenValue.toString()
: faker.datatype
.number({
min: 1000000000000000000,
max: 10000000000000000000000,
})
.toString(),
},
},
datetime: faker.date.past().toISOString(),
@ -90,11 +102,16 @@ export const generateYesVotes = (
};
};
export const generateNoVotes = (numberOfVotes = 5): ProposalFields_votes_no => {
export const generateNoVotes = (
numberOfVotes = 5,
fixedTokenValue?: number
): ProposalFields_votes_no => {
return {
__typename: 'ProposalVoteSide',
totalNumber: faker.datatype.number({ min: 0, max: 100 }).toString(),
totalTokens: faker.datatype.number({ min: 1, max: 10000 }).toString(),
totalTokens: faker.datatype
.number({ min: 1000000000000000000, max: 10000000000000000000000 })
.toString(),
votes: Array.from(Array(numberOfVotes)).map(() => {
return {
__typename: 'Vote',
@ -104,12 +121,14 @@ export const generateNoVotes = (numberOfVotes = 5): ProposalFields_votes_no => {
__typename: 'Party',
stake: {
__typename: 'PartyStake',
currentStakeAvailable: faker.datatype
.number({
min: 1,
max: 10000,
})
.toString(),
currentStakeAvailable: fixedTokenValue
? fixedTokenValue.toString()
: faker.datatype
.number({
min: 1000000000000000000,
max: 10000000000000000000000,
})
.toString(),
},
},
datetime: faker.date.past().toISOString(),

View File

@ -0,0 +1,59 @@
import { NETWORK_PARAMS_QUERY } from '@vegaprotocol/web3';
import type { VegaKeyExtended } from '@vegaprotocol/wallet';
import type { MockedResponse } from '@apollo/client/testing';
import type { NetworkParamsQuery } from '@vegaprotocol/web3';
export const mockPubkey = '0x123';
const mockKeypair = {
pub: mockPubkey,
} as VegaKeyExtended;
export const mockWalletContext = {
keypair: mockKeypair,
keypairs: [mockKeypair],
sendTx: jest.fn().mockReturnValue(Promise.resolve(null)),
connect: jest.fn(),
disconnect: jest.fn(),
selectPublicKey: jest.fn(),
connector: null,
};
const mockEthereumConfig = {
network_id: '3',
chain_id: '3',
confirmations: 3,
collateral_bridge_contract: {
address: 'bridge address',
},
};
export const networkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
request: {
query: NETWORK_PARAMS_QUERY,
},
result: {
data: {
networkParameters: [
{
__typename: 'NetworkParameter',
key: 'blockchains.ethereumConfig',
value: JSON.stringify(mockEthereumConfig),
},
],
},
},
};
const oneMinute = 1000 * 60;
const oneHour = oneMinute * 60;
const oneDay = oneHour * 24;
const oneWeek = oneDay * 7;
const oneMonth = oneWeek * 4;
export const fiveMinutes = new Date(oneMinute * 5);
export const fiveHours = new Date(oneHour * 5);
export const fiveDays = new Date(oneDay * 5);
export const lastWeek = new Date(-oneWeek);
export const nextWeek = new Date(oneWeek);
export const lastMonth = new Date(-oneMonth);
export const nextMonth = new Date(oneMonth);

View File

@ -58,6 +58,7 @@ export const useOrderEdit = (order: OrderFields | null) => {
timeInForce: VegaWalletOrderTimeInForce[order.timeInForce],
// @ts-ignore fix me please!
sizeDelta: 0,
// @ts-ignore fix me please!
expiresAt: order.expiresAt
? {
value: toNanoSeconds(new Date(order.expiresAt)), // Wallet expects timestamp in nanoseconds

View File

@ -8,7 +8,8 @@ export interface SplashProps {
export const Splash = ({ children }: SplashProps) => {
const splashClasses = classNames(
'w-full h-full',
'flex items-center justify-center'
'flex items-center justify-center',
'text-white'
);
return <div className={splashClasses}>{children}</div>;
};