From aa0be2b3e8558fa0ff5b6300f9847e53b643fde8 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Tue, 26 Jul 2022 21:10:49 +0100 Subject: [PATCH] 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 --- README.md | 3 +- .../src/integration/view/governance.cy.js | 12 +- .../src/components/eth-wallet/eth-wallet.tsx | 6 +- apps/token/src/i18n/translations/dev.json | 73 +++- .../src/lib/type-policies/proposal.spec.tsx | 101 ----- apps/token/src/lib/type-policies/proposal.ts | 19 - .../__generated__/ProposalFields.ts | 28 ++ .../current-proposal-status.tsx | 4 +- .../proposal-header.spec.tsx | 277 +++++++++++++ .../proposal-header.tsx | 109 +++++ .../components/proposal/proposal.tsx | 5 +- .../proposals-list-filter/index.tsx | 1 + .../proposals-list-filter.tsx | 46 +++ .../components/proposals-list-item/index.tsx | 2 + .../proposals-list-item-details.spec.tsx | 377 ++++++++++++++++++ .../proposals-list-item-details.tsx | 207 ++++++++++ .../proposals-list-item.tsx | 22 + .../proposals-list/proposals-list.spec.tsx | 219 ++++++++++ .../proposals-list/proposals-list.tsx | 135 ++++--- .../governance/hooks/use-vote-information.ts | 8 +- .../routes/governance/proposal-fragment.ts | 9 + .../proposal/__generated__/Proposal.ts | 28 ++ .../proposals/__generated__/Proposals.ts | 28 ++ .../proposals/proposals-container.tsx | 8 +- .../test-helpers/generate-proposals.ts | 57 ++- .../routes/governance/test-helpers/mocks.ts | 59 +++ .../src/lib/order-hooks/use-order-edit.tsx | 1 + .../src/components/splash/splash.tsx | 3 +- 28 files changed, 1608 insertions(+), 239 deletions(-) delete mode 100644 apps/token/src/lib/type-policies/proposal.spec.tsx delete mode 100644 apps/token/src/lib/type-policies/proposal.ts create mode 100644 apps/token/src/routes/governance/components/proposal-detail-header/proposal-header.spec.tsx create mode 100644 apps/token/src/routes/governance/components/proposal-detail-header/proposal-header.tsx create mode 100644 apps/token/src/routes/governance/components/proposals-list-filter/index.tsx create mode 100644 apps/token/src/routes/governance/components/proposals-list-filter/proposals-list-filter.tsx create mode 100644 apps/token/src/routes/governance/components/proposals-list-item/index.tsx create mode 100644 apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.spec.tsx create mode 100644 apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.tsx create mode 100644 apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item.tsx create mode 100644 apps/token/src/routes/governance/components/proposals-list/proposals-list.spec.tsx create mode 100644 apps/token/src/routes/governance/test-helpers/mocks.ts diff --git a/README.md b/README.md index db0c18375..45f24a40d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/token-e2e/src/integration/view/governance.cy.js b/apps/token-e2e/src/integration/view/governance.cy.js index 2f5dff6e0..33f3b94ab 100644 --- a/apps/token-e2e/src/integration/view/governance.cy.js +++ b/apps/token-e2e/src/integration/view/governance.cy.js @@ -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'); }); }); }); diff --git a/apps/token/src/components/eth-wallet/eth-wallet.tsx b/apps/token/src/components/eth-wallet/eth-wallet.tsx index 5ac04bd47..0fba6fe69 100644 --- a/apps/token/src/components/eth-wallet/eth-wallet.tsx +++ b/apps/token/src/components/eth-wallet/eth-wallet.tsx @@ -202,9 +202,9 @@ export const EthWallet = () => {
-

{t('ethereumKey')}

+

{t('ethereumKey')}

{account && ( -
+
{ ) : ( + )} + {filterVisible && ( +
+

{t('FilterProposalsDescription')}

+ + setFilterString(e.target.value)} + /> + +
+ )} +
+ ); +}; diff --git a/apps/token/src/routes/governance/components/proposals-list-item/index.tsx b/apps/token/src/routes/governance/components/proposals-list-item/index.tsx new file mode 100644 index 000000000..4279509ad --- /dev/null +++ b/apps/token/src/routes/governance/components/proposals-list-item/index.tsx @@ -0,0 +1,2 @@ +export { ProposalsListItem } from './proposals-list-item'; +export { ProposalsListItemDetails } from './proposals-list-item-details'; diff --git a/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.spec.tsx b/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.spec.tsx new file mode 100644 index 000000000..19df0d1ad --- /dev/null +++ b/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.spec.tsx @@ -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 +) => ( + + + + + + + + + +); + +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' + ); + }); +}); diff --git a/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.tsx b/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.tsx new file mode 100644 index 000000000..0a607e3b3 --- /dev/null +++ b/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item-details.tsx @@ -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')} {t('not reached')} + + ); +}; +const ParticipationNotReached = () => { + const { t } = useTranslation(); + return ( + <> + {t('Participation')} {t('not reached')} + + ); +}; + +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')} + + ); + voteDetails = ( + <> + {format( + new Date(proposal.terms.enactmentDatetime), + DATE_FORMAT_DETAILED + )} + + ); + break; + } + case ProposalState.Passed: { + proposalStatus = ( + <> + {t('voteState_Passed')} + + ); + 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')} + + ); + 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')} + + ); + 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')} + + ); + voteStatus = + (!participationMet && ) || + (!majorityMet && ) || + (willPass && ( + <> + {t('Set to')} {t('pass')} + + )) || + (!willPass && ( + <> + {t('Set to')} {t('fail')} + + )); + break; + } + case ProposalState.Declined: { + proposalStatus = ( + <> + {t('voteState_Declined')} + + ); + voteStatus = + (!participationMet && ) || + (!majorityMet && ); + break; + } + case ProposalState.Rejected: { + proposalStatus = ( + <> + {t('voteState_Rejected')}{' '} + + + ); + voteStatus = proposal.rejectionReason && ( + <>{t(proposal.rejectionReason)} + ); + break; + } + } + + return ( +
+
+ {proposalStatus} +
+ {voteDetails && ( +
+ {voteDetails} +
+ )} + {voteStatus && ( +
+ testing testing 123 + {voteStatus} +
+ )} + {proposal.id && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item.tsx b/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item.tsx new file mode 100644 index 000000000..bb1f1330f --- /dev/null +++ b/apps/token/src/routes/governance/components/proposals-list-item/proposals-list-item.tsx @@ -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 ( +
  • + + +
  • + ); +}; diff --git a/apps/token/src/routes/governance/components/proposals-list/proposals-list.spec.tsx b/apps/token/src/routes/governance/components/proposals-list/proposals-list.spec.tsx new file mode 100644 index 000000000..a8834913a --- /dev/null +++ b/apps/token/src/routes/governance/components/proposals-list/proposals-list.spec.tsx @@ -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[]) => ( + + + + + + + + + +); + +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(); + }); +}); diff --git a/apps/token/src/routes/governance/components/proposals-list/proposals-list.tsx b/apps/token/src/routes/governance/components/proposals-list/proposals-list.tsx index 4895bac89..2e48914a8 100644 --- a/apps/token/src/routes/governance/components/proposals-list/proposals-list.tsx +++ b/apps/token/src/routes/governance/components/proposals-list/proposals-list.tsx @@ -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

    {t('noProposals')}

    ; - } + 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 ( <> -

    {t('proposedChangesToVegaNetwork')}

    -

    {t('vegaTokenHoldersCanVote')}

    -

    {t('requiredMajorityDescription')}

    -

    {t('proposals')}

    -
      - {proposals.map((proposal) => ( - - ))} -
    + + {failedProposalsCulled.length > 0 && ( + + )} + +
    +

    {t('openProposals')}

    + {sortedProposals.open.length > 0 ? ( +
      + {sortedProposals.open.filter(filterPredicate).map((proposal) => ( + + ))} +
    + ) : ( +

    + {t('noOpenProposals')} +

    + )} +
    + +
    +

    {t('closedProposals')}

    + {sortedProposals.closed.length > 0 ? ( +
      + {sortedProposals.closed.filter(filterPredicate).map((proposal) => ( + + ))} +
    + ) : ( +

    + {t('noClosedProposals')} +

    + )} +
    ); }; - -interface ProposalListItemProps { - proposal: Proposals_proposals; -} - -const ProposalListItem = ({ proposal }: ProposalListItemProps) => { - const { t } = useTranslation(); - if (!proposal || !proposal.id) return null; - - return ( -
  • - -
    {getProposalName(proposal)}
    - - - - {t('state')} - - - - - - {isFuture(new Date(proposal.terms.closingDatetime)) - ? t('closesOn') - : t('closedOn')} - - - {format( - new Date(proposal.terms.closingDatetime), - DATE_FORMAT_DETAILED - )} - - - - {isFuture(new Date(proposal.terms.enactmentDatetime)) - ? t('proposedEnactment') - : t('enactedOn')} - - - {format( - new Date(proposal.terms.enactmentDatetime), - DATE_FORMAT_DETAILED - )} - - - -
  • - ); -}; diff --git a/apps/token/src/routes/governance/hooks/use-vote-information.ts b/apps/token/src/routes/governance/hooks/use-vote-information.ts index 77bb98643..208f52425 100644 --- a/apps/token/src/routes/governance/hooks/use-vote-information.ts +++ b/apps/token/src/routes/governance/hooks/use-vote-information.ts @@ -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] ); diff --git a/apps/token/src/routes/governance/proposal-fragment.ts b/apps/token/src/routes/governance/proposal-fragment.ts index be5c3a3cb..05ed9b0e5 100644 --- a/apps/token/src/routes/governance/proposal-fragment.ts +++ b/apps/token/src/routes/governance/proposal-fragment.ts @@ -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 } } diff --git a/apps/token/src/routes/governance/proposal/__generated__/Proposal.ts b/apps/token/src/routes/governance/proposal/__generated__/Proposal.ts index 691b98602..3baa46224 100644 --- a/apps/token/src/routes/governance/proposal/__generated__/Proposal.ts +++ b/apps/token/src/routes/governance/proposal/__generated__/Proposal.ts @@ -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) */ diff --git a/apps/token/src/routes/governance/proposals/__generated__/Proposals.ts b/apps/token/src/routes/governance/proposals/__generated__/Proposals.ts index 9cd318827..951146baa 100644 --- a/apps/token/src/routes/governance/proposals/__generated__/Proposals.ts +++ b/apps/token/src/routes/governance/proposals/__generated__/Proposals.ts @@ -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) */ diff --git a/apps/token/src/routes/governance/proposals/proposals-container.tsx b/apps/token/src/routes/governance/proposals/proposals-container.tsx index 5a25a8fe8..fba6c3243 100644 --- a/apps/token/src/routes/governance/proposals/proposals-container.tsx +++ b/apps/token/src/routes/governance/proposals/proposals-container.tsx @@ -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 ( - <> - - - - ); + return ; }; diff --git a/apps/token/src/routes/governance/test-helpers/generate-proposals.ts b/apps/token/src/routes/governance/test-helpers/generate-proposals.ts index d6d4a4f11..83e4550ce 100644 --- a/apps/token/src/routes/governance/test-helpers/generate-proposals.ts +++ b/apps/token/src/routes/governance/test-helpers/generate-proposals.ts @@ -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>( + return mergeWith>( 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(), diff --git a/apps/token/src/routes/governance/test-helpers/mocks.ts b/apps/token/src/routes/governance/test-helpers/mocks.ts new file mode 100644 index 000000000..41506f336 --- /dev/null +++ b/apps/token/src/routes/governance/test-helpers/mocks.ts @@ -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 = { + 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); diff --git a/libs/orders/src/lib/order-hooks/use-order-edit.tsx b/libs/orders/src/lib/order-hooks/use-order-edit.tsx index 2e1a9e3d0..e3853d335 100644 --- a/libs/orders/src/lib/order-hooks/use-order-edit.tsx +++ b/libs/orders/src/lib/order-hooks/use-order-edit.tsx @@ -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 diff --git a/libs/ui-toolkit/src/components/splash/splash.tsx b/libs/ui-toolkit/src/components/splash/splash.tsx index f86751d43..e1c4b3fb3 100644 --- a/libs/ui-toolkit/src/components/splash/splash.tsx +++ b/libs/ui-toolkit/src/components/splash/splash.tsx @@ -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
    {children}
    ; };